diff --git a/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs b/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs index 34b6b28e..75876a3a 100644 --- a/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs +++ b/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs @@ -51,10 +51,12 @@ namespace BaseGames.Tests.EditMode.AI 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; } } diff --git a/Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs b/Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs new file mode 100644 index 00000000..21fe18a5 --- /dev/null +++ b/Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs @@ -0,0 +1,35 @@ +using BaseGames.AI; + +namespace BaseGames.Tests.EditMode.AI +{ + public sealed class FakeBossControl : IBossControl + { + public int Phase; + public bool Transitioning; + public bool Full; + + // 记录最近一次过渡请求,供断言"图确实发起了过渡" + public int LastTargetPhase = -1; + public float LastDuration = -1f; + public int BeginCallCount; + + // 锚点:测试按下标直给坐标;距离由测试直接设定 + public UnityEngine.Vector2[] Anchors = new UnityEngine.Vector2[0]; + public float DistanceToAnchorValue; + + public int CurrentPhase => Phase; + public bool IsPhaseTransitioning => Transitioning; + public bool ResourceFull => Full; + + public void BeginPhaseTransition(int targetPhase, float invincibleDuration) + { + LastTargetPhase = targetPhase; + LastDuration = invincibleDuration; + BeginCallCount++; + Transitioning = true; // 由测试手工置回 false 模拟过渡结束 + } + + public UnityEngine.Vector2 AnchorAt(int index) => Anchors[index]; + public float DistanceToAnchor(int index) => DistanceToAnchorValue; + } +} diff --git a/Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs.meta b/Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs.meta new file mode 100644 index 00000000..e0ae2ee8 --- /dev/null +++ b/Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dc93ec3a62b58c24da5b420bdeeed029 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs b/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs index a6649097..3cd16141 100644 --- a/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs +++ b/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs @@ -14,11 +14,13 @@ namespace BaseGames.Tests.EditMode.AI 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; } diff --git a/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs b/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs index 882dd2ab..d7908377 100644 --- a/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs +++ b/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs @@ -14,6 +14,7 @@ namespace BaseGames.Tests.EditMode.AI 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; @@ -21,6 +22,7 @@ namespace BaseGames.Tests.EditMode.AI 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/Enemies/EnemyBrainContextBossFacetTests.cs b/Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs new file mode 100644 index 00000000..31c20c2b --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs @@ -0,0 +1,46 @@ +using System; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; +using BaseGames.Enemies; + +namespace BaseGames.Tests.EditMode.Enemies +{ + public class EnemyBrainContextBossFacetTests + { + // EnemyBase.Awake 会对缺失的碰撞体/引用报错,这些与本测试无关。 + [SetUp] public void SetUp() => LogAssert.ignoreFailingMessages = true; + [TearDown] public void TearDown() => LogAssert.ignoreFailingMessages = false; + + static T MakeEnemy(string name) where T : EnemyBase + { + var go = new GameObject(name); + return go.AddComponent(); + } + + [Test] + public void NonBoss_AccessingBossFacet_Throws() + { + var enemy = MakeEnemy("plain-enemy"); + var ctx = new EnemyBrainContext(enemy); + + Assert.Throws(() => { var _ = ctx.Boss; }); + + UnityEngine.Object.DestroyImmediate(enemy.gameObject); + } + + [Test] + public void Boss_AccessingBossFacet_ReturnsTheBoss() + { + var boss = MakeEnemy("boss"); + var ctx = new EnemyBrainContext(boss); + + 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); + } + } +} diff --git a/Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs.meta b/Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs.meta new file mode 100644 index 00000000..e99766cb --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 90d0535e362d3b9469d2c91a70c4c8ca +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/AI/IAiContext.cs b/Assets/_Game/Scripts/AI/IAiContext.cs index 308c544e..6e52a91e 100644 --- a/Assets/_Game/Scripts/AI/IAiContext.cs +++ b/Assets/_Game/Scripts/AI/IAiContext.cs @@ -11,5 +11,11 @@ namespace BaseGames.AI ICombatant Combat { get; } IActorVitals Vitals { get; } Blackboard Blackboard { get; } + + /// + /// Boss 专属决策面。**非 Boss 敌人访问本属性会抛 InvalidOperationException**—— + /// 小怪图误用 Boss 边必须立刻暴露,不返回空对象把问题盖住。 + /// + IBossControl Boss { get; } } } diff --git a/Assets/_Game/Scripts/AI/IBossControl.cs b/Assets/_Game/Scripts/AI/IBossControl.cs new file mode 100644 index 00000000..0f80a8f7 --- /dev/null +++ b/Assets/_Game/Scripts/AI/IBossControl.cs @@ -0,0 +1,25 @@ +namespace BaseGames.AI +{ + /// + /// Boss 专属决策面。挂在 上供 Boss 图读取阶段状态、发起阶段过渡、 + /// 查询竞技场锚点。只暴露"决策需要知道的",过渡演出与无敌帧由实现方(BossBase)负责。 + /// 非 Boss 敌人访问 会抛异常——这是有意的显式失败。 + /// + public interface IBossControl + { + /// 当前阶段索引(出生为 0)。 + int CurrentPhase { get; } + + /// 是否处于阶段过渡(无敌 + 过渡演出)期间。 + bool IsPhaseTransitioning { get; } + + /// Boss 资源是否已满(无资源组件时恒为 false)。 + bool ResourceFull { get; } + + /// 发起阶段过渡:无敌 invincibleDuration 秒后切入 targetPhase。 + void BeginPhaseTransition(int targetPhase, float invincibleDuration); + + // 竞技场锚点两成员(AnchorAt / DistanceToAnchor)在 Task 10 与 + // BossArenaAnchors 组件一并加入——此刻加会让 Task 2–8 期间 BossBase 编译不过。 + } +} diff --git a/Assets/_Game/Scripts/AI/IBossControl.cs.meta b/Assets/_Game/Scripts/AI/IBossControl.cs.meta new file mode 100644 index 00000000..157c24bb --- /dev/null +++ b/Assets/_Game/Scripts/AI/IBossControl.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 20ae59ebfd68f04488a369b1c1911040 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs b/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs index 30dc8e08..819218e4 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs @@ -1,3 +1,4 @@ +using System; using UnityEngine; using BaseGames.AI; @@ -10,11 +11,16 @@ namespace BaseGames.Enemies public sealed class EnemyBrainContext : IAiContext, ISensor, ICombatant, IActorVitals, IEnemyActor { readonly EnemyBase _enemy; + readonly BossBase _boss; readonly Blackboard _blackboard = new Blackboard(); Vector2 _lastKnown; float _lostTimer; - public EnemyBrainContext(EnemyBase enemy) { _enemy = enemy; } + public EnemyBrainContext(EnemyBase enemy) + { + _enemy = enemy; + _boss = enemy as BossBase; // 非 Boss 为 null,Boss facet 访问时显式抛 + } // ---- IAiContext ---- public ISensor Sensor => this; @@ -23,6 +29,14 @@ namespace BaseGames.Enemies public IActorVitals Vitals => this; public Blackboard Blackboard => _blackboard; + /// + /// Boss 专属决策面。非 Boss 敌人访问即抛——小怪图误挂了 Boss 边必须立刻暴露, + /// 不返回空对象让错误静默通过(CLAUDE.md 第 6 条)。 + /// + public IBossControl Boss => _boss ?? throw new InvalidOperationException( + $"[EnemyBrainContext] '{_enemy.name}' 不是 Boss(未挂 BossBase 或其子类)," + + "但它的 AI 图访问了 IAiContext.Boss。请检查该敌人是否误用了 Boss 专属的图或图片段。"); + /// 每帧由 EnemyAiBrain 在 Tick 之前调用,维护最后已知位置与丢失计时(供需要的敌人用)。 public void Refresh(float dt) { diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs b/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs index afbcfc5d..1e6ea68f 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs +++ b/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs @@ -12,7 +12,7 @@ namespace BaseGames.Enemies /// Boss 敌人基类。扩展 以支持多阶段切换、技能执行与战斗结束广播。 /// 具体 Boss 继承此类并重写 。 /// - public class BossBase : EnemyBase + public class BossBase : EnemyBase, BaseGames.AI.IBossControl { [Header("Boss 配置")] [SerializeField] private string _bossId; @@ -31,12 +31,15 @@ namespace BaseGames.Enemies public string BossId => _bossId; - /// 当前是否有 Boss 技能正在执行(BD_UseBossSkill 轮询此值)。 + /// 当前是否有 Boss 技能正在执行。 public bool IsBossSkillExecuting => _skillExecutor != null && _skillExecutor.IsExecuting; protected int _currentPhase = 0; - /// 当前 Boss 阶段索引(BD Task 可直接查询)。 + /// 当前 Boss 阶段索引。IBossControl 实现。 public int CurrentPhase => _currentPhase; + + /// Boss 资源是否已满(未挂资源组件时恒为 false)。IBossControl 实现。 + public bool ResourceFull => _bossResource != null && _bossResource.IsFull; private Coroutine _counterStaggerCoroutine; // 缓存加权候选与其有效权重(两者等长、下标对应),避免 UseBossSkillWeighted() 每次 new List → GC 分配 diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossResource.cs b/Assets/_Game/Scripts/Enemies/Boss/BossResource.cs index 681ac2bd..73b516bb 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/BossResource.cs +++ b/Assets/_Game/Scripts/Enemies/Boss/BossResource.cs @@ -26,6 +26,10 @@ namespace BaseGames.Boss public float NormalizedValue => _config != null && _config.maxValue > 0f ? _currentValue / _config.maxValue : 0f; + /// 资源是否已达上限(供 AI 图条件边判定"可放大招")。 + public bool IsFull => _config != null && _config.maxValue > 0f + && _currentValue >= _config.maxValue; + private void Awake() { if (_boss == null) _boss = GetComponentInParent();