1. 项目概述:从零构建一个会“思考”的地宫守卫
最近在鼓捣一个2D地牢探险的小项目,核心需求是做一个有巡逻、警戒、追击和攻击逻辑的“看守怪物”。一开始我尝试用一堆if-else和标志位来堆逻辑,代码很快就变成了一团乱麻,怪物行为僵硬不说,后期想加个“受伤后逃跑”的状态都无从下手。这让我下定决心,必须引入状态机来重构。
状态机不是什么新概念,你可以把它理解为一个智能灯的开关面板。灯有“关闭”、“常亮”、“呼吸”、“闪烁”几种模式,你每次只能处于其中一种模式。按“呼吸”键,就从“关闭”切换到“呼吸”模式,灯开始执行呼吸效果的逻辑。状态机之于游戏角色,就是这个开关面板,它清晰地定义了角色“能做什么”以及“在什么条件下切换到下一个行为”。对于Godot这种节点化、场景化的引擎来说,实现一个清晰、可扩展的自定义状态机,能让你的游戏逻辑获得质的提升。本文将分享我如何为Godot 4中的地宫看守怪物,从设计到实现,完整地构建一套自定义状态机,并附上可直接复用的完整源代码。
2. 状态机核心设计与思路拆解
2.1 为什么不用AnimationTree而选择自研?
Godot自带的AnimationTree配合AnimationNodeStateMachine确实是一个强大的状态机,尤其对于动画驱动型角色。但对于我这个看守怪物,状态切换的逻辑远不止动画播放。它需要:
- 复杂的条件判断:是否看到玩家(视野锥检测)、是否进入攻击范围、自身血量是否低于阈值。
- 状态专属的逻辑更新:巡逻时需要沿着路径点移动;追击时需要实时寻路;警戒时需要播放一个“?”动画并短暂停留。
- 状态间的数据传递:比如从“追击”状态切换到“攻击”状态时,需要知道玩家的当前位置。
AnimationTree的状态机更侧重于动画的混合与切换,将游戏逻辑(如AI决策)硬塞进去会显得很别扭,代码可读性会下降。而一个纯粹用GDScript编写的、面向游戏逻辑的状态机,我们可以完全掌控其生命周期和决策过程,结构更清晰,也更容易调试。
2.2 自定义状态机的架构设计
我设计的这个状态机,核心是面向对象和组合的思想。主要包含以下几个部分:
- 状态基类 (
State.gd):这是一个抽象类(在GDScript中通过虚函数实现),定义了所有具体状态必须实现的接口。这是整个状态机的“宪法”。 - 具体状态类:继承自
State,如IdleState(空闲)、PatrolState(巡逻)、ChaseState(追击)、AttackState(攻击)等。每个类只关心自己状态下的逻辑。 - 状态上下文 (
StateMachine.gd):这是状态机的管理者,它持有当前状态实例的引用,并负责在每帧调用当前状态的更新逻辑。更重要的是,它提供了状态间切换的方法。 - 宿主对象:也就是我们的看守怪物(
Guard.gd)。它拥有一个StateMachine实例,并将自身作为“上下文”传递给状态机。状态类可以通过这个上下文,获取到怪物所有的属性(如速度、动画播放器、攻击检测区域)和方法(如移动、播放动画)。
这种设计的最大好处是高内聚、低耦合。PatrolState的代码里只会写巡逻相关的逻辑,想修改追击行为,就去改ChaseState,它们之间互不干扰。添加一个新状态(比如FleeState逃跑状态),只需要新建一个继承自State的脚本,然后在状态机的切换逻辑里加上条件即可,原有代码几乎不用动。
3. 核心模块代码实现与解析
3.1 状态基类 (State.gd) 与状态机管理器 (StateMachine.gd)
这是整个系统的地基,务必保持简洁和稳定。
# State.gd - 状态基类 extends Node class_name State # 状态进入时调用。ctx是状态机传递进来的上下文(通常是怪物自身) func enter(ctx: Dictionary) -> void: pass # 状态退出时调用 func exit() -> void: pass # 每帧更新逻辑。delta是帧时间,ctx是上下文 func update(delta: float, ctx: Dictionary) -> void: pass # 物理帧更新逻辑(在_physics_process中调用) func physics_update(delta: float, ctx: Dictionary) -> void: pass# StateMachine.gd - 状态机管理器 extends Node class_name StateMachine var current_state: State = null var states: Dictionary = {} # 存储所有注册的状态实例,键为状态名 # 初始化状态机,并设置初始状态 func init(start_state_name: String, ctx: Dictionary) -> void: # 确保所有子节点(即具体状态节点)都已就绪 for child in get_children(): if child is State: states[child.name] = child # 切换到初始状态 if start_state_name in states: change_state(start_state_name, ctx) else: push_error("StateMachine: Initial state '%s' not found!" % start_state_name) # 切换到新状态 func change_state(new_state_name: String, ctx: Dictionary) -> void: if new_state_name in states: # 退出当前状态 if current_state: current_state.exit() # 切换并进入新状态 current_state = states[new_state_name] current_state.enter(ctx) else: push_error("StateMachine: State '%s' not found!" % new_state_name) # 在宿主的_process中调用 func update(delta: float, ctx: Dictionary) -> void: if current_state: current_state.update(delta, ctx) # 在宿主的_physics_process中调用 func physics_update(delta: float, ctx: Dictionary) -> void: if current_state: current_state.physics_update(delta, ctx)关键设计解析:
StateMachine本身也是一个Node,具体状态类(如PatrolState)作为它的子节点。这样,我们可以在编辑器中通过拖拽的方式向状态机添加状态节点,管理起来非常直观。ctx(上下文)参数是一个字典,我选择它是因为灵活,可以传递任意需要的参数。常见的做法是直接传递宿主对象(怪物节点)本身。
3.2 看守怪物主体 (Guard.gd) 与状态集成
怪物主体负责整合所有组件,并驱动状态机。
# Guard.gd extends CharacterBody2D class_name Guard @export var patrol_speed: float = 50.0 @export var chase_speed: float = 120.0 @export var attack_range: float = 16.0 @export var sight_range: float = 150.0 @export var fov_angle: float = 60.0 # 视野角度 @onready var animation_player: AnimationPlayer = $AnimationPlayer @onready var state_machine: StateMachine = $StateMachine @onready var ray_cast: RayCast2D = $RayCast2D @onready var patrol_points: Array = $PatrolPath.get_children() # 假设有一个Path2D节点存放位置点 var player_ref: CharacterBody2D = null # 对玩家的引用 var current_patrol_index: int = 0 func _ready() -> void: # 初始化状态机,将自身的关键属性通过字典传递给状态机上下文 var ctx = { “host” = self, “animation_player” = animation_player, “ray_cast” = ray_cast, “patrol_points” = patrol_points, “patrol_speed” = patrol_speed, “chase_speed” = chase_speed, “attack_range” = attack_range, “sight_range” = sight_range, “fov_angle” = fov_angle, “player_ref” = player_ref, “current_patrol_index” = current_patrol_index, } state_machine.init(“Patrol”, ctx) # 初始状态为巡逻 func _physics_process(delta: float) -> void: # 首先,更新一些共享的上下文信息,例如玩家是否在视野内 update_shared_context() # 然后驱动状态机进行物理更新 state_machine.physics_update(delta, {“host” = self}) func update_shared_context() -> void: # 这里实现视野检测逻辑,如果检测到玩家,则更新ctx中的player_ref # 这是一个简化示例,实际可能需要射线检测、区域检测等 var player = get_tree().get_first_node_in_group(“player”) if player and is_player_in_sight(player): player_ref = player else: player_ref = null func is_player_in_sight(player: CharacterBody2D) -> bool: # 计算距离和角度判断 var direction_to_player = (player.global_position - global_position).normalized() var distance_to_player = global_position.distance_to(player.global_position) var forward_direction = Vector2.RIGHT.rotated(rotation) # 假设怪物面朝右 if distance_to_player > sight_range: return false var angle_to_player = forward_direction.angle_to(direction_to_player) if rad_to_deg(abs(angle_to_player)) > fov_angle / 2.0: return false # 射线检测,确保中间没有障碍物 ray_cast.target_position = player.global_position - global_position ray_cast.force_raycast_update() return ray_cast.get_collider() == player实操心得:将
state_machine.update的调用放在_physics_process中而非_process,是为了让状态逻辑与物理移动同步,避免出现视觉上的抖动或逻辑不同步。update_shared_context函数集中处理了所有状态都可能关心的信息(如玩家位置),保证了数据源的唯一性。
3.3 具体状态实现:以巡逻与追击为例
让我们看看两个最关键的状态是如何工作的。
# PatrolState.gd extends State class_name PatrolState var ctx: Dictionary var host: Guard var patrol_points: Array var current_index: int var patrol_speed: float func enter(p_ctx: Dictionary) -> void: ctx = p_ctx host = ctx[“host”] patrol_points = ctx[“patrol_points”] current_index = ctx.get(“current_patrol_index”, 0) patrol_speed = ctx[“patrol_speed”] host.animation_player.play(“walk”) func physics_update(delta: float, p_ctx: Dictionary) -> void: # 更新上下文,可能player_ref已变化 ctx.merge(p_ctx, true) # 1. 条件判断:如果发现玩家,切换到追击状态 if ctx[“player_ref”]: host.state_machine.change_state(“Chase”, ctx) return # 2. 巡逻逻辑:向当前目标点移动 if patrol_points.is_empty(): return var target_point: Vector2 = patrol_points[current_index].global_position var direction: Vector2 = (target_point - host.global_position).normalized() host.velocity = direction * patrol_speed host.move_and_slide() # 更新面向方向 if direction.x != 0: host.scale.x = sign(direction.x) * abs(host.scale.x) # 3. 判断是否到达目标点 if host.global_position.distance_to(target_point) < 5.0: current_index = wrapi(current_index + 1, 0, patrol_points.size()) ctx[“current_patrol_index”] = current_index # 更新上下文,供下次进入时使用 func exit() -> void: # 巡逻状态退出时,可以停止行走动画或重置一些临时变量 host.animation_player.stop()# ChaseState.gd extends State class_name ChaseState var ctx: Dictionary var host: Guard var chase_speed: float var attack_range: float func enter(p_ctx: Dictionary) -> void: ctx = p_ctx host = ctx[“host”] chase_speed = ctx[“chase_speed”] attack_range = ctx[“attack_range”] host.animation_player.play(“run”) # 假设有奔跑动画 func physics_update(delta: float, p_ctx: Dictionary) -> void: ctx.merge(p_ctx, true) var player = ctx[“player_ref”] # 1. 条件判断:如果丢失目标,回到巡逻状态 if not player: host.state_machine.change_state(“Patrol”, ctx) return # 2. 条件判断:如果进入攻击范围,切换到攻击状态 if host.global_position.distance_to(player.global_position) <= attack_range: host.state_machine.change_state(“Attack”, ctx) return # 3. 追击逻辑:向玩家移动 var direction: Vector2 = (player.global_position - host.global_position).normalized() host.velocity = direction * chase_speed host.move_and_slide() # 更新面向方向 if direction.x != 0: host.scale.x = sign(direction.x) * abs(host.scale.x) func exit() -> void: host.animation_player.stop()代码细节解析:在
enter函数中,我们将传入的上下文ctx保存为成员变量,并从中解构出需要的参数(如host,patrol_speed)。在physics_update中,我们首先用ctx.merge(p_ctx, true)来合并最新的上下文(主要是为了获取最新的player_ref),然后进行状态转移的条件判断,最后才是本状态的专属逻辑(移动)。这种“判断先行”的模式确保了状态响应的及时性。
4. 状态机系统的扩展与高级技巧
4.1 实现攻击状态与状态间数据传递
攻击状态通常包含攻击动画播放、伤害判定和冷却。这里的关键是如何从“追击”状态平滑过渡到“攻击”,并确保攻击动作完成后能回到正确状态。
# AttackState.gd extends State class_name AttackState var ctx: Dictionary var host: Guard var attack_cooldown: float = 1.0 var cooldown_timer: float = 0.0 var is_attacking: bool = false func enter(p_ctx: Dictionary) -> void: ctx = p_ctx host = ctx[“host”] cooldown_timer = attack_cooldown is_attacking = false # 立即开始攻击动画,动画最后一帧会触发一个“attack_finished”信号 host.animation_player.play(“attack”) host.animation_player.connect(“animation_finished”, _on_attack_animation_finished) func physics_update(delta: float, p_ctx: Dictionary) -> void: ctx.merge(p_ctx, true) var player = ctx[“player_ref”] if not is_attacking: # 攻击动画播放期间,可以停止移动或进行其他处理 host.velocity = Vector2.ZERO host.move_and_slide() # 攻击冷却计时 cooldown_timer -= delta if cooldown_timer <= 0 and not is_attacking: # 冷却结束,且当前攻击动画已播放完,判断下一步 if player and host.global_position.distance_to(player.global_position) <= ctx[“attack_range”] * 1.2: # 玩家仍在附近,再次攻击 cooldown_timer = attack_cooldown host.animation_player.play(“attack”) is_attacking = true else: # 玩家已远离,切换回追击或巡逻 host.state_machine.change_state(“Chase” if player else “Patrol”, ctx) func _on_attack_animation_finished(anim_name: String) -> void: if anim_name == “attack”: is_attacking = false # 在这里可以进行伤害判定,例如调用host的一个apply_damage()函数 host.apply_damage_to_player() func exit() -> void: host.animation_player.disconnect(“animation_finished”, _on_attack_animation_finished) host.animation_player.stop()注意事项:攻击状态的管理比移动状态更复杂,因为它涉及到动画时序。我采用了“动画驱动事件”的方式:通过连接
animation_finished信号来精确知道攻击动作何时结束,从而进行伤害判定和状态切换判断。cooldown_timer用于控制攻击频率,防止一帧内触发多次攻击。
4.2 设计一个“警戒”状态增加行为层次
一个聪明的守卫不会在丢失目标后立刻回到漫无目的的巡逻。增加一个AlertState(警戒状态)可以大大提升怪物的行为真实感。
# AlertState.gd extends State class_name AlertState var ctx: Dictionary var host: Guard var alert_duration: float = 2.0 var timer: float = 0.0 var last_known_player_position: Vector2 func enter(p_ctx: Dictionary) -> void: ctx = p_ctx host = ctx[“host”] timer = alert_duration # 记录玩家最后消失的位置 last_known_player_position = ctx[“player_ref”].global_position if ctx[“player_ref”] else host.global_position host.animation_player.play(“alert”) # 播放一个“?”或“警惕”的动画 # 可以在这里播放一个音效 func physics_update(delta: float, p_ctx: Dictionary) -> void: ctx.merge(p_ctx, true) # 警戒期间,面朝玩家最后出现的位置 var direction = (last_known_player_position - host.global_position).normalized() if direction.x != 0: host.scale.x = sign(direction.x) * abs(host.scale.x) # 计时 timer -= delta if timer <= 0: # 警戒时间结束,回到巡逻 host.state_machine.change_state(“Patrol”, ctx) return # 警戒期间如果再次发现玩家,立刻追击 if ctx[“player_ref”]: host.state_machine.change_state(“Chase”, ctx) func exit() -> void: host.animation_player.stop()这个状态通常在ChaseState丢失目标(player_ref变为null)时触发,而不是直接回Patrol。这让怪物看起来像是在思考“他跑哪儿去了?”,行为更具层次感。
4.3 使用黑板(Blackboard)优化上下文管理
当状态增多、需要传递的数据变复杂时,使用一个简单的字典作为上下文可能会变得难以维护。我们可以引入一个黑板模式来集中管理所有共享数据。
# Blackboard.gd extends Node class_name Blackboard # 使用字典存储所有共享数据 var data: Dictionary = {} func set_value(key: String, value) -> void: data[key] = value func get_value(key: String, default = null): return data.get(key, default) func has_key(key: String) -> bool: return data.has(key)然后在Guard.gd中初始化一个Blackboard节点,并传递给状态机。
# Guard.gd (部分修改) @onready var blackboard: Blackboard = $Blackboard @onready var state_machine: StateMachine = $StateMachine func _ready() -> void: # 初始化黑板数据 blackboard.set_value(“host”, self) blackboard.set_value(“patrol_speed”, patrol_speed) # ... 设置其他数据 blackboard.set_value(“current_state”, “”) # 甚至可以存储当前状态名 state_machine.init(“Patrol”, blackboard) # 在状态类中,通过ctx(现在是一个Blackboard实例)来存取数据 func enter(ctx: Blackboard) -> void: host = ctx.get_value(“host”) speed = ctx.get_value(“patrol_speed”)黑板模式使数据流动更加清晰和类型安全(虽然GDScript是动态类型),也便于调试,你可以随时打印出黑板上的所有内容来查看AI的“思维”。
5. 调试技巧与常见问题排查
5.1 状态可视化与调试输出
在开发过程中,我们迫切需要知道怪物当前处于什么状态。一个简单有效的方法是在StateMachine.change_state时打印日志,并在屏幕上显示当前状态。
# StateMachine.gd (修改change_state方法) func change_state(new_state_name: String, ctx: Dictionary) -> void: if new_state_name in states: print(“[StateMachine] %s -> %s” % [current_state.name if current_state else “None”, new_state_name]) if current_state: current_state.exit() current_state = states[new_state_name] current_state.enter(ctx) # 更新黑板或直接设置一个标签文本 if ctx is Blackboard: ctx.set_value(“current_state”, new_state_name) else: push_error(“StateMachine: State ‘%s’ not found!” % new_state_name)在怪物场景中,添加一个Label节点,并在_process中更新其文本为当前状态名,这样在运行游戏时就能实时看到状态切换。
5.2 常见问题与解决方案
状态“卡住”,不切换
- 检查条件判断:确保触发状态切换的条件(如
player_ref不为空、距离判断)在预期情况下为真。使用print或断点调试输出关键变量的值。 - 检查状态机更新:确认你在怪物的
_physics_process中正确调用了state_machine.physics_update(delta, ctx)。 - 检查上下文合并:在状态的
update函数中,你是否正确合并了最新的上下文(ctx.merge(p_ctx, true))?可能新的player_ref没有传递进来。
- 检查条件判断:确保触发状态切换的条件(如
动画与状态不同步
- 动画信号连接/断开:像
AttackState中,确保在enter时连接信号,在exit时断开信号,防止信号重复连接或调用已销毁状态的方法。 - 动画名拼写错误:
animation_player.play(“walk”)中的”walk”必须与AnimationPlayer中创建的动画名称完全一致,注意大小写。
- 动画信号连接/断开:像
性能问题
- 视野检测优化:
is_player_in_sight函数每帧调用可能开销较大,尤其是射线检测。可以考虑每几帧(例如每5帧)进行一次精细检测,或者使用Area2D配合Physics Layers进行粗略的触发检测。 - 状态实例化:我们的设计是在编辑器中将状态节点预先添加好,运行时没有实例化开销。这是最佳实践。
- 视野检测优化:
状态逻辑混乱
- 遵循单一职责:确保每个状态只做一件事。
PatrolState只负责巡逻和发现玩家后切换,不要在里面处理攻击逻辑。 - 善用黑板:当需要在多个状态间传递复杂数据(如“玩家最后已知位置”、“仇恨目标列表”)时,尽早引入黑板模式,避免状态间直接耦合。
- 遵循单一职责:确保每个状态只做一件事。
这套自定义状态机方案,我已经在几个不同的2D动作和RPG项目中复用,效果非常稳定。它最大的优势就是清晰。无论是时隔数月回头修改,还是与团队其他成员协作,基于状态机的AI代码都像一本结构清晰的说明书,远比面条式的条件判断要好维护得多。你可以根据项目需求,轻松地添加FleeState(逃跑)、StunState(眩晕)、TalkState(对话)等状态,让游戏角色的行为丰富起来。