第十章:感知系统与避障
本章导言
在前面的章节中,我们已经初步构建了一个具备基础行为逻辑的敌方单位。虽然它们已经能够在地图上巡逻并在发现玩家时发起攻击,但在实际测试中,这种基础 AI 的表现显得有些“机械”且缺乏挑战性。为了打破这种单调的游戏体验,本章将针对现有系统的局限性进行全方位的智能化升级。
我们将通过以下四个维度的技术迭代,将简单的“巡逻兵”进化为具备战术深度的“智能猎手”:
-
从“全知视角”到“扇形视野”:针对目前敌人 360 度无死角感知的弊端,我们将引入带角度的视野组件。通过模拟真实的“可视角度”,玩家将获得从背后潜行的战术空间,这不仅增强了战斗的真实感,也为潜行玩法提供了可能。
-
从“死冲撞墙”到“动态避障”:为了解决敌人遭遇障碍物即“卡死”的尴尬表现,我们将利用形状投射(Shape Casting)技术为 AI 赋予前方碰撞检测能力。敌人将学会感知地形障碍并智能调整移动方向,实现平滑的绕障行进。
-
从“瞬间遗忘”到“区域搜寻”:目前的敌人一旦丢失目标会立即切回巡逻态,缺乏行为连续性。本章将新增“索敌状态”,让敌人在目标消失后,依然会在最后可见区域进行延伸搜索,以此提升战斗的紧张感与沉浸感。
-
从“单一近战”到“远程压制”:为了丰富战斗的多样性,我们将设计一种新型敌方远程攻击单位,导弹攻击车。它具备发射自动追踪导弹的能力,能从远距离形成持续压制。通过不同兵种的组合,原本重复的行为模式将转化为极具威胁的战术组合。
通过本章的深度实践,你将掌握 方向感知计算、动态障碍避让、复杂状态机切换以及追踪弹弹道算法 等关键 AI 技术,让你的游戏世界更具生命力。
1 视野感知系统的构建
在设计敌人AI时,第一步通常是赋予它“看见玩家”的能力。只有在察觉到玩家存在后,敌人才有可能做出下一步的行动,比如追击、攻击、报警或逃跑。因此,感知系统是所有智能行为的起点。
本节我们将从零开始搭建一个具备方向感知能力的视野系统,解决敌人“眼观六路”的问题,使它只能侦测到自身前方视野范围内的玩家。我们将使用 Godot 中的 RayCast2D 节点模拟视线方向,并结合角度判断逻辑来实现一个“带视角限制”的感知组件,从而让敌人具备更真实、更可控的“视力”。
1.1 视野感知的原理说明
在前一章中,我们为敌人配置了一个基于 Area2D 的探测组件,一旦玩家进入敌人的探测范围,不论从哪个方向靠近,敌人都会立即发现玩家并展开攻击。这种设计的本质是“360 度全向感知”,实现简单,但缺乏真实性与策略性。在现实世界中,任何实体的视觉都是有方向性的。例如人类的双眼只能看到大约 180 度范围,而一些动物的视野甚至更窄。在游戏设计中,如果敌人也具备方向性的视野限制,玩家就可以通过巧妙的路径选择,例如从背后靠近敌人、隐藏在视野盲区中,从而避开侦测,获得更具挑战性的潜行体验。
Godot 并不直接提供“扇形视野检测”的节点类型。但我们可以借助如下机制来实现这一功能:
- 使用 RayCast2D 节点:通过发出一条朝向玩家的射线,模拟敌人“看向”某个方向,同时检测是否有碰撞体(例如玩家)在这条射线上。
- 判断视角夹角是否在允许范围内:我们可以计算敌人正面方向与玩家方向之间的夹角,如果这个夹角小于预定的阈值,就判断玩家在视野范围内。
- 当玩家在射线长度范围内,且与敌人正面方向的夹角小于某个阈值(例如 90 度),我们就可以认为敌人“看到了”玩家。
1.2 构建视野感知组件
在上一小节中我们了解了如何通过“射线 + 角度判断”的方式模拟一个带方向限制的视野感知系统。现在我们将使用 Godot 中的 RayCast2D 节点来实现这一功能。
RayCast2D节点介绍
RayCast2D 是一个用于发射2D射线并检测碰撞的节点,它的工作原理是从一个起点向某个方向发出一条“看不见的射线”,用来检查射线是否与其它物体发生碰撞。这在游戏开发中被广泛应用,例如判断玩家是否站在地面上,检测视线是否被遮挡,实现武器打击线、激光、探照等功能。RayCast2D 具有以下几个关键属性:
Target Position:射线的目标位置(相对于自身位置),也就是射线的方向与长度。Enabled:射线是否启用。Collision Mask:决定射线会检测哪些碰撞层中的物体。get_collider():可以获取当前射线击中的物体(如果有)。is_colliding():返回是否与某个物体发生了碰撞。
我们可以将 RayCast2D 理解为一个“看不见的探针”,它始终指向一个方向,并报告它在这条线上看到了什么。
新建视野感知组件场景
我们来创建一个新的组件,专门用于让敌人感知玩家是否出现在视野范围内。新建一个场景,根节点使用RayCast2D,然后重命名为SearchComponent。修改其根节点属性如下:
- Target Position,设置为(350,0),也就是将射线向X轴正值方向延长到350个像素单位长度。
- Collision Mask属性,设置为6,也就是PlayerBody,这样射线就只会去检测是否和玩家发生碰撞。
- 你可以在编辑器中看到一条箭头状的线,表示射线的方向与范围。之所以要让它朝向右侧,是为了让它和坦克的正面朝向一致。
具体的设置图示如下:

然后给根节点附加代码如下:
extends RayCast2D
var player_ref : Player # 保存玩家节点的引用
func _ready() -> void:
player_ref = get_tree().get_first_node_in_group("Player")
_ready函数中,使用get_tree函数获取游戏场景树,其方法get_first_node_in_group可以从某个组中获取该组的第一个节点。我们用这个功能来获取Player对象引用,便于后续判断玩家位置。请确保玩家的根节点被添加到了 "Player" 组中。
还需要注意一点,player_ref变量的类型标注是Player,确保你在 player.gd 顶部添加了 class_name Player,这样它在其它脚本中就可以作为类型使用。class_name 关键字允许你为一个脚本定义一个全局可识别的类名。这样,你可以在其他脚本中直接使用类名来引用这个类的对象。
在SearchCompnent场景的代码中,增加如下代码:
代码解释:look_at函数是一个内置函数,它会根据传入的参数进行旋转,让节点指向目标节点的位置,这样就可以让射线指向玩家了。_process函数每帧都会被调用,这样我们模拟出了敌人一直“眼睛盯着玩家”的效果。
代码解释:get_player_angle函数负责计算射线节点的相对旋转角度,transform.get_rotation函数会返回节点相对于父节点的旋转角度,abs函数会返回绝对值。因为我们会后续把这个节点放到敌方坦克上,所以父节点就是坦克本身,这个角度的意义是计算射线和坦克正前方的夹角,因为射线始终会跟踪玩家位置,这个角度也就是坦克的正前方朝向和玩家位置之间的夹角,
func player_detected():
var obj = get_collider()
if obj:
return obj.is_in_group("Player")
return false
player_detected函数负责检测射线是否和玩家发生碰撞。get_collider函数会返回射线碰上的第一个碰撞物体,如果判断它属于玩家对象,那么就返回true,否则返回false。
func can_see_player():
if get_player_angle()< PI/2 and player_detected():
return true
else:
return false
can_see_player函数判断以下两个条件是否满足,第一个条件是看射线的相对旋转角度是否小于阈值,第二个条件是玩家是否在射线的长度范围内。具体来说,所以只有当玩家在350个像素单位长度内,而且敌方坦克的正前方朝向和玩家之间的夹角绝对值小于90度,才会返回true,否则返回false。换言之,敌人的可视角度为 180度。
通过以上步骤,我们成功构建了一个独立、可重用的视野感知组件。接下来,我们将把它集成到敌方坦克的节点结构中,用于判断玩家是否进入了敌人的可见范围,并根据结果驱动敌人的攻击行为。
1.3 修改DetectComponent
在本ab 前半部分,我们为敌方坦克引入了新的视野感知组件 SearchComponent,以模拟带角度限制的视觉感知;而原有的 DetectComponent 则是使用 Area2D 实现的360 度全向感知机制,它本身仍然是一个功能完善、结构清晰的探测模块,我们完全可以将其继续保留,并应用到其他敌方单位上。为了实现统一接口、便于行为逻辑的复用,我们需要对其进行一项关键修改:将其探测函数命名统一为 can_see_player(),以与 SearchComponent 保持一致。
打开DetectComponent场景的脚本文件,修改代码:
extends Area2D
var player_ref : Player
func can_see_player():
if has_overlapping_bodies():
var targets = get_overlapping_bodies()
if not targets.is_empty():
player_ref = targets[0]
return true
return false
can_see_player函数。接口签名与 SearchComponent 完全一致,实现行为上的统一。
修改炮台场景代码
因为DetectComponent被炮台所使用,需要修改炮塔代码,打开炮塔场景,修改代码中的find_player函数:
func find_player():
if detect_component.can_see_player():
var target_pos = detect_component.player_ref.global_position
weapon_component.target(target_pos)
weapon_component.shoot(target_pos)
接口解耦与“鸭子类型”
尽管 SearchComponent(射线)与 DetectComponent(圆形区域)的底层物理实现完全不同,但对“坦克”这个宿主来说,它只关心一件事:“你能告诉我有没有看到玩家吗?” 这种不看对象类型、只看对象行为的编程风格,让我们的代码从“为特定节点编写”进化到了“为功能接口编写”。这个修改背后实际上体现了一个面向对象设计中非常重要的思想:“不同的对象,只要提供相同的方法接口,就可以被相同方式调用。”
在传统的面向对象编程(OOP)中,如果我们希望多个对象可以以统一方式使用,通常会让它们继承同一个父类或实现同一个接口。这种机制被称为多态(polymorphism),它依赖于明确的类型关系。但在像 Python、GDScript这样的动态语言中,还有另一种更灵活的方式:鸭子类型(Duck Typing)。所谓“鸭子类型”,源自一句幽默的编程谚语:“如果一个东西走起来像鸭子,叫起来也像鸭子,那我就可以把它当作鸭子。”这句话背后的含义是:不关心对象的类型是什么,只要它具有我们想调用的方法和属性,我们就认为它是合法的对象。
具体到我们的项目中:
- SearchComponent 和 DetectComponent 虽然底层实现不同(一个基于 RayCast2D,一个基于 Area2D);
- 但只要它们都提供
can_see_player()方法,并提供了player_ref属性; - 从使用者(如炮塔或坦克)的角度来看,就可以统一通过
can_see_player函数来判断是否看见玩家,无需关心底层细节。
2 构建避障组件
在上一节中,我们让敌人能够“看见玩家”,但他们仍然不够聪明。当他们在地图中巡逻或追击时,遇到障碍物时只会一头撞上去,然后卡在原地。这种行为不但看起来不智能,也会破坏游戏体验。
为了让敌人更好地适应复杂的地图环境,我们需要为他们加入“避障能力”,让他们能在前方检测到障碍物,并自动调整移动方向,像真正的智能体一样灵活穿梭。这一节中,我们将构建一个名为 AvoidComponent 的避障组件,它将赋予敌人“感知前路”的能力。而实现这一功能的关键节点,正是 Godot 中的 ShapeCast2D节点类型。
2.1 避障机制的思路
自动避障的思路就是“投石问路”。敌人的避障行为比作盲人使用盲杖行走的过程:
- 盲人无法直接看到障碍物,但会用手中的盲杖不断探测前方路面;
- 一旦探测到有障碍,便立刻调整步伐、改变方向;
- 整个过程是动态、连续、依靠触觉反馈进行决策的。
我们为敌人添加的 ShapeCast2D 节点,就像是插在敌人正前方的一根“虚拟盲杖”,它不具备视觉功能,但可以触觉般“感知”前方一段距离内是否有障碍。ShapeCast2D 是 Godot 中用于碰撞探测的节点,它的工作方式与 RayCast2D 类似,但不是发射一条“线”,而是将一个形状(Shape)从当前位置沿某个方向“投射出去”,并检测这段路径上是否会与其它物体发生碰撞。
你可以把它理解为一个“前方探测区域”,常见设置方式如下:
Shape:指定探测体的形状(例如圆形、矩形等),用来模拟体积感;Target Position:设置投射方向与距离,即“盲杖”伸出的方向;Collision Mask:设置探测哪些物体,比如只探测障碍物图层;is_colliding():判断当前是否有碰撞;get_collision_normal():获取碰撞表面的法线方向,帮助决定如何调整移动方向。
避障流程概览:
- 发出前方探测区域,敌人朝其前方投射出一个“圆形探测体”(例如半径为20像素,长度为50像素),表示“我将要经过的路径”。
- 检测是否碰撞障碍,如果探测区域内检测到障碍物(如墙体、障碍砖块),调用
is_colliding()返回true。 - 获取碰撞方向反馈,使用
get_collision_normal()获取碰撞表面的法线向量。在物理或图形学中,法线向量(normal vector)是指一个垂直于表面的单位向量。好比你行走时撞上了一堵墙,感受到的撞击力的方向,就是碰撞表面的法线向量。如果你撞上的墙会说话,它可能会说“你撞上来了,这个法线方向你别走了,我推你回去。” - 然后计算新的方向向量,将“原本想走的方向”与“法线的推力”相加,本质上是在做向量合成。法线会抵消掉指向墙壁的分量,引导单位沿着墙的边缘“滑”过去。 这个避障逻辑并不是完整的寻路算法,但它能有效应对大部分局部障碍情况,是一种高效、轻量、易部署的解决方案。
2.2 构建 AvoidComponent
在上一小节中,我们已经了解了避障的基本原理和 ShapeCast2D 的工作方式。接下来,我们将创建一个名为 AvoidComponent 的避障组件,使敌方单位在移动过程中能够探测前方的障碍物,并在必要时调整方向以避开它们。
组件场景设置
新建一个场景,根节点设置为ShapeCast2D,重命名为AvoidComponent。在属性中进行如下修改:
shape:设置为CircleShape2D,表示探测范围为一个圆形radius:设置为20,模拟敌人的身体尺寸。target position:设置为(50,0),表示投射方向为敌人正前方 50 像素collision mask:设置为5,这样它只会去检测障碍物对象
在窗口中你会看到如下的效果:

编写避障逻辑脚本
然后给根节点附加代码如下:
extends ShapeCast2D
@export var force: float = 2 # 用来改变坦克的移动方向
func obstackes_detected():
return is_colliding()
obstackes_detected函数负责检测是否存在障碍物,is_colliding是一个内置函数,它会检测是否和其它碰撞体发生了碰撞,如果是则返回true,否则返回false。
func get_new_direction():
if obstackes_detected():
var normal_vec = get_collision_normal(0)
var direction = owner.transform.x + normal_vec * force
return direction
get_new_direction函数负责计算新的移动方向,如果判断存在障碍物,那么就会通过get_collision_normal函数来计算出法线向量,owner是指节点的宿主,避障组件的宿主就是敌方坦克单位,将法线向量乘以force再加上坦克的当前移动方向,就可以计算出一个新的移动方向,作为函数的返回值。这个函数的意思就是:我想继续往前走,但撞到了东西,于是我基于原本的移动方向,再加上一点‘弹开’的方向,以稍微调整方向。
2.3 与敌方坦克场景集成
现在我们已经完成了视野感知组件 SearchComponent 和避障组件 AvoidComponent 的功能设计,接下来要将这两个组件集成到敌方坦克中,让它真正具备“看”和“绕”的能力。
修改敌方坦克场景
打开敌方坦克场景enemy_tank.tscn,将原来的DectectComponent(360度探测器)删除,在节点树中增加视野感知组件SearchComponent,然后增加避障组件AvoidComponent,在右侧属性面板中将force设置为2。目前的敌方坦克的节点树如下图

修改敌方坦克代码
坦克的相应代码也需要修改,打开代码文件,修改代码如下:
# 定义两个引用节点变量
@onready var search_component: Node2D = $SearchComponent
@onready var avoid_component: Node2D = $AvoidComponent
func update_direction(target_pos: Vector2):
var direction = global_position.direction_to(target_pos)
if avoid_component.obstackes_detected():
direction = avoid_component.get_new_direction()
var angle_rad = direction.angle()
var delta = get_physics_process_delta_time()
rotation = rotate_toward(rotation,angle_rad,2*PI*delta )
- 修改
update_direction函数,其中需要增加条件判断,当检测到障碍物,那么就会调用避障组件的get_new_direction函数,从而计算出新的移动方向。 - 然后根据新的方向来计算出新的旋转角度,然后旋转到新的角度。这里
rotation属性的赋值中,应用到了rotate_toward函数,我们在Player中也用到了这个函数,它会进行插值计算,让角度旋转更加平滑。
验证避障组件功能
然后将修改后的敌方坦克场景放置到game场景的Enemies节点下,使用移动工具将它放置到地图中的道路上。然后在坦克节点中,编辑其Points属性,设置为当前节点树中的Points节点。
为了验证和理解避障组件的功能,我们可以做一个小实验,按照如下步骤来操作和观察。
首先在AvoidComponent 代码中增加如下的打印功能,打印出三个向量便于观察。
func get_new_direction():
if obstackes_detected():
var normal_vec = get_collision_normal(0)
print("normal vector: %s" %normal_vec)
var direction = owner.transform.x + normal_vec * force
print("owner right vector: %s" %owner.transform.x)
print("new direction: %s" %direction)
return direction
_process函数来调用上面这个函数
在实验的时候,我们需要暂时让坦克不要移动和射击,打开坦克节点树中的StateMachine节点代码,增加如下的代码:
这样就会暂时禁用状态机。然后打开Player场景,暂时将其Collision Layer设置为5和6,这样会被认为是障碍物。为了观察到碰撞效果,我们可以点击编辑器上方菜单的Debug,选择Visible Collisions Shapes。让我们把玩家坦克开到它的前方。游戏运行时如下图所示:

从实验的输出可以观察到,避障组件检测到障碍物的时候会变成红色,表示检测到障碍物。下面Output窗口区域会输出三种向量的值。normal vector是两个碰撞区接触时的法线向量,因为玩家坦克位于右上方,所以法线向量就指向左下方的方向,具体的值是(-0.83,0.55),owner right vector是坦克正面朝向向量,这个值是(1,0),new direction是计算出的新的移动方向,这个值是(1,0)+(-0.83,0.55)*2。这个新的移动方向计算出来是(-0.66,1.11),这个方向是左下方,坦克就会规避障碍物。
实验观察完成后,记得将三个地方还原。
- Player的
Collision Layer设置为6 - 删除AvoidComponent中代码的
_process函数和三处print代码。 - 将StateMachine中代码的
_ready函数中的set_physics_process(true)代码一行删除。
还原后再次进入游戏,测试一下敌方坦克的视野感知组件,当玩家坦克从前方靠近敌方坦克时,它会发起攻击。如果玩家坦克从后方靠近敌方坦克,敌人将不会察觉,提升潜行策略的可行性。
3 敌方单位状态机扩展
当前我们的敌方单位在状态机中只具备两种行为模式:巡逻(Patrol)与追击(Attack)。一旦玩家进入敌人的视野范围,敌人就会立刻转入追击状态;而当玩家脱离视野,敌人会立刻放弃目标,回到原路径继续巡逻。虽然这样的行为逻辑简单清晰,但从游戏体验的角度看,这种“看见就追,消失就放弃”的反应过于机械,显得不够智能和真实。现实中,如果一个警卫刚刚看到可疑人物,他并不会一转身就当作什么也没发生,而是会在最后看到的位置附近搜索一段时间,试图找回目标。
为了模拟这种“目标丢失后的主动搜索行为”,我们设计了一个新状态:索敌状态(Wander ),这个状态模拟了敌人“丢失目标后仍主动搜索一段时间”的行为。
3.1 索敌状态设计思路
索敌状态介于追击与巡逻之间,是一个“短期记忆+区域搜索”的中间过程。其行为模式可以用如下流程描述:
- 由追击状态触发:当敌人正在追击玩家,但
can_see_player()变为false,即玩家脱离视野。 - 转入索敌状态:敌人根据当前朝向与上一次玩家位置,生成一个大致方向的搜索点。
- 短时间内持续移动:敌人在该区域进行有限范围的探索(带有一定随机性),模拟“凭印象找人”。
- 搜索失败后回归巡逻:若在设定时间内未重新发现玩家,则转入巡逻状态。
- 随时中断:若在索敌过程中再次发现玩家,立即中断搜索并重新追击。
下一小节中,我们将开始动手实现这一状态的具体节点结构与代码逻辑,并将其集成到敌人的状态机中,让它真正具备“丢失目标后继续搜索”的能力。
3.2 实现EnemyWander状态
本节将基于该设计,具体实现这个新状态 EnemyWander,并将其集成进敌人的状态机中。
创建EnemyWander状态节点
我们仍然会继承之前建立的State基础类。新建一个场景,根节点设置为State,重命名为EnemyWander。然后给它增加一个Timer子节点。用于限制索敌持续时间。附加代码如下:
extends State
@export var enemy: Node2D # 坦克节点的引用
@export var distance: float = 200 # 设置移动目标点的距离
@export var search_component :Node2D # 坦克的视野感知组件
@onready var timer: Timer = $Timer # 定时器,用于控制索敌状态持续的时间
var target_pos : Vector2 # 移动目标点的坐标
var noise: float = 50 # 用于设置随机值大小
var end_wander: bool # 用于标志是否结束索敌状态
func find_next_point():
var random_x = randf_range(-noise, noise)
var random_y = randf_range(-noise, noise)
var random_vec = Vector2(random_x,random_y)
var pos = distance * enemy.transform.x + random_vec
target_pos = enemy.global_position + pos
find_next_point函数用于确定下一个移动目标点,它会生成一个“朝前略带随机性”的搜索目标点。这样可以模拟敌人在某个方向搜索但路径又不完全可预测的效果。- 函数中使用
randf_range函数产生了两个在(-noise,noise)范围内的随机值,这两个随机值构成一个向量 - 然后根据
enemy的前进方向去乘以distance,也就得到前进方向的延长线,在这个延长线上加上这个随机向量,就得到了下一个移动目标点的相对坐标。 - 相对坐标再加上
enemy的全局坐标,就得到了下一个移动目标点的全局坐标。
func near_point():
if enemy.global_position.distance_to(target_pos) < 5:
return true
else:
return false
near_point函数用于判断坦克是否到达了目标点,使用distance_to函数计算了坦克和目标点的距离,如果距离小于5,就返回true,否则返回false。
# 将定时器和响应函数进行关联
func _ready() -> void:
timer.timeout.connect(on_time_out)
func enter():
end_wander = false
find_next_point()
timer.start(10)
end_wander设置为false,之后调用了find_next_point函数设置了下一个移动目标点,然后调用timer的start方法开始了索敌状态的持续时间为10秒。
on_time_out函数用于结束索敌状态,函数将end_wander设置为true。
# 用于处理更新逻辑
func physics_update(delta: float) -> void:
if search_component.can_see_player():
transitioned.emit(self,"EnemyAttack")
else:
if not end_wander:
enemy.update_direction(target_pos)
enemy.move()
if near_point():
find_next_point()
else:
transitioned.emit(self,"EnemyPatrol")
- 调用
search_component的函数can_see_player来判断有没有看到玩家坦克,如果看到则发出EnemyAttack信号,进入追击状态。 - 如果没看到,则判断有没有结束索敌状态,如果没有结束则更新坦克的方向,移动坦克
- 如果到达目标点则重新设置目标点,如果结束索敌状态则发出EnemyPatrol信号,进入巡逻状态。
3.3 集成到敌方坦克场景
索敌状态完成后,给敌方坦克场景增加此状态,打开敌方坦克场景文件enemy_tank.tscn。在StateMachine节点下增加一个EnemyWander节点。并设置其三个属性:
- Enemy属性设置为节点树中的根节点。
- SearchComponent属性设置为节点树中的SearchComponent节点。
- Distance属性设置为200
为了让状态流转到索敌,还需要修改EnemyAttack节点代码,修改代码如下:
func physics_update(delta: float) -> void:
if search_component.can_see_player():
target_pos = search_component.player_ref.global_position
enemy.update_direction(target_pos)
enemy.move()
weapon_component.target(target_pos)
weapon_component.shoot(target_pos)
else:
transitioned.emit(self,"EnemyWander")
完成上述设置后,敌人将具备三段式行为模式:巡逻 → 发现玩家 → 攻击 → 玩家消失 → 索敌 → 未找到 → 回到巡逻。你可以在游戏中测试这一完整行为链条。随着状态系统的丰富,敌人的反应将更像一个“有目标、会犹豫”的智能体,而不再是简单的条件触发机关。
4 导弹攻击车设计与实现
随着敌方单位行为的日益复杂,我们已经构建了具备视野感知、避障与状态切换能力的坦克类敌人。为了进一步提升战斗节奏的丰富性和玩家决策的多样性,本节将引入一种全新类型的敌人单位导弹攻击车(Missile Vehicle)。相比于此前的固定炮台和敌方坦克单位,导弹攻击车将融合远程打击、自动追踪等特点,成为一种具有“区域控制力”的高威胁单位。
4.1 导弹攻击车设计
导弹攻击车是敌方阵营中的“远程压制者”,其主要目标是通过发射自动追踪的导弹,对玩家构成持续威胁,并迫使玩家采取规避动作或主动反击。它不像普通炮塔那样静止不动,也不像坦克那样近战接近,而是一种半移动+远程压制的单位。
其在敌人体系中的功能定位如下:
| 类型 | 功能特点 | 局限性 |
|---|---|---|
| 炮塔 | 固定发射,范围大,反应快 | 无法移动 |
| 坦克 | 可移动追击,带有近距离压迫 | 攻击方式单一,需要接近目标 |
| 导弹攻击车 | 移动 + 远程追踪导弹,区域压制 | 攻击间隔较长,导弹可被击毁 |
导弹攻击车的行为特征如下:
| 能力类型 | 设计说明 |
|---|---|
| 感知方式 | 使用360°感知组件,具备全方位探测能力 |
| 攻击方式 | 发射带有自动追踪功能的导弹 |
| 发射逻辑 | 具有一定冷却时间 |
| 导弹行为 | 一旦发射,可自动转向追踪玩家位置 |
| 移动能力 | 可以沿路径巡逻,具备避障与状态切换能力 |
| 弱点设计 | 导弹可被玩家击落 |
接下来的小节中,我们将逐步实现导弹攻击车的关键组件:导弹本体、导弹发射器与车辆结构,并将其集成进现有敌方行为体系。
4.2 构建导弹场景
导弹作为一种特殊的远程攻击单位,与我们之前实现的普通炮弹既有相似之处,又有显著差异。在构建导弹场景时,我们希望它具备以下功能:
- 能够自动追踪玩家坦克,因此会挂载探测组件。
- 命中玩家坦克时,会对其进行杀伤,因此会挂载伤害组件。
- 能够被玩家炮弹所击毁,因此会挂载承伤组件和血量组件。
- 导弹可以无视地图中的障碍物,直接穿越障碍飞行。
在架构设计上,我们沿用了面向组件的思路:导弹自身是一个容器,通过组合不同的功能组件来实现所需能力。由于导弹的部分行为与普通炮弹相似(如移动、击中后销毁),我们可以直接继承炮弹基础场景 bullet_base.tscn 来避免重复工作。
场景继承与设置
按如下步骤来构导弹建场景:
-
在菜单中选择New Inherited Scene,选择bullet_base.tscn文件,点击OK,就可以得到一个继承炮弹的导弹场景。将根节点重命名为EnemyMissile,卸载掉原有的代码文件,然后保存为enemy_missile.tscn。
-
导弹场景需要新的图像表示,选中Sprite2D。找到towerDefense_tile251.png,将其拖入到Texture属性中,因为图像朝向为右,不需要再旋转;将Rotation设置为0,然后保存。
-
选择HitBoxComponent,在Collisition Mask设置中,只选择2,这样就会忽略障碍物,只对玩家坦克进行碰撞检测。
-
然后给导弹增加探测组件,在根节点下放置一个DetectComponent,将Collision Mask设置为6。然后在探测组件下增加一个CollisionShape2D子节点,将Shape设置为CircleShape2D,Radius设置为100,模拟导弹本身的感知范围。
-
导弹应能被玩家攻击击毁,因此我们为它添加承伤系统,在根节点下放置一个HurtBoxComponent,将Collisiton Layer设置为4,Mask设置为1。然后在HurtBoxComponent下增加一个CollisionShape2D子节点,将Shape设置为CapsuleShape2D,调整形状大小,让它和导弹图片大小差不多即可。
-
最后是给导弹增加血量组件,在根节点下放置一下HealthComponent,血量设置为1,表示被命中一次即销毁。
-
还要注意给根节点增加组标签,标签设置为EnemyMissile。用于后续特效、逻辑判断中快速识别该类型单位。
此时导弹的节点树已经设置完成,它的一部分是继承自炮弹,另一部分是独立的。节点树显示如下:

导弹场景代码编写
我们还需要给它附加代码,增加代码文件如下:
extends Node2D
@export var speed:float = 300 # 导弹的移动速度
@onready var visible_on_screen_notifier_2d: VisibleOnScreenNotifier2D = $VisibleOnScreenNotifier2D
@onready var hit_box_component: Area2D = $HitBoxComponent
@onready var detect_component: Area2D = $DetectComponent
@onready var health_component: Node = $HealthComponent
@onready var hurt_box_component: Area2D = $HurtBoxComponent
# 将各个节点的事件连接到响应函数
func _ready():
hit_box_component.hit.connect(on_hit)
hurt_box_component.get_damage.connect(health_component.get_damage)
visible_on_screen_notifier_2d.screen_exited.connect(on_screen_exited)
health_component.died.connect(on_died)
func _process(delta: float) -> void:
if detect_component.can_see_player():
update_direction(detect_component.player_ref.global_position,delta)
move_forward(delta)
func update_direction(target_pos: Vector2,delta: float):
var direction = global_position.direction_to(target_pos)
var angle_rad = direction.angle()
rotation = rotate_toward(rotation,angle_rad,2*PI*delta )
func move_forward(delta):
position += transform.x * speed * delta
update_direction函数负责调整导弹的朝向角度,它会根据目标位置计算出导弹的方向,然后旋转到新的角度。move_forward函数负责朝transform.x方向前进。_process函数则是调用探测组件功能,以判断是否可以看见玩家。然后调用update_direction和move_forward函数。这样就可以实现“自动追踪”。
func on_hit():
Gamemanager.bullet_hit.emit(global_position)
queue_free()
func on_screen_exited():
queue_free()
func on_died():
Gamemanager.entity_died.emit(global_position,get_groups())
queue_free()
- on_hit函数负责处理导弹被击中的情况
- on_screen_exited函数负责处理导弹离开屏幕的情况,
- on_died函数负责处理导弹被击毁的情况。
导弹的结构现在已经搭建完毕,它是一个高度组件化、继承复用良好的单位类型。
更新爆炸特效处理逻辑
在特效管理中,我们只处理了两种单位被击毁的情况,分别是敌人和玩家,现在需要处理导弹被击毁的情况,因此需要在VFX_Manager中修改函数。
func on_entity_died(pos:Vector2, groups:Array):
if "Enemy" in groups:
explode(pos,"regular_exp")
elif "EnemyMissile" in groups:
explode(pos, "explosion")
elif "Player" in groups:
explode(pos,"sonic_exp")
5.3 构建发射组件(MisLaunchComponent)
为了让导弹攻击车具备“智能发射导弹”的能力,我们将发射逻辑独立封装为一个组件,命名为 MisLaunchComponent。这个组件的职责包括:
- 根据目标位置朝向目标;
- 在预设的冷却时间间隔内连续发射导弹;
- 播放导弹发射的音效;
创建场景和设置
新建一个场景,根节点为Node2D节点,重命名为MisLaunchComponent,然后在MisLaunchComponent下放置三个子节点:
- Sprite2D节点,用于显示发射器的图像,从资源中找到towerDefense_tile204.png设置为贴图。
- AudioStreamPlayer2D节点,用于播放音效,重命名为ShootSound。文件系统中找到rocket.ogg,将其设置为音效资源,可以试听一下调整音量。
- Timer节点,控制导弹冷却间隔
- Sprite2D节点下增加两个Marker2D节点,用于表示左右两个导弹的发射位置,调整其坐标分别设置为(46,9)和(46,-9)。
场景设置完成后节点树的图例如下:

编写场景代码:
为 MisLaunchComponent 附加脚本,并编写如下代码:
extends Node2D
var can_shoot: bool = true #导弹是否可以发射
var min_cool_down: float = 0.2 #发射后的最小冷却时间
@export var cool_down: float = 0.5 #冷却时间
@export var missile_scene: PackedScene #导弹场景变量
@onready var marker_1: Marker2D = $Sprite2D/Marker2D
@onready var marker_2: Marker2D = $Sprite2D/Marker2D2
@onready var shoot_sound: AudioStreamPlayer2D = $ShootSound
@onready var timer: Timer = $Timer
@onready var launch_pos = [marker_1, marker_2] # 导弹发射位置的数组变量
func _ready() -> void:
timer.timeout.connect(on_time_out)
func target(target_pos:Vector2):
look_at(target_pos)
- _ready函数将定时器的timeout事件连接到on_time_out函数
- target函数负责设置目标位置,然后调整发射器的角度。
func shoot(target_pos:Vector2):
if can_shoot:
timer.start(cool_down)
shoot_sound.play()
can_shoot = false
var missile = missile_scene.instantiate()
missile.global_position = launch_pos.pick_random().global_position
missile.look_at(target_pos)
missile.top_level = true
add_child(missile)
- shoot函数负责发射导弹,判断如果导弹可以发射,则启动定时器,播放音效
- 然后将导弹场景实例化,将其位置设置为两个导弹发射位置的其中随机一个
- 然后设置导弹的角度,最后添加到场景中。
top_level变量是用于将导弹设置为顶层节点,导弹的运动不会受到发射器运动的影响。
func on_time_out():
can_shoot = true
func upgrade(value):
cool_down = max(min_cool_down,cool_down-value )
on_time_out函数负责重置导弹发射状态upgrade函数负责升级导弹发射组件。可以通过降低冷却时间提升射速,但不会低于min_cool_down限制。
通过这个组件化的设计,我们可以轻松将 MisLaunchComponent 应用于任何导弹攻击单位
5.4 构建导弹攻击车(EnemyMisVehicle)
导弹攻击车在功能框架上与之前的敌方坦克类似,依然采用组件化架构和状态机控制,但它在以下几个方面展现了差异化设计:
- 远程火力输出:导弹攻击车使用的是我们在上一节开发的 MisLaunchComponent,替代了原有的 WeaponComponent,可以远程精确打击玩家;
- 全向感知能力:使用 DetectComponent 实现360度感知,适合无视视野限制、全方位感应的单位行为;
- 战术配置调整:移动速度稍慢、血量略高,体现其重火力单位的定位。
场景创建和设置
新建一个场景,根节点为CharacterBody2D节点,重命名为EnemyMisVehicle。设置Collisiton Layer为7,Mask为5,6,7。
在根节点下方添加并设置如下子节点:
- Sprite2D,显示导弹攻击车的图像,重命名为Base,在文件中找到tankBody_darkLarge_outline.png,将其设置为纹理。
- CollisionShape2D,用于表示导弹攻击车的碰撞形状,设置Shape为CircleShape2D,Radius设置为25。
- MisLaunchComponent,导弹发射组件,设置发射间隔为2秒,设置导弹场景文件为enemy_missile.tscn。
- HurtBoxComponent,用于导弹攻击车的承伤组件,设置Collisiton Layer为4,Mask为1。在节点下放置CollisionShape2D子节点,设置Shape为CircleShape2D,Radius设置为25。
- DectectComponent,用于导弹攻击车的探测组件,设置Collision Mask为6。在节点下放置CollisionShape2D子节点,设置Shape为CircleShape2D,Radius设置为400。
- AvoidComponent,用于导弹攻击车的避障组件。
- HealthComponent,用于导弹攻击车的血量组件,设置血量为5。
- TrailComponent,用于导弹攻击车的轨迹组件,设置Trail Length为10。
- StateMachine,用于导弹攻击车的状态机,设置初始状态为EnemyPatrol。在节点下放置三种状态节点,分别是EnemyPatrol,EnemyWander,EnemyAttack。需要将这些状态节点的的属性设置好。
- AnimationPlayer,用于表示受伤时的闪烁动画。可以从坦克场景中复制过来。
最终的节点树如下所示:

编写导弹攻击车控制脚本
附加脚本文件并添加如下代码:
extends CharacterBody2D
@export var points: Node2D
@export var speed: float = 100
@onready var weapon_component: Node2D = $MisLaunchComponent
@onready var hurt_box_component: Area2D = $HurtBoxComponent
@onready var health_component: Node = $HealthComponent
@onready var detect_component: Area2D = $DetectComponent
@onready var avoid_component: Node2D = $AvoidComponent
@onready var animation_player: AnimationPlayer = $AnimationPlayer
@onready var trail_compoment: Node2D = $TrailCompoment
func _ready() -> void:
hurt_box_component.get_damage.connect(health_component.get_damage)
health_component.died.connect(on_died)
hurt_box_component.get_damage.connect(on_get_damage)
func _physics_process(delta: float) -> void:
trail_compoment.start()
_ready函数中将几种信号和响应函数进行关联,_physics_process函数中启动轨迹组件的更新。
func on_died():
Gamemanager.entity_died.emit(global_position,get_groups())
queue_free()
func on_get_damage(damage):
animation_player.play("flash")
on_died函数是敌人死亡的响应函数,它会发出entity_died信号,然后销毁自己。on_get_damage函数是敌人受到伤害的响应函数,它会播放flash动画。
func update_direction(target_pos: Vector2):
var direction = global_position.direction_to(target_pos)
if avoid_component.obstackes_detected():
direction = avoid_component.get_new_direction()
var angle_rad = direction.angle()
var delta = get_physics_process_delta_time()
rotation = rotate_toward(rotation,angle_rad,2*PI*delta )
func move():
velocity = transform.x * speed
move_and_slide()
- update_direction函数负责更新导弹攻击车的移动方向,逻辑和敌方坦克的移动一样。通过避障组件判断当前是否存在障碍物,若有则重新计算方向。
- move函数负责移动导弹攻击车。
通过这些设置与代码,导弹攻击车便成为一类具有独特行为和战略意义的敌人单位。它不仅能全方向感知玩家,还能持续打击远处目标。
5 实验与测试
通过前面的章节,我们已经完成了视野感知系统、避障机制以及导弹攻击车的设计与实现。现在,我们需要将这些功能整合到游戏的主场景中,并进行整体测试,确保所有新增机制协同工作,带来更真实、更具挑战性的游戏体验。
下面在主场景中添加导弹攻击车并进行设置:
- 打开主场景 game.tscn,在 Enemies 节点下新增一个 EnemyMisVehicle 节点。
- 调整其初始位置,确保其位于道路区域,避免出生时即发生碰撞。
- 编辑 EnemyMisVehicle 的 points 属性,选择场景中的 Points 节点,确保其可以正常巡逻。
- 可按需再添加 1~2 个普通敌人坦克单位,用于观察不同单位的探测功能。
- 在菜单栏点击:Debug → Visible Collision Shapes,这样我们可以清晰地看到以下内容
- 导弹攻击车探测区域的范围
- 避障组件的检测开关或碰撞区
- 导弹在飞行时的碰撞与运动路径
主场景设置完成后的效果如下:

运行主场景后,你会看到导弹攻击车和敌方坦克都开始巡逻。我们可以依次验证如下关键行为:
- 视野感知与追踪判断。玩家若从敌方坦克正前方靠近,其会旋转朝向玩家,并发射子弹。若从后方接近,则不会触发攻击。不过导弹攻击车使用的是 360 度探测,看到玩家后会全方位响应。
- 避障机制表现良好。玩家可以将敌方单位引诱到障碍物周围,行进路线被障碍物阻挡时,其会绕开障碍物而不是持续卡位;输出窗口若打印向量值,可验证法线计算方向与修正后的移动方向。
- 发射逻辑与冷却节奏。导弹能够以设定频率自动发射;导弹命中玩家后触发爆炸特效,并销毁自己;
- 受伤反馈与单位销毁。玩家炮弹击中两种敌方移动单位后,其会播放闪烁动画;当它们血量耗尽后,这些单位会被销毁,并触发爆炸粒子效果。如果玩家攻击导弹,导弹也会被击毁。
通过本节的集成与测试,我们完成了整个“敌人AI升级”模块的开发:从视野感知、智能避障到远程导弹打击,每一步都通过组件化结构实现高内聚、低耦合,易于维护和拓展。你已经拥有了开发高级敌人行为的完整能力!
本章小结
在本章中,我们围绕“让敌人看见并聪明地移动”这一核心目标,深入构建了视野感知系统与避障机制,显著提升了游戏的策略性与智能表现。
在感知系统的开发中,我们通过 RayCast2D 构建了具备视角限制的 SearchComponent,成功模拟出类似真实生物的扇形视野。通过对比 360 度全向感知的 DetectComponent,我们利用“鸭子类型”思想统一了接口设计,使系统在保持灵活性的同时更具可拓展性。
在解决“移动”方面,我们引入了基于 ShapeCast2D 的 AvoidComponent。通过深入理解碰撞法线向量的物理意义,即指向“不可通行”的方向,我们利用法线反作用力与原始移动向量叠加,巧妙地为敌人计算出避险路径。这种基于局部感知的绕行逻辑,让敌人告别了简单的“盲目冲撞”,实现了更自然的动态避障行为。
本章的另一大重点是构建了新型敌人单位“导弹攻击车”。我们不仅为其设计了支持冷却控制与视觉反馈的可重用发射组件 MisLaunchComponent,还实现了一套完整的自主追踪导弹系统。从导弹的追踪算法到最终的爆炸、受伤交互逻辑,这套感知—避障—攻击链条的协同运行,通过主场景的集成测试,证明了复杂 AI 行为树在实战中的稳定性。
虽然本章实现的避障机制简单高效,但其本质上属于“局部避障”,在处理复杂地形时仍存在局限性。由于缺乏全局路径规划能力,敌人目前只能应对正前方的障碍,无法预判更远处的复杂阻挡,在遇到 U 型墙或长距离障碍时容易出现“卡边”或无法寻找最优路径的情况。
为了彻底解决这些痛点,下一章我们将深入探讨 Godot 的 Navigation 导航系统。我们将从局部避障跨越到全局寻路,让敌人真正具备“像人一样规划路线”的能力。