評鑒Maze源碼(2):GamePlayKit的狀態(tài)機(jī)

上一篇文章《評鑒Maze源碼(1):GamePlayKit的ECS“實體-組件-系統(tǒng)”》里,我已經(jīng)介紹了在Maze游戲中的ECS方法,這個方法里面,關(guān)于Enemy實體的行為,需要狀態(tài)機(jī)來配合管理,這一篇文章,我就跟大家介紹一下GameplayKit里面狀態(tài)機(jī)的使用。

一,狀態(tài)機(jī)的介紹

狀態(tài)機(jī):能夠準(zhǔn)確的表達(dá)同一實體,不同階段的狀態(tài)和狀態(tài)遷移條件。
1,狀態(tài),可能是實體對象的屬性,也可能是屬性集合。
2,遷移條件,指的是外界的突發(fā)事件及滿足特殊條件的屬性變化。

游戲里面的實體會存在很多狀態(tài),比如蘋果的SceneKit,女探險家運動的幾個狀態(tài),在游戲過程中女探險家在這幾個狀態(tài)里面遷移。狀態(tài)機(jī)示意圖狀態(tài)機(jī)示意圖:

女冒險家狀態(tài)遷移示意

其分為,Running狀態(tài)Jumping狀態(tài)Falling狀態(tài)。狀態(tài)遷移條件表明在帶箭頭的直線上面。狀態(tài)機(jī)模式給我們編寫程序帶來明顯的好處,通過條件判斷,方便的管理對象實體的狀態(tài)。

關(guān)于狀態(tài)機(jī)的實現(xiàn),有很多方式,其中比較樸素的是if-else的判斷,如果狀態(tài)多,根據(jù)需求,狀態(tài)遷移也會有不斷的變化,那么if-else的編程會帶來很多代碼維護(hù)的問題。《Head first 設(shè)計模式》(Head first是我覺得很輕松愉快的一個系列讀物,推薦想要進(jìn)入一個新的技術(shù),卻苦于無法迅速入門的同學(xué)。但是入門后,仍然需要毅力和付出來完全掌握這項技術(shù),對任何事情都是如此。)為我們提供了很好的狀態(tài)機(jī)編程模式的教學(xué),教會我們簡單,可擴(kuò)展性的狀態(tài)機(jī)設(shè)計模式實現(xiàn)。

但是,在iOS里面,我們再也不用擔(dān)心狀態(tài)機(jī)的代碼編寫問題了,因為蘋果實現(xiàn)狀態(tài)機(jī)模式,我們掌握如何使用就行。而且不僅僅是游戲開發(fā),在別的APP應(yīng)用中,也能很從容的使用GameplayKit所提供的狀態(tài)機(jī)框架。

二,GameplayKit里面的狀態(tài)機(jī)API

樸素的狀態(tài)機(jī)實現(xiàn)方法,這里提一下,就是為了對比狀態(tài)機(jī)模式的實現(xiàn)方法。

比如小明的狀態(tài),定義為下面這三種,吃飯,睡覺和工作。小明作為一個對象,里面有currentState這一屬性,代表當(dāng)前小明的狀態(tài)。暫且將currentState定為int型,吃飯、睡覺和工作類型值分別是1,2,3。暫且將小明的狀態(tài)機(jī)變化簡化為“吃飯->睡覺->工作->吃飯”這一循環(huán)。實現(xiàn)代碼,小明對象提供一個changeState的方法,其參數(shù)是下一個要變化的狀態(tài)。changeState的實現(xiàn):
- (Bool)changeState:(XMState*)state {
switch(state):
{
case eat:
// 判斷當(dāng)前狀態(tài)work到下一步遷移狀態(tài)eat的有效性
if (self.currentState == work) {
// 進(jìn)行狀態(tài)遷移
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;
}

這里做的兩個工作,一個是判斷狀態(tài)遷移的有效性(判斷當(dāng)前狀態(tài)),另一個是進(jìn)行狀態(tài)的遷移(設(shè)置currentStatus狀態(tài))。如果,加入新的狀態(tài),或者狀態(tài)循環(huán)發(fā)生變化,狀態(tài)機(jī)的switch-case和if-else的判斷將會不斷增加,可維護(hù)性變差,代碼冗余將不斷上升。

然而,通過狀態(tài)機(jī)模式,可以使得代碼變得可維護(hù)和,GameplayKit提供了這一模式的實現(xiàn),我們現(xiàn)在來好好掌握它。

1,狀態(tài)對象GKState

在GameplayKit里面,蘋果有狀態(tài)對象GKState,來作為所有狀態(tài)的基類祖先。

對象GKState提供了一些方法,這些方法有兩類作用:

(1)狀態(tài)對象本身屬性和管理狀態(tài)遷移的有效性。如:

// 驗證下一個狀態(tài)是否有效,如果無效的話,是不會發(fā)生狀態(tài)遷移的
- (BOOL)isValidNextState:(Class)stateClass {
     return stateClass == [WJSSleepState class];
}

(2)為狀態(tài)的更新和遷移提供了填寫邏輯代碼的位置。在實體的狀態(tài)進(jìn)行更新或者遷移的時候,需要開發(fā)者填入相應(yīng)的邏輯來完成實體狀態(tài)的變化。

(3)按照上面小明同學(xué)的“吃飯,睡覺和工作”三個狀態(tài),定義這三個狀態(tài)。

@interface WJSWorkState : WJSState

@end

@interface WJSEatState : WJSState

@end

@interface WJSSleepState : WJSState

@end

如何驅(qū)動實體進(jìn)行狀態(tài)的更新和遷移呢?即樸素編程里面的changeState方法。GameplayKit提供了狀態(tài)機(jī)對象GKStateMachine來對狀態(tài)GKState進(jìn)行管理。

2,驅(qū)動狀態(tài)變化的狀態(tài)機(jī)對象GKStateMachine

GameplayKit提供管理狀態(tài)遷移的狀態(tài)機(jī)對象GKStateMachine,實現(xiàn)狀態(tài)對象的管理、更新和遷移。首先,在初始化的階段,將在上面步驟中實體的所有狀態(tài),都加入到狀態(tài)機(jī)對象GKStateMachine進(jìn)行管理。

// 1,初始化各個狀態(tài)
WJSWorkState *workState = [WJSWorkState new];
WJSEatState *eatState = [WJSEatState new];
WJSSleepState *sleepState = [WJSSleepState new];

// 2,初始化狀態(tài)機(jī),并將各個狀態(tài),加入其當(dāng)前管理的狀態(tài)機(jī)對象
_stateMachine = [GKStateMachine stateMachineWithStates:@[workState,eatState, sleepState]];

// 3,進(jìn)入work狀態(tài)
[_stateMachine enterState:[workState class]];

其次,狀態(tài)機(jī)對象負(fù)責(zé)狀態(tài)的更新和狀態(tài)的遷移,這里涉及兩層意思:
(1)狀態(tài)的更新:指的是當(dāng)前狀態(tài)的更新。在整個程序系統(tǒng)運行的時候,當(dāng)前狀態(tài)也許需要不斷的更新、計算和執(zhí)行規(guī)定操作。調(diào)用狀態(tài)機(jī)的updateWithDelta:方法,狀態(tài)機(jī)會調(diào)用當(dāng)前狀態(tài)的updateWithDelta:方法,開發(fā)者在GKState里面覆寫該方法,填入相應(yīng)的更新邏輯,就可以對當(dāng)前狀態(tài)進(jìn)行更新。

 // 狀態(tài)機(jī)更新當(dāng)前狀態(tài)的更新函數(shù)
 [_stateMachine updateWithDeltaTime:1];

(2)狀態(tài)的遷移:從當(dāng)前狀態(tài)遷移到下一個狀態(tài)。GKState里面提供的回調(diào),提供給開發(fā)者作為狀態(tài)遷移邏輯代碼的處理。

// 狀態(tài)機(jī)進(jìn)行狀態(tài)遷移
[_stateMachine enterState:[workState class]];

狀態(tài)對象的活動:進(jìn)入新的狀態(tài)前,需要檢查狀態(tài)的可靠性;如果可靠,需要調(diào)用狀態(tài)遷移提供的方法,進(jìn)行業(yè)務(wù)邏輯處理,相應(yīng)需要覆寫的方法如下:

// 1,狀態(tài)遷移時,填寫邏輯代碼的位置
// 離開當(dāng)前狀態(tài)時,調(diào)用該方法,參數(shù)是下一個狀態(tài) 
- (void)willExitWithNextState:(GKState *)nextState {
    NSLog(@"[WJSState Eat] willExitWithNextState:%@", nextState);
}

 // 進(jìn)入當(dāng)前狀態(tài)時,調(diào)用該方法,參數(shù)是上一個狀態(tài)
- (void)didEnterWithPreviousState:(GKState *)previousState {
    [super didEnterWithPreviousState:previousState];
    NSLog(@"[WJSState Eat] didEnterWithPreviousState:%@", previousState);
}

// 2,狀態(tài)更新
// 狀態(tài)機(jī)調(diào)用updateWithDeltaTime時,狀態(tài)機(jī)會調(diào)用當(dāng)前狀態(tài)的updateWithDeltaTime方法
- (void)updateWithDeltaTime:(NSTimeInterval)seconds {
    NSLog(@"[WJSState Eat] updateWithDeltaTime");
}

為了更方便的了解狀態(tài)機(jī)模式的使用,我將小明例子的demo代碼上傳到了Github,地址點我點我!

點擊update按鈕,狀態(tài)更新,實際調(diào)用的是當(dāng)前狀態(tài)里的updateWithDeltaTime:方法。點擊change按鈕,狀態(tài)按照設(shè)定遷移,當(dāng)前狀態(tài)離開的時候,調(diào)用willExitWithNextState方法。進(jìn)入新的狀態(tài)后,調(diào)用新狀態(tài)的didEnterWithPreviousState方法。

3,使用總結(jié)

因此使用狀態(tài)機(jī)模式的步驟按照以下步驟進(jìn)行:
(1)分析好需求,理清實體不同狀態(tài)的更新和遷移邏輯,畫出狀態(tài)機(jī)的設(shè)計圖。
(2)使用GKState,實現(xiàn)具體狀態(tài)。
(3)使用GKStateMachine,在不同處理邏輯里,實現(xiàn)狀態(tài)的遷移。

三,Maze游戲里面如何使用狀態(tài)機(jī)。

Maze游戲中,由于Player(就是那個菱形◇)是玩家控制的,需要管理的就只有兩個狀態(tài)“生和死”。所以并不需要多么復(fù)雜的邏輯。但是enemies(四個方塊)們就不一樣了,他們的狀態(tài)根據(jù)情況有四種,如下圖所示(圖是蘋果提供的):


Maze狀態(tài)機(jī)示意圖

Maze狀態(tài)機(jī)Maze狀態(tài)機(jī),Enemy的四種狀態(tài)之間的遷移邏輯:
(1)Flee(逃離)狀態(tài)和Chase(捕獵)狀態(tài)的遷移是依賴“Player gets power up”,即玩家輸入(單擊屏幕),玩家power up,狀態(tài)從Chase遷移到Flee。一旦power up的時間到了,狀態(tài)從Flee遷移回Chase狀態(tài)。

// 進(jìn)入Chase狀態(tài),調(diào)用Sprite組件,恢復(fù)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];
}

// 進(jìn)入Flee狀態(tài),調(diào)用Sprite組件,改變enemies的外在,并設(shè)定逃離目標(biāo)(隨機(jī)函數(shù))。
- (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(逃離)狀態(tài)到Defeated(被擊敗)狀態(tài)的遷移,依賴物理碰撞檢測系統(tǒng)。在初始化階段,定義了enemies和player的物理檢測實體范圍和碰撞回調(diào)。如果檢測到回調(diào),在回調(diào)里面調(diào)用GKStateMachine進(jìn)行狀態(tài)遷移。

- (void)didBeginContact:(SKPhysicsContact *)contact {
   // 1,發(fā)生碰撞時(碰撞檢測由引擎負(fù)責(zé)),調(diào)用該函數(shù)。
  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狀態(tài),player掛掉。反之,enemy切換入defeated狀態(tài)
  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(被擊敗)狀態(tài)經(jīng)過不斷的更新,回到了重生點,就遷移到了Respawn(重生)狀態(tài)

// 在defeated狀態(tài)里,enemy對象尋路回到重生點,到了重生點后。調(diào)用狀態(tài)機(jī),進(jìn)入重生Respawn狀態(tài)
NSArray<GKGridGraphNode *> *path = [graph findPathFromNode:enemyNode toNode:self.respawnPosition];
[component followPath:path completion:^{
    [self.stateMachine enterState:[AAPLEnemyRespawnState class]];
}];

(4)在重生Respwan狀態(tài),重生時間到了,就回到了Chase(捕獵)狀態(tài)。這里的倒計時,是stateMachine采用updateWithDeltaTime自減時間變量實現(xiàn)。

// 1,從Defeated狀態(tài)進(jìn)入Respawn狀態(tài),調(diào)用該函數(shù)
- (void)didEnterWithPreviousState:(__nullable GKState *)previousState {
   // 2,倒計時static變量置為10
  static const NSTimeInterval defaultRespawnTime = 10;
  self.timeRemaining = defaultRespawnTime;

  // 3,調(diào)用Sprite組件,設(shè)置重生動畫
  AAPLSpriteComponent *component = (AAPLSpriteComponent *)[self.entity componentForClass:[AAPLSpriteComponent class]];
  component.pulseEffectEnabled = YES;
}

  // 4, _stateMachine受系統(tǒng)的updateWithDeltaTime驅(qū)動,進(jìn)行倒計時自減。倒計時到后,進(jìn)入Chase狀態(tài)。
- (void)updateWithDeltaTime:(NSTimeInterval)seconds {
  self.timeRemaining -= seconds;
  if (self.timeRemaining < 0) {
      [self.stateMachine enterState:[AAPLEnemyChaseState class]];
  }
}

  // 5,從當(dāng)前Respawn狀態(tài)進(jìn)入Chase狀態(tài),調(diào)用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狀態(tài)的變遷,使用stateMachine調(diào)用位置,這里總結(jié)下:
(1)響應(yīng)玩家點擊時,進(jìn)行power up。
(2)物理碰撞檢測回調(diào)里調(diào)用。
(3)狀態(tài)更新調(diào)用updateWithDelta時,進(jìn)行調(diào)用。

實際上,驅(qū)動游戲里狀態(tài)機(jī)更新的力量和方式,在我上一篇文章的圖里(上篇文章的圖可能有點錯誤,這里修改下),已經(jīng)比較清晰:

驅(qū)動游戲里狀態(tài)機(jī)更新示意圖

componetSysteme的updateWithDelta:方法,會調(diào)用stateMachine的updateWithDelta:方法,進(jìn)而調(diào)用當(dāng)前狀態(tài)的updateWithDelta:方法,這樣實現(xiàn)狀態(tài)的更新。

四,何去何從

除了前兩篇文章所術(shù)的ECS和狀態(tài)機(jī),我還將撰寫兩篇文章,描述Maze游戲里出現(xiàn)的技術(shù)。

1, 尋路系統(tǒng)。
2,隨機(jī)數(shù),rule system。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,923評論 6 535
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,740評論 3 420
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,856評論 0 380
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,175評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 71,931評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,321評論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,383評論 3 443
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,533評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,082評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,891評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,067評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,618評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,319評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,732評論 0 27
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,987評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,794評論 3 394
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 48,076評論 2 375

推薦閱讀更多精彩內(nèi)容