Chapter 8
Selecting and Unlocking Levels
对于这个游戏,你将给玩家一个选择从哪一关开始的方法。因为levels都是分开的,你将使用一个Scroll View Node来让玩家选择。
另外,你会希望有一个有弹性的方法去添加更多的levels,追踪哪一level已经被玩家解锁。GameState类在这时很有帮助。
当然,你必须添加额外的level CCB文件。你也会学习如何对当前app中得level文件计数。
你还会完成更多的menus。
Adding the Content Node
从设计Scroll View's的内容node开始。
Content node是一个容器,其内容可以滚动。这样的话,你将设计每个world的level,作为独立的page,稍后,在Scroll View node中enable pagination,这样每个world menu就会在屏幕中居中。
在scrolling view中,Content node的内容尺寸决定了可滑动区域的尺寸。Scroll View的内容尺寸决定了用于可以触摸和拖动的区域。同时也决定了Conten node的边界。
Note:内容会被绘制在Scroll View外面。如果你需要内容仅仅在Scroll View区域,你需要在上面绘制一个sprite。或者你可以在程序中添加Scroll View到CCClippingNode。
内容的分页要求你考虑the Scroll View automatically determines the number of pages based on the ratio of the Scroll View size in relation to the Content node size。
举例来说,如果你需要五个独立的,水平滚动的页面,每个宽为100 points,Scroll View的宽必须是100,总Content node的尺寸必须是500.这假设了页面没有空白。如果在独立的页面中又空白,你还要考虑空白的尺寸。
创建一个新的CCB文件在UserInterface文件夹中,命名为MainMenuLevelSelect.ccb。类型必须设置为Node。选择root node,在Item Properties,设置内容类型为%。因为你需要在Scroll Views上添加3个页面,并且只允许水平滚动,你必须设置content尺寸为300%宽,100%高。这会让Scroll View的Content Node三倍大于Scroll View。
你应该同时命名rool node为levelSelect。在Item Code Connetions,需要设置一个自定义类,命名为MainMenuLevelSelect。
在Tileless Editor View中,拖动W1_bg,W2_bg等背景图片,一共需要拖动三个。这三幅图都是411x290points尺寸的。
现在,这三幅图很可能在相同位置,你需要在水平方向上把空间均匀出来。W1_bg的位置应该是0x0,W2_bg的位置应该是441x0,W3_bg的位置应该是882x0。图片之间都添加了30-point-wide的空白。是否添加空白是可选的。另外,设置每个background sprite的anchor point为0x0。如图:
Adding the Scroll View Node
打开MainScene.ccb,转换到Node Library View,拖动一个Scroll View node到stage。选中Scroll View node,编辑position类型为%,值为50,anchor point为0.5x0.5.
现在,重要的事情是:Scroll View的内容尺寸。每个单独的background图片(page)是411points宽。你添加了30points的空白,这意味着一个单独page是411 + 30 points宽,输入441x290作为Scroll View的内容尺寸。移动到CCScrollView属性。
第一步应该是设置Content node值。设置为UserInterface/MainMenuLevelSelect.ccb。保存后应该能直接看到效果,如图:
Scroll View node应该仅仅能水平移动;因此,把Vertical scroll的勾去掉。Bounces设置决定了边缘的表现。比如说,如果你在第一幅page中向左移动,或者在最后一幅page中向右移动,并且你勾选了Bounces,你可以向这个方向稍微拖动一下node,但是它会反弹回来。如果没有勾选Bounces,那么效果仅仅是无法拖动node。Bouncing让用户能知道滚动内容的边界。最后,Paging enabled将每一页滚动和snap到位。没有paging的话,you can scroll through the Content node without any kind of snapping.
在Item Code Connection,你应该分配一个Doc root var。输入_levelSelectScrollView。
因为空白的原因,效果如上,这种情况一般不是一个问题,因为它让你看到右侧有更多东西,鼓励用户去滑动。同样当你到最后一页时,空间会更清晰。但是,有时候,让Scroll View页在stage上居中更重要。为了抵消这30point空白的效果,你必须再添加15points给Scroll View的X position。为了这样做,改变Scroll View的Xposition类型为points,输入299(284+15),再把position type改为%。
Showing the Scroll View Popover
在你尝试Scroll View之前,你必须创建MainMenuLevelSelect类和_levelSelectScrollView变量。
打开MainScene.m,添加代码:
#import "MainScene.h" @implementation MainScene { __weak CCScrollView *_levelSelectScrollView; } - (void)didLoadFromCCB { NSLog(@"scroll View:%@", _levelSelectScrollView); } - (void)ChangeToGameScene { CCScene *scene = [CCBReader loadAsScene:@"GameScene"]; CCTransition *transition = [CCTransition transitionFadeWithDuration:1.5]; [[CCDirector sharedDirector] presentScene:scene withTransition:transition]; } @end
下一步,创建一个新的类,命名为MainMenuLevelSelect,继承自CCNode。在.h文件中,添加代码:
#import "CCNode.h" @class MainMenuButtons; @interface MainMenuLevelSelect : CCNode @property (weak) MainMenuButtons *mainMenuButtons; @end
在MainMenuButtons.m中,添加代码:
#import "MainMenuButtons.h" #import "SettingsLayer.h" #import "MainMenuLevelSelect.h" @implementation MainMenuButtons { __weak MainMenuLevelSelect *_levelSelect; } - (void)didLoadFromCCB { _levelSelect = (MainMenuLevelSelect*)[self.parent getChildByName:@"levelSelect" recursively:YES]; _levelSelect.parent.visible = NO; _levelSelect.mainMenuButtons = self; }
首先输出了一个新的MainMenuLevelSelect类头文件,以便去添加类的变量_levelSelect.
在didLoadFromCCB中,你通过MainMenuLevelSelect实例的名字获取引用。你以前经常这样做,但是慢着,MainMenuButtons类和Scroll View或者Scroll View的Content node没有关联,意味着他们不是MainMenuButtons类的children。如果你仔细看getChildByName:方法,你会注意到它实际上发送给MainMenuButtons的parent node。因为MainMenuButtons是MainScene的child,self.parent是MainScene实例的引用。MainScene包含作为child的Scroll View,并且Scroll View包含作为child的Content node。因而,MainMenuLevelSelect实例可以通过名字被递归找到。
隐藏CCScrollView实例而不是MainMenuLevelSelect实例是很重要的,因为使所有CCScrollView的触摸事件失效。如果你仅仅让_levelSelect invisible,它仍然有相同的视觉效果,但是用户不能操作了。
最后,_levelSelect被分配给一个指向MainMenuButtons实例的引用,这样它就可以之后发送show message给MainMenuButtons实例,当能过户关闭MainMenuLevelSelect popover时。
剩下的工作是更新shouldPalyGame方法,代码如下:
- (void)shouldPlayGame { _levelSelect.parent.visible = YES; self.visible = NO; NSLog(@"Play"); }
这让CCScrollView visible并且隐藏MainMenuButtons实例。
注意,不像SettingsLayer,CCScrollView实例并不从scen中移除,当应该显示的时候才被重载;你仅仅是简单地改变了它的visible状态。相比于load一个CCB或者在程序中创建一个新的node实例,改变visible状态更有效。
虽然改变visible状态也需要耗费内存,但是,如果你频繁需要一个node,你必须有足够的类存让它在任何时间显示出来。
也就是说,让CCScrollView和它的Content node一直在scene中是一个实用的做法。你需要创建一个额外的CCB文件,它包含了Scroll View node,以便可以用CCBReader载入Scroll View和它的Content node。现在,当Scroll View node 被直接添加到MainScene.ccb中时,只有MainMenuLevelSelect.ccb(Scroll View的Content node)是一个单独的CCB实例。所以仅需要用CCBReader载入MainMenuLevelSelect.ccb。
如果你想从它的parent node中移除CCScrollView,你必须在程序中创建另一个CCScrollView的实例,用载入的Content node初始化它,或者把它分配Scroll View的contentNode属性----但是仅仅在第二次以后,further complicating the code。
Tip:永远记住:Keep it simple stupid,或者KISS原则。
运行APP,你现在可以滑动3个背景图片,注意到你现在还可以敲击下方的buttons。
Designing the Scroll View Content Node
你现在无法做的事情是真正的敲击一个level并且玩,你也不能关闭Scroll View的popover。为了做到这些,设计level-selection pages,这样每个page都代表一个world。
从添加每个page上得close button开始。打开MainMenuLevelSelect.ccb。拖动CloseButton.ccb到每个W#_bg sprite上。最好是直接拖动每个button到W#_bg sprite中,这样它就变成了page background image的child了。
选择每个CloseButton,改变它的Position类型为%,值为100.
现在,添加loge和title。在Tileless Editor View中,拖动W1_logo到W1_bg sprite上,拖动W1_title到W1_logo sprite上。W1_title应该是W1_logo的child,W1_logo应该是W1_bg的child。对W2和W3重复这一过程。如图:
选择每个logo和titleimage,改变它们的位置。logo position类型应该是%,值是50 x 85,title position
类型应该是percent,值是50x0。对于3个level buttons,你最好使用Box Layout node来水平对齐它们。拖动一个Box Layout到每个W#_bg sprite上,改变Box Layout node的位置类型为%,值为50x35.同时改变anchor point为0.5x0.5,这样child nodes会在Box layout node的位置处居中,Spacing 应该是30。
你应该拖动W#_l1,W#_l2和W#_l3图片,按顺序添加到CCLayout Box node中,这样,每个Box Layout node都有3个sprites children。如图:暂时忽略CCButton nodes。
改变所有W#_l# sprite(除了W1_l1)为light gray---color code为999999。因为第一关当然是永远解锁过的。
然后,添加一个button座位每个W#_l# sprite的child,一共9个buttons。但是你现在仅仅应该添加一个button,编辑属性,并且copy和paste它8次。
button的位置和anchor point 应该都是0x0。改变preferred size type为%,值为100x100.清楚Title field,改变Sprite frame属性为Normal和Highlighted State为NULL。
在Button的Item Code Connections中,输入shouldLoadLevel:座位selector。注意后面的:,因为你将作为一个参数接收button。
现在你可以copy和paste button8次。然后拖动到W#_l#sprite中。
最后,你需要一个方法去给 buttons确定身份,这样当button被敲击的时候,你就知道你应该loading哪一Level了。
一个方法是对第一个button输入名字为1,第二个为2,最后一个是9.确保输入的都是数字。
最后的视觉效果如下:
Unlocking Levels
在你对level-selection buttons编程之前,你必须更新GameState类,以记录哪些levels已经解锁,和最近玩的level。
打开GameStat.h,添加如下代码:
#import <Foundation/Foundation.h> @interface GameState : NSObject + (GameState*)sharedGameState; @property CGFloat musicVolume; @property CGFloat effectsVolume; @property int currentLevel; @property (readonly)int highstUnlockedLevel; - (BOOL)unlockNextLevel; @end
currentLevel是正在被玩的level。在unlockNextLevel方法中将要用到它。highestUnlockedLevel表示玩家可以玩的level的最高编号。自由unlockNextLevel方法可以改变它。在GameState.m中添加如下代码:
static NSString *keyForUnlockedLevel = @"unlockedLevel"; - (void)setHighstUnlockedLevel:(int)level { int totalLevelCount = 9; if(_currentLevel > 0 && _currentLevel <= totalLevelCount) { [[NSUserDefaults standardUserDefaults]setInteger:level forKey:keyForUnlockedLevel]; } } - (int)highstUnlockedLevel { NSNumber *number = [[NSUserDefaults standardUserDefaults] objectForKey:keyForUnlockedLevel]; return (number? [number intValue] : 1); }
setter方法定义了level的最大值---一共9levels。currentLevel属性在存储到NSUserDefaults前先被检测是否在1到9之间。getter方法获取存储过的NSNumber,并且返回它的intValue或者1(如果KeyForUnlockedLevel还咩有key的话)。返回1是因为first level应该永远是解锁过的。
未解锁的levels在unlockNextLevel方法中被完成了,添加方法:
- (BOOL)unlockNextLevel { int highest = self.highstUnlockedLevel; if(_currentLevel >= highest ) { [self setHighstUnlockedLevel:_currentLevel + 1]; } return (highest < self.highstUnlockedLevel); }
当前的highestUnlockedLevel是通过self.highestUnlockedLevel,从属性setter中得到的,然后分配个highest变量。如果currentLevel大于等于highest number。setHighestUnlockedLevel:setter被调用,这样就可以解锁当前level的下一level。最后,该方法返回一个BOOL值,表明下一level是否被解锁,(通过比较前highest数和当前highestUnlockedLevel)
Highlighting Level Buttons
level buttons需要被解锁----也就是说,它们的颜色被设置到白色----如果它们的level目前可以被访问。“可访问”意味着按钮的level 数字小于等于highestUnlockedLevel。你同时想要启动GameScene,并且载入button的对应level文件。
在MainMenuLevelSelect.m中添加如下代码。在didLoadFromCCB中的代码会列举所有的buttons,和当前的highestUnlockedLevel相比较。如果button的level应该被解锁,button的parent sprite 就设置颜色为白色,移除昏暗效果。代码如下:
#import "MainMenuLevelSelect.h" #import "MainMenuButtons.h" #import "SceneManager.h" #import "GameState.h" @implementation MainMenuLevelSelect - (void)didLoadFromCCB { int count = 1; int highest = [GameState sharedGameState].highstUnlockedLevel; CCNode *button; while((button = [self getChildByName:@(count).stringValue recursively:YES])) { if (button.name.intValue <= highest) { CCSprite *sprite = (CCSprite*)button.parent; sprite.color = [CCColor whiteColor]; } count++; } } @end
注意@(count).stringValue,在OC中,你可以用这种语法初始化arrays,dictionaries,numbers。
比如:
NSArray * array = @[obj1,obj2,obj3];
NSDictionary *dict = @{key1:obj1,key2:obj2,key3:obj3};
NSNumber *number = @(1234);
NSNumber *number = @(YES);
NSNumber *number = @(count);
这种语法比使用常规方法要更简洁更短。
Note:为什么不使用NSNumber呢?很简单:NSNumber是一个不变类。你必须创建一个新的NSNumber object,如果值必须改变的话。同样也没有NSMutableNumber类。
Closing the Level-Selection Popover
level-selection popover可以用CloseButton.ccb实例关闭。
在MainMenuLevelSelect.m中添加如下代码:
- (void)shouldClose { self.parent.visible = NO; [_mainMenuButtons show]; }
Content node的parent实例是Scroll View。所以让parent的visible状态为NO,然后让MainMenuButtons自身显示。
Loading Levels
剩下为每一关注册button presses,并且测试level有没有unlocked。如果有,就load level;否则,你可以添加一个拒绝的sound effect。
在MainMenuLevelSelect.m中添加代码:
// loading a level or not - (void)shouldLoadLevel:(CCButton*)sender { GameState *gameState = [GameState sharedGameState]; int levelNumber = sender.name.intValue; if (levelNumber <= gameState.highstUnlockedLevel) { gameState.currentLevel = levelNumber; [SceneManager presentGameScene]; }else { //maybe play a 'access denied' sound effect here } } @end
像之前一样,button的name属性通过intValue被转化为int。现在打开ScenenManeger.m,首先import GameScene和GameState 头文件。如下:
#import "SceneManager.h" #import "GameScene.h" #import "GameState.h"
找到presentGameScene方法,用下面内容替换:
+ (void)presentGameScene { //id s = [CCBReader loadAsScene:@"GameScene"]; //id t = [CCTransition transitionMoveInWithDirection:CCTransitionDirectionRight duration:1.0]; //[[CCDirector sharedDirector] presentScene:s withTransition:t]; CCScene *scene = [CCBReader loadAsScene:@"GameScene"]; GameScene *gameScene = (GameScene*)scene.children.firstObject; int levelNumber = [GameState sharedGameState].currentLevel; NSString *level = [NSString stringWithFormat:@"Levels/Level%i",levelNumber]; [GameScene loadLevelNamed:level]; id t = [CCTransition transitionPushWithDirection:CCTransitionDirectionLeft duration:1.0]; [[CCDirector sharedDirector]presentScene:scene withTransition:t]; }
GameScene像往常一样,使用CCBReader的loadAsScene方法载入。loadAsScene方法返回一个通用CCScene对象,它的唯一child永远是载入的CCB的root node。当前level number转变为一个string。
Tip:开发者们仍然频繁的用额外的0填充文件名,以对抗:数字排序。比如说,Level0001,因为你也许有上千个level。请不要这么做!现代OS知道numeric sorting问题。string-formatting代码段:Level%i适用所有数字。
使用GameScene实例的引用,你可以发送LoadLevelNamed:方法,传递生成的level string。你应该在显示scene之前做完这些,这样scene在渲染之前就全部准备好了。loadLevelNamed:方法也是你之前用过的,除了你还没有从其他类中使用它。为了避免编译器抱怨没有找到selector,你必须打开GameScene.h,添加如下:
- (void)loadLevelNamed:(NSString*)levelCCB;
在GameScene.m中,你需要移除[self loadLevelNamed:nil];
尽管它现在可以成功的载入第一level,但这是错误的。毕竟,第一level已经通过GameScene.ccb引用了,至今,你还没有载入特殊的level。你现在应该改变这一点,通过更新loadLevelNamed:方法。如下:
- (void)loadLevelName:(NSString*)levelCCB { [_levelNode removeFromParent]; CCNode * level = [CCBReader load:levelCCB]; [self addChild:level]; _levelNode = level;
_levelNode是一个Sub File node的引用,现在时Level1.ccb。载入一个level首先应该移除当前存在的level。然后CCBReader载入由MainMenuLevelSelect.m中得shouldLoadLevelNamed:方法生成的levelCCB string。然后level作为一个child加载如GameScene。
最后,_levelNode引用被新的level替换。注意因为_levelNode是一个__weak引用,所以你不能直接把CCBReader load:方法返回的node分配给它。在OC运行时,它会被置为nil。
如果你现在运行,按下Play,然后敲击first-level button,你应该能进入GameScene。但是现在没有pause button了。
至今,GameScene必须依靠SpriteBuilder中得nodes顺序决定绘制的顺序。level content最先被绘制,然后是GameMenuLayer在level上面一层被绘制。
但是,现在你移除了_levelNode.并且载入了一个新的,并且作为child node加入了GameScene。添加一个新的node永远会被放在list的末尾;因此,绘制顺序现在就反了,你就不再能够看到pause button。
为了修复这一点,用下面的代码取代addChild这一行:
level.zOrder = -1; [self addChild:level];
zOrder属性决定了node得绘制顺序。有着更低的zOrder的nodes在有着更高的zOrder的nodes之前绘制,但是它们必须有同一个parent。注意这两行代码也可以写成:[self addChild:level z:-1];
现在,载入levels可以工作!但是还有要考虑的,现在GameScene在GameScene.ccb中引用了Level1.ccb。所以当你载入GameScene,它会载入Level1.ccb,然后用新的level替换。这有些不效率。
Adding a Dummy Level
为了修复这一点,在SpriteBuilder中,右键点击Levels文件夹,选择New File,重命名为DummyLevel.ccb,设置类型为Node。这就可以了,你现在仅仅需要一个空的CCB。
现在打开GameScene.ccb,选择level Content node。在Item Properties,点击CCB File下拉菜单。选择Levels/DummyLevel.ccb。
Winning Levels
如果你通关并且到达了exit会发生什么呢?现在是时候解决这个问题并且解锁下一level了。
在SpriteBuilder中,右键点击UserInterface/Popovers文件夹,添加一个New File,命名为LevelCompleteLayer.ccb,设置类型为layer。改变root node 的content size 类型为%,值为100x100.转到Item Code Connections,输入GameMenuLayer作为类名。你需要一个background image,label和button。
从Tileless Editor View中,拖动以_bg结尾的图片到stage上。必须,我使用W2_bg。改变background sprite的位置类型为%,值为50x50.
然后,转到Node Library View,拖动一个LabelTTF Node
到background sprite,这样label成为sprite的child。改变label的位置类型为%,值为50x66。设置label的title为“Level Complete”.
最后,添加一个Button,拖动到background sprite上,作为一个child。改变button的位置类型为%,值为50x30.
首选尺寸为170x50。button的Title应该是Next Level。改变Normal State和Highlighted State的SpriteFrame,为P_resume.png,改变Highlighted State的Background和Label color为99999.
别忘记设置selector,输入shouldLoadNextLevel。视觉效果如下图:
Tip:如果你想要让这个popover更加功能齐全,考虑添加retry和exit button。毕竟,popover和其他的popovers使用相同的GameMenuLayer类,所以只要把button的selectors连接到shouldRestartGame和shouldExitGame即可。
回到Xcode,打开GameScene.m,import GameState头文件:
#import "GameScene.h" #import "Trigger.h" #import "GameMenuLayer.h" #import "GameState.h"
然后,定位到参数是player何exit的physics collision方法,替换为下面的代码:
- (BOOL)ccPhysicsCollisionBegin:(CCPhysicsCollisionPair *)pair player:(CCNode *)player exit:(CCNode *)exit { [[GameState sharedGameState]unlockNextLevel]; [[GameState sharedGameState] synchronize]; [self showPopoverNamed:@"UserInterface/Popovers/LevelCompleteLayer"]; return NO; }
这解锁了下一个level。你可以在任何一个level完成的时候调用这个方法。甚至当你重新玩level1,它也不会重新设置highest unlocked level为level2.GameState是同步的。
注意到synchronize方法可能是未定义的,如果没有定义,你可以添加在GameState.h和GameState.m,或者用下面的代码替换synchronize:
[[NSUserDefaults standardUserDefaults] synchronize];。另外,当然了,LevelCompleteLayer被显示为一个popup。那么,在GameMenuLayer.m中,添加一个import“GameState.h”如下:
#import "GameMenuLayer.h" #import "GameScene.h" #import "SceneManager.h" #import "GameState.h"
然后,添加如下的selector:
- (void)shouldLoadNextLevel { [GameState sharedGameState].currentLevel += 1; [SceneManager presentGameScene]; }
这段代码很直接。因为player即将进入下一level,所以当前level增加1。presentGameScene方法则呈现一个新的GameScene实例,并且载入当前level。
Adding More Levels
有一个简单的方法添加更多的levels。打开Finder,找到Packages/SpriteBuilder Resources.sbpack文件夹。你可以看Level1.ccb和DummyLevel.ccb文件。
你可以复制Level1.ccb。
Counting Level Files
你将必须计算Level#.ccb文件的数量。在GameState.m中添加代码是完全可选的。记住,however,that if you do add it,unlocking more levels will work only if you have consecutively named Level#.ccb files in SpriteBuilder.
添加代码:
- (int)levelCount { NSBundle *mainBundle = [NSBundle mainBundle]; NSString *path; int count = 0; do { count++; NSString *level = [NSString stringWithFormat:@"Level%i",count]; path = [mainBundle pathForResource:level ofType:@"ccbi" inDirectory:@"Published-iOS/Levels"]; }while(path != nil); count --; return count; }
为了使用levelCount方法,编辑setHighestUnlockedLevel:方法,替换第一行代码,如下:
- (void)setHighstUnlockedLevel:(int)level { int totalLevelCount = [self levelCount]; //int totalLevelCount = 9; if(_currentLevel > 0 && _currentLevel <= totalLevelCount) { [[NSUserDefaults standardUserDefaults]setInteger:level forKey:keyForUnlockedLevel]; } }
Showing the Correct Level-Selection Page
最后,但是也同样重要的,让我们返回到ScrollView。当你完成