Article

C# 实战课 12:目标选择与仇恨系统(让 AI 攻击决策稳定可解释)

系列定位:Unity 老版本兼容(.NET 3.5 / C# 4)+ 项目驱动。
本章目标:把 AI 的目标选择从“临时 if/else”升级为“仇恨驱动 + 规则约束”的决策模块,直接服务后续 Unity 战斗章节。

学习目标

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

  1. 建模“仇恨值”并解释其来源与衰减策略。
  2. 设计可扩展的目标选择策略(最近、最低血量、最高威胁等)。
  3. 解决目标切换抖动问题(hysteresis/粘滞阈值)。
  4. 将仇恨系统接入伤害结算、技能系统、状态机。

为什么 AI 需要仇恨系统

常见初版 AI:

if (distanceToPlayer < 8f)
{
    target = player;
}

这会出现三个核心问题:

  1. 谁近打谁,导致坦克职业没有意义。
  2. 多人场景下频繁切目标,表现“抽搐”。
  3. 无法解释“为什么怪打我不打他”,调试困难。

仇恨系统的价值在于:把“AI 应该打谁”变成可配置、可记录、可审计的规则。

仇恨模型设计

1. 数据结构

public sealed class ThreatEntry
{
    public int TargetId;
    public float Threat;
    public float LastUpdateTime;

    public ThreatEntry(int targetId)
    {
        TargetId = targetId;
        Threat = 0f;
        LastUpdateTime = 0f;
    }
}
public sealed class ThreatTable
{
    private readonly Dictionary<int, ThreatEntry> _entries = new Dictionary<int, ThreatEntry>(16);

    public void AddThreat(int targetId, float delta, float now)
    {
        ThreatEntry entry;
        if (!_entries.TryGetValue(targetId, out entry))
        {
            entry = new ThreatEntry(targetId);
            _entries.Add(targetId, entry);
        }

        entry.Threat += delta;
        if (entry.Threat < 0f)
        {
            entry.Threat = 0f;
        }

        entry.LastUpdateTime = now;
    }

    public void Decay(float now, float decayPerSecond)
    {
        if (decayPerSecond <= 0f)
        {
            return;
        }

        var removed = ListPool<int>.Get();

        foreach (var kv in _entries)
        {
            var e = kv.Value;
            var dt = now - e.LastUpdateTime;
            if (dt <= 0f)
            {
                continue;
            }

            var decay = dt * decayPerSecond;
            e.Threat -= decay;
            if (e.Threat <= 0.01f)
            {
                removed.Add(e.TargetId);
            }
            else
            {
                e.LastUpdateTime = now;
            }
        }

        for (var i = 0; i < removed.Count; i++)
        {
            _entries.Remove(removed[i]);
        }

        ListPool<int>.Release(removed);
    }

    public IEnumerable<ThreatEntry> Entries
    {
        get { return _entries.Values; }
    }

    public void Clear()
    {
        _entries.Clear();
    }
}

威胁来源规则(项目可落地版本)

推荐先用稳定的 4 类来源:

  1. 造成伤害:威胁 += 伤害值 * 1.0
  2. 治疗敌方目标:威胁 += 治疗值 * 0.5
  3. 控制技能命中:固定威胁 +80
  4. 嘲讽技能:直接覆盖到指定高值(例如 10000)

对应实现:

public static class ThreatRule
{
    public static float FromDamage(int damage)
    {
        return damage;
    }

    public static float FromHeal(int heal)
    {
        return heal * 0.5f;
    }

    public static float FromControl()
    {
        return 80f;
    }

    public static float TauntValue()
    {
        return 10000f;
    }
}

目标选择器(含防抖)

1. 选择策略接口

public interface ITargetSelector
{
    Actor Select(Actor self, ThreatTable table, Actor current, float now);
}

2. 默认策略:最高仇恨 + 切换阈值

public sealed class HighestThreatSelector : ITargetSelector
{
    private readonly BattleWorld _world;
    private readonly float _switchThreshold;

    public HighestThreatSelector(BattleWorld world, float switchThreshold)
    {
        _world = world;
        _switchThreshold = switchThreshold;
    }

    public Actor Select(Actor self, ThreatTable table, Actor current, float now)
    {
        Actor best = null;
        float bestThreat = -1f;

        foreach (var e in table.Entries)
        {
            var actor = _world.GetActor(e.TargetId);
            if (actor == null || actor.Hp <= 0f)
            {
                continue;
            }

            if (!_world.CanBeTargeted(self, actor))
            {
                continue;
            }

            if (e.Threat > bestThreat)
            {
                bestThreat = e.Threat;
                best = actor;
            }
        }

        if (best == null)
        {
            return null;
        }

        if (current == null)
        {
            return best;
        }

        var currentThreat = _world.GetThreat(self.Id, current.Id);
        if (best.Id != current.Id && bestThreat < currentThreat + _switchThreshold)
        {
            // 防止轻微波动导致频繁切目标
            return current;
        }

        return best;
    }
}

项目驱动:守卫 AI 接入仇恨系统

1. 行为组件

public sealed class ThreatComponent
{
    private readonly Actor _owner;
    private readonly ThreatTable _table;
    private readonly ITargetSelector _selector;

    public Actor CurrentTarget { get; private set; }

    public ThreatComponent(Actor owner, ThreatTable table, ITargetSelector selector)
    {
        _owner = owner;
        _table = table;
        _selector = selector;
    }

    public void AddThreat(int sourceId, float delta, float now)
    {
        _table.AddThreat(sourceId, delta, now);
    }

    public void Tick(float now, float dt)
    {
        _table.Decay(now, 4f); // 每秒衰减 4 点,可配置

        var next = _selector.Select(_owner, _table, CurrentTarget, now);
        if (next == CurrentTarget)
        {
            return;
        }

        var from = CurrentTarget == null ? "None" : CurrentTarget.Id.ToString();
        var to = next == null ? "None" : next.Id.ToString();

        CurrentTarget = next;

        FoundationLog.Info("Threat", "owner=" + _owner.Id + " switch from=" + from + " to=" + to);
    }

    public void Reset()
    {
        CurrentTarget = null;
        _table.Clear();
    }
}

2. 与伤害管线联动

在伤害结算成功后追加:

public void OnDamageResolved(int sourceId, int targetId, int finalDamage, float now)
{
    var target = _world.GetActor(targetId);
    if (target == null || target.ThreatComp == null)
    {
        return;
    }

    target.ThreatComp.AddThreat(sourceId, ThreatRule.FromDamage(finalDamage), now);
}

3. 与状态机联动

  • PatrolState:当 CurrentTarget != null 切到 ChaseState
  • ChaseState:如果目标失效,尝试从仇恨表选下一个;为空则回 PatrolState
  • AttackState:技能选择优先命中 CurrentTarget
  • DeadStateThreatComponent.Reset()

配置建议

{
  "Threat": {
    "DecayPerSecond": 4.0,
    "SwitchThreshold": 25.0,
    "TauntDuration": 3.0,
    "ForgetDistance": 35.0
  }
}

验收清单

  1. 双人打怪时,怪会稳定攻击高威胁目标,不会每秒乱切。
  2. 嘲讽触发后目标立即锁定坦克,持续时间结束后恢复常规仇恨。
  3. 目标死亡或离开可攻击范围后,能在 1 帧内选中次优目标。
  4. 日志可还原完整链路:威胁增长、衰减、切换原因。

常见坑

坑 1:仇恨只增不减

长战斗中仇恨会无限膨胀,导致后期切换失真。必须有衰减或归一化策略。

坑 2:切换阈值过低

轻微治疗或 DOT 触发就切目标,表现非常差。要用 SwitchThreshold 防抖。

坑 3:忽略目标有效性

未判断死亡、隐身、不可选中,会导致 AI 锁定空气目标。

本月作业

实现“范围仇恨联动”:

  1. 支持 AOE 技能给命中目标分别加威胁。
  2. 支持“仇恨转移”技能(把自身 30% 仇恨转给队友)。
  3. 输出 30 秒战斗仇恨曲线日志(每 1 秒采样一次)。

下一章进入“战斗回放数据落盘与重放工具”,把命令、伤害、仇恨三条链统一成可回放诊断资产。