上一篇文章《評鑒Maze源碼(1):GamePlayKit的ECS“實體-組件-系統”》里,我已經介紹了在Maze游戲中的ECS方法,這個方法里面,關于Enemy實體的行為,需要狀態機來配合管理,這一篇文章,我就跟大家介紹一下GameplayKit里面狀態機的使用。
一,狀態機的介紹
狀態機:能夠準確的表達同一實體,不同階段的狀態和狀態遷移條件。
1,狀態,可能是實體對象的屬性,也可能是屬性集合。
2,遷移條件,指的是外界的突發事件及滿足特殊條件的屬性變化。
游戲里面的實體會存在很多狀態,比如蘋果的SceneKit,女探險家運動的幾個狀態,在游戲過程中女探險家在這幾個狀態里面遷移。狀態機示意圖狀態機示意圖:
其分為,Running狀態、Jumping狀態和Falling狀態。狀態遷移條件表明在帶箭頭的直線上面。狀態機模式給我們編寫程序帶來明顯的好處,通過條件判斷,方便的管理對象實體的狀態。
關于狀態機的實現,有很多方式,其中比較樸素的是if-else的判斷,如果狀態多,根據需求,狀態遷移也會有不斷的變化,那么if-else的編程會帶來很多代碼維護的問題。《Head first 設計模式》(Head first是我覺得很輕松愉快的一個系列讀物,推薦想要進入一個新的技術,卻苦于無法迅速入門的同學。但是入門后,仍然需要毅力和付出來完全掌握這項技術,對任何事情都是如此。)為我們提供了很好的狀態機編程模式的教學,教會我們簡單,可擴展性的狀態機設計模式實現。
但是,在iOS里面,我們再也不用擔心狀態機的代碼編寫問題了,因為蘋果實現狀態機模式,我們掌握如何使用就行。而且不僅僅是游戲開發,在別的APP應用中,也能很從容的使用GameplayKit所提供的狀態機框架。
二,GameplayKit里面的狀態機API
樸素的狀態機實現方法,這里提一下,就是為了對比狀態機模式的實現方法。
比如小明的狀態,定義為下面這三種,吃飯,睡覺和工作。小明作為一個對象,里面有currentState這一屬性,代表當前小明的狀態。暫且將currentState定為int型,吃飯、睡覺和工作類型值分別是1,2,3。暫且將小明的狀態機變化簡化為“吃飯->睡覺->工作->吃飯”這一循環。實現代碼,小明對象提供一個changeState的方法,其參數是下一個要變化的狀態。changeState的實現:
- (Bool)changeState:(XMState*)state {
switch(state):
{
case eat:
// 判斷當前狀態work到下一步遷移狀態eat的有效性
if (self.currentState == work) {
// 進行狀態遷移
self.currentState = state;
return YES;
}
return NO;
case sleep:
if (self.currentState == eat) {
self.currentState = state;
return YES;
}
return NO;
case work:
if (self.currentState == sleep) {
self.currentState = state;
return YES;
}
return NO;
}
return NO;
}
這里做的兩個工作,一個是判斷狀態遷移的有效性(判斷當前狀態),另一個是進行狀態的遷移(設置currentStatus狀態)。如果,加入新的狀態,或者狀態循環發生變化,狀態機的switch-case和if-else的判斷將會不斷增加,可維護性變差,代碼冗余將不斷上升。
然而,通過狀態機模式,可以使得代碼變得可維護和,GameplayKit提供了這一模式的實現,我們現在來好好掌握它。
1,狀態對象GKState
在GameplayKit里面,蘋果有狀態對象GKState,來作為所有狀態的基類祖先。
對象GKState提供了一些方法,這些方法有兩類作用:
(1)狀態對象本身屬性和管理狀態遷移的有效性。如:
// 驗證下一個狀態是否有效,如果無效的話,是不會發生狀態遷移的
- (BOOL)isValidNextState:(Class)stateClass {
return stateClass == [WJSSleepState class];
}
(2)為狀態的更新和遷移提供了填寫邏輯代碼的位置。在實體的狀態進行更新或者遷移的時候,需要開發者填入相應的邏輯來完成實體狀態的變化。
(3)按照上面小明同學的“吃飯,睡覺和工作”三個狀態,定義這三個狀態。
@interface WJSWorkState : WJSState
@end
@interface WJSEatState : WJSState
@end
@interface WJSSleepState : WJSState
@end
如何驅動實體進行狀態的更新和遷移呢?即樸素編程里面的changeState方法。GameplayKit提供了狀態機對象GKStateMachine來對狀態GKState進行管理。
2,驅動狀態變化的狀態機對象GKStateMachine
GameplayKit提供管理狀態遷移的狀態機對象GKStateMachine,實現狀態對象的管理、更新和遷移。首先,在初始化的階段,將在上面步驟中實體的所有狀態,都加入到狀態機對象GKStateMachine進行管理。
// 1,初始化各個狀態
WJSWorkState *workState = [WJSWorkState new];
WJSEatState *eatState = [WJSEatState new];
WJSSleepState *sleepState = [WJSSleepState new];
// 2,初始化狀態機,并將各個狀態,加入其當前管理的狀態機對象
_stateMachine = [GKStateMachine stateMachineWithStates:@[workState,eatState, sleepState]];
// 3,進入work狀態
[_stateMachine enterState:[workState class]];
其次,狀態機對象負責狀態的更新和狀態的遷移,這里涉及兩層意思:
(1)狀態的更新:指的是當前狀態的更新。在整個程序系統運行的時候,當前狀態也許需要不斷的更新、計算和執行規定操作。調用狀態機的updateWithDelta:方法,狀態機會調用當前狀態的updateWithDelta:方法,開發者在GKState里面覆寫該方法,填入相應的更新邏輯,就可以對當前狀態進行更新。
// 狀態機更新當前狀態的更新函數
[_stateMachine updateWithDeltaTime:1];
(2)狀態的遷移:從當前狀態遷移到下一個狀態。GKState里面提供的回調,提供給開發者作為狀態遷移邏輯代碼的處理。
// 狀態機進行狀態遷移
[_stateMachine enterState:[workState class]];
狀態對象的活動:進入新的狀態前,需要檢查狀態的可靠性;如果可靠,需要調用狀態遷移提供的方法,進行業務邏輯處理,相應需要覆寫的方法如下:
// 1,狀態遷移時,填寫邏輯代碼的位置
// 離開當前狀態時,調用該方法,參數是下一個狀態
- (void)willExitWithNextState:(GKState *)nextState {
NSLog(@"[WJSState Eat] willExitWithNextState:%@", nextState);
}
// 進入當前狀態時,調用該方法,參數是上一個狀態
- (void)didEnterWithPreviousState:(GKState *)previousState {
[super didEnterWithPreviousState:previousState];
NSLog(@"[WJSState Eat] didEnterWithPreviousState:%@", previousState);
}
// 2,狀態更新
// 狀態機調用updateWithDeltaTime時,狀態機會調用當前狀態的updateWithDeltaTime方法
- (void)updateWithDeltaTime:(NSTimeInterval)seconds {
NSLog(@"[WJSState Eat] updateWithDeltaTime");
}
為了更方便的了解狀態機模式的使用,我將小明例子的demo代碼上傳到了Github,地址點我點我!
點擊update按鈕,狀態更新,實際調用的是當前狀態里的updateWithDeltaTime:方法。點擊change按鈕,狀態按照設定遷移,當前狀態離開的時候,調用willExitWithNextState方法。進入新的狀態后,調用新狀態的didEnterWithPreviousState方法。
3,使用總結
因此使用狀態機模式的步驟按照以下步驟進行:
(1)分析好需求,理清實體不同狀態的更新和遷移邏輯,畫出狀態機的設計圖。
(2)使用GKState,實現具體狀態。
(3)使用GKStateMachine,在不同處理邏輯里,實現狀態的遷移。
三,Maze游戲里面如何使用狀態機。
Maze游戲中,由于Player(就是那個菱形◇)是玩家控制的,需要管理的就只有兩個狀態“生和死”。所以并不需要多么復雜的邏輯。但是enemies(四個方塊)們就不一樣了,他們的狀態根據情況有四種,如下圖所示(圖是蘋果提供的):
Maze狀態機Maze狀態機,Enemy的四種狀態之間的遷移邏輯:
(1)Flee(逃離)狀態和Chase(捕獵)狀態的遷移是依賴“Player gets power up”,即玩家輸入(單擊屏幕),玩家power up,狀態從Chase遷移到Flee。一旦power up的時間到了,狀態從Flee遷移回Chase狀態。
// 進入Chase狀態,調用Sprite組件,恢復enemies的外在
- (void)didEnterWithPreviousState:(__nullable GKState *)previousState {
// Set the enemy sprite to its normal appearance, undoing any changes that happened in other states.
AAPLSpriteComponent *component = (AAPLSpriteComponent *)
[self.entity componentForClass:[AAPLSpriteComponent class]];
[component useNormalAppearance];
}
// 進入Flee狀態,調用Sprite組件,改變enemies的外在,并設定逃離目標(隨機函數)。
- (void)didEnterWithPreviousState:(__nullable GKState *)previousState {
AAPLSpriteComponent *component = (AAPLSpriteComponent *)
[self.entity componentForClass:[AAPLSpriteComponent class]];
[component useFleeAppearance];
// Choose a location to flee towards.
self.target = [[self.game.random arrayByShufflingObjectsInArray:self.game.level.enemyStartPositions] firstObject];
}
(2)Flee(逃離)狀態到Defeated(被擊敗)狀態的遷移,依賴物理碰撞檢測系統。在初始化階段,定義了enemies和player的物理檢測實體范圍和碰撞回調。如果檢測到回調,在回調里面調用GKStateMachine進行狀態遷移。
- (void)didBeginContact:(SKPhysicsContact *)contact {
// 1,發生碰撞時(碰撞檢測由引擎負責),調用該函數。
AAPLSpriteNode *enemyNode;
if (contact.bodyA.categoryBitMask == ContactCategoryEnemy) {
enemyNode = (AAPLSpriteNode *)contact.bodyA.node;
}
else if (contact.bodyB.categoryBitMask == ContactCategoryEnemy) {
enemyNode = (AAPLSpriteNode *)contact.bodyB.node;
}
NSAssert(enemyNode != nil, @"Expected player-enemy/enemy-player collision");
// 2,如果enemy處于chase狀態,player掛掉。反之,enemy切換入defeated狀態
AAPLEntity *entity = (AAPLEntity *)enemyNode.owner.entity;
AAPLIntelligenceComponent *aiComponent = (AAPLIntelligenceComponent *)[entity componentForClass:[AAPLIntelligenceComponent class]];
if ([aiComponent.stateMachine.currentState isKindOfClass:[AAPLEnemyChaseState class]]) {
[self playerAttacked];
}
else {
// Otherwise, that enemy enters the Defeated state only if in a state that allows that transition.
[aiComponent.stateMachine enterState:[AAPLEnemyDefeatedState class]];
}
}
(3)Defeated(被擊敗)狀態經過不斷的更新,回到了重生點,就遷移到了Respawn(重生)狀態
// 在defeated狀態里,enemy對象尋路回到重生點,到了重生點后。調用狀態機,進入重生Respawn狀態
NSArray<GKGridGraphNode *> *path = [graph findPathFromNode:enemyNode toNode:self.respawnPosition];
[component followPath:path completion:^{
[self.stateMachine enterState:[AAPLEnemyRespawnState class]];
}];
(4)在重生Respwan狀態,重生時間到了,就回到了Chase(捕獵)狀態。這里的倒計時,是stateMachine采用updateWithDeltaTime自減時間變量實現。
// 1,從Defeated狀態進入Respawn狀態,調用該函數
- (void)didEnterWithPreviousState:(__nullable GKState *)previousState {
// 2,倒計時static變量置為10
static const NSTimeInterval defaultRespawnTime = 10;
self.timeRemaining = defaultRespawnTime;
// 3,調用Sprite組件,設置重生動畫
AAPLSpriteComponent *component = (AAPLSpriteComponent *)[self.entity componentForClass:[AAPLSpriteComponent class]];
component.pulseEffectEnabled = YES;
}
// 4, _stateMachine受系統的updateWithDeltaTime驅動,進行倒計時自減。倒計時到后,進入Chase狀態。
- (void)updateWithDeltaTime:(NSTimeInterval)seconds {
self.timeRemaining -= seconds;
if (self.timeRemaining < 0) {
[self.stateMachine enterState:[AAPLEnemyChaseState class]];
}
}
// 5,從當前Respawn狀態進入Chase狀態,調用Sprite組件,改變外在。
- (void)willExitWithNextState:(GKState * __nonnull)nextState {
// Restore the sprite's original appearance.
AAPLSpriteComponent *component = (AAPLSpriteComponent *)[self.entity componentForClass:[AAPLSpriteComponent class]];
component.pulseEffectEnabled = NO;
}
在Xcode中搜索stateMachine,看看Maze里enemies狀態的變遷,使用stateMachine調用位置,這里總結下:
(1)響應玩家點擊時,進行power up。
(2)物理碰撞檢測回調里調用。
(3)狀態更新調用updateWithDelta時,進行調用。
實際上,驅動游戲里狀態機更新的力量和方式,在我上一篇文章的圖里(上篇文章的圖可能有點錯誤,這里修改下),已經比較清晰:
componetSysteme的updateWithDelta:方法,會調用stateMachine的updateWithDelta:方法,進而調用當前狀態的updateWithDelta:方法,這樣實現狀態的更新。
四,何去何從
除了前兩篇文章所術的ECS和狀態機,我還將撰寫兩篇文章,描述Maze游戲里出現的技術。
1, 尋路系統。
2,隨機數,rule system。