上一篇文章《評鑒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狀態機
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*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時,進行調用。
實際上,驅動游戲裡狀態機更新的力量和方式,在我上一篇文章的圖裡(上篇文章的圖可能有點錯誤,這裡修改下),已經比較清晰:
enemy狀態更新圖示(修改後)
componetSysteme的updateWithDelta:方法,會調用stateMachine的updateWithDelta:方法,進而調用當前狀態的updateWithDelta:方法,這樣實現狀態的更新。
四、何去何從
除了前兩篇文章所術的ECS和狀態機,我還將撰寫兩篇文章,描述Maze游戲裡出現的技術。
1.尋路系統。
2.隨機數,rule system。