feat(enemy): RushEngagement——两个耦合 flag 合并为 RushExit 语义枚举

This commit is contained in:
2026-07-29 12:32:50 +08:00
parent 1c6ce57648
commit bb1e2f7705
7 changed files with 204 additions and 0 deletions
@@ -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<System.InvalidOperationException>(() => m.Build(b, Rest));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a4d24a2a0dafa43488bf4da3c32ca72a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8dc9b46f90ca9734e85831dd4fc0299c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,64 @@
using System;
using UnityEngine;
using BaseGames.AI;
using BaseGames.Enemies.Abilities;
namespace BaseGames.Enemies
{
/// <summary>接触冲锋:一个能力包办追击与伤害。冲锋能力自己管方向、速度、动画与命中。</summary>
[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<IAiContext, bool> _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<IAiContext, bool> CanEngage
{
get
{
if (_canEngage == null)
{
string id = RequireAbilityId();
_canEngage = _exit == RushExit.Committed
? x => x.Sensor.InChaseZone() && x.Combat.CanUseAbility(id)
: (Func<IAiContext, bool>)(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;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4df22f40788d1624fbcbe0081aea92e1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,16 @@
namespace BaseGames.Enemies
{
/// <summary>冲锋的脱战语义。一个值同时决定"脱战边怎么写"与"进入条件带不带冷却门"。</summary>
public enum RushExit
{
/// <summary>追不到就放弃:脱离全部感知区即停。</summary>
OnLostTarget,
/// <summary>
/// 起手即锁定:整段冲锋跑完才脱战,中途丢失感知不打断。
/// 冷却门由模块自动附加——否则打完回未发现态后,下一帧仍在追逐区会立刻再冲,
/// 出现 冲锋态 ↔ 未发现态 每帧抖动。
/// </summary>
Committed,
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 62cce901ea34dbd4ba6edc77f15e17f5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: