原文地址:[Tutorial/Example] Finite State Machines: The most awesome thing in the history of ever.
原作者:PixelatedPope
译者注:之前HeartBeast的2D横版教程里曾经有一集介绍过简单的状态机,之前FC妹子也做过状态机的视频,我搬了生肉其实自己也没仔细看,这两天因为自己想尝试一下,而且群里之前也有好几个朋友都问过状态机的原理和机制,恰好找到了这篇教程,虽然是前两年的老文章,示例也是GMS1.4的,但对状态机介绍还是蛮清楚的,希望大家喜欢
嗨~诸位,也许有些人还记得我之前一篇关于状态机的教程,但考虑到状态机的使用恰好是本月挑战中的“专家级挑战”,我觉得是时候再写一篇更正式的教程了。在这个教程里,我们会简单复刻一些马里奥世界的内容。
最终效果的演示:状态机效果演示
项目文件:网盘下载
我不会逐行去介绍代码,但是应该足够让大家明白基本原理并实践操作了。让我们正式开始吧。
有限状态机:这是个啥?
如果你有关注YoYo Games的官方技术博客,也许你曾经见过这篇文章,这篇文章很好的解释了什么事状态机,强烈对此有兴趣的朋友仔细阅读,不过我会先简单定义一下。
一个有限状态机(后文以状态机缩写指代)是一种特殊的组织代码的方式,用这种方式你能确保你的对象随时都知道自己所处的状态以及所能做的操作。其中每一个状态都是独立的代码块,与其他不同的状态分开独立运行,这么做可以使得游戏的调试变得更加方便,同时也更易于增加新的功能(比如一些特殊的能力和动画之类)。玩家角色在跳跃的时候看起来有点奇怪?那就直接去“跳跃”的状态里找问题吧!
同样这个机制也可以用于给敌人实现基本的AI逻辑,让敌人可以根据状态做出不同的决策。
恰好状态机机制是本月挑战里的“专家级”难度,但我十分希望当我刚开始学习编程时就能了解这个知识点,正确运用状态机可以为你免去很多不必要的麻烦。接下来我们就来看一下如何使用吧。
有限状态机适合我的项目吗?
这个问题俨然是“世上没有愚蠢的问题”这句话的最佳反证。状态机系统永远适合你的项目,这个问题可以修改成这样“状态机是否适合我这个对象?”确实,并不是所有的对象都需要用上状态机机制,但你可能会惊讶地发现有那么多对象都适合使用状态机去进行管理。
显然,可控的角色和敌人都需要使用状态机,但实际上我的游戏控制器对象也采用了这一机制,用来区分在主菜单、设置菜单和关卡选择等不同的场景的用途,甚至我的镜头控制器也用了状态机,比方说“跟随玩家状态”,“过场动画状态”和“显示特定对象状态”等等。
那么如何才能确认某个对象需要使用状态机呢?其实非常简单,对于每个对象都要问一问自己:“这个对象可以做些什么?”
如果这个对象需要处理超过2件事情以上的功能,那你就应该考虑去做一个状态机。让我们来试着问一下这个问题,比如马里奥,尤其是在超级马里奥世界里,马里奥可以做什么呢?
他可以:
- 站立
- 行走/跑
- 躲闪
- 跳
- 爬墙
显然上面这个列表还有更多没列出来的,但这是个好的开始。因此,显然马里奥有很多事情可做,而且几乎所有这些事情都是独立的状态,那你现在就已经有了一个对象应该要做的功能列表了,现在是时候画一个流程图了。
认真脸:流程图。
不开玩笑,港真,流程图是你的好伙伴。下面是几个示例(从最上面那个链接里借用的)
设计好流程图并梳理好你所有需要处理的状态是非常重要的第一步。在你正式开始编码之前,你需要制定出基本的状态和各自的规则。你不需要彻底搞清楚你的角色能做到每一个操作,这是状态机最棒的特性,它总是易于扩展,但是基本的设计是非常重要的。
OK,设计好了。怎么实现呢?
最好的办法当然是一头扎进去然后直接动手了,是吧?为了让这个过程变得更轻松简单,我做了一个小脚本可供使用
让我们来看一下这个脚本并了解一下它是怎么工作的:
state_machine_init()
当你在创建需要使用状态机的对象时,可以在Create事件中调用这个脚本。它会创建一对数据结构和一系列十分有用的变量。现在我们先来看看这些变量
- state - 这个变量是当前状态的标志位。这个脚本中将会包含在对象step事件中执行的代码。
- state_next - 当我们切换状态时,我们希望在切换之前当前状态能执行完毕,因此我们调用这个脚本来切换状态,同时更新这个值,然后上述"state"变量将在"End Step"事件中发生变化。
- state_name - 这个变量用于获取保存当前状态的名称(创建时设定的名称)
- state_timer - 这个变量用于记录当前状态持续的“step”数(即运行了多少帧),实用程度绝对超乎想象
- state_map - 一个ds_map数据结构,把你设定的状态名称作为键名保存进来,
- state_stack - 一个用来记录你历史状态的数据结构。可以用来实现一些状态机的进阶功能,比如变回到之前的状态。
- state_new - 这是个非常有用的变量,当你切换到某个状态时,有可能你希望该状态处于初始化状态,比如速度设为0,或者更新精灵等等,这些情况十分常见。这时候你只需要在状态的最开始将这个值设为“true”即可完成这一切操作。
- state_var[0] - state_var 这个变量比较特别。这是一个用来存储某个状态的持续时间的数组。因为我发现我自己经常会有这样的需求——“我想要清楚地跟踪并记录这个状态下的一些信息……但是别的状态没这种需求”那我该怎么办?每次有这种需求的时候都新建一个变量?这不是很荒谬嘛?所以,我用“state_var”来作为替代品,把这个数组作为针对该状态的一个便笺本,或是剪贴板,取决于你的用法。这个数组可以记录我所需要的值,因此我不必新建变量来进行记录,可能有点说不太清楚,但这个非常有用。
如果你下载并仔细看了我提供的脚本,你可能会注意懂我在里面放了一些建议性的变量(也许你的游戏用得着)。比如,通常情况下"state_can_interrupt"或"state_is_invincible" 这些变量都会派得上用场。
State_create("state name",state_script)
当状态机引擎实例化以后,我们需要创建我们自己的状态。比如说,我们要创建几个马里奥的状态,那我们就可以像下面这样操作
state_create("Stand",state_mario_stand); //调用"state_mario_stand"脚本处理“站立”状态
state_create("Walk",state_mario_walk); //调用"state_mario_walk"脚本处理“行走”状态
state_create("Air",state_mario_air); //调用"state_mario_air"脚本处理“空中”状态
state_create("Crouch",state_mario_crouch); //调用"state_mario_crouch"脚本处理“蹲下”状态
一个对象可以创建任意多的状态,尽可能根据你的需求去随意创建即可
state_init("State Name")
一旦创建好所有的状态,现在就可以来设置对象初始的默认状态了,对于马里奥而言,这应该是站立状态
state_init("Stand"); //把“站立”状态设置为默认初始状态
非常简单
state_execute()
这是状态机的核心,在“step”事件中调用这个方法就可以调用你当前状态的脚本
state_update()
这个脚本应当放在“end step”事件中调用,用于处理不同状态之间的切换
state_cleanup()
这个脚本调用最好放在“destroy”事件里。因为之前我们在处理状态机的过程中会创建一些数据结构,因此当对象实例被销毁时应当彻底销毁那些数据结构来释放内存。
重要提醒 也许你还不知道,当你切换“room”时,如果你的非持久化对象(没有勾选“persistant”)具备“destory”事件,那在切换场景的时候这个对象会直接消失但是并不触发“destory”事件里的代码,因此,如果你的游戏中会出现这种情况,请务必谨慎处理。
此处强调,GMS2中有一个新的事件“clean up”,该事件可以确保在跳转room之类的操作后仍然可以执行清除动态资源的操作,因此在GMS2中可以吧这个脚本的调用放在“clean up”中,就不会出现这个提醒中所说的问题了,感谢群友提醒:)
state_switch("State Name" or state_script)
这个方法是用来在不同状态间进行切换的。你可以把创建状态时起的名字或状态的脚本名作为参数(推荐使用名字更直观)。比如当马里奥在站立状态下,我可能会按下方向键来控制他下蹲:
if(keyboard_check_pressed(vk_down))
switch_state("Crouch");
你也可以用相同的方法去利用左右方向键来控制走动状态,或用跳跃键来使他执行跳跃状态的脚本。
state_switch_previous()
之前提过,这是状态机的进阶功能。在某些情况下,你可能需要对象恢复到上一阶段的状态。比如我有一个角色拥有施放咒语的状态,并且有另一个状态是击中时被击退,他有可能在任何状态下被击中:站立、行走、下蹲甚至丢道具等等,但当他被“击退”后不能总是让他恢复成站立状态,我希望他能恢复到被击退之前的状态。那在这种情况下,这个脚本就能派上用场了,这个利用了"state_stack"这一数据结构
好吧,也许用说的还不够清楚,下面这个图可以简要的说明具体的使用情况
你可看到"create"、"step"、"end step"、"destory"等不同的事件中的代码,这是状态机系统的基础设置,这其实非常容易,其复杂程度取决于你的状态到底有多复杂。
动手编写一个状态
让我们从最简单的状态开始:“站力状态”. 打开文章开头的那个工程链接并下载下来打开,然后找到“ Scripts>Platform Boy States>pb_state_stand”。
然后你会发现我写了一行"if(state_new)". 让我们来看一下这个状态里都做了些什么. 我把所有的速度变量都设为0,并确保对象处于默认精灵下(马里奥步行精灵的第一帧就是站立状态),为了确保他确实显示的是第一帧海拔image_index设为了0。只要我处于站立状态,所有这些值都应当保持如此,这样就没必要反复去设置了。
在第12行,你会看到我正在检测输入操作,看马里奥是不是马上就要撞墙,我倾向于在状态脚本运行之前先检测用户的输入操作。这不是100%必要的,但是做一次检测然后去调用状态脚本中的内容是个很好的习惯。
为什么我要在12-13行检测碰撞呢?因为如果我不检测碰撞只单纯运行控制事件,那当我们向左或向右朝着墙走过去就会走到墙里去,我不希望马里奥钻进墙里去,所以必须时刻检测,当你试图操作马里奥钻进墙壁时立刻进行切换,因此这个检测在你切换到“行走”状态前是必须完成的。
接下来,我会检测是否按下了跳跃按钮。如果按下了跳跃键,那就会切换到“state_air”空中状态,并将y速度设置为“jump_strength”。因为我的空中状态没有区分下落和跳跃,仅仅是在空中,所以要跳起来的话我需要设置跳跃的力度。
你可能会注意到,在这里我用了两个“if”来处理“state_switch”(嗯,你注意到这点了吗?)没错,设想一下如果现在我们冲着没有墙的方向走去,同时按下了跳跃键……会发生什么呢?所以,当这种情况发生时最后被下达的指令优先级应当更高。因此在这种情况下我会执行跳跃的操作而不是向左走。你可以简单的按照你代码中的顺序来排列不同状态的优先级,25行就是一个示例。
在这里,我正在检测马里奥的脚底的地面。比如当我站在地面上时,也许因为一些原因下面的地面突然消失了,那现在他就要掉下来,而此时应当切换到“空中”状态,迫使他掉下去,并且优先级要高于跳跃或落下。
当然,在同一时间任意组合所有的操作可能性也不大,但是正确梳理不同状态之间的优先级和关系是十分必要的,可以避免很多不必要的意外状况。
这就是我的站立状态中所需要的全部功能,但想一想我们还可以在里面加些什么东西?比如我们可以检测马里奥当前是不是站在一个移动的平台或者传送带上。如果是,那我会获取这个对象的速度,并根据实际情况将这个速度设到马里奥的x轴或y轴的速度上,以便他可以跟着平台运动。而当我处于“空中”状态时则完全不用操心这些问题,甚至我都用不着做相关的判断和检测。但是在“行走”状态下还是需要考虑这种情况的,所以也许你也要考虑在行走状态的脚本中添加相应的检测代码。
尾声
这篇文章写得有点长,我也不确定你是否能从我的代码中得到更多启发(我的碰撞检测有一点疯狂,此处不展开)。所以,自己试玩一下范例工程,然后尝试建立自己的状态机角色,如果有任何问题欢迎私信或给我留言。
当你第一次开始使用状态机时,你可能觉得你之前学编程知识都要被丢掉了。因为不再需要像“on_ground”或“can_jump”这样的变量了。 如果你处于站立状态,你就能确认自己正在地面上并一定能进行跳跃操作......其他状态下则肯定不是站立状态,没错吧? 请务必信我这一次,状态机机制十分有用,值得你学习和尝试去根据自己的需求实现一个定制版的状态机系统,请相信我,这是一个令人难以置信的有价值的东西。 不要放弃,不断续尝试,不断改进,最后你一定会实现你想要的效果。
希望这些内容能派得上用场,而不是单纯耗费了你的时间。再次声明,如果有任何问题可以私信或给我留言。
很荣幸你能读到这里,现在亲自去试试看吧!
将所有状态存储在一个枚举类,然后通过将状态名当成传入参数执行程序吗?那怎么判断它当前是否符合进入该状态的条件,if else的判断吗?有点乱
@越陌丶度阡:看习惯吧……感觉这种做法主要是可以把功能模块独立开避免耦合度过高然后互相干扰影响,事实上如果都写在一起不用状态机,这种判断的if else也是避免不了的
@越陌丶度阡:干嘛用if else 直接一个状态一个类即可,继承统一个接口,切换状态就是切换对象。不过现在都有playmaker这种有限状态机插件了,直接使用就好,从个人使用情况来看,我认为制作AI 有限状态机比行为树好用
那么无限状态机是什么?想了解一下
@Cuerzor:大概只存在于理论或者模型里吧……就像无穷大到底是多大一样
@顺子:可不可以让一个obj拥有两台状态机,一台控制人物整体的状态,一台控制人物手部(如攻击)的状态?
@Cuerzor:应该是没问题,不过复杂度就更高了,文章里这个脚本就不够用了需要自定义处理一些东西了