From d01df7b5f08806b636b39854a058bc7c1c8c249d Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 3 Jul 2026 14:23:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20AiRuntime=20=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2(push,=E4=B8=8D=E5=8F=97=E9=97=A8=E9=99=90?= =?UTF-8?q?=E5=88=B6)+trace=E8=A7=A6=E5=8F=91=E5=8E=9F=E5=9B=A0+=E6=B1=A0?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Tests/EditMode/AI/AiRuntimeTests.cs | 48 ++++++++++++++++++++++ Assets/_Game/Scripts/AI/AiRuntime.cs | 36 +++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/Assets/Tests/EditMode/AI/AiRuntimeTests.cs b/Assets/Tests/EditMode/AI/AiRuntimeTests.cs index b0348569..9748d14d 100644 --- a/Assets/Tests/EditMode/AI/AiRuntimeTests.cs +++ b/Assets/Tests/EditMode/AI/AiRuntimeTests.cs @@ -111,5 +111,53 @@ namespace BaseGames.Tests.EditMode.AI var rt = new AiRuntime(b.Build(), ctx); CollectionAssert.Contains(ctx.M.Calls, "LookAround"); } + + [Test] + public void EventTransition_FiresOnSignal_EvenBeforeTick() + { + var ctx = new FakeAiContext(); + var rt = new AiRuntime(Graph(), ctx); + rt.Send(AiSignal.Died); + rt.Tick(0.1f); + Assert.AreEqual("Dead", rt.CurrentStateName); + } + + [Test] + public void EventTransition_FiresEvenWhenNotControllable() + { + // 死亡等事件不受让位门限制(门只挡决策推进/条件评估,不挡事件) + var ctx = new FakeAiContext(); + var rt = new AiRuntime(Graph(), ctx); + ctx.V.Controllable = false; + rt.Send(AiSignal.Died); + rt.Tick(0.1f); + Assert.AreEqual("Dead", rt.CurrentStateName); + } + + [Test] + public void Trace_RecordsTriggerReason() + { + var ctx = new FakeAiContext(); + var rt = new AiRuntime(Graph(), ctx); + ctx.S.Sees = true; + rt.Tick(0.1f); + Assert.AreEqual(1, rt.Trace.Count); + Assert.AreEqual("Patrol", rt.Trace[0].From); + Assert.AreEqual("Chase", rt.Trace[0].To); + StringAssert.Contains("SeesPlayer", rt.Trace[0].Trigger); + } + + [Test] + public void Reset_ReturnsToEntryAndClearsState() + { + var ctx = new FakeAiContext(); + var rt = new AiRuntime(Graph(), ctx); + ctx.S.Sees = true; rt.Tick(0.1f); // -> Chase + ctx.BB.Set("scratch", 42); + rt.Reset(); + Assert.AreEqual("Patrol", rt.CurrentStateName); + Assert.IsEmpty(rt.Trace); + Assert.IsFalse(ctx.BB.Has("scratch")); + } } } diff --git a/Assets/_Game/Scripts/AI/AiRuntime.cs b/Assets/_Game/Scripts/AI/AiRuntime.cs index 5a15df5f..c767c320 100644 --- a/Assets/_Game/Scripts/AI/AiRuntime.cs +++ b/Assets/_Game/Scripts/AI/AiRuntime.cs @@ -44,9 +44,20 @@ namespace BaseGames.AI internal static float TimeInState(IAiContext ctx) => _byContext.TryGetValue(ctx, out var rt) ? rt._timeInState : 0f; + /// 推送一个信号,用于事件驱动转换(push)。 + public void Send(AiSignal signal) => _pending.Enqueue(signal); + public void Tick(float dt) { - // IsControllable 让位门:受击/硬直/击飞时挂起,不推进决策也不跑 Tick。 + // 1) 事件转换:push,最高优先,且不受 IsControllable 门限制 + while (_pending.Count > 0) + { + var sig = _pending.Dequeue(); + if (TryEventTransition(sig)) + return; // 事件触发转换后本帧结束 + } + + // 2) IsControllable 让位门:受击/硬直/击飞时挂起,不推进决策也不跑 Tick。 if (!_ctx.Vitals.IsControllable) { IsSuspended = true; @@ -54,6 +65,7 @@ namespace BaseGames.AI } IsSuspended = false; + // 3) 条件转换 _timeInState += dt; if (TryTransition()) @@ -64,7 +76,7 @@ namespace BaseGames.AI bool TryTransition() { - // 全局/父层转换优先(本任务只评估条件型;事件型在 Task 6 处理) + // 全局/父层转换优先(只评估条件型;事件型由 TryEventTransition 处理) var globals = _graph.GlobalTransitions; for (int i = 0; i < globals.Count; i++) { @@ -84,6 +96,26 @@ namespace BaseGames.AI return false; } + bool TryEventTransition(AiSignal sig) + { + var globals = _graph.GlobalTransitions; + for (int i = 0; i < globals.Count; i++) + { + var t = globals[i]; + if (t.IsEvent && t.Event.Value == sig) + return Switch(t); + } + + var locals = _current.Transitions; + for (int i = 0; i < locals.Count; i++) + { + var t = locals[i]; + if (t.IsEvent && t.Event.Value == sig) + return Switch(t); + } + return false; + } + bool Switch(Transition t) { var from = _current.Name;