diff --git a/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs b/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs index ee4b13e6..3c928064 100644 --- a/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs +++ b/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs @@ -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(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)); + } } } diff --git a/Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs b/Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs index 5f704bf0..42f3667f 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs @@ -3,7 +3,8 @@ namespace BaseGames.Enemies.Abilities /// 多攻击选招策略。 public enum AttackSelectionMode { - WeightedRandom, // 合格集内按 weight 加权随机 - Priority // 合格集内取 priority 最高(并列取首个) + WeightedRandom, // 合格集内按 weight 加权随机 + Priority, // 合格集内取 priority 最高(并列取首个) + WeightedRandomAntiRepeat, // 同 WeightedRandom,但对上一次选中的招施加权重折扣,压低连续重复 } } diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs index d69ef58e..a3509c4c 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs @@ -12,15 +12,26 @@ namespace BaseGames.Enemies.Abilities private readonly List _candidates; // 权重缓冲区(与 _candidates 等长,每次选招复用):避免每次选招 new List 造成 GC private readonly List _weightBuf; + // 防重复折扣系数:0 = 有替代时绝不连续重复,1 = 不惩罚 + private readonly float _antiRepeatFactor; - public EnemyAttackSelector(IEnumerable candidates) + private IAttackCandidate _lastPicked; + + public EnemyAttackSelector(IEnumerable candidates, float antiRepeatFactor = 0.3f) { - _candidates = new List(candidates); - _weightBuf = new List(_candidates.Count); + _candidates = new List(candidates); + _weightBuf = new List(_candidates.Count); + _antiRepeatFactor = Mathf.Clamp01(antiRepeatFactor); } public int Count => _candidates.Count; + /// 上一次实际选中的招(防重复折扣的作用对象;供测试与调试面板读取)。 + public IAttackCandidate LastPicked => _lastPicked; + + /// 对象池复用 / 阶段切换时清除防重复记忆。 + 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); } } diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs b/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs index 8dadfcf3..b2608cef 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs +++ b/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs @@ -184,6 +184,8 @@ namespace BaseGames.Enemies _currentPhase = phase; LastUsedSkillId = null; // 新阶段重置权重惩罚,防止跨阶段漂移 + // 新阶段换招池,上一阶段的防重复记忆不应跨阶段影响选招 + AttackSelector?.ResetRepeatMemory(); _onBossPhaseChanged?.Raise(new BossPhaseEvent { BossId = _bossId, diff --git a/Assets/_Game/Scripts/Enemies/EnemyBase.cs b/Assets/_Game/Scripts/Enemies/EnemyBase.cs index 6ed64367..0c45e175 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyBase.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyBase.cs @@ -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() diff --git a/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs b/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs index c8761222..eb7835a5 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs @@ -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;