路线阶段:Unity 入门实战第 12 章。
本章目标:把敌人逻辑从“状态分支堆叠”升级为“可组合决策节点”,为后续复杂敌人行为与Boss机制打基础。
学习目标
完成本章后,你应该能做到:
- 理解行为树与 FSM 的职责边界与组合方式。
- 实现基础行为树节点(选择、顺序、条件、动作)。
- 引入黑板(Blackboard)共享上下文数据。
- 在不增加明显 GC 的前提下,让 AI 决策可配置化。
为什么要从纯 FSM 进化
FSM 在中小行为下很好用,但敌人复杂度提升后会出现:
- 状态爆炸:巡逻/追击/受击/召唤/狂暴/撤退交叉后难维护。
- 条件分散:同一条件在多个状态重复判断。
- 新增行为改动范围过大,回归风险高。
行为树的优势:将行为拆为可组合节点,复用条件与动作。
行为树核心结构
1. 节点状态
public enum BtStatus
{
Success = 0,
Failure = 1,
Running = 2
}
2. 节点接口
public interface IBtNode
{
BtStatus Tick(BtContext ctx, float dt);
void Reset(BtContext ctx);
}
3. 黑板上下文
public sealed class BtContext
{
public readonly Actor Self;
public readonly GameWorld World;
public readonly Dictionary<string, object> Blackboard;
public BtContext(Actor self, GameWorld world)
{
Self = self;
World = world;
Blackboard = new Dictionary<string, object>(32);
}
public T Get<T>(string key)
{
object value;
if (Blackboard.TryGetValue(key, out value))
{
return (T)value;
}
return default(T);
}
public void Set<T>(string key, T value)
{
Blackboard[key] = value;
}
}
基础组合节点
Selector(选择器)
public sealed class SelectorNode : IBtNode
{
private readonly List<IBtNode> _children;
public SelectorNode(List<IBtNode> children)
{
_children = children;
}
public BtStatus Tick(BtContext ctx, float dt)
{
for (var i = 0; i < _children.Count; i++)
{
var s = _children[i].Tick(ctx, dt);
if (s == BtStatus.Success || s == BtStatus.Running)
{
return s;
}
}
return BtStatus.Failure;
}
public void Reset(BtContext ctx)
{
for (var i = 0; i < _children.Count; i++)
{
_children[i].Reset(ctx);
}
}
}
Sequence(顺序器)
public sealed class SequenceNode : IBtNode
{
private readonly List<IBtNode> _children;
private int _runningIndex;
public SequenceNode(List<IBtNode> children)
{
_children = children;
_runningIndex = 0;
}
public BtStatus Tick(BtContext ctx, float dt)
{
for (; _runningIndex < _children.Count; _runningIndex++)
{
var s = _children[_runningIndex].Tick(ctx, dt);
if (s == BtStatus.Running)
{
return BtStatus.Running;
}
if (s == BtStatus.Failure)
{
_runningIndex = 0;
return BtStatus.Failure;
}
}
_runningIndex = 0;
return BtStatus.Success;
}
public void Reset(BtContext ctx)
{
_runningIndex = 0;
for (var i = 0; i < _children.Count; i++)
{
_children[i].Reset(ctx);
}
}
}
Condition/Action
public sealed class ConditionNode : IBtNode
{
private readonly Func<BtContext, bool> _check;
public ConditionNode(Func<BtContext, bool> check)
{
_check = check;
}
public BtStatus Tick(BtContext ctx, float dt)
{
return _check(ctx) ? BtStatus.Success : BtStatus.Failure;
}
public void Reset(BtContext ctx) { }
}
public sealed class ActionNode : IBtNode
{
private readonly Func<BtContext, float, BtStatus> _act;
public ActionNode(Func<BtContext, float, BtStatus> act)
{
_act = act;
}
public BtStatus Tick(BtContext ctx, float dt)
{
return _act(ctx, dt);
}
public void Reset(BtContext ctx) { }
}
实战:守卫敌人行为树
目标行为优先级:
- 死亡处理
- 被击中处理
- 有目标且可攻击 -> 攻击
- 有目标但不可攻击 -> 追击
- 无目标 -> 巡逻
树结构
Selector
Sequence(Dead?) -> DeadAction
Sequence(Hit?) -> HitAction
Sequence(HasTarget && InRange) -> AttackAction
Sequence(HasTarget) -> ChaseAction
PatrolAction
关键动作示例
private static BtStatus Attack(BtContext ctx, float dt)
{
var target = ctx.Get<Actor>("target");
if (target == null)
{
return BtStatus.Failure;
}
string reason;
var ok = ctx.Self.Skills.TryCast(ctx.Self, target, 1001, ctx.World.Time.Now, out reason);
if (!ok)
{
return BtStatus.Failure;
}
return BtStatus.Success;
}
private static BtStatus Chase(BtContext ctx, float dt)
{
var target = ctx.Get<Actor>("target");
if (target == null)
{
return BtStatus.Failure;
}
ctx.Self.Nav.MoveTo(target.Transform.position, dt);
return BtStatus.Running;
}
private static BtStatus Patrol(BtContext ctx, float dt)
{
ctx.Self.Nav.Patrol(dt);
return BtStatus.Running;
}
与仇恨系统结合
在行为树 Tick 前更新黑板:
ctx.Set("target", threatComp.CurrentTarget);
ctx.Set("hasTarget", threatComp.CurrentTarget != null);
ctx.Set("inRange", IsInAttackRange(self, threatComp.CurrentTarget));
ctx.Set("isHit", self.IsHit);
ctx.Set("isDead", self.Hp <= 0f);
这样条件节点不直接访问复杂外部对象,逻辑更可测。
Tick 策略与性能
建议:
- 远距离敌人降频 Tick(例如 0.2s 一次)。
- 屏幕外敌人仅运行轻量决策。
- 节点对象常驻,禁止每帧 new。
AI 调度器
public sealed class AiTickScheduler : IUpdatable
{
public int Order { get { return 260; } }
private readonly List<EnemyAiBrain> _brains;
private float _acc;
public AiTickScheduler(List<EnemyAiBrain> brains)
{
_brains = brains;
_acc = 0f;
}
public void Tick(float dt, float unscaledDt)
{
_acc += dt;
if (_acc < 0.05f)
{
return;
}
var step = _acc;
_acc = 0f;
for (var i = 0; i < _brains.Count; i++)
{
_brains[i].Tick(step);
}
}
}
与流程状态机联动
Battle状态:启动 AI TickPaused状态:暂停 AI TickResult/Unloading:重置行为树与黑板
验收清单
- 敌人可在巡逻/追击/攻击间稳定切换。
- 被击中与死亡优先级正确,不会被攻击动作覆盖。
- 同场 30+ 敌人时 AI 决策无明显卡顿。
- 日志可定位行为节点命中路径。
常见坑
坑 1:树节点直接读写全局单例
会失去可测试性。应通过 BtContext 与黑板传递。
坑 2:Sequence Running 索引不重置
会出现“卡在中间节点”问题。失败或成功后需重置索引。
坑 3:条件节点有副作用
条件应是纯判断,副作用只能放在动作节点。
本月作业
实现“远程法师敌人”行为树:
- 保持距离、读条施法、被近身后闪现撤离。
- 法师与近战共享部分条件节点。
- 对比两种敌人树结构的复用率与性能。
下一章进入 Unity 入门实战 13:Boss 战多阶段机制(阶段切换、技能脚本化与演出同步)。