上一篇文章《 评鉴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所提供的状态机框架。
朴素的状态机实现方法,这里提一下,就是为了对比状态机模式的实现方法。
比如小明的状态,定义为下面这三种,吃饭,睡觉和工作。小明作为一个对象,里面有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游戏中,由于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。