最近在做 UE5 战斗 AI 时,“狂暴敌人”这个需求让我折腾了好一阵子。表面上看只是几个状态来回切换,但真正把行为树搭起来之后才发现,状态切不过去、分支中断、黑板数据不同步的问题一个接一个。后来我把“战斗状态机”和“行为树”两者的职责边界重新梳理了一遍,整个战斗 AI 才稳定下来。这篇文章就是一次完整复盘,从底层概念到实战构建都会讲到,包含可直接参考的 C++ 类示例和蓝图行为树搭建思路,适合正在做 UE5 战斗系统的开发者,也适合刚接触行为树的同学。
1. 背景与核心概念
1.1 什么是“狂暴敌人”AI
“狂暴敌人”是战斗类游戏中很常见的敌人设计,通常表现为:当生命值低于某个阈值,或者受到一定次数攻击后,敌人会进入一种更激进的状态,移动速度上升、攻击频率变快、伤害变高,甚至解锁新的技能。
从 AI 实现的角度看,它不是一个单纯的行为树分支,而是整个 AI 行为框架需要支持“运行时状态变化”。也就是说,AI 不能只靠一两个条件判断写死,它需要一套机制来感知自身状态、环境状态,并在不同状态下切换不同的行为策略。
用 UE5 来实现,一般有三种思路:
- 纯蓝图状态机:用枚举变量 + 分支判断实现。
- 纯行为树:把状态用黑板键表示,行为树里大量使用装饰器做条件分流。
- 状态机 + 行为树:状态机负责状态切换,行为树负责当前状态下的行为执行。
第三种是项目实战中性价比最高的方案,也是本文重点。
1.2 行为树是什么
行为树是一种树状结构的决策系统,阅读和执行顺序很直观。树的根节点一般是Root,往下挂Selector、Sequence这样的组合节点,最终落到Task节点上。
行为树的执行逻辑可以简单理解为:
Selector:从上往下找第一个能执行的分支。Sequence:从上往下顺序执行所有子节点,任意一个失败则整条序列失败。Task:真正执行动作,例如移动、攻击、等待。Decorator:装饰器,附加在节点上,控制该分支是否允许执行。Service:服务,在节点执行期间不断更新外部数据,例如刷新玩家位置。
行为树在 UE5 中的最大优点是“可视化、可配置”,美术和策划也能看懂一部分逻辑,方便调参。
1.3 状态机是什么
状态机,全称有限状态机(FSM),核心思想是“在任一时刻,对象只能处于有限个状态中的一个,并且只能从当前状态切换到允许的相邻状态”。
战斗 AI 中常见的状态有:
- 待机 Idle
- 巡逻 Patrol
- 追踪 Chase
- 攻击 Attack
- 狂暴 Berserk
状态机擅长处理“状态切换条件”,但一旦状态多起来,嵌套的 if-else 会非常难维护。所以需要行为树来承接“每个状态下具体怎么做”的执行逻辑。
1.4 为什么要状态机 + 行为树结合
状态机解决“何时切换状态”的问题,行为树解决“当前状态下做什么”的问题。
如果只用状态机,行为逻辑全写在状态内部,状态一多,代码很容易膨胀,而且每个状态内部的分支条件会让调试变得困难。如果只用行为树,虽然也可以用黑板键模拟状态,但状态切换的边界不够清晰,尤其是遇到“狂暴不可逆”“受伤后强制追击”这类强约束条件时,行为树分支会变得臃肿。
把两者结合后,职责非常明确:
- EnemyCharacter 维护当前状态,并处理状态切换逻辑。
- AI Controller 负责运行行为树。
- 行为树根据黑板上的状态值,决定执行哪条分支。
- 狂暴状态通过修改黑板键和数值属性,让行为树和动画系统同步感知。
2. 环境准备与版本说明
2.1 UE5 版本与模块准备
本文示例基于 UE5.1 之后的通用特性编写,UE5.0 到 UE5.4 的大部分 API 和编辑器操作基本一致,如果你的项目版本不同,注意路径和节点名称可能出现细微差异。
需要确保项目启用了以下模块:
- AIModule:行为树、黑板、AI Controller 的基础模块。
- NavigationSystem:AI 移动寻路必须的导航系统。
- GameplayTasks:行为树任务节点的底层依赖,通常在 Build.cs 中一并加上。
打开项目的 Build.cs 文件,例如:
// Source/BerserkDemo/BerserkDemo.Build.cs public class BerserkDemo : ModuleRules { public BerserkDemo(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "AIModule", "GameplayTasks", "NavigationSystem" }); } }如果是蓝图项目,不需要手动改 Build.cs,但依然要在项目设置中保证默认的 AI 模块和导航网格相关功能可用。
2.2 编辑器准备
在 UE5 编辑器中,至少需要准备:
- 一个第三人称模板或空白项目,方便放置玩家角色和敌人。
- Content 目录下建好 AI 相关文件夹。
- 场景中放置 NavMeshBounds 或 RecastNavMesh,并按
P键显示导航网格范围。
建议目录结构如下:
Content/ ├── AI/ │ ├── Blackboards/ │ ├── BehaviorTrees/ │ ├── Controllers/ │ └── Tasks/ ├── Characters/ │ ├── BerserkEnemy/ ├── Level/ └── ...3. 行为树核心机制拆解
3.1 Blackboard 黑板:AI 的公共记忆
黑板是行为树与外界通信的数据池。行为树节点可以从黑板读取数据,也可以向黑板写入数据。
强烈建议在这个阶段把黑板键类型定义清楚,不要混用:
| 黑板键 | 类型 | 作用 |
|---|---|---|
| TargetActor | Object | 保存玩家/目标的 Actor 引用 |
| CurrentState | Enum | 当前 AI 状态,对应 EEnemyState |
| bIsBerserk | Bool | 是否处于狂暴状态 |
| DistanceToTarget | Float | 与目标距离,供装饰器条件判断 |
在 C++ 中定义枚举时,建议使用UENUM(BlueprintType),这样蓝图和黑板编辑器都能直接使用该枚举类型。
3.2 行为树节点类型
行为树中的节点通常分为四类:
- 组合节点 Composite
- 任务节点 Task
- 装饰器 Decorator
- 服务节点 Service
在 UE5 编辑器中,右键行为树空白处即可创建节点。一个典型的狂暴敌人行为树结构如下:
Root └── Selector ├── [Decorator: bIsBerserk == True] Sequence │ ├── Service: UpdateTarget │ ├── Task: MoveTo TargetActor │ └── Task: BerserkAttack ├── [Decorator: DistanceToTarget < AttackRange] Sequence │ ├── Task: MoveTo TargetActor │ └── Task: NormalAttack ├── [Decorator: TargetActor != None] Task │ └── MoveTo TargetActor └── Task └── Wait / Patrol3.3 Decorator 与状态切换的关键:Observer Aborts
行为树里最常见的坑是:状态已经变了,但行为树没有切换分支。
原因通常是 Decorator 没有设置好监听机制。UE5 的 Blackboard Decorator 需要在细节面板中配置Observer Aborts,可选值包括:
- None:不主动中止,只能等当前分支自然结束。
- Self:只中止自身节点。
- Lower Priority:中止优先级更低的分支。
- Both:同时中止自身和低优先级分支。
在狂暴敌人 AI 中,一般建议设置为Both,这样当bIsBerserk从 false 变为 true 时,行为树能立刻中止当前分支,重新进入狂暴分支。
4. 狂暴敌人整体战斗状态机设计
4.1 状态定义
先用枚举定义敌人状态:
UENUM(BlueprintType) enum class EEnemyState : uint8 { Idle UMETA(DisplayName = "Idle"), Chase UMETA(DisplayName = "Chase"), Attack UMETA(DisplayName = "Attack"), Berserk UMETA(DisplayName = "Berserk"), Dead UMETA(DisplayName = "Dead") };不建议把 Patrol 放到状态枚举里,巡逻可以视为 Idle 状态下的一种行为,这样可以减少状态切换复杂度。
4.2 状态切换条件
狂暴敌人 AI 的状态切换条件可以整理成下表:
| 状态 | 触发条件 | 行为表现 | 退出条件 |
|---|---|---|---|
| Idle | AI 出生后默认状态 | 待机、缓慢巡逻 | 感知到玩家或受伤 |
| Chase | 发现玩家或丢失攻击距离 | 向目标移动 | 玩家死亡或拉到攻击距离 |
| Attack | 距离小于攻击范围 | 普通攻击循环 | 玩家拉远距离 |
| Berserk | 生命值比例低于阈值 | 加速、高频率攻击、释放狂暴技能 | 不可逆,直到死亡 |
狂暴状态一般是不可逆状态,也就是一旦生命值低于阈值,不会再恢复到普通状态。这个设计符合大多数战斗中“狂怒”的行为语义。
4.3 狂暴状态的参数变化
普通状态下:
- 移动速度:420
- 攻击间隔:2.0 秒
- 单次攻击伤害:20
狂暴状态下:
- 移动速度:600
- 攻击间隔:1.0 秒
- 单次攻击伤害:30
- 身体缩放:1.15 倍左右,并触发特效材质变化
这里的数值只是示例,要根据你的游戏平衡去调整。但注意,狂暴参数最好不要写死在行为树节点中,而是通过配置数据表或者暴露给蓝图调整。
5. 完整实战:构建一个狂暴敌人 AI
5.1 创建项目结构
我以一个名为BerserkDemo的项目为例,实际操作时替换成你的项目模块名。
项目结构如下:
BerserkDemo/ ├── Source/ │ └── BerserkDemo/ │ ├── BerserkDemo.Build.cs │ ├── BerserkDemoGameMode.h │ ├── BerserkEnemy.h / .cpp │ ├── BerserkAIController.h / .cpp │ └── BTTask_BerserkAttack.h / .cpp └── Content/ ├── AI/ │ ├── Blackboards/ │ └── BehaviorTrees/ └── Characters/5.2 创建敌人角色类
在 C++ 中,敌人角色类继承自ACharacter,需要包含状态枚举、血量、狂暴阈值、伤害回调等。
// Source/BerserkDemo/BerserkEnemy.h #pragma once #include "CoreMinimal.h" #include "GameFramework/Character.h" #include "BerserkEnemy.generated.h" UENUM(BlueprintType) enum class EEnemyState : uint8 { Idle UMETA(DisplayName = "Idle"), Chase UMETA(DisplayName = "Chase"), Attack UMETA(DisplayName = "Attack"), Berserk UMETA(DisplayName = "Berserk"), Dead UMETA(DisplayName = "Dead") }; UCLASS() class BERSERKDEMO_API ABerserkEnemy : public ACharacter { GENERATED_BODY() public: ABerserkEnemy(); virtual float TakeDamage( float DamageAmount, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser) override; UPROPERTY(BlueprintReadOnly, Category = "AI|State") EEnemyState CurrentState; UPROPERTY(BlueprintReadOnly, Category = "AI|State") bool bIsBerserk; void PerformBerserkAttack(); protected: virtual void BeginPlay() override; UPROPERTY(EditDefaultsOnly, Category = "AI|Config") float MaxHealth; UPROPERTY(VisibleInstanceOnly, Category = "AI|Config") float CurrentHealth; UPROPERTY(EditDefaultsOnly, Category = "AI|Config") float BerserkHealthPercent; void CheckAndEnterBerserk(); };对应的 CPP 文件:
// Source/BerserkDemo/BerserkEnemy.cpp #include "BerserkEnemy.h" #include "AIController.h" #include "BehaviorTree/BlackboardComponent.h" #include "GameFramework/CharacterMovementComponent.h" ABerserkEnemy::ABerserkEnemy() { MaxHealth = 100.f; CurrentHealth = MaxHealth; BerserkHealthPercent = 0.3f; bIsBerserk = false; CurrentState = EEnemyState::Idle; } void ABerserkEnemy::BeginPlay() { Super::BeginPlay(); CurrentHealth = MaxHealth; } float ABerserkEnemy::TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser) { float ActualDamage = Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser); CurrentHealth = FMath::Max(0.f, CurrentHealth - ActualDamage); CheckAndEnterBerserk(); return ActualDamage; } void ABerserkEnemy::CheckAndEnterBerserk() { if (bIsBerserk || CurrentHealth <= 0.f) { return; } if (CurrentHealth / MaxHealth <= BerserkHealthPercent) { bIsBerserk = true; CurrentState = EEnemyState::Berserk; UCharacterMovementComponent* MoveComp = GetCharacterMovement(); if (MoveComp) { MoveComp->MaxWalkSpeed = 600.f; } AController* Controller = GetController(); AAIController* AIController = Cast<AAIController>(Controller); if (AIController) { UBlackboardComponent* Blackboard = AIController->GetBlackboardComponent(); if (Blackboard) { Blackboard->SetValueAsEnum("CurrentState", (uint8)CurrentState); Blackboard->SetValueAsBool("bIsBerserk", true); } } } } void ABerserkEnemy::PerformBerserkAttack() { // 在这里接入你的攻击系统,例如播放动画、发射碰撞体、结算伤害。 // 项目中如果用 Gameplay Ability System,建议通过 GAS 触发。 UE_LOG(LogTemp, Log, TEXT("[BerserkEnemy] Perform Berserk Attack!")); }说明:CheckAndEnterBerserk中的核心思路是“敌人自己判断狂暴条件,并同步更新黑板上键值”。这样行为树不用轮询血量,只要监听bIsBerserk变化即可。
5.3 创建 AI Controller 类
AI Controller 负责控制敌人 Pawn,并在 Possess 时运行行为树。
// Source/BerserkDemo/BerserkAIController.h #pragma once #include "CoreMinimal.h" #include "AIController.h" #include "BerserkAIController.generated.h" class UBehaviorTree; UCLASS() class BERSERKDEMO_API ABerserkAIController : public AAIController { GENERATED_BODY() public: ABerserkAIController(); protected: virtual void OnPossess(APawn* InPawn) override; UPROPERTY(EditDefaultsOnly, Category = "AI") TObjectPtr<UBehaviorTree> BehaviorTreeAsset; };// Source/BerserkDemo/BerserkAIController.cpp #include "BerserkAIController.h" #include "BehaviorTree/BehaviorTree.h" void ABerserkAIController::OnPossess(APawn* InPawn) { Super::OnPossess(InPawn); if (BehaviorTreeAsset) { RunBehaviorTree(BehaviorTreeAsset); } }5.4 配置黑板键
在 Content 中创建 Blackboard 资产,命名为BB_BerserkEnemy,然后添加以下键:
TargetActor:Object 类型CurrentState:Enum 类型,枚举选择EEnemyStatebIsBerserk:Bool 类型
如果后续行为树需要根据距离切换攻击分支,建议再加一个 Float 键DistanceToTarget,由 Service 节点更新。
5.5 构建行为树
创建 Behavior Tree 资产,命名为BT_BerserkEnemy,并关联BB_BerserkEnemy。
行为树根节点下方挂一个Selector,根架构如下:
Root └── Selector ├── Berserk Branch ├── Attack Branch ├── Chase Branch └── Idle Branch每个分支结构为:
- Berserk Branch:
- Decorator:Blackboard 判断
bIsBerserk == true - Sequence:
- Service:更新目标与距离
- Task:MoveTo TargetActor
- Task:自定义
BTTask_BerserkAttack
- Decorator:Blackboard 判断
- Attack Branch:
- Decorator:Blackboard 判断
DistanceToTarget < AttackRange - Sequence:
- Task:MoveTo TargetActor
- Task:普通攻击
- Decorator:Blackboard 判断
- Chase Branch:
- Decorator:Blackboard 判断
TargetActor != None - Task:MoveTo TargetActor
- Decorator:Blackboard 判断
- Idle Branch:
- Task:Wait,也可以挂巡逻任务
关键一步:Berserk Branch 的 Decorator 必须在细节面板中设置Observer Aborts = Both,否则bIsBerserk变化时行为树可能不会立刻切换。
5.6 自定义攻击任务节点
创建一个UBTTaskNode子类,命名为BTTask_BerserkAttack。
// Source/BerserkDemo/BTTask_BerserkAttack.h #pragma once #include "CoreMinimal.h" #include "BehaviorTree/BTTaskNode.h" #include "BTTask_BerserkAttack.generated.h" UCLASS() class BERSERKDEMO_API UBTTask_BerserkAttack : public UBTTaskNode { GENERATED_BODY() public: UBTTask_BerserkAttack(); protected: virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override; };// Source/BerserkDemo/BTTask_BerserkAttack.cpp #include "BTTask_BerserkAttack.h" #include "AIController.h" #include "BerserkEnemy.h" UBTTask_BerserkAttack::UBTTask_BerserkAttack() { NodeName = TEXT("Berserk Attack"); } EBTNodeResult::Type UBTTask_BerserkAttack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) { AAIController* AIController = OwnerComp.GetAIOwner(); if (!AIController) { return EBTNodeResult::Failed; } ABerserkEnemy* Enemy = Cast<ABerserkEnemy>(AIController->GetPawn()); if (!Enemy) { return EBTNodeResult::Failed; } Enemy->PerformBerserkAttack(); return EBTNodeResult::Succeeded; }5.7 运行验证
在编辑器中进行以下操作:
- 将敌人蓝图或 C++ 类的
AIControllerClass设置为BerserkAIController。 - 将
BerserkAIController的BehaviorTreeAsset设置为BT_BerserkEnemy。 - 在场景中放置
NavMeshBoundsVolume,并按P键查看导航网格是否覆盖地面。 - 运行游戏,控制玩家角色靠近敌人。
- 用武器或控制台命令对敌人造成伤害,观察敌人状态是否切换。
验证要点:
- 正常状态下敌人会追向玩家并攻击。
- 当生命值降到 30% 以下时,移动速度明显提高,攻击动画频率变快。
- 行为树调试窗口中,应从普通分支跳到 Berserk 分支。
6. 常见问题与排查思路
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 敌人站在原地不动 | 场景没有导航网格,或 NavMesh 没有覆盖到敌人位置 | 检查P键下的绿色导航范围,调整 NavMeshBoundsVolume |
| 行为树完全不执行 | AI Controller 未设置,或 OnPossess 中没有调用 RunBehaviorTree | 确认 Pawn 的 AIControllerClass 已配置,并检查 BehaviorTreeAsset 是否有效 |
| 黑板键报错 | 设置值与黑板键类型不匹配 | 检查SetValueAsEnum、SetValueAsBool与黑板中键的类型是否一致 |
| 状态切到狂暴后行为树没有反应 | Decorator 没有监听黑板键变化 | 将对应 Decorator 的 Observer Aborts 设置为 Both |
| 敌人追踪时走走停停 | 目标类型不对,或 MoveTo 每次都在拿旧位置 | 使用 Service 高频更新 TargetActor 黑板键,建议频率设置为 0.2 - 0.5 秒 |
| 狂暴后速度没有生效 | CharacterMovement 的 MaxWalkSpeed 被动画或 Ability 覆盖 | 检查是否有其他系统在覆盖移动速度,并用日志打印实际速度 |
| Task 返回 Failed 后整条分支卡死 | Sequence 中某个 Task 快速失败,又没有 Fallback 分支 | 给高风险 Task 加 Decorator 条件,或对失败分类,区分可重试和不可重试的失败 |
7. 最佳实践与工程建议
7.1 职责边界不要混
状态机负责“状态切换”,行为树负责“状态内执行”。不要在行为树里写太多状态切换逻辑,也不要让 Character 类去直接驱动行为树节点。正确的数据流是:
受伤事件 -> Character 判断状态 -> 更新黑板 -> 行为树感知变化 -> 切换分支7.2 使用 Service 更新高频数据
玩家位置、目标距离这类数据不适合在 Task 中一次性获取,因为 AI 需要持续更新。建议使用 Service 节点,在行为树分支执行期间不断刷新黑板键。
比如:
// 自定义 BTS_UpdateTarget 的 TickNode 示意 void UBTS_UpdateTarget::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) { Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds); AAIController* AIController = OwnerComp.GetAIOwner(); if (!AIController) { return; } APawn* TargetPawn = nullptr; // 通过 AI 感知组件或简单距离探测获取玩家 // 将目标写入 TargetActorKey UBlackboardComponent* Blackboard = OwnerComp.GetBlackboardComponent(); if (Blackboard) { Blackboard->SetValueAsObject(TargetKey.SelectedKeyName, TargetPawn); if (TargetPawn) { const float Distance = FVector::Distance(GetOwner()->GetActorLocation(), TargetPawn->GetActorLocation()); Blackboard->SetValueAsFloat(DistanceToTargetKey.SelectedKeyName, Distance); } } }7.3 狂暴参数集中配置
不要把一个敌人的血量、速度、狂暴阈值散落到行为树节点中。建议使用DataTable、PrimaryDataAsset或UDeveloperSettings管理,方便批量调整和做变种敌人。
7.4 降低轮询开销
大量 AI 如果用 Service 每帧计算距离,性能会明显下降。实际项目中可以通过 AI 感知组件AIPerception来触发视野感知和伤害感知,状态切换由事件驱动,而不是每帧轮询。
7.5 调试建议
推荐使用 UE5 行为树调试功能,运行游戏后选中 AI Controller,可以查看当前行为树停留在哪个节点。
常用手段:
- 在 Task 和 Service 中打印关键状态日志。
- 在 Character 的
TakeDamage中断言黑板上键值是否正确。 - 用可视化辅助把当前
CurrentState绘制在屏幕上。
8. 总结与下一步学习路线
这一段内容并没有太多需要往深扩展的理论,重点是你在动手前,先把状态机与行为树的边界想清楚。
本文核心要点可以浓缩成三点:
- 状态机负责定义“狂暴不可逆”这类强状态规则。
- 行为树负责当前状态下的攻击、追踪、待机行为。
- 黑板是状态机和行为树之间的通信桥梁,Decorator 的 Observer Aborts 是状态切换能否立即生效的关键。
如果再往下学,建议按这个顺序进阶:
- AI 感知:使用
AIPerception组件替代手动轮询玩家位置。 - EQS 环境查询:解决“狂暴敌人优先选择哪个目标”或“跳到哪个位置”等更复杂的决策问题。
- GAS 技能系统:把攻击、伤害、减速等效果交给 Gameplay Ability 管理。
- MassAI:如果你的游戏有大量 AI 单位,可以考虑 UE5 的 Mass 框架优化性能。
建议先做一个最简单的“巡逻敌人”,再逐步加入状态机和狂暴分支。等这套结构跑通之后,你会发现后续加入飞行敌人、远程敌人、Boss 技能都只是在这个骨架上不断填充分支节点而已。如果这篇文章对你有帮助,可以收藏备用,等真正开始调 AI 时再对照排查。