diff --git a/Assets/Tests/EditMode/AI/AiRuntimeTests.cs b/Assets/Tests/EditMode/AI/AiRuntimeTests.cs new file mode 100644 index 00000000..e00fdaaa --- /dev/null +++ b/Assets/Tests/EditMode/AI/AiRuntimeTests.cs @@ -0,0 +1,93 @@ +using NUnit.Framework; +using BaseGames.AI; + +namespace BaseGames.Tests.EditMode.AI +{ + public class AiRuntimeTests + { + static AiGraph Graph() + { + var b = new BrainBuilder(); + b.Entry("Patrol"); + b.Global().To("Dead").OnEvent(AiSignal.Died); + b.State("Patrol") + .Tick(c => c.Mover.WalkRandom()) + .To("Chase").When(c => c.Sensor.SeesPlayer(), "SeesPlayer"); + b.State("Chase") + .OnEnter(c => c.Mover.FacePlayer()) + .Tick(c => c.Mover.MoveTo(c.Sensor.LastKnown)) + .To("Search").When(c => c.Sensor.LostFor(2f), "LostFor(2s)"); + b.State("Search") + .To("Patrol").After(3f); + b.State("Dead"); + return b.Build(); + } + + [Test] + public void StartsAtEntry() + { + var rt = new AiRuntime(Graph(), new FakeAiContext()); + Assert.AreEqual("Patrol", rt.CurrentStateName); + } + + [Test] + public void Tick_RunsCurrentStateTickAction() + { + var ctx = new FakeAiContext(); + var rt = new AiRuntime(Graph(), ctx); + rt.Tick(0.1f); + CollectionAssert.Contains(ctx.M.Calls, "WalkRandom"); + } + + [Test] + public void ConditionTransition_FiresAndRunsEnterExit() + { + var ctx = new FakeAiContext(); + var rt = new AiRuntime(Graph(), ctx); + ctx.S.Sees = true; + rt.Tick(0.1f); + Assert.AreEqual("Chase", rt.CurrentStateName); + CollectionAssert.Contains(ctx.M.Calls, "FacePlayer"); // Chase.OnEnter + } + + [Test] + public void Gate_SuspendsDecisionWhenNotControllable() + { + var ctx = new FakeAiContext(); + var rt = new AiRuntime(Graph(), ctx); + ctx.S.Sees = true; + ctx.V.Controllable = false; // 受击中 + rt.Tick(0.1f); + Assert.AreEqual("Patrol", rt.CurrentStateName); // 未转换 + Assert.IsTrue(rt.IsSuspended); + CollectionAssert.DoesNotContain(ctx.M.Calls, "WalkRandom"); // Tick 也不跑 + } + + [Test] + public void Gate_ResumesAndReevaluatesWhenControllable() + { + var ctx = new FakeAiContext(); + var rt = new AiRuntime(Graph(), ctx); + ctx.S.Sees = true; + ctx.V.Controllable = false; + rt.Tick(0.1f); + ctx.V.Controllable = true; // 恢复 + rt.Tick(0.1f); + Assert.AreEqual("Chase", rt.CurrentStateName); + } + + [Test] + public void After_UsesStateTimer() + { + var ctx = new FakeAiContext(); + var rt = new AiRuntime(Graph(), ctx); + ctx.S.Sees = true; rt.Tick(0.1f); // -> Chase + ctx.S.Sees = false; ctx.S.LostForValue = 5f; rt.Tick(0.1f); // -> Search + Assert.AreEqual("Search", rt.CurrentStateName); + rt.Tick(1f); rt.Tick(1f); + Assert.AreEqual("Search", rt.CurrentStateName); // 未满 3s + rt.Tick(1.5f); + Assert.AreEqual("Patrol", rt.CurrentStateName); // 累计 >3s + } + } +} diff --git a/Assets/Tests/EditMode/AI/AiRuntimeTests.cs.meta b/Assets/Tests/EditMode/AI/AiRuntimeTests.cs.meta new file mode 100644 index 00000000..c1523744 --- /dev/null +++ b/Assets/Tests/EditMode/AI/AiRuntimeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf8b988fe76512c45aa8792dbbfc0ff6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/AI/Fakes.meta b/Assets/Tests/EditMode/AI/Fakes.meta new file mode 100644 index 00000000..5311efce --- /dev/null +++ b/Assets/Tests/EditMode/AI/Fakes.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 95728961a728dc349b73ada5def60ffa +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs b/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs new file mode 100644 index 00000000..b47a5618 --- /dev/null +++ b/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using UnityEngine; +using BaseGames.AI; + +namespace BaseGames.Tests.EditMode.AI +{ + public sealed class FakeSensor : ISensor + { + public bool Sees; public float LostForValue; public Vector2 Last; + public bool SeesPlayer() => Sees; + public bool InRange(float range) => Sees; // 简化:可见即在范围 + public bool LostFor(float seconds) => LostForValue >= seconds; + public Vector2 LastKnown => Last; + } + + public sealed class FakeMover : IMover + { + public List Calls = new List(); + public void MoveTo(Vector2 t) => Calls.Add("MoveTo"); + public void FacePlayer() => Calls.Add("FacePlayer"); + public void Stop() => Calls.Add("Stop"); + public void WalkRandom() => Calls.Add("WalkRandom"); + public void LookAround() => Calls.Add("LookAround"); + } + + public sealed class FakeCombat : ICombatant + { + public string Running; public List Used = new List(); + public bool UseAbility(string id) { Used.Add(id); Running = id; return true; } + public bool IsAbilityRunning(string id = null) => id == null ? Running != null : Running == id; + public bool NoAbilityRunning => Running == null; + public bool CanUseAbility(string id) => true; + } + + public sealed class FakeVitals : IActorVitals + { + public bool Alive = true; public bool Controllable = true; public float Hp = 1f; + public bool IsAlive => Alive; + public bool IsControllable => Controllable; + public float HpPercent => Hp; + public bool HpBelow(float ratio) => Hp < ratio; + } + + public sealed class FakeAiContext : IAiContext + { + public FakeSensor S = new FakeSensor(); + public FakeMover M = new FakeMover(); + public FakeCombat C = new FakeCombat(); + public FakeVitals V = new FakeVitals(); + public Blackboard BB = new Blackboard(); + public ISensor Sensor => S; + public IMover Mover => M; + public ICombatant Combat => C; + public IActorVitals Vitals => V; + public Blackboard Blackboard => BB; + } +} diff --git a/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs.meta b/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs.meta new file mode 100644 index 00000000..3f25f9e2 --- /dev/null +++ b/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7ec131e6cb78fbb48bb85f7ebdcf9da7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/AI/AiRuntime.cs b/Assets/_Game/Scripts/AI/AiRuntime.cs new file mode 100644 index 00000000..f4c822ea --- /dev/null +++ b/Assets/_Game/Scripts/AI/AiRuntime.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; + +namespace BaseGames.AI +{ + /// + /// 每敌人实例一份的轻量执行器。持有当前状态、状态计时器、trace。 + /// 共享的 AiGraph 不持有任何实例态。 + /// + public sealed class AiRuntime + { + readonly AiGraph _graph; + readonly IAiContext _ctx; + AiState _current; + float _timeInState; + + readonly Queue _pending = new Queue(); + readonly List _trace = new List(); + const int TraceCap = 16; + + public AiRuntime(AiGraph graph, IAiContext ctx) + { + _graph = graph; + _ctx = ctx; + EnterInitial(); + } + + public string CurrentStateName => _current.Name; + public bool IsSuspended { get; private set; } + public IReadOnlyList Trace => _trace; + public float TimeInStateValue => _timeInState; + + void EnterInitial() + { + _current = _graph.GetState(_graph.EntryState); + _timeInState = 0f; + _current.OnEnter?.Invoke(_ctx); + } + + /// 供 BrainBuilder.After() 通过 context 读取当前 runtime 的状态计时器。 + static readonly System.Runtime.CompilerServices.ConditionalWeakTable _byContext + = new System.Runtime.CompilerServices.ConditionalWeakTable(); + internal static float TimeInState(IAiContext ctx) + => _byContext.TryGetValue(ctx, out var rt) ? rt._timeInState : 0f; + + public void Tick(float dt) + { + _byContext.Remove(_ctx); + _byContext.Add(_ctx, this); + + // IsControllable 让位门:受击/硬直/击飞时挂起,不推进决策也不跑 Tick。 + if (!_ctx.Vitals.IsControllable) + { + IsSuspended = true; + return; + } + IsSuspended = false; + + _timeInState += dt; + + if (TryTransition()) + return; // 本帧发生转换,新状态下一帧再 Tick + + _current.OnTick?.Invoke(_ctx, dt); + } + + bool TryTransition() + { + // 全局/父层转换优先(本任务只评估条件型;事件型在 Task 6 处理) + foreach (var t in _graph.GlobalTransitions) + if (!t.IsEvent && t.Condition(_ctx)) + return Switch(t); + + foreach (var t in _current.Transitions) + if (!t.IsEvent && t.Condition(_ctx)) + return Switch(t); + + return false; + } + + bool Switch(Transition t) + { + var from = _current.Name; + _current.OnExit?.Invoke(_ctx); + _current = _graph.GetState(t.Target); + _timeInState = 0f; + RecordTrace(new TransitionRecord(from, t.Target, t.Label)); + _current.OnEnter?.Invoke(_ctx); + return true; + } + + void RecordTrace(TransitionRecord r) + { + _trace.Add(r); + if (_trace.Count > TraceCap) _trace.RemoveAt(0); + } + + /// 对象池复用时重置到初始态,清空所有实例态。 + public void Reset() + { + _pending.Clear(); + _trace.Clear(); + _ctx.Blackboard.Clear(); + IsSuspended = false; + EnterInitial(); + } + } +} diff --git a/Assets/_Game/Scripts/AI/AiRuntime.cs.meta b/Assets/_Game/Scripts/AI/AiRuntime.cs.meta new file mode 100644 index 00000000..b6ba09fe --- /dev/null +++ b/Assets/_Game/Scripts/AI/AiRuntime.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ee06ec9754397a84289448f7c76e384a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/AI/BrainBuilder.cs b/Assets/_Game/Scripts/AI/BrainBuilder.cs index 04269e24..6b754ed6 100644 --- a/Assets/_Game/Scripts/AI/BrainBuilder.cs +++ b/Assets/_Game/Scripts/AI/BrainBuilder.cs @@ -89,11 +89,12 @@ namespace BaseGames.AI return _stateBuilder; } - /// 在本状态停留 seconds 秒后转换(读 runtime 的状态计时器)。Task 5 回填实现。 + /// 在本状态停留 seconds 秒后转换(读 runtime 的状态计时器)。 public StateBuilder After(float seconds) { - // 依赖 AiRuntime.TimeInState(Task 5 定义)。本任务先临时抛异常以通过编译,Task 5 回填正式实现。 - throw new NotImplementedException("After 将在 Task 5 回填(依赖 AiRuntime 状态计时器)。"); + _state.AddTransition(Transition.OnCondition( + _target, c => AiRuntime.TimeInState(c) >= seconds, $"after {seconds}s")); + return _stateBuilder; } }