本节,我将使用自定义状态机实现看守地宫怪物,完成了基础类State,状态机类StateMachine的编码,实现了怪物的闲置巡逻类、追踪类和攻击类,以及对应动画等。这节代码有点多,不过还好,代码比较简单。最终效果如下:
一、基本概念
状态机(State Machine)是有限状态自动机的简称,是指一个数学模型,通常体现为一张状态转换图。
基本组成
有限状态机主要由以下几个部分组成:
1.状态(State): 状态是有限状态机的一个基本元素,代表了系统在某一时刻的一种情况。
2.输入(Input): 输入是系统从一个状态转换到另一个状态的触发条件。有限状态机在接收到特定的输入时,会从当前状态转换到另一个状态。
3.输出(Output): 输出是状态机在执行某个动作或处理某个输入时产生的结果。在状态转换中可能会伴随有输出。
4.状态转换(Transition): 状态转换描述了状态机在接收到特定输入时,从当前状态迁移到下一个状态的过程。
5.初始状态(Initial State): 初始状态是系统开始运行时的状态。
6.终态(Final State): 终态(也称为接受状态或终止状态)是系统执行完毕或处理结束后的状态。
工作原理
有限状态机的工作原理可以概括为:
1.系统开始于初始状态。
2.接收到输入后,根据当前状态和输入,确定下一个状态。
3.进入新状态后,执行该状态对应的动作(如果有)。
4.重复上述过程,直到到达终态。
应用
有限状态机因其结构简单、逻辑清晰,在多个领域有广泛的应用:
计算机科学: 在编译原理中用于词法分析,在操作系统中管理进程状态等。
控制理论: 在自动化控制系统中,用来设计控制器。
通信系统: 在数字通信中,用于编码和同步。
软件工程: 在游戏开发、用户界面设计等领域中管理复杂的逻辑。
有限状态机是理解复杂系统行为的一种强有力的工具,通过将系统的行为分解为一系列的状态和转换,可以更容易地分析和设计系统。
二、基础代码编写
1.添加状态类代码
新建脚本文件保存在Class文件夹下命名为:State.gd,这是个状态类基础代码,定义了一个状态改变信号Transitioned,和四个基本函数进入函数Enter()、退出函数Exit()、更新函数Update()、物理更新函数Physics_Update(),只是定义了函数没有函数内容,当继承时具体填写内容。代码如下:
extends Node
class_name State
signal Transitioned
func Enter():
pass
func Exit():
pass
func Update(delta:float):
pass
func Physics_Update(delta:float):
pass
2.添加有限状态机代码
新建脚本文件保存在Class文件夹下命名为:StateMachine.gd。顾名思义,这是对敌人的各种状态进行管理的一个类,代码中有具体功能注释。代码如下:
extends Node
@export var inital_state:State #初始状态
var current_state:State #当前状态
var states:Dictionary={} #状态字典
func _ready():
#完成状态字典数据
for child in get_children():
if child is State:
states[child.name.to_lower()]= child
child.Transitioned.connect(on_child_transition) #连接信号到本页脚本
#设置初始状态
if inital_state:
inital_state.Enter() #调用状态进入函数
current_state = inital_state
pass
func _process(delta):
#调用当前状态更新函数
if current_state:
current_state.Update(delta)
func _physics_process(delta):
#调用当前状态物理更新函数
if current_state:
current_state.Physics_Update(delta)
#状态改变信号调用函数,第一个参数表示目前处于状态,也就是进入新的状态有哪个状态发起的;第二个参数表示要进行新状态的名称
func on_child_transition(state,new_state_name):
#如果传入的状态部署当前状态,退出信号
if state!=current_state:
return
#根据状态名称调出状态数据字典中对应的状态
var new_state = states.get(new_state_name.to_lower())
#如果状态数据字典中不存在对应的状态退出
if !new_state:
return
#退出当前状态,调用状态退出函数
if current_state:
current_state.Exit()
#进入新的状态,调用进入函数
new_state.Enter()
#将当前状态设置为新的状态
current_state = new_state
这样我们有效状态机的基础代码就写好了。
三、敌人的各种状态代码
在我们的文件系统重新建一个States文件夹用来保存各种状态。在该文件下新建EnemyState文件夹来保存敌人的状态代码。
1、空闲巡逻代码
新建脚本文件保存在States->EnemyState文件夹下命名为:EnemyIdle.gd。代码如下:
extends State #继承基本状态类
class_name EnemyIdle #类名称
@export var enemy:CharacterBody2D #敌人,出现在该类的检查器,可拖入敌人的CharacterBody2D对象
@export var move_speed:=30.0 #敌人移动速度,出现在该类的检查器
@export var anima:AnimatedSprite2D #敌人播放动画类,出现在该类的检查器
var player:CharacterBody2D #玩家对象
var move_direction:Vector2 #敌人移动方向
var wander_time #敌人巡逻时间
#随机巡逻函数,产生随机方向和巡逻时间
func randomize_wander():
#产出敌人随机移动方向
move_direction = Vector2(randi_range(-1,1),randi_range(-1,1)).normalized()
#敌人此方向随机巡逻时间
wander_time = randf_range(1,3)
#状态进入时调用的函数
func Enter():
#在主目录中根据分组查询主人公对象
player = get_tree().get_first_node_in_group("Player")
randomize_wander()#调用随机巡逻函数
func Update(delta:float):
if wander_time>0:#如果该方向巡逻时间大于0,巡逻时间减去delta时间
wander_time -=delta
else:#如果敌人在方向巡逻时间完成,从新产生巡逻随机方向和时间
randomize_wander()
func Physics_Update(delta:float):
#获取敌人和主人公之间的方向和距离
var direction= player.global_position-enemy.global_position
#如果敌人和主人公之间的方向和距离大于跟踪距离,敌人进行巡逻状态
if direction.length()<25:
Transitioned.emit(self,"Attack")#如果敌人和主人公之间的方向和距离小于攻击距离,发出攻击信号
return
elif direction.length()<100:
Transitioned.emit(self,"Follow")#如果敌人和主人公之间的方向和距离处于跟踪距离,发出跟踪信号
return
if enemy:
enemy.velocity = move_direction * move_speed#设置敌人的速度
if enemy.velocity==Vector2.ZERO:#如果敌人的速度为0,播放休闲动画
anima.play("Idle")
else:#如果敌人的速度不为0,播放行走动画
anima.play("Walk")
2、跟踪状态代码
新建脚本文件保存在States->EnemyState文件夹下命名为:EnemyFollow.gd。代码如下:
extends State #继承基本状态类
class_name EnemyFollow #类名称
@export var enemy:CharacterBody2D #敌人,出现在该类的检查器,可拖入敌人的CharacterBody2D对象
@export var move_speed:=30.0 #敌人移动速度,出现在该类的检查器
@export var anima:AnimatedSprite2D #敌人播放动画类,出现在该类的检查器
var player:CharacterBody2D #玩家对象
#状态进入时调用的函数
func Enter():
#在主目录中根据分组查询主人公对象
player = get_tree().get_first_node_in_group("Player")
pass
func Update(delta:float):
pass
func Physics_Update(delta:float):
#计算敌人与主人公之间的方向和距离
var direction= player.global_position-enemy.global_position
if direction.length()>100:#如果敌人与主人公之间的距离未达到跟踪范围,发出空闲巡逻状态信号
Transitioned.emit(self,"Idle")
return
if direction.length()<25:#如果敌人与主人公之间的距离进入攻击范围,发出攻击状态信号
Transitioned.emit(self,"Attack")
return
if anima:#如果动画设置不为空,播放行走动画
anima.play("Run")
enemy.velocity = direction.normalized() * move_speed #设置行走速度
3、攻击状态代码
新建脚本文件保存在States->EnemyState文件夹下命名为:EnemyAttack.gd。代码如下:
extends State #继承基本状态类
class_name EnemyAttack #类名称
@export var enemy:CharacterBody2D #敌人,出现在该类的检查器,可拖入敌人的CharacterBody2D对象
@export var anima:AnimatedSprite2D #敌人播放动画类,出现在该类的检查器
var player:CharacterBody2D #玩家对象
#状态进入时调用的函数
func Enter():
#在主目录中根据分组查询主人公对象
player = get_tree().get_first_node_in_group("Player")
func Physics_Update(delta:float):
enemy.velocity= Vector2()
#计算敌人与主人公之间的方向和距离
var direction= player.global_position-enemy.global_position
#如果敌人与主人公之间的距离大于100,发出空闲巡逻状态信号
if direction.length()>100:
Transitioned.emit(self,"Idle")
elif direction.length()>25:#如果敌人与主人公之间的距离达到跟踪范围,发出跟踪状态信号
Transitioned.emit(self,"Follow")
if anima:#播放攻击动画
anima.play("Attack")
四、应用到场景中
新建CharacterBody2D场景,存到Scenes文件夹下,命名为Monster。为场景添加相关节点。
1.添加AnimatedSprite2D节点。
添加AnimatedSprite2D节点,命名为Anima。在其检查器中,选择Animation->Sprite Frames属性,下拉菜单中选择新建SpriteFrames。选中该属性,在动画帧面板中讲default命名为Idle,单击从精灵表中添加动画帧按钮,在弹出的打开文件对话框中选择我们准备的敌人图片素材,如下:
在弹出的选择帧面板中将水平设置为4,垂直设为5,这是根据我们敌人图片对应进行设置的,因为我们的敌人图片正好是5行4列。然后选择0-3帧图片,最后单击添加帧按钮。
然后在动画帧面板中开启循环和自动播放按钮,如下:
这样就完成了等待动画。下面单击添加动画按钮,命名为Walk,然后跟制作等待动画类似完成行走动画;依此类推完成跑动动画Run、攻击动画Attack,这里面有个细节需要说一下,行走动画为图片素材的第2行、跑步动画为图片素材的第3行;攻击动画为图片素材的4和5行。这3个动画都不需要开启自动播放,但是行走动画、跑步动画需要开启循环,攻击动画不需要开启循环。跑步动画和攻击动画设为8FPS,动画变快,游戏显得更合理些。
2.添加CollisionShape2D节点
添加CollisionShape2D节点,命名为Collision。在其检查器中,选择CollisionShape2D->Shape属性选择新建CapsuleShape2D(椭圆形碰撞),然后在场景中将椭圆形调整合适大小和位置。
3.添加Node2D节点
一是添加Node2D节点,命名为StateMachine。然后单击为选中节点创建或设置脚本按钮,选择我们前面编写好的代码StateMachine.gd。
然后在检查器中,将Inital State设置为Idle状态。
二是选择StateMachine节点,单击添加子节点按钮,然后在创建节点对话框中选择EnemyIdle节点,该节点重命名为Idle。
在检查其中将Enemy设置成Monster根节点;Anima设置成该场景中Anima节点。
三是与二方法类似添加EnemyFollow和EnemyAttack节点,重命名为Follow和Attack。在检查器中对应设置Enemy和Anima属性,最终节点目录如下:
4.根节点添加脚本
选择Monster跟节点,单击为选中节点创建或设置脚本按钮,把脚本保存到Scripts目录,命名为Monster.gd。编写如下代码:
extends CharacterBody2D
@onready var anima = $Anima #获取动画
func _physics_process(delta):
if velocity.x<0:#如果速度小于0,翻转动画
anima.flip_h=true
else:
anima.flip_h= false
move_and_slide()
5.主场景中调用
切换到Main主场景中,单击实例化子场景,选择Monster场景。
然后调整到需要的位置。
最后看一下效果:
这节就到这了,下节见。