feat(ai): Boss 图共享片段 + 竞技场锚点 + AiSignal.Engaged
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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using BaseGames.AI;
|
||||
|
||||
namespace BaseGames.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// Boss 图的共享建态片段。与 <see cref="AiStateFragments"/> 同级——是静态建态原语,
|
||||
/// 不是可插拔模块:Boss 图手写,重复的形状抽成片段即可,不需要 SO 下拉与多态序列化。
|
||||
/// </summary>
|
||||
public static class BossFragments
|
||||
{
|
||||
/// <summary>
|
||||
/// 阶段过渡态:进入时发起过渡(无敌 + 演出由 BossBase 负责),过渡结束后由调用方挂出边。
|
||||
/// 只发起一次——放 OnEnter 而非 Tick,因为 BossBase.BeginPhaseTransition 对重入会告警。
|
||||
/// </summary>
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 一组「逼近 ↔ 到射程选招攻击」。转发到 ApproachAttackEngagement 的共享实现,
|
||||
/// 使模块路径与 Boss 图路径永远是同一份行为。
|
||||
/// 返回逼近态的 builder,调用方可继续挂阶段过渡等出边。
|
||||
/// </summary>
|
||||
public static BrainBuilder.StateBuilder ApproachAttack(
|
||||
BrainBuilder b, string approach, string attack, string rest)
|
||||
=> ApproachAttackEngagement.Declare(b, approach, attack, rest);
|
||||
|
||||
/// <summary>
|
||||
/// 移动到第 index 个竞技场锚点(<see cref="BossArenaAnchors"/>),到位后由调用方挂出边。
|
||||
/// 坐标经 IBossControl 取得——声明层的 lambda 不得闭包捕获具体实例。
|
||||
/// </summary>
|
||||
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());
|
||||
}
|
||||
|
||||
/// <summary>共享条件:已到达第 index 个锚点(容差内)。</summary>
|
||||
public static Func<IAiContext, bool> AtAnchor(int index, float tolerance = 0.2f)
|
||||
=> x => x.Boss.DistanceToAnchor(index) <= tolerance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0f04cf21bca8c61418a9ce9e88f6ba91
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+16
-5
@@ -22,8 +22,17 @@ namespace BaseGames.Enemies
|
||||
public string EngageLabel => "InChaseZone";
|
||||
|
||||
public void Build(BrainBuilder b, string rest)
|
||||
=> Declare(b, Approach, Attack, rest);
|
||||
|
||||
/// <summary>
|
||||
/// 建一组「逼近 ↔ 到射程选招攻击」。态名参数化,供本模块与 Boss 图共用同一份实现
|
||||
/// (Boss 可能有多个阶段各一组,如地面组与空中组)。
|
||||
/// 返回逼近态的 builder,调用方可在其上继续挂自己的出边(如阶段过渡)。
|
||||
/// </summary>
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// 竞技场锚点集合:Boss 定点走位(跳到悬空平台、传送到房间固定角落)的坐标来源。
|
||||
/// 挂在 Boss 上;锚点物体本身放在场景里(随房间布局走,不进 SO)。
|
||||
///
|
||||
/// AI 图不直接持有本组件——坐标经 <see cref="BaseGames.AI.IBossControl.AnchorAt"/> 取得
|
||||
/// (图的 lambda 不得闭包捕获实例)。BossBase 在 Awake 解析本组件并转发。
|
||||
/// </summary>
|
||||
public sealed class BossArenaAnchors : MonoBehaviour
|
||||
{
|
||||
[Tooltip("锚点物体(顺序即下标,AI 图按下标引用)")]
|
||||
[SerializeField] private Transform[] _anchors;
|
||||
|
||||
public int Count => _anchors != null ? _anchors.Length : 0;
|
||||
|
||||
/// <summary>取第 index 个锚点坐标。下标越界或漏配即抛——不静默回退到自身位置。</summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2e82425fc5d93141b283d4b30a67a9b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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 图里移除资源相关的边。");
|
||||
|
||||
/// <summary>第 index 个竞技场锚点坐标。IBossControl 实现。未挂锚点组件即抛——
|
||||
/// AI 图用了锚点片段却没配锚点是配置错误,必须暴露而非回退到自身位置。</summary>
|
||||
public Vector2 AnchorAt(int index) => RequireAnchors().At(index);
|
||||
|
||||
/// <summary>当前位置到第 index 个锚点的距离。IBossControl 实现。</summary>
|
||||
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<BossSkillExecutor>(true);
|
||||
if (_bossResource == null) _bossResource = GetComponentInChildren<BossResource>(true);
|
||||
if (_phaseGate == null) _phaseGate = GetComponentInChildren<BossPhaseAbilityGate>(true);
|
||||
if (_arenaAnchors == null) _arenaAnchors = GetComponentInChildren<BossArenaAnchors>(true);
|
||||
}
|
||||
|
||||
protected override void OnEnable()
|
||||
|
||||
Reference in New Issue
Block a user