feat(enemy): EnemyAttackSelector 选招器(射程+冷却+LOS+着地过滤,两模式选招)+单测
This commit is contained in:
@@ -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<IAttackCandidate>(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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Enemies.Abilities
|
||||
{
|
||||
/// <summary>
|
||||
/// 多攻击选招器:从候选(category==Attack)里按"射程 + 冷却 + LOS + 着地"过滤,
|
||||
/// 再按 <see cref="AttackSelectionMode"/> 选一个。纯逻辑、无 MonoBehaviour 依赖,可单测。
|
||||
/// </summary>
|
||||
public sealed class EnemyAttackSelector
|
||||
{
|
||||
private readonly List<IAttackCandidate> _candidates;
|
||||
|
||||
public EnemyAttackSelector(IEnumerable<IAttackCandidate> candidates)
|
||||
=> _candidates = new List<IAttackCandidate>(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; // 理论不达(浮点边界兜底)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user