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:
@@ -104,5 +104,84 @@ namespace BaseGames.Tests.EditMode.Enemies
|
||||
for (int i = 0; i < 50; i++)
|
||||
Assert.AreSame(ok, Sel(bad, ok).Select(true, true, AttackSelectionMode.WeightedRandom));
|
||||
}
|
||||
|
||||
// ── WeightedRandomAntiRepeat ─────────────────────────────────────────
|
||||
|
||||
static EnemyAttackSelector SelAntiRepeat(float factor, params IAttackCandidate[] cs)
|
||||
=> new EnemyAttackSelector(new List<IAttackCandidate>(cs), factor);
|
||||
|
||||
[Test]
|
||||
public void AntiRepeat_ZeroFactor_NeverPicksSameTwiceWhenAlternativeExists()
|
||||
{
|
||||
var a = new FakeCandidate { WeightV = 1f };
|
||||
var b = new FakeCandidate { WeightV = 1f };
|
||||
var s = SelAntiRepeat(0f, a, b);
|
||||
|
||||
var prev = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var cur = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||||
Assert.AreNotSame(prev, cur, "系数为 0 时不应连续选中同一招");
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AntiRepeat_ZeroFactor_SingleCandidate_StillReturnsIt()
|
||||
{
|
||||
// 只有一招时折扣会把权重压到 0,WeightedPick 返回 -1;
|
||||
// 必须退化为 Priority 路径把它选出来,否则敌人永远不出手。
|
||||
var only = new FakeCandidate { WeightV = 1f };
|
||||
var s = SelAntiRepeat(0f, only);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
Assert.AreSame(only, s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AntiRepeat_DiscountsPreviousPick_ButDoesNotBanIt()
|
||||
{
|
||||
// 系数 0.5:a 被选过后权重折半,但仍有机会再被选中。
|
||||
var a = new FakeCandidate { WeightV = 1f };
|
||||
var b = new FakeCandidate { WeightV = 1f };
|
||||
var s = SelAntiRepeat(0.5f, a, b);
|
||||
|
||||
s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||||
int repeats = 0;
|
||||
var prev = s.LastPicked;
|
||||
for (int i = 0; i < 400; i++)
|
||||
{
|
||||
var cur = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||||
if (ReferenceEquals(cur, prev)) repeats++;
|
||||
prev = cur;
|
||||
}
|
||||
Assert.Greater(repeats, 0, "折扣不应等于禁用");
|
||||
Assert.Less (repeats, 200, "折扣后重复率应低于无折扣时的约 50%");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AntiRepeat_IneligiblePrevious_DoesNotBlockOthers()
|
||||
{
|
||||
var a = new FakeCandidate { WeightV = 1f };
|
||||
var b = new FakeCandidate { WeightV = 1f };
|
||||
var s = SelAntiRepeat(0f, a, b);
|
||||
|
||||
var first = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||||
((FakeCandidate)first).CanUseV = false; // 上一招进冷却
|
||||
|
||||
var second = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||||
Assert.IsNotNull(second);
|
||||
Assert.AreNotSame(first, second);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlainWeighted_DoesNotApplyAntiRepeat()
|
||||
{
|
||||
// WeightedRandom 模式下唯一候选恒被选中(回归:折扣不得泄漏到旧模式)
|
||||
var only = new FakeCandidate { WeightV = 1f };
|
||||
var s = SelAntiRepeat(0f, only);
|
||||
for (int i = 0; i < 10; i++)
|
||||
Assert.AreSame(only, s.Select(true, true, AttackSelectionMode.WeightedRandom));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +184,8 @@ namespace BaseGames.Enemies
|
||||
|
||||
_currentPhase = phase;
|
||||
LastUsedSkillId = null; // 新阶段重置权重惩罚,防止跨阶段漂移
|
||||
// 新阶段换招池,上一阶段的防重复记忆不应跨阶段影响选招
|
||||
AttackSelector?.ResetRepeatMemory();
|
||||
_onBossPhaseChanged?.Raise(new BossPhaseEvent
|
||||
{
|
||||
BossId = _bossId,
|
||||
|
||||
@@ -601,7 +601,10 @@ namespace BaseGames.Enemies
|
||||
candidates.Add(ab);
|
||||
}
|
||||
}
|
||||
_attackSelector = new Abilities.EnemyAttackSelector(candidates);
|
||||
// 折扣系数是策划配置项,未配 StatsSO 的敌人用 0.3 这个合法业务缺省(非掩盖漏配:
|
||||
// 该模式本身是可选的,没配就等于用默认折扣)。
|
||||
float antiRepeat = _statsSO != null ? _statsSO.attackAntiRepeatFactor : 0.3f;
|
||||
_attackSelector = new Abilities.EnemyAttackSelector(candidates, antiRepeat);
|
||||
}
|
||||
|
||||
protected virtual void Update()
|
||||
|
||||
@@ -44,6 +44,10 @@ namespace BaseGames.Enemies
|
||||
public BaseGames.Enemies.Abilities.AttackSelectionMode attackSelectionMode
|
||||
= BaseGames.Enemies.Abilities.AttackSelectionMode.WeightedRandom;
|
||||
|
||||
[Tooltip("WeightedRandomAntiRepeat 模式下,上一次选中的招的权重折扣系数。" +
|
||||
"0 = 有替代招时绝不连续重复;1 = 不惩罚。仅该模式生效")]
|
||||
[Range(0f, 1f)] public float attackAntiRepeatFactor = 0.3f;
|
||||
|
||||
[Header("追击 & AI 阶段")]
|
||||
[Tooltip("是否有警觉状态:勾选=未发现态进入视野先进警觉(朝向+警觉动画)再追击;不勾选=进入追逐感知直接追击")]
|
||||
public bool HasAlertState = true;
|
||||
|
||||
Reference in New Issue
Block a user