Article

Unity 入门实战 12:敌人 AI 行为树基础(从 FSM 过渡到可配置决策)

路线阶段:Unity 入门实战第 12 章。
本章目标:把敌人逻辑从“状态分支堆叠”升级为“可组合决策节点”,为后续复杂敌人行为与Boss机制打基础。

学习目标

完成本章后,你应该能做到:

  1. 理解行为树与 FSM 的职责边界与组合方式。
  2. 实现基础行为树节点(选择、顺序、条件、动作)。
  3. 引入黑板(Blackboard)共享上下文数据。
  4. 在不增加明显 GC 的前提下,让 AI 决策可配置化。

为什么要从纯 FSM 进化

FSM 在中小行为下很好用,但敌人复杂度提升后会出现:

  1. 状态爆炸:巡逻/追击/受击/召唤/狂暴/撤退交叉后难维护。
  2. 条件分散:同一条件在多个状态重复判断。
  3. 新增行为改动范围过大,回归风险高。

行为树的优势:将行为拆为可组合节点,复用条件与动作。

行为树核心结构

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) { }
}

实战:守卫敌人行为树

目标行为优先级:

  1. 死亡处理
  2. 被击中处理
  3. 有目标且可攻击 -> 攻击
  4. 有目标但不可攻击 -> 追击
  5. 无目标 -> 巡逻

树结构

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 策略与性能

建议:

  1. 远距离敌人降频 Tick(例如 0.2s 一次)。
  2. 屏幕外敌人仅运行轻量决策。
  3. 节点对象常驻,禁止每帧 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 Tick
  • Paused 状态:暂停 AI Tick
  • Result/Unloading:重置行为树与黑板

验收清单

  1. 敌人可在巡逻/追击/攻击间稳定切换。
  2. 被击中与死亡优先级正确,不会被攻击动作覆盖。
  3. 同场 30+ 敌人时 AI 决策无明显卡顿。
  4. 日志可定位行为节点命中路径。

常见坑

坑 1:树节点直接读写全局单例

会失去可测试性。应通过 BtContext 与黑板传递。

坑 2:Sequence Running 索引不重置

会出现“卡在中间节点”问题。失败或成功后需重置索引。

坑 3:条件节点有副作用

条件应是纯判断,副作用只能放在动作节点。

本月作业

实现“远程法师敌人”行为树:

  1. 保持距离、读条施法、被近身后闪现撤离。
  2. 法师与近战共享部分条件节点。
  3. 对比两种敌人树结构的复用率与性能。

下一章进入 Unity 入门实战 13:Boss 战多阶段机制(阶段切换、技能脚本化与演出同步)。