feat(enemy): RushEngagement——两个耦合 flag 合并为 RushExit 语义枚举
This commit is contained in:
@@ -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:
|
||||||
Reference in New Issue
Block a user