feat(enemy): BossPhaseAbilityGate——阶段即换招池
按阶段启停能力组件,被禁用的招经 CanUse 自动退出选招候选, 阶段门不需要碰选招器。取代 BossSkillSO.availablePhaseIndices。 EnterPhase 先换池后广播;OnSpawn 回阶段 0(对象池复用)。
This commit is contained in:
@@ -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<BossPhaseAbilityGate>();
|
||||
var p0 = _host.AddComponent<StubAbility>();
|
||||
// EnemyAbilityBase 是 [DisallowMultipleComponent],第二个能力必须另起子节点
|
||||
// (挂在 _host 下,TearDown 会连带销毁)
|
||||
var p1Go = new GameObject("p1"); p1Go.transform.SetParent(_host.transform);
|
||||
var p1 = p1Go.AddComponent<StubAbility>();
|
||||
|
||||
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<BossPhaseAbilityGate>();
|
||||
var any = _host.AddComponent<StubAbility>();
|
||||
|
||||
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<BossPhaseAbilityGate>();
|
||||
var listed = _host.AddComponent<StubAbility>();
|
||||
var otherGo = new GameObject("unlisted"); otherGo.transform.SetParent(_host.transform);
|
||||
var unlisted = otherGo.AddComponent<StubAbility>();
|
||||
|
||||
SetEntries(gate, (listed, new[] { 1 }));
|
||||
|
||||
gate.ApplyPhase(0);
|
||||
Assert.IsFalse(listed.enabled, "登记的能力应被阶段门管控");
|
||||
Assert.IsTrue (unlisted.enabled, "未登记的能力不受阶段门影响");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace BaseGames.Enemies.Abilities
|
||||
/// </summary>
|
||||
public interface IAttackCandidate
|
||||
{
|
||||
bool CanUse { get; } // 未冷却 + 未运行 + 存活
|
||||
bool CanUse { get; } // 组件启用 + 未冷却 + 未运行 + 存活
|
||||
bool RequiresLineOfSight { get; } // 是否需要视线(由敌人级 LOS 提供)
|
||||
bool RequiresGrounded { get; } // 是否需要着地
|
||||
float Weight { get; } // WeightedRandom 权重
|
||||
|
||||
@@ -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<BossSkillExecutor>(true);
|
||||
if (_bossResource == null) _bossResource = GetComponentInChildren<BossResource>(true);
|
||||
if (_phaseGate == null) _phaseGate = GetComponentInChildren<BossPhaseAbilityGate>(true);
|
||||
}
|
||||
|
||||
protected override void OnEnable()
|
||||
@@ -78,7 +83,7 @@ namespace BaseGames.Enemies
|
||||
/// </summary>
|
||||
public string LastUsedSkillId { get; private set; }
|
||||
|
||||
// ── 技能执行(BD Task 调用入口)─────────────────────────────────────
|
||||
// ── 技能执行(决策层调用入口)───────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 通过技能 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
|
||||
|
||||
/// <summary>
|
||||
/// 启动阶段过渡演出:无敌帧 + 可选定格时间,结束后自动调用 <see cref="EnterPhase"/>。
|
||||
/// BD_BossPhaseTransition 检查 <see cref="IsPhaseTransitioning"/> 来等待过渡完成。
|
||||
/// 决策层检查 <see cref="IsPhaseTransitioning"/> 来等待过渡完成。
|
||||
/// </summary>
|
||||
/// <param name="targetPhase">过渡目标阶段索引。</param>
|
||||
/// <param name="invincibleDuration">无敌帧持续时间(秒)。</param>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
|
||||
namespace BaseGames.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// 阶段 = 换招池:按当前阶段启用 / 禁用能力组件。
|
||||
/// 被禁用的能力其 <see cref="EnemyAbilityBase.CanUse"/> 为 false,
|
||||
/// 因此自动从 <see cref="EnemyAttackSelector"/> 的候选里消失——阶段门不需要碰选招器。
|
||||
///
|
||||
/// 由 <see cref="BossBase.EnterPhase"/> 与 <see cref="BossBase.OnSpawn"/> 直接调用
|
||||
/// (而非订阅阶段频道),保证与阶段切换严格同序。
|
||||
/// </summary>
|
||||
public sealed class BossPhaseAbilityGate : MonoBehaviour
|
||||
{
|
||||
[Serializable]
|
||||
public struct PhaseEntry
|
||||
{
|
||||
[Tooltip("受阶段管控的能力组件")]
|
||||
public EnemyAbilityBase ability;
|
||||
[Tooltip("该能力可用的阶段索引;空数组 = 全阶段可用")]
|
||||
public int[] phases;
|
||||
}
|
||||
|
||||
[Tooltip("阶段可用性表。未登记的能力不受本组件影响(保持其自身启用状态)")]
|
||||
[SerializeField] private PhaseEntry[] _entries;
|
||||
|
||||
/// <summary>把指定阶段的启用集应用到所有登记的能力。</summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a9f265c84eb5db34b8fd06472949ee83
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user