Godot 4.3敌人追踪全解析:从场地搭建到导航寻路避障

发布时间:2026/9/30 17:29:11
Godot 4.3敌人追踪全解析:从场地搭建到导航寻路避障 做到第8节游戏终于不再是一个方块在一片纯色里乱跑了。这一节的任务很明确给主角圈出一块能打的场地再放一个会主动追着玩家跑的敌人。我原本以为这只是复制粘贴两个节点的事结果实际做下来敌人到底怎么追这一个问题就绕了不少路。这篇记录一下我搭建场地和实现敌人追踪的完整过程包括代码、物理层规划以及那些在Godot 4.3里亲身踩过的坑。这个练习系列面向的是和我一样在摸索Godot的开发者。无论你是刚接触Godot还是已经写过一些简单脚本想系统整理思路这篇都适用。读完你至少能做出一个带边界、带敌人、敌人会主动追击的基础场景也能理解追踪AI从简陋到可用的演进过程。1. 动手前先想清楚场地和敌人追踪到底解决什么问题1.1 从能跑的方块到有敌人的场景前面几节做完玩家角色已经有了移动、跳跃、相机跟随但整个游戏还很空。角色在一个没有边界的世界里飘没有目标也没有威胁游戏感基本不存在。第8节的本质是把游戏从角色控制器演示推向可玩的小场景。其中两个核心问题场地必须让玩家待在一个明确的范围内。没有场地敌人追踪就无从谈起——空间无限大敌人追出来没有意义玩家的策略也不可能成立。敌人追踪游戏中第一个有目的的AI行为。它让玩家和场景产生真正的互动敌人会主动向你移动你需要利用场地地形和移动能力来应对。这两件事放在同一节做是有道理的。场地提供了追踪AI运行的空间限制追踪AI则验证了场地是否合理。如果场地太大敌人永远追不上或者太小敌人把玩家堵死在角落那都是设计问题需要在两个系统联动时才能发现。1.2 场地的形式选择动手前我考虑过两种场地方案方案优点缺点适合场景StaticBody2D CollisionShape2D快速、直观、代码量少无法画出复杂地面只适合规则形状练习、原型、竞技场TileMapLayerGodot 4.3或 TileMap4.2及以下能画复杂地形视觉直观方便迭代地图需要理解图块集、碰撞层绘制规则平台跳跃、俯视角关卡我最后选择了 StaticBody2D CollisionShape2D 作为练习方案原因很实际这一节的焦点是敌人追踪AI我不想被地形绘制分散太多精力。先把一个四四方方的战斗场围起来让敌人追踪的逻辑能在稳定的空间里调试。后面需要更丰富的地形时再迁移到 TileMapLayer 也不迟。如果你直接选择 TileMapLayer也没问题但要注意在 Godot 4.3 中 TileMap 节点被拆分为 TileMapLayer每个层是一个独立节点。很多网上的旧教程用的是 TileMap直接把代码和节点结构套用到 4.3 会报错建议看官方文档确认版本差异。1.3 敌人追踪的两种技术路线追踪AI也有两条路线直线追踪每个物理帧把敌人的速度方向指向玩家当前位置配合move_and_slide()移动。适用于没有障碍物或障碍物很稀疏的场景。导航寻路追踪用 NavigationAgent2D NavigationRegion2D 生成路径敌人在场景中绕开障碍物接近玩家。适用于有墙体、有复杂地形的场景。我先把直线追踪跑通因为逻辑最直接。直线追踪可以立刻验证敌人感知范围、速度、碰撞体积这些基础参数是否合理。跑通之后再引入导航寻路观察两者手感差异。这一节的最终版我用了导航寻路但直线追踪的代码我会保留在工程里作为对照。2. 搭场地战斗空间是怎么围起来的2.1 静态碰撞体的组合在场景树里我新建了一个 Node2D 作为 ArenaRoot下面按四个方向各放一个 StaticBody2D每个 StaticBody2D 里挂 CollisionShape2D形状用 RectangleShape2D。四个墙体的坐标需要计算。假如场地是 2000x1200 像素四堵墙的厚度是 50 像素那么上墙位置(0, -600)大小(2000, 50)下墙位置(0, 600)大小(2000, 50)左墙位置(-1000, 0)大小(50, 1200)右墙位置(1000, 0)大小(50, 1200)这里用RectangleShape2D.size而不是scale来设置墙体大小。原因是碰撞体的缩放会和节点的变换叠加后续如果需要动态调整墙体尺寸直接用 size 更可控特别是在做运行时生成或修改场景时不容易出错。2.2 物理层规划永远不要在Layer/Mask上偷懒场地和角色加好之后第一个坑就来了敌人和墙体碰撞正常但敌人之间也互相碰撞几个敌人追玩家时会挤成一团。要处理这个问题物理层Layer和掩码Mask的规划必须一开始就做对。我在 Project Settings - Layer Names - 2D Physics 里定义了三个层层名称包含对象1world墙体、障碍物2player玩家角色3enemy敌人角色各节点的设置墙体 StaticBody2DLayer 1Mask 2 3要挡住玩家和敌人玩家 CharacterBody2DLayer 2Mask 1只检测墙体玩家和敌人之间的交互交给Area2D不用物理碰撞敌人 CharacterBody2DLayer 3Mask 1只检测墙体为什么要让玩家和敌人的 Mask 都只有墙体因为如果你把敌人也放进玩家的 Maskmove_and_slide()会把他们互相推开看起来像在挤空气。更重要的是如果敌人数量多了move_and_slide()每帧处理多个碰撞体会增加计算开销。用 Area2D 来做玩家与敌人的交互检测既灵活又能区分碰到了就扣血和物理上挡住去路两种逻辑。2.3 相机边界限制场地搭好后相机如果没有边界玩家移出场地就能看到墙外的世界很出戏。我用的是 Camera2D 的limit_left、limit_top、limit_right、limit_bottom四个属性直接把数值设为场地边界的坐标。这一步没什么难度但很容易被忽略。没有相机边界的场地敌人追踪再合理观感还是像在舞台边演边漏光。Godot 4.x 中 Camera2D 的 limits 是像素坐标直接填写实际值即可。2.4 场地里的初始角色摆放我在场地中央放置玩家在场地两侧放置了两个敌人。初始位置要避免出现敌人出生在玩家脸上的情况不然玩家出生瞬间就挨打体验很差。我用 Editor 里手动调整位置没有写生成脚本。练习阶段把数据放场景里最直接但如果你后续要做敌人复活机制建议把敌人的出生点存成变量或者独立节点方便在代码里 reset。3. 敌人追踪的第一版不用寻路先跑起来看效果3.1 直线追踪的代码骨架我在Enemy.gd里写了最原始的版本这里先把玩家引用拿到手extends CharacterBody2D export var move_speed : 120.0 onready var player: Node2D get_tree().get_first_node_in_group(player) func _physics_process(delta: float) - void: if not player: return var direction : global_position.direction_to(player.global_position) velocity direction * move_speed move_and_slide()这段代码的核心是global_position.direction_to()它返回一个从敌人位置指向玩家位置的单位向量。方向乘以速度就是移动速度向量喂给move_and_slide()后Godot 的物理引擎会处理碰撞滑动。注意_physics_process里有个delta参数但这里我没用它。原因是move_and_slide()内部已经使用物理帧步长来计算位移不需要我再手动乘 delta。很多刚接触 Godot 的开发者会在_physics_process里手动position velocity * delta这种做法在 CharacterBody2D 里是多余的而且容易因为顺序问题产生抖动。CharacterBody2D 的正确姿势是设置velocity然后调用move_and_slide()。3.2 给敌人加一个检测半径直线追踪跑通后我马上发现一个问题敌人隔着整个场地追过来一旦玩家和敌人不在同一屏这场追逐就没有意义。于是我给敌人挂了一个 Area2D 作为检测圈只有玩家进入圈内敌人才开始追。extends CharacterBody2D export var move_speed : 120.0 export var detection_range : 250.0 onready var player: Node2D get_tree().get_first_node_in_group(player) onready var detection_area: Area2D $DetectionArea func _ready() - void: # 把Area2D的碰撞形状改成CircleShape2D半径设为detection_range var circle : CircleShape2D.new() circle.radius detection_range ($DetectionArea/CollisionShape2D.shape as CircleShape2D).radius detection_range func _physics_process(delta: float) - void: if not player: return var dist : global_position.distance_to(player.global_position) if dist detection_range: var direction : global_position.direction_to(player.global_position) velocity direction * move_speed else: velocity Vector2.ZERO move_and_slide()这里我把 Area2D 的碰撞形状和检测距离绑定在一起这样后续调数值时只需要改detection_range导出变量在 Inspector 里就能直接看到效果。刚开始我犯过一个错把detection_range只放在 Area2D 的碰撞形状上脚本里没有用结果玩家已经进入敌人视野了敌人还是纹丝不动。因为脚本判断的是变量不是 Area2D 实际检测到的目标。统一用同一个变量就不会出现代码和配置不一致的问题。3.3 状态字段给敌人最基本的AI结构纯追踪的敌人其实很单调。我参考很多游戏的敌人设计后给敌人加了一个简单的状态字段IDLE未发现玩家和CHASE追击中。enum State { IDLE, CHASE } var state : State.IDLE func _physics_process(delta: float) - void: var dist : global_position.distance_to(player.global_position) match state: State.IDLE: velocity Vector2.ZERO if dist detection_range: state State.CHASE # 进入追击时可以做个视觉提示比如改变敌人颜色 State.CHASE: if dist detection_range * 1.2: state State.IDLE else: var direction : global_position.direction_to(player.global_position) velocity direction * move_speed move_and_slide()注意我用的是detection_range * 1.2来退出追击状态而不是直接用detection_range。这叫迟滞区间进入追击的阈值和退出追击的阈值不一样。如果两个阈值完全一样敌人会在玩家刚好在边界上反复横跳时不停地在IDLE和CHASE之间切换表现就是敌人突然停下来、突然又追像接触不良一样。把退出的检测范围调大一点等于给状态切换加了一层稳定缓冲。这个设计在AI里非常常用后面做攻击AI也是同一个道理。3.4 多个敌人的行为差异我场景里放了两个敌人共享同一个 Enemy.gd 脚本。为了让它们看起来不是完全复制粘贴我给每个敌人导出变量里调了不同的move_speed和detection_range。这种差异化不需要改代码在 Inspector 里逐个修改即可。如果敌人数量再大一些可以在_ready()里随机化速度或者按区域设置参数。练习阶段手动调就够了。4. 升级追踪AI用导航寻路绕开障碍物4.1 直线追踪的死穴直线追踪虽然简单一旦场地里出现障碍物就非常尴尬。敌人会直直地撞向玩家所在的方向然后被墙体卡住在墙边来回滑动看起来智商为负。如果你只想做一个空场地格斗游戏直线追踪可能够用。但既然我们精心设计了场地边界下一步很自然会想能不能让敌人绕开墙走一条合理的路径来接近玩家这就轮到 Godot 的导航体系上场。核心组件是这三个NavigationRegion2D管理导航地图告诉系统哪些区域敌人可以走NavigationAgent2D挂在敌人身上负责向导航地图请求路径并维护敌人的寻路状态NavigationServer2D底层服务不需要直接操作但要知道它存在4.2 搭建导航区域我在场景里新建了一个 NavigationRegion2D然后给它挂了一个 NavigationPolygon 资源。在这里有一个非常关键的坑NavigationPolygon 需要手动烘焙Bake导航区域不然运行时导航地图是空的敌人会直接报错或者原地不动。操作顺序是选中 NavigationRegion2D在 Inspector 里给 NavigationPolygon 创建新资源然后用编辑器顶部的Bake NavigationPolygon按钮进行烘焙。烘焙出来的绿色区域就是敌人可以行走的地方。如果你把绿色区域设置得和场地边界重合那墙体之外就会变成不可行走区域敌人永远不会穿墙出去。如果后面场地变了比如加了柱子或墙体记得重新烘焙。这个操作不能漏我第一遍就漏了运行时 enemy 的target_position报failed to reach destination错误排查了半天才发现是没烘焙。4.3 敌人的导航追踪代码给敌人加上 NavigationAgent2D 后脚本变成这样extends CharacterBody2D export var move_speed : 120.0 export var detection_range : 250.0 onready var player: Node2D get_tree().get_first_node_in_group(player) onready var navigation_agent: NavigationAgent2D $NavigationAgent2D enum State { IDLE, CHASE } var state : State.IDLE func _ready() - void: navigation_agent.path_desired_distance 8.0 navigation_agent.target_desired_distance 8.0 # 等待场景完全就绪后再设置初始目标避免地图还没构建导致寻路失败 await get_tree().physics_frame func _physics_process(delta: float) - void: if not player: return var dist : global_position.distance_to(player.global_position) match state: State.IDLE: velocity Vector2.ZERO if dist detection_range: state State.CHASE State.CHASE: if dist detection_range * 1.2: state State.IDLE else: navigation_agent.target_position player.global_position _move_along_path() func _move_along_path() - void: if navigation_agent.is_navigation_finished(): velocity Vector2.ZERO return var next_pos: Vector2 navigation_agent.get_next_path_position() var direction : global_position.direction_to(next_pos) velocity direction * move_speed move_and_slide()path_desired_distance和target_desired_distance是寻路到达精度的控制参数。如果设得太小敌人会一直尝试走到目标点正中心导致接近玩家时会脚步细碎地调整位置设得太大敌人在离玩家还有一段距离时就停下看起来没有贴上来。我测试下来 8.0 在这个 2000x1200 场景里手感合适数值需要读者根据角色大小和坐标规模自己微调。注意target_position不是路径而是目标点。NavigationAgent2D 每帧接受玩家的全局坐标内部会根据导航地图实时更新路径。这种设计的好处是玩家在移动时敌人会不断基于最新目标重新规划路径表现上就是追踪完全实时。4.4 寻路成本控制_physics_process每帧都调用navigation_agent.target_position player.global_position从代码逻辑来讲没有问题但导航系统内部每次设置目标都会触发路径查询。场景里敌人少时感受不到差别一旦敌人数量到几十个寻路查询量会直接拉高 CPU 占用。我的优化方式很朴素用计时器把目标更新降频比如每 0.2 秒更新一次 target_position而不是每物理帧更新。寻路路径不需要每帧重算玩家移动 0.2 秒后的位置变化对敌人的路径决策影响很小但性能收益是实打实的。onready var path_update_timer: Timer $PathUpdateTimer func _ready() - void: path_update_timer.wait_time 0.2 path_update_timer.timeout.connect(_update_target) path_update_timer.start() func _update_target() - void: if state State.CHASE and player: navigation_agent.target_position player.global_position_physics_process里就不用再写target_position赋值了只调用_move_along_path()移动。这样敌人追踪的实时性仍然够但路径查询频率降低了 5 倍。5. 调试时遇到的主要问题与排查过程5.1 敌人在墙角和墙体反复抖动第一版直线追踪跑起来后敌人只要在墙角追我就会贴在墙上高速抖动还伴随着滋滋的声音。排查思路是这样的先确认抖动是视觉问题还是物理位置问题。我打印了global_position发现位置确实在墙角来回跳动每秒几十次。这说明move_and_slide()在每帧计算中都把敌人推出了碰撞体但下一帧敌人又重新向墙壁方向加速于是推出去、拉回来、推出去、拉回来。解决方向有两个我最后都做了在_move_along_path()里判断如果速度方向和墙体碰撞法线方向接近垂直即敌人正在贴墙滑动就适当降低垂直于墙面的速度分量。再一个更保险的措施是敌人接近目标点很远时不要强行朝向目标方向。导航寻路给出的next_pos本身已经考虑了墙体使用global_position.direction_to(next_pos)是安全的方向来源所以直线追踪的抖动问题在换到导航后基本消失。经验是只要是 CharacterBody2D 的敌人追踪尽量避免直接对玩家位置求方向尽量让方向来自导航路径点。墙体会破坏直线方向的有效性。5.2 get_tree().get_first_node_in_group(player) 拿到 null这是我练习中遇到频率最高的低级错误。原因通常是玩家节点没有加到player这个 group。我确认玩家角色已经加好 group 后仍然有概率拿到 null后来发现是节点加载顺序问题Enemy 的_ready()执行时玩家节点可能还没进入场景树。解决方案是不要依赖onready在_ready()里直接赋值而是懒加载var player: Node2D func _get_player() - Node2D: if not player: player get_tree().get_first_node_in_group(player) return player在_physics_process里每帧调用_get_player()既保证引用不为空也避免了在_ready()时强依赖其他节点的加载顺序。这个模式在后续做子弹、拾取物、NPC 交互时都能复用。5.3 敌人发现玩家后不会立刻转向的问题用导航寻路后敌人从 IDLE 切到 CHASE 时第一帧get_next_path_position()可能会返回上一次寻路的旧路径点导致敌人朝着错误的方向先走一小段。表现就是敌人在原地停顿一下然后再转向玩家。我加了一个即时刷新进入 CHASE 状态时立刻设置navigation_agent.target_position player.global_position并手动让导航代理跳过当前路径。在 Godot 4.3 里可以调用navigation_agent.get_next_path_position()前先navigation_agent.target_position player.global_position让系统重新规划。这样状态切换和路径更新绑定不会出现先走旧路再修正的延迟。5.4 多个敌人使用同一个 NavigationRegion2D 的注意点所有敌人共享一个导航地图这是正常状态不需要为每个敌人单独创建 NavigationRegion2D。NavigationAgent2D 本身是轻量的每个敌人挂一个即可。但如果敌人数量特别多建议把敌人放进单独的组方便统一管理比如enemies组在代码中用get_tree().get_nodes_in_group(enemies)批量操作。我在实际场景中放了 6 个敌人启用导航寻路后 FPS 在高刷屏上没有明显波动说明这种体量用 NavigationAgent2D 完全没有性能压力。6. 迭代方向敌人AI如何变得更聪明6.1 从追踪到攻击循环追踪只是敌人AI的第一步。下一步很自然的扩展是当敌人追到玩家面前时停止移动并触发攻击。我建议在状态机里加一个ATTACK状态判断逻辑是global_position.distance_to(player.global_position) attack_range进入攻击后停止追踪播放攻击动画然后给玩家造成伤害。关键仍然是状态切换的阈设计攻击范围要比追踪停止范围稍大避免敌人走到攻击距离边缘时反复切换攻击/追踪。这个状态机模式IDLE / CHASE / ATTACK几乎是所有近战敌人的通用骨架。先写状态再写每个状态里的行为比直接堆 if 判断可维护得多。6.2 视觉反馈让玩家看懂敌人在干什么在调试追踪AI时我强烈建议打开 Debug 视觉反馈。最简单的方式是在敌人脚下画一条线指向它当前的目标方向或者改变敌人颜色。我用的方法是给敌人根节点挂一个Line2D在_physics_process里更新它的第二个端点为玩家位置$VisionLine.points [Vector2.ZERO, to_local(player.global_position)]这样每一步追踪意图都可视化。调试完再把 Line2D 隐藏即可不用删。这个技巧从视觉上帮我发现了IDLE 转 CHASE 时会转向旧路径的问题否则单看敌人行为很难定位。6.3 敌人追踪的随机化与个性化两个敌人行为完全一样会很无聊。我给每个敌人导出了move_speed、detection_range、attack_range后在 Inspector 里做了差异化配置一个速度快但检测距离短一个检测距离远但速度慢。这样玩家面对它们时会有不同的应对策略战斗层次感立刻出来了。在 Script 里也可以加入简单的随机扰动func _ready() - void: move_speed * randf_range(0.9, 1.1) detection_range * randf_range(0.8, 1.2)但要注意_ready()里使用随机数后Inspector 中的导出值会被覆盖。如果希望保留手动配置的精度可以把随机化的开关做成一个布尔导出变量需要时才启用。6.4 想清楚追踪失败的兜底敌人追踪不是永远都能成功的。如果玩家跑过一道门而门在导航地图里被标记为不可走敌人就会在门前徘徊。这时候需要给敌人一个放弃追踪的条件比如超过一定时间没有追上玩家就重置回 IDLE等玩家再次靠近时才重新追踪。我在状态机里加了一个简单的计时var chase_time : 0.0 func _physics_process(delta: float) - void: if state State.CHASE: chase_time delta if chase_time 5.0: state State.IDLE chase_time 0.0这样敌人不会因为一次失败的追踪就永远卡在同一个位置。做 AI 时一定要考虑玩家打破 AI 预期的情形否则看起来就像敌人执念过强反而显得僵硬。7. 关于联机场景的一个提醒这次练习是纯单机但如果你接下来打算做联机特别是用了回滚Rollback机制的 Godot 4 项目有一点需要提前重视敌人追踪状态在回滚帧里必须可预测。回滚机制的原理是记录输入、回滚到某个帧、重新模拟逻辑。如果敌人的_physics_process里依赖了随机数、系统时间或者全局的非确定性节点查询回滚后模拟出来的结果就可能和实际历史帧不一致这就是很多人遇到的回滚不干净问题。我处理的办法是把敌人AI的所有决策输入玩家位置、速度、状态机当前态都限定在可通过物理帧复现的状态里随机数使用固定随机种子避免在_physics_process里直接调用randf()。这个话题展开讲很复杂这一节单机练习先不深入但如果你有联机打算从第一天写敌人AI时就要保持确定性后续会省很多事。场地和敌人追踪做完后游戏才算有了真正的攻防感。玩家不再是漫无目的地游荡敌人也不再是墙上的装饰画。下一步我准备给敌人加攻击和受击反馈让战斗循环完整起来。Godot 里实现这套逻辑并不难难的是每个细节都测试到位这次练习积累的排查思路至少能让你在后面加复杂AI时不至于一头雾水。