From eaba54ea766426818fbb497a7862ce7876fdcd34 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 30 Jul 2026 14:32:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20Boss=20=E5=9B=BE=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E7=89=87=E6=AE=B5=20+=20=E7=AB=9E=E6=8A=80=E5=9C=BA=E9=94=9A?= =?UTF-8?q?=E7=82=B9=20+=20AiSignal.Engaged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BossFragments:阶段过渡 / 逼近选招 / 移动到锚点三个静态建态片段。 ApproachAttackEngagement 的建态逻辑提为 internal static Declare(态名参数化), 模块路径与 Boss 图路径共用同一份行为,Boss 可建多组(地面组/空中组)。 锚点坐标经 IBossControl.AnchorAt / DistanceToAnchor 暴露,不走黑板—— EnemyAiBrain._context 是私有的(BossBase 无从写入),且 ResetScratch 会清黑板 带来隐式时序约束。锚点本就是 Boss 专属知识,归 Boss facet 更直。 锚点漏配 / 下标越界显式抛,不回退到自身位置。 Co-Authored-By: Claude Opus 5 (1M context) --- Assets/_Game/Scripts/AI/AiSignal.cs | 9 ++- Assets/_Game/Scripts/AI/IBossControl.cs | 9 ++- .../Enemies/AIBrain/Modules/BossFragments.cs | 64 +++++++++++++++++++ .../AIBrain/Modules/BossFragments.cs.meta | 11 ++++ .../Engagement/ApproachAttackEngagement.cs | 21 ++++-- .../Scripts/Enemies/Boss/BossArenaAnchors.cs | 55 ++++++++++++++++ .../Enemies/Boss/BossArenaAnchors.cs.meta | 11 ++++ Assets/_Game/Scripts/Enemies/Boss/BossBase.cs | 19 ++++++ 8 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs create mode 100644 Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs create mode 100644 Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs.meta diff --git a/Assets/_Game/Scripts/AI/AiSignal.cs b/Assets/_Game/Scripts/AI/AiSignal.cs index c7994bfc..bd19c90e 100644 --- a/Assets/_Game/Scripts/AI/AiSignal.cs +++ b/Assets/_Game/Scripts/AI/AiSignal.cs @@ -1,16 +1,21 @@ namespace BaseGames.AI { /// - /// 决策层的推送信号,用于事件驱动的状态转换(不靠轮询)。 + /// 外部 → 决策层的推送信号,用于事件驱动的状态转换(不靠轮询)。 + /// 事件边不受 IsControllable 让位门阻挡。 /// /// 只保留真实存在发送方与消费方的信号。受击 / 硬直 / 击飞 / 弹反**不在此列**—— /// 它们由敌人物理状态机(EnemyStateType)处理,决策层经 IActorVitals.IsControllable /// 让位门自动挂起。在这里重复定义会诱导写出与物理层打架的第二套受击逻辑。 /// - /// 新增信号的判据:已经有确定的发送方与消费方,否则不要预留。 + /// 新增信号的判据:已经有确定的发送方与消费方,否则不要预留——无发送方的占位枚举只会变成死值。 /// public enum AiSignal { + /// 敌人已死亡:由 EnemyBase 的死亡流程发出,让决策停止。 Died, + + /// 开战:由竞技场开战触发器发出,让 Boss 从静置态进入入场演出。 + Engaged, } } diff --git a/Assets/_Game/Scripts/AI/IBossControl.cs b/Assets/_Game/Scripts/AI/IBossControl.cs index 8eee98d1..4d37dcd8 100644 --- a/Assets/_Game/Scripts/AI/IBossControl.cs +++ b/Assets/_Game/Scripts/AI/IBossControl.cs @@ -1,3 +1,5 @@ +using UnityEngine; + namespace BaseGames.AI { /// @@ -20,7 +22,10 @@ namespace BaseGames.AI /// 过渡进行中重复调用会被忽略并告警——应在 OnEnter 调用一次,不要放在 Tick。 void BeginPhaseTransition(int targetPhase, float invincibleDuration); - // 竞技场锚点两成员(AnchorAt / DistanceToAnchor)随 BossArenaAnchors 组件一并加入, - // 届时 BossBase 同步实现。 + /// 第 index 个竞技场锚点的世界坐标。未挂锚点组件 / 下标越界即抛。 + Vector2 AnchorAt(int index); + + /// Boss 当前位置到第 index 个锚点的距离。未挂锚点组件 / 下标越界即抛。 + float DistanceToAnchor(int index); } } diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs new file mode 100644 index 00000000..ddaf4bb6 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs @@ -0,0 +1,64 @@ +using System; +using BaseGames.AI; + +namespace BaseGames.Enemies +{ + /// + /// Boss 图的共享建态片段。与 同级——是静态建态原语, + /// 不是可插拔模块:Boss 图手写,重复的形状抽成片段即可,不需要 SO 下拉与多态序列化。 + /// + public static class BossFragments + { + /// + /// 阶段过渡态:进入时发起过渡(无敌 + 演出由 BossBase 负责),过渡结束后由调用方挂出边。 + /// 只发起一次——放 OnEnter 而非 Tick,因为 BossBase.BeginPhaseTransition 对重入会告警。 + /// + public static BrainBuilder.StateBuilder PhaseTransition( + BrainBuilder b, string state, int targetPhase, float invincibleDuration) + { + if (targetPhase < 0) + throw new ArgumentOutOfRangeException(nameof(targetPhase), + $"BossFragments.PhaseTransition: 状态 '{state}' 的目标阶段不能为负。"); + if (invincibleDuration <= 0f) + throw new ArgumentOutOfRangeException(nameof(invincibleDuration), + $"BossFragments.PhaseTransition: 状态 '{state}' 的无敌时长必须 > 0——" + + "为 0 意味着过渡演出期间可被打断,这不是阶段过渡的语义。"); + + return b.DeclareState(state) + .OnEnter(x => + { + x.Locomotion.Stop(); + x.Combat.InterruptAbilities(); + x.Boss.BeginPhaseTransition(targetPhase, invincibleDuration); + }); + } + + /// + /// 一组「逼近 ↔ 到射程选招攻击」。转发到 ApproachAttackEngagement 的共享实现, + /// 使模块路径与 Boss 图路径永远是同一份行为。 + /// 返回逼近态的 builder,调用方可继续挂阶段过渡等出边。 + /// + public static BrainBuilder.StateBuilder ApproachAttack( + BrainBuilder b, string approach, string attack, string rest) + => ApproachAttackEngagement.Declare(b, approach, attack, rest); + + /// + /// 移动到第 index 个竞技场锚点(),到位后由调用方挂出边。 + /// 坐标经 IBossControl 取得——声明层的 lambda 不得闭包捕获具体实例。 + /// + public static BrainBuilder.StateBuilder MoveToAnchor(BrainBuilder b, string state, int index) + { + if (index < 0) + throw new ArgumentOutOfRangeException(nameof(index), + $"BossFragments.MoveToAnchor: 状态 '{state}' 的锚点下标不能为负。"); + + return b.DeclareState(state) + .OnEnter(x => x.Locomotion.MoveTo(x.Boss.AnchorAt(index))) + .OnExit (x => x.Locomotion.Stop()); + } + + /// 共享条件:已到达第 index 个锚点(容差内)。 + public static Func AtAnchor(int index, float tolerance = 0.2f) + => x => x.Boss.DistanceToAnchor(index) <= tolerance; + } +} diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs.meta new file mode 100644 index 00000000..6398217e --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0f04cf21bca8c61418a9ce9e88f6ba91 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs index 108ebe62..019ad0d4 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs @@ -22,8 +22,17 @@ namespace BaseGames.Enemies public string EngageLabel => "InChaseZone"; public void Build(BrainBuilder b, string rest) + => Declare(b, Approach, Attack, rest); + + /// + /// 建一组「逼近 ↔ 到射程选招攻击」。态名参数化,供本模块与 Boss 图共用同一份实现 + /// (Boss 可能有多个阶段各一组,如地面组与空中组)。 + /// 返回逼近态的 builder,调用方可在其上继续挂自己的出边(如阶段过渡)。 + /// + internal static BrainBuilder.StateBuilder Declare( + BrainBuilder b, string approach, string attack, string rest) { - b.DeclareState(Approach) + var approachBuilder = b.DeclareState(approach) .OnEnter(x => x.Locomotion.Pursue(x.Sensor.LastKnown)) .Tick (x => x.Locomotion.Pursue(x.Sensor.LastKnown)) .OnExit(x => x.Locomotion.Stop()) @@ -31,16 +40,18 @@ namespace BaseGames.Enemies // (EnemyAttackSelector.Eligible 的 InAttackRange 门),故"够得着"不会在 // "已脱离全部感知"之后仍成立,两条件不会相争。若将来出现射程大于追击区的招式, // 那属于射程/感知区配置错误,由 AI 配方校验器报出,不要靠调整此处边序绕开。 - .To(Attack).When(x => x.Combat.HasEligibleAttack(), "attackInRange") + .To(attack).When(x => x.Combat.HasEligibleAttack(), "attackInRange") .To(rest) .When(AiStateFragments.LostAllZones, "leftAllZones"); // 攻击一旦起手就打完:刻意不挂 leftAllZones 边。 - // 玩家在前摇中跑出感知区时招式仍完整打完,之后经 Approach 自然脱战—— + // 玩家在前摇中跑出感知区时招式仍完整打完,之后经逼近态自然脱战—— // 只多一帧,且消除了"起手到一半凭空收招"的观感缺陷。 - b.DeclareState(Attack) + b.DeclareState(attack) .OnEnter(x => { x.Locomotion.Stop(); x.Combat.UseBestAttack(); }) .OnExit (x => x.Combat.InterruptAbilities()) - .To(Approach).When(x => !x.Combat.IsAbilityRunning(), "attackDone"); + .To(approach).When(x => !x.Combat.IsAbilityRunning(), "attackDone"); + + return approachBuilder; } // 本模块无自身配置:招式的射程/冷却/权重全归敌人身上的 EnemyAttackSelector, diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs b/Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs new file mode 100644 index 00000000..152d5685 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs @@ -0,0 +1,55 @@ +using UnityEngine; + +namespace BaseGames.Enemies +{ + /// + /// 竞技场锚点集合:Boss 定点走位(跳到悬空平台、传送到房间固定角落)的坐标来源。 + /// 挂在 Boss 上;锚点物体本身放在场景里(随房间布局走,不进 SO)。 + /// + /// AI 图不直接持有本组件——坐标经 取得 + /// (图的 lambda 不得闭包捕获实例)。BossBase 在 Awake 解析本组件并转发。 + /// + public sealed class BossArenaAnchors : MonoBehaviour + { + [Tooltip("锚点物体(顺序即下标,AI 图按下标引用)")] + [SerializeField] private Transform[] _anchors; + + public int Count => _anchors != null ? _anchors.Length : 0; + + /// 取第 index 个锚点坐标。下标越界或漏配即抛——不静默回退到自身位置。 + public Vector2 At(int index) + { + if (_anchors == null || index < 0 || index >= _anchors.Length) + throw new System.IndexOutOfRangeException( + $"[BossArenaAnchors] {name} 请求锚点下标 {index},但只配了 {Count} 个。" + + "请在 Inspector 补齐锚点,或修正 AI 图里的下标。"); + var t = _anchors[index]; + if (t == null) + throw new System.InvalidOperationException( + $"[BossArenaAnchors] {name} 第 {index} 个锚点为空引用。请在 Inspector 指定。"); + return t.position; + } + +#if UNITY_EDITOR + private void OnValidate() + { + if (_anchors == null) return; + for (int i = 0; i < _anchors.Length; i++) + if (_anchors[i] == null) + Debug.LogError($"[BossArenaAnchors] {name} 第 {i} 个锚点未指定。", this); + } + + private void OnDrawGizmosSelected() + { + if (_anchors == null) return; + Gizmos.color = new Color(1f, 0.8f, 0.2f, 0.9f); + for (int i = 0; i < _anchors.Length; i++) + { + if (_anchors[i] == null) continue; + Gizmos.DrawWireSphere(_anchors[i].position, 0.35f); + UnityEditor.Handles.Label(_anchors[i].position, $"anchor {i}"); + } + } +#endif + } +} diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs.meta b/Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs.meta new file mode 100644 index 00000000..ea9dde72 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c2e82425fc5d93141b283d4b30a67a9b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs b/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs index 25526dbc..7157e7e7 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs +++ b/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs @@ -29,6 +29,10 @@ namespace BaseGames.Enemies [Tooltip("按阶段启用 / 禁用能力组件;未挂载则所有能力全阶段可用")] [SerializeField] private BossPhaseAbilityGate _phaseGate; + [Header("竞技场锚点(可选)")] + [Tooltip("定点走位用的锚点集合;AI 图未用到锚点片段时可留空")] + [SerializeField] private BossArenaAnchors _arenaAnchors; + [Header("玩家反制事件(可选)")] [Tooltip("订阅此频道以响应玩家弹反成功事件")] [SerializeField] private ParryInfoEventChannelSO _onParrySuccess; @@ -49,6 +53,20 @@ namespace BaseGames.Enemies : throw new System.InvalidOperationException( $"[BossBase] '{name}' 的 AI 图查询了资源满值,但未挂 BossResource 组件。" + "请挂上该组件,或从 AI 图里移除资源相关的边。"); + + /// 第 index 个竞技场锚点坐标。IBossControl 实现。未挂锚点组件即抛—— + /// AI 图用了锚点片段却没配锚点是配置错误,必须暴露而非回退到自身位置。 + public Vector2 AnchorAt(int index) => RequireAnchors().At(index); + + /// 当前位置到第 index 个锚点的距离。IBossControl 实现。 + public float DistanceToAnchor(int index) + => Vector2.Distance(transform.position, RequireAnchors().At(index)); + + private BossArenaAnchors RequireAnchors() + => _arenaAnchors != null ? _arenaAnchors : throw new System.InvalidOperationException( + $"[BossBase] '{name}' 的 AI 图使用了竞技场锚点,但未挂 BossArenaAnchors 组件。" + + "请挂上该组件并配好锚点,或从 AI 图里移除锚点片段。"); + private Coroutine _counterStaggerCoroutine; // 缓存加权候选与其有效权重(两者等长、下标对应),避免 UseBossSkillWeighted() 每次 new List → GC 分配 @@ -65,6 +83,7 @@ namespace BaseGames.Enemies if (_skillExecutor == null) _skillExecutor = GetComponentInChildren(true); if (_bossResource == null) _bossResource = GetComponentInChildren(true); if (_phaseGate == null) _phaseGate = GetComponentInChildren(true); + if (_arenaAnchors == null) _arenaAnchors = GetComponentInChildren(true); } protected override void OnEnable()