Cocos Creator 超简洁代码实现有限状态机 FSM,打造丝滑角色动作
引言:本文作者黄聪是一名在校大学生,设计毕设的过程中,他参考《游戏编程模式》一书,摸索出了一套角色动作控制方案。
作为一名在校学生,前段时间在做毕业设计的过程中,我也遇到了很多同学都会遇到的问题:角色的动作逻辑全都写在 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();elsecc.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() 为控制移动的模块,与该系统无关
即使状态机有这些常见的扩展,它们也受到一些限制。这里只是记录下我的解决方式,意为抛砖引玉,欢迎大家点击文末【阅读原文】到论坛专贴一起交流!
往期精彩
Cocos Creator 超简洁代码实现有限状态机 FSM,打造丝滑角色动作相关推荐
- 华为云CDN为芒果TV加速,打造丝滑“追剧观综”的观看体验
华为云CDN为芒果TV加速,打造丝滑"追剧观综"的观看体验 芒果,不仅在水果类中怎么爱都不嫌多,在综艺界的名头也是响当当.作为全国综艺的扛把子-芒果TV, 芒果TV坐拥过亿注册用户 ...
- Unity简单几行代码让玩家水平移动更丝滑真实
可以先来看看基础的移动代码,接收玩家的输入,然后赋予刚体速度. 但是这种写法存在几个问题,下面一一纠正. 首先,如果直接改变刚体的速度,那么可能会出现穿墙的问题. 而且没有一种从速度0到缓慢加速的过程 ...
- cocos creator 倒计时代码 (5秒倒计时)
第一步:现在场景中 放一个 label 取个名字叫: daojishi ; 第二步:把以下代码复制到脚本添加给这个 叫 daojishi 名字的 label .(倒计时5秒) 第三步:如图 以下是代码 ...
- 【Cocos Creator 实战教程(2)】——天天酷跑(动画、动作相关)
转载请保留原文链接,个人公众号:xinshouit(新手程序员),欢迎关注 准备工作 把背景图拉长,很长很长的那种....一会我们要让它滑动起来 背景动画 为背景节点添加滚动动画 现在背景就循环滚动起 ...
- 打造丝滑的滑动视差控件(ScrollParallaxView)
1.前言 Wishing you peace, joy and happiness through the coming year. 提交祝贺大家新春快乐,狗年行大运. 2.正文 在年前跟大家分享一款 ...
- 手把手教你原生JavaScript打造丝滑流畅的轮播图,让你的网站瞬间提升用户体验
简介 轮播图是网页设计中常见的交互组件之一,用于展示多张图片或内容,让用户能够方便地浏览.切换和选择.本文将介绍如何使用原生 JavaScript 手写一个简单的轮播图,并且通过代码解释实现细节. 目 ...
- Python程序员:代码写的好,丝滑的壁纸少不了
前言 嗨喽,大家好呀~这里是爱看美女的茜茜呐 又到了学Python时刻~ 不知道大家的电脑桌面一般用的什么类型的壁纸? 早上来上班,打开电脑,被漂亮的桌面壁纸所吸引,年底将近,这又是哪个地方的节日? ...
- Cocos Creator 3.x 优量汇/广点通 android
cocos creator 接入 优量汇 (以前叫广点通): https://adnet.qq.com/http://xn--4oqq81ac4mc4rhev 本次接入三种广告: 横幅广告 (bann ...
- 3DUI Cocos Creator
分享一个小组件,实现3DUI~ 效果 使用效果.步骤.原理见视频[1] 环境 Cocos Creator 3.7.1 原理 UI相机生成一张RT,动态计算UV生成四方形网格 步骤 层级 相机 材质 组 ...
最新文章
- QT学习 之 计算器的实现
- python 多项式拟合
- javaGUI猜生日游戏
- android应用框架搭建之BaseActivity
- ajax返回类型探讨
- spark的三种运行模式以及yarn-client和yarn-cluster在提交命令上的区别
- AVFoundation – AVMetadataItem 获取媒体属性元数据
- 关于提高网页加载速度个人学习以及经验总结
- Java @Deprecated注解
- RTT时钟管理篇——阻塞延时和时基更新函数
- java的trans文件大小写_文件大小写转换与后缀不变
- SQL查询-巧用记录数统计人数
- java制作主页,JSP教程基础篇之简单首页制作
- 三星s8自带测试硬件软件,屏幕素质测试 三星S8表现较好_手机评测-中关村在线...
- UE 在场景或UMG中播放视频
- java基础篇(10) 可变参数列表介绍
- win10怎么放计算机在桌面,win10怎么把此电脑放到桌面_w10如何把此电脑添加到桌面...
- 背景图自适应屏幕大小
- 使用D435i相机跑ORB-SLAM2_RGBD_DENSE_MAP-master稠密建图编译(实时彩色点云地图加回环+保存点云地图)
- ORA-27101异常处理