From bf8101b3f5e88215f01fe409085f5661c9d3c1f6 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Wed, 29 Jul 2026 14:10:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(enemy):=20PerceptionSkeleton=E2=80=94?= =?UTF-8?q?=E2=80=94=E4=B8=A4=E5=B1=82=E5=8F=AF=E6=8F=92=E6=8B=94=E7=9A=84?= =?UTF-8?q?=E6=84=9F=E7=9F=A5=E9=AA=A8=E6=9E=B6=EF=BC=8C=E8=87=AA=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E6=AD=BB=E4=BA=A1=E7=BB=88=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EditMode/AI/PerceptionSkeletonTests.cs | 220 ++++++++++++++++++ .../AI/PerceptionSkeletonTests.cs.meta | 11 + .../AIBrain/Modules/PerceptionSkeleton.cs | 83 +++++++ .../Modules/PerceptionSkeleton.cs.meta | 11 + 4 files changed, 325 insertions(+) create mode 100644 Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs create mode 100644 Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs create mode 100644 Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs.meta diff --git a/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs b/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs new file mode 100644 index 00000000..b3f49a15 --- /dev/null +++ b/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs @@ -0,0 +1,220 @@ +using System.Collections.Generic; +using NUnit.Framework; +using BaseGames.AI; +using BaseGames.Enemies; + +namespace BaseGames.Tests.EditMode.AI +{ + public class PerceptionSkeletonTests + { + 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 AiRuntime Build(Ctx ctx, IUnawareModule unaware = null, IEngagementModule engagement = null) + { + var b = new BrainBuilder(); + PerceptionSkeleton.Add(b, + unaware ?? new SinglePost(LocomotionMode.Patrol), + engagement ?? new RushEngagement("rush", RushExit.OnLostTarget)); + return new AiRuntime(b.Build(), ctx); + } + + // ── 升级 ────────────────────────────────────────────────────────── + + [Test] + public void Unaware_ToEngagement_WhenInChaseZone() + { + var ctx = new Ctx(); + var rt = Build(ctx); + ctx.S.Chase = true; + rt.Tick(0.1f); + Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); + } + + [Test] + public void Unaware_ToAlert_WhenInVision_AndHasAlert() + { + var ctx = new Ctx { HasAlert = true }; + var rt = Build(ctx); + ctx.S.Vision = true; + rt.Tick(0.1f); + Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName); + } + + [Test] + public void Unaware_StaysUnaware_WhenInVision_ButNoAlertState() + { + var ctx = new Ctx { HasAlert = false }; + var rt = Build(ctx); + ctx.S.Vision = true; + rt.Tick(0.1f); + Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); + } + + [Test] + public void ChaseZone_BeatsAlert_WhenBothActive() + { + var ctx = new Ctx { HasAlert = true }; + var rt = Build(ctx); + ctx.S.Chase = true; ctx.S.Vision = true; + rt.Tick(0.1f); + Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); + } + + // 升级边必须优先于未发现层内部计时边 + [Test] + public void UpgradeEdge_BeatsUnawareInternalDwellEdge() + { + var ctx = new Ctx(); + var rt = Build(ctx, new AlternatingIdlePatrol(idleDwell: 1f, patrolDwell: 1f)); + Assert.AreEqual(AlternatingIdlePatrol.Idle, rt.CurrentStateName); + ctx.S.Chase = true; + rt.Tick(5f); // 计时早已到点,但追击边优先 + Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); + } + + // ── 警觉 ────────────────────────────────────────────────────────── + + [Test] + public void Alert_ToEngagement_WhenEnteringChaseZone() + { + var ctx = new Ctx(); + var rt = Build(ctx); + ctx.S.Vision = true; rt.Tick(0.1f); + Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName); + ctx.S.Chase = true; rt.Tick(0.1f); + Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); + } + + [Test] + public void Alert_ToRest_WhenLeavingVision() + { + var ctx = new Ctx(); + var rt = Build(ctx); + ctx.S.Vision = true; rt.Tick(0.1f); + ctx.S.Vision = false; rt.Tick(0.1f); + Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); + } + + [Test] + public void Alert_FacesLastKnown() + { + var ctx = new Ctx(); + ctx.S.Last = new UnityEngine.Vector2(7f, 2f); + var rt = Build(ctx); + ctx.S.Vision = true; rt.Tick(0.1f); + Assert.AreEqual(new UnityEngine.Vector2(7f, 2f), ctx.L.FacedAt); + } + + // ── 降级:追击后永不回警觉 ───────────────────────────────────────── + + [Test] + public void AfterEngagement_GoesToRest_NeverBackToAlert() + { + var ctx = new Ctx(); + var rt = Build(ctx); + ctx.S.Chase = true; rt.Tick(0.1f); + Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); + ctx.S.Chase = false; ctx.S.Vision = false; rt.Tick(0.1f); + Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); // 不是 Alert + } + + // ── 死亡 ────────────────────────────────────────────────────────── + + [Test] + public void Died_TransitionsToDeath_FromAnyState() + { + var ctx = new Ctx(); + var rt = Build(ctx); + ctx.S.Chase = true; rt.Tick(0.1f); + Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); + rt.Send(AiSignal.Died); + rt.Tick(0.1f); + Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName); + } + + // 真实死亡路径:PerformDeath 先 ForceState(Dead) 再发 Died, + // 所以信号被处理时 IsControllable 已为假。事件边必须仍能通过。 + [Test] + public void Died_WorksEvenWhenNotControllable() + { + var ctx = new Ctx(); + var rt = Build(ctx); + ctx.V.Controllable = false; // 已是 Dead 物理态 + rt.Send(AiSignal.Died); + rt.Tick(0.1f); + Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName); + } + + [Test] + public void Death_IsTerminal_NoConditionEdgesEvaluatedAfterwards() + { + var ctx = new Ctx(); + var rt = Build(ctx); + rt.Send(AiSignal.Died); + rt.Tick(0.1f); + ctx.V.Controllable = false; // 死后不可控 + ctx.S.Chase = true; // 即便玩家又进追逐区 + for (int i = 0; i < 10; i++) rt.Tick(0.1f); + Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName); // 也不复活 + } + + // ── 校验 ────────────────────────────────────────────────────────── + + sealed class BadUnaware : IUnawareModule + { + public string Entry => "X"; + public string Rest => "NotInStates"; + public IReadOnlyList States => new[] { "X" }; + public void Declare(BrainBuilder b) => AiStateFragments.Locomotion(b, "X", LocomotionMode.Idle); + public void Link(BrainBuilder b) { } + } + + sealed class UndeclaredUnaware : IUnawareModule + { + public string Entry => "Y"; + public string Rest => "Y"; + public IReadOnlyList States => new[] { "Y" }; + public void Declare(BrainBuilder b) { } // 忘了声明行为 + public void Link(BrainBuilder b) { } + } + + [Test] + public void Throws_WhenRestNotInStates() + { + var b = new BrainBuilder(); + Assert.Throws(() => PerceptionSkeleton.Add( + b, new BadUnaware(), new RushEngagement("rush"))); + } + + [Test] + public void Throws_WhenUnawareStateNotDeclared() + { + var b = new BrainBuilder(); + Assert.Throws(() => PerceptionSkeleton.Add( + b, new UndeclaredUnaware(), new RushEngagement("rush"))); + } + + [Test] + public void Throws_WhenModuleIsNull() + { + var b = new BrainBuilder(); + Assert.Throws(() => PerceptionSkeleton.Add( + b, null, new RushEngagement("rush"))); + } + } +} diff --git a/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs.meta b/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs.meta new file mode 100644 index 00000000..22af76df --- /dev/null +++ b/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e25626c96226d9a489b332e79cd933d6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs new file mode 100644 index 00000000..eec82aa2 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using BaseGames.AI; + +namespace BaseGames.Enemies +{ + /// + /// 感知骨架:把「未发现 → 警觉 → 交战」的升降级规则,挂到两个模块声明的态上。 + /// 骨架永远只做这一件事——未发现态长什么样、交战怎么打,全部归模块。 + /// + /// 规则: + /// 未发现:在追逐区 → 交战(优先);否则 有警觉态且在视野 → 警觉;否则保持。 + /// 警觉: 在追逐区 → 交战;否则 脱离视野 → Rest;否则保持(朝向最后已知位置)。 + /// 交战: 脱战条件由交战模块自定,脱战一律回 Rest —— 永不回警觉。 + /// 死亡: 全局事件边,优先级最高,且不受 IsControllable 门阻挡。 + /// + /// 死亡态由骨架自己声明为无行为终态,不做成可插拔层——死亡在本项目里完全归物理层 + /// (EnemyBase.PerformDeath 切终态/关碰撞/播死亡动画/归还池;EnemyDeathSequence 做 + /// 死亡前摇演出并可经动画事件生成小怪)。且 PerformDeath 先 ForceState(Dead) 再发 + /// Died 信号,此后 IsControllable 永久为假、AiRuntime 不再求值任何条件边, + /// 所以死亡链里放条件转换必然卡住。AI 图需要这个态只是为了让全局边有去处、决策停止。 + /// + /// 入口默认取 unaware.Entry;需要前置态(掉落链 / 出场链)的敌人在调用本方法后 + /// 再调 b.Entry("自己的态") 覆盖即可。 + /// + public static class PerceptionSkeleton + { + public const string Alert = "Alert"; + public const string Death = "Death"; + + static readonly Func HasAlertAndInVision = + x => x is IEnemyActor a && a.HasAlertState && x.Sensor.InVisionZone(); + + static readonly Func LeftVision = x => !x.Sensor.InVisionZone(); + + public static void Add(BrainBuilder b, + IUnawareModule unaware, + IEngagementModule engagement) + { + if (b == null) throw new ArgumentNullException(nameof(b)); + if (unaware == null) throw new ArgumentNullException(nameof(unaware)); + if (engagement == null) throw new ArgumentNullException(nameof(engagement)); + + // 1) 各模块先声明自己的态(未发现层此阶段只声明态,不挂内部边) + unaware.Declare(b); + engagement.Build(b, unaware.Rest); + AiStateFragments.Terminal(b, Death); + + var states = unaware.States; + if (states == null || states.Count == 0) + throw new InvalidOperationException($"{unaware.GetType().Name}: States 不能为空。"); + for (int i = 0; i < states.Count; i++) + b.RequireState(states[i]); // Declare 漏声明的态在此暴露 + if (!Contains(states, unaware.Rest)) + throw new InvalidOperationException( + $"{unaware.GetType().Name}: Rest='{unaware.Rest}' 必须属于 States。"); + + b.Entry(unaware.Entry); + b.Global().To(Death).OnEvent(AiSignal.Died); + + // 2) 升级边先挂——AiRuntime 按声明顺序评估条件边,升级必须优先于 + // 未发现层的内部边(如巡逻计时),否则玩家进追逐区那一帧可能被计时边抢走。 + for (int i = 0; i < states.Count; i++) + b.State(states[i]) + .To(engagement.EntryState).When(engagement.CanEngage, engagement.EngageLabel) + .To(Alert).When(HasAlertAndInVision, "InVision+HasAlert"); + + AiStateFragments.Locomotion(b, Alert, LocomotionMode.Face) + .To(engagement.EntryState).When(engagement.CanEngage, engagement.EngageLabel) + .To(unaware.Rest).When(LeftVision, "leftVision"); + + // 3) 未发现层内部边最后挂,优先级低于升级边 + unaware.Link(b); + } + + static bool Contains(IReadOnlyList list, string value) + { + for (int i = 0; i < list.Count; i++) + if (list[i] == value) return true; + return false; + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs.meta new file mode 100644 index 00000000..758010cc --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 105259e9e0091134fb5afb8c0648ef45 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: