diff --git a/Assets/Tests/EditMode/AI/AiStateFragmentsTests.cs b/Assets/Tests/EditMode/AI/AiStateFragmentsTests.cs new file mode 100644 index 00000000..65a5b6b9 --- /dev/null +++ b/Assets/Tests/EditMode/AI/AiStateFragmentsTests.cs @@ -0,0 +1,68 @@ +using NUnit.Framework; +using BaseGames.AI; +using BaseGames.Enemies; + +namespace BaseGames.Tests.EditMode.AI +{ + public class AiStateFragmentsTests + { + static AiRuntime Run(System.Action build, FakeAiContext ctx) + { + var b = new BrainBuilder(); + b.Entry("S"); + build(b); + return new AiRuntime(b.Build(), ctx); + } + + [Test] + public void Locomotion_SetsModeOnEnter() + { + var ctx = new FakeAiContext(); + Run(b => AiStateFragments.Locomotion(b, "S", LocomotionMode.Patrol), ctx); + Assert.AreEqual(LocomotionMode.Patrol, ctx.L.CurrentMode); + } + + [Test] + public void Locomotion_FaceMode_FacesLastKnown() + { + var ctx = new FakeAiContext(); + ctx.S.Last = new UnityEngine.Vector2(3f, 0f); + Run(b => AiStateFragments.Locomotion(b, "S", LocomotionMode.Face), ctx); + Assert.AreEqual(new UnityEngine.Vector2(3f, 0f), ctx.L.FacedAt); + } + + [Test] + public void Ability_RetriggersOnTick_WhenNotRunning() + { + var ctx = new FakeAiContext(); + var rt = Run(b => AiStateFragments.Ability(b, "S", "atk"), ctx); + Assert.AreEqual(1, ctx.C.Used.Count); + ctx.C.Running = null; // 模拟被受击打断 + rt.Tick(0.1f); + Assert.AreEqual(2, ctx.C.Used.Count); // 重新触发 + } + + [Test] + public void AbilityOnce_DoesNotRetrigger() + { + var ctx = new FakeAiContext(); + var rt = Run(b => AiStateFragments.AbilityOnce(b, "S", "death"), ctx); + Assert.AreEqual(1, ctx.C.Used.Count); + ctx.C.Running = null; // 演出播完 + rt.Tick(0.1f); + rt.Tick(0.1f); + Assert.AreEqual(1, ctx.C.Used.Count); // 不重播 + } + + [Test] + public void LostAllZones_TrueOnlyWhenBothZonesEmpty() + { + var ctx = new FakeAiContext(); + Assert.IsTrue(AiStateFragments.LostAllZones(ctx)); + ctx.S.Chase = true; + Assert.IsFalse(AiStateFragments.LostAllZones(ctx)); + ctx.S.Chase = false; ctx.S.Vision = true; + Assert.IsFalse(AiStateFragments.LostAllZones(ctx)); + } + } +} diff --git a/Assets/Tests/EditMode/AI/AiStateFragmentsTests.cs.meta b/Assets/Tests/EditMode/AI/AiStateFragmentsTests.cs.meta new file mode 100644 index 00000000..1ec104a5 --- /dev/null +++ b/Assets/Tests/EditMode/AI/AiStateFragmentsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7119f55162bc4944d93f017720546cc2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs b/Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs index d736a110..c0fae0a2 100644 --- a/Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs +++ b/Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs @@ -11,9 +11,11 @@ namespace BaseGames.Tests.EditMode.AI public bool IsMoving { get; set; } public void SetMode(LocomotionMode mode) { CurrentMode = mode; Calls.Add("SetMode:" + mode); } public void Approach(Transform t) { CurrentMode = LocomotionMode.Approach; Calls.Add("Approach"); } - public void Pursue(Vector2 p) { CurrentMode = LocomotionMode.Approach; Calls.Add("Pursue"); } + public Vector2 PursuedTo; + public void Pursue(Vector2 p) { PursuedTo = p; CurrentMode = LocomotionMode.Approach; Calls.Add("Pursue"); } public void MoveTo(Vector2 p) { Calls.Add("MoveTo"); } - public void Face(Vector2 p) { CurrentMode = LocomotionMode.Face; Calls.Add("Face"); } + public Vector2 FacedAt; + public void Face(Vector2 p) { FacedAt = p; CurrentMode = LocomotionMode.Face; Calls.Add("Face"); } public void Stop() { CurrentMode = LocomotionMode.Idle; Calls.Add("Stop"); } } } diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Modules.meta new file mode 100644 index 00000000..7d409c67 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6cdc72e828f0c274bba9eef075274c90 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs new file mode 100644 index 00000000..ce83ef01 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs @@ -0,0 +1,59 @@ +using System; +using BaseGames.AI; + +namespace BaseGames.Enemies +{ + /// + /// 建态原语。骨架、各模块、以及定制 AiScript 写自定义态时共用这几个形状, + /// 保证"进入声明意图 / 离开收尾"的契约在全项目一致。 + /// + public static class AiStateFragments + { + /// 共享条件:脱离全部感知(追逐区与视野区都不在)。 + public static readonly Func LostAllZones = + x => !x.Sensor.InChaseZone() && !x.Sensor.InVisionZone(); + + /// 由 EnemyLocomotion 驱动的态:进入 / 每帧声明移动意图,离开时停。 + public static BrainBuilder.StateBuilder Locomotion(BrainBuilder b, string state, LocomotionMode mode) + => b.State(state) + .OnEnter(x => ApplyLocomotion(x, mode)) + .Tick (x => ApplyLocomotion(x, mode)) + .OnExit(x => x.Locomotion.Stop()); + + /// + /// 由能力驱动的持续态:进入 / 每帧确保能力在跑(受击打断后自动重触发),离开中断。 + /// 用于追击、冲锋这类"只要还在这个状态就该一直在做"的能力。 + /// + public static BrainBuilder.StateBuilder Ability(BrainBuilder b, string state, string abilityId) + => b.State(state) + .OnEnter(x => EnsureAbility(x, abilityId)) + .Tick (x => EnsureAbility(x, abilityId)) + .OnExit(x => x.Combat.InterruptAbilities()); + + /// + /// 由能力驱动的一次性态:只在进入时触发,不每帧重触发。 + /// 用于死亡等一次性演出——若用 Ability(),演出播完会被 Tick 无限重播。 + /// + public static BrainBuilder.StateBuilder AbilityOnce(BrainBuilder b, string state, string abilityId) + => b.State(state).OnEnter(x => EnsureAbility(x, abilityId)); + + /// + /// 无行为的终态。不需要在这里停移动——转入本态时,上一个态的 OnExit 已经收尾。 + /// 用于"死亡演出交给物理状态机(EnemyBase.PerformDeath)"的敌人。 + /// + public static BrainBuilder.StateBuilder Terminal(BrainBuilder b, string state) + => b.State(state); + + static void ApplyLocomotion(IAiContext x, LocomotionMode mode) + { + if (mode == LocomotionMode.Face) x.Locomotion.Face(x.Sensor.LastKnown); + else x.Locomotion.SetMode(mode); + } + + static void EnsureAbility(IAiContext x, string abilityId) + { + if (!string.IsNullOrEmpty(abilityId) && !x.Combat.IsAbilityRunning(abilityId)) + x.Combat.UseAbility(abilityId); + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs.meta new file mode 100644 index 00000000..c7dc8166 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 11402d4d9daab614689585f74890b6c1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: