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 List<NodeStatus> childStatuses = new List<NodeStatus>(); 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 Func<Blackboard, 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.GetValue<GameObject>("Self"); if (owner != null) { agent = owner.GetComponent<NavMeshAgent>(); 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.GetValue<Vector3>(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 关键节点实现示例
我们需要实现几个自定义的条件和行为节点。
条件节点:IsHealthLowNode
public class IsHealthLowNode : ConditionNode { public float threshold = 0.3f; // 生命值阈值比例 public string cooldownKey = "FleeCooldownTimer"; // 冷却计时器在黑板的键名 protected override bool CheckCondition(Blackboard bb) { // 假设黑板上有"Health"和"MaxHealth" float health = bb.GetValue<float>("Health"); float maxHealth = bb.GetValue<float>("MaxHealth"); float cooldownTimer = bb.GetValue<float>(cooldownKey, 0f); // 获取冷却时间,默认0 return (health / maxHealth) < threshold && cooldownTimer <= 0f; } }行为节点:FleeNode
public 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.GetValue<GameObject>("Self"); agent = owner.GetComponent<NavMeshAgent>(); Vector3 playerPos = blackboard.GetValue<Vector3>("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 List<BTNode> nodes = new List<BTNode>(); // 可以包含黑板默认值定义 }在编辑器中,我们需要一个自定义编辑器来可视化地连接这些节点。这涉及到更复杂的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 = GetComponent<HealthComponent>(); 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.GetValue<float>("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方法,特别是处理aborted=true的情况。
- 原因:行为节点的
黑板数据不同步或为空
- 原因:
BehaviorTreeRunner中更新黑板数据的代码有误,或者键名拼写错误。 - 排查:在
BehaviorTreeRunner的Update中打印关键黑板数据;使用常量键名避免拼写错误;在节点中读取数据前增加空值检查。
- 原因:
多个敌人AI行为不一致
- 原因:直接使用了
ScriptableObject资产,而没有克隆(Clone),导致所有敌人共享了节点的运行时状态(如started,status)。 - 解决:确保在
BehaviorTreeRunner.Start中对treeAsset进行了深拷贝。
- 原因:直接使用了
选择器节点不按预期工作
- 原因:对选择器“遇到成功或Running即返回”的逻辑理解有误。如果一个分支返回
Running,选择器会一直执行该分支,直到其结束,期间不会重新评估其他更高优先级分支的条件(除非从根节点重新Tick)。 - 理解:行为树是“反应式”的,但优先级重评估只在每次从根节点Tick时发生。如果需要更高优先级条件能随时打断,通常需要将中断条件作为装饰节点放在更上层,或者使用“观察者”装饰节点(一些高级行为树框架支持),它在子节点运行时持续监控某个条件,一旦条件满足就中止子节点。
- 原因:对选择器“遇到成功或Running即返回”的逻辑理解有误。如果一个分支返回
从零构建行为树的过程,是一个深入理解AI决策架构的绝佳机会。它迫使你思考状态、条件、行为之间的关系,并设计出清晰的数据流。虽然初期需要投入时间搭建框架,但一旦核心系统就位,后续扩展新行为、调试AI逻辑的效率会大大提升。这套自研的系统,在灵活性、可控性和学习价值上,是直接使用插件难以比拟的。你可以根据项目需求,轻松地添加诸如“等待节点”、“随机选择节点”、“行为树变量”等高级特性,让它真正成为你项目AI系统的坚实基石。
