merge: 敌人寻路逼近+攻击选择器(ApproachAttack 交战范式)
寻路逼近(Approach)↔选招攻击(Attack)两态子机,与冲锋(ChaseAbility)并列; 招式自管圆形射程;WeightedRandom/Priority 两选招模式; EditMode 167/167 + PlayMode 冒烟验证通过。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,13 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
public bool NoAbilityRunning => Running == null;
|
||||
public bool CanUseAbility(string id) => CanUse;
|
||||
public void InterruptAbilities() => Running = null;
|
||||
public bool Eligible; // 测试控制:是否有合格攻击
|
||||
public bool HasEligibleAttack() => Eligible;
|
||||
public bool UseBestAttack()
|
||||
{
|
||||
if (!Eligible) return false;
|
||||
Used.Add("best"); Running = "best"; return true;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakeVitals : IActorVitals
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
public bool IsMoving { get; set; }
|
||||
public void SetMode(LocomotionMode mode) { CurrentMode = mode; Calls.Add("SetMode:" + mode); }
|
||||
public void Approach(Transform t) { CurrentMode = LocomotionMode.Approach; Calls.Add("Approach"); }
|
||||
public void Pursue(Vector2 p) { CurrentMode = LocomotionMode.Approach; Calls.Add("Pursue"); }
|
||||
public void MoveTo(Vector2 p) { Calls.Add("MoveTo"); }
|
||||
public void Face(Vector2 p) { CurrentMode = LocomotionMode.Face; Calls.Add("Face"); }
|
||||
public void Stop() { CurrentMode = LocomotionMode.Idle; Calls.Add("Stop"); }
|
||||
|
||||
@@ -275,6 +275,115 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
Assert.AreEqual(LocomotionMode.Patrol, ctx.L.CurrentMode); // 落地即巡逻移动意图
|
||||
}
|
||||
|
||||
// ── ApproachAttack 交战风格 ───────────────────────────────────────
|
||||
static AiGraph GraphApproach(string entry = "Idle")
|
||||
{
|
||||
var b = new BrainBuilder();
|
||||
PerceptionStateMachine.Add(b, new PerceptionStateMachine.Config
|
||||
{
|
||||
Idle = "Idle", Patrol = "Patrol", Alert = "Alert", Death = "Death",
|
||||
Entry = entry, Rest = "Patrol",
|
||||
IdleMode = LocomotionMode.Idle, PatrolMode = LocomotionMode.Patrol, AlertMode = LocomotionMode.Face,
|
||||
Engagement = PerceptionStateMachine.EngagementStyle.ApproachAttack,
|
||||
ApproachState = "Approach", AttackState = "Attack",
|
||||
});
|
||||
return b.Build();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Approach_EnteredFromChaseZone_UsesPursue()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var rt = new AiRuntime(GraphApproach(), ctx);
|
||||
ctx.S.Chase = true;
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual("Approach", rt.CurrentStateName);
|
||||
CollectionAssert.Contains(ctx.L.Calls, "Pursue");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Approach_ToAttack_WhenEligibleAttack()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var rt = new AiRuntime(GraphApproach(), ctx);
|
||||
ctx.S.Chase = true; rt.Tick(0.1f);
|
||||
ctx.C.Eligible = true;
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual("Attack", rt.CurrentStateName);
|
||||
CollectionAssert.Contains(ctx.C.Used, "best");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Attack_StopsLocomotion_OnEnter()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var rt = new AiRuntime(GraphApproach(), ctx);
|
||||
ctx.S.Chase = true; rt.Tick(0.1f);
|
||||
ctx.C.Eligible = true; rt.Tick(0.1f);
|
||||
CollectionAssert.Contains(ctx.L.Calls, "Stop");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Attack_ToApproach_WhenAttackDone()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var rt = new AiRuntime(GraphApproach(), ctx);
|
||||
ctx.S.Chase = true; rt.Tick(0.1f);
|
||||
ctx.C.Eligible = true; rt.Tick(0.1f);
|
||||
Assert.AreEqual("Attack", rt.CurrentStateName);
|
||||
ctx.C.Running = null;
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual("Approach", rt.CurrentStateName);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Approach_ToRest_WhenLeftAllZones()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var rt = new AiRuntime(GraphApproach(), ctx);
|
||||
ctx.S.Chase = true; rt.Tick(0.1f);
|
||||
ctx.S.Chase = false; ctx.S.Vision = false;
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual("Patrol", rt.CurrentStateName);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApproachAttack_Death_IsGlobal()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var rt = new AiRuntime(GraphApproach(), ctx);
|
||||
ctx.S.Chase = true; rt.Tick(0.1f);
|
||||
rt.Send(AiSignal.Died); rt.Tick(0.1f);
|
||||
Assert.AreEqual("Death", rt.CurrentStateName);
|
||||
}
|
||||
|
||||
// 进 Attack 后玩家脱离全部感知区:攻击仍在跑 → 不回 Approach,改判 leftAllZones → Rest(Patrol)。
|
||||
[Test]
|
||||
public void Attack_ToRest_WhenLeftAllZones()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var rt = new AiRuntime(GraphApproach(), ctx);
|
||||
ctx.S.Chase = true; rt.Tick(0.1f); // → Approach
|
||||
ctx.C.Eligible = true; rt.Tick(0.1f); // → Attack, Running="best"
|
||||
Assert.AreEqual("Attack", rt.CurrentStateName);
|
||||
ctx.S.Chase = false; ctx.S.Vision = false; // 攻击仍 running,但脱离全部感知
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual("Patrol", rt.CurrentStateName);
|
||||
}
|
||||
|
||||
// 死亡是全局事件,从 Attack 态也能达 Death。
|
||||
[Test]
|
||||
public void ApproachAttack_Death_FromAttack()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var rt = new AiRuntime(GraphApproach(), ctx);
|
||||
ctx.S.Chase = true; rt.Tick(0.1f); // → Approach
|
||||
ctx.C.Eligible = true; rt.Tick(0.1f); // → Attack
|
||||
Assert.AreEqual("Attack", rt.CurrentStateName);
|
||||
rt.Send(AiSignal.Died); rt.Tick(0.1f);
|
||||
Assert.AreEqual("Death", rt.CurrentStateName);
|
||||
}
|
||||
|
||||
// ── 死亡(全局事件,任意态可达)───────────────────────────────────
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8ce8f4da759211e4b9e06dfe498585a9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.Enemies
|
||||
{
|
||||
public class EnemyAttackSelectorTests
|
||||
{
|
||||
sealed class FakeCandidate : IAttackCandidate
|
||||
{
|
||||
public bool CanUseV = true, InRange = true, ReqLOS = false, ReqGround = false, Executed;
|
||||
public float WeightV = 1f;
|
||||
public int PriorityV = 0;
|
||||
public bool CanUse => CanUseV;
|
||||
public bool RequiresLineOfSight => ReqLOS;
|
||||
public bool RequiresGrounded => ReqGround;
|
||||
public float Weight => WeightV;
|
||||
public int Priority => PriorityV;
|
||||
public bool InAttackRange() => InRange;
|
||||
public bool Execute() { Executed = true; return true; }
|
||||
}
|
||||
|
||||
static EnemyAttackSelector Sel(params IAttackCandidate[] cs)
|
||||
=> new EnemyAttackSelector(new List<IAttackCandidate>(cs));
|
||||
|
||||
[Test]
|
||||
public void Empty_NoEligible()
|
||||
{
|
||||
var s = Sel();
|
||||
Assert.IsFalse(s.HasEligible(true, true));
|
||||
Assert.IsNull(s.Select(true, true, AttackSelectionMode.Priority));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExcludesOnCooldown()
|
||||
{
|
||||
var c = new FakeCandidate { CanUseV = false };
|
||||
Assert.IsFalse(Sel(c).HasEligible(true, true));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExcludesOutOfRange()
|
||||
{
|
||||
var c = new FakeCandidate { InRange = false };
|
||||
Assert.IsFalse(Sel(c).HasEligible(true, true));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExcludesWhenNeedsLOS_ButNoLOS()
|
||||
{
|
||||
var c = new FakeCandidate { ReqLOS = true };
|
||||
Assert.IsFalse(Sel(c).HasEligible(false, true));
|
||||
Assert.IsTrue (Sel(c).HasEligible(true, true));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExcludesWhenNeedsGrounded_ButAirborne()
|
||||
{
|
||||
var c = new FakeCandidate { ReqGround = true };
|
||||
Assert.IsFalse(Sel(c).HasEligible(true, false));
|
||||
Assert.IsTrue (Sel(c).HasEligible(true, true));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Priority_PicksHighest()
|
||||
{
|
||||
var lo = new FakeCandidate { PriorityV = 1 };
|
||||
var hi = new FakeCandidate { PriorityV = 5 };
|
||||
var pick = Sel(lo, hi).Select(true, true, AttackSelectionMode.Priority);
|
||||
Assert.AreSame(hi, pick);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Priority_TieTakesFirstInList()
|
||||
{
|
||||
var a = new FakeCandidate { PriorityV = 3 };
|
||||
var b = new FakeCandidate { PriorityV = 3 };
|
||||
var pick = Sel(a, b).Select(true, true, AttackSelectionMode.Priority);
|
||||
Assert.AreSame(a, pick);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Priority_SkipsIneligible()
|
||||
{
|
||||
var blocked = new FakeCandidate { PriorityV = 9, InRange = false };
|
||||
var ok = new FakeCandidate { PriorityV = 1 };
|
||||
var pick = Sel(blocked, ok).Select(true, true, AttackSelectionMode.Priority);
|
||||
Assert.AreSame(ok, pick);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Weighted_SingleEligible_AlwaysThatOne()
|
||||
{
|
||||
var only = new FakeCandidate { WeightV = 2f };
|
||||
for (int i = 0; i < 20; i++)
|
||||
Assert.AreSame(only, Sel(only).Select(true, true, AttackSelectionMode.WeightedRandom));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Weighted_NeverPicksIneligible()
|
||||
{
|
||||
var bad = new FakeCandidate { WeightV = 100f, InRange = false };
|
||||
var ok = new FakeCandidate { WeightV = 1f };
|
||||
for (int i = 0; i < 50; i++)
|
||||
Assert.AreSame(ok, Sel(bad, ok).Select(true, true, AttackSelectionMode.WeightedRandom));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6f22b047123bb324a9e3f6cf0e71178f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -8,5 +8,11 @@ namespace BaseGames.AI
|
||||
bool NoAbilityRunning { get; }
|
||||
bool CanUseAbility(string abilityId);
|
||||
void InterruptAbilities(); // 中断当前所有能力(如退出追击时停追击能力)
|
||||
|
||||
/// <summary>攻击选择器中是否有"够得着且未冷却"的招(供 Approach→Attack 转换)。</summary>
|
||||
bool HasEligibleAttack();
|
||||
|
||||
/// <summary>按敌人配置的选招模式选一个攻击并触发;返回是否成功触发。</summary>
|
||||
bool UseBestAttack();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace BaseGames.AI
|
||||
{
|
||||
void SetMode(LocomotionMode mode); // Idle(停) / Patrol(按配置策略游走)
|
||||
void Approach(Transform target); // 持续跟随,派生 RunSpeed
|
||||
void Pursue(Vector2 target); // 寻路逼近(RunSpeed):每帧朝 target 重寻路(底层防抖)
|
||||
void MoveTo(Vector2 point); // 一次性目标点
|
||||
void Face(Vector2 lookAt); // 停 + 朝向
|
||||
void Stop();
|
||||
|
||||
@@ -26,15 +26,14 @@ namespace BaseGames.Enemies
|
||||
/// <summary>每帧由 EnemyAiBrain 在 Tick 之前调用,维护最后已知位置与丢失计时(供需要的敌人用)。</summary>
|
||||
public void Refresh(float dt)
|
||||
{
|
||||
if (InVisionZone() && _enemy.PlayerTransform != null)
|
||||
{
|
||||
bool vision = InVisionZone();
|
||||
// 追逐区(aggro)或视野内都刷新最后已知位置:Approach 逼近态仅由 aggro 门控进入,
|
||||
// 若只在视野时刷新,aggro 先于视野命中时 Pursue 会拿到 zero/过期点而扑向错误位置。
|
||||
if ((vision || InChaseZone()) && _enemy.PlayerTransform != null)
|
||||
_lastKnown = _enemy.PlayerTransform.position;
|
||||
_lostTimer = 0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
_lostTimer += dt;
|
||||
}
|
||||
// 丢失计时仍以视野为准(LostFor 语义=脱离视野的时长,不受 aggro 影响)。
|
||||
if (vision) _lostTimer = 0f;
|
||||
else _lostTimer += dt;
|
||||
}
|
||||
|
||||
/// <summary>对象池复用时清空临时态。</summary>
|
||||
@@ -82,6 +81,25 @@ namespace BaseGames.Enemies
|
||||
public void InterruptAbilities()
|
||||
=> _enemy.Abilities?.InterruptAll(Abilities.InterruptReason.ExternalRequest);
|
||||
|
||||
public bool HasEligibleAttack()
|
||||
{
|
||||
var sel = _enemy.AttackSelector;
|
||||
if (sel == null) return false;
|
||||
return sel.HasEligible(_enemy.IsPlayerVisible(), _enemy.Movement != null && _enemy.Movement.IsGrounded);
|
||||
}
|
||||
|
||||
public bool UseBestAttack()
|
||||
{
|
||||
var sel = _enemy.AttackSelector;
|
||||
if (sel == null) return false;
|
||||
var mode = _enemy.StatsSO != null
|
||||
? _enemy.StatsSO.attackSelectionMode
|
||||
: Abilities.AttackSelectionMode.WeightedRandom;
|
||||
bool grounded = _enemy.Movement != null && _enemy.Movement.IsGrounded;
|
||||
var pick = sel.Select(_enemy.IsPlayerVisible(), grounded, mode);
|
||||
return pick != null && pick.Execute();
|
||||
}
|
||||
|
||||
// ---- IActorVitals ----
|
||||
public bool IsAlive => _enemy.IsAlive;
|
||||
public bool IsControllable => _enemy.CurrentState == EnemyStateType.Controlled;
|
||||
|
||||
@@ -11,9 +11,15 @@ namespace BaseGames.Enemies
|
||||
/// 警觉: 在追逐区→追击; 否则 脱离视野→R; 否则保持警觉(朝向玩家)。
|
||||
/// 追击: 脱离全部感知(追逐区且视野区都不在)→R(永不回警觉)。
|
||||
/// 缺省自动降级:无警觉(HasAlert=false)→警觉分支不触发。
|
||||
/// 交战风格(Engagement):默认 ChaseAbility=单一冲锋能力节点;
|
||||
/// ApproachAttack=把追击换成 Approach(寻路逼近)↔Attack(到射程选招攻击、攻击时停逼近) 两态子机,
|
||||
/// 打完回逼近重判、脱离全部感知区→R。
|
||||
/// </summary>
|
||||
public static class PerceptionStateMachine
|
||||
{
|
||||
/// <summary>交战风格:单一冲锋能力,或"寻路逼近 + 选招攻击"两态子机。</summary>
|
||||
public enum EngagementStyle { ChaseAbility, ApproachAttack }
|
||||
|
||||
public sealed class Config
|
||||
{
|
||||
public string Idle = "Idle";
|
||||
@@ -34,6 +40,11 @@ namespace BaseGames.Enemies
|
||||
// 追击=带冷却的冲刺能力时开启(opt-in):冲刺打完→巡逻(CD 期),冷却结束且仍在追逐区→再冲刺。
|
||||
// 由冷却门控 Patrol→Chase,避免"CD 期在追逐区每帧 Chase↔Patrol 来回抖"。
|
||||
public bool PatrolBetweenChases = false;
|
||||
|
||||
// 交战风格(默认冲锋能力,保持现状);ApproachAttack 时启用 Approach↔Attack 两态子机。
|
||||
public EngagementStyle Engagement = EngagementStyle.ChaseAbility;
|
||||
public string ApproachState = "Approach";
|
||||
public string AttackState = "Attack";
|
||||
}
|
||||
|
||||
public static void Add(BrainBuilder b, Config c)
|
||||
@@ -42,35 +53,66 @@ namespace BaseGames.Enemies
|
||||
b.Global().To(c.Death).OnEvent(AiSignal.Died); // 受击致死 → 死亡(全局最高优先)
|
||||
|
||||
// 进追击的条件:默认"在追逐区"。PatrolBetweenChases 时加冷却门——CD 中不回追击,
|
||||
// 使 Chase(冲刺)→Patrol(CD 巡逻)→Chase 循环不抖动。
|
||||
// 使 Chase(冲刺)→Patrol(CD 巡逻)→Chase 循环不抖动。冷却门仅在冲锋能力风格下生效。
|
||||
string chaseId = c.ChaseAbilityId;
|
||||
System.Func<IAiContext, bool> canChase = c.PatrolBetweenChases
|
||||
? (x => x.Sensor.InChaseZone() && x.Combat.CanUseAbility(chaseId))
|
||||
: (x => x.Sensor.InChaseZone());
|
||||
string chaseLabel = c.PatrolBetweenChases ? "InChaseZone+offCD" : "InChaseZone";
|
||||
System.Func<IAiContext, bool> canChase;
|
||||
string chaseLabel;
|
||||
if (c.Engagement == EngagementStyle.ChaseAbility && c.PatrolBetweenChases)
|
||||
{
|
||||
canChase = x => x.Sensor.InChaseZone() && x.Combat.CanUseAbility(chaseId);
|
||||
chaseLabel = "InChaseZone+offCD";
|
||||
}
|
||||
else
|
||||
{
|
||||
canChase = x => x.Sensor.InChaseZone();
|
||||
chaseLabel = "InChaseZone";
|
||||
}
|
||||
|
||||
// 交战入口态:冲锋风格=Chase;逼近风格=Approach
|
||||
string combatEntry = c.Engagement == EngagementStyle.ApproachAttack ? c.ApproachState : c.Chase;
|
||||
|
||||
// 待机 / 巡逻:未发现态,升级转换相同(追逐优先、其次警觉)
|
||||
AddLocomotionState(b, c.Idle, c.IdleMode)
|
||||
.To(c.Chase).When(canChase, chaseLabel)
|
||||
.To(combatEntry).When(canChase, chaseLabel)
|
||||
.To(c.Alert).When(x => HasAlert(x) && x.Sensor.InVisionZone(), "InVision+HasAlert");
|
||||
AddLocomotionState(b, c.Patrol, c.PatrolMode)
|
||||
.To(c.Chase).When(canChase, chaseLabel)
|
||||
.To(combatEntry).When(canChase, chaseLabel)
|
||||
.To(c.Alert).When(x => HasAlert(x) && x.Sensor.InVisionZone(), "InVision+HasAlert");
|
||||
|
||||
// 警觉(仅升级路径出现):进追逐区→追击;脱离视野→R;在视野内保持(朝向玩家)
|
||||
AddLocomotionState(b, c.Alert, c.AlertMode)
|
||||
.To(c.Chase).When(canChase, chaseLabel)
|
||||
.To(combatEntry).When(canChase, chaseLabel)
|
||||
.To(c.Rest).When(x => !x.Sensor.InVisionZone(), "leftVision");
|
||||
|
||||
// 追击退出:
|
||||
// PatrolBetweenChases: 冲刺打完(能力结束)→R(CD 期巡逻);committed 冲锋不被感知丢失中途打断,
|
||||
// 是否再冲刺交由 Patrol 侧冷却门控。
|
||||
// 默认: 脱离全部感知(追逐区且视野区都不在)→R(永不回警觉)。
|
||||
var chase = AddAbilityState(b, c.Chase, chaseId);
|
||||
if (c.PatrolBetweenChases)
|
||||
chase.To(c.Rest).When(x => !x.Combat.IsAbilityRunning(chaseId), "dashDone→CD");
|
||||
if (c.Engagement == EngagementStyle.ApproachAttack)
|
||||
{
|
||||
// Approach:寻路逼近;有招够得着→Attack;脱离全部感知→R
|
||||
b.State(c.ApproachState)
|
||||
.OnEnter(x => x.Locomotion.Pursue(x.Sensor.LastKnown))
|
||||
.Tick(x => x.Locomotion.Pursue(x.Sensor.LastKnown))
|
||||
.OnExit(x => x.Locomotion.Stop())
|
||||
.To(c.AttackState).When(x => x.Combat.HasEligibleAttack(), "attackInRange")
|
||||
.To(c.Rest).When(x => !x.Sensor.InChaseZone() && !x.Sensor.InVisionZone(), "leftAllZones");
|
||||
|
||||
// Attack:停逼近 + 选招攻击;打完回 Approach;脱离感知→R
|
||||
b.State(c.AttackState)
|
||||
.OnEnter(x => { x.Locomotion.Stop(); x.Combat.UseBestAttack(); })
|
||||
.OnExit(x => x.Combat.InterruptAbilities())
|
||||
.To(c.ApproachState).When(x => !x.Combat.IsAbilityRunning(), "attackDone")
|
||||
.To(c.Rest).When(x => !x.Sensor.InChaseZone() && !x.Sensor.InVisionZone(), "leftAllZones");
|
||||
}
|
||||
else
|
||||
chase.To(c.Rest).When(x => !x.Sensor.InChaseZone() && !x.Sensor.InVisionZone(), "leftAllZones");
|
||||
{
|
||||
// 追击退出:
|
||||
// PatrolBetweenChases: 冲刺打完(能力结束)→R(CD 期巡逻);committed 冲锋不被感知丢失中途打断,
|
||||
// 是否再冲刺交由 Patrol 侧冷却门控。
|
||||
// 默认: 脱离全部感知(追逐区且视野区都不在)→R(永不回警觉)。
|
||||
var chase = AddAbilityState(b, c.Chase, chaseId);
|
||||
if (c.PatrolBetweenChases)
|
||||
chase.To(c.Rest).When(x => !x.Combat.IsAbilityRunning(chaseId), "dashDone→CD");
|
||||
else
|
||||
chase.To(c.Rest).When(x => !x.Sensor.InChaseZone() && !x.Sensor.InVisionZone(), "leftAllZones");
|
||||
}
|
||||
|
||||
// 死亡:终态(无转出);死亡演出走物理状态机
|
||||
AddAbilityState(b, c.Death, c.DeathAbilityId);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace BaseGames.Enemies.Abilities
|
||||
{
|
||||
/// <summary>能力调度分类:攻击选择器只从 <see cref="Attack"/> 类里选招。</summary>
|
||||
public enum AbilityCategory
|
||||
{
|
||||
None, // 未分类(默认,不参与选招)
|
||||
Attack, // 攻击招式(选择器候选)
|
||||
Movement, // 位移/机动
|
||||
Utility // 辅助/增益
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f3568dcdc4a3bdd4084ed27fe9369dcc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace BaseGames.Enemies.Abilities
|
||||
{
|
||||
/// <summary>多攻击选招策略。</summary>
|
||||
public enum AttackSelectionMode
|
||||
{
|
||||
WeightedRandom, // 合格集内按 weight 加权随机
|
||||
Priority // 合格集内取 priority 最高(并列取首个)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 42fd6b0dbc1f9404baede42a158b4cb0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -16,7 +16,7 @@ namespace BaseGames.Enemies.Abilities
|
||||
/// - 受击/死亡时由 <see cref="EnemyBase"/> 调用 <see cref="Interrupt"/>。
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public abstract class EnemyAbilityBase : MonoBehaviour
|
||||
public abstract class EnemyAbilityBase : MonoBehaviour, IAttackCandidate
|
||||
{
|
||||
[Header("配置 SO")]
|
||||
[SerializeField] protected EnemyAbilitySO _config;
|
||||
@@ -43,6 +43,24 @@ namespace BaseGames.Enemies.Abilities
|
||||
/// <summary>BD 任务统一查询入口:当前是否可用(冷却完毕且未执行中)。</summary>
|
||||
public virtual bool CanUse => !_isRunning && !IsOnCooldown && _enemy != null && _enemy.IsAlive;
|
||||
|
||||
// ── IAttackCandidate(供 EnemyAttackSelector 选招)──────────────────
|
||||
public bool RequiresLineOfSight => _config != null && _config.requiresLineOfSight;
|
||||
public bool RequiresGrounded => _config != null && _config.requiresGrounded;
|
||||
public float Weight => _config != null ? _config.weight : 0f;
|
||||
public int Priority => _config != null ? _config.priority : 0;
|
||||
|
||||
/// <summary>本招圆形攻击范围内是否有玩家(招式自管其射程;LOS 复用敌人级 IsPlayerVisible)。</summary>
|
||||
public virtual bool InAttackRange()
|
||||
{
|
||||
if (_config == null || _config.rangeRadius <= 0f
|
||||
|| _enemy == null || _enemy.PlayerTransform == null) return false;
|
||||
float sign = _transform.localScale.x < 0f ? -1f : 1f;
|
||||
Vector2 origin = (Vector2)_transform.position
|
||||
+ new Vector2(_config.rangeOffset.x * sign, _config.rangeOffset.y);
|
||||
float r = _config.rangeRadius;
|
||||
return ((Vector2)_enemy.PlayerTransform.position - origin).sqrMagnitude <= r * r;
|
||||
}
|
||||
|
||||
protected virtual void Awake()
|
||||
{
|
||||
_enemy = GetComponentInParent<EnemyBase>();
|
||||
@@ -164,6 +182,20 @@ namespace BaseGames.Enemies.Abilities
|
||||
$"实际 = {(_config != null ? _config.GetType().Name : "null")}。请重建为正确的子类 SO。", this);
|
||||
return null;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
/// <summary>选中时绘制攻击射程范围(编辑器可视化辅助,不影响运行时)。</summary>
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (_config == null || _config.category != AbilityCategory.Attack || _config.rangeRadius <= 0f) return;
|
||||
float sign = transform.localScale.x < 0f ? -1f : 1f;
|
||||
Vector3 origin = transform.position
|
||||
+ new Vector3(_config.rangeOffset.x * sign, _config.rangeOffset.y, 0f);
|
||||
UnityEditor.Handles.color = new Color(1f, 0.4f, 0.2f, 0.9f);
|
||||
UnityEditor.Handles.DrawWireDisc(origin, Vector3.forward, _config.rangeRadius);
|
||||
UnityEditor.Handles.Label(origin, $"{_config.abilityId} range");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>WaitForSeconds 池(架构 §10 GC 优化)。能力协程统一通过此获取等待指令。</summary>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Enemies.Abilities
|
||||
{
|
||||
@@ -8,7 +10,7 @@ namespace BaseGames.Enemies.Abilities
|
||||
/// 由对应 EnemyAbilityBase 子类组件读取并执行。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Enemies/Enemy Ability", fileName = "EAB_")]
|
||||
public class EnemyAbilitySO : ScriptableObject
|
||||
public class EnemyAbilitySO : ScriptableObject, IValidatable
|
||||
{
|
||||
[Header("标识")]
|
||||
[Tooltip("BD 任务通过此 Id 调用能力(如 \"melee_combo\" / \"blink_strike\")")]
|
||||
@@ -42,5 +44,25 @@ namespace BaseGames.Enemies.Abilities
|
||||
public string exclusionGroup = "";
|
||||
[Tooltip("AI 调度优先级(值越高越优先被选择,冷却就绪时对比)")]
|
||||
[Min(0)] public int priority = 0;
|
||||
|
||||
[Header("选招(攻击选择器用)")]
|
||||
[Tooltip("能力调度分类;攻击选择器只从 Attack 类里选招")]
|
||||
public AbilityCategory category = AbilityCategory.None;
|
||||
[Tooltip("WeightedRandom 模式下的相对权重(越大越易被选)")]
|
||||
[Min(0f)] public float weight = 1f;
|
||||
|
||||
[Header("攻击触发范围(招式自管,圆形)")]
|
||||
[Tooltip("触发半径(m);category==Attack 时必须 > 0,否则永远够不着(Awake 报错)")]
|
||||
[Min(0f)] public float rangeRadius = 0f;
|
||||
[Tooltip("范围圆心相对敌人的偏移(m),X 随朝向翻转")]
|
||||
public Vector2 rangeOffset = Vector2.zero;
|
||||
|
||||
/// <summary>SOValidationRunner 自动扫描调用:校验 Attack 类招式必须配置有效射程。</summary>
|
||||
public IEnumerable<ValidationResult> Validate()
|
||||
{
|
||||
if (category == AbilityCategory.Attack && rangeRadius <= 0f)
|
||||
yield return ValidationResult.Error(
|
||||
$"能力 \"{abilityId}\" category=Attack 但 rangeRadius<=0,攻击选择器永远选不中该招式,请补齐 rangeRadius。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Enemies.Abilities
|
||||
{
|
||||
/// <summary>
|
||||
/// 多攻击选招器:从候选(category==Attack)里按"射程 + 冷却 + LOS + 着地"过滤,
|
||||
/// 再按 <see cref="AttackSelectionMode"/> 选一个。纯逻辑、无 MonoBehaviour 依赖,可单测。
|
||||
/// </summary>
|
||||
public sealed class EnemyAttackSelector
|
||||
{
|
||||
private readonly List<IAttackCandidate> _candidates;
|
||||
|
||||
public EnemyAttackSelector(IEnumerable<IAttackCandidate> candidates)
|
||||
=> _candidates = new List<IAttackCandidate>(candidates);
|
||||
|
||||
public int Count => _candidates.Count;
|
||||
|
||||
private static bool Eligible(IAttackCandidate c, bool hasLOS, bool grounded)
|
||||
=> c != null && c.CanUse && c.InAttackRange()
|
||||
&& (!c.RequiresLineOfSight || hasLOS)
|
||||
&& (!c.RequiresGrounded || grounded);
|
||||
|
||||
public bool HasEligible(bool hasLOS, bool grounded)
|
||||
{
|
||||
for (int i = 0; i < _candidates.Count; i++)
|
||||
if (Eligible(_candidates[i], hasLOS, grounded)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public IAttackCandidate Select(bool hasLOS, bool grounded, AttackSelectionMode mode)
|
||||
=> mode == AttackSelectionMode.Priority
|
||||
? SelectByPriority(hasLOS, grounded)
|
||||
: SelectByWeight(hasLOS, grounded);
|
||||
|
||||
private IAttackCandidate SelectByPriority(bool hasLOS, bool grounded)
|
||||
{
|
||||
IAttackCandidate best = null;
|
||||
for (int i = 0; i < _candidates.Count; i++)
|
||||
{
|
||||
var c = _candidates[i];
|
||||
if (!Eligible(c, hasLOS, grounded)) continue;
|
||||
if (best == null || c.Priority > best.Priority) best = c; // 并列取首个
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private IAttackCandidate SelectByWeight(bool hasLOS, bool grounded)
|
||||
{
|
||||
float total = 0f;
|
||||
for (int i = 0; i < _candidates.Count; i++)
|
||||
{
|
||||
var c = _candidates[i];
|
||||
if (Eligible(c, hasLOS, grounded)) total += Mathf.Max(0f, c.Weight);
|
||||
}
|
||||
if (total <= 0f) return SelectByPriority(hasLOS, grounded); // 权重全 0 → 退化为按 Priority 选(并列取首个)
|
||||
float roll = Random.value * total;
|
||||
for (int i = 0; i < _candidates.Count; i++)
|
||||
{
|
||||
var c = _candidates[i];
|
||||
if (!Eligible(c, hasLOS, grounded)) continue;
|
||||
roll -= Mathf.Max(0f, c.Weight);
|
||||
if (roll <= 0f) return c;
|
||||
}
|
||||
return null; // 理论不达(浮点边界兜底)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9ee7c2be22a1ff548b0bb0e7e8e7a3ca
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace BaseGames.Enemies.Abilities
|
||||
{
|
||||
/// <summary>
|
||||
/// 攻击选择器的候选契约。攻击类 <see cref="EnemyAbilityBase"/> 实现之;
|
||||
/// 抽象出来使 <see cref="EnemyAttackSelector"/> 可脱离 MonoBehaviour 单测。
|
||||
/// </summary>
|
||||
public interface IAttackCandidate
|
||||
{
|
||||
bool CanUse { get; } // 未冷却 + 未运行 + 存活
|
||||
bool RequiresLineOfSight { get; } // 是否需要视线(由敌人级 LOS 提供)
|
||||
bool RequiresGrounded { get; } // 是否需要着地
|
||||
float Weight { get; } // WeightedRandom 权重
|
||||
int Priority { get; } // Priority 优先级
|
||||
bool InAttackRange(); // 招式自管圆形射程内是否有玩家
|
||||
bool Execute(); // 触发本招
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8627938e5d8ca4440b1c8e52700b1b0b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -68,7 +68,9 @@ namespace BaseGames.Enemies.Abilities
|
||||
Phase = AbilityRunState.Active;
|
||||
|
||||
float duration = atk.fallbackDuration;
|
||||
if (atk.clip != null && _animancer != null)
|
||||
// clip 是 ClipTransition(包装对象,恒非 null);须判其内部 .Clip 是否为空——
|
||||
// 为空表示本段无动画,按 EnemyAttackSO 契约走 fallbackDuration,不可 Play(否则 Animancer 抛 Clip is null)。
|
||||
if (atk.clip != null && atk.clip.Clip != null && _animancer != null)
|
||||
{
|
||||
var state = _animancer.Play(atk.clip);
|
||||
if (state != null && state.Length > 0f) duration = state.Length;
|
||||
|
||||
@@ -221,6 +221,9 @@ namespace BaseGames.Enemies
|
||||
/// <summary>能力注册表(架构 §8.3)。Awake 时自动收集所有 EnemyAbilityBase 组件。</summary>
|
||||
public EnemyAbilityRegistry Abilities => _abilities;
|
||||
private readonly EnemyAbilityRegistry _abilities = new EnemyAbilityRegistry();
|
||||
/// <summary>攻击选择器(从已注册能力里筛出 category==Attack 的候选,Awake 时构建)。</summary>
|
||||
public Abilities.EnemyAttackSelector AttackSelector => _attackSelector;
|
||||
private Abilities.EnemyAttackSelector _attackSelector;
|
||||
/// <summary>由 _onPlayerSpawned 事件缓存的玩家 Transform,供 BD 任务读取。</summary>
|
||||
public Transform PlayerTransform => _playerTransform;
|
||||
/// <summary>感知 Hub;供 BD 任务及 QuotaManager 暂停/恢复感知使用。</summary>
|
||||
@@ -537,6 +540,7 @@ namespace BaseGames.Enemies
|
||||
_pooledObject = GetComponent<PooledObject>();
|
||||
_brain = GetComponent<EnemyAiBrain>();
|
||||
_abilities.CollectFrom(gameObject);
|
||||
BuildAttackSelector();
|
||||
_colliders = GetComponentsInChildren<Collider2D>(true);
|
||||
|
||||
// 收集配置型行为模块(零代码扩展点)
|
||||
@@ -552,6 +556,30 @@ namespace BaseGames.Enemies
|
||||
// 订阅在 OnEnable 中处理
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从已注册能力里收集 category==Attack 的候选,构建攻击选择器。
|
||||
/// 根因校验:攻击招 rangeRadius<=0(永远够不着)显式报错,不静默兜底。
|
||||
/// </summary>
|
||||
private void BuildAttackSelector()
|
||||
{
|
||||
var candidates = new System.Collections.Generic.List<Abilities.IAttackCandidate>();
|
||||
var all = _abilities?.All;
|
||||
if (all != null)
|
||||
{
|
||||
for (int i = 0; i < all.Count; i++)
|
||||
{
|
||||
var ab = all[i];
|
||||
if (ab == null || ab.Config == null) continue;
|
||||
if (ab.Config.category != BaseGames.Enemies.Abilities.AbilityCategory.Attack) continue;
|
||||
if (ab.Config.rangeRadius <= 0f)
|
||||
Debug.LogError($"[EnemyBase] 攻击招 '{ab.Config.abilityId}' 的 rangeRadius<=0," +
|
||||
"永远够不着玩家。请在其 EnemyAbilitySO 上配置攻击触发半径。", ab);
|
||||
candidates.Add(ab);
|
||||
}
|
||||
}
|
||||
_attackSelector = new Abilities.EnemyAttackSelector(candidates);
|
||||
}
|
||||
|
||||
protected virtual void Update()
|
||||
{
|
||||
_stats?.TickAttackCooldown(Time.deltaTime);
|
||||
|
||||
@@ -39,6 +39,11 @@ namespace BaseGames.Enemies
|
||||
[Header("战斗")]
|
||||
[Min(0f)] public float AttackCooldown = 1f;
|
||||
|
||||
[Header("攻击选招策略")]
|
||||
[Tooltip("到攻击范围内如何从多个候选招里选一个")]
|
||||
public BaseGames.Enemies.Abilities.AttackSelectionMode attackSelectionMode
|
||||
= BaseGames.Enemies.Abilities.AttackSelectionMode.WeightedRandom;
|
||||
|
||||
[Header("追击 & AI 阶段")]
|
||||
[Tooltip("是否有警觉状态:勾选=未发现态进入视野先进警觉(朝向+警觉动画)再追击;不勾选=进入追逐感知直接追击")]
|
||||
public bool HasAlertState = true;
|
||||
|
||||
@@ -84,6 +84,18 @@ namespace BaseGames.Enemies
|
||||
_enemy?.MoveTo(point);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 寻路逼近:设 Approach 模式 + RunSpeed,每帧朝 target 发一次寻路请求(底层 Nav 自带防抖/受阻/NavLink)。
|
||||
/// 与一次性 <see cref="MoveTo"/> 的区别:设跑速、语义为"持续追向移动目标"。供 AI 追击态每帧调用。
|
||||
/// </summary>
|
||||
public void Pursue(Vector2 target)
|
||||
{
|
||||
_mode = LocomotionMode.Approach;
|
||||
_approachTarget = null;
|
||||
if (_enemy?.Stats != null) _enemy.Nav?.SetSpeed(_enemy.Stats.RunSpeed);
|
||||
_enemy?.MoveTo(target);
|
||||
}
|
||||
|
||||
public void Face(Vector2 lookAt)
|
||||
{
|
||||
_mode = LocomotionMode.Face;
|
||||
|
||||
Reference in New Issue
Block a user