您可以捐助,支持我们的公益事业。

1元 10元 50元





认证码:  验证码,看不清楚?请点击刷新验证码 必填



  求知 文章 文库 Lib 视频 iPerson 课程 认证 咨询 工具 讲座 Model Center   Code  
会员   
   
 
     
   
 
 订阅
Cocos Creator 超简洁代码实现有限状态机 FSM
 
作者:黄聪
  2280  次浏览      20 次
 2022-3-2
 
编辑推荐:
本文基于游戏编程模式中的 状态模式(State Pattern)进行展开 ,希望对您的学习有所帮助。
本文来自COCOS,由火龙果软件Alice编辑、推荐。

作为一名在校学生,前段时间在做毕业设计的过程中,我也遇到了很多同学都会遇到的问题:角色的动作逻辑全都写在 Player.ts 里面,当一个玩家脚本需要同时执行多个逻辑的时候(移动控制,动画播放,按键管理等等),无一例外地出现了这样的局面——

我们优雅地判断了按键输入,希望在 WASD 的按键驱动下,让我们的主人公顺理成章地旋转跳跃翻飞升华,于是在判断按键输入的代码块里改变了角色的动作播放,又设置了移动速度,还在某个 update 里面不停地设置他的方向……

光是想想我就已经戴上了痛苦面具!于是我在网上搜索了各路资料,在不懈的努力下最终摸索出了一套方案,思路基于游戏编程模式中的 状态模式(State Pattern) 。

以下是我在 Cocos Creaotr 2.4.x 用框架实现的角色移动、跳跃、下蹲、跳斩状态之间的切换效果,且 Player.ts 脚本内不再包含状态的行为逻辑。

成品效果,部分素材来源于网络

初试

让我们从零开始。为了保证思路清晰,我们假设现在在做一个 2D 横版闯关游戏,需要让主角对我们的键盘输入做出响应,按下空格键跳跃。这个功能看起来很容易实现:

  • Player.ts
private _jumpVelocity: number = 100;

onKeyDown(event: any) {
  if (cc.macro.KEY.space == event.keyCode) {
         this.node.getComponent(Rigibody).
setVerticalVelocity(this._jumpVelocity);
     }
}

但这有个问题, 没有东西可以阻止「空中跳跃」, 当角色在空中时疯狂按下空格,角色就会浮空。简单的修复方式是给 Player.ts 增加一个 _onGround 字段,然后这样:

private _onGround: boolena = false;
private _jumpVelocity: number = 100;

onKeyDown(event: any) {
  if (cc.macro.KEY.space == event.keyCode) {
         if(this._onGround) {
             this._onGround = false;
          // 跳跃...
         }
     }
}

意识到了吗?此时我们还没有实现角色的其他动作。当角色在地面上时,我希望按下↓方向键时,角色能够卧倒,松开时又能站起来:

private _onGround: boolena = false;
private _jumpVelocity: number = 100;

onKeyDown(event: any) {
  if (cc.macro.KEY.space == event.keyCode) {
         if(this._onGround) {
             this._onGround = false;
          // 如果在地上,就跳起来
         }
     }
     else if (cc.macro.KEY.down == event.keyCode) {
         if (this._onGround){
             // 如果在地上,就卧倒
         }
     }
}

onKeyUp(event: any) {
   if (cc.macro.KEY.down == event.keyCode) {
         // 起立
     }
}

新的问题出现了。通过这段代码,角色可能从卧倒状态跳起来,并且可以在空中按方向键趴下,这可不是我们想要的,因此这时候又要加入新的字段……

private _onGround: boolena = false;
private _isDucking: boolean = false;
private _jumpVelocity: number = 100;

onKeyDown(event: any) {
  if (cc.macro.KEY.space == event.keyCode) {
         if(this._onGround && !this._isDucking) {
             this._onGround = false;
          // 如果在地上,不在卧倒,就跳起来
         }
     }
     else if (cc.macro.KEY.down == event.keyCode) {
         if (this._onGround){
             this._isDucking = true;
             // 如果在地上,就卧倒
         }
     }
}

onKeyUp(event: any) {
   if (cc.macro.KEY.down == event.keyCode) {
         if (this._isDucking) {
             this._isDucking = false;
             // 起立
         }
     }
}

但是这样的实现方法很明显有很大问题。 每次我们改动代码时,就会破坏之前写好的一些东西。 我们需要增加更多动作——滑铲、跳斩攻击、向后闪避等,但若用这种方法,完成之前就会造成一堆漏洞。

有限状态机(FSM)

经历了上述的挫败后,我痛定思痛,把桌面清空,留下纸笔,开始画流程图。我给角色的每个行为都画了一个盒子:站立、跳跃、卧倒、跳斩……当角色响应按键时,画一个箭头,连接到它需要切换的状态。

如此,就建立好了一个有限状态机,它的特点是:

  • 拥有角色所有可能状态的集合。 在这里,状态有站立、卧倒、跳跃以及跳斩。
  • 状态机同一时间只能处于一个状态。 角色不可能同时处于站立和卧倒状态,这也是使用 FSM 的理由之一。
  • 所有的按键输入都将发送给状态机。 在这里就是不同按键的按下和弹起。
  • 每个状态都有一系列的状态转移、转移条件和输入与另一个状态相关。 当处于这个状态下,输入满足另一个状态的条件,状态机的状态就切换到目标的状态。

这就是状态机的核心思维:状态、输入、转移。

枚举与分支

回来分析之前的代码存在的问题。首先,它不合时宜地捆绑了一大堆 bool 变量: _onGround 和 _isDucking 这些变量似乎不可能同时为真或假,因此我们需要的其实是枚举。类似这样:

enum State {
  STATE_IDLE,
  STATE_JUMPING,
  STATE_DUCKING,
  STATE_DIVING
};

这样一来不需要一堆字段,我们只需要根据枚举进行对应的判断:

onKeyDown(event: any) {
     switch(_state) {
         case State.STATE_IDLE:
             if(cc.macro.KEY.space == event.keyCode){
                 _state = STATE_JUMPING;
                 // 跳跃...
             }
             else if (cc.macro.KEY.down == event.keyCode) {
                 _state = STATE_DUCKING;
                 // 卧倒...
             }
             break;
            
         case State.STATE_JUMPING:
             if (cc.macro.KEY.down == event.keyCode) {
                 _state = STATE_DIVING;
                 // 跳斩...
             }
             break;
            
         case State.STATE_DUCKING:
             //...
             break;
     }

看起来也就改变了一点点,但是比起之前的代码有了很大的进步。我们在条件分支进行了区分,将某个状态中运行的逻辑聚合到了一起。

这是最简单的状态机实现方式,但是实际问题没有这么简单。 我们的角色还存在着按键蓄力,松开时进行一段特殊攻击。 现在的代码没有办法很清晰地胜任这样的工作。

还记得一开始画的状态机流程图吗?每一个状态方盒子给了我一些灵感,于是我开始尝试, 用 面向对象 的思想去设计状态机。

状态模式

即使 switch 可以完成这些需求,但就像我们用起来的那样:崎岖且繁琐。因此我决定去使用游戏编程模式中的思想, 让我们能使用简单的接口去完成复杂的逻辑工作, 目标还是老样子:高内聚,低耦合。

状态接口

将状态封装成一个基类,用于控制某个状态相关的行为,并让状态记住自己所依附的角色信息。

这么做的目的很明确: 让每个状态拥有相同的类型与共性,方便我们集中管理。

export default class StateBase {
     protected _role: Player | null = null;
     constructor(player: Player) {
         this._role = player;
     }

    //start------------虚方法-----------
     /**进入该状态时被调用 */
     onEnter() { }
    
     /**该状态每帧都会调用的方法 */
     onUpdate(dt: any) { }
    
     /**该状态监听的键盘输入事件 */
     onKeyDown(event: any) { }
    
     /**该状态监听的键盘弹起事件 */
     onKeyUp(event: any) { }
    
     /**离开该状态时调用 */
     onExit() { }
     //end--------------虚方法------------
}

为每个状态写一个类

对于每个状态,我们定义一个类的实现接口。

它的方法定义了 角色在这个状态的行为 。换句话说,从之前的 switch 中取出每个 case ,将它们移动到状态类中。

export default class Player_Idle extends StateBase {
onEnter(): void { }

onExit(): void { }

onUpdate(dt: any ): void { }

onKeyDown(event: any ): void {
switch (event.keyCode) {
case cc.macro.KEY.space:
// 跳跃状态
break ;
case cc.macro.KEY.down:
// 卧倒状态
break ;
}
}

onKeyUp(event: any ): void { }
}

要注意,这里就已经把原本写在 Player.ts 中的 Idle 状态逻辑移除,放到了 Player_Idle.ts 类中。这样非常的清晰——在这个状态内只存在我们需要他判断的逻辑。

状态委托

接下来,重新构建角色内原来的逻辑,放弃庞大的 switch,通过一个变量来存储当前正在执行的状态。

export default class Player {
     protected _state: StateBase | null = null; 
//角色当前状态 constructor() {
         onInit();
     }
onInit() {
         this.schedule(this.onUpdate);
     }
 
  onKeyDown(event: any) {
         this._state.onKeyDown(event);
     }
onKeyUp(event: any) {
         this._state.onKeyUp(event);
     }
onUpdate(dt) {
         this._state.onUpdate(dt);
     }
}

为了「改变状态」,我们只需要将 _state 指向不同的 StateBase 对象,这样就实现了状态模式的全部内容。

将状态存在哪里?

又一个小细节:上面说到,为了「改变状态」,我们需要将 _state 指向新的状态对象,但是这个对象从哪里来呢?

我们知道一个角色有多个属于它的状态,而这些状态不可能是游离态存在内存中,我们必须用某些方式把这个角色的所有状态管理起来,我们或许可以这样做:找个人畜无害的位置,添加一个静态类,存储玩家的所有状态:

export class PlayerStates {
     static idle: IdleState;
     static jumping: JumpingState;
     static ducking: DuckingState;
     static diving: DivingState;
     //...
}

这样玩家就可以切换状态:

export default class Player_Idle extends StateBase {
     onEnter(): void { }

    onExit(): void { }
onUpdate(dt: any): void { }
onKeyDown(event: any): void {
         switch (event.keyCode) {
             case cc.macro.KEY.space:
                 // 跳跃状态
                 this._role._state = PlayerStates.JumpingState;
                 break;
             case cc.macro.KEY.down:
                 // 卧倒状态
                 this._role._state = PlayerStates.DuckingState;
                 break;
         }
     }
onKeyUp(event: any): void { }
}

这有问题吗?没有问题。但现在优化到了这一步,我不甘心这么做,因为这依旧是一个耦合较高的实现方法。这样的实现方式意味着 每个角色都需要一个单独的类来存放状态合集 ,当一个游戏中存在多个角色,多个职业的时候,这个做法就相当繁琐。

那么这个问题有没有突 破口呢?当然有, 用容器装起来! 既解决了耦合问题,也保留了之前的方式的所有灵活性,只需要往容器中注册一个状态就可以了。

protected _mapStates: Map <string, StateBase>
= new Map (); //角色状态集合

将现有的代码模块化

现在整理一下我们所实现的部分:

  • 多个状态继承自一个状态基类,实现相同的接口。
  • 角色类中定义了该角色当前状态的变量 _state 。
  • 用一个容器 _mapStates 存储某个角色的状态合集。

我觉着功能已经差不多完善了,将处理状态相关的变量聚合到一个类中,将角色类彻底放空,同时像一般的管理器一样,实现对于状态类的增删查改,画个框架图便于理解。

  • Animator.ts
/**动画机类,用于管理单个角色的状态 */
export default class Animator {
     protected _mapStates: Map<string, StateBase>
 = new Map();  
 //角色状态集合
     protected _state: StateBase | null = null;                
  //角色当前状态

    /**
      * 注册状态
      * @param key 状态名
      * @param state 状态对象
      * @returns 
      */
     regState(key: string, state: StateBase): void {
         if ('' === key) {
             cc.error('The key of state is empty');
             return;
         }
         if (null == state) {
             cc.error('Target state is null');
             return;
         }
         if (this._mapStates.has(key))
             return;
this._mapStates.set(key, state);
     }
/**
      * 删除状态
      * @param key 状态名
      * @returns 
      */
     delState(key: string): void {
         if ('' === key) {
             cc.error('The key of state is empty');
             return;
         }
this._mapStates.delete(key);
     }
/**
      * 切换状态
      * @param key 状态名
      * @returns 
      */
     switchState(key: string) {
         if ('' === key) {
             cc.error('The key of state is empty.');
             return;
         }
if (this._state) {
             if (this._state == this._mapStates.get(key))
                 return;
             this._state.onExit();
         }

this._state = this._mapStates.get(key);
         if (this._state)
             this._state.onEnter();
         else
             cc.warn(`Animator error: state '${key}'
not found.`);
     }
/**获取状态机内所有状态 */
     getStates(): Map<string, StateBase> {
         return this._mapStates;
     }
/**获取当前状态 */
     getCurrentState(): StateBase {
         return this._state;
     }
/**当前状态更新函数 */
     onUpdate(dt: any) {
         if (!this._state) {
             return;
         }
         if (!this._state.onUpdate) {
             cc.warn('Animator onUpdate: 
state has not update function.');
             return;
         }
         this._state.onUpdate(dt);
     }
}

接下来在角色类中只需要定义一个 Animator 类的变量,并向其中注册我们需要的状态,再继续执行之前的逻辑代码:

  • Player.ts
 // 按键响应事件绑定
         cc.systemEvent.on(cc.SystemEvent.EventType.
KEY_DOWN, this.onKeyDown, this);
         cc.systemEvent.on(cc.SystemEvent.EventType.
KEY_UP, this.onKeyUp, this);
        
         this.schedule(this.onUpdate);
     }

    onEnter(params?: any) { }
onUpdate(dt: any) {
         this._animator.onUpdate(dt);
     }
onKeyDown(event: any) {
         let state = this._animator.getCurrentState();
         if (state) {
             state.onKeyDown(event);
         }
     }
onKeyUp(event: any) {
         let state = this._animator.getCurrentState();
         if (state) {
             state.onKeyUp(event);
         }
     }
}

当然,可以选择做一些拓展的工作,让状态机也被管理起来:

  • AnimatorManager.ts
export default class Player {
  private _animator: Animator| null = null;
    
onInit() {
// 状态机注册
this._animator = new Animator();
if (this._animator) {
this._animator.regState('Idle', new IdleState(this));
this._animator.regState('Jumping', new JumpingState(this));
this._animator.regState('Ducking', new DuckingState(this));
this._animator.regState('Diving', new DivingState(this));
}

/**动画机管理器 */
export default class AnimatorManager {
     //单例
     private static _instance: AnimatorManager 
| null = null;
     public static instance(): AnimatorManager {
         if (!this._instance) {
             this._instance = new AnimatorManager();
         }
         return this._instance;
     }
private _mapAnimators: Map<string, Animator>
 = new Map<string, Animator>();
/**
      * 获取动画机,若不存在则新建并返回
      * @param key 动画机名
      * @returns 动画机
      */
     getAnimator(key: string): Animator | null {
         if ("" == key) {
             cc.error("AnimatorManager error: The key of Animator is empty");
         }
let anim: Animator | null = null;
         if (!this._mapAnimators.has(key)) {
             anim = new Animator();
             this._mapAnimators.set(key, anim);
         }
         else {
             anim = this._mapAnimators.get(key);
         }
return anim;
     }
/**
      * 删除动画机
      * @param key 动画机名
      */
     delAnimator(key: string) {
         this._mapAnimators.delete(key);
     }
/** 清空动画机 */
     clearAnimator() {
         this._mapAnimators.clear();
     }
/**动画机状态更新 */
     onUpdate(dt: any) {
         this._mapAnimators.forEach
((value: Animator, key: string) => {
             value.onUpdate(dt);
         });
     }
}

这样角色类的 new 操作就被集中到了管理类,在 Player.ts 中也就不需要再 new 了:

// 状态机注册
this._animator = AnimatorManager.instance().
getAnimator("player");
if (this._animator) {
this._animator.regState('Idle', new IdleState(this));
this._animator.regState('Jumping', new JumpingState(this));
this._animator.regState('Ducking', new DuckingState(this));
this._animator.regState('Diving', new DivingState(this));
}

成品

最终的角色状态切换效果通过如下代码实现,干净整洁:

注:this.getController() 为控制移动的模块,与该系统无关

即使状态机有这些常见的扩展,它们也受到一些限制。这里只是记录下我的解决方式!

 

   
2280 次浏览       20
相关文章

一文了解汽车嵌入式AUTOSAR架构
嵌入式Linux系统移植的四大步骤
嵌入式中设计模式的艺术
嵌入式软件架构设计 模块化 & 分层设计
相关文档

企点嵌入式PHP的探索实践
ARM与STM简介
ARM架构详解
华为鸿蒙深度研究
相关课程

嵌入式C高质量编程
嵌入式操作系统组件及BSP裁剪与测试
基于VxWorks的嵌入式开发、调试与测试
嵌入式单元测试最佳实践

最新活动计划
数据建模方法与工具 12-16[北京]
基于模型系统仿真与验证 12-14 [讲座]
白盒测试技术与工具实践 12-24[线上]
LLM大模型应用与项目构建 12-26[特惠]
UML和EA进行系统分析设计 12-20[线上]
SysML建模专家 1-16[北京]
 
 
最新文章
基于FPGA的异构计算在多媒体中的应用
深入Linux内核架构——简介与概述
Linux内核系统架构介绍
浅析嵌入式C优化技巧
进程间通信(IPC)介绍
最新课程
嵌入式Linux驱动开发
代码整洁之道-态度、技艺与习惯
嵌入式软件测试
嵌入式C高质量编程
嵌入式软件可靠性设计
成功案例
某军工所 嵌入式软件架构
中航工业某研究所 嵌入式软件开发指南
某轨道交通 嵌入式软件高级设计实践
深圳 嵌入式软件架构设计—高级实践
某企业 基于IPD的嵌入式软件开发
更多...