diff --git a/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs b/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs index 3c928064..d161bc1e 100644 --- a/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs +++ b/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs @@ -155,23 +155,91 @@ namespace BaseGames.Tests.EditMode.Enemies if (ReferenceEquals(cur, prev)) repeats++; prev = cur; } - Assert.Greater(repeats, 0, "折扣不应等于禁用"); - Assert.Less (repeats, 200, "折扣后重复率应低于无折扣时的约 50%"); + // 统计断言(无固定随机种子):系数 0.5 → 每次重复概率 0.5/1.5≈1/3, + // 400 次期望≈133,标准差≈9.4;下面的边界约 ±5σ,正确实现下几乎不可能红。 + const string msg = "系数 0.5 → 每次重复概率 0.5/1.5≈1/3,400 次期望≈133(±5σ 约 [90,180])"; + Assert.Greater(repeats, 90, "折扣不应等于禁用。" + msg); + Assert.Less (repeats, 180, "重复率明显高于预期,折扣可能未生效。" + msg); } [Test] public void AntiRepeat_IneligiblePrevious_DoesNotBlockOthers() { + // 用系数 0.5 而非 0:若实现把折扣错误地施加在「资格归零」之前 + // (c.Weight * factor 而非 w * factor),冷却中的上一招会拿到 0.5 的权重而可能被选中。 + // 系数为 0 时那个 bug 也会算出 0,测试将永远通过、失去意义。 var a = new FakeCandidate { WeightV = 1f }; var b = new FakeCandidate { WeightV = 1f }; - var s = SelAntiRepeat(0f, a, b); + var s = SelAntiRepeat(0.5f, 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); + for (int i = 0; i < 20; i++) + { + var second = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat); + Assert.IsNotNull(second); + Assert.AreNotSame(first, second, "冷却中的招不得因防重复折扣路径被选中"); + } + } + + [Test] + public void ResetRepeatMemory_RestoresUndiscountedWeights() + { + // 确定性用例:a 权重 1 / 优先级 0,b 权重 0 / 优先级 5。 + // 选中 a 后若不清记忆,a 被折扣到 0 → 全零 → 走 Priority 退化路径选出 b。 + // 清了记忆则 a 恢复权重 1,仍应选 a。因此该用例精确地在重置失效时变红。 + var a = new FakeCandidate { WeightV = 1f, PriorityV = 0 }; + var b = new FakeCandidate { WeightV = 0f, PriorityV = 5 }; + var s = SelAntiRepeat(0f, a, b); + + Assert.AreSame(a, s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat)); + s.ResetRepeatMemory(); + Assert.AreSame(a, s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat), + "清除防重复记忆后应恢复未折扣的权重分布"); + } + + [Test] + public void AntiRepeat_FactorOne_AppliesNoPenalty() + { + // 系数 1.0 是 [Range] 的上界,语义为「不惩罚」:重复率应回到约 50% + var a = new FakeCandidate { WeightV = 1f }; + var b = new FakeCandidate { WeightV = 1f }; + var s = SelAntiRepeat(1f, 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, 150, + "系数 1.0 应无惩罚:重复概率≈50%,400 次期望≈200(σ≈10,150 约为 -5σ)"); + } + + [Test] + public void AntiRepeat_ThreeCandidates_NoImmediateRepeat_AndAllReachable() + { + // 三候选:既验证折扣循环的下标没写错,也验证除上一招外其余候选都仍可达 + var a = new FakeCandidate { WeightV = 1f }; + var b = new FakeCandidate { WeightV = 1f }; + var c = new FakeCandidate { WeightV = 1f }; + var s = SelAntiRepeat(0f, a, b, c); + + var seen = new HashSet(); + var prev = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat); + seen.Add(prev); + for (int i = 0; i < 200; i++) + { + var cur = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat); + Assert.AreNotSame(prev, cur, "系数为 0 时不应连续选中同一招"); + seen.Add(cur); + prev = cur; + } + Assert.AreEqual(3, seen.Count, "三个等权候选应都能被选到"); } [Test] diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs b/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs index 819218e4..8b46a078 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs @@ -109,6 +109,9 @@ namespace BaseGames.Enemies var mode = _enemy.StatsSO != null ? _enemy.StatsSO.attackSelectionMode : Abilities.AttackSelectionMode.WeightedRandom; + // 折扣系数与 mode 同步每次刷新,使播放模式下调参立即生效(不必重进场景) + if (_enemy.StatsSO != null) + sel.AntiRepeatFactor = _enemy.StatsSO.attackAntiRepeatFactor; bool grounded = _enemy.Movement != null && _enemy.Movement.IsGrounded; var pick = sel.Select(_enemy.IsPlayerVisible(), grounded, mode); return pick != null && pick.Execute(); diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs index a3509c4c..45f8b3dd 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs @@ -9,23 +9,35 @@ namespace BaseGames.Enemies.Abilities /// public sealed class EnemyAttackSelector { + /// 防重复折扣系数的默认值。EnemyStatsSO 字段初值与 EnemyBase 的兜底分支共用此常量。 + public const float DefaultAntiRepeatFactor = 0.3f; + private readonly List _candidates; // 权重缓冲区(与 _candidates 等长,每次选招复用):避免每次选招 new List 造成 GC private readonly List _weightBuf; // 防重复折扣系数:0 = 有替代时绝不连续重复,1 = 不惩罚 - private readonly float _antiRepeatFactor; + private float _antiRepeatFactor; private IAttackCandidate _lastPicked; - public EnemyAttackSelector(IEnumerable candidates, float antiRepeatFactor = 0.3f) + public EnemyAttackSelector(IEnumerable candidates, + float antiRepeatFactor = DefaultAntiRepeatFactor) { - _candidates = new List(candidates); - _weightBuf = new List(_candidates.Count); - _antiRepeatFactor = Mathf.Clamp01(antiRepeatFactor); + _candidates = new List(candidates); + _weightBuf = new List(_candidates.Count); + AntiRepeatFactor = antiRepeatFactor; // 经属性赋值,夹紧逻辑只有一处 } public int Count => _candidates.Count; + /// 防重复折扣系数。与 attackSelectionMode 一样每次选招前由调用方刷新, + /// 使策划在播放模式下调参能立即生效。 + public float AntiRepeatFactor + { + get => _antiRepeatFactor; + set => _antiRepeatFactor = Mathf.Clamp01(value); + } + /// 上一次实际选中的招(防重复折扣的作用对象;供测试与调试面板读取)。 public IAttackCandidate LastPicked => _lastPicked; diff --git a/Assets/_Game/Scripts/Enemies/EnemyBase.cs b/Assets/_Game/Scripts/Enemies/EnemyBase.cs index 0c45e175..fd5da26b 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyBase.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyBase.cs @@ -601,9 +601,11 @@ namespace BaseGames.Enemies candidates.Add(ab); } } - // 折扣系数是策划配置项,未配 StatsSO 的敌人用 0.3 这个合法业务缺省(非掩盖漏配: - // 该模式本身是可选的,没配就等于用默认折扣)。 - float antiRepeat = _statsSO != null ? _statsSO.attackAntiRepeatFactor : 0.3f; + // 缺 StatsSO 已由本类其它处显式报错,这里的三目只是避免空引用; + // 真正的业务缺省是 SO 字段自身的初值。 + float antiRepeat = _statsSO != null + ? _statsSO.attackAntiRepeatFactor + : BaseGames.Enemies.Abilities.EnemyAttackSelector.DefaultAntiRepeatFactor; _attackSelector = new Abilities.EnemyAttackSelector(candidates, antiRepeat); } @@ -760,6 +762,9 @@ namespace BaseGames.Enemies // 重置能力冷却 _abilities.InterruptAll(InterruptReason.Dead); + // 对象池复用不得继承上一条命的防重复记忆(否则新生即带着上次的招被折扣) + _attackSelector?.ResetRepeatMemory(); + // 重置决策层(回到 Entry、清临时态) if (_brain != null) _brain.enabled = true; // 复用时恢复决策组件(可能曾被 QuotaManager 裁剪禁用) _brain?.ResetBrain(); diff --git a/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs b/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs index eb7835a5..78dea70d 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs @@ -46,7 +46,8 @@ namespace BaseGames.Enemies [Tooltip("WeightedRandomAntiRepeat 模式下,上一次选中的招的权重折扣系数。" + "0 = 有替代招时绝不连续重复;1 = 不惩罚。仅该模式生效")] - [Range(0f, 1f)] public float attackAntiRepeatFactor = 0.3f; + [Range(0f, 1f)] public float attackAntiRepeatFactor + = BaseGames.Enemies.Abilities.EnemyAttackSelector.DefaultAntiRepeatFactor; [Header("追击 & AI 阶段")] [Tooltip("是否有警觉状态:勾选=未发现态进入视野先进警觉(朝向+警觉动画)再追击;不勾选=进入追逐感知直接追击")]