diff --git a/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs b/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs
deleted file mode 100644
index ca004669..00000000
--- a/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs
+++ /dev/null
@@ -1,489 +0,0 @@
-using System.Collections.Generic;
-using UnityEngine;
-using NUnit.Framework;
-using BaseGames.AI;
-using BaseGames.Enemies;
-
-namespace BaseGames.Tests.EditMode.AI
-{
- ///
- /// PerceptionStateMachine 全感知↔状态矩阵测试(spec《敌人感知↔状态》):
- /// 待机/巡逻(未发现) → 追击(优先)/警觉(次);警觉 → 追击/降级R/保持;
- /// 追击 → 脱离全部感知才降级R(永不回警觉);死亡=全局事件。
- /// 覆盖 HasAlert 分支、locomotion 意图、追击能力触发/中断、de-escalation。
- ///
- public class PerceptionStateMachineTests
- {
- // 实现 IAiContext + IEnemyActor(HasAlertState 可配),复用 FakeAiContext.cs 的子 fake。
- sealed class Ctx : IAiContext, IEnemyActor
- {
- public FakeSensor S = new FakeSensor();
- public FakeLocomotion L = new FakeLocomotion();
- public FakeCombat C = new FakeCombat();
- public FakeVitals V = new FakeVitals();
- public Blackboard BB = new Blackboard();
- public bool HasAlert = true;
-
- public ISensor Sensor => S;
- public IEnemyLocomotion Locomotion => L;
- public ICombatant Combat => C;
- public IActorVitals Vitals => V;
- public Blackboard Blackboard => BB;
- public bool HasAlertState => HasAlert;
- }
-
- static AiGraph Graph(string entry = "Idle")
- {
- var b = new BrainBuilder();
- PerceptionStateMachine.Add(b, new PerceptionStateMachine.Config
- {
- Idle = "Idle", Patrol = "Patrol", Alert = "Alert", Chase = "Chase", Death = "Death",
- Entry = entry, Rest = "Patrol",
- IdleMode = LocomotionMode.Idle,
- PatrolMode = LocomotionMode.Patrol,
- AlertMode = LocomotionMode.Face,
- ChaseAbilityId = "chase",
- });
- return b.Build();
- }
-
- // PatrolBetweenChases 变体:冲刺打完→巡逻(CD),冷却结束仍在追逐区→再冲刺。
- static AiGraph GraphCD()
- {
- var b = new BrainBuilder();
- PerceptionStateMachine.Add(b, new PerceptionStateMachine.Config
- {
- Idle = "Idle", Patrol = "Patrol", Alert = "Alert", Chase = "Chase", Death = "Death",
- Entry = "Idle", Rest = "Patrol",
- IdleMode = LocomotionMode.Idle, PatrolMode = LocomotionMode.Patrol, AlertMode = LocomotionMode.Face,
- ChaseAbilityId = "chase",
- PatrolBetweenChases = true,
- });
- return b.Build();
- }
-
- // ── 入口 / 待机 ───────────────────────────────────────────────────
-
- [Test]
- public void Entry_IsIdle_WithIdleLocomotion()
- {
- var ctx = new Ctx();
- var rt = new AiRuntime(Graph(), ctx);
- Assert.AreEqual("Idle", rt.CurrentStateName);
- Assert.AreEqual(LocomotionMode.Idle, ctx.L.CurrentMode);
- }
-
- [Test]
- public void Idle_Stays_WhenNoPerception()
- {
- var ctx = new Ctx();
- var rt = new AiRuntime(Graph(), ctx);
- rt.Tick(0.1f);
- Assert.AreEqual("Idle", rt.CurrentStateName);
- }
-
- [Test]
- public void Idle_ToChase_WhenInChaseZone()
- {
- var ctx = new Ctx();
- var rt = new AiRuntime(Graph(), ctx);
- ctx.S.Chase = true;
- rt.Tick(0.1f);
- Assert.AreEqual("Chase", rt.CurrentStateName);
- }
-
- [Test]
- public void Idle_ToAlert_WhenInVision_AndHasAlert()
- {
- var ctx = new Ctx { HasAlert = true };
- var rt = new AiRuntime(Graph(), ctx);
- ctx.S.Vision = true;
- rt.Tick(0.1f);
- Assert.AreEqual("Alert", rt.CurrentStateName);
- }
-
- [Test]
- public void Idle_StaysIdle_WhenInVision_ButNoAlertState()
- {
- var ctx = new Ctx { HasAlert = false }; // 无警觉态 → 不进警觉
- var rt = new AiRuntime(Graph(), ctx);
- ctx.S.Vision = true;
- rt.Tick(0.1f);
- Assert.AreEqual("Idle", rt.CurrentStateName);
- }
-
- [Test]
- public void Idle_ChaseBeatsAlert_WhenBothZones()
- {
- var ctx = new Ctx { HasAlert = true };
- var rt = new AiRuntime(Graph(), ctx);
- ctx.S.Chase = true; ctx.S.Vision = true; // 两者都满足 → 追击优先
- rt.Tick(0.1f);
- Assert.AreEqual("Chase", rt.CurrentStateName);
- }
-
- // ── 巡逻(entry=Patrol):与待机同规则 ─────────────────────────────
-
- [Test]
- public void Patrol_ToChase_WhenInChaseZone()
- {
- var ctx = new Ctx();
- var rt = new AiRuntime(Graph("Patrol"), ctx);
- Assert.AreEqual("Patrol", rt.CurrentStateName);
- Assert.AreEqual(LocomotionMode.Patrol, ctx.L.CurrentMode);
- ctx.S.Chase = true;
- rt.Tick(0.1f);
- Assert.AreEqual("Chase", rt.CurrentStateName);
- }
-
- [Test]
- public void Patrol_ToAlert_WhenInVision_AndHasAlert()
- {
- var ctx = new Ctx { HasAlert = true };
- var rt = new AiRuntime(Graph("Patrol"), ctx);
- ctx.S.Vision = true;
- rt.Tick(0.1f);
- Assert.AreEqual("Alert", rt.CurrentStateName);
- }
-
- // ── 警觉 ──────────────────────────────────────────────────────────
-
- Ctx InAlert(out AiRuntime rt)
- {
- var ctx = new Ctx { HasAlert = true };
- rt = new AiRuntime(Graph(), ctx);
- ctx.S.Vision = true; // Idle → Alert
- rt.Tick(0.1f);
- Assert.AreEqual("Alert", rt.CurrentStateName);
- return ctx;
- }
-
- [Test]
- public void Alert_AppliesFaceLocomotion()
- {
- var ctx = InAlert(out var rt);
- Assert.AreEqual(LocomotionMode.Face, ctx.L.CurrentMode);
- CollectionAssert.Contains(ctx.L.Calls, "Face");
- }
-
- [Test]
- public void Alert_ToChase_WhenInChaseZone()
- {
- var ctx = InAlert(out var rt);
- ctx.S.Chase = true;
- rt.Tick(0.1f);
- Assert.AreEqual("Chase", rt.CurrentStateName);
- }
-
- [Test]
- public void Alert_ToRest_WhenLeftVision()
- {
- var ctx = InAlert(out var rt);
- ctx.S.Vision = false; // 脱离视野 → 降级 R(=Patrol)
- rt.Tick(0.1f);
- Assert.AreEqual("Patrol", rt.CurrentStateName);
- }
-
- [Test]
- public void Alert_Stays_WhenStillInVision()
- {
- var ctx = InAlert(out var rt);
- ctx.S.Vision = true; ctx.S.Chase = false;
- rt.Tick(0.1f);
- Assert.AreEqual("Alert", rt.CurrentStateName);
- }
-
- // ── 追击 ──────────────────────────────────────────────────────────
-
- Ctx InChase(out AiRuntime rt)
- {
- var ctx = new Ctx();
- rt = new AiRuntime(Graph(), ctx);
- ctx.S.Chase = true; // Idle → Chase
- rt.Tick(0.1f);
- Assert.AreEqual("Chase", rt.CurrentStateName);
- return ctx;
- }
-
- [Test]
- public void Chase_TriggersAbility_NotLocomotion()
- {
- var ctx = InChase(out var rt);
- CollectionAssert.Contains(ctx.C.Used, "chase"); // 触发追击能力
- // 进 Chase 后不应再有 locomotion 意图声明(追击移动归能力)
- int lastStop = ctx.L.Calls.FindLastIndex(s => s == "Stop");
- var afterEnter = ctx.L.Calls.GetRange(lastStop + 1, ctx.L.Calls.Count - lastStop - 1);
- CollectionAssert.IsEmpty(afterEnter);
- }
-
- [Test]
- public void Chase_ToRest_WhenLeftAllZones()
- {
- var ctx = InChase(out var rt);
- ctx.S.Chase = false; ctx.S.Vision = false; // 追逐区且视野区都不在 → R
- rt.Tick(0.1f);
- Assert.AreEqual("Patrol", rt.CurrentStateName);
- }
-
- [Test]
- public void Chase_Stays_WhenInChaseZoneOnly()
- {
- var ctx = InChase(out var rt);
- ctx.S.Chase = true; ctx.S.Vision = false; // 视野被墙挡漏检,但仍在追逐区
- rt.Tick(0.1f);
- Assert.AreEqual("Chase", rt.CurrentStateName);
- }
-
- [Test]
- public void Chase_Stays_WhenInVisionZoneOnly()
- {
- var ctx = InChase(out var rt);
- ctx.S.Chase = false; ctx.S.Vision = true; // 出追逐区但仍在视野 → 不降级
- rt.Tick(0.1f);
- Assert.AreEqual("Chase", rt.CurrentStateName);
- }
-
- [Test]
- public void Chase_NeverGoesToAlert()
- {
- // 追击中即便"在视野且有警觉态",也绝不回警觉——只在脱离全部感知时降级 R。
- var ctx = InChase(out var rt);
- ctx.HasAlert = true;
- ctx.S.Chase = false; ctx.S.Vision = true;
- rt.Tick(0.1f);
- Assert.AreEqual("Chase", rt.CurrentStateName); // 不是 Alert
- Assert.AreNotEqual("Alert", rt.CurrentStateName);
- }
-
- [Test]
- public void Chase_OnExit_InterruptsAbility()
- {
- var ctx = InChase(out var rt);
- Assert.AreEqual("chase", ctx.C.Running); // 追击能力运行中
- ctx.S.Chase = false; ctx.S.Vision = false;
- rt.Tick(0.1f); // Chase → R,OnExit 中断能力
- Assert.IsNull(ctx.C.Running);
- }
-
- [Test]
- public void RestIsPatrol_AfterChaseDeescalation()
- {
- var ctx = InChase(out var rt);
- ctx.S.Chase = false; ctx.S.Vision = false;
- rt.Tick(0.1f);
- Assert.AreEqual("Patrol", rt.CurrentStateName); // R = Patrol
- 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]
- public void Death_OnDiedSignal_FromIdle()
- {
- var ctx = new Ctx();
- var rt = new AiRuntime(Graph(), ctx);
- rt.Send(AiSignal.Died);
- rt.Tick(0.1f);
- Assert.AreEqual("Death", rt.CurrentStateName);
- }
-
- [Test]
- public void Death_OnDiedSignal_FromChase()
- {
- var ctx = InChase(out var rt);
- rt.Send(AiSignal.Died);
- rt.Tick(0.1f);
- Assert.AreEqual("Death", rt.CurrentStateName);
- }
-
- [Test]
- public void Death_IsTerminal_NoTransitionOut()
- {
- var ctx = new Ctx();
- var rt = new AiRuntime(Graph(), ctx);
- rt.Send(AiSignal.Died); rt.Tick(0.1f);
- Assert.AreEqual("Death", rt.CurrentStateName);
- ctx.S.Chase = true; ctx.S.Vision = true; // 即便重新满足感知也不复活
- rt.Tick(0.1f);
- Assert.AreEqual("Death", rt.CurrentStateName);
- }
-
- // ── PatrolBetweenChases(CD 期巡逻)───────────────────────────────
-
- // 驱动到 Chase 后模拟冲刺进行中(能力 running)。
- Ctx InCD_Chase(out AiRuntime rt)
- {
- var ctx = new Ctx();
- rt = new AiRuntime(GraphCD(), ctx);
- ctx.S.Chase = true; ctx.C.CanUse = true;
- rt.Tick(0.1f); // Idle → Chase,OnEnter 触发冲刺(Running="chase")
- Assert.AreEqual("Chase", rt.CurrentStateName);
- Assert.AreEqual("chase", ctx.C.Running);
- return ctx;
- }
-
- [Test]
- public void CD_Chase_Stays_WhileDashRunning()
- {
- var ctx = InCD_Chase(out var rt);
- ctx.S.Chase = false; ctx.S.Vision = false; // 冲刺是 committed 冲锋:感知丢失也不中途打断
- rt.Tick(0.1f);
- Assert.AreEqual("Chase", rt.CurrentStateName);
- }
-
- [Test]
- public void CD_Chase_ToPatrol_WhenDashDone()
- {
- var ctx = InCD_Chase(out var rt);
- ctx.C.Running = null; ctx.C.CanUse = false; // 冲刺打完,进入冷却
- rt.Tick(0.1f);
- Assert.AreEqual("Patrol", rt.CurrentStateName); // 冲完→CD 期巡逻
- }
-
- [Test]
- public void CD_Patrol_Stays_DuringCooldown_NoOscillation()
- {
- var ctx = InCD_Chase(out var rt);
- ctx.C.Running = null; ctx.C.CanUse = false;
- rt.Tick(0.1f); // → Patrol
- Assert.AreEqual("Patrol", rt.CurrentStateName);
- // CD 中仍在追逐区:不能回追击,多帧保持巡逻(不抖)
- ctx.S.Chase = true; ctx.C.CanUse = false;
- for (int i = 0; i < 5; i++) rt.Tick(0.1f);
- Assert.AreEqual("Patrol", rt.CurrentStateName);
- }
-
- [Test]
- public void CD_Patrol_ToChase_WhenCooldownEnds_AndStillInChaseZone()
- {
- var ctx = InCD_Chase(out var rt);
- ctx.C.Running = null; ctx.C.CanUse = false;
- rt.Tick(0.1f); // → Patrol
- ctx.S.Chase = true; ctx.C.CanUse = true; // 冷却结束 + 仍在追逐区
- rt.Tick(0.1f);
- Assert.AreEqual("Chase", rt.CurrentStateName); // 再次冲刺
- Assert.AreEqual("chase", ctx.C.Running);
- }
-
- [Test]
- public void CD_Patrol_StaysHome_WhenPlayerLost()
- {
- var ctx = InCD_Chase(out var rt);
- ctx.C.Running = null; ctx.C.CanUse = false;
- rt.Tick(0.1f); // → Patrol
- // 玩家丢失(脱离追逐区):即便冷却结束也不回追击,留在巡逻(降级回家)
- ctx.S.Chase = false; ctx.S.Vision = false; ctx.C.CanUse = true;
- rt.Tick(0.1f);
- Assert.AreEqual("Patrol", rt.CurrentStateName);
- }
- }
-}
diff --git a/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs.meta b/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs.meta
deleted file mode 100644
index 0ce20998..00000000
--- a/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs.meta
+++ /dev/null
@@ -1,11 +0,0 @@
-fileFormatVersion: 2
-guid: 11d9c7954aefbd84e93387db27268546
-MonoImporter:
- externalObjects: {}
- serializedVersion: 2
- defaultReferences: []
- executionOrder: 0
- icon: {instanceID: 0}
- userData:
- assetBundleName:
- assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs
index b5bd5f86..e5cc6c24 100644
--- a/Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs
+++ b/Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs
@@ -3,35 +3,17 @@ using BaseGames.AI;
namespace BaseGames.Enemies
{
///
- /// E001(草蛭)AI —— 纯决策层,复用 PerceptionStateMachine 的"待机/警觉/追击/巡逻"统一规则。
- /// 只声明差异:每个状态触发哪个能力(待机=伪装静止、巡逻=游走、警觉=张口、追击=接触追击)。
- /// AI 只决策(进哪个状态/转换条件/触发哪个能力);移动/朝向/停/速度/动画/伤害/阶段声明全部在能力里实现。
- /// 依据 Docs/Game/Design/小怪设计-程序开发文档-01.md。
+ /// E001(草蛭)AI —— 纯决策层,组装两层模块。
+ /// 出生伪装静止(伪装成石头),一旦交战过就只回巡逻;追击=带冷却的接触冲锋,
+ /// 起手即锁定、打完回巡逻走冷却;死亡演出走物理状态机(EnemyBase.PerformDeath)。
+ /// AI 只决策;移动 / 朝向 / 速度 / 动画 / 伤害全部在能力与 EnemyLocomotion 里实现。
+ /// 依据 Docs/Game/敌人/小怪/E001_草蛭.md。
///
[AiDefinition("E001")]
public sealed class E001CaoZhiAi : AiScript
{
- protected override void Build(BrainBuilder b)
- {
- PerceptionStateMachine.Add(b, new PerceptionStateMachine.Config
- {
- Idle = "Idle_Disguise",
- Patrol = "Move_Patrol",
- Alert = "Alert",
- Chase = "Chase",
- Death = "Death",
- // [临时调试] 出生即巡逻,用于真机验证"中心悬空导致导航寻路失效"是否真实存在。
- // 验证完成后必须改回 "Idle_Disguise"(伪装静止)。
- Entry = "Move_Patrol",
- Rest = "Move_Patrol",
-
- IdleMode = LocomotionMode.Idle,
- PatrolMode = LocomotionMode.Patrol,
- AlertMode = LocomotionMode.Face,
- ChaseAbilityId = "e001_chase",
- PatrolBetweenChases = true, // 冲刺=带冷却的冲锋:冲完→CD 期巡逻→冷却结束仍在追逐区→再冲
- // DeathAbilityId 留空:死亡演出走物理状态机(EnemyBase.PerformDeath)
- });
- }
+ protected override void Build(BrainBuilder b) => PerceptionSkeleton.Add(b,
+ unaware: new DisguiseThenPatrol(),
+ engagement: new RushEngagement("e001_chase", RushExit.Committed));
}
}
diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs b/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs
deleted file mode 100644
index dde04530..00000000
--- a/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs
+++ /dev/null
@@ -1,156 +0,0 @@
-using BaseGames.AI;
-
-namespace BaseGames.Enemies
-{
- ///
- /// 可复用的"待机/警觉/追击/巡逻"感知状态机(统一规则,见 spec《敌人感知↔状态》)。
- /// 纯决策:AI 只决定"进哪个状态 / 转换条件 / 每状态的移动意图或攻击能力";
- /// 移动/朝向/动画/伤害的实现全部在 EnemyLocomotion 或攻击能力里(AI 不 actuate)。
- /// 规则:
- /// 待机/巡逻: 在追逐区→追击(优先); 否则 有警觉且在视野→警觉; 否则保持。
- /// 警觉: 在追逐区→追击; 否则 脱离视野→R; 否则保持警觉(朝向玩家)。
- /// 追击: 脱离全部感知(追逐区且视野区都不在)→R(永不回警觉)。
- /// 缺省自动降级:无警觉(HasAlert=false)→警觉分支不触发。
- /// 交战风格(Engagement):默认 ChaseAbility=单一冲锋能力节点;
- /// ApproachAttack=把追击换成 Approach(寻路逼近)↔Attack(到射程选招攻击、攻击时停逼近) 两态子机,
- /// 打完回逼近重判、脱离全部感知区→R。
- ///
- public static class PerceptionStateMachine
- {
- /// 交战风格:单一冲锋能力,或"寻路逼近 + 选招攻击"两态子机。
- public enum EngagementStyle { ChaseAbility, ApproachAttack }
-
- public sealed class Config
- {
- public string Idle = "Idle";
- public string Patrol = "Patrol";
- public string Alert = "Alert";
- public string Chase = "Chase";
- public string Death = "Death";
- public string Entry = "Idle"; // 初始态
- public string Rest = "Patrol"; // R:降级(退出追击/警觉丢失)回到的静止态
- // 待机/巡逻/警觉的移动意图(由 EnemyLocomotion 执行)
- public LocomotionMode IdleMode = LocomotionMode.Idle;
- public LocomotionMode PatrolMode = LocomotionMode.Patrol;
- public LocomotionMode AlertMode = LocomotionMode.Face; // 停 + 朝向玩家
- // 追击/死亡走能力(追击=接触伤害等战斗语义)
- public string ChaseAbilityId;
- public string DeathAbilityId;
-
- // 追击=带冷却的冲刺能力时开启(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)
- {
- b.Entry(c.Entry);
- b.Global().To(c.Death).OnEvent(AiSignal.Died); // 受击致死 → 死亡(全局最高优先)
-
- // 进追击的条件:默认"在追逐区"。PatrolBetweenChases 时加冷却门——CD 中不回追击,
- // 使 Chase(冲刺)→Patrol(CD 巡逻)→Chase 循环不抖动。冷却门仅在冲锋能力风格下生效。
- string chaseId = c.ChaseAbilityId;
- System.Func 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(combatEntry).When(canChase, chaseLabel)
- .To(c.Alert).When(x => HasAlert(x) && x.Sensor.InVisionZone(), "InVision+HasAlert");
- AddLocomotionState(b, c.Patrol, c.PatrolMode)
- .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(combatEntry).When(canChase, chaseLabel)
- .To(c.Rest).When(x => !x.Sensor.InVisionZone(), "leftVision");
-
- 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
- {
- // 追击退出:
- // 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);
- }
-
- ///
- /// 一个"由 EnemyLocomotion 驱动"的状态:进入/每帧声明移动意图,离开时停。
- /// Face 模式每帧朝向最后已知玩家位置(Sensor.LastKnown 由 EnemyBrainContext.Refresh 维护)。
- ///
- static BrainBuilder.StateBuilder AddLocomotionState(BrainBuilder b, string state, LocomotionMode mode)
- {
- return b.State(state)
- .OnEnter(x => ApplyLocomotion(x, mode))
- .Tick(x => ApplyLocomotion(x, mode))
- .OnExit(x => x.Locomotion.Stop());
- }
-
- static void ApplyLocomotion(IAiContext x, LocomotionMode mode)
- {
- if (mode == LocomotionMode.Face) x.Locomotion.Face(x.Sensor.LastKnown);
- else x.Locomotion.SetMode(mode);
- }
-
- /// 一个"由攻击能力驱动"的状态:进入/每帧确保能力运行(受击打断后重触发),离开中断。
- static BrainBuilder.StateBuilder AddAbilityState(BrainBuilder b, string state, string abilityId)
- {
- return b.State(state)
- .OnEnter(x => EnsureAbility(x, abilityId))
- .Tick(x => EnsureAbility(x, abilityId))
- .OnExit(x => x.Combat.InterruptAbilities());
- }
-
- static void EnsureAbility(IAiContext x, string abilityId)
- {
- if (!string.IsNullOrEmpty(abilityId) && !x.Combat.IsAbilityRunning(abilityId))
- x.Combat.UseAbility(abilityId);
- }
-
- static bool HasAlert(IAiContext x) => x is IEnemyActor a && a.HasAlertState;
- }
-}
diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs.meta b/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs.meta
deleted file mode 100644
index 1743d379..00000000
--- a/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs.meta
+++ /dev/null
@@ -1,11 +0,0 @@
-fileFormatVersion: 2
-guid: d6bbbd575378d614da31905f1178dbf8
-MonoImporter:
- externalObjects: {}
- serializedVersion: 2
- defaultReferences: []
- executionOrder: 0
- icon: {instanceID: 0}
- userData:
- assetBundleName:
- assetBundleVariant: