diff --git a/Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs b/Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs index c474feb5..3d7fe35f 100644 --- a/Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs +++ b/Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Reflection; using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; @@ -54,5 +55,71 @@ namespace BaseGames.Tests.EditMode.Enemies ab.enabled = false; Assert.IsFalse(ab.CanUse, "禁用的能力组件不可用——否则选招器会选中一个启不动招的招"); } + + // 阶段门的 _entries 是私有序列化字段——与放置脚手架同样用反射写入。 + // 反射只用来喂入这份配置数据,ApplyPhase 的判定逻辑走的仍是生产代码。 + private static void SetEntries(BossPhaseAbilityGate gate, + params (EnemyAbilityBase ab, int[] phases)[] items) + { + var arr = new BossPhaseAbilityGate.PhaseEntry[items.Length]; + for (int i = 0; i < items.Length; i++) + arr[i] = new BossPhaseAbilityGate.PhaseEntry { ability = items[i].ab, phases = items[i].phases }; + typeof(BossPhaseAbilityGate) + .GetField("_entries", BindingFlags.NonPublic | BindingFlags.Instance) + .SetValue(gate, arr); + } + + [Test] + public void ApplyPhase_EnablesOnlyAbilitiesAllowedInThatPhase() + { + _host = new GameObject("boss"); + var gate = _host.AddComponent(); + var p0 = _host.AddComponent(); + // EnemyAbilityBase 是 [DisallowMultipleComponent],第二个能力必须另起子节点 + // (挂在 _host 下,TearDown 会连带销毁) + var p1Go = new GameObject("p1"); p1Go.transform.SetParent(_host.transform); + var p1 = p1Go.AddComponent(); + + SetEntries(gate, (p0, new[] { 0 }), (p1, new[] { 1 })); + + gate.ApplyPhase(0); + Assert.IsTrue (p0.enabled, "阶段 0 的招应在阶段 0 启用"); + Assert.IsFalse(p1.enabled, "阶段 1 的招不应在阶段 0 启用"); + + gate.ApplyPhase(1); + Assert.IsFalse(p0.enabled, "换阶段后旧池的招应被禁用"); + Assert.IsTrue (p1.enabled, "换阶段后新池的招应被启用"); + } + + [Test] + public void ApplyPhase_EmptyPhaseArrayMeansAllPhases() + { + _host = new GameObject("boss"); + var gate = _host.AddComponent(); + var any = _host.AddComponent(); + + SetEntries(gate, (any, new int[0])); + + gate.ApplyPhase(0); + Assert.IsTrue(any.enabled, "空阶段数组 = 全阶段可用"); + gate.ApplyPhase(7); + Assert.IsTrue(any.enabled, "空阶段数组在任意阶段都应保持启用"); + } + + [Test] + public void ApplyPhase_UnlistedAbilityIsUntouched() + { + _host = new GameObject("boss"); + var gate = _host.AddComponent(); + var listed = _host.AddComponent(); + var otherGo = new GameObject("unlisted"); otherGo.transform.SetParent(_host.transform); + var unlisted = otherGo.AddComponent(); + + SetEntries(gate, (listed, new[] { 1 })); + + gate.ApplyPhase(0); + Assert.IsFalse(listed.enabled, "登记的能力应被阶段门管控"); + Assert.IsTrue (unlisted.enabled, "未登记的能力不受阶段门影响"); + } } } diff --git a/Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs b/Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs index 2cb61610..f76783bc 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs @@ -6,7 +6,7 @@ namespace BaseGames.Enemies.Abilities /// public interface IAttackCandidate { - bool CanUse { get; } // 未冷却 + 未运行 + 存活 + bool CanUse { get; } // 组件启用 + 未冷却 + 未运行 + 存活 bool RequiresLineOfSight { get; } // 是否需要视线(由敌人级 LOS 提供) bool RequiresGrounded { get; } // 是否需要着地 float Weight { get; } // WeightedRandom 权重 diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs b/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs index b2608cef..25526dbc 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs +++ b/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs @@ -25,6 +25,10 @@ namespace BaseGames.Enemies [Header("资源组件(可选)")] [SerializeField] private BossResource _bossResource; + [Header("阶段招池门(可选)")] + [Tooltip("按阶段启用 / 禁用能力组件;未挂载则所有能力全阶段可用")] + [SerializeField] private BossPhaseAbilityGate _phaseGate; + [Header("玩家反制事件(可选)")] [Tooltip("订阅此频道以响应玩家弹反成功事件")] [SerializeField] private ParryInfoEventChannelSO _onParrySuccess; @@ -60,6 +64,7 @@ namespace BaseGames.Enemies // includeInactive:true 确保禁用状态的子组件也能被发现(如分阶段按需启用的执行器) if (_skillExecutor == null) _skillExecutor = GetComponentInChildren(true); if (_bossResource == null) _bossResource = GetComponentInChildren(true); + if (_phaseGate == null) _phaseGate = GetComponentInChildren(true); } protected override void OnEnable() @@ -78,7 +83,7 @@ namespace BaseGames.Enemies /// public string LastUsedSkillId { get; private set; } - // ── 技能执行(BD Task 调用入口)───────────────────────────────────── + // ── 技能执行(决策层调用入口)─────────────────────────────────────── /// /// 通过技能 ID 执行 Boss 技能。 @@ -186,6 +191,8 @@ namespace BaseGames.Enemies LastUsedSkillId = null; // 新阶段重置权重惩罚,防止跨阶段漂移 // 新阶段换招池,上一阶段的防重复记忆不应跨阶段影响选招 AttackSelector?.ResetRepeatMemory(); + // 阶段 = 换招池:先换池,再广播阶段事件,保证订阅方看到的是新池 + _phaseGate?.ApplyPhase(phase); _onBossPhaseChanged?.Raise(new BossPhaseEvent { BossId = _bossId, @@ -195,7 +202,7 @@ namespace BaseGames.Enemies /// /// 启动阶段过渡演出:无敌帧 + 可选定格时间,结束后自动调用 。 - /// BD_BossPhaseTransition 检查 来等待过渡完成。 + /// 决策层检查 来等待过渡完成。 /// /// 过渡目标阶段索引。 /// 无敌帧持续时间(秒)。 @@ -205,7 +212,7 @@ namespace BaseGames.Enemies { Debug.LogWarning( $"[BossBase] '{_bossId}' 已在阶段过渡中(当前阶段 {_currentPhase})," + - $"忽略跳转至阶段 {targetPhase} 的请求。请检查行为树逻辑是否重复触发阶段切换。", + $"忽略跳转至阶段 {targetPhase} 的请求。请检查决策层逻辑是否重复触发阶段切换。", this); return; } @@ -363,6 +370,7 @@ namespace BaseGames.Enemies base.OnSpawn(); LastUsedSkillId = null; _currentPhase = 0; + _phaseGate?.ApplyPhase(0); // 对象池复用:回到阶段 0 的启用集 _skillExecutor?.ResetAllCooldowns(); } } diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs b/Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs new file mode 100644 index 00000000..4adc303f --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs @@ -0,0 +1,61 @@ +using System; +using UnityEngine; +using BaseGames.Enemies.Abilities; + +namespace BaseGames.Enemies +{ + /// + /// 阶段 = 换招池:按当前阶段启用 / 禁用能力组件。 + /// 被禁用的能力其 为 false, + /// 因此自动从 的候选里消失——阶段门不需要碰选招器。 + /// + /// 由 直接调用 + /// (而非订阅阶段频道),保证与阶段切换严格同序。 + /// + public sealed class BossPhaseAbilityGate : MonoBehaviour + { + [Serializable] + public struct PhaseEntry + { + [Tooltip("受阶段管控的能力组件")] + public EnemyAbilityBase ability; + [Tooltip("该能力可用的阶段索引;空数组 = 全阶段可用")] + public int[] phases; + } + + [Tooltip("阶段可用性表。未登记的能力不受本组件影响(保持其自身启用状态)")] + [SerializeField] private PhaseEntry[] _entries; + + /// 把指定阶段的启用集应用到所有登记的能力。 + public void ApplyPhase(int phase) + { + if (_entries == null) return; + for (int i = 0; i < _entries.Length; i++) + { + var e = _entries[i]; + if (e.ability == null) continue; + e.ability.enabled = IsAllowed(e.phases, phase); + } + } + + private static bool IsAllowed(int[] phases, int phase) + { + if (phases == null || phases.Length == 0) return true; // 空 = 全阶段 + for (int i = 0; i < phases.Length; i++) + if (phases[i] == phase) return true; + return false; + } + +#if UNITY_EDITOR + // 漏配显式报错,不静默跳过(CLAUDE.md 第 6 条) + private void OnValidate() + { + if (_entries == null) return; + for (int i = 0; i < _entries.Length; i++) + if (_entries[i].ability == null) + Debug.LogError( + $"[BossPhaseAbilityGate] {name} 第 {i} 项未指定能力组件,该行不会生效。", this); + } +#endif + } +} diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs.meta b/Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs.meta new file mode 100644 index 00000000..225b5dc1 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a9f265c84eb5db34b8fd06472949ee83 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: