欢迎阅读Godot3平台跳跃游戏实践系列文章,本系列将从创建工程开始,记录一个平台跳跃小游戏的制作过程,文章中如有错误或不妥之处欢迎指出。
上一篇文章中,我们制作了一只史莱姆来对付玩家,可惜它太弱了被玩家一脚踩扁。经过上一篇对角色代码的修改发现,随着日后角色状态的增加,代码将会越来越难维护,本篇文章将围绕使用状态机管理角色状态展开。
效果展示:
本篇涉及以下内容:
- 状态机的编写与使用
- 玩家状态管理,滑铲、死亡状态扩展
- 蜘蛛的制作
- 鬼魂的制作
状态鸡是什么?好吃吗?
状态鸡是一种烹饪状态不断变化的鸡,上一口吃到的是烤鸡、下一口吃到的就可能是炸鸡,被列为二十一世纪十大不可思议美食之一。 本文要使用的状态机指有限状态机(finite-state machine, FSM),又称有限状态自动机(finite-state automation, FSA),相信CS专业的同学对这个名词一定不陌生(回想起被编译原理支配的恐惧)。简单来说,状态机是一种包含有限个状态,并能管理这些状态之间的转移和动作的模型。在Godot中,状态机的一大用处便是角色的状态管理。角色的状态较多、转换关系较复杂、日后需要新增更多状态等情况下,使用状态机是一个不错的选择。
在上一篇文章的史莱姆脚本中,状态管理已经初见端倪。在史莱姆脚本中,我们定义了史莱姆的三种状态:静止、行走、被踩扁,三种状态的转移关系如下:
我们编写了一个set_state
函数,来设置当前的状态,并执行相应操作:
func set_state(new_state):
state = new_state # 设置当前状态
# 根据不同的状态作出处理
match state:
State.IDLING:
# 处理静止状态
State.WALKING:
# 处理行走状态
State.SQUASHED:
# 处理踩扁状态
这便是状态机的一个雏形。对于史莱姆这种简单的生物,这样写就已经很够用了,对于更加复杂的情况,我们再可以进行扩展。
再回顾上一篇的角色脚本,没有使用状态管理,对于状态的判断代码都挤在_physics_process
中,上一篇加入了一个受伤状态is_hurt
,对于它的判断就多了三处。可以发现如果不使用状态管理,仅通过变量来标记状态,会导致处理逻辑分散,不方便拓展等问题。
那么使用状态机的缺点呢?不可避免的一点便是代码量的增加,这跟引入各种软件设计模式差不多,需要根据实际情况权衡利弊,例如上面的史莱姆,一个set_state函数能搞定状态管理,就没必要用高射炮打蚊子了。
不使用状态机可以吗?完全没问题,如果你觉得使用状态机反而让代码更加复杂了,或者只是要快速编写一个小项目,或者你对代码了如指掌,不需要这种花里胡哨的东西,那完全可以不用它。
状态机编写
不管在哪种引擎中使用,状态机总有几个必不可少的元素:
- 所有状态的集合,通常是一个数组或字典
- 当前状态
- 状态处理逻辑,包含当前状态行为处理、状态转移条件判断及符合条件的状态转移操作,通常在每一帧被调用
一种写法
根据上面的描述来编写出状态机代码,状态机基类:
StateMachine.gd
extends Node
class_name StateMachine
var states := [] # 包含所有状态的数组
var state: int # 当前状态
var previous_state: int # 上一个状态
func process(delta):
"""状态机逻辑,预期在每一帧执行"""
if state != null:
# 执行当前状态行为
_do_actions(delta)
# 检查是否符合状态转移条件
var new_state = _check_conditions(delta)
if new_state != null:
set_state(new_state)
func _do_actions(delta):
"""执行当前状态行为"""
pass
func _check_conditions(delta):
"""检查当前状态转移条件,返回需要转移到的状态"""
pass
func _enter_state(state, old_state):
"""进入状态"""
pass
func _exit_state(state, new_state):
"""退出状态"""
pass
func set_state(new_state):
"""设置当前状态"""
if states.has(new_state):
previous_state = state
state = states[new_state]
if previous_state != null:
_exit_state(previous_state, state)
if state != null:
_enter_state(state, previous_state)
func set_state_deferred(new_state):
"""设置当前状态的延迟调用包装"""
call_deferred("set_state", new_state)
func add_state(new_state):
"""新增状态"""
states.append(new_state)
之前没有提到的,GDScript中支持指定变量类型,使用: 类型
将显式地指定变量类型,使用:=
赋值将隐式地指定变量类型为值的类型。带下划线的函数表示它是私有函数或虚函数。
process
函数是状态机的主要逻辑入口,其中包含了对当前状态行为的处理(_do_actions
)、状态转移条件判断(_check_conditions
)及符合条件的状态转移操作(set_state
),还包含了状态进入与退出的处理。
使用时只需要新建一个具体的状态机类,比如玩家的PlayerStateMachine
,然后实现要用到的虚函数,在其中像史莱姆脚本里那样,用match
来判断各个状态并做相应处理,之后在Player类中每一帧都调用状态机的process
函数即可。
当然还可以再改造,比如直接将状态机脚本挂在一个子节点上,重写_physics_process
或_process
函数来做每一帧的处理。
另一种写法
上面的代码中,状态的行为、转移条件判断、进入退出逻辑都是混合在一个函数中。也就是说,状态机不仅管理状态,还做状态的行为逻辑等处理。
另一种写法将职责进一步划分,状态机只负责状态的管理,而状态将独立成一个类,具体状态的行为逻辑等交给具体的状态类去做。
状态机类StateMachine.gd
extends Node
class_name StateMachine
var states := {} # 包含所有状态的字典
var state: BaseState = null # 当前状态
var previous_state: BaseState = null # 上一个状态
func process(delta):
"""状态机逻辑,预期在每一帧执行"""
if state != null:
# 执行当前状态行为
state._do_actions(delta)
# 检查是否符合状态转移条件
var new_state = state._check_conditions(delta)
if new_state != null:
set_state(new_state)
func set_state(new_state_name):
"""设置当前状态"""
if states.has(new_state_name):
previous_state = state
state = states[new_state_name]
if previous_state != null:
previous_state._exit_state()
if state != null:
state._enter_state()
func set_state_deferred(new_state_name):
"""设置当前状态的延迟调用包装"""
call_deferred("set_state", new_state_name)
func add_state(new_state):
"""新增状态"""
states[new_state._name()] = new_state
状态基类BaseState.gd:
extends Node
class_name BaseState
static func _name():
"""状态名称"""
return null
func _do_actions(delta):
"""执行状态行为"""
pass
func _check_conditions(delta):
"""检查当前状态转移条件,返回需要转移到的状态"""
return null
func _enter_state():
"""进入该状态"""
pass
func _exit_state():
"""退出该状态"""
pass
这种写法的好处是职责划分与处理逻辑更加清晰,使用时只需要让状态们继承BaseState
,实现要用到的虚函数即可。每个状态负责自己的逻辑处理,代码也就不会混在一起了。
坏处是要写的类变多了,有多少个状态就要写多少个类,并且由于各个状态难免存在公共的处理逻辑,需要再将这部分逻辑抽至父类,比较考验代码功底;另一点是,GDScript作为动态类型语言,单独的类编写起来比较吃力(缺少代码提示),还可能会遇到一些问题(比如显式类型声明导致的循环引用问题,在修了)。
给玩家角色用上状态机
虽然第二种写法看起来更好些,但考虑到代码量以及上面提到的问题,这里还是使用第一种写法。
先把玩家角色的状态图画一下,心里有点13数再编码:
状态转移条件比较容易理解就不写了(UML老师看了想打人)。
先来对玩家脚本进行改造,跟状态相关的处理都需要挪到状态机中,只留下一些通用的与状态无关的逻辑(文章最后会给出完整代码):
Player.gd
...省略一大堆成员变量...
var state_machine: PlayerStateMachine # 状态机
func _ready():
set_camera_limits() # 初始化摄像机限制范围
# 初始化状态机
state_machine = PlayerStateMachine.new(self)
state_machine.set_state_deferred(PlayerStateMachine.IDLE)
func _physics_process(delta):
# 调用状态机处理逻辑
state_machine.process(delta)
func process_velocity(delta):
"""计算速度"""
# 水平方向
if direction != 0:
# 变加速运动
velocity.x += direction * acc.x * delta
acc.x *= acceleration_rate.x
velocity.x = clamp(velocity.x, -max_speed.x, max_speed.x)
else:
# 还原加速度,以摩擦力做减速运动
acc.x = acceleration.x
velocity.x = lerp(velocity.x, 0, friction)
# 竖直方向
velocity.y += acc.y * gravity_ratio * delta
velocity.y = clamp(velocity.y, -max_speed.y, max_speed.y)
func process_movement(delta):
"""移动"""
velocity = move_and_slide(velocity, FLOOR_NORMAL)
func check_bounce():
"""检测弹跳"""
...
func bounce(speed):
"""以给定速度弹跳"""
velocity.y = speed
func take_damage(damage, bounce_force = Vector2.ZERO):
"""受到伤害,damage为伤害数值,bounce_force为受到的作用力"""
...
func set_camera_limits():
"""设置摄像机范围限制"""
...
class PlayerStateMachine extends StateMachine:
"""玩家状态机"""
...
不难发现我偷偷修改了一些运动相关的变量与逻辑,加速过程不再使用lerp
函数,而是使用变加速的方式(加速度与加速度变化率)。至于为何要这样改,变加速得到的速度曲线会比lerp
更为自然(个人感觉)。之后如果有时间应该会写一篇文章来介绍一下不同的运动实现与速度曲线的关系。
以上不是重点,可以注意到这里没有新建一个脚本文件来写PlayerStateMachine,而是直接将其写成了Player的内部类,主要原因是上文提到的循环引用问题,假如另写一个脚本文件PlayerStateMachine.gd:
extends StateMachine
class_name PlayerStateMachine
var p: Player = null # ❌运行时会报循环引用错误
var p = null # 这样没事,但没有了代码提示
...
主要是由于Player中引用了PlayerStateMachine类,而PlayerStateMachine中又引用了Player,由于Player脚本已经加载过一次了,重复的声明将导致重复加载,由此造成该错误:
当然这并不是正常现象,开发人员也表示在修了,预计在3.2.x版本会得到修复。
现在的孩子被各种智能IDE惯坏之后自然无法接受没有代码提示的生活,幸好天无绝人之路,写成内部类可以解决这个问题。PlayerStateMachine类的结构如下:
class PlayerStateMachine extends StateMachine:
"""玩家状态机"""
# 状态:静止、行走、跳跃、受击、滑铲、死亡
enum {IDLE, WALK, JUMP, HURT, SLIDE, DEAD}
var p: Player = null # 引用Player实例
var state_duration = 0 # 状态持续时间,用于状态定时切换
func _init(player: Player):
"""构造函数"""
p = player
add_state(IDLE)
add_state(WALK)
add_state(JUMP)
add_state(HURT)
add_state(SLIDE)
add_state(DEAD)
set_state_deferred(IDLE)
func _do_actions(delta):
"""执行当前状态行为"""
match state:
IDLE, WALK:
# 处理输入,检测弹跳
...
JUMP:
# 处理输入,检测弹跳
...
HURT:
# 不处理输入,控制空中与地面时的阻力
...
SLIDE:
# 处理输入,检测弹跳,累计滑铲持续时间
...
DEAD:
# 不处理输入
...
# 根据方向翻转动画、计算速度、运动等
...
func _check_conditions(delta):
"""检查当前状态转移条件,返回需要转移到的状态"""
match state:
IDLE:
# 处于地面且有移动方向则转移至行走状态
# 不处于地面则转移至跳跃状态
...
WALK:
# 没有方向输入则转移至静止状态
# 行走时按下↓则转移至滑铲状态
...
JUMP:
# 落地则转移至行走或静止状态
...
SLIDE:
# 松开方向键、改变方向、持续时间到则退出滑铲状态
...
func _enter_state(state, old_state):
"""进入状态"""
match state:
IDLE:
# 调整重力比率及摩擦力,播放相应动画
...
WALK:
...
JUMP:
# 空中受到完全重力影响
...
HURT:
# 播放受伤闪烁动画
...
SLIDE:
...
DEAD:
# 播放死亡消失动画
...
func _exit_state(state, new_state):
"""退出状态"""
match state:
HURT:
# 停止受伤闪烁动画播放
...
func set_state(new_state):
# 重写set_state函数,避免死亡后进入其他状态
if state == DEAD: return
.set_state(new_state) # 调用父类函数
func _handle_input(delta):
"""通用输入处理"""
...
func _sprite_flip():
"""通用Sprite翻转"""
...
虽然代码会多一些,但在处理滑铲、死亡等状态时就很好用。例如滑铲状态,需要处理滑铲时间控制、滑铲时的地面摩擦力、显示滑铲动画、松开方向键与方向改变时停止滑铲、滑铲时跳跃等等,未加入状态机的情况下写这些逻辑不免会有些头痛,使用了状态机后只需在相应函数做状态分支处理即可。
需要注意的是,Godot中内部类仅仅是写在脚本内部的一个类,它并不能直接访问外部成员,这也就是为什么PlayerStateMachine的构造函数中仍然要传入Player实例。
运行效果:
制作更多角色
理解了状态机的编写及使用后,就可以将它应用于更多的角色制作中了。值得一提的是,并非要一成不变地采取上面的方式来编写状态机,只要理解了状态管理思想,按照最舒适的写法去编写就行了。
蜘蛛
上一篇提到的蜘蛛,我把它做成类似以撒的结合里的那些小蜘蛛,平时在地上乱爬,角色一靠近就向角色猛冲。
蜘蛛场景结构与史莱姆差不多,不同的是多了一个感知区域Area2D
节点,用来检测玩家是否靠近,如果玩家靠近,蜘蛛就会向玩家发起攻击。
Godot支持场景继承,如果之后有更多类似于这样的角色,每次都创建同样的节点就太low了,可以先创建一个包含通用节点的父场景,然后通过菜单Scene -> New Inherited Scene...
来创建继承于它的子场景。
运行效果:
文章最后会给出完整脚本,在蜘蛛脚本中,我没有使用StateMachine
类,而是用了类似于史莱姆的状态管理方式。
鬼魂
鬼魂平时在一个区域内漂浮,如果玩家接近,将会追赶玩家。
运行效果:
与蜘蛛脚本不同的是,在鬼魂脚本中我使用了StateMachine
类来管理状态,可以对比二者的代码实现,看看这两种方法用起来有哪些不一样。
完整项目已上传至gayhub:
https://github.com/sh1n24/MiniPlatform_blog
转眼Godot已经发布3.2版本了,之后会升级到新版本进行开发。