查看原文
其他

Cocos Creator 超简洁代码实现有限状态机 FSM,打造丝滑角色动作

黄聪 COCOS 2022-06-10

引言:本文作者黄聪是一名在校大学生,设计毕设的过程中,他参考《游戏编程模式》一书,摸索出了一套角色动作控制方案。


作为一名在校学生,前段时间在做毕业设计的过程中,我也遇到了很多同学都会遇到的问题:角色的动作逻辑全都写在 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

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));
        }

        // 按键响应事件绑定
        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 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() 为控制移动的模块,与该系统无关


即使状态机有这些常见的扩展,它们也受到一些限制。这里只是记录下我的解决方式,意为抛砖引玉,欢迎大家点击文末【阅读原文】到论坛专贴一起交流!


往期精彩

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存