feat(enemy): 选招器支持防重复折扣——WeightedRandomAntiRepeat

上一次选中的招按 EnemyStatsSO.attackAntiRepeatFactor 折扣权重(默认 0.3)。
折扣把唯一候选压到 0 时退化为 Priority 路径,不会出现敌人永不出手。
阶段切换清空防重复记忆。取代 BossBase.UseBossSkillWeighted 里的同名逻辑。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 12:43:01 +08:00
co-authored by Claude Opus 5
parent b0fd040904
commit eb3f4888a4
6 changed files with 132 additions and 12 deletions
@@ -3,7 +3,8 @@ namespace BaseGames.Enemies.Abilities
/// <summary>多攻击选招策略。</summary>
public enum AttackSelectionMode
{
WeightedRandom, // 合格集内按 weight 加权随机
Priority // 合格集内取 priority 最高(并列取首个)
WeightedRandom, // 合格集内按 weight 加权随机
Priority, // 合格集内取 priority 最高(并列取首个)
WeightedRandomAntiRepeat, // 同 WeightedRandom,但对上一次选中的招施加权重折扣,压低连续重复
}
}
@@ -12,15 +12,26 @@ namespace BaseGames.Enemies.Abilities
private readonly List<IAttackCandidate> _candidates;
// 权重缓冲区(与 _candidates 等长,每次选招复用):避免每次选招 new List 造成 GC
private readonly List<float> _weightBuf;
// 防重复折扣系数:0 = 有替代时绝不连续重复,1 = 不惩罚
private readonly float _antiRepeatFactor;
public EnemyAttackSelector(IEnumerable<IAttackCandidate> candidates)
private IAttackCandidate _lastPicked;
public EnemyAttackSelector(IEnumerable<IAttackCandidate> candidates, float antiRepeatFactor = 0.3f)
{
_candidates = new List<IAttackCandidate>(candidates);
_weightBuf = new List<float>(_candidates.Count);
_candidates = new List<IAttackCandidate>(candidates);
_weightBuf = new List<float>(_candidates.Count);
_antiRepeatFactor = Mathf.Clamp01(antiRepeatFactor);
}
public int Count => _candidates.Count;
/// <summary>上一次实际选中的招(防重复折扣的作用对象;供测试与调试面板读取)。</summary>
public IAttackCandidate LastPicked => _lastPicked;
/// <summary>对象池复用 / 阶段切换时清除防重复记忆。</summary>
public void ResetRepeatMemory() => _lastPicked = null;
private static bool Eligible(IAttackCandidate c, bool hasLOS, bool grounded)
=> c != null && c.CanUse && c.InAttackRange()
&& (!c.RequiresLineOfSight || hasLOS)
@@ -34,9 +45,24 @@ namespace BaseGames.Enemies.Abilities
}
public IAttackCandidate Select(bool hasLOS, bool grounded, AttackSelectionMode mode)
=> mode == AttackSelectionMode.Priority
? SelectByPriority(hasLOS, grounded)
: SelectByWeight(hasLOS, grounded);
{
switch (mode)
{
case AttackSelectionMode.Priority:
return Remember(SelectByPriority(hasLOS, grounded));
case AttackSelectionMode.WeightedRandomAntiRepeat:
return Remember(SelectByWeight(hasLOS, grounded, antiRepeat: true));
default:
return Remember(SelectByWeight(hasLOS, grounded, antiRepeat: false));
}
}
// 只在真选中了东西时更新记忆:选空时保留上一招,避免"空一帧就清掉防重复"。
private IAttackCandidate Remember(IAttackCandidate pick)
{
if (pick != null) _lastPicked = pick;
return pick;
}
private IAttackCandidate SelectByPriority(bool hasLOS, bool grounded)
{
@@ -50,20 +76,25 @@ namespace BaseGames.Enemies.Abilities
return best;
}
private IAttackCandidate SelectByWeight(bool hasLOS, bool grounded)
private IAttackCandidate SelectByWeight(bool hasLOS, bool grounded, bool antiRepeat)
{
// 有效权重:不合格候选填 0(由共享原语保证零权重项绝不被选中)。缓冲区复用 → 选招零 GC。
_weightBuf.Clear();
for (int i = 0; i < _candidates.Count; i++)
{
var c = _candidates[i];
_weightBuf.Add(Eligible(c, hasLOS, grounded) ? Mathf.Max(0f, c.Weight) : 0f);
float w = Eligible(c, hasLOS, grounded) ? Mathf.Max(0f, c.Weight) : 0f;
// 防重复:上一招权重打折。折扣可把权重压到 0——此时若它是唯一候选,
// 下面的 Priority 退化路径会把它选出来,不会出现"敌人永远不出手"。
if (antiRepeat && w > 0f && ReferenceEquals(c, _lastPicked))
w *= _antiRepeatFactor;
_weightBuf.Add(w);
}
int idx = BaseGames.Core.WeightedPick.Index(_weightBuf);
if (idx >= 0) return _candidates[idx];
// 无正权重(合格候选权重全 0)→ 退化为按 Priority 选(并列取首个)
// 无正权重(合格候选权重全 0,或全被防重复折扣压平)→ 退化为按 Priority 选(并列取首个)
return SelectByPriority(hasLOS, grounded);
}
}