☰
Godot 4.3敌人追踪全解析:从场地搭建到导航寻路避障
2026/9/30 17:28:51 网站建设 项目流程

做到第8节,游戏终于不再是"一个方块在一片纯色里乱跑"了。这一节的任务很明确:给主角圈出一块能打的场地,再放一个会主动追着玩家跑的敌人。我原本以为这只是复制粘贴两个节点的事,结果实际做下来,"敌人到底怎么追"这一个问题就绕了不少路。这篇记录一下我搭建场地和实现敌人追踪的完整过程,包括代码、物理层规划,以及那些在Godot 4.3里亲身踩过的坑。

这个练习系列面向的是和我一样在摸索Godot的开发者。无论你是刚接触Godot,还是已经写过一些简单脚本想系统整理思路,这篇都适用。读完你至少能做出一个带边界、带敌人、敌人会主动追击的基础场景,也能理解追踪AI从简陋到可用的演进过程。

1. 动手前先想清楚:场地和敌人追踪到底解决什么问题

1.1 从"能跑的方块"到"有敌人的场景"

前面几节做完,玩家角色已经有了移动、跳跃、相机跟随,但整个游戏还很"空"。角色在一个没有边界的世界里飘,没有目标也没有威胁,游戏感基本不存在。

第8节的本质,是把游戏从"角色控制器演示"推向"可玩的小场景"。其中两个核心问题:

  • 场地:必须让玩家待在一个明确的范围内。没有场地,敌人追踪就无从谈起——空间无限大,敌人追出来没有意义,玩家的策略也不可能成立。
  • 敌人追踪:游戏中第一个"有目的"的AI行为。它让玩家和场景产生真正的互动:敌人会主动向你移动,你需要利用场地地形和移动能力来应对。

这两件事放在同一节做是有道理的。场地提供了追踪AI运行的空间限制,追踪AI则验证了场地是否合理。如果场地太大敌人永远追不上,或者太小敌人把玩家堵死在角落,那都是设计问题,需要在两个系统联动时才能发现。

1.2 场地的形式选择

动手前我考虑过两种场地方案:

方案优点缺点适合场景
StaticBody2D + CollisionShape2D快速、直观、代码量少无法"画"出复杂地面,只适合规则形状练习、原型、竞技场
TileMapLayer(Godot 4.3)或 TileMap(4.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敌人角色

各节点的设置:

  • 墙体 StaticBody2D:Layer = 1,Mask = 2 + 3(要挡住玩家和敌人)
  • 玩家 CharacterBody2D:Layer = 2,Mask = 1(只检测墙体;玩家和敌人之间的交互交给Area2D,不用物理碰撞)
  • 敌人 CharacterBody2D:Layer = 3,Mask = 1(只检测墙体)

为什么要让玩家和敌人的 Mask 都只有墙体?因为如果你把敌人也放进玩家的 Mask,move_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时不至于一头雾水。

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

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

立即咨询