CocosCreator 2.x 实现角色有限状态管理 (FSM)

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

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

于是我捂着翻滚的胃部在网上搜索各种资料,在不懈的努力下最终摸索出了一套方案,思想基于游戏编程模式中的状态模式(State Pattern)

以下是我用框架实现的角色移动、跳跃、下蹲、跳斩状态之间的切换效果,且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类中。这样非常的清晰——在这个状态内只存在我们需要他判断的逻辑。
files

状态委托

接下来,重新构建角色内原来的逻辑,放弃庞大的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()为控制移动的模块,与该系统无关)

最后

即使状态机有这些常见的扩展,它们也受到一些限制,对于较复杂的状态转换逻辑,这套系统依旧比较累赘。这里只是记录下我的解决方式,意为抛砖引玉,欢迎交流!

我的博客:AlertNote

31赞

:+1: :+1: :+1:

:+1: :+1: :+1:

:+1: :+1: :+1:

:+1: :+1: :+1:学习了

:+1: :+1: :+1:

:+1: :+1: :+1:
学习了

:+1: :+1: :+1:
学习了

image image image 学习了

:+1::+1::+1::+1::+1::+1:

好像在spritekit看到过一个成熟框架,要是能翻译成js就棒了

Nice work

Mark 学习一下

学习了 :+1:

愿意学习:)

1赞

:innocent: :grinning:

网上各种资料指的是游戏编程模式那本书吧 :grimacing:

这本书写的很清晰,用例也参考了这本书

:+1:后生可畏啊