☰
Godot 4.x有限状态机实战:为NPC打造智能巡逻追击行为系统
2026/10/5 16:14:53 网站建设 项目流程

这次我们来看一个不用写大量复杂 AI 逻辑,也能让 NPC 行为变得“聪明”的项目方向:在 Godot 游戏引擎里用有限状态机(FSM)搭建智能 NPC 行为系统。

如果你正在做独立游戏,或者正在学 Godot,想要解决“NPC 只会站桩、巡逻、看见玩家就发呆”这类问题,这篇文章可以直接收藏。我们会先讲清楚状态机能解决什么,再给出一套可以在 Godot 4.x 里直接跑的 GDScript 实现思路,包括状态定义、状态切换、玩家检测、巡逻与追敌、批量 NPC 复用,最后补充调试方法和常见坑。

这不是某个收费插件,而是游戏 AI 设计里的经典方案。它的核心价值在于:把 NPC 的复杂行为拆成一个个独立状态,每个状态下只做一件事,再由状态机根据条件切换。代码可读性、扩展性和调试体验都比“堆 if else”好一个档次。

1. 核心能力速览

能力项说明
项目类型Godot 游戏引擎中的 NPC 行为系统设计实践
核心模式有限状态机(FSM),也适合扩展为行为树
主要功能NPC 巡逻、追击、返回、待机、攻击前摇等状态管理
支持平台Windows、macOS、Linux、Android、Web 等 Godot 支持的导出平台
是否需要 GPU 训练不需要,这是引擎内逻辑代码,不涉及训练显存
启动方式通过 Godot 编辑器一键运行场景
是否需要 API不需要外部 API,状态机是纯本地脚本
是否支持批量任务支持,同一状态类可复用到多个 NPC 节点
适合场景2D/3D 动作游戏、RPG、恐怖游戏、塔防游戏等 NPC 行为控制

这套方案的优点是轻量。你不需要装外部依赖,不涉及显存占用,不需要调推理接口。只要 Godot 项目能用 GDScript 跑起来,就能用这套状态机。它的重点不是“AI 模型”,而是“行为控制逻辑”。

2. 适用场景与使用边界

2.1 适合谁用

  • 用 Godot 做独立游戏,需要给 NPC 设计巡逻、警戒、追击行为的开发者。
  • 已经会写基础 GDScript,但面对多个 NPC 行为时代码逐渐混乱的开发者。
  • 想系统学习游戏 AI 设计,从有限状态机入门的同学。
  • 需要做游戏 Demo、Game Jam、课程设计的学生。

2.2 能解决什么问题

最典型的问题是:NPC 动作看起来“呆”。

常见情况是,玩家靠近 NPC 后,NPC 没有任何反应;或者 NPC 巡逻到墙角后一直撞墙;又或者 NPC 的 AI 逻辑全部写在一个_process里,代码几百行,加一个新行为就要改原来一大片逻辑。

使用有限状态机后,这些问题会被拆开:

  • 同一个 NPC 也只需要在特定时刻执行一种状态。
  • 每个状态代码都放在独立文件中,修改巡逻逻辑不会影响追击逻辑。
  • 新增“巡逻”、“追击”、“返回出生点”只需要在状态机上添加新的状态节点。

2.3 不适合什么场景

  • 如果 NPC 需要复杂决策,比如多目标权衡、动态规划、长期记忆,纯 FSM 会变得难维护。
  • 如果 AI 行为需要大量随机组合和团队协作,可以考虑行为树(Behavior Tree)或规划系统。
  • 如果追求“学习型 AI”,比如让 NPC 在运行中学习玩家习惯,FSM 不适合,需要强化学习或外部 AI 服务。

2.4 使用边界与合规提醒

游戏 NPC 行为本身不涉及版权风险,但如果你的游戏里用到真实人物形象、声音、名字或品牌素材,必须确认素材授权。尤其是发布到 Steam、微信小游戏或商业平台时,NPC 肖像、语音、世界观设定都不能随意使用未授权素材。

另外,如果你把 NPC 对话或行为接入外部大模型 API,要注意用户隐私和数据合规,不要在没有明确提示的情况下收集玩家语音、人脸等个人信息。

3. 环境准备与前置条件

3.1 安装 Godot

开发有限状态机 NPC 不需要特殊硬件,任何能运行 Godot 的电脑都行。不过更稳妥的配置是:

  • CPU:双核以上即可。
  • 内存:4GB 以上。
  • 显卡:Godot 默认兼容性渲染器可以跑在多数集成显卡上。
  • 磁盘:1GB 左右即可。

推荐下载 Godot 4.x 标准版,官网下载后解压即可使用,不需要安装。注意区分标准版和 .NET 版,如果不用 C#,选标准版就够了。

如果下载后打不开,先检查:

  • 是否下载了完整文件。
  • 是否双击.exe或 macOS 的.app。
  • 是否被杀毒软件拦截。
  • 如果下载慢,考虑镜像站点。

3.2 创建项目骨架

打开 Godot,创建新项目。

为了保持代码清晰,建议先建立如下目录结构:

res:// ├── scenes/ │ └── npc/ │ └── npc.tscn ├── scripts/ │ ├── npc/ │ │ ├── npc.gd │ │ ├── npc_state_machine.gd │ │ └── states/ │ │ ├── state.gd │ │ ├── idle_state.gd │ │ ├── patrol_state.gd │ │ ├── chase_state.gd │ │ └── return_state.gd ├── player/ │ └── player.tscn

目录结构不是强制要求,但建议从现在开始培养模块化习惯。后续 NPC 数量增加时,你会感谢这个结构。

4. 有限状态机核心设计

4.1 什么是有限状态机

有限状态机,简称 FSM,是一种行为模型。它由一组“状态”和“状态之间的切换条件”组成。

在任意时刻,一个 NPC 只处于一个状态。例如:

  • 待机状态:玩家不在范围内,NPC 站着不动或做一些随机小动作。
  • 巡逻状态:沿着路径点移动。
  • 追击状态:发现玩家后向玩家移动。
  • 返回状态:失去玩家目标后回到出生点或巡逻起点。

因为同一时刻只有一个状态,逻辑不会互相干扰。为了说清楚,可以这样理解:

如果 NPC 在“巡逻”状态下被攻击,它需要切换到“追击”或“战斗”状态;如果追击过程中玩家跑远了,它要切回“返回”状态。整个系统就是一个状态切换表。

4.2 为什么用状态机而不是 if else

很多新手写 NPC 逻辑时,会这样写:

# 反例示意:所有行为写在一个函数里 func _process(delta): if player_in_range: move_to_player() elif is_patrolling: patrol() else: idle_behavior()

这种方法在三个五个行为时还能撑住。一旦新增攻击、防守、召唤、逃跑、搜刮等行为,_process会变成几百行的“大泥球”,改一个逻辑就会影响另外几个。

而状态机的做法是,把每个状态封装成独立脚本,每个脚本只处理自己的逻辑。状态切换由状态机管理。

这样做的好处:

  • 单一职责,状态逻辑清晰。
  • 可复用,一个“追击状态”可以让多个 NPC 用。
  • 方便调试,能直接看到当前状态名。
  • 扩展容易,新增状态不影响已有状态。
  • 性能可控,每个帧只运行一个状态逻辑。

4.3 状态机架构选择

在 Godot 中实现 FSM 有几种常见方案:

  • 简单枚举 + switch 分支:适合 3 个以内状态,不推荐扩展。
  • 状态对象模式:每个状态一个脚本,状态机持有当前状态。
  • 节点状态模式:每个状态是一个子节点,状态机通过启用/禁用来切换。
  • 动画树状态机模式:便于和动画系统联动,适合视觉状态切换明显的情况。

本文用一个更通用、也更容易理解的方式:状态对象模式。每个状态继承一个基础状态类State.gd,然后填充进入、退出、更新方法。

5. 实现智能 NPC 行为系统

5.1 定义基类状态

先建立一个状态基类。这个类不直接挂在场景上,而是作为所有状态的父类使用。

# scripts/npc/states/state.gd class_name NPCState extends RefCounted var npc: Node2D var state_machine: Node func _init(_npc: Node2D, _state_machine: Node) -> void: npc = _npc state_machine = _state_machine # 进入状态时调用 func enter() -> void: pass # 退出状态时调用 func exit() -> void: pass # 每帧更新 func update(_delta: float) -> void: pass # 物理帧更新,适合移动 func physics_update(_delta: float) -> void: pass

这样设计的目的是固定状态接口。之后不管新增多少种状态,只需要继承并实现这几个方法。

5.2 状态机管理节点

状态机负责保存当前状态,并提供切换方法。

# scripts/npc/npc_state_machine.gd class_name NPCStateMachine extends Node var current_state: NPCState var states := {} func setup_state(npc: Node2D, state_name: String, state_logic: NPCState) -> void: var new_state: NPCState = state_logic new_state.npc = npc new_state.state_machine = self states[state_name] = new_state func change_state(state_name: String) -> void: if current_state: current_state.exit() if not states.has(state_name): push_warning("状态不存在: " + state_name) return current_state = states[state_name] current_state.enter() print("NPC切换到状态: ", state_name) func _physics_process(delta: float) -> void: if current_state: current_state.physics_update(delta) func _process(delta: float) -> void: if current_state: current_state.update(delta)

这里设定了三个关键点:

  1. 状态需要先注册到状态机。
  2. 切换状态时先调用旧状态的exit()。
  3. 当前状态每帧会被调用update和physics_update。

5.3 待机状态

待机状态适合做 NPC 空闲时的表现。为了简单,我们先让它什么都不做,但保留扩展口。

# scripts/npc/states/idle_state.gd extends NPCState func enter() -> void: print("进入待机状态") # 可以在这里播放待机动画 func update(_delta: float) -> void: if npc.has_method("check_player_in_range"): if npc.check_player_in_range(): state_machine.change_state("Chase")

这个状态每帧检查玩家是否进入警戒范围。如果进入,就切换成追击状态。

5.4 巡逻状态

巡逻状态通常让 NPC 在几个路径点之间往返。

# scripts/npc/states/patrol_state.gd extends NPCState var patrol_points: Array[Node2D] = [] var target_index := 0 var move_speed := 60.0 func enter() -> void: print("进入巡逻状态") patrol_points = npc.patrol_points func physics_update(delta: float) -> void: if patrol_points.is_empty(): return var target: Node2D = patrol_points[target_index] var dir: Vector2 = (target.global_position - npc.global_position).normalized() npc.global_position += dir * move_speed * delta if npc.global_position.distance_to(target.global_position) < 10.0: target_index = (target_index + 1) % patrol_points.size() func update(_delta: float) -> void: if npc.has_method("check_player_in_range"): if npc.check_player_in_range(): state_machine.change_state("Chase")

如果patrol_points是 Node2D 数组,直接在场景里摆几个 Marker2D 就行。

5.5 追击状态

追击状态是智能 NPC 的核心表现。它的逻辑很简单:看到玩家就靠近,到达一定距离就攻击。

# scripts/npc/states/chase_state.gd extends NPCState var move_speed := 120.0 var attack_distance := 20.0 func enter() -> void: print("进入追击状态") # 播放移动动画或警戒音效 func physics_update(delta: float) -> void: var player: Node2D = npc.get_nearest_player() if player == null: state_machine.change_state("Return") return var dist: float = npc.global_position.distance_to(player.global_position) if dist > attack_distance: var dir: Vector2 = (player.global_position - npc.global_position).normalized() npc.global_position += dir * move_speed * delta else: # 可调用攻击逻辑 npc.try_attack(player) func update(_delta: float) -> void: if npc.has_method("check_player_in_range"): if not npc.check_player_in_range(): state_machine.change_state("Return")

追击状态会调用 NPC 提供的get_nearest_player()和try_attack()。这两个方法由具体的 NPC 脚本实现,让状态逻辑保持通用。

5.6 返回状态

返回状态用于让 NPC 回到出生点或巡逻起始点。

# scripts/npc/states/return_state.gd extends NPCState var spawn_point: Vector2 var move_speed := 80.0 func enter() -> void: print("进入返回状态") spawn_point = npc.spawn_point func physics_update(delta: float) -> void: if npc.global_position.distance_to(spawn_point) > 10.0: var dir: Vector2 = (spawn_point - npc.global_position).normalized() npc.global_position += dir * move_speed * delta else: state_machine.change_state("Patrol")

回到出生点后,继续切换到巡逻状态,形成循环。

5.7 NPC 主体脚本

现在把状态机和 NPC 的检测逻辑绑定起来。

# scripts/npc/npc.gd extends CharacterBody2D @export var detection_range: float = 300.0 @export var move_speed: float = 100.0 @export var patrol_points: Array[Node2D] = [] @onready var state_machine: Node = $StateMachine var spawn_point: Vector2 var player: Node2D func _ready() -> void: spawn_point = global_position _setup_states() func _setup_states() -> void: state_machine.setup_state(self, "Idle", IdleState.new(self, state_machine)) state_machine.setup_state(self, "Patrol", PatrolState.new(self, state_machine)) state_machine.setup_state(self, "Chase", ChaseState.new(self, state_machine)) state_machine.setup_state(self, "Return", ReturnState.new(self, state_machine)) state_machine.change_state("Idle") func check_player_in_range() -> bool: if player == null: return false return global_position.distance_to(player.global_position) <= detection_range func get_nearest_player() -> Node2D: if player and is_instance_valid(player): return player return null func try_attack(_target: Node2D) -> void: print("攻击玩家") # 实际项目中在这里播放攻击动画或发射投射物

注意:setup_state中我直接使用IdleState.new(self, state_machine),但在脚本顶层需要提前引用对应类。由于 GDScript 的全局类名是从class_name注册的,前面每个状态脚本都要加上class_name,比如class_name IdleState。你可以根据自己喜好调整。

假如不使用全局类名,也可以改成 preload:

const IdleState = preload("res://scripts/npc/states/idle_state.gd")

这种方式在大型项目里更显式,不容易踩到类名冲突。

5.8 场景搭建

在 Godot 编辑器中创建 NPC 场景:

  1. 新建场景,根节点选CharacterBody2D。
  2. 添加子节点CollisionShape2D,设置碰撞形状为圆形或矩形。
  3. 添加子节点StateMachine,挂载npc_state_machine.gd。
  4. 把npc.gd挂载到根节点。
  5. 在场景里放几个Marker2D节点,拖到 NPC 的patrol_points数组里。
  6. 给玩家场景提供一个可被引用的节点,例如在 NPC 的player属性赋值玩家节点。

如果你没有玩家场景,可以先在测试场景里放一个CharacterBody2D,写几行移动代码,让 NPC 能检测到它。

6. 功能测试与效果验证

6.1 测试循环行为

启动场景后,重点验证四步:

  1. 初始状态:NPC 先进入待机状态。
  2. 进入检测范围:玩家靠近到detection_range内,NPC 切换到追击状态。
  3. 追击玩家:NPC 靠近玩家并触发攻击逻辑。
  4. 玩家离开范围:NPC 切换到返回状态,回到出生点后继续巡逻。

如果玩家一直待在范围内,NPC 会一直追击,直至攻击判定。

6.2 判断测试结果

满足以下条件即算成功:

  • 控制台能看到状态切换日志。
  • 没有报错,没有空指针。
  • NPC 能在多个路径点之间连续巡逻。
  • 玩家进入范围和离开范围时,行为有明确变化。
  • global_position移动平滑,没有抖动或跳变。

6.3 常见失败原因

失败现象可能原因排查方式
状态切换后报错状态类没有正确初始化检查setup_state是否在_ready前调用
玩家无法被检测player属性未赋值检查场景节点引用是否丢失
巡逻点不生效patrol_points数组为空确认导出的数组是否被填充
NPC 走出屏幕不回来返回状态到出生点后还在切状态检查状态切换条件和距离阈值
状态切换太快两个状态互相切换检查每个状态里的切换判断是否有延迟条件

7. 扩展:批量 NPC 复用与行为差异化

7.1 多 NPC 复用同一套状态机

上面的设计里,NPC 每个状态都写成了独立类,这套类本身不保存 NPC 特有数据,而是通过npc引用获取数据。因此你可以在场景里放多个 NPC 角色节点,每个节点都挂同一个npc.gd,设置不同的detection_range、move_speed和patrol_points。

例如游戏里同时存在三种 NPC:

  • 普通守卫:检测范围小,移动慢。
  • 精英守卫:检测范围大,移动快。
  • 巡逻狗:没有攻击逻辑,只追踪玩家并吠叫。

它们只需调整导出参数,不需要修改状态机代码。

7.2 新增一个“攻击状态”的步骤

如果要从追击状态里拆出更复杂的攻击前摇,可以这样扩展:

  1. 新建attack_state.gd,继承NPCState。
  2. enter中播放攻击动画,physics_update中控制转身面对玩家。
  3. 在 NPC 的_setup_states中注册Attack状态。
  4. 在ChaseState中,如果距离小于攻击距离,切换为Attack。
  5. 在AttackState中,攻击动画结束后切回Chase。

这样每加一个状态,只需要改动很少的行数。

7.3 和动画系统的联动

在 Godot 中,NPC 的动画切换通常依赖AnimationTree或AnimationPlayer。状态机里的每个状态enter中,可以直接调用动画接口。

例如在ChaseState.enter里:

npc.play_animation("run")

在IdleState.enter里:

npc.play_animation("idle")

更进一步,可以使用 Godot 的AnimationTree配合AnimationNodeStateMachine,把视觉状态和逻辑状态并行设计。不过初学者建议先跑通逻辑状态机,再考虑动画树。

8. 资源占用与性能观察

8.1 性能表现

有限状态机是纯逻辑代码,性能开销取决于每个状态中的计算量。Godot 引擎本身每帧都会调用_process和_physics_process,状态机只是把这些调用转发给当前状态,额外开销非常低。

当场景里同时存在多个 NPC 时,可以做以下优化:

  • 降低状态更新频率,例如改为每 0.2 秒判断一次玩家距离,而不是每帧判断。
  • 只在玩家进入大范围时启用精确检测。
  • 把不是当前状态的 NPC 移出高频率更新逻辑。

8.2 如何观察性能

Godot 自带调试器。运行项目时,打开“调试器”面板,可以看到每个节点的脚本方法调用情况。如果要统计 NPC 数量对帧率的影响,可以使用以下方式:

  • 控制台输出Engine.get_frames_per_second()。
  • 使用Performance单例。
print(Performance.get_monitor(Performance.TIME_PROCESS))

8.3 降低性能开销的建议

优化方向做法
检测频率使用Timer定时检测,而不是每帧检测
距离计算使用is_instance_valid避免无效引用
路径点搜索预计算路径点数组,避免每帧查找节点
状态日志只在调试模式开启print

9. 常见问题与排查方法

问题现象可能原因排查方式解决方案
Godot 下载后打不开文件不完整、杀软拦截、缺少运行库检查官方下载文件或日志重新下载,关闭杀软或添加白名单
编辑器里新建项目卡住网络、磁盘、渲染驱动问题切换渲染器为兼容模式在项目设置里改为 Compatibility 渲染
脚本里使用class_name后报错类名重复或加载顺序冲突全局搜索重复类名改用preload方式
NPC 不巡逻patrol_points没有赋值检查数组是否为空在场景里拖入 Marker2D
状态机不断切换到一个状态状态条件不满足退出打印每个状态 enter 和 exit在每个状态里输出当前坐标和目标坐标
玩家离开后 NPC 不追踪没有拿到玩家引用检查player节点是否释放在_exit_tree时清空引用
多个 NPC 共用状态导致逻辑串线状态对象内保存了实例状态确认每个 NPC 都创建了自己的状态实例不要在状态里保存每个节点的全局数据
追击时 NPC 抖动速度过大或距离阈值过小打印每帧速度调整move_speed或者使用move_and_slide

10. 最佳实践与使用建议

10.1 第一次测试时用最小配置

不要一上来就搭建复杂关卡。先创建一个空场景,放一个地面、一个 NPC、一个玩家,设置好基础状态机。跑通“巡逻 -> 追击 -> 返回”循环之后,再往项目里加动画、音效、攻击效果。

最小可运行配置可以这样固定下来:一个npc.gd,四个状态脚本,一个StateMachine节点。这套配置可以当作以后所有 NPC 的起点模板。

10.2 状态里不放系统级服务

状态脚本不应该直接访问get_node("/root/GameState")这类全局路径。更好的做法是让 NPC 自身提供所需接口,例如:

npc.set_move_target(target) npc.attack() npc.get_health()

这样状态机只依赖 NPC 的公共方法,便于后续替换不同 NPC 类型。

10.3 调试工具随手做

可以在 NPC 头顶添加一个Label,每帧显示当前状态名。这样你不需要时刻盯着控制台,运行场景时肉眼就能看到行为是否正确。

@onready var state_label: Label = $StateLabel func _process(_delta: float) -> void: state_label.text = state_machine.current_state.name

这个操作对排查状态切换问题非常有帮助。

10.4 注意场景节点复用

不要在一个 NPC 的场景里硬编其他 NPC 的引用。正确做法是使用@export暴露引用,然后在场景或代码里填入。这样每个 NPC 实例能独立运作,不会因为共享同一节点引用而出现异常。

10.5 接入外部 AI 服务时的边界

如果你打算让 NPC 的对话更智能,接入第三方 LLM 或 TTS 服务,请务必注意:

  • 不能把玩家隐私数据上传到未知服务。
  • 必须在游戏开始前获得用户同意。
  • 生成的对话内容要经过审核,避免出现不当内容。
  • 不要直接使用未授权的人物名、声音、图像。

Finite State Machine 只是行为控制的一部分,外部 AI 服务属于另一套安全性更高的工程范畴。

11. 后续扩展方向

当前这套状态机足够应付很多 2D/3D 游戏的基础 NPC 行为。如果继续往下做,可以扩展为以下几个方向。

11.1 行为树的引入

如果 NPC 需要处理多条件叠加、优先级选择,例如“挨打时优先逃跑”、“有道具时优先搜索道具”、“血量低时优先撤退”,FSM 的状态切换表会变得很复杂。

这时可以引入行为树思路,或者使用 Godot 插件实现行为树编辑器。行为树的节点天然支持优先级选择和条件分支,更适合复杂决策。

11.2 感知系统与记忆系统

一个高级 NPC 不应该只靠距离判断玩家。更真实的做法是:

  • 通过视线射线检测。
  • 通过噪音判断。
  • 通过最近事件列表记录“刚才听到的声音”。
  • 通过记忆系统让 NPC 在玩家离开视线后仍搜索一段时间。

这些都可以以独立的系统模块存在,状态机只需要根据感知系统的输出切换状态。

11.3 群体行为和队形控制

当多个 NPC 同时行动时,可以使用 flocking 算法,让 NPC 之间有间距、会排队、会绕开障碍。状态机负责单个 NPC 的行为逻辑,群体调度负责协作关系。

11.4 多状态并行与子状态机

某些 NPC 可能需要“一边走路一边说话”或者“一边追击一边受击”。这时可以设计“主状态机 + 子状态机”,例如:主状态机决定“移动 / 战斗”,子状态机处理“受击 / 播放语音 / 播放表情”。这样既能保持全局逻辑清晰,又能处理叠加行为。

12. 总结与下一步

这篇文章从一个最容易被忽略但至关重要的角度切入:游戏 AI 不一定要“智能到吓人”,先做到“行为可预期、逻辑可维护、扩展不崩盘”。有限状态机恰恰就是这样一个兼顾简单和实用的工程方案。

建议你先跑通本文最基础的四状态循环,也就是“待机 -> 巡逻 -> 追击 -> 返回”,然后把状态切换日志和 NPC 头顶状态标签都打开跑一遍,确认每个状态都能被触发。接着再逐步加入动画、音效、攻击行为。

最容易踩的坑有两个:

第一个是多个状态脚本之间互相引用混乱,导致状态反复切换,表现为 NPC 不停抖动或者状态日志刷屏。遇到这种情况,先去掉动画和音效,只保留移动逻辑,再逐一确认条件。

第二个是忘记更新场景里导出的patrol_points数组,导致 NPC 一直停在原地。检查这个数组是否为空,是最快的排查方式。

这套状态机架构可以继续扩展方向包括:接入行为树、加入感知系统、支持子状态机、配合动画树做复杂表现。如果将来你做游戏开发遇到 NPC 行为难维护的问题,建议从 FSM 开始。

下一阶段可以做的事也很明确:打开 Godot,新建两个场景,一个放 NPC,一个放玩家,按本文代码完整跑一遍,然后把中间发现的问题记录下来,再开始扩展自己的玩法状态。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询