diff --git a/Assets/Tests/EditMode/AI/RushEngagementTests.cs b/Assets/Tests/EditMode/AI/RushEngagementTests.cs new file mode 100644 index 00000000..e4377500 --- /dev/null +++ b/Assets/Tests/EditMode/AI/RushEngagementTests.cs @@ -0,0 +1,83 @@ +using NUnit.Framework; +using BaseGames.AI; +using BaseGames.Enemies; + +namespace BaseGames.Tests.EditMode.AI +{ + public class RushEngagementTests + { + const string Rest = "Rest"; + + // 单独跑交战模块:手搓一个最小 rest 态,直接以交战入口为图入口。 + static AiRuntime RunAlone(RushEngagement m, FakeAiContext ctx) + { + var b = new BrainBuilder(); + AiStateFragments.Locomotion(b, Rest, LocomotionMode.Patrol); + m.Build(b, Rest); + b.Entry(m.EntryState); + return new AiRuntime(b.Build(), ctx); + } + + [Test] + public void OnLostTarget_CanEngage_IsChaseZoneOnly() + { + var m = new RushEngagement("rush", RushExit.OnLostTarget); + var ctx = new FakeAiContext(); + ctx.C.CanUse = false; // 冷却中 + ctx.S.Chase = true; + Assert.IsTrue(m.CanEngage(ctx)); // 不看冷却 + Assert.AreEqual("InChaseZone", m.EngageLabel); + } + + [Test] + public void Committed_CanEngage_AlsoRequiresOffCooldown() + { + var m = new RushEngagement("rush", RushExit.Committed); + var ctx = new FakeAiContext(); + ctx.S.Chase = true; + ctx.C.CanUse = false; + Assert.IsFalse(m.CanEngage(ctx)); // CD 中不进 + ctx.C.CanUse = true; + Assert.IsTrue(m.CanEngage(ctx)); + Assert.AreEqual("InChaseZone+offCD", m.EngageLabel); + } + + [Test] + public void OnLostTarget_ExitsWhenAllZonesLost() + { + var ctx = new FakeAiContext { }; + ctx.S.Chase = true; + var rt = RunAlone(new RushEngagement("rush", RushExit.OnLostTarget), ctx); + Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); + rt.Tick(0.1f); + Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); // 还在追逐区 + ctx.S.Chase = false; ctx.S.Vision = false; + rt.Tick(0.1f); + Assert.AreEqual(Rest, rt.CurrentStateName); + } + + [Test] + public void Committed_NotInterruptedByLosingTarget() + { + var ctx = new FakeAiContext(); + ctx.S.Chase = true; + var rt = RunAlone(new RushEngagement("rush", RushExit.Committed), ctx); + Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); + ctx.S.Chase = false; ctx.S.Vision = false; // 中途丢失感知 + rt.Tick(0.1f); + Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); // 冲锋不被打断 + ctx.C.Running = null; // 冲锋跑完 + rt.Tick(0.1f); + Assert.AreEqual(Rest, rt.CurrentStateName); + } + + [Test] + public void Build_Throws_WhenAbilityMissing() + { + var b = new BrainBuilder(); + AiStateFragments.Locomotion(b, Rest, LocomotionMode.Patrol); + var m = new RushEngagement(); // 未配能力 + Assert.Throws(() => m.Build(b, Rest)); + } + } +} diff --git a/Assets/Tests/EditMode/AI/RushEngagementTests.cs.meta b/Assets/Tests/EditMode/AI/RushEngagementTests.cs.meta new file mode 100644 index 00000000..fbec1058 --- /dev/null +++ b/Assets/Tests/EditMode/AI/RushEngagementTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a4d24a2a0dafa43488bf4da3c32ca72a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement.meta new file mode 100644 index 00000000..8a7fcd18 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8dc9b46f90ca9734e85831dd4fc0299c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushEngagement.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushEngagement.cs new file mode 100644 index 00000000..8e7b389f --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushEngagement.cs @@ -0,0 +1,64 @@ +using System; +using UnityEngine; +using BaseGames.AI; +using BaseGames.Enemies.Abilities; + +namespace BaseGames.Enemies +{ + /// 接触冲锋:一个能力包办追击与伤害。冲锋能力自己管方向、速度、动画与命中。 + [Serializable] + public sealed class RushEngagement : IEngagementModule + { + public const string Rush = "Rush"; + + [Tooltip("冲锋能力")] + [SerializeField] AbilityRef _ability; + [Tooltip("脱战语义:OnLostTarget=追不到就放弃;Committed=起手即锁定,打完才脱战")] + [SerializeField] RushExit _exit = RushExit.OnLostTarget; + + // 惰性缓存:[SerializeReference] 反序列化不保证走构造函数,不能在 ctor 里预计算。 + [NonSerialized] Func _canEngage; + + public RushEngagement() { } + public RushEngagement(AbilityRef ability, RushExit exit = RushExit.OnLostTarget) + { _ability = ability; _exit = exit; } + + public string EntryState => Rush; + + public string EngageLabel => _exit == RushExit.Committed ? "InChaseZone+offCD" : "InChaseZone"; + + public Func CanEngage + { + get + { + if (_canEngage == null) + { + string id = RequireAbilityId(); + _canEngage = _exit == RushExit.Committed + ? x => x.Sensor.InChaseZone() && x.Combat.CanUseAbility(id) + : (Func)(x => x.Sensor.InChaseZone()); + } + return _canEngage; + } + } + + public void Build(BrainBuilder b, string rest) + { + string id = RequireAbilityId(); + var s = AiStateFragments.Ability(b, Rush, id); + if (_exit == RushExit.Committed) + s.To(rest).When(x => !x.Combat.IsAbilityRunning(id), "rushDone→CD"); + else + s.To(rest).When(AiStateFragments.LostAllZones, "leftAllZones"); + } + + // 校验放这里而非构造函数:反序列化路径不走 ctor,漏配必须在建图时暴露。 + string RequireAbilityId() + { + string id = _ability.Id; + if (string.IsNullOrEmpty(id)) + throw new InvalidOperationException("RushEngagement:未配置冲锋能力。"); + return id; + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushEngagement.cs.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushEngagement.cs.meta new file mode 100644 index 00000000..ce1c40a2 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushEngagement.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4df22f40788d1624fbcbe0081aea92e1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushExit.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushExit.cs new file mode 100644 index 00000000..0d49597b --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushExit.cs @@ -0,0 +1,16 @@ +namespace BaseGames.Enemies +{ + /// 冲锋的脱战语义。一个值同时决定"脱战边怎么写"与"进入条件带不带冷却门"。 + public enum RushExit + { + /// 追不到就放弃:脱离全部感知区即停。 + OnLostTarget, + + /// + /// 起手即锁定:整段冲锋跑完才脱战,中途丢失感知不打断。 + /// 冷却门由模块自动附加——否则打完回未发现态后,下一帧仍在追逐区会立刻再冲, + /// 出现 冲锋态 ↔ 未发现态 每帧抖动。 + /// + Committed, + } +} diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushExit.cs.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushExit.cs.meta new file mode 100644 index 00000000..10a95244 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushExit.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 62cce901ea34dbd4ba6edc77f15e17f5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: