系列定位:Unity 老版本兼容(.NET 3.5 / C# 4)+ 项目驱动。
本章目标:把 AI 的目标选择从“临时 if/else”升级为“仇恨驱动 + 规则约束”的决策模块,直接服务后续 Unity 战斗章节。
学习目标
完成本章后,你应该能做到:
- 建模“仇恨值”并解释其来源与衰减策略。
- 设计可扩展的目标选择策略(最近、最低血量、最高威胁等)。
- 解决目标切换抖动问题(hysteresis/粘滞阈值)。
- 将仇恨系统接入伤害结算、技能系统、状态机。
为什么 AI 需要仇恨系统
常见初版 AI:
if (distanceToPlayer < 8f)
{
target = player;
}
这会出现三个核心问题:
- 谁近打谁,导致坦克职业没有意义。
- 多人场景下频繁切目标,表现“抽搐”。
- 无法解释“为什么怪打我不打他”,调试困难。
仇恨系统的价值在于:把“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.0
- 治疗敌方目标:威胁 += 治疗值 * 0.5
- 控制技能命中:固定威胁 +80
- 嘲讽技能:直接覆盖到指定高值(例如 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切到ChaseStateChaseState:如果目标失效,尝试从仇恨表选下一个;为空则回PatrolStateAttackState:技能选择优先命中CurrentTargetDeadState:ThreatComponent.Reset()
配置建议
{
"Threat": {
"DecayPerSecond": 4.0,
"SwitchThreshold": 25.0,
"TauntDuration": 3.0,
"ForgetDistance": 35.0
}
}
验收清单
- 双人打怪时,怪会稳定攻击高威胁目标,不会每秒乱切。
- 嘲讽触发后目标立即锁定坦克,持续时间结束后恢复常规仇恨。
- 目标死亡或离开可攻击范围后,能在 1 帧内选中次优目标。
- 日志可还原完整链路:威胁增长、衰减、切换原因。
常见坑
坑 1:仇恨只增不减
长战斗中仇恨会无限膨胀,导致后期切换失真。必须有衰减或归一化策略。
坑 2:切换阈值过低
轻微治疗或 DOT 触发就切目标,表现非常差。要用 SwitchThreshold 防抖。
坑 3:忽略目标有效性
未判断死亡、隐身、不可选中,会导致 AI 锁定空气目标。
本月作业
实现“范围仇恨联动”:
- 支持 AOE 技能给命中目标分别加威胁。
- 支持“仇恨转移”技能(把自身 30% 仇恨转给队友)。
- 输出 30 秒战斗仇恨曲线日志(每 1 秒采样一次)。
下一章进入“战斗回放数据落盘与重放工具”,把命令、伤害、仇恨三条链统一成可回放诊断资产。