编辑推荐: |
本文基于游戏编程模式中的 状态模式(State Pattern)进行展开
,希望对您的学习有所帮助。
本文来自COCOS,由火龙果软件Alice编辑、推荐。 |
|
作为一名在校学生,前段时间在做毕业设计的过程中,我也遇到了很多同学都会遇到的问题:角色的动作逻辑全都写在 Player.ts 里面,当一个玩家脚本需要同时执行多个逻辑的时候(移动控制,动画播放,按键管理等等),无一例外地出现了这样的局面——
我们优雅地判断了按键输入,希望在 WASD 的按键驱动下,让我们的主人公顺理成章地旋转跳跃翻飞升华,于是在判断按键输入的代码块里改变了角色的动作播放,又设置了移动速度,还在某个 update 里面不停地设置他的方向……
光是想想我就已经戴上了痛苦面具!于是我在网上搜索了各路资料,在不懈的努力下最终摸索出了一套方案,思路基于游戏编程模式中的 状态模式(State Pattern) 。
以下是我在 Cocos Creaotr 2.4.x 用框架实现的角色移动、跳跃、下蹲、跳斩状态之间的切换效果,且 Player.ts 脚本内不再包含状态的行为逻辑。
成品效果,部分素材来源于网络
初试
让我们从零开始。为了保证思路清晰,我们假设现在在做一个 2D 横版闯关游戏,需要让主角对我们的键盘输入做出响应,按下空格键跳跃。这个功能看起来很容易实现:
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 存储某个角色的状态合集。
我觉着功能已经差不多完善了,将处理状态相关的变量聚合到一个类中,将角色类彻底放空,同时像一般的管理器一样,实现对于状态类的增删查改,画个框架图便于理解。
/**动画机类,用于管理单个角色的状态 */
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 类的变量,并向其中注册我们需要的状态,再继续执行之前的逻辑代码:
// 按键响应事件绑定
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);
}
}
}
|
当然,可以选择做一些拓展的工作,让状态机也被管理起来:
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() 为控制移动的模块,与该系统无关
即使状态机有这些常见的扩展,它们也受到一些限制。这里只是记录下我的解决方式!
|