feat(ai): 新增 IBossControl facet——Boss 阶段状态经 IAiContext 供图读取
非 Boss 敌人访问 IAiContext.Boss 显式抛 InvalidOperationException, 不返回空对象掩盖小怪图误挂 Boss 边的错误。BossResource 补 IsFull 供图条件边判定。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,10 +51,12 @@ namespace BaseGames.Tests.EditMode.AI
|
|||||||
public FakeCombat C = new FakeCombat();
|
public FakeCombat C = new FakeCombat();
|
||||||
public FakeVitals V = new FakeVitals();
|
public FakeVitals V = new FakeVitals();
|
||||||
public Blackboard BB = new Blackboard();
|
public Blackboard BB = new Blackboard();
|
||||||
|
public FakeBossControl B = new FakeBossControl();
|
||||||
public ISensor Sensor => S;
|
public ISensor Sensor => S;
|
||||||
public IEnemyLocomotion Locomotion => L;
|
public IEnemyLocomotion Locomotion => L;
|
||||||
public ICombatant Combat => C;
|
public ICombatant Combat => C;
|
||||||
public IActorVitals Vitals => V;
|
public IActorVitals Vitals => V;
|
||||||
public Blackboard Blackboard => BB;
|
public Blackboard Blackboard => BB;
|
||||||
|
public IBossControl Boss => B;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using BaseGames.AI;
|
||||||
|
|
||||||
|
namespace BaseGames.Tests.EditMode.AI
|
||||||
|
{
|
||||||
|
public sealed class FakeBossControl : IBossControl
|
||||||
|
{
|
||||||
|
public int Phase;
|
||||||
|
public bool Transitioning;
|
||||||
|
public bool Full;
|
||||||
|
|
||||||
|
// 记录最近一次过渡请求,供断言"图确实发起了过渡"
|
||||||
|
public int LastTargetPhase = -1;
|
||||||
|
public float LastDuration = -1f;
|
||||||
|
public int BeginCallCount;
|
||||||
|
|
||||||
|
// 锚点:测试按下标直给坐标;距离由测试直接设定
|
||||||
|
public UnityEngine.Vector2[] Anchors = new UnityEngine.Vector2[0];
|
||||||
|
public float DistanceToAnchorValue;
|
||||||
|
|
||||||
|
public int CurrentPhase => Phase;
|
||||||
|
public bool IsPhaseTransitioning => Transitioning;
|
||||||
|
public bool ResourceFull => Full;
|
||||||
|
|
||||||
|
public void BeginPhaseTransition(int targetPhase, float invincibleDuration)
|
||||||
|
{
|
||||||
|
LastTargetPhase = targetPhase;
|
||||||
|
LastDuration = invincibleDuration;
|
||||||
|
BeginCallCount++;
|
||||||
|
Transitioning = true; // 由测试手工置回 false 模拟过渡结束
|
||||||
|
}
|
||||||
|
|
||||||
|
public UnityEngine.Vector2 AnchorAt(int index) => Anchors[index];
|
||||||
|
public float DistanceToAnchor(int index) => DistanceToAnchorValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: dc93ec3a62b58c24da5b420bdeeed029
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -14,11 +14,13 @@ namespace BaseGames.Tests.EditMode.AI
|
|||||||
public FakeCombat C = new FakeCombat();
|
public FakeCombat C = new FakeCombat();
|
||||||
public FakeVitals V = new FakeVitals();
|
public FakeVitals V = new FakeVitals();
|
||||||
public Blackboard BB = new Blackboard();
|
public Blackboard BB = new Blackboard();
|
||||||
|
public FakeBossControl B = new FakeBossControl();
|
||||||
public ISensor Sensor => S;
|
public ISensor Sensor => S;
|
||||||
public IEnemyLocomotion Locomotion => L;
|
public IEnemyLocomotion Locomotion => L;
|
||||||
public ICombatant Combat => C;
|
public ICombatant Combat => C;
|
||||||
public IActorVitals Vitals => V;
|
public IActorVitals Vitals => V;
|
||||||
public Blackboard Blackboard => BB;
|
public Blackboard Blackboard => BB;
|
||||||
|
public IBossControl Boss => B;
|
||||||
public bool HasAlertState => true;
|
public bool HasAlertState => true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ namespace BaseGames.Tests.EditMode.AI
|
|||||||
public FakeCombat C = new FakeCombat();
|
public FakeCombat C = new FakeCombat();
|
||||||
public FakeVitals V = new FakeVitals();
|
public FakeVitals V = new FakeVitals();
|
||||||
public Blackboard BB = new Blackboard();
|
public Blackboard BB = new Blackboard();
|
||||||
|
public FakeBossControl B = new FakeBossControl();
|
||||||
public bool HasAlert = true;
|
public bool HasAlert = true;
|
||||||
|
|
||||||
public ISensor Sensor => S;
|
public ISensor Sensor => S;
|
||||||
@@ -21,6 +22,7 @@ namespace BaseGames.Tests.EditMode.AI
|
|||||||
public ICombatant Combat => C;
|
public ICombatant Combat => C;
|
||||||
public IActorVitals Vitals => V;
|
public IActorVitals Vitals => V;
|
||||||
public Blackboard Blackboard => BB;
|
public Blackboard Blackboard => BB;
|
||||||
|
public IBossControl Boss => B;
|
||||||
public bool HasAlertState => HasAlert;
|
public bool HasAlertState => HasAlert;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.TestTools;
|
||||||
|
using BaseGames.Enemies;
|
||||||
|
|
||||||
|
namespace BaseGames.Tests.EditMode.Enemies
|
||||||
|
{
|
||||||
|
public class EnemyBrainContextBossFacetTests
|
||||||
|
{
|
||||||
|
// EnemyBase.Awake 会对缺失的碰撞体/引用报错,这些与本测试无关。
|
||||||
|
[SetUp] public void SetUp() => LogAssert.ignoreFailingMessages = true;
|
||||||
|
[TearDown] public void TearDown() => LogAssert.ignoreFailingMessages = false;
|
||||||
|
|
||||||
|
static T MakeEnemy<T>(string name) where T : EnemyBase
|
||||||
|
{
|
||||||
|
var go = new GameObject(name);
|
||||||
|
return go.AddComponent<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void NonBoss_AccessingBossFacet_Throws()
|
||||||
|
{
|
||||||
|
var enemy = MakeEnemy<EnemyBase>("plain-enemy");
|
||||||
|
var ctx = new EnemyBrainContext(enemy);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => { var _ = ctx.Boss; });
|
||||||
|
|
||||||
|
UnityEngine.Object.DestroyImmediate(enemy.gameObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Boss_AccessingBossFacet_ReturnsTheBoss()
|
||||||
|
{
|
||||||
|
var boss = MakeEnemy<BossBase>("boss");
|
||||||
|
var ctx = new EnemyBrainContext(boss);
|
||||||
|
|
||||||
|
Assert.AreSame(boss, ctx.Boss);
|
||||||
|
Assert.AreEqual(0, ctx.Boss.CurrentPhase);
|
||||||
|
Assert.IsFalse(ctx.Boss.IsPhaseTransitioning);
|
||||||
|
Assert.IsFalse(ctx.Boss.ResourceFull); // 未挂 BossResource
|
||||||
|
|
||||||
|
UnityEngine.Object.DestroyImmediate(boss.gameObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 90d0535e362d3b9469d2c91a70c4c8ca
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -11,5 +11,11 @@ namespace BaseGames.AI
|
|||||||
ICombatant Combat { get; }
|
ICombatant Combat { get; }
|
||||||
IActorVitals Vitals { get; }
|
IActorVitals Vitals { get; }
|
||||||
Blackboard Blackboard { get; }
|
Blackboard Blackboard { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Boss 专属决策面。**非 Boss 敌人访问本属性会抛 InvalidOperationException**——
|
||||||
|
/// 小怪图误用 Boss 边必须立刻暴露,不返回空对象把问题盖住。
|
||||||
|
/// </summary>
|
||||||
|
IBossControl Boss { get; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace BaseGames.AI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Boss 专属决策面。挂在 <see cref="IAiContext"/> 上供 Boss 图读取阶段状态、发起阶段过渡、
|
||||||
|
/// 查询竞技场锚点。只暴露"决策需要知道的",过渡演出与无敌帧由实现方(BossBase)负责。
|
||||||
|
/// 非 Boss 敌人访问 <see cref="IAiContext.Boss"/> 会抛异常——这是有意的显式失败。
|
||||||
|
/// </summary>
|
||||||
|
public interface IBossControl
|
||||||
|
{
|
||||||
|
/// <summary>当前阶段索引(出生为 0)。</summary>
|
||||||
|
int CurrentPhase { get; }
|
||||||
|
|
||||||
|
/// <summary>是否处于阶段过渡(无敌 + 过渡演出)期间。</summary>
|
||||||
|
bool IsPhaseTransitioning { get; }
|
||||||
|
|
||||||
|
/// <summary>Boss 资源是否已满(无资源组件时恒为 false)。</summary>
|
||||||
|
bool ResourceFull { get; }
|
||||||
|
|
||||||
|
/// <summary>发起阶段过渡:无敌 invincibleDuration 秒后切入 targetPhase。</summary>
|
||||||
|
void BeginPhaseTransition(int targetPhase, float invincibleDuration);
|
||||||
|
|
||||||
|
// 竞技场锚点两成员(AnchorAt / DistanceToAnchor)在 Task 10 与
|
||||||
|
// BossArenaAnchors 组件一并加入——此刻加会让 Task 2–8 期间 BossBase 编译不过。
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 20ae59ebfd68f04488a369b1c1911040
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using BaseGames.AI;
|
using BaseGames.AI;
|
||||||
|
|
||||||
@@ -10,11 +11,16 @@ namespace BaseGames.Enemies
|
|||||||
public sealed class EnemyBrainContext : IAiContext, ISensor, ICombatant, IActorVitals, IEnemyActor
|
public sealed class EnemyBrainContext : IAiContext, ISensor, ICombatant, IActorVitals, IEnemyActor
|
||||||
{
|
{
|
||||||
readonly EnemyBase _enemy;
|
readonly EnemyBase _enemy;
|
||||||
|
readonly BossBase _boss;
|
||||||
readonly Blackboard _blackboard = new Blackboard();
|
readonly Blackboard _blackboard = new Blackboard();
|
||||||
Vector2 _lastKnown;
|
Vector2 _lastKnown;
|
||||||
float _lostTimer;
|
float _lostTimer;
|
||||||
|
|
||||||
public EnemyBrainContext(EnemyBase enemy) { _enemy = enemy; }
|
public EnemyBrainContext(EnemyBase enemy)
|
||||||
|
{
|
||||||
|
_enemy = enemy;
|
||||||
|
_boss = enemy as BossBase; // 非 Boss 为 null,Boss facet 访问时显式抛
|
||||||
|
}
|
||||||
|
|
||||||
// ---- IAiContext ----
|
// ---- IAiContext ----
|
||||||
public ISensor Sensor => this;
|
public ISensor Sensor => this;
|
||||||
@@ -23,6 +29,14 @@ namespace BaseGames.Enemies
|
|||||||
public IActorVitals Vitals => this;
|
public IActorVitals Vitals => this;
|
||||||
public Blackboard Blackboard => _blackboard;
|
public Blackboard Blackboard => _blackboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Boss 专属决策面。非 Boss 敌人访问即抛——小怪图误挂了 Boss 边必须立刻暴露,
|
||||||
|
/// 不返回空对象让错误静默通过(CLAUDE.md 第 6 条)。
|
||||||
|
/// </summary>
|
||||||
|
public IBossControl Boss => _boss ?? throw new InvalidOperationException(
|
||||||
|
$"[EnemyBrainContext] '{_enemy.name}' 不是 Boss(未挂 BossBase 或其子类)," +
|
||||||
|
"但它的 AI 图访问了 IAiContext.Boss。请检查该敌人是否误用了 Boss 专属的图或图片段。");
|
||||||
|
|
||||||
/// <summary>每帧由 EnemyAiBrain 在 Tick 之前调用,维护最后已知位置与丢失计时(供需要的敌人用)。</summary>
|
/// <summary>每帧由 EnemyAiBrain 在 Tick 之前调用,维护最后已知位置与丢失计时(供需要的敌人用)。</summary>
|
||||||
public void Refresh(float dt)
|
public void Refresh(float dt)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace BaseGames.Enemies
|
|||||||
/// Boss 敌人基类。扩展 <see cref="EnemyBase"/> 以支持多阶段切换、技能执行与战斗结束广播。
|
/// Boss 敌人基类。扩展 <see cref="EnemyBase"/> 以支持多阶段切换、技能执行与战斗结束广播。
|
||||||
/// 具体 Boss 继承此类并重写 <see cref="EnterPhase"/>。
|
/// 具体 Boss 继承此类并重写 <see cref="EnterPhase"/>。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BossBase : EnemyBase
|
public class BossBase : EnemyBase, BaseGames.AI.IBossControl
|
||||||
{
|
{
|
||||||
[Header("Boss 配置")]
|
[Header("Boss 配置")]
|
||||||
[SerializeField] private string _bossId;
|
[SerializeField] private string _bossId;
|
||||||
@@ -31,12 +31,15 @@ namespace BaseGames.Enemies
|
|||||||
|
|
||||||
public string BossId => _bossId;
|
public string BossId => _bossId;
|
||||||
|
|
||||||
/// <summary>当前是否有 Boss 技能正在执行(BD_UseBossSkill 轮询此值)。</summary>
|
/// <summary>当前是否有 Boss 技能正在执行。</summary>
|
||||||
public bool IsBossSkillExecuting => _skillExecutor != null && _skillExecutor.IsExecuting;
|
public bool IsBossSkillExecuting => _skillExecutor != null && _skillExecutor.IsExecuting;
|
||||||
|
|
||||||
protected int _currentPhase = 0;
|
protected int _currentPhase = 0;
|
||||||
/// <summary>当前 Boss 阶段索引(BD Task 可直接查询)。</summary>
|
/// <summary>当前 Boss 阶段索引。IBossControl 实现。</summary>
|
||||||
public int CurrentPhase => _currentPhase;
|
public int CurrentPhase => _currentPhase;
|
||||||
|
|
||||||
|
/// <summary>Boss 资源是否已满(未挂资源组件时恒为 false)。IBossControl 实现。</summary>
|
||||||
|
public bool ResourceFull => _bossResource != null && _bossResource.IsFull;
|
||||||
private Coroutine _counterStaggerCoroutine;
|
private Coroutine _counterStaggerCoroutine;
|
||||||
|
|
||||||
// 缓存加权候选与其有效权重(两者等长、下标对应),避免 UseBossSkillWeighted() 每次 new List → GC 分配
|
// 缓存加权候选与其有效权重(两者等长、下标对应),避免 UseBossSkillWeighted() 每次 new List → GC 分配
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ namespace BaseGames.Boss
|
|||||||
public float NormalizedValue => _config != null && _config.maxValue > 0f
|
public float NormalizedValue => _config != null && _config.maxValue > 0f
|
||||||
? _currentValue / _config.maxValue : 0f;
|
? _currentValue / _config.maxValue : 0f;
|
||||||
|
|
||||||
|
/// <summary>资源是否已达上限(供 AI 图条件边判定"可放大招")。</summary>
|
||||||
|
public bool IsFull => _config != null && _config.maxValue > 0f
|
||||||
|
&& _currentValue >= _config.maxValue;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
if (_boss == null) _boss = GetComponentInParent<BossBase>();
|
if (_boss == null) _boss = GetComponentInParent<BossBase>();
|
||||||
|
|||||||
Reference in New Issue
Block a user