【Godot4自学手册】第二十七节自定义状态机完成看守地宫怪物

本节,我将使用自定义状态机实现看守地宫怪物,完成了基础类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场景。
请添加图片描述

然后调整到需要的位置。
请添加图片描述

最后看一下效果:
请添加图片描述

这节就到这了,下节见。

最近更新

  1. TCP协议是安全的吗?

    2024-03-23 17:24:01       16 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-03-23 17:24:01       16 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-03-23 17:24:01       15 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-03-23 17:24:01       18 阅读

热门阅读

  1. 哈夫曼de树

    2024-03-23 17:24:01       20 阅读
  2. 探索与利用:ε-greedy策略的魅力

    2024-03-23 17:24:01       17 阅读
  3. 5.80 BCC工具之tcpconnect.py解读

    2024-03-23 17:24:01       21 阅读
  4. 面试(二)

    2024-03-23 17:24:01       13 阅读
  5. odoo中,使用paramiko库ssh连接Linux

    2024-03-23 17:24:01       16 阅读
  6. AWS ECS安全更新及自动化应对方案

    2024-03-23 17:24:01       19 阅读
  7. Android 封装的工具类

    2024-03-23 17:24:01       20 阅读
  8. Oracle修改Number类型精度报错:ORA-01440

    2024-03-23 17:24:01       17 阅读