feat(enemy): 死亡层三模块(终态/单段/两段)

This commit is contained in:
2026-07-29 13:46:20 +08:00
parent 3d00c66677
commit 3bc7eb1855
7 changed files with 187 additions and 0 deletions
@@ -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<System.InvalidOperationException>(() => new AbilityDeath().Build(b));
}
}
}
@@ -0,0 +1,30 @@
using System;
using UnityEngine;
using BaseGames.AI;
using BaseGames.Enemies.Abilities;
namespace BaseGames.Enemies
{
/// <summary>单段死亡能力:进入即触发一次,不重播。</summary>
[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);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1a3f1cc9d7026a840a01ab2dc951006b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
using System;
using BaseGames.AI;
namespace BaseGames.Enemies
{
/// <summary>死亡演出交给物理状态机(EnemyBase.PerformDeath):AI 只需要一个无行为终态。</summary>
[Serializable]
public sealed class TerminalDeath : IDeathModule
{
public const string Death = "Death";
public string EntryState => Death;
public void Build(BrainBuilder b) => AiStateFragments.Terminal(b, Death);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: efb16f225a6b3b846b117409a3dfb620
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,38 @@
using System;
using UnityEngine;
using BaseGames.AI;
using BaseGames.Enemies.Abilities;
namespace BaseGames.Enemies
{
/// <summary>两段死亡:前段(挣扎 / 膨胀)播完接后段(爆体,可带生成物)。</summary>
[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);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0ebca92989544c9449248e635c9a6103
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: