作者:Nathan Ranney
翻译:highway★
按照设计,状态机一次只能处于一种状态。 由我们来定义对我们的情况有意义的状态,以及它们之间的关系。 在本文中,我们将使用状态机来控制在任何给定时间可用的玩家操作,允许我们设置角色并定义角色可以执行的操作。
大家好, 今天我想告诉你如何设置一个简单的状态机。 状态机是一种数据结构,顾名思义,它跟踪不同的状态。 例如,我们的游戏可能有三种状态:“游戏运行”,“游戏暂停”和“游戏结束”。我们可能会使用状态机来记住哪一个处于活动状态,并定义如何从一个状态转换到另一个状态(请参阅 上面的图片)。
基础设置
此条目需要比上一个篇文章(绘制精灵)多一些动画,因此在开始之前,你需要将这些动画添加到工程中。 从此链接下载精灵并将其添加到你的项目中。 我已经恰当地命名了文件,因此只要确保精灵的名称与文件名相匹配,就可以将其添加到 GMS 中。 继续添加所有精灵 - 甚至是敌人的精灵 - 因为我们将在以后的文章中需要这些精灵。 确保每个精灵的原点是 (16,32)
。
枚举,控制器和持久性
为了设置我们的状态机,我们首先确定哪些状态是可能的,以及我们如何在代码中识别它们。 由于这个例子都是关于角色动作的,所以让我们定义那些动作是什么,并给每个动作一个整数 id
。 最简单的方法是使用 enum
(枚举,Enumeration 的缩写),它是在自定义变量类型下保存的常量的集合。 如果你熟悉 Gamemaker 中的 Macros(宏),那么枚举就是这样的。 我更喜欢使用枚举,因为它们比宏更容易管理和跟踪。
创建一个脚本并将其命名为enum_init。 添加以下行。 //states enum states { normal, crouch, attack, hit }
请注意,我们不必设置每个条目的值,枚举自动将值0指定为“正常”,然后在每个条目后递增。 我们可以随时获得值
var example = states.attack; show_message(string(example)); //output: 3
实际上,你可以通过为每个条目指定值来覆盖枚举的自动编号,但重要的是要重申枚举是常量。 它们定义后无法更改!
枚举也是全局的,这意味着任何对象都可以访问它们。 这对我们的状态机来说非常完美。
现在我们有了枚举,我们在哪里实例化它? 从非持久对象调用这些变量,比如我们的 oPlayer 对象,不是最好的主意。 我们想要做的是创建一个持久的控制器对象(它始终存在),以管理许多对象可以访问的枚举和其他数据类型之类的东西。 继续创建一个新对象并将其命名为“con”(译注:建议还是 oController 这样与其他对象保持相同的命名前缀,在看代码时会比较易读。)。 我喜欢保持我的控制器名称简短,因为它更容易返回。选中新对象上的“持久(Persistent)”框。 最后,将 Create 事件添加到对象,并添加以下面的代码:
///init 初始化 enum_init();
将 con
对象放在你的房间里。 由于此对象是持久的,因此除非您明确销毁,否则它将继续存在! 无需在每个房间放置此物体。
Switch cases
既然我们已经在枚举中定义了状态,我们就可以从我们的玩家对象中访问它们了。 打开我们在上一个条目中创建的 oPlayer 对象,并将以下行添加到 create
事件中。
attack = false; //states currentState = 0; lastState = 0; //movement xSpeed = 0; ySpeed = 0; lastSprite = sprite;
将 End Step 事件添加到 oPlayer,然后添加一些代码。
xPos = x; yPos = y; x += xSpeed; y += ySpeed; //animation frame_reset();
现在让我们跳到 step
事件。 我们可以删除我们在之前那篇文章中添加的几乎所有代码,因为大多数代码只是为了展示 draw_sprite_ext 的不同部分。 查看下面的代码,并确保你的 step
事件看起来完全相同。
//buttons player_buttons(); //animation frame_counter(); //state switch switch currentState { case states.normal: normal_state(); break; case states.crouch: crouch_state(); break; case states.attack: attack_state(); break; }
如果你之前从未见过 switch
语句,你可能会想知道到底发生了什么。 我稍后会解释,但首先我们需要创建三个新脚本:normal_state,crouch_state 和 attack_state。 我喜欢使用不同状态的脚本,因为它使代码更容易阅读。 你可以弹出所需的任何脚本(译注:在 GMS2 中,在代码中的脚本上按下鼠标中键即可弹出对应的脚本,并链接在当前对象窗口),并在该特定状态下工作。
好吧,所有这一切究竟意味着什么呢? 什么是 switch
语句以及它是如何工作的? 将 switch
语句视为 if
语句的更具体版本。if
语句用于布尔值检查,条件满足则执行,switch
语句用于根据变量的值执行代码。 看看下面的代码块。
//if statement if(currentState == states.normal) { normal_state(); } else if(currentState == states.crouch) { crouch_state(); } //switch statement switch currentState { case states.normal: normal_state(); break; case states.crouch: crouch_state(); break; }
这两段代码在功能上都是相同的。 它们都将根据 currentState
变量的当前值运行我们想要的脚本,但 switch
语句要清晰得多。 当我们添加状态时,使用 if
语句变得难以管理。 switch
语句更容易管理。
最后,我们需要在 player_buttons 脚本中添加一个新的按钮变量。 打开该脚本并添加此行:
attack = keyboard_check_pressed(ord("Z"));
状态机
我们已经定义了一个可能状态的枚举,以及变量 currentState 来跟踪哪个状态是活动的。 现在我们知道了 switch
语句的工作原理,我们可以创建在每个状态下执行的代码,以及在它们之间进行转换的规则。 switch
语句可以很容易地显示我们的状态机是什么以及它正在做什么。 如果我们的 currentState 变量等于语句中的一个 case
,则执行与该 case
相关的代码。 由于我们为每个状态创建了脚本,因此请继续打开 normal_state 脚本并添加以下代码
//移动 if(left) { xSpeed = -2; } else if(right) { xSpeed = 2; } else { xSpeed = 0; } //切换到下蹲状态 if(down) { currentState = states.crouch; } //切换到攻击状态 if(attack) { currentState = states.attack; }
这段代码非常简单。 对我来说,正常状态意味着角色的默认状态。 他们没有执行任何特殊操作,例如攻击或使用道具,玩家可以完全控制角色。 在这里,我们有左右移动,并转换到蹲和攻击状态。 如果你现在运行游戏,你将无法看到我们的状态机的全部效果。 如果你按 下
或 Z
,你将改变状态,不再能够移动。 接下来让我们定义蹲状态。 打开 crouch_state 脚本并添加以下代码:
xSpeed = 0; if(!down) { currentState = states.normal; }
蹲下时(按住向下箭头键)我们停止玩家的水平移动(xSpeed = 0
)。 如果他们释放向下键,我们将返回正常状态。 这将是一个在蹲下时添加不同动作的好地方,比如爬行或者可能是蹲下的攻击。
打开我们创建的最后一个状态脚本,attack_state,并添加以下代码:
xSpeed = 0; if(frame > sprite_get_number(sprite) - 1) { currentState = states.normal; }
我们再次将水平速度归零,并且当动画结束时我们将玩家状态设置回正常。 但是......我们还没有设置我们的动画,是吗? 动画控制是状态机和 switch
的另一个重要用途! 创建一个新脚本并将其命名为 animation_control。 添加以下代码:
xScale = approach(xScale,1,0.03); yScale = approach(yScale,1,0.03); //动画控制 switch currentState { case states.normal: if(left) { facing = -1; } else if(right) { facing = 1; } if(left || right) { sprite = sprPlayer_Run; } else { sprite = sprPlayer_Idle; } break; case states.crouch: sprite = sprPlayer_Crouch; break; case states.attack: sprite = sprPlayer_Attack; break; } //如果精灵更改,则将帧重置为0 if (lastSprite != sprite) { lastSprite = sprite; frame = 0; }
通过使用另一个 switch
语句,我们可以轻松控制播放器动画。 请注意,我们可以在 switch
case
中使用 if
语句! 我们没有将动画控制与我们创建的初始 switch
语句组合在一起的原因有几个。 首先,我们希望我们的动画在所有代码的最后发生。 动画是之前发生的一切的结果! 其次,它让代码更好读。 上面代码底部的最后一个表达式会在精灵更改时将帧重置为 0
。 这可以防止在更改精灵时动画在错误的帧上启动。
请注意,我们将 xScale
和 yScale
代码移动到 animation_control
的顶部。 这对以后很重要。
在 oPlayer 对象中打开 end step
事件,并将以下行添加到代码的底部。 这将确保它在其他一切之后发生。
animation_control();
来吧,运行游戏。 你应该有一个能够左右奔跑,空闲,蹲和攻击的角色了! 我们能够根据当前状态区分角色的行为。 除了管理优势之外,设置状态机还可以更轻松地跟踪错误,添加新行为以及跟踪对象的整体结构。 我经常使用状态机和 switch
语句来控制比如要显示的菜单屏幕,当前的游戏模式以及定义给敌人的 AI 类型等内容。
标题那个图在《游戏编程模式》(Robert Nystrom ) 介绍状态模式的时候也提到了,@highway★,可以看看。
我刚用以前别人那个脚本改的差不多了……