尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Unity游戏AI:从零手搓行为树系统,构建智能敌人实战指南

Unity游戏AI:从零手搓行为树系统,构建智能敌人实战指南 1. 项目概述与核心价值最近在做一个动作游戏需要给敌人设计一套既灵活又可控的AI。试过状态机简单敌人还行一旦逻辑复杂起来状态切换的连线就乱成一团维护起来简直是噩梦。后来转向了行为树Behavior Tree发现它用树状结构来组织AI决策逻辑清晰得像看流程图特别适合处理这种带有优先级、条件判断和序列执行的行为。网上虽然有不少教程和插件比如Behaviour Designer但要么讲得太浅只给个框架要么直接上插件底层原理一笔带过。对于想真正理解行为树如何运作、并希望拥有自定义节点能力的人来说总觉得隔了一层。所以我决定抛开插件从零开始在Unity里手搓一套行为树系统并构建一个完整的“智能敌人”作为实战案例。这个项目的目的不仅仅是实现一个会巡逻、追击、攻击的敌人更重要的是深入行为树节点的设计模式理解组合节点Composite、装饰节点Decorator和行为节点Task是如何协同工作的。你会看到如何用C#脚本构建一个可扩展的节点基类如何设计一个简洁高效的行为树执行器Executor以及如何将这套系统优雅地集成到Unity的MonoBehaviour生命周期中。最终你将获得一个完全可控、可深度定制的AI框架并能根据项目需求轻松创建出属于自己的特殊行为节点。2. 行为树核心架构设计思路拆解在动手写代码之前我们必须先理解行为树解决问题的核心思路。它与有限状态机FSM最大的不同在于控制流的方向。状态机是“事件驱动”的一个事件触发一个状态转换而行为树是“自顶向下、周期性查询”的每一帧或固定间隔都会从根节点开始执行根据节点的执行结果成功、失败、运行中来决定下一步走到哪个分支。2.1 为什么选择行为树而非状态机对于游戏AI尤其是需要复杂决策、行为优先级和条件中断的AI行为树有几个显著优势可读性与可维护性树形结构直观像流程图一样清晰展示了从决策到执行的全部路径。添加新行为如“躲藏”通常只需增加一个分支而不会像状态机那样可能需要在多个状态间新增转换条件导致“蜘蛛网”效应。模块化与复用性节点尤其是叶子节点是高度模块化的。一个“移动到某点”的节点既可以被“巡逻”行为调用也可以被“追击”行为调用。这种复用性极大地减少了代码冗余。动态优先级通过选择器节点Selector可以轻松实现行为的优先级。例如一个典型的敌人AI树可能是优先级1是否看到玩家(是) - 攻击 (否) - 优先级2是否听到异响(是) - 前往查看 (否) - 优先级3巡逻。这种优先级逻辑在状态机中实现起来会比较别扭。更好的响应中断行为树天然支持更高优先级行为对低优先级行为的打断。因为每一帧都从根节点重新评估如果高优先级条件突然满足如玩家进入视野执行流会立刻切换到高优先级分支当前低优先级分支如巡逻的行为节点会收到“中止”信号并清理现场。2.2 我们的行为树系统顶层设计我们不追求像商业插件那样功能大而全而是聚焦于实现一个清晰、健壮、易扩展的核心。整个系统可以分为三层节点层定义所有类型节点的基类与具体实现。这是行为树的灵魂。树层负责组织节点形成一棵具体的树并持有执行所需的环境数据黑板。执行器层驱动行为树每帧从根节点开始“滴答”Tick并处理节点的状态流转。它们之间的关系是执行器持有一棵行为树行为树由许多节点构成。执行器每帧调用行为树根节点的Tick方法并将一个共享的黑板对象传递给节点作为节点间通信和数据存储的媒介。注意这里我们采用“黑板”模式它是一个简单的键值对容器。节点可以从中读取信息如“玩家位置”也可以写入信息如“当前巡逻点”。这避免了节点间紧耦合的引用是行为树设计的常见做法。3. 核心节点类型解析与基类实现行为树的威力来自于其有限的几种节点类型通过组合产生的无限可能。我们首先来实现这些节点的基类。3.1 节点状态与基类设计任何节点在执行后都必须返回一个明确的状态以告知父节点它的执行结果。我们定义三种状态public enum NodeStatus { Success, // 执行成功 Failure, // 执行失败 Running // 正在执行下一帧继续 }Running状态是行为树支持持续行为如移动的关键。一个返回Running的节点下一帧会直接从它这里继续执行而不是从头开始除非被更高优先级节点打断。接下来是所有节点的抽象基类BTNode。为了支持Unity编辑器的可视化编辑这是我们未来的扩展方向我们让它继承自ScriptableObject。但核心逻辑与ScriptableObject无关你可以根据需要调整。using UnityEngine; public abstract class BTNode : ScriptableObject { [HideInInspector] public NodeStatus status NodeStatus.Failure; // 当前执行状态 [HideInInspector] public bool started false; // 是否已开始执行针对Running节点 [HideInInspector] public string guid; // 用于编辑器节点连接 [HideInInspector] public Vector2 position; // 用于编辑器节点位置 // 行为树执行入口 public NodeStatus Tick(BehaviorTree tree, Blackboard blackboard) { if (!started) { OnStart(tree, blackboard); started true; } status OnUpdate(tree, blackboard); if (status ! NodeStatus.Running) { OnStop(tree, blackboard); started false; } return status; } // 强制中止节点当节点处于Running状态但行为树切换分支时调用 public void Abort(BehaviorTree tree, Blackboard blackboard) { if (started) { OnStop(tree, blackboard, true); started false; status NodeStatus.Failure; } } // 子类需要重写的三个核心方法 protected virtual void OnStart(BehaviorTree tree, Blackboard blackboard) { } protected abstract NodeStatus OnUpdate(BehaviorTree tree, Blackboard blackboard); protected virtual void OnStop(BehaviorTree tree, Blackboard blackboard, bool aborted false) { } }设计解析Tick方法是模板方法模式的应用。它保证了无论什么节点其生命周期都是OnStart-OnUpdate- (若结束)OnStop。started标志位确保了OnStart和OnStop只会被调用一次。Abort方法至关重要。当一个Running节点因为父节点如选择器选择了其他分支而需要被中断时必须调用此方法来清理资源例如停止导航、结束动画。3.2 组合节点行为树的决策骨架组合节点是拥有子节点的节点它决定了子节点的执行顺序和逻辑。主要有三种1. 序列节点public class SequenceNode : CompositeNode { protected override void OnStart() { currentChildIndex 0; } protected override NodeStatus OnUpdate() { for (int i currentChildIndex; i children.Count; i) { var child children[i]; var childStatus child.Tick(tree, blackboard); if (childStatus NodeStatus.Failure || childStatus NodeStatus.Running) { // 遇到失败或运行中则返回。如果是运行中记录当前位置下次从此继续。 if (childStatus NodeStatus.Running) { currentChildIndex i; } return childStatus; } // 子节点成功继续执行下一个 } // 所有子节点都成功序列节点成功 return NodeStatus.Success; } protected override void OnStop(bool aborted) { // 如果被中止需要中止当前正在运行的子节点 if (aborted currentChildIndex children.Count) { children[currentChildIndex].Abort(tree, blackboard); } currentChildIndex 0; } }要点序列节点按顺序执行子节点直到有一个子节点失败或返回Running。只有所有子节点成功它才成功。它是实现“步骤A完成后再做步骤B”逻辑的核心。2. 选择器节点public class SelectorNode : CompositeNode { protected override void OnStart() { currentChildIndex 0; } protected override NodeStatus OnUpdate() { for (int i currentChildIndex; i children.Count; i) { var child children[i]; var childStatus child.Tick(tree, blackboard); if (childStatus NodeStatus.Success || childStatus NodeStatus.Running) { // 遇到成功或运行中则返回。 if (childStatus NodeStatus.Running) { currentChildIndex i; } return childStatus; } // 子节点失败尝试下一个 } // 所有子节点都失败选择器节点失败 return NodeStatus.Failure; } protected override void OnStop(bool aborted) { /* 类似Sequence处理中止逻辑 */ } }要点选择器节点按顺序执行子节点直到有一个子节点成功或返回Running。它是实现“优先级”逻辑的核心。通常用于组织不同条件的行为分支。3. 并行节点public class ParallelNode : CompositeNode { public enum ParallelPolicy { RequireOne, RequireAll } public ParallelPolicy successPolicy ParallelPolicy.RequireAll; public ParallelPolicy failurePolicy ParallelPolicy.RequireOne; private ListNodeStatus childStatuses new ListNodeStatus(); protected override void OnStart() { childStatuses.Clear(); for (int i 0; i children.Count; i) { childStatuses.Add(NodeStatus.Running); } } protected override NodeStatus OnUpdate() { int successCount 0, failureCount 0; for (int i 0; i children.Count; i) { if (childStatuses[i] NodeStatus.Running) { childStatuses[i] children[i].Tick(tree, blackboard); } if (childStatuses[i] NodeStatus.Success) successCount; if (childStatuses[i] NodeStatus.Failure) failureCount; } if (successPolicy ParallelPolicy.RequireOne successCount 0) return NodeStatus.Success; if (successPolicy ParallelPolicy.RequireAll successCount children.Count) return NodeStatus.Success; if (failurePolicy ParallelPolicy.RequireOne failureCount 0) return NodeStatus.Failure; if (failurePolicy ParallelPolicy.RequireAll failureCount children.Count) return NodeStatus.Failure; return NodeStatus.Running; } }要点并行节点同时执行所有子节点。它通过successPolicy和failurePolicy来决定自身何时成功或失败。例如RequireOne成功即成功RequireAll失败才失败。这可以用来实现“一边移动一边播放动画”或“监控多个条件任一满足即触发”的效果。实操心得并行节点的资源管理和中止逻辑比较复杂因为要同时管理多个可能处于Running状态的子节点。在上面的简化实现中OnStop方法需要遍历所有Running状态的子节点并调用Abort。在实际项目中需要仔细设计。3.3 装饰节点行为的微调器装饰节点只有一个子节点用于修改或增强这个子节点的行为。常见的有反转节点将子节点的成功变为失败失败变为成功。重复节点重复执行子节点N次或直到失败。条件节点只有满足某个条件时才执行子节点否则直接返回失败。强制成功/失败节点无论子节点返回什么都强制返回成功或失败。这里以实现一个条件节点为例它是最常用的装饰节点之一public class ConditionNode : DecoratorNode { // 这里使用一个委托来定义条件实践中可以从黑板读取数据或进行更复杂的判断 public FuncBlackboard, bool condition; protected override NodeStatus OnUpdate() { if (condition null) return NodeStatus.Failure; if (condition.Invoke(blackboard)) { // 条件满足执行子节点 return child ! null ? child.Tick(tree, blackboard) : NodeStatus.Success; } else { // 条件不满足直接失败 return NodeStatus.Failure; } } }设计解析条件节点本身不执行具体行为它只是一个“看门人”。在实际使用中我们可以创建不同的条件节点子类如IsPlayerInSight、IsHealthLow等每个子类实现自己特定的condition检查逻辑。这比在行为节点内部写条件判断要清晰得多实现了“条件”与“行为”的分离。3.4 行为节点具体的执行单元行为节点是树的叶子是真正执行游戏逻辑的地方比如移动、播放动画、攻击。它们需要继承自BTNode并实现具体的OnUpdate逻辑。一个典型的“移动到位置”行为节点using UnityEngine; using UnityEngine.AI; // 假设使用Unity的NavMeshAgent public class MoveToPositionNode : BTNode { public string targetPositionKey TargetPosition; // 从黑板读取目标位置的键名 private NavMeshAgent agent; protected override void OnStart(BehaviorTree tree, Blackboard blackboard) { // 通常从黑板关联的GameObject上获取组件 GameObject owner blackboard.GetValueGameObject(Self); if (owner ! null) { agent owner.GetComponentNavMeshAgent(); if (agent ! null) { agent.isStopped false; } } } protected override NodeStatus OnUpdate(BehaviorTree tree, Blackboard blackboard) { if (agent null || !agent.isActiveAndEnabled) return NodeStatus.Failure; Vector3 targetPos blackboard.GetValueVector3(targetPositionKey); agent.SetDestination(targetPos); if (agent.pathPending) return NodeStatus.Running; if (agent.remainingDistance agent.stoppingDistance) { // 到达目的地 return NodeStatus.Success; } if (agent.pathStatus NavMeshPathStatus.PathInvalid) { // 路径无效 return NodeStatus.Failure; } // 仍在移动中 return NodeStatus.Running; } protected override void OnStop(BehaviorTree tree, Blackboard blackboard, bool aborted) { if (aborted agent ! null agent.isActiveAndEnabled) { agent.isStopped true; // 被中止时停止导航 } } }要点数据驱动目标位置不是硬编码的而是通过targetPositionKey从黑板中动态获取。这使得同一个移动节点可以被用于“移动到巡逻点”或“移动到玩家位置”。正确的生命周期管理OnStart中获取组件并启动导航OnUpdate中更新状态OnStop中特别是被中止时清理资源停止导航。这是编写健壮行为节点的关键。返回Running只要还在移动就返回Running这样父节点如序列节点就会在下一帧继续执行本节点直到移动成功或失败。4. 构建智能敌人AI实战从树设计到集成有了节点基础我们现在来构建一个具体的敌人AI。假设敌人行为如下最高优先级如果生命值低于30%且不在冷却中则逃跑。次高优先级如果看到玩家则追击并攻击。最低优先级在几个预设点之间巡逻。4.1 设计行为树结构用我们定义的节点来描述这棵树的伪代码结构如下根节点 (Selector选择最高优先级成功的分支) | ├── 分支1: 逃跑 (Sequence) │ ├── 条件节点: 生命值 30% 且 逃跑技能不在冷却 │ └── 行为节点: 执行逃跑逻辑 (如向远离玩家的方向移动) | ├── 分支2: 攻击玩家 (Sequence) │ ├── 条件节点: 玩家在视野内 │ ├── 行为节点: 移动到攻击范围 │ └── 行为节点: 执行攻击动作 | └── 分支3: 巡逻 (Sequence) ├── 行为节点: 获取下一个巡逻点 └── 行为节点: 移动到巡逻点这个结构清晰地体现了优先级先检查能否逃跑不能则检查能否攻击都不能则去巡逻。4.2 关键节点实现示例我们需要实现几个自定义的条件和行为节点。条件节点IsHealthLowNodepublic class IsHealthLowNode : ConditionNode { public float threshold 0.3f; // 生命值阈值比例 public string cooldownKey FleeCooldownTimer; // 冷却计时器在黑板的键名 protected override bool CheckCondition(Blackboard bb) { // 假设黑板上有Health和MaxHealth float health bb.GetValuefloat(Health); float maxHealth bb.GetValuefloat(MaxHealth); float cooldownTimer bb.GetValuefloat(cooldownKey, 0f); // 获取冷却时间默认0 return (health / maxHealth) threshold cooldownTimer 0f; } }行为节点FleeNodepublic class FleeNode : BTNode { public float fleeDistance 20f; public float cooldownDuration 10f; private NavMeshAgent agent; private Vector3 fleeDirection; protected override void OnStart(BehaviorTree tree, Blackboard blackboard) { GameObject owner blackboard.GetValueGameObject(Self); agent owner.GetComponentNavMeshAgent(); Vector3 playerPos blackboard.GetValueVector3(PlayerPosition); Vector3 myPos owner.transform.position; // 计算远离玩家的方向 fleeDirection (myPos - playerPos).normalized; Vector3 fleeTarget myPos fleeDirection * fleeDistance; // 在NavMesh上找到一个可行的逃跑点 if (NavMesh.SamplePosition(fleeTarget, out NavMeshHit hit, fleeDistance, NavMesh.AllAreas)) { agent.SetDestination(hit.position); agent.isStopped false; } // 设置冷却时间 blackboard.SetValue(FleeCooldownTimer, cooldownDuration); } protected override NodeStatus OnUpdate(BehaviorTree tree, Blackboard blackboard) { // 移动逻辑与MoveToPositionNode类似省略... // 成功到达逃跑点或路径无效后返回 Success/Failure // 这里简化处理逃跑开始即认为成功实际可能需要到达目的地 if (!agent.pathPending agent.remainingDistance agent.stoppingDistance) { return NodeStatus.Success; } return NodeStatus.Running; } protected override void OnStop(BehaviorTree tree, Blackboard blackboard, bool aborted) { if (aborted agent ! null) { agent.isStopped true; // 如果逃跑被中断可以不清除冷却或者清除取决于设计 // blackboard.SetValue(FleeCooldownTimer, 0f); } } }4.3 组装行为树与执行器现在我们需要一个BehaviorTree资产类来保存节点间的连接关系以及一个BehaviorTreeRunner组件来驱动它。简化版BehaviorTree资产using System.Collections.Generic; using UnityEngine; [CreateAssetMenu(fileName NewBehaviorTree, menuName AI/Behavior Tree)] public class BehaviorTree : ScriptableObject { public BTNode rootNode; public ListBTNode nodes new ListBTNode(); // 可以包含黑板默认值定义 }在编辑器中我们需要一个自定义编辑器来可视化地连接这些节点。这涉及到更复杂的Editor GUI编程超出了本文核心但思路是将每个节点绘制为一个可拖拽的窗口并用连线表示父子关系。行为树执行器using UnityEngine; public class BehaviorTreeRunner : MonoBehaviour { public BehaviorTree treeAsset; private BehaviorTree treeInstance; private Blackboard blackboard; void Start() { if (treeAsset ! null) { // 实例化行为树深拷贝避免多个敌人共享同一棵树的状态 treeInstance treeAsset.Clone(); blackboard new Blackboard(); // 将自身GameObject存入黑板 blackboard.SetValue(Self, gameObject); // 初始化其他黑板数据如Health, MaxHealth等 var health GetComponentHealthComponent(); if (health ! null) { blackboard.SetValue(Health, health.currentHealth); blackboard.SetValue(MaxHealth, health.maxHealth); } // 初始化树 treeInstance.Bind(blackboard); } } void Update() { if (treeInstance ! null blackboard ! null) { // 更新黑板数据例如玩家位置 GameObject player GameObject.FindGameObjectWithTag(Player); if (player ! null) { blackboard.SetValue(PlayerPosition, player.transform.position); } // 更新冷却计时器 float cooldown blackboard.GetValuefloat(FleeCooldownTimer, 0f); if (cooldown 0) { blackboard.SetValue(FleeCooldownTimer, cooldown - Time.deltaTime); } // 驱动行为树执行 treeInstance.Tick(); } } }关键点实例化必须对ScriptableObject资产进行克隆Clone因为资产是共享的而每个敌人的行为树状态如节点当前是否Running应该是独立的。黑板绑定将运行时的数据如敌人自身引用、玩家位置、生命值存入黑板供所有节点查询。每帧驱动在Update中更新黑板动态数据并调用树的Tick方法。4.4 在Unity中配置与运行创建节点ScriptableObject为每种节点类型Sequence,Selector,IsHealthLowNode,MoveToPositionNode,FleeNode等创建对应的Asset文件。组装行为树通过一个自定义编辑器窗口将这些节点Asset拖入并用连线组成我们之前设计的树状结构。将组装好的树保存为一个BehaviorTreeAsset。挂载运行在敌人Prefab上挂载BehaviorTreeRunner组件并将第2步创建的BehaviorTreeAsset拖拽赋值。运行游戏敌人就会根据你设计的行为树逻辑开始运作。5. 高级技巧、优化与常见问题5.1 黑板数据的高效管理黑板虽然方便但滥用会导致性能问题和数据混乱。以下是一些最佳实践键名常量化不要到处使用字符串字面量定义一个静态类来存放所有键名。public static class BlackboardKeys { public const string Self Self; public const string PlayerPosition PlayerPosition; public const string Health Health; // ... }类型安全基础的blackboard.SetValue/GetValue是泛型的但运行时类型错误仍可能发生。可以考虑为常用数据类型封装辅助方法或在Set/Get时加入更严格的类型检查。数据驱动与事件驱动结合不是所有数据都需要每帧更新。例如“玩家是否在视野内”这个检查可能比较耗时。可以每N帧检查一次或者由玩家的移动事件来触发黑板更新。5.2 行为树的性能考量行为树每帧从根节点执行如果树非常深且节点很多可能会有性能开销。优化Tick频率不是所有AI都需要每帧Tick。对于非活跃或远处的敌人可以降低Tick频率如每0.2秒一次。条件节点优化将计算代价高的条件检查如射线检测、物理Overlap放在选择器靠后的位置或者为其添加冷却时间。使用子树将复杂或可复用的逻辑分支封装成单独的BehaviorTreeAsset在主树中通过一个特殊的SubTreeNode来引用。这有助于管理大型行为树。5.3 可视化调试调试AI逻辑时能看到当前执行到哪个节点至关重要。节点状态可视化在编辑器的自定义窗口或游戏内的Debug绘制中用不同颜色如绿/红/黄高亮显示节点的当前状态Success/Failure/Running。黑板数据监视在编辑器里创建一个窗口实时显示黑板中所有键值对的变化。行为历史记录记录最近若干帧行为树执行的路径方便回溯AI的决策过程。5.4 常见问题与排查节点永远返回Running导致其他分支无法执行原因行为节点如移动没有正确判断成功或失败条件或者条件节点逻辑有误一直返回Running。排查检查返回Running的节点的OnUpdate逻辑确保在任务完成时有明确的成功或失败出口。使用调试工具高亮当前Running的节点。行为被意外中断状态残留原因行为节点的OnStop或Abort方法没有正确清理资源。例如移动节点被中止后没有停止NavMeshAgent导致敌人滑行。解决确保所有可能返回Running的节点都正确实现了OnStop方法特别是处理abortedtrue的情况。黑板数据不同步或为空原因BehaviorTreeRunner中更新黑板数据的代码有误或者键名拼写错误。排查在BehaviorTreeRunner的Update中打印关键黑板数据使用常量键名避免拼写错误在节点中读取数据前增加空值检查。多个敌人AI行为不一致原因直接使用了ScriptableObject资产而没有克隆Clone导致所有敌人共享了节点的运行时状态如started,status。解决确保在BehaviorTreeRunner.Start中对treeAsset进行了深拷贝。选择器节点不按预期工作原因对选择器“遇到成功或Running即返回”的逻辑理解有误。如果一个分支返回Running选择器会一直执行该分支直到其结束期间不会重新评估其他更高优先级分支的条件除非从根节点重新Tick。理解行为树是“反应式”的但优先级重评估只在每次从根节点Tick时发生。如果需要更高优先级条件能随时打断通常需要将中断条件作为装饰节点放在更上层或者使用“观察者”装饰节点一些高级行为树框架支持它在子节点运行时持续监控某个条件一旦条件满足就中止子节点。从零构建行为树的过程是一个深入理解AI决策架构的绝佳机会。它迫使你思考状态、条件、行为之间的关系并设计出清晰的数据流。虽然初期需要投入时间搭建框架但一旦核心系统就位后续扩展新行为、调试AI逻辑的效率会大大提升。这套自研的系统在灵活性、可控性和学习价值上是直接使用插件难以比拟的。你可以根据项目需求轻松地添加诸如“等待节点”、“随机选择节点”、“行为树变量”等高级特性让它真正成为你项目AI系统的坚实基石。
返回列表