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;