From 6f9fa8c4b8d2ed447f72a9555af724b208f9b09b Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 3 Jul 2026 14:04:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20BrainGraph=20AiGraph=20+=20BrainBui?= =?UTF-8?q?lder(fluent,=20=E6=98=BE=E5=BC=8F=E6=A0=87=E7=AD=BE,=20?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E6=A0=A1=E9=AA=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Tests/EditMode/AI/BrainBuilderTests.cs | 73 +++++++++++ .../EditMode/AI/BrainBuilderTests.cs.meta | 11 ++ Assets/_Game/Scripts/AI/AiGraph.cs | 27 ++++ Assets/_Game/Scripts/AI/AiGraph.cs.meta | 11 ++ Assets/_Game/Scripts/AI/AiScript.cs | 35 +++++ Assets/_Game/Scripts/AI/AiScript.cs.meta | 11 ++ Assets/_Game/Scripts/AI/BrainBuilder.cs | 122 ++++++++++++++++++ Assets/_Game/Scripts/AI/BrainBuilder.cs.meta | 11 ++ 8 files changed, 301 insertions(+) create mode 100644 Assets/Tests/EditMode/AI/BrainBuilderTests.cs create mode 100644 Assets/Tests/EditMode/AI/BrainBuilderTests.cs.meta create mode 100644 Assets/_Game/Scripts/AI/AiGraph.cs create mode 100644 Assets/_Game/Scripts/AI/AiGraph.cs.meta create mode 100644 Assets/_Game/Scripts/AI/AiScript.cs create mode 100644 Assets/_Game/Scripts/AI/AiScript.cs.meta create mode 100644 Assets/_Game/Scripts/AI/BrainBuilder.cs create mode 100644 Assets/_Game/Scripts/AI/BrainBuilder.cs.meta diff --git a/Assets/Tests/EditMode/AI/BrainBuilderTests.cs b/Assets/Tests/EditMode/AI/BrainBuilderTests.cs new file mode 100644 index 00000000..aa68f2da --- /dev/null +++ b/Assets/Tests/EditMode/AI/BrainBuilderTests.cs @@ -0,0 +1,73 @@ +using NUnit.Framework; +using BaseGames.AI; + +namespace BaseGames.Tests.EditMode.AI +{ + public class BrainBuilderTests + { + static AiGraph BuildSample() + { + var b = new BrainBuilder(); + b.Entry("Patrol"); + b.Global().To("Dead").OnEvent(AiSignal.Died); + b.State("Patrol") + .To("Chase").When(c => c.Sensor.SeesPlayer(), "SeesPlayer"); + b.State("Chase") + .To("Patrol").When(c => c.Sensor.LostFor(2f), "LostFor(2s)"); + b.State("Dead"); + return b.Build(); + } + + [Test] + public void Build_SetsEntryState() + { + Assert.AreEqual("Patrol", BuildSample().EntryState); + } + + [Test] + public void Build_RegistersAllStates() + { + var g = BuildSample(); + CollectionAssert.AreEquivalent( + new[] { "Patrol", "Chase", "Dead" }, + System.Linq.Enumerable.ToList(g.StateNames)); + } + + [Test] + public void Build_UsesExplicitConditionLabel() + { + var g = BuildSample(); + var t = g.GetState("Patrol").Transitions[0]; + Assert.AreEqual("SeesPlayer", t.Label); + } + + [Test] + public void Build_UnlabeledCondition_FallsBackToCond() + { + var b = new BrainBuilder(); + b.Entry("A"); + b.State("A").To("B").When(c => true); + b.State("B"); + var g = b.Build(); + Assert.AreEqual("cond", g.GetState("A").Transitions[0].Label); + } + + [Test] + public void Build_GlobalTransitionsAreSeparate() + { + var g = BuildSample(); + Assert.AreEqual(1, g.GlobalTransitions.Count); + Assert.AreEqual("Dead", g.GlobalTransitions[0].Target); + Assert.AreEqual(AiSignal.Died, g.GlobalTransitions[0].Event); + } + + [Test] + public void Build_UnknownTransitionTarget_Throws() + { + var b = new BrainBuilder(); + b.Entry("A"); + b.State("A").To("Nonexistent").When(c => true); + Assert.Throws(() => b.Build()); + } + } +} diff --git a/Assets/Tests/EditMode/AI/BrainBuilderTests.cs.meta b/Assets/Tests/EditMode/AI/BrainBuilderTests.cs.meta new file mode 100644 index 00000000..c60b3426 --- /dev/null +++ b/Assets/Tests/EditMode/AI/BrainBuilderTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0dd4a9e62b5f3614cbf783a420b80ee8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/AI/AiGraph.cs b/Assets/_Game/Scripts/AI/AiGraph.cs new file mode 100644 index 00000000..88b19182 --- /dev/null +++ b/Assets/_Game/Scripts/AI/AiGraph.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace BaseGames.AI +{ + /// + /// 不可变状态图,每敌人类型构建一次、所有实例共享(flyweight)。 + /// 既被 AiRuntime 执行,又被导出器/调试器只读投影。 + /// + public sealed class AiGraph + { + readonly Dictionary _states; + public string EntryState { get; } + public IReadOnlyList GlobalTransitions { get; } + + public AiGraph(string entry, Dictionary states, IReadOnlyList globals) + { + EntryState = entry; + _states = states; + GlobalTransitions = globals; + } + + public AiState GetState(string name) => _states[name]; + public bool HasState(string name) => _states.ContainsKey(name); + public IEnumerable StateNames => _states.Keys; + public IEnumerable States => _states.Values; + } +} diff --git a/Assets/_Game/Scripts/AI/AiGraph.cs.meta b/Assets/_Game/Scripts/AI/AiGraph.cs.meta new file mode 100644 index 00000000..93607aea --- /dev/null +++ b/Assets/_Game/Scripts/AI/AiGraph.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5334a83a79910ba41a8e8892c4df95f9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/AI/AiScript.cs b/Assets/_Game/Scripts/AI/AiScript.cs new file mode 100644 index 00000000..e5632de8 --- /dev/null +++ b/Assets/_Game/Scripts/AI/AiScript.cs @@ -0,0 +1,35 @@ +using System; + +namespace BaseGames.AI +{ + /// 标注一个 AiScript 对应的敌人类型 id,供 AiDefinitionRegistry 反射收集(第 3 阶段)。 + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class AiDefinitionAttribute : Attribute + { + public string Id { get; } + public AiDefinitionAttribute(string id) => Id = id; + } + + /// + /// 一种敌人 AI 的声明基类(唯一事实来源)。子类实现 Build 用 fluent 描述状态机。 + /// 构建结果 AiGraph 每类型只建一次、实例共享(flyweight)。 + /// + public abstract class AiScript + { + AiGraph _cached; + + /// 惰性构建并缓存共享 AiGraph。 + public AiGraph GetOrBuildGraph() + { + if (_cached == null) + { + var b = new BrainBuilder(); + Build(b); + _cached = b.Build(); + } + return _cached; + } + + protected abstract void Build(BrainBuilder b); + } +} diff --git a/Assets/_Game/Scripts/AI/AiScript.cs.meta b/Assets/_Game/Scripts/AI/AiScript.cs.meta new file mode 100644 index 00000000..3971dcdc --- /dev/null +++ b/Assets/_Game/Scripts/AI/AiScript.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5c42a6f10cf2ed540a00bea550640b6c +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 new file mode 100644 index 00000000..0bc00d36 --- /dev/null +++ b/Assets/_Game/Scripts/AI/BrainBuilder.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; + +namespace BaseGames.AI +{ + /// fluent builder:声明层的唯一入口。构建出不可变 AiGraph。 + public sealed class BrainBuilder + { + readonly Dictionary _states = new Dictionary(); + readonly List _globals = new List(); + string _entry; + + public BrainBuilder Entry(string stateName) + { + _entry = stateName; + return this; + } + + public StateBuilder State(string name) + { + if (!_states.TryGetValue(name, out var s)) + { + s = new AiState(name); + _states[name] = s; + } + return new StateBuilder(this, s); + } + + public GlobalBuilder Global() => new GlobalBuilder(this); + + internal void AddGlobal(Transition t) => _globals.Add(t); + + public AiGraph Build() + { + if (string.IsNullOrEmpty(_entry)) + throw new InvalidOperationException("BrainBuilder: 未设置 Entry 状态。"); + if (!_states.ContainsKey(_entry)) + throw new InvalidOperationException($"BrainBuilder: Entry 状态 '{_entry}' 未声明。"); + + foreach (var s in _states.Values) + foreach (var t in s.Transitions) + if (!_states.ContainsKey(t.Target)) + throw new InvalidOperationException( + $"BrainBuilder: 状态 '{s.Name}' 的转换指向未声明状态 '{t.Target}'。"); + foreach (var t in _globals) + if (!_states.ContainsKey(t.Target)) + throw new InvalidOperationException( + $"BrainBuilder: 全局转换指向未声明状态 '{t.Target}'。"); + + return new AiGraph(_entry, _states, _globals); + } + + public sealed class StateBuilder + { + readonly BrainBuilder _owner; + readonly AiState _state; + internal StateBuilder(BrainBuilder owner, AiState state) { _owner = owner; _state = state; } + + public StateBuilder OnEnter(Action fn) { _state.OnEnter = fn; return this; } + public StateBuilder Tick(Action fn) { _state.OnTick = (c, _) => fn(c); return this; } + public StateBuilder Tick(Action fn) { _state.OnTick = fn; return this; } + public StateBuilder OnExit(Action fn) { _state.OnExit = fn; return this; } + + public TransitionBuilder To(string target) => new TransitionBuilder(_owner, this, _state, target); + } + + public sealed class TransitionBuilder + { + readonly BrainBuilder _owner; + readonly StateBuilder _stateBuilder; + readonly AiState _state; + readonly string _target; + internal TransitionBuilder(BrainBuilder owner, StateBuilder sb, AiState state, string target) + { _owner = owner; _stateBuilder = sb; _state = state; _target = target; } + + /// + /// 条件转换。label 为可读标签(用于 Mermaid 边 / trace 触发原因), + /// C# 9 环境下需显式传入;不传则回退为 "cond"。 + /// + public StateBuilder When(Func cond, string label = null) + { + _state.Transitions.Add(Transition.OnCondition(_target, cond, label ?? "cond")); + return _stateBuilder; + } + + /// 事件转换。 + public StateBuilder OnEvent(AiSignal evt) + { + _state.Transitions.Add(Transition.OnEvent(_target, evt)); + return _stateBuilder; + } + + /// 在本状态停留 seconds 秒后转换(读 runtime 的状态计时器)。Task 5 回填实现。 + public StateBuilder After(float seconds) + { + // 依赖 AiRuntime.TimeInState(Task 5 定义)。本任务先临时抛异常以通过编译,Task 5 回填正式实现。 + throw new NotImplementedException("After 将在 Task 5 回填(依赖 AiRuntime 状态计时器)。"); + } + } + + public sealed class GlobalBuilder + { + readonly BrainBuilder _owner; + string _pendingTarget; + internal GlobalBuilder(BrainBuilder owner) { _owner = owner; } + + public GlobalBuilder To(string target) { _pendingTarget = target; return this; } + + public GlobalBuilder OnEvent(AiSignal evt) + { + _owner.AddGlobal(Transition.OnEvent(_pendingTarget, evt)); + return this; + } + + public GlobalBuilder When(Func cond, string label = null) + { + _owner.AddGlobal(Transition.OnCondition(_pendingTarget, cond, label ?? "cond")); + return this; + } + } + } +} diff --git a/Assets/_Game/Scripts/AI/BrainBuilder.cs.meta b/Assets/_Game/Scripts/AI/BrainBuilder.cs.meta new file mode 100644 index 00000000..defe001b --- /dev/null +++ b/Assets/_Game/Scripts/AI/BrainBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 52f0da4a028419747ab6d48abe5aa786 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: