ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Godot 4 NPC行为系统实战:基于有限状态机的巡逻追踪攻击实现

Godot 4 NPC行为系统实战:基于有限状态机的巡逻追踪攻击实现 做游戏开发时NPC 行为往往是项目中最容易失控的部分。早期我写过一段怪物 AI用的是多个if嵌套判断玩家靠近就追击、距离太远就回去巡逻、血量低了就逃跑。刚开始逻辑简单还能撑住等需求一多巡逻、警戒、攻击、技能释放、死亡动画全都堆在一个脚本里改一个状态就牵连另外三四个状态调试时不停打印日志也看不清角色到底处于什么阶段。后来把项目重构为有限状态机之后整个行为的扩展性和可读性明显提升。这篇文章就围绕 Godot 4 的 NPC 行为系统展开从概念到完整实战带着大家一步步实现巡逻、追踪、攻击三个核心状态并补充状态切换的工程化思路。本文适合刚接触 Godot 游戏开发的初学者也适合在项目里已经写过简单 NPC 逻辑、想进一步把行为结构整理清楚的开发者。读完后你不仅能写出一个可运行的 FSM有限状态机Finite State Machine框架还会理解为什么状态拆分比if-else更利于后续维护。1. 游戏 AI 与有限状态机很多新手会把“游戏 AI”想得很神秘觉得 AI 就是让角色像真人一样思考。但在游戏开发领域AI 的本质更接近于“规则驱动”。我们给 NPC 设计一系列条件和行为让它根据当前环境和自身属性做出合理反馈给玩家一种“这个角色有想法”的体验。常见的游戏 AI 技术包括有限状态机、行为树、导航网格、战棋式寻路、GOAP 目标导向规划等。1.1 有限状态机的核心概念有限状态机是一种数学模型它由三个基本要素组成状态State表示某一时刻实体所处的模式例如“待机”“巡逻”“攻击”。事件Event或条件Condition触发状态切换的外部输入或内部判断例如“玩家进入警戒范围”。动作Action在当前状态下执行的逻辑例如“朝目标移动”“播放动画”“扣减血量”。举一个生活化的例子一台电风扇有“关闭”“低速”“中速”“高速”四个状态按下遥控器上的按钮就是触发事件每次切换都会执行对应的转速动作。游戏 NPC 的 AI 本质上也遵循同样的模式只是状态数量更多、切换条件更复杂。1.2 为什么推荐用 FSM 管理 NPC 行为在没有状态机的时候我们往往用一个脚本承载所有逻辑。例如# 不推荐大量 if 嵌套导致逻辑混乱 if health 0: die() elif can_see_player(): if distance attack_range: attack() else: chase() elif out_of_home(): return_home() else: patrol()当状态少时这种写法勉强能用。一旦加入“巡逻路线”“警戒值”“技能冷却”“受击反馈”每个分支之间还会交叉影响代码会越来越难读。FSM 的核心优势是把每个状态独立成对象或独立函数状态之间互不干扰需要扩展新行为时只需要添加一个新状态不用翻动整块逻辑。FSM 也有局限当 AI 的行为层级很深、需要记忆多个条件组合时FSM 会退化成“状态爆炸”。比如一个 NPC 同时需要处理饥饿、睡眠、工作、社交状态数量会快速增长。这时更适合引入行为树Behavior Tree或分层状态机。但作为绝大多数动作游戏、塔防游戏、模拟经营项目的基础FSM 已经足够实用而且上手门槛低。2. 环境准备与版本说明本文基于 Godot 4.x 编写使用 GDScript 语言完成演示。Godot 4 在节点系统、动画播放、物理碰撞方面相比 3.x 有较大改动因此代码不能直接用于 Godot 3。2.1 版本选择与环境配置你可以从 Godot 官网下载 Godot 4 的标准版本。下载后解压即可运行不需要额外安装运行时。本文示例使用 Godot 4.2 版本演示如果你的版本是 4.0 或 4.1大部分代码也通用少数 API 调用需要根据官方文档微调。建议使用如下环境操作系统Windows 10 / 11、macOS 或 Linux 均可。Godot 版本4.x 系列。语言GDScript。编辑器Godot 内置编辑器。有部分用户会遇到“Godot 下载打不开”的问题常见原因有两个一是系统缺少必要的运行库二是下载的版本与操作系统位数不匹配。Godot 4 官方提供了 64 位和 32 位的 Windows 版本如果你的电脑是旧 32 位系统请选择对应的 32 位构建。macOS 系统还需要在首次运行时右键应用图标选择“打开”绕过系统安全限制。2.2 演示项目结构规划在创建项目之前先规划目录结构。一个清晰的文件组织能让后续扩展更顺畅npc_fsm_demo/ ├── project.godot ├── scenes/ │ ├── main.tscn │ └── npc/ │ ├── npc.tscn │ └── player.tscn ├── scripts/ │ ├── npc/ │ │ ├── npc.gd │ │ ├── states/ │ │ │ ├── state.gd │ │ │ ├── patrol_state.gd │ │ │ ├── chase_state.gd │ │ │ └── attack_state.gd │ └── player.gd └── assets/这个结构把状态脚本独立放到了states文件夹中NPC 主脚本只负责“持有当前状态”和“调用状态逻辑”这样职责边界会非常清晰。3. 核心实现从状态基类到状态切换在编写具体状态前我们先抽象出一套“状态基类”。所有具体巡逻、追踪、攻击状态都继承它从而保证统一的接口。3.1 状态基类的设计状态基类中我们定义三个核心方法enter()进入状态时调用适合播放进场动画、记录初始位置、初始化计时器。process(delta)每帧调用适合放置状态内持续执行的逻辑例如检测玩家距离、移动角色。exit()离开状态时调用适合清理计时器、恢复移动速度、停止动画。为什么需要单独的enter和exit因为状态切换时有些逻辑只应该执行一次。例如从“巡逻”进入“追踪”时NPC 应该立刻面向玩家播放警觉动画如果这个动作写在每帧执行的process中就会反复触发。下面是状态基类代码# 文件路径scripts/npc/states/state.gd class_name State extends Node # 当前状态持有的 NPC 引用 var npc: CharacterBody2D # 当前状态的状态机引用 var state_machine: Node # 是否需要每帧调用 process var active : false func setup(npc_node: CharacterBody2D, fsm_node: Node) - void: npc npc_node state_machine fsm_node func enter() - void: active true func exit() - void: active false func process(_delta: float) - void: # 子类按需重写 pass这里把状态脚本设计成了Node类型而不仅仅是RefCounted。原因是我们在场景中会把每个状态挂为 NPC 节点的子节点这样在 Godot 编辑器的节点面板中就能直观看到当前 NPC 注册了哪些状态调试时也方便查看每个状态节点的属性。同时Node可以使用get_tree()轻松访问场景树上的其他节点这在 AI 中非常常见。3.2 状态机脚本状态机本身不关心具体业务逻辑它只负责三件事注册状态、指定初始状态、切换状态。代码如下# 文件路径scripts/npc/npc_state_machine.gd class_name NPCStateMachine extends Node signal state_changed(state_name: String) # 预加载状态脚本 const PatrolStateScript : preload(res://scripts/npc/states/patrol_state.gd) const ChaseStateScript : preload(res://scripts/npc/states/chase_state.gd) const AttackStateScript : preload(res://scripts/npc/states/attack_state.gd) var current_state: State func _ready() - void: _register_states() change_state(Patrol) func _register_states() - void: var state_names : [Patrol, Chase, Attack] for state_name in state_names: var state_script: Script match state_name: Patrol: state_script PatrolStateScript Chase: state_script ChaseStateScript Attack: state_script AttackStateScript _: continue var state_node: State state_script.new() state_node.name state_name state_node.setup(get_parent(), self) add_child(state_node) func change_state(new_state_name: String) - void: if current_state and current_state.name new_state_name: return if current_state: current_state.exit() current_state get_node_or_null(new_state_name) as State if current_state: current_state.enter() state_changed.emit(current_state.name)_register_states里用了preload加载状态脚本创建状态节点时把 NPC 节点和状态机自身传给状态状态对象就能直接访问 NPC 的方法和属性。get_parent()在状态机脚本中取到的是挂载状态机的 NPC 节点因为状态机的父节点就是 NPC 场景的根节点。3.3 为什么不用枚举管理状态很多入门教程会用枚举定义状态enum State { PATROL, CHASE, ATTACK }枚举方案在简单项目中确实直观但它的问题在于状态逻辑依旧堆积在同一个脚本里无法把每个状态拆成独立文件。当项目变大团队协作时容易产生冲突。利用节点 类继承的方式每位开发者可以只维护自己负责的状态脚本甚至在编辑器里给特定状态添加不同的导出参数。4. 完整实战让 NPC 感知玩家现在我们把 FSM 系统应用到一个具体场景中。场景包含一个玩家角色和一个 NPC玩家可以移动NPC 会在巡逻点和追踪之间自动切换。4.1 创建项目与主场景打开 Godot新建一个空项目命名为npc_fsm_demo。在文件系统中创建scenes和scripts目录结构参照 2.2 节。接着创建主场景根节点使用Node2D保存为scenes/main.tscn。为了让视觉反馈更明显我给主场景添加一个简单的背景颜色和一个说明标签。由于主场景不是本文重点这里不做复杂地图绘制用一个ColorRect铺底色即可。4.2 玩家角色脚本玩家使用CharacterBody2D通过 WASD 或方向键移动。代码如下# 文件路径scripts/player.gd extends CharacterBody2D export var speed : 200.0 func _physics_process(_delta: float) - void: var input_direction : Input.get_vector(left, right, up, down) velocity input_direction * speed move_and_slide()在使用这个脚本前需要先在 Godot 的项目设置中添加入方向键映射。在Project Settings - Input Map中添加以下动作left绑定 A 键和左方向键。right绑定 D 键和右方向键。up绑定 W 键和上方向键。down绑定 S 键和下方向键。玩家场景还需要一个碰撞体和可见外观。最快捷的方式是在玩家节点下添加CollisionShape2D和一个ColorRect或Sprite2D。4.3 NPC 角色与状态脚本先创建 NPC 场景节点结构如下NPC (CharacterBody2D) ├── CollisionShape2D ├── Sprite2D (或 ColorRect) ├── VisionArea (Area2D) │ ├── CollisionShape2D ├── NPCStateMachine (Node)NPC 脚本负责定义基础属性例如移动速度、攻击距离、巡逻点同时提供状态脚本需要调用的公共方法# 文件路径scripts/npc/npc.gd extends CharacterBody2D export var move_speed : 100.0 export var attack_damage : 10 export var attack_range : 25.0 export var vision_range : 180.0 var home_position: Vector2 var target_player: Node2D onready var sprite: Sprite2D $Sprite2D onready var vision_area: Area2D $VisionArea func _ready() - void: home_position global_position vision_area.body_entered.connect(_on_vision_body_entered) vision_area.body_exited.connect(_on_vision_body_exited) func _physics_process(_delta: float) - void: move_and_slide() func move_to(target_position: Vector2, speed: float) - void: var direction : global_position.direction_to(target_position) velocity direction * speed if direction.x 0: sprite.flip_h true elif direction.x 0: sprite.flip_h false func face_to(target_position: Vector2) - void: var direction : global_position.direction_to(target_position) if direction.x 0: sprite.flip_h true elif direction.x 0: sprite.flip_h false func _on_vision_body_entered(body: Node2D) - void: if body.is_in_group(player): target_player body func _on_vision_body_exited(body: Node2D) - void: if body target_player: target_player null这里有一个关键点视觉范围使用了Area2D而不是在状态脚本中每帧计算 distance。使用 Area2D 的好处是物理引擎会帮我们监听进入和退出事件不需要每一帧遍历场景中的所有玩家节点性能更友好。当玩家进入圆形范围时body_entered信号就会触发。4.4 巡逻状态巡逻状态最基本的实现是在两个或多个点之间往返移动。为了简化演示这里让 NPC 在自身出生点左右两侧巡逻。# 文件路径scripts/npc/states/patrol_state.gd extends State var current_target_index : 0 var patrol_points: Array[Vector2] [] func _ready() - void: pass func enter() - void: super() # 以 NPC 的初始位置为中心向左和向右各延伸一格 var center : npc.global_position patrol_points [ center Vector2(-80, 0), center Vector2(80, 0) ] current_target_index 0 func process(delta: float) - void: if patrol_points.is_empty(): return var target : patrol_points[current_target_index] var distance_to_target : npc.global_position.distance_to(target) if distance_to_target 5.0: current_target_index (current_target_index 1) % patrol_points.size() npc.move_to(target, npc.move_speed * 0.5) # 检测玩家切换状态 if npc.target_player: var distance_to_player : npc.global_position.distance_to(npc.target_player.global_position) if distance_to_player npc.attack_range: state_machine.change_state(Attack) else: state_machine.change_state(Chase)细节说明进入巡逻状态时我们把 NPC 的出生点作为基准重新生成巡逻点避免 NPC 回程后巡逻区域偏移。(current_target_index 1) % patrol_points.size()实现了数组循环索引。4.5 追踪状态追踪状态的核心有两个一是让 NPC 朝玩家移动二是判断是否满足攻击条件或脱离仇恨范围。# 文件路径scripts/npc/states/chase_state.gd extends State export var lose_distance : 220.0 func enter() - void: super() func process(delta: float) - void: if not npc.target_player: state_machine.change_state(Patrol) return var distance_to_player : npc.global_position.distance_to(npc.target_player.global_position) if distance_to_player lose_distance: # 超出追击范围回到巡逻 state_machine.change_state(Patrol) return if distance_to_player npc.attack_range: state_machine.change_state(Attack) return npc.move_to(npc.target_player.global_position, npc.move_speed) func exit() - void: super() npc.velocity Vector2.ZEROlose_distance通常要大于视觉范围。否则玩家刚离开视野NPC 就会立刻放下仇恨回到巡逻状态追击体验会很奇怪。合理的设置是看到玩家的距离为 180丢失仇恨的距离为 220这样能给 NPC 一个“追出去一段路”的缓冲。4.6 攻击状态攻击状态负责在攻击范围内输出伤害同时加入冷却时间限制避免 NPC 每帧都攻击玩家。# 文件路径scripts/npc/states/attack_state.gd extends State export var attack_cooldown : 1.0 var attack_timer : 0.0 var is_attacking : false func enter() - void: super() attack_timer 0.0 func process(delta: float) - void: if attack_timer attack_cooldown: attack_timer delta return if not npc.target_player: state_machine.change_state(Patrol) return var distance_to_player : npc.global_position.distance_to(npc.target_player.global_position) if distance_to_player npc.attack_range: state_machine.change_state(Chase) return attack_timer 0.0 do_attack() func do_attack() - void: # 在实际项目中这里可以调用受击函数播放挥砍动画 print(NPC 对玩家造成 , npc.attack_damage, 点伤害)为了让攻击有节奏感攻击冷却默认设置 1 秒。理论上我们还可以加入前摇动画、后摇动画它们都属于状态内部逻辑在enter中播放动画、在exit中停止即可。4.7 场景装配代码都准备好之后回到 Godot 编辑器完成场景装配。创建玩家场景挂载player.gd和CollisionShape2D将玩家加入player分组这样 NPC 的Area2D信号才能识别它。创建 NPC 场景挂载npc.gd添加CollisionShape2D、VisionArea圆形检测区域、NPCStateMachine子节点。在 NPC 场景中挂载一个普通的Node脚本节点指定脚本为npc_state_machine.gd作为 NPC 的子节点。在主场景中把玩家和 NPC 都实例化出来运行游戏。注意npc.gd中使用了$Sprite2D路径因此 NPC 场景必须有一个名为Sprite2D的子节点。如果没有这个节点运行时会报错。4.8 运行与验证点击运行后预期的效果是玩家靠近 NPC 的视觉范围时NPC 从巡逻切换到追踪。玩家被追上且进入攻击范围后NPC 开始按 1 秒一次的频率攻击。玩家远离 NPC 超过丢失仇恨距离后NPC 回到巡逻模式。你还可以在状态机脚本里添加一行调试输出让状态切换过程可视化# 放在 state_changed 信号处理函数中 func _on_state_changed(state_name: String) - void: print(当前状态切换为, state_name)观察输出日志你会看到类似这样的内容当前状态切换为Patrol 当前状态切换为Chase 当前状态切换为Attack5. 状态机设计的进阶扩展初版 FSM 跑通后接下来可以考虑扩展它的能力。以下扩展方向在真实项目里非常常见。5.1 加入受击与死亡状态很多战斗游戏中NPC 受到玩家攻击后应该播放硬直动画不能继续追击这就是“受击状态”和“死亡状态”的职责。加入受击状态后攻击状态检测到玩家出招时会切到 HitHit 持续 0.3 秒后回到追击。死亡状态则直接禁用碰撞体不再响应任何输入。状态数量增加后最好在_register_states里用字典注册状态脚本减少match分支。5.2 状态机可视化调试编写游戏 AI 时你很难单靠print看清状态切换过程。推荐在调试面板上绘制当前状态比如在 NPC 头顶添加一个Label子节点在state_changed信号里更新文本或者使用 Godot 的tool脚本在编辑器中实时预览。另一个更专业的做法是保存一段“状态切换历史”。每次切换状态时把当前时间和状态名追加到数组发生 Bug 后打印历史记录能快速定位是哪一步异常跳转导致的。5.3 FSM 与行为树的取舍当 NPC 行为具备明显的前置条件依赖时行为树可能比 FSM 更合适。比如一个 NPC 要执行“如果玩家血量低于 30%使用治疗药水否则继续攻击”这个条件只控制单次行为用 FSM 需要额外增加“治疗状态”。行为树则通过选择节点和条件节点直接表达这条规则。对于中小型项目FSM 和状态模式已经足够只有当你需要维护几十种复杂 AI 行为时行为树才值得引入。Godot 4 社区也提供了行为树插件感兴趣的话可以在 4.x 基础上继续研究。6. 常见问题与排查思路6.1 Godot 下载打不开问题现象常见原因解决思路双击 exe 没有反应系统缺少显卡驱动或运行库安装最新显卡驱动关闭杀毒软件重新解压macOS 提示已损坏系统安全策略拦截右键应用选择“打开”或前往“系统偏好设置 - 隐私与安全性”允许打开后黑屏崩溃显卡不支持 Vulkan在项目设置中把渲染器改为 OpenGL / Compatibility版本与系统不匹配下载了 32 位版本但 CPU 架构不同根据操作系统选择对应构建6.2 NPC 不追踪玩家如果玩家进入了Area2D范围但 NPC 仍然纹丝不动优先按下列顺序排查玩家是否加入了player分组。分组的名称必须与npc.gd中的is_in_group(player)完全一致。Area2D的CollisionShape2D是否配置了正确的圆形形状以及是否勾选了Monitorable。玩家节点是否有CollisionShape2D如果玩家只有视觉图形而没有物理碰撞体body_entered信号永远不会触发。6.3 NPC 抖动明显NPC 到达目标点时如果速度持续向目标方向推进但位移非常小就会出现抖动。解决方式有两种一是设置一个小阈值距离小于阈值时直接修正位置并取下一个目标点二是使用move_toward函数限制单帧移动距离。var next_position : npc.global_position.move_toward(target, speed * delta) npc.global_position next_position6.4 状态切换过于频繁当玩家站在攻击范围边界时NPC 可能在 Chase 和 Attack 之间来回横跳。解决方案是加入切换冷却时间或者使用“滞回比较”的思路进入攻击状态需要的距离阈值是attack_range但退出攻击状态要等到距离大于attack_range * 1.2。这样边界区域不会导致反复切换。7. 最佳实践与工程建议7.1 状态的粒度控制不要把“攻击”编成一个大而全的状态建议拆成“攻击前摇”“攻击命中”“攻击后摇”。攻击前摇阶段可以加入面向锁定攻击命中阶段才真正发射伤害检测。这样玩家看到怪物的挥砍动作会更有层次感也方便后续增加闪避和弹反机制。7.2 数据驱动配置将状态参数抽离到 Godot 的export变量中不同怪物通过场景资源的差异生成完全不同的 AI。同一套状态机代码可以同时服务于近战小兵和远程射手只需要在巡逻状态中配置不同巡逻范围、攻击距离、移动速度。这对项目复用是大加分项。7.3 状态切换的唯一入口无论状态内部使用什么方式发出切换请求最终都必须经过state_machine.change_state()。不要直接在状态脚本里修改current_state属性否则状态退出逻辑无法统一执行计时器和动画残留问题会很难排查。7.4 AI 与表现层分离AI 逻辑中尽量不直接控制动画、音效等表现层内容。状态脚本通过信号通知表现层播放动画例如attack_started信号由状态发出动画播放器监听后决定播放哪段攻击动画。这样做的好处是 AI 和美术表现可以并行开发也方便在更换角色模型时单独调整动画配置。8. 总结与后续学习路线本文演示的 NPC 行为系统虽然只实现了巡逻、追踪、攻击三个状态但它已经具备了状态机的完整骨架。你可以在不改变整体架构的前提下不断加入更多 NPC 能力远程怪物增加“施法状态”BOSS 增加“召唤状态”受控单位增加“冰冻状态”。下一步建议尝试这些练习设计一张有障碍物的地图让 NPC 追踪玩家时绕过墙体需要引入 Godot 的NavigationServer2D。将攻击状态改为触发玩家扣血的真实战斗加入血量 HUD 和死亡状态。在状态机中加入随机偏航让同一行为的多个 NPC 表现不完全一致提升真实感。如果你正在用 Godot 做自己的游戏建议先把这套 FSM 模板保存下来后续新角色直接复用再通过export配置不同参数效率会高很多。喜欢这篇实战笔记的话可以收藏备用下一篇我会继续展示如何把巡逻路径改为路径点组件让 NPC 在任意地图路线上巡逻。
返回列表