feat(ai): BrainBuilder.DeclareState——重复声明状态即报错,防模块间同名静默覆盖

State() 仍保持宽松(有则取),用于给已声明的态挂边;DeclareState() 只用于
首次声明行为,重复声明同名状态会抛异常。AiStateFragments 的四个建态原语
(Locomotion/Ability/AbilityOnce/Terminal)改用 DeclareState。同时给
IEngagementModule/IDeathModule/IUnawareModule 补充不变量说明。
This commit is contained in:
2026-07-29 12:01:19 +08:00
parent 5dcbce6019
commit 80fdc36762
6 changed files with 56 additions and 5 deletions
@@ -85,5 +85,34 @@ namespace BaseGames.Tests.EditMode.AI
b.State("Real"); b.State("Real");
Assert.DoesNotThrow(() => b.RequireState("Real")); Assert.DoesNotThrow(() => b.RequireState("Real"));
} }
[Test]
public void DeclareState_Throws_OnDuplicateName()
{
var b = new BrainBuilder();
b.DeclareState("Dup");
var ex = Assert.Throws<System.InvalidOperationException>(() => b.DeclareState("Dup"));
StringAssert.Contains("Dup", ex.Message);
}
[Test]
public void DeclareState_ReturnsUsableBuilder_OnFirstDeclare()
{
var b = new BrainBuilder();
b.Entry("A");
b.DeclareState("A").To("B").When(c => true, "go");
b.DeclareState("B");
Assert.DoesNotThrow(() => b.Build());
}
[Test]
public void State_StaysLenient_ForAttachingToDeclaredState()
{
// State() 仍是"有则取"——骨架要给模块已声明的态挂升级边,靠的就是这个。
var b = new BrainBuilder();
b.Entry("A");
b.DeclareState("A");
Assert.DoesNotThrow(() => b.State("A").To("A").When(c => false, "noop"));
}
} }
} }
+14
View File
@@ -39,6 +39,20 @@ namespace BaseGames.AI
$"BrainBuilder: 状态 '{name}' 尚未声明。请先声明它的行为(AiStateFragments.* 或 State(name)),再挂转换。"); $"BrainBuilder: 状态 '{name}' 尚未声明。请先声明它的行为(AiStateFragments.* 或 State(name)),再挂转换。");
} }
/// <summary>
/// 首次声明一个状态。名字已存在即抛——防止两个独立模块取了同名状态时,
/// 后者的 OnEnter/Tick/OnExit 静默覆盖前者(敌人跑错行为且零报错)。
/// 只有"首次声明行为"的调用点用它;给已声明的态挂边仍用 State()。
/// </summary>
public StateBuilder DeclareState(string name)
{
if (_states.ContainsKey(name))
throw new InvalidOperationException(
$"BrainBuilder: 状态 '{name}' 已被声明过。两个模块取了同名状态会互相覆盖回调——" +
"请给其中一个换个不冲突的名字。");
return State(name);
}
internal void AddGlobal(Transition t) => _globals.Add(t); internal void AddGlobal(Transition t) => _globals.Add(t);
public AiGraph Build() public AiGraph Build()
@@ -15,7 +15,7 @@ namespace BaseGames.Enemies
/// <summary>由 EnemyLocomotion 驱动的态:进入 / 每帧声明移动意图,离开时停。</summary> /// <summary>由 EnemyLocomotion 驱动的态:进入 / 每帧声明移动意图,离开时停。</summary>
public static BrainBuilder.StateBuilder Locomotion(BrainBuilder b, string state, LocomotionMode mode) public static BrainBuilder.StateBuilder Locomotion(BrainBuilder b, string state, LocomotionMode mode)
=> b.State(state) => b.DeclareState(state)
.OnEnter(x => ApplyLocomotion(x, mode)) .OnEnter(x => ApplyLocomotion(x, mode))
.Tick (x => ApplyLocomotion(x, mode)) .Tick (x => ApplyLocomotion(x, mode))
.OnExit(x => x.Locomotion.Stop()); .OnExit(x => x.Locomotion.Stop());
@@ -27,7 +27,7 @@ namespace BaseGames.Enemies
public static BrainBuilder.StateBuilder Ability(BrainBuilder b, string state, string abilityId) public static BrainBuilder.StateBuilder Ability(BrainBuilder b, string state, string abilityId)
{ {
RequireAbilityId(state, abilityId); RequireAbilityId(state, abilityId);
return b.State(state) return b.DeclareState(state)
.OnEnter(x => EnsureAbility(x, abilityId)) .OnEnter(x => EnsureAbility(x, abilityId))
.Tick (x => EnsureAbility(x, abilityId)) .Tick (x => EnsureAbility(x, abilityId))
.OnExit(x => x.Combat.InterruptAbilities()); .OnExit(x => x.Combat.InterruptAbilities());
@@ -40,7 +40,7 @@ namespace BaseGames.Enemies
public static BrainBuilder.StateBuilder AbilityOnce(BrainBuilder b, string state, string abilityId) public static BrainBuilder.StateBuilder AbilityOnce(BrainBuilder b, string state, string abilityId)
{ {
RequireAbilityId(state, abilityId); RequireAbilityId(state, abilityId);
return b.State(state).OnEnter(x => EnsureAbility(x, abilityId)); return b.DeclareState(state).OnEnter(x => EnsureAbility(x, abilityId));
} }
/// <summary> /// <summary>
@@ -48,7 +48,7 @@ namespace BaseGames.Enemies
/// 用于"死亡演出交给物理状态机(EnemyBase.PerformDeath"的敌人。 /// 用于"死亡演出交给物理状态机(EnemyBase.PerformDeath"的敌人。
/// </summary> /// </summary>
public static BrainBuilder.StateBuilder Terminal(BrainBuilder b, string state) public static BrainBuilder.StateBuilder Terminal(BrainBuilder b, string state)
=> b.State(state); => b.DeclareState(state);
static void ApplyLocomotion(IAiContext x, LocomotionMode mode) static void ApplyLocomotion(IAiContext x, LocomotionMode mode)
{ {
@@ -5,6 +5,9 @@ namespace BaseGames.Enemies
/// <summary> /// <summary>
/// 死亡层:单态终结(演出交给物理状态机)/ 单段死亡能力 / 两段演出(可带生成物)。 /// 死亡层:单态终结(演出交给物理状态机)/ 单段死亡能力 / 两段演出(可带生成物)。
/// 骨架只挂一条 Global → EntryState 的 Died 事件边,链条内容全归本模块。 /// 骨架只挂一条 Global → EntryState 的 Died 事件边,链条内容全归本模块。
///
/// 同 IEngagementModule:骨架只把全局 Died 边指向 EntryState,不在死亡链内部挂边,
/// 故单阶段 Build 足够。
/// </summary> /// </summary>
public interface IDeathModule public interface IDeathModule
{ {
@@ -12,6 +12,10 @@ namespace BaseGames.Enemies
/// ///
/// 实现注意:模块会经 [SerializeReference] 反序列化,构造函数不保证被调用。 /// 实现注意:模块会经 [SerializeReference] 反序列化,构造函数不保证被调用。
/// 不要在构造函数里预计算缓存或做参数校验——缓存用惰性属性,校验放 Build()。 /// 不要在构造函数里预计算缓存或做参数校验——缓存用惰性属性,校验放 Build()。
///
/// 为何本层只需单阶段 Build(而未发现层要拆 Declare/Link):骨架只把边**指向**
/// EntryState,从不在本模块自己的状态上挂边,所以 Build 内声明的边不会被外部抢占。
/// 将来若修改骨架、使其向交战态挂边,必须先回来重新审视本契约。
/// </summary> /// </summary>
public interface IEngagementModule public interface IEngagementModule
{ {
@@ -18,7 +18,8 @@ namespace BaseGames.Enemies
/// <summary>挂内部转换(如站立 ⇄ 巡逻的计时边)。没有内部边的模块留空实现。</summary> /// <summary>挂内部转换(如站立 ⇄ 巡逻的计时边)。没有内部边的模块留空实现。</summary>
void Link(BrainBuilder b); void Link(BrainBuilder b);
/// <summary>图入口(出生态)。</summary> /// <summary>图入口(出生态)。注意与交战/死亡层的 EntryState 不同:
/// 本属性会成为整张图唯一的 BrainBuilder.Entry,而 EntryState 只是别处 To() 的目标。</summary>
string Entry { get; } string Entry { get; }
/// <summary>脱战 / 警觉丢失后回到的态。必须属于 States。</summary> /// <summary>脱战 / 警觉丢失后回到的态。必须属于 States。</summary>