From 80fdc3676258a55953770bb41f4d8100fcd205d6 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Wed, 29 Jul 2026 12:01:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20BrainBuilder.DeclareState=E2=80=94?= =?UTF-8?q?=E2=80=94=E9=87=8D=E5=A4=8D=E5=A3=B0=E6=98=8E=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=8D=B3=E6=8A=A5=E9=94=99=EF=BC=8C=E9=98=B2=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E9=97=B4=E5=90=8C=E5=90=8D=E9=9D=99=E9=BB=98=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State() 仍保持宽松(有则取),用于给已声明的态挂边;DeclareState() 只用于 首次声明行为,重复声明同名状态会抛异常。AiStateFragments 的四个建态原语 (Locomotion/Ability/AbilityOnce/Terminal)改用 DeclareState。同时给 IEngagementModule/IDeathModule/IUnawareModule 补充不变量说明。 --- Assets/Tests/EditMode/AI/BrainBuilderTests.cs | 29 +++++++++++++++++++ Assets/_Game/Scripts/AI/BrainBuilder.cs | 14 +++++++++ .../AIBrain/Modules/AiStateFragments.cs | 8 ++--- .../Enemies/AIBrain/Modules/IDeathModule.cs | 3 ++ .../AIBrain/Modules/IEngagementModule.cs | 4 +++ .../Enemies/AIBrain/Modules/IUnawareModule.cs | 3 +- 6 files changed, 56 insertions(+), 5 deletions(-) diff --git a/Assets/Tests/EditMode/AI/BrainBuilderTests.cs b/Assets/Tests/EditMode/AI/BrainBuilderTests.cs index e3265891..899846aa 100644 --- a/Assets/Tests/EditMode/AI/BrainBuilderTests.cs +++ b/Assets/Tests/EditMode/AI/BrainBuilderTests.cs @@ -85,5 +85,34 @@ namespace BaseGames.Tests.EditMode.AI b.State("Real"); Assert.DoesNotThrow(() => b.RequireState("Real")); } + + [Test] + public void DeclareState_Throws_OnDuplicateName() + { + var b = new BrainBuilder(); + b.DeclareState("Dup"); + var ex = Assert.Throws(() => 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")); + } } } diff --git a/Assets/_Game/Scripts/AI/BrainBuilder.cs b/Assets/_Game/Scripts/AI/BrainBuilder.cs index fe0a7aea..860d6e21 100644 --- a/Assets/_Game/Scripts/AI/BrainBuilder.cs +++ b/Assets/_Game/Scripts/AI/BrainBuilder.cs @@ -39,6 +39,20 @@ namespace BaseGames.AI $"BrainBuilder: 状态 '{name}' 尚未声明。请先声明它的行为(AiStateFragments.* 或 State(name)),再挂转换。"); } + /// + /// 首次声明一个状态。名字已存在即抛——防止两个独立模块取了同名状态时, + /// 后者的 OnEnter/Tick/OnExit 静默覆盖前者(敌人跑错行为且零报错)。 + /// 只有"首次声明行为"的调用点用它;给已声明的态挂边仍用 State()。 + /// + 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); public AiGraph Build() diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs index 92c33f94..3660df69 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs @@ -15,7 +15,7 @@ namespace BaseGames.Enemies /// 由 EnemyLocomotion 驱动的态:进入 / 每帧声明移动意图,离开时停。 public static BrainBuilder.StateBuilder Locomotion(BrainBuilder b, string state, LocomotionMode mode) - => b.State(state) + => b.DeclareState(state) .OnEnter(x => ApplyLocomotion(x, mode)) .Tick (x => ApplyLocomotion(x, mode)) .OnExit(x => x.Locomotion.Stop()); @@ -27,7 +27,7 @@ namespace BaseGames.Enemies public static BrainBuilder.StateBuilder Ability(BrainBuilder b, string state, string abilityId) { RequireAbilityId(state, abilityId); - return b.State(state) + return b.DeclareState(state) .OnEnter(x => EnsureAbility(x, abilityId)) .Tick (x => EnsureAbility(x, abilityId)) .OnExit(x => x.Combat.InterruptAbilities()); @@ -40,7 +40,7 @@ namespace BaseGames.Enemies public static BrainBuilder.StateBuilder AbilityOnce(BrainBuilder b, string state, string abilityId) { RequireAbilityId(state, abilityId); - return b.State(state).OnEnter(x => EnsureAbility(x, abilityId)); + return b.DeclareState(state).OnEnter(x => EnsureAbility(x, abilityId)); } /// @@ -48,7 +48,7 @@ namespace BaseGames.Enemies /// 用于"死亡演出交给物理状态机(EnemyBase.PerformDeath)"的敌人。 /// public static BrainBuilder.StateBuilder Terminal(BrainBuilder b, string state) - => b.State(state); + => b.DeclareState(state); static void ApplyLocomotion(IAiContext x, LocomotionMode mode) { diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs index 10a758a8..3b6871f9 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs @@ -5,6 +5,9 @@ namespace BaseGames.Enemies /// /// 死亡层:单态终结(演出交给物理状态机)/ 单段死亡能力 / 两段演出(可带生成物)。 /// 骨架只挂一条 Global → EntryState 的 Died 事件边,链条内容全归本模块。 + /// + /// 同 IEngagementModule:骨架只把全局 Died 边指向 EntryState,不在死亡链内部挂边, + /// 故单阶段 Build 足够。 /// public interface IDeathModule { diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/IEngagementModule.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/IEngagementModule.cs index b2139d00..bc677abb 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/IEngagementModule.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/IEngagementModule.cs @@ -12,6 +12,10 @@ namespace BaseGames.Enemies /// /// 实现注意:模块会经 [SerializeReference] 反序列化,构造函数不保证被调用。 /// 不要在构造函数里预计算缓存或做参数校验——缓存用惰性属性,校验放 Build()。 + /// + /// 为何本层只需单阶段 Build(而未发现层要拆 Declare/Link):骨架只把边**指向** + /// EntryState,从不在本模块自己的状态上挂边,所以 Build 内声明的边不会被外部抢占。 + /// 将来若修改骨架、使其向交战态挂边,必须先回来重新审视本契约。 /// public interface IEngagementModule { diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/IUnawareModule.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/IUnawareModule.cs index eda45fe4..5c09184e 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/IUnawareModule.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/IUnawareModule.cs @@ -18,7 +18,8 @@ namespace BaseGames.Enemies /// 挂内部转换(如站立 ⇄ 巡逻的计时边)。没有内部边的模块留空实现。 void Link(BrainBuilder b); - /// 图入口(出生态)。 + /// 图入口(出生态)。注意与交战/死亡层的 EntryState 不同: + /// 本属性会成为整张图唯一的 BrainBuilder.Entry,而 EntryState 只是别处 To() 的目标。 string Entry { get; } /// 脱战 / 警觉丢失后回到的态。必须属于 States。