Files
zeling_v2/Docs_Dev/superpowers/plans/2026-07-03-braingraph-phase1-core-runtime.md
T
2026-07-21 10:22:43 +08:00

46 KiB
Raw Blame History

BrainGraph 第 1 阶段:核心运行时 实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 实现 BrainGraph 决策框架的纯 C# 核心运行时(不可变状态图 + fluent builder + 每实例运行时 + 条件/事件转换 + IsControllable 让位门 + trace + 池复用重置),全部用 EditMode 单测覆盖,零 Unity 场景依赖。

Architecture: HFSM 骨干。声明层(AiScript.Build)用 BrainBuilder 构建成不可变共享 AiGraph(每敌人类型一份 flyweight);每个敌人实例持有一个轻量 AiRuntime,通过 IAiContext(暴露 ISensor/IMover/ICombatant/IActorVitals/Blackboard 的能力接口)驱动真实子系统。核心层只依赖 BaseGames.Core + UnityEngine(用 Vector2 等值类型),用假的能力接口即可完整单测。

Tech Stack: Unity 2022+, C#, Unity Test Framework (NUnit, EditMode), asmdef。

Spec: Docs/superpowers/specs/2026-07-03-enemy-ai-framework-design.md(本阶段覆盖其 §3.1/§3.2/§3.3 的条件+事件转换、§3.6 IsControllable 门、§3.8 池复用重置;§3.4 能力编排 sugar、§3.7 AiScheduler、§4 完整接口方法集、§6 交付面、§7 组件与适配器留待后续阶段)。


文件结构(本阶段新建)

新程序集 BaseGames.AI,位于 Assets/_Game/Scripts/AI/

  • BaseGames.AI.asmdef — 新程序集,仅引用 BaseGames.Core
  • AiSignal.cs — 推送信号枚举。
  • IAiContext.cs — 上下文接口(聚合能力接口 + 黑板)。
  • ISensor.cs / IMover.cs / ICombatant.cs / IActorVitals.cs — 能力接口(本阶段只声明被测试消费的最小成员,完整成员集在第 3 阶段接适配器时按需补齐)。
  • Blackboard.cs — 具名、可枚举的 AI 临时值容器。
  • Transition.cs — 一条转换(条件型 / 事件型 + 可读标签 + 优先级)。
  • AiState.cs — 一个状态(OnEnter/Tick/OnExit + 出边转换列表)。
  • AiGraph.cs — 不可变状态图(状态字典 + 入口 + 全局/父层转换)。
  • BrainBuilder.cs — fluent builder,含 StateBuilder / TransitionBuilder / GlobalBuilder
  • AiScript.cs — 抽象声明基类 + AiDefinitionAttribute
  • TransitionRecord.cs — trace 记录(from/to/trigger)。
  • AiRuntime.cs — 每实例执行器(Tick、转换评估、门、事件队列、trace、Reset)。

测试(Assets/Tests/EditMode/AI/):

  • BlackboardTests.csBrainBuilderTests.csAiRuntimeTests.csFakes/FakeAiContext.cs
  • 修改 Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef 增加 BaseGames.AI 引用。

命名/注释不得出现任何参考游戏名(CLAUDE.md 第 4 条)。


Task 1: 新建 BaseGames.AI 程序集与基础类型

Files:

  • Create: Assets/_Game/Scripts/AI/BaseGames.AI.asmdef

  • Create: Assets/_Game/Scripts/AI/AiSignal.cs

  • Create: Assets/_Game/Scripts/AI/Blackboard.cs

  • Create: Assets/Tests/EditMode/AI/BlackboardTests.cs

  • Modify: Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef

  • Step 1: 创建程序集定义

Assets/_Game/Scripts/AI/BaseGames.AI.asmdef

{
  "name": "BaseGames.AI",
  "rootNamespace": "BaseGames.AI",
  "references": [
    "BaseGames.Core"
  ],
  "includePlatforms": [],
  "excludePlatforms": [],
  "allowUnsafeCode": false,
  "overrideReferences": false,
  "precompiledReferences": [],
  "autoReferenced": true,
  "defineConstraints": [],
  "versionDefines": [],
  "noEngineReferences": false
}
  • Step 2: 创建 AiSignal 枚举

Assets/_Game/Scripts/AI/AiSignal.cs

namespace BaseGames.AI
{
    /// <summary>决策层的推送信号,用于事件驱动的状态转换(不靠轮询)。</summary>
    public enum AiSignal
    {
        Damaged,      // 受到伤害
        Parried,      // 被弹反
        Staggered,    // 进入硬直
        KnockedUp,    // 被击飞
        Died,         // 死亡
        PlayerSpotted,// 首次侦测到玩家
        AbilityEnded, // 当前能力协程结束/被打断
        PhaseChanged  // Boss 阶段变更
    }
}
  • Step 3: 在测试程序集里加 BaseGames.AI 引用

修改 Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdefreferences 数组,加入一行 "BaseGames.AI"(放在 "BaseGames.Core" 之后):

  "references": [
    "BaseGames.Combat.StatusEffects",
    "BaseGames.Combat",
    "BaseGames.Core",
    "BaseGames.AI",
    "BaseGames.Core.Events",
    "BaseGames.Core.Save",
    "BaseGames.EventChain",
    "UnityEngine.TestRunner",
    "UnityEditor.TestRunner"
  ],
  • Step 4: 写失败测试 —— Blackboard 读写与枚举

Assets/Tests/EditMode/AI/BlackboardTests.cs

using NUnit.Framework;
using UnityEngine;
using BaseGames.AI;

namespace BaseGames.Tests.EditMode.AI
{
    public class BlackboardTests
    {
        [Test]
        public void SetThenGet_ReturnsValue()
        {
            var bb = new Blackboard();
            bb.Set("lastKnown", new Vector2(3f, 4f));
            Assert.AreEqual(new Vector2(3f, 4f), bb.Get<Vector2>("lastKnown"));
        }

        [Test]
        public void Get_MissingKey_ReturnsDefault()
        {
            var bb = new Blackboard();
            Assert.AreEqual(0f, bb.Get<float>("nope"));
        }

        [Test]
        public void Keys_EnumeratesAllSetKeys()
        {
            var bb = new Blackboard();
            bb.Set("a", 1);
            bb.Set("b", 2);
            CollectionAssert.AreEquivalent(new[] { "a", "b" }, bb.Keys);
        }

        [Test]
        public void Clear_RemovesAll()
        {
            var bb = new Blackboard();
            bb.Set("a", 1);
            bb.Clear();
            CollectionAssert.IsEmpty(bb.Keys);
        }

        [Test]
        public void Snapshot_ReturnsKeyToStringMap_ForIntrospection()
        {
            var bb = new Blackboard();
            bb.Set("hp", 0.5f);
            var snap = bb.Snapshot();
            Assert.AreEqual("0.5", snap["hp"]);
        }
    }
}
  • Step 5: 运行测试确认失败

RunUnity Test RunnerEditMode,或 CLI):Unity -runTests -testPlatform EditMode -testFilter BlackboardTests Expected: 编译失败 / FAIL —— Blackboard 未定义。

  • Step 6: 实现 Blackboard

Assets/_Game/Scripts/AI/Blackboard.cs

using System.Collections.Generic;
using System.Globalization;

namespace BaseGames.AI
{
    /// <summary>
    /// 具名、可枚举的 AI 临时值容器(每敌人实例一份)。
    /// 保留"具名"是为了让调试器/MCP 能通用枚举当前值(见 spec §3.5)。
    /// </summary>
    public sealed class Blackboard
    {
        readonly Dictionary<string, object> _values = new Dictionary<string, object>();

        public void Set<T>(string key, T value) => _values[key] = value;

        public T Get<T>(string key)
            => _values.TryGetValue(key, out var v) && v is T t ? t : default;

        public bool Has(string key) => _values.ContainsKey(key);

        public IReadOnlyCollection<string> Keys => _values.Keys;

        public void Clear() => _values.Clear();

        /// <summary>供调试器/MCP 自省:key → 值的字符串表示。</summary>
        public Dictionary<string, string> Snapshot()
        {
            var result = new Dictionary<string, string>(_values.Count);
            foreach (var kv in _values)
            {
                result[kv.Key] = kv.Value is System.IFormattable f
                    ? f.ToString(null, CultureInfo.InvariantCulture)
                    : kv.Value?.ToString() ?? "null";
            }
            return result;
        }
    }
}
  • Step 7: 运行测试确认通过

Run: EditMode BlackboardTests Expected: PASS5 项全绿)。

  • Step 8: 提交
git add Assets/_Game/Scripts/AI/BaseGames.AI.asmdef Assets/_Game/Scripts/AI/BaseGames.AI.asmdef.meta \
        Assets/_Game/Scripts/AI/AiSignal.cs Assets/_Game/Scripts/AI/AiSignal.cs.meta \
        Assets/_Game/Scripts/AI/Blackboard.cs Assets/_Game/Scripts/AI/Blackboard.cs.meta \
        Assets/Tests/EditMode/AI Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef
git commit -m "feat(ai): BrainGraph 核心程序集 + AiSignal + Blackboard(可自省)"

Task 2: 能力接口、上下文、AiScript 声明基类

Files:

  • Create: Assets/_Game/Scripts/AI/ISensor.cs
  • Create: Assets/_Game/Scripts/AI/IMover.cs
  • Create: Assets/_Game/Scripts/AI/ICombatant.cs
  • Create: Assets/_Game/Scripts/AI/IActorVitals.cs
  • Create: Assets/_Game/Scripts/AI/IAiContext.cs
  • Create: Assets/_Game/Scripts/AI/AiScript.cs

本阶段接口只声明测试消费到的最小成员(YAGNI)。第 3 阶段接适配器时,按 spec §4 的完整映射表补齐其余方法(SlotDetects/JumpTo/UseSkillWeighted/SetPoiseLevel 等)。

  • Step 1: ISensor

Assets/_Game/Scripts/AI/ISensor.cs

using UnityEngine;

namespace BaseGames.AI
{
    /// <summary>感知能力接口(第 3 阶段由 SensorAdapter 适配 PhysicsPerceptionSystem/ThreatAssessor)。</summary>
    public interface ISensor
    {
        bool SeesPlayer();
        bool InRange(float range);      // 内部用平方距离比较
        bool LostFor(float seconds);    // 丢失玩家已持续 seconds
        Vector2 LastKnown { get; }
    }
}
  • Step 2: IMover

Assets/_Game/Scripts/AI/IMover.cs

using UnityEngine;

namespace BaseGames.AI
{
    /// <summary>移动能力接口(第 3 阶段由 MoverAdapter 适配 IPathAgent/EnemyMovement)。</summary>
    public interface IMover
    {
        void MoveTo(Vector2 target);
        void FacePlayer();
        void Stop();
        void WalkRandom();
        void LookAround();
    }
}
  • Step 3: ICombatant

Assets/_Game/Scripts/AI/ICombatant.cs

namespace BaseGames.AI
{
    /// <summary>战斗/能力调度接口(第 3 阶段由 CombatantAdapter 适配 EnemyAbilityRegistry)。</summary>
    public interface ICombatant
    {
        bool UseAbility(string abilityId);
        bool IsAbilityRunning(string abilityId = null); // null = 任意能力
        bool NoAbilityRunning { get; }
        bool CanUseAbility(string abilityId);
    }
}
  • Step 4: IActorVitals

Assets/_Game/Scripts/AI/IActorVitals.cs

namespace BaseGames.AI
{
    /// <summary>生命体征/受控状态接口(第 3 阶段由 VitalsAdapter 适配 EnemyStats/EnemyBase)。</summary>
    public interface IActorVitals
    {
        bool IsAlive { get; }
        /// <summary>物理态是否为 Controlled——决策层仅在此为真时推进(IsControllable 让位门, spec §3.6)。</summary>
        bool IsControllable { get; }
        float HpPercent { get; }
        bool HpBelow(float ratio);
    }
}
  • Step 5: IAiContext

Assets/_Game/Scripts/AI/IAiContext.cs

namespace BaseGames.AI
{
    /// <summary>
    /// 传给所有状态回调 / 转换条件的上下文。声明层的 lambda 只能通过它访问实例,
    /// 不得闭包捕获具体敌人——这是 lambda 挂在共享 AiGraph 上的前提(spec §3.8)。
    /// </summary>
    public interface IAiContext
    {
        ISensor Sensor { get; }
        IMover Mover { get; }
        ICombatant Combat { get; }
        IActorVitals Vitals { get; }
        Blackboard Blackboard { get; }
    }
}
  • Step 6: AiScript 抽象基类 + 特性

Assets/_Game/Scripts/AI/AiScript.cs

using System;

namespace BaseGames.AI
{
    /// <summary>标注一个 AiScript 对应的敌人类型 id,供 AiDefinitionRegistry 反射收集(第 3 阶段)。</summary>
    [AttributeUsage(AttributeTargets.Class, Inherited = false)]
    public sealed class AiDefinitionAttribute : Attribute
    {
        public string Id { get; }
        public AiDefinitionAttribute(string id) => Id = id;
    }

    /// <summary>
    /// 一种敌人 AI 的声明基类(唯一事实来源)。子类实现 Build 用 fluent 描述状态机。
    /// 构建结果 AiGraph 每类型只建一次、实例共享(flyweight, spec §3.1)。
    /// </summary>
    public abstract class AiScript
    {
        AiGraph _cached;

        /// <summary>惰性构建并缓存共享 AiGraph。</summary>
        public AiGraph GetOrBuildGraph()
        {
            if (_cached == null)
            {
                var b = new BrainBuilder();
                Build(b);
                _cached = b.Build();
            }
            return _cached;
        }

        protected abstract void Build(BrainBuilder b);
    }
}
  • Step 7: 编译校验(无独立测试,类型将由后续任务的测试消费)

Run: 触发 Unity 编译(MCP unity_get_compilation_errors 或切到 Editor)。 Expected: 无编译错误(BrainBuilder/AiGraph 尚未定义会报错 —— 因此本步骤仅创建接口文件,AiScript.cs 依赖的 BrainBuilder/AiGraph 在 Task 3、4 定义;先不提交,与 Task 3、4 连续完成后一并编译)。

说明:AiScript.cs 引用了 Task 3/4 才定义的 BrainBuilder/AiGraph。执行时先完成 Step 1-5(接口,可独立编译),Step 6 的 AiScript.cs 可暂缓到 Task 4 之后再落,或与 Task 3、4 合并提交。TDD 顺序见下方 Task 3。

  • Step 8: 提交接口层
git add Assets/_Game/Scripts/AI/ISensor.cs Assets/_Game/Scripts/AI/IMover.cs \
        Assets/_Game/Scripts/AI/ICombatant.cs Assets/_Game/Scripts/AI/IActorVitals.cs \
        Assets/_Game/Scripts/AI/IAiContext.cs \
        Assets/_Game/Scripts/AI/*.meta
git commit -m "feat(ai): BrainGraph 能力接口(ISensor/IMover/ICombatant/IActorVitals)+IAiContext"

Task 3: TransitionAiState

Files:

  • Create: Assets/_Game/Scripts/AI/Transition.cs
  • Create: Assets/_Game/Scripts/AI/AiState.cs
  • Create: Assets/_Game/Scripts/AI/TransitionRecord.cs

这两个是数据结构(无独立行为测试),其行为由 Task 4builder)与 Task 5/6runtime)的测试覆盖。因此本任务只创建类型 + 编译校验,不单独写测试。

  • Step 1: Transition

Assets/_Game/Scripts/AI/Transition.cs

using System;

namespace BaseGames.AI
{
    /// <summary>一条转换:条件型(Condition 非空)或事件型(Event 非空)。二者互斥。</summary>
    public sealed class Transition
    {
        public string Target { get; }
        public Func<IAiContext, bool> Condition { get; }   // 条件型:pull,按 LOD 频率评估
        public AiSignal? Event { get; }                    // 事件型:push,收到信号即触发
        public string Label { get; }                        // 可读标签(条件源码文本 / 事件名)

        Transition(string target, Func<IAiContext, bool> condition, AiSignal? evt, string label)
        {
            Target = target;
            Condition = condition;
            Event = evt;
            Label = label;
        }

        public static Transition OnCondition(string target, Func<IAiContext, bool> condition, string label)
            => new Transition(target, condition, null, label);

        public static Transition OnEvent(string target, AiSignal evt)
            => new Transition(target, null, evt, "on " + evt);

        public bool IsEvent => Event.HasValue;
    }
}
  • Step 2: TransitionRecordtrace 用)

Assets/_Game/Scripts/AI/TransitionRecord.cs

namespace BaseGames.AI
{
    /// <summary>一次已发生的转换,供调试器/MCP 自省"最近为何转换"。</summary>
    public readonly struct TransitionRecord
    {
        public readonly string From;
        public readonly string To;
        public readonly string Trigger; // 条件标签或事件名

        public TransitionRecord(string from, string to, string trigger)
        {
            From = from;
            To = to;
            Trigger = trigger;
        }

        public override string ToString() => $"{From} -> {To} : {Trigger}";
    }
}
  • Step 3: AiState

Assets/_Game/Scripts/AI/AiState.cs

using System;
using System.Collections.Generic;

namespace BaseGames.AI
{
    /// <summary>一个状态:进入/每帧/退出钩子 + 出边转换列表(按优先级有序,首个命中即转)。</summary>
    public sealed class AiState
    {
        public string Name { get; }
        public Action<IAiContext> OnEnter { get; internal set; }
        public Action<IAiContext, float> OnTick { get; internal set; } // (ctx, dt)
        public Action<IAiContext> OnExit { get; internal set; }
        public List<Transition> Transitions { get; } = new List<Transition>();

        public AiState(string name) => Name = name;
    }
}
  • Step 4: 编译校验

Run: Unity 编译(unity_get_compilation_errors)。 Expected: 无新错误(这些类型自洽;AiScript.cs 仍缺 BrainBuilder/AiGraph → Task 4 补齐)。

  • Step 5: 提交
git add Assets/_Game/Scripts/AI/Transition.cs Assets/_Game/Scripts/AI/AiState.cs \
        Assets/_Game/Scripts/AI/TransitionRecord.cs Assets/_Game/Scripts/AI/*.meta
git commit -m "feat(ai): BrainGraph Transition/AiState/TransitionRecord 数据结构"

Task 4: AiGraphBrainBuilderfluent 声明)

Files:

  • Create: Assets/_Game/Scripts/AI/AiGraph.cs

  • Create: Assets/_Game/Scripts/AI/BrainBuilder.cs

  • Create: Assets/Tests/EditMode/AI/BrainBuilderTests.cs

  • Step 1: 写失败测试 —— builder 构建出正确的图结构

Assets/Tests/EditMode/AI/BrainBuilderTests.cs

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<System.InvalidOperationException>(() => b.Build());
        }
    }
}
  • Step 2: 运行测试确认失败

Run: EditMode BrainBuilderTests Expected: 编译失败 —— AiGraph/BrainBuilder 未定义。

  • Step 3: 实现 AiGraph

Assets/_Game/Scripts/AI/AiGraph.cs

using System.Collections.Generic;

namespace BaseGames.AI
{
    /// <summary>
    /// 不可变状态图,每敌人类型构建一次、所有实例共享(flyweight, spec §3.1/§3.8)。
    /// 既被 AiRuntime 执行,又被 MermaidExporter/调试器只读投影。
    /// </summary>
    public sealed class AiGraph
    {
        readonly Dictionary<string, AiState> _states;
        public string EntryState { get; }
        public IReadOnlyList<Transition> GlobalTransitions { get; }

        public AiGraph(string entry, Dictionary<string, AiState> states, IReadOnlyList<Transition> globals)
        {
            EntryState = entry;
            _states = states;
            GlobalTransitions = globals;
        }

        public AiState GetState(string name) => _states[name];
        public bool HasState(string name) => _states.ContainsKey(name);
        public IEnumerable<string> StateNames => _states.Keys;
        public IEnumerable<AiState> States => _states.Values;
    }
}
  • Step 4: 实现 BrainBuilder(含 StateBuilder/TransitionBuilder/GlobalBuilder

Assets/_Game/Scripts/AI/BrainBuilder.cs

using System;
using System.Collections.Generic;

namespace BaseGames.AI
{
    /// <summary>fluent builder:声明层的唯一入口。构建出不可变 AiGraph。</summary>
    public sealed class BrainBuilder
    {
        readonly Dictionary<string, AiState> _states = new Dictionary<string, AiState>();
        readonly List<Transition> _globals = new List<Transition>();
        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}' 未声明。");

            // 校验所有转换目标存在(根因暴露,不做下游兜底, CLAUDE.md 第 6 条)
            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);
        }

        // ---- 嵌套 builder ----

        public sealed class StateBuilder
        {
            readonly BrainBuilder _owner;
            readonly AiState _state;
            internal StateBuilder(BrainBuilder owner, AiState state) { _owner = owner; _state = state; }

            public StateBuilder OnEnter(Action<IAiContext> fn) { _state.OnEnter = fn; return this; }
            public StateBuilder Tick(Action<IAiContext> fn) { _state.OnTick = (c, _) => fn(c); return this; }
            public StateBuilder Tick(Action<IAiContext, float> fn) { _state.OnTick = fn; return this; }
            public StateBuilder OnExit(Action<IAiContext> 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; }

            /// <summary>
            /// 条件转换。label 为可读标签(用于 Mermaid 边 / trace 触发原因),
            /// C# 9 环境下需显式传入;不传则回退为 "cond"。
            /// (升级到 C# 10 后可改用 [CallerArgumentExpression] 自动抓取源码文本。)
            /// </summary>
            public StateBuilder When(Func<IAiContext, bool> cond, string label = null)
            {
                _state.Transitions.Add(Transition.OnCondition(_target, cond, label ?? "cond"));
                return _stateBuilder;
            }

            /// <summary>事件转换。</summary>
            public StateBuilder OnEvent(AiSignal evt)
            {
                _state.Transitions.Add(Transition.OnEvent(_target, evt));
                return _stateBuilder;
            }

            /// <summary>在本状态停留 seconds 秒后转换(读 runtime 的状态计时器)。</summary>
            public StateBuilder After(float seconds)
            {
                _state.Transitions.Add(Transition.OnCondition(
                    _target, c => AiRuntime.TimeInState(c) >= seconds, $"after {seconds}s"));
                return _stateBuilder;
            }
        }

        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<IAiContext, bool> cond, string label = null)
            {
                _owner.AddGlobal(Transition.OnCondition(_pendingTarget, cond, label ?? "cond"));
                return this;
            }
        }
    }
}

注:After() 引用了 AiRuntime.TimeInState(c)(Task 5 定义的静态辅助,通过 context 取当前 runtime 的状态计时器)。若执行到本任务时 AiRuntime 尚未定义,先临时把 After 的实现体改为 throw new NotImplementedException() 以通过编译,Task 5 完成后回填(回填是同一函数体,非占位)。测试 BrainBuilderTests 不覆盖 After,不受影响。

标签方案(C# 9 环境,Unity 2022.3:转换的可读标签走显式可选字符串When(cond, "SeesPlayer"))。声明时建议给关键条件转换传标签,使 Mermaid 边 / trace 触发原因可读;不传则回退为 "cond"不使用 [CallerArgumentExpression](那是 C# 10 特性,本项目 Unity 2022.3 用 C# 9,编译器不会填充)。将来升级语言级别后可无痛切换为自动抓取。

  • Step 5: 运行测试确认通过

Run: EditMode BrainBuilderTests Expected: PASS5 项)。若 After 临时抛异常,本测试不涉及,仍应全绿。

  • Step 6: 补落 AiScript.csTask 2 Step 6 暂缓的文件)并编译

此时 BrainBuilder/AiGraph 已存在,创建/确认 AiScript.cs(内容见 Task 2 Step 6),编译应通过。

  • Step 7: 提交
git add Assets/_Game/Scripts/AI/AiGraph.cs Assets/_Game/Scripts/AI/BrainBuilder.cs \
        Assets/_Game/Scripts/AI/AiScript.cs Assets/Tests/EditMode/AI/BrainBuilderTests.cs \
        Assets/_Game/Scripts/AI/*.meta Assets/Tests/EditMode/AI/*.meta
git commit -m "feat(ai): BrainGraph AiGraph + BrainBuilder(fluent, 条件源码标签, 目标校验)"

Task 5: AiRuntime —— Tick、条件转换、IsControllable 让位门、状态计时器

Files:

  • Create: Assets/_Game/Scripts/AI/AiRuntime.cs

  • Create: Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs

  • Create: Assets/Tests/EditMode/AI/AiRuntimeTests.cs

  • Step 1: 写测试替身 FakeAiContext

Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs

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<string> Calls = new List<string>();
        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<string> Used = new List<string>();
        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;
    }
}
  • Step 2: 写失败测试 —— Tick、条件转换、门、计时器

Assets/Tests/EditMode/AI/AiRuntimeTests.cs

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
        }
    }
}
  • Step 3: 运行测试确认失败

Run: EditMode AiRuntimeTests Expected: 编译失败 —— AiRuntime 未定义。

  • Step 4: 实现 AiRuntime(本任务先实现条件转换 + 门 + 计时器;事件转换在 Task 6 追加)

Assets/_Game/Scripts/AI/AiRuntime.cs

using System.Collections.Generic;

namespace BaseGames.AI
{
    /// <summary>
    /// 每敌人实例一份的轻量执行器(spec §3.2)。持有当前状态、状态计时器、trace。
    /// 共享的 AiGraph 不持有任何实例态。
    /// </summary>
    public sealed class AiRuntime
    {
        readonly AiGraph _graph;
        readonly IAiContext _ctx;
        AiState _current;
        float _timeInState;

        readonly Queue<AiSignal> _pending = new Queue<AiSignal>();
        readonly List<TransitionRecord> _trace = new List<TransitionRecord>();
        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<TransitionRecord> Trace => _trace;
        public float TimeInStateValue => _timeInState;

        void EnterInitial()
        {
            _current = _graph.GetState(_graph.EntryState);
            _timeInState = 0f;
            _current.OnEnter?.Invoke(_ctx);
        }

        /// <summary>供 BrainBuilder.After() 通过 context 读取当前 runtime 的状态计时器。</summary>
        static readonly System.Runtime.CompilerServices.ConditionalWeakTable<IAiContext, AiRuntime> _byContext
            = new System.Runtime.CompilerServices.ConditionalWeakTable<IAiContext, AiRuntime>();
        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 让位门(spec §3.6):受击/硬直/击飞时挂起,不推进决策也不跑 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);
        }

        /// <summary>对象池复用时重置到初始态,清空所有实例态(spec §3.8)。</summary>
        public void Reset()
        {
            _pending.Clear();
            _trace.Clear();
            _ctx.Blackboard.Clear();
            IsSuspended = false;
            EnterInitial();
        }
    }
}
  • Step 5: 回填 BrainBuilder.After(若 Task 4 曾临时抛异常)

确认 BrainBuilder.TransitionBuilder.After 的实现体为(Task 4 Step 4 所示的正式版):

_state.Transitions.Add(Transition.OnCondition(
    _target, c => AiRuntime.TimeInState(c) >= seconds, $"after {seconds}s"));
  • Step 6: 运行测试确认通过

Run: EditMode AiRuntimeTests Expected: PASS6 项:入口、Tick、条件转换、门挂起、门恢复重评估、After 计时器)。

  • Step 7: 提交
git add Assets/_Game/Scripts/AI/AiRuntime.cs Assets/_Game/Scripts/AI/BrainBuilder.cs \
        Assets/Tests/EditMode/AI/Fakes Assets/Tests/EditMode/AI/AiRuntimeTests.cs \
        Assets/_Game/Scripts/AI/*.meta Assets/Tests/EditMode/AI/*.meta Assets/Tests/EditMode/AI/Fakes/*.meta
git commit -m "feat(ai): AiRuntime 条件转换+IsControllable让位门+状态计时器+trace+Reset"

Task 6: AiRuntime 事件转换 + trace 触发原因 + 池复用测试

Files:

  • Modify: Assets/_Game/Scripts/AI/AiRuntime.cs

  • Modify: Assets/Tests/EditMode/AI/AiRuntimeTests.cs

  • Step 1: 追加失败测试 —— 事件转换(push)、优先级、Reset

AiRuntimeTests 类中追加:

        [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"));
        }
  • Step 2: 运行确认新测试失败

Run: EditMode AiRuntimeTests Expected: EventTransition_* FAILSend 未实现事件处理),Reset_*/Trace_* 可能已过(视 Task 5 实现);至少事件相关 2 项失败。

  • Step 3: 实现事件处理

AiRuntime.cs 追加 Send 并在 Tick 开头处理事件队列(事件不受让位门限制)。修改如下:

在字段区已有 _pending。新增方法:

        /// <summary>推送一个信号,用于事件驱动转换(push, spec §3.3/§4.1)。</summary>
        public void Send(AiSignal signal) => _pending.Enqueue(signal);

Tick 改为先排空事件(不受门限制),再走门 + 条件:

        public void Tick(float dt)
        {
            _byContext.Remove(_ctx);
            _byContext.Add(_ctx, this);

            // 1) 事件转换:push,最高优先,且不受 IsControllable 门限制
            while (_pending.Count > 0)
            {
                var sig = _pending.Dequeue();
                if (TryEventTransition(sig))
                    return; // 事件触发转换后本帧结束
            }

            // 2) IsControllable 让位门
            if (!_ctx.Vitals.IsControllable)
            {
                IsSuspended = true;
                return;
            }
            IsSuspended = false;

            _timeInState += dt;

            // 3) 条件转换
            if (TryTransition())
                return;

            _current.OnTick?.Invoke(_ctx, dt);
        }

        bool TryEventTransition(AiSignal sig)
        {
            foreach (var t in _graph.GlobalTransitions)
                if (t.IsEvent && t.Event.Value == sig)
                    return Switch(t);
            foreach (var t in _current.Transitions)
                if (t.IsEvent && t.Event.Value == sig)
                    return Switch(t);
            return false;
        }

Reset() 中已有 _pending.Clear()Task 5),无需改动。

  • Step 4: 运行测试确认全通过

Run: EditMode AiRuntimeTests Expected: PASSTask 5 的 6 项 + 本任务 4 项 = 10 项全绿)。

  • Step 5: 提交
git add Assets/_Game/Scripts/AI/AiRuntime.cs Assets/Tests/EditMode/AI/AiRuntimeTests.cs
git commit -m "feat(ai): AiRuntime 事件转换(push,不受门限制)+trace触发原因+池复用重置测试"

Task 7: 阶段自检与文档回链

Files:

  • Modify: Docs/superpowers/specs/2026-07-03-enemy-ai-framework-design.md(在文末追加"实现进度"回链,可选)

  • Step 1: 全量跑核心测试

Run: EditMode 全部 BaseGames.Tests.EditMode.AI.* Expected: BlackboardTests(5) + BrainBuilderTests(5) + AiRuntimeTests(10) 全 PASS。

  • Step 2: 编译零错误确认

Run: unity_get_compilation_errors Expected: 无错误、无警告(除已知的 CallerArgumentExpression 兼容处理)。

  • Step 3: 验证 flyweight 共享(手动断言脚本,可选)

用 MCP unity_execute_code 执行:构造一个测试 AiScript 子类,调用两次 GetOrBuildGraph(),断言返回同一引用ReferenceEquals == true)→ 证明每类型只建一份、实例共享。

  • Step 4: 提交阶段收尾
git add -A
git commit -m "chore(ai): BrainGraph 第1阶段核心运行时收尾——全测试通过"

后续阶段路线图(各自展开为独立计划)

本阶段交付"可独立单测、可工作的状态机核心"。后续阶段依赖它,届时各写一份计划:

  • 第 2 阶段:能力编排 sugar + AiScheduler
    • AbilitySequence + Seq.Select/Sequence fluent(选招→Execute→while(IsRunning)等待→重评估,spec §3.4)。
    • 嵌套子状态机(层级 HFSM,供 Boss 阶段),AiScheduler LOD 错峰调度(spec §3.7)。
  • 第 3 阶段:能力接口完整成员 + 适配器 + 组件 + 信号接线
    • 按 spec §4 补齐 4 接口全部方法;SensorAdapter/MoverAdapter/CombatantAdapter/VitalsAdapterBaseGames.Enemies.AI asmdef,去 Opsive 引用、加 BaseGames.AI)。
    • EnemyAiBrain : MonoBehaviour + AiDefinitionRegistry(反射收集 [AiDefinition]RuntimeInitializeOnLoadMethod 重置,遵关闭域重载约束)。
    • EnemyBase raise AiSignal(受击/弹反/死亡/发现玩家/能力结束/阶段变更),移除 #if GRAPH_DESIGNER BT 集成;IPoolable 回收回调调 AiRuntime.Reset()
  • 第 4 阶段:交付面
    • MermaidExporterAiGraph→stateDiagram-v2,菜单 + MCP 静态入口,spec §6.1)。
    • AiDebugServiceServiceLocator 注册,ListAgents/GetAgent/ExportMermaid/SetPaused/Step/ForceStatespec §6.3)。
    • Editor/BrainGraphDebuggerWindow(实时高亮 + trace + 黑板 + 能力 + 门状态,spec §6.2)。
  • 第 5 阶段:脚手架接线 + 试点 + 退役 BD
    • 扩展 SceneObjectPlacerTool/CharacterWizardWindow 自动挂 EnemyAiBrain + 绑定定义/参数 SO(消除 SceneObjectPlacerTool.cs:354/524 手动 TODO)。
    • 试点 E001_CaoZhiPlayMode 行为验收)+ ChaoFengBoss(多阶段/加权技能/弱点窗口)。
    • parity 后批量迁移,删除 49 个 BD_*EnemyBase BT 集成、com.opsive.* 包(spec §8)。

自检记录(writing-plans self-review

  • Spec 覆盖:本阶段对应 spec §3.1(共享图)/§3.2(轻量运行时)/§3.3(条件+事件转换)/§3.6(IsControllable门)/§3.8(池复用重置、无实例捕获约束经 IAiContext 落实)。§3.4/§3.5黑板已建/§3.7/§4完整/§6/§7/§8 明确列入后续阶段路线图,无遗漏。
  • 占位扫描:无 TBD/TODO 占位;After 的临时抛异常 + Task 5 回填是明确的两步实现(非占位),已注明回填内容与顺序。
  • 类型一致性IAiContext.Sensor/Mover/Combat/Vitals/BlackboardAiRuntime.CurrentStateName/IsSuspended/Trace/Send/Reset/TickTransition.OnCondition/OnEvent/IsEvent/LabelBrainBuilder.Entry/State/Global/BuildStateBuilder.OnEnter/Tick/OnExit/ToTransitionBuilder.When/OnEvent/After 跨任务命名一致。
  • 环境适配Unity 2022.3 = C# 9。转换标签走显式可选字符串(不用 C# 10 的 CallerArgumentExpression);After 用状态计时器、事件转换用 AiSignal,均 C# 9 兼容。