美文网首页程序员iOS 开发
评鉴Maze源码(2):GamePlayKit的状态机

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

作者: 小武的技术渔场 | 来源:发表于2016-04-13 08:47 被阅读316次

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

    相关文章

      网友评论

        本文标题:评鉴Maze源码(2):GamePlayKit的状态机

        本文链接:https://www.haomeiwen.com/subject/iivikttx.html