评鉴Maze源码(2):GamePlayKit的状态机

上一篇文章《评鉴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状态机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。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,757评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,478评论 1 289
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,540评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,593评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,903评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,329评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,659评论 2 309
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,383评论 0 195
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,055评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,337评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,864评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,227评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,820评论 3 231
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,999评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,750评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,365评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,260评论 2 258

推荐阅读更多精彩内容