diff --git a/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs b/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs index 75876a3a..d3ad1a33 100644 --- a/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs +++ b/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using UnityEngine; using BaseGames.AI; +using BaseGames.Enemies; namespace BaseGames.Tests.EditMode.AI { @@ -44,7 +45,7 @@ namespace BaseGames.Tests.EditMode.AI public bool HpBelow(float ratio) => Hp < ratio; } - public sealed class FakeAiContext : IAiContext + public sealed class FakeAiContext : IAiContext, IEnemyActor { public FakeSensor S = new FakeSensor(); public FakeLocomotion L = new FakeLocomotion(); @@ -52,11 +53,13 @@ namespace BaseGames.Tests.EditMode.AI public FakeVitals V = new FakeVitals(); public Blackboard BB = new Blackboard(); public FakeBossControl B = new FakeBossControl(); + public bool HasAlert = true; public ISensor Sensor => S; public IEnemyLocomotion Locomotion => L; public ICombatant Combat => C; public IActorVitals Vitals => V; public Blackboard Blackboard => BB; public IBossControl Boss => B; + public bool HasAlertState => HasAlert; } } diff --git a/Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs b/Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs index 21fe18a5..142a51b1 100644 --- a/Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs +++ b/Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs @@ -13,9 +13,9 @@ namespace BaseGames.Tests.EditMode.AI public float LastDuration = -1f; public int BeginCallCount; - // 锚点:测试按下标直给坐标;距离由测试直接设定 + // 锚点:测试按下标直给坐标;距离由 SelfPosition 与该下标锚点算出(不忽略 index) public UnityEngine.Vector2[] Anchors = new UnityEngine.Vector2[0]; - public float DistanceToAnchorValue; + public UnityEngine.Vector2 SelfPosition; public int CurrentPhase => Phase; public bool IsPhaseTransitioning => Transitioning; @@ -30,6 +30,7 @@ namespace BaseGames.Tests.EditMode.AI } public UnityEngine.Vector2 AnchorAt(int index) => Anchors[index]; - public float DistanceToAnchor(int index) => DistanceToAnchorValue; + public float DistanceToAnchor(int index) + => UnityEngine.Vector2.Distance(SelfPosition, Anchors[index]); } } diff --git a/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs b/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs index 3cd16141..b4def37f 100644 --- a/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs +++ b/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs @@ -7,23 +7,6 @@ namespace BaseGames.Tests.EditMode.AI { public class PerceptionRecipeSoTests { - sealed class Ctx : IAiContext, IEnemyActor - { - public FakeSensor S = new FakeSensor(); - public FakeLocomotion L = new FakeLocomotion(); - public FakeCombat C = new FakeCombat(); - public FakeVitals V = new FakeVitals(); - public Blackboard BB = new Blackboard(); - public FakeBossControl B = new FakeBossControl(); - public ISensor Sensor => S; - public IEnemyLocomotion Locomotion => L; - public ICombatant Combat => C; - public IActorVitals Vitals => V; - public Blackboard Blackboard => BB; - public IBossControl Boss => B; - public bool HasAlertState => true; - } - static PerceptionRecipeSO MakeE001Recipe() { var so = ScriptableObject.CreateInstance(); @@ -61,7 +44,7 @@ namespace BaseGames.Tests.EditMode.AI public void Graph_BehavesLikeE001() { var so = MakeE001Recipe(); - var ctx = new Ctx(); + var ctx = new FakeAiContext(); var rt = new AiRuntime(so.GetOrBuildGraph(), ctx); Assert.AreEqual(DisguiseThenPatrol.Disguise, rt.CurrentStateName); ctx.S.Chase = true; diff --git a/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs b/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs index d7908377..bb21a5d5 100644 --- a/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs +++ b/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs @@ -7,26 +7,7 @@ namespace BaseGames.Tests.EditMode.AI { public class PerceptionSkeletonTests { - sealed class Ctx : IAiContext, IEnemyActor - { - public FakeSensor S = new FakeSensor(); - public FakeLocomotion L = new FakeLocomotion(); - public FakeCombat C = new FakeCombat(); - public FakeVitals V = new FakeVitals(); - public Blackboard BB = new Blackboard(); - public FakeBossControl B = new FakeBossControl(); - public bool HasAlert = true; - - public ISensor Sensor => S; - public IEnemyLocomotion Locomotion => L; - public ICombatant Combat => C; - public IActorVitals Vitals => V; - public Blackboard Blackboard => BB; - public IBossControl Boss => B; - public bool HasAlertState => HasAlert; - } - - static AiRuntime Build(Ctx ctx, IUnawareModule unaware = null, IEngagementModule engagement = null) + static AiRuntime Build(FakeAiContext ctx, IUnawareModule unaware = null, IEngagementModule engagement = null) { var b = new BrainBuilder(); PerceptionSkeleton.Add(b, @@ -40,7 +21,7 @@ namespace BaseGames.Tests.EditMode.AI [Test] public void Unaware_ToEngagement_WhenInChaseZone() { - var ctx = new Ctx(); + var ctx = new FakeAiContext(); var rt = Build(ctx); ctx.S.Chase = true; rt.Tick(0.1f); @@ -50,7 +31,7 @@ namespace BaseGames.Tests.EditMode.AI [Test] public void Unaware_ToAlert_WhenInVision_AndHasAlert() { - var ctx = new Ctx { HasAlert = true }; + var ctx = new FakeAiContext { HasAlert = true }; var rt = Build(ctx); ctx.S.Vision = true; rt.Tick(0.1f); @@ -60,7 +41,7 @@ namespace BaseGames.Tests.EditMode.AI [Test] public void Unaware_StaysUnaware_WhenInVision_ButNoAlertState() { - var ctx = new Ctx { HasAlert = false }; + var ctx = new FakeAiContext { HasAlert = false }; var rt = Build(ctx); ctx.S.Vision = true; rt.Tick(0.1f); @@ -70,7 +51,7 @@ namespace BaseGames.Tests.EditMode.AI [Test] public void ChaseZone_BeatsAlert_WhenBothActive() { - var ctx = new Ctx { HasAlert = true }; + var ctx = new FakeAiContext { HasAlert = true }; var rt = Build(ctx); ctx.S.Chase = true; ctx.S.Vision = true; rt.Tick(0.1f); @@ -81,7 +62,7 @@ namespace BaseGames.Tests.EditMode.AI [Test] public void UpgradeEdge_BeatsUnawareInternalDwellEdge() { - var ctx = new Ctx(); + var ctx = new FakeAiContext(); var rt = Build(ctx, new AlternatingIdlePatrol(idleDwell: 1f, patrolDwell: 1f)); Assert.AreEqual(AlternatingIdlePatrol.Idle, rt.CurrentStateName); ctx.S.Chase = true; @@ -94,7 +75,7 @@ namespace BaseGames.Tests.EditMode.AI [Test] public void Alert_ToEngagement_WhenEnteringChaseZone() { - var ctx = new Ctx(); + var ctx = new FakeAiContext(); var rt = Build(ctx); ctx.S.Vision = true; rt.Tick(0.1f); Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName); @@ -105,7 +86,7 @@ namespace BaseGames.Tests.EditMode.AI [Test] public void Alert_ToRest_WhenLeavingVision() { - var ctx = new Ctx(); + var ctx = new FakeAiContext(); var rt = Build(ctx); ctx.S.Vision = true; rt.Tick(0.1f); ctx.S.Vision = false; rt.Tick(0.1f); @@ -115,7 +96,7 @@ namespace BaseGames.Tests.EditMode.AI [Test] public void Alert_FacesLastKnown() { - var ctx = new Ctx(); + var ctx = new FakeAiContext(); ctx.S.Last = new UnityEngine.Vector2(7f, 2f); var rt = Build(ctx); ctx.S.Vision = true; rt.Tick(0.1f); @@ -127,7 +108,7 @@ namespace BaseGames.Tests.EditMode.AI [Test] public void AfterEngagement_GoesToRest_NeverBackToAlert() { - var ctx = new Ctx(); + var ctx = new FakeAiContext(); var rt = Build(ctx); ctx.S.Chase = true; rt.Tick(0.1f); Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); @@ -140,7 +121,7 @@ namespace BaseGames.Tests.EditMode.AI [Test] public void Died_TransitionsToDeath_FromAnyState() { - var ctx = new Ctx(); + var ctx = new FakeAiContext(); var rt = Build(ctx); ctx.S.Chase = true; rt.Tick(0.1f); Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); @@ -154,7 +135,7 @@ namespace BaseGames.Tests.EditMode.AI [Test] public void Died_WorksEvenWhenNotControllable() { - var ctx = new Ctx(); + var ctx = new FakeAiContext(); var rt = Build(ctx); ctx.V.Controllable = false; // 已是 Dead 物理态 rt.Send(AiSignal.Died); @@ -165,7 +146,7 @@ namespace BaseGames.Tests.EditMode.AI [Test] public void Death_IsTerminal_NoConditionEdgesEvaluatedAfterwards() { - var ctx = new Ctx(); + var ctx = new FakeAiContext(); var rt = Build(ctx); rt.Send(AiSignal.Died); rt.Tick(0.1f); @@ -224,7 +205,7 @@ namespace BaseGames.Tests.EditMode.AI [Test] public void Skeleton_WithApproachAttack_EscalatesAndDisengages() { - var ctx = new Ctx(); + var ctx = new FakeAiContext(); var rt = Build(ctx, new SinglePost(LocomotionMode.Patrol), new ApproachAttackEngagement()); Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); @@ -246,7 +227,7 @@ namespace BaseGames.Tests.EditMode.AI public void Skeleton_WithDisguiseThenPatrol_DisengagesToPatrol_NotDisguise() { // 唯一 Entry != Rest 的未发现层:出生伪装静止,脱战只回巡逻,伪装态从此不可达。 - var ctx = new Ctx(); + var ctx = new FakeAiContext(); var rt = Build(ctx, new DisguiseThenPatrol()); Assert.AreEqual(DisguiseThenPatrol.Disguise, rt.CurrentStateName); @@ -267,7 +248,7 @@ namespace BaseGames.Tests.EditMode.AI public void EntryCanBeOverridden_ForEnemiesWithPrefixStates() { // 骨架注释承诺的出路:掉落链 / 出场链这类前置态,调 Add 之后再覆盖 Entry。 - var ctx = new Ctx(); + var ctx = new FakeAiContext(); var b = new BrainBuilder(); PerceptionSkeleton.Add(b, new SinglePost(LocomotionMode.Patrol), new RushEngagement("rush", RushExit.OnLostTarget)); diff --git a/Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs b/Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs index 31c20c2b..ca368656 100644 --- a/Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs +++ b/Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs @@ -8,14 +8,26 @@ namespace BaseGames.Tests.EditMode.Enemies { public class EnemyBrainContextBossFacetTests { - // EnemyBase.Awake 会对缺失的碰撞体/引用报错,这些与本测试无关。 - [SetUp] public void SetUp() => LogAssert.ignoreFailingMessages = true; - [TearDown] public void TearDown() => LogAssert.ignoreFailingMessages = false; + // 本项目关闭了 Domain/Scene Reload,EditMode 测试跑在当前打开的场景上: + // 建出来的物体必须在 TearDown 里销毁,断言失败时才不会把物体永久留在场景里。 + GameObject _go; - static T MakeEnemy(string name) where T : EnemyBase + // EnemyBase.Awake 会对缺失的碰撞体/引用报错,这些与本测试无关。 + [SetUp] + public void SetUp() => LogAssert.ignoreFailingMessages = true; + + [TearDown] + public void TearDown() { - var go = new GameObject(name); - return go.AddComponent(); + LogAssert.ignoreFailingMessages = false; + if (_go != null) UnityEngine.Object.DestroyImmediate(_go); + _go = null; + } + + T MakeEnemy(string name) where T : EnemyBase + { + _go = new GameObject(name); + return _go.AddComponent(); } [Test] @@ -25,8 +37,6 @@ namespace BaseGames.Tests.EditMode.Enemies var ctx = new EnemyBrainContext(enemy); Assert.Throws(() => { var _ = ctx.Boss; }); - - UnityEngine.Object.DestroyImmediate(enemy.gameObject); } [Test] @@ -38,9 +48,8 @@ namespace BaseGames.Tests.EditMode.Enemies Assert.AreSame(boss, ctx.Boss); Assert.AreEqual(0, ctx.Boss.CurrentPhase); Assert.IsFalse(ctx.Boss.IsPhaseTransitioning); - Assert.IsFalse(ctx.Boss.ResourceFull); // 未挂 BossResource - - UnityEngine.Object.DestroyImmediate(boss.gameObject); + // 未挂 BossResource:图查询资源满值属配置错误,必须抛而不是静默返回 false + Assert.Throws(() => { var _ = ctx.Boss.ResourceFull; }); } } } diff --git a/Assets/_Game/Scripts/AI/IBossControl.cs b/Assets/_Game/Scripts/AI/IBossControl.cs index 0f80a8f7..8eee98d1 100644 --- a/Assets/_Game/Scripts/AI/IBossControl.cs +++ b/Assets/_Game/Scripts/AI/IBossControl.cs @@ -1,8 +1,8 @@ namespace BaseGames.AI { /// - /// Boss 专属决策面。挂在 上供 Boss 图读取阶段状态、发起阶段过渡、 - /// 查询竞技场锚点。只暴露"决策需要知道的",过渡演出与无敌帧由实现方(BossBase)负责。 + /// Boss 专属决策面。挂在 上供 Boss 图读取阶段状态、发起阶段过渡。 + /// 只暴露"决策需要知道的",过渡演出与无敌帧由实现方(BossBase)负责。 /// 非 Boss 敌人访问 会抛异常——这是有意的显式失败。 /// public interface IBossControl @@ -13,13 +13,14 @@ namespace BaseGames.AI /// 是否处于阶段过渡(无敌 + 过渡演出)期间。 bool IsPhaseTransitioning { get; } - /// Boss 资源是否已满(无资源组件时恒为 false)。 + /// Boss 资源是否已满。未挂资源组件时抛异常——图问了资源却没配组件是配置错误。 bool ResourceFull { get; } - /// 发起阶段过渡:无敌 invincibleDuration 秒后切入 targetPhase。 + /// 发起阶段过渡:无敌 invincibleDuration 秒后切入 targetPhase。 + /// 过渡进行中重复调用会被忽略并告警——应在 OnEnter 调用一次,不要放在 Tick。 void BeginPhaseTransition(int targetPhase, float invincibleDuration); - // 竞技场锚点两成员(AnchorAt / DistanceToAnchor)在 Task 10 与 - // BossArenaAnchors 组件一并加入——此刻加会让 Task 2–8 期间 BossBase 编译不过。 + // 竞技场锚点两成员(AnchorAt / DistanceToAnchor)随 BossArenaAnchors 组件一并加入, + // 届时 BossBase 同步实现。 } } diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs b/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs index 1e6ea68f..8dadfcf3 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs +++ b/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs @@ -38,8 +38,13 @@ namespace BaseGames.Enemies /// 当前 Boss 阶段索引。IBossControl 实现。 public int CurrentPhase => _currentPhase; - /// Boss 资源是否已满(未挂资源组件时恒为 false)。IBossControl 实现。 - public bool ResourceFull => _bossResource != null && _bossResource.IsFull; + /// Boss 资源是否已满。IBossControl 实现。 + /// 未挂 BossResource 即抛——AI 图问了资源却没配资源组件是配置错误,必须暴露。 + public bool ResourceFull => _bossResource != null + ? _bossResource.IsFull + : throw new System.InvalidOperationException( + $"[BossBase] '{name}' 的 AI 图查询了资源满值,但未挂 BossResource 组件。" + + "请挂上该组件,或从 AI 图里移除资源相关的边。"); private Coroutine _counterStaggerCoroutine; // 缓存加权候选与其有效权重(两者等长、下标对应),避免 UseBossSkillWeighted() 每次 new List → GC 分配 @@ -192,7 +197,7 @@ namespace BaseGames.Enemies /// /// 过渡目标阶段索引。 /// 无敌帧持续时间(秒)。 - public void BeginPhaseTransition(int targetPhase, float invincibleDuration = 1.5f) + public void BeginPhaseTransition(int targetPhase, float invincibleDuration) { if (IsPhaseTransitioning) {