diff --git a/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs b/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs new file mode 100644 index 00000000..ee4b13e6 --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using NUnit.Framework; +using BaseGames.Enemies.Abilities; + +namespace BaseGames.Tests.EditMode.Enemies +{ + public class EnemyAttackSelectorTests + { + sealed class FakeCandidate : IAttackCandidate + { + public bool CanUseV = true, InRange = true, ReqLOS = false, ReqGround = false, Executed; + public float WeightV = 1f; + public int PriorityV = 0; + public bool CanUse => CanUseV; + public bool RequiresLineOfSight => ReqLOS; + public bool RequiresGrounded => ReqGround; + public float Weight => WeightV; + public int Priority => PriorityV; + public bool InAttackRange() => InRange; + public bool Execute() { Executed = true; return true; } + } + + static EnemyAttackSelector Sel(params IAttackCandidate[] cs) + => new EnemyAttackSelector(new List(cs)); + + [Test] + public void Empty_NoEligible() + { + var s = Sel(); + Assert.IsFalse(s.HasEligible(true, true)); + Assert.IsNull(s.Select(true, true, AttackSelectionMode.Priority)); + } + + [Test] + public void ExcludesOnCooldown() + { + var c = new FakeCandidate { CanUseV = false }; + Assert.IsFalse(Sel(c).HasEligible(true, true)); + } + + [Test] + public void ExcludesOutOfRange() + { + var c = new FakeCandidate { InRange = false }; + Assert.IsFalse(Sel(c).HasEligible(true, true)); + } + + [Test] + public void ExcludesWhenNeedsLOS_ButNoLOS() + { + var c = new FakeCandidate { ReqLOS = true }; + Assert.IsFalse(Sel(c).HasEligible(false, true)); + Assert.IsTrue (Sel(c).HasEligible(true, true)); + } + + [Test] + public void ExcludesWhenNeedsGrounded_ButAirborne() + { + var c = new FakeCandidate { ReqGround = true }; + Assert.IsFalse(Sel(c).HasEligible(true, false)); + Assert.IsTrue (Sel(c).HasEligible(true, true)); + } + + [Test] + public void Priority_PicksHighest() + { + var lo = new FakeCandidate { PriorityV = 1 }; + var hi = new FakeCandidate { PriorityV = 5 }; + var pick = Sel(lo, hi).Select(true, true, AttackSelectionMode.Priority); + Assert.AreSame(hi, pick); + } + + [Test] + public void Priority_TieTakesFirstInList() + { + var a = new FakeCandidate { PriorityV = 3 }; + var b = new FakeCandidate { PriorityV = 3 }; + var pick = Sel(a, b).Select(true, true, AttackSelectionMode.Priority); + Assert.AreSame(a, pick); + } + + [Test] + public void Priority_SkipsIneligible() + { + var blocked = new FakeCandidate { PriorityV = 9, InRange = false }; + var ok = new FakeCandidate { PriorityV = 1 }; + var pick = Sel(blocked, ok).Select(true, true, AttackSelectionMode.Priority); + Assert.AreSame(ok, pick); + } + + [Test] + public void Weighted_SingleEligible_AlwaysThatOne() + { + var only = new FakeCandidate { WeightV = 2f }; + for (int i = 0; i < 20; i++) + Assert.AreSame(only, Sel(only).Select(true, true, AttackSelectionMode.WeightedRandom)); + } + + [Test] + public void Weighted_NeverPicksIneligible() + { + var bad = new FakeCandidate { WeightV = 100f, InRange = false }; + var ok = new FakeCandidate { WeightV = 1f }; + for (int i = 0; i < 50; i++) + Assert.AreSame(ok, Sel(bad, ok).Select(true, true, AttackSelectionMode.WeightedRandom)); + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs new file mode 100644 index 00000000..310bff99 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace BaseGames.Enemies.Abilities +{ + /// + /// 多攻击选招器:从候选(category==Attack)里按"射程 + 冷却 + LOS + 着地"过滤, + /// 再按 选一个。纯逻辑、无 MonoBehaviour 依赖,可单测。 + /// + public sealed class EnemyAttackSelector + { + private readonly List _candidates; + + public EnemyAttackSelector(IEnumerable candidates) + => _candidates = new List(candidates); + + public int Count => _candidates.Count; + + private static bool Eligible(IAttackCandidate c, bool hasLOS, bool grounded) + => c != null && c.CanUse && c.InAttackRange() + && (!c.RequiresLineOfSight || hasLOS) + && (!c.RequiresGrounded || grounded); + + public bool HasEligible(bool hasLOS, bool grounded) + { + for (int i = 0; i < _candidates.Count; i++) + if (Eligible(_candidates[i], hasLOS, grounded)) return true; + return false; + } + + public IAttackCandidate Select(bool hasLOS, bool grounded, AttackSelectionMode mode) + => mode == AttackSelectionMode.Priority + ? SelectByPriority(hasLOS, grounded) + : SelectByWeight(hasLOS, grounded); + + private IAttackCandidate SelectByPriority(bool hasLOS, bool grounded) + { + IAttackCandidate best = null; + for (int i = 0; i < _candidates.Count; i++) + { + var c = _candidates[i]; + if (!Eligible(c, hasLOS, grounded)) continue; + if (best == null || c.Priority > best.Priority) best = c; // 并列取首个 + } + return best; + } + + private IAttackCandidate SelectByWeight(bool hasLOS, bool grounded) + { + float total = 0f; + for (int i = 0; i < _candidates.Count; i++) + { + var c = _candidates[i]; + if (Eligible(c, hasLOS, grounded)) total += Mathf.Max(0f, c.Weight); + } + if (total <= 0f) return SelectByPriority(hasLOS, grounded); // 权重全 0 → 退化为确定性取首个合格 + float roll = Random.value * total; + for (int i = 0; i < _candidates.Count; i++) + { + var c = _candidates[i]; + if (!Eligible(c, hasLOS, grounded)) continue; + roll -= Mathf.Max(0f, c.Weight); + if (roll <= 0f) return c; + } + return null; // 理论不达(浮点边界兜底) + } + } +}