跳转至

第十章:感知系统与避障

本章导言

在前面的章节中,我们已经初步构建了一个具备基础行为逻辑的敌方单位。虽然它们已经能够在地图上巡逻并在发现玩家时发起攻击,但在实际测试中,这种基础 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,这样射线就只会去检测是否和玩家发生碰撞。
  • 你可以在编辑器中看到一条箭头状的线,表示射线的方向与范围。之所以要让它朝向右侧,是为了让它和坦克的正面朝向一致。

具体的设置图示如下: alt text

然后给根节点附加代码如下:

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场景的代码中,增加如下代码:

func _process(delta: float) -> void:
    if player_ref:
        look_at(player_ref.global_position) 
代码解释:look_at函数是一个内置函数,它会根据传入的参数进行旋转,让节点指向目标节点的位置,这样就可以让射线指向玩家了。_process函数每帧都会被调用,这样我们模拟出了敌人一直“眼睛盯着玩家”的效果。

func get_player_angle():
    var angle = transform.get_rotation()
    return abs(angle)
代码解释: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,这样它只会去检测障碍物对象

在窗口中你会看到如下的效果: alt text

编写避障逻辑脚本

然后给根节点附加代码如下:

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。目前的敌方坦克的节点树如下图 alt text

修改敌方坦克代码

坦克的相应代码也需要修改,打开代码文件,修改代码如下:

# 定义两个引用节点变量
@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函数来调用上面这个函数
func _process(delta: float) -> void:
    get_new_direction

在实验的时候,我们需要暂时让坦克不要移动和射击,打开坦克节点树中的StateMachine节点代码,增加如下的代码:

func _ready():
    set_physics_process(false)
这样就会暂时禁用状态机。然后打开Player场景,暂时将其Collision Layer设置为5和6,这样会被认为是障碍物。为了观察到碰撞效果,我们可以点击编辑器上方菜单的Debug,选择Visible Collisions Shapes。让我们把玩家坦克开到它的前方。游戏运行时如下图所示:

alt text

从实验的输出可以观察到,避障组件检测到障碍物的时候会变成红色,表示检测到障碍物。下面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)
代码解释:enter函数用于进入索敌状态,函数先将end_wander设置为false,之后调用了find_next_point函数设置了下一个移动目标点,然后调用timer的start方法开始了索敌状态的持续时间为10秒。

func on_time_out():
    end_wander = true   
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。用于后续特效、逻辑判断中快速识别该类型单位。

此时导弹的节点树已经设置完成,它的一部分是继承自炮弹,另一部分是独立的。节点树显示如下: alt text

导弹场景代码编写

我们还需要给它附加代码,增加代码文件如下:

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_directionmove_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)。

场景设置完成后节点树的图例如下: alt text

编写场景代码:

为 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,用于表示受伤时的闪烁动画。可以从坦克场景中复制过来。

最终的节点树如下所示: alt text

编写导弹攻击车控制脚本

附加脚本文件并添加如下代码:

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
代码解释:points变量是指在关卡中的巡逻点所在节点。speed定义了移动速度。其它变量是子节点引用

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 实验与测试

通过前面的章节,我们已经完成了视野感知系统、避障机制以及导弹攻击车的设计与实现。现在,我们需要将这些功能整合到游戏的主场景中,并进行整体测试,确保所有新增机制协同工作,带来更真实、更具挑战性的游戏体验。

下面在主场景中添加导弹攻击车并进行设置:

  1. 打开主场景 game.tscn,在 Enemies 节点下新增一个 EnemyMisVehicle 节点。
  2. 调整其初始位置,确保其位于道路区域,避免出生时即发生碰撞。
  3. 编辑 EnemyMisVehicle 的 points 属性,选择场景中的 Points 节点,确保其可以正常巡逻。
  4. 可按需再添加 1~2 个普通敌人坦克单位,用于观察不同单位的探测功能。
  5. 在菜单栏点击:Debug → Visible Collision Shapes,这样我们可以清晰地看到以下内容
    • 导弹攻击车探测区域的范围
    • 避障组件的检测开关或碰撞区
    • 导弹在飞行时的碰撞与运动路径

主场景设置完成后的效果如下: alt text

运行主场景后,你会看到导弹攻击车和敌方坦克都开始巡逻。我们可以依次验证如下关键行为:

  • 视野感知与追踪判断。玩家若从敌方坦克正前方靠近,其会旋转朝向玩家,并发射子弹。若从后方接近,则不会触发攻击。不过导弹攻击车使用的是 360 度探测,看到玩家后会全方位响应。
  • 避障机制表现良好。玩家可以将敌方单位引诱到障碍物周围,行进路线被障碍物阻挡时,其会绕开障碍物而不是持续卡位;输出窗口若打印向量值,可验证法线计算方向与修正后的移动方向。
  • 发射逻辑与冷却节奏。导弹能够以设定频率自动发射;导弹命中玩家后触发爆炸特效,并销毁自己;
  • 受伤反馈与单位销毁。玩家炮弹击中两种敌方移动单位后,其会播放闪烁动画;当它们血量耗尽后,这些单位会被销毁,并触发爆炸粒子效果。如果玩家攻击导弹,导弹也会被击毁。

通过本节的集成与测试,我们完成了整个“敌人AI升级”模块的开发:从视野感知、智能避障到远程导弹打击,每一步都通过组件化结构实现高内聚、低耦合,易于维护和拓展。你已经拥有了开发高级敌人行为的完整能力!

本章小结

在本章中,我们围绕“让敌人看见并聪明地移动”这一核心目标,深入构建了视野感知系统与避障机制,显著提升了游戏的策略性与智能表现。

在感知系统的开发中,我们通过 RayCast2D 构建了具备视角限制的 SearchComponent,成功模拟出类似真实生物的扇形视野。通过对比 360 度全向感知的 DetectComponent,我们利用“鸭子类型”思想统一了接口设计,使系统在保持灵活性的同时更具可拓展性。

在解决“移动”方面,我们引入了基于 ShapeCast2D 的 AvoidComponent。通过深入理解碰撞法线向量的物理意义,即指向“不可通行”的方向,我们利用法线反作用力与原始移动向量叠加,巧妙地为敌人计算出避险路径。这种基于局部感知的绕行逻辑,让敌人告别了简单的“盲目冲撞”,实现了更自然的动态避障行为。

本章的另一大重点是构建了新型敌人单位“导弹攻击车”。我们不仅为其设计了支持冷却控制与视觉反馈的可重用发射组件 MisLaunchComponent,还实现了一套完整的自主追踪导弹系统。从导弹的追踪算法到最终的爆炸、受伤交互逻辑,这套感知—避障—攻击链条的协同运行,通过主场景的集成测试,证明了复杂 AI 行为树在实战中的稳定性。

虽然本章实现的避障机制简单高效,但其本质上属于“局部避障”,在处理复杂地形时仍存在局限性。由于缺乏全局路径规划能力,敌人目前只能应对正前方的障碍,无法预判更远处的复杂阻挡,在遇到 U 型墙或长距离障碍时容易出现“卡边”或无法寻找最优路径的情况。

为了彻底解决这些痛点,下一章我们将深入探讨 Godot 的 Navigation 导航系统。我们将从局部避障跨越到全局寻路,让敌人真正具备“像人一样规划路线”的能力。