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:
2026-07-30 14:32:32 +08:00
co-authored by Claude Opus 5
parent 8c56d207bd
commit eaba54ea76
8 changed files with 190 additions and 9 deletions
@@ -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:
@@ -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