diff --git a/Assets/Tests/EditMode/AI/DeathModuleTests.cs b/Assets/Tests/EditMode/AI/DeathModuleTests.cs new file mode 100644 index 00000000..a7d5fe01 --- /dev/null +++ b/Assets/Tests/EditMode/AI/DeathModuleTests.cs @@ -0,0 +1,72 @@ +using NUnit.Framework; +using BaseGames.AI; +using BaseGames.Enemies; + +namespace BaseGames.Tests.EditMode.AI +{ + public class DeathModuleTests + { + // 从一个活着的态出发,发 Died 信号进入死亡链。 + static AiRuntime RunWithDeath(IDeathModule m, FakeAiContext ctx) + { + var b = new BrainBuilder(); + AiStateFragments.Locomotion(b, "Alive", LocomotionMode.Patrol); + m.Build(b); + b.Entry("Alive"); + b.Global().To(m.EntryState).OnEvent(AiSignal.Died); + return new AiRuntime(b.Build(), ctx); + } + + [Test] + public void TerminalDeath_EntersAndStays_NoAbility() + { + var ctx = new FakeAiContext(); + var rt = RunWithDeath(new TerminalDeath(), ctx); + rt.Send(AiSignal.Died); + rt.Tick(0.1f); + Assert.AreEqual(TerminalDeath.Death, rt.CurrentStateName); + Assert.AreEqual(0, ctx.C.Used.Count); // 演出走物理状态机,AI 不触发能力 + rt.Tick(0.1f); + Assert.AreEqual(TerminalDeath.Death, rt.CurrentStateName); + } + + [Test] + public void AbilityDeath_TriggersOnce_DoesNotLoop() + { + var ctx = new FakeAiContext(); + var rt = RunWithDeath(new AbilityDeath("die"), ctx); + rt.Send(AiSignal.Died); + rt.Tick(0.1f); + 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 TwoStageDeath_PreThenFinal() + { + var ctx = new FakeAiContext(); + var rt = RunWithDeath(new TwoStageDeath("die_pre", "die"), ctx); + rt.Send(AiSignal.Died); + rt.Tick(0.1f); + Assert.AreEqual(TwoStageDeath.DeathPre, rt.CurrentStateName); + CollectionAssert.Contains(ctx.C.Used, "die_pre"); + + rt.Tick(0.1f); + Assert.AreEqual(TwoStageDeath.DeathPre, rt.CurrentStateName); // 前段还在跑 + + ctx.C.Running = null; // 前段结束 + rt.Tick(0.1f); + Assert.AreEqual(TwoStageDeath.Death, rt.CurrentStateName); + CollectionAssert.Contains(ctx.C.Used, "die"); + } + + [Test] + public void AbilityDeath_Build_Throws_WhenAbilityMissing() + { + var b = new BrainBuilder(); + Assert.Throws(() => new AbilityDeath().Build(b)); + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/AbilityDeath.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/AbilityDeath.cs new file mode 100644 index 00000000..ab08edae --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/AbilityDeath.cs @@ -0,0 +1,30 @@ +using System; +using UnityEngine; +using BaseGames.AI; +using BaseGames.Enemies.Abilities; + +namespace BaseGames.Enemies +{ + /// 单段死亡能力:进入即触发一次,不重播。 + [Serializable] + public sealed class AbilityDeath : IDeathModule + { + public const string Death = "Death"; + + [Tooltip("死亡演出能力")] + [SerializeField] AbilityRef _ability; + + public AbilityDeath() { } + public AbilityDeath(AbilityRef ability) { _ability = ability; } + + public string EntryState => Death; + + public void Build(BrainBuilder b) + { + string id = _ability.Id; + if (string.IsNullOrEmpty(id)) + throw new InvalidOperationException("AbilityDeath:未配置死亡能力。若死亡演出走物理状态机,请改用 TerminalDeath。"); + AiStateFragments.AbilityOnce(b, Death, id); + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/AbilityDeath.cs.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/AbilityDeath.cs.meta new file mode 100644 index 00000000..432539b7 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/AbilityDeath.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1a3f1cc9d7026a840a01ab2dc951006b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TerminalDeath.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TerminalDeath.cs new file mode 100644 index 00000000..ca4fbf0e --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TerminalDeath.cs @@ -0,0 +1,14 @@ +using System; +using BaseGames.AI; + +namespace BaseGames.Enemies +{ + /// 死亡演出交给物理状态机(EnemyBase.PerformDeath):AI 只需要一个无行为终态。 + [Serializable] + public sealed class TerminalDeath : IDeathModule + { + public const string Death = "Death"; + public string EntryState => Death; + public void Build(BrainBuilder b) => AiStateFragments.Terminal(b, Death); + } +} diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TerminalDeath.cs.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TerminalDeath.cs.meta new file mode 100644 index 00000000..44188b29 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TerminalDeath.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: efb16f225a6b3b846b117409a3dfb620 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TwoStageDeath.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TwoStageDeath.cs new file mode 100644 index 00000000..dc49d6e4 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TwoStageDeath.cs @@ -0,0 +1,38 @@ +using System; +using UnityEngine; +using BaseGames.AI; +using BaseGames.Enemies.Abilities; + +namespace BaseGames.Enemies +{ + /// 两段死亡:前段(挣扎 / 膨胀)播完接后段(爆体,可带生成物)。 + [Serializable] + public sealed class TwoStageDeath : IDeathModule + { + public const string DeathPre = "Death_Pre"; + public const string Death = "Death"; + + [Tooltip("前段演出能力(挣扎 / 膨胀)")] + [SerializeField] AbilityRef _preAbility; + [Tooltip("后段演出能力(爆体 / 生成物)")] + [SerializeField] AbilityRef _ability; + + public TwoStageDeath() { } + public TwoStageDeath(AbilityRef preAbility, AbilityRef ability) + { _preAbility = preAbility; _ability = ability; } + + public string EntryState => DeathPre; + + public void Build(BrainBuilder b) + { + string preId = _preAbility.Id; + string id = _ability.Id; + if (string.IsNullOrEmpty(preId)) throw new InvalidOperationException("TwoStageDeath:未配置前段能力。"); + if (string.IsNullOrEmpty(id)) throw new InvalidOperationException("TwoStageDeath:未配置后段能力。"); + + AiStateFragments.AbilityOnce(b, DeathPre, preId) + .To(Death).When(x => !x.Combat.IsAbilityRunning(preId), "preDone"); + AiStateFragments.AbilityOnce(b, Death, id); + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TwoStageDeath.cs.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TwoStageDeath.cs.meta new file mode 100644 index 00000000..8ccf2a57 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TwoStageDeath.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0ebca92989544c9449248e635c9a6103 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: