Files
zeling_v2/Docs/superpowers/plans/2026-07-10-enemy-locomotion-refactor.md
T
2026-07-20 10:33:18 +08:00

39 KiB
Raw Blame History

敌人执行层统一 (EnemyLocomotion) Implementation Plan

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: 用统一的 EnemyLocomotion 执行器取代 7 种巡逻/4 套移动/IMover/三个协程能力,删除 AiPhase 影子层与 EnemyStatsSO 死字段,让敌人 AI 保持"只决策"、执行层单一化——先在 E001 落地并验证。

Architecture: 三条正交轴:反应态 FSM(EnemyStateType) 当门,BrainGraph 决策(声明 locomotion 意图 + 触发攻击能力),执行层 = EnemyLocomotion(移动/朝向/步态动画) + EnemyAbilityBase(攻击)。EnemyLocomotionEnemyNavAgent(地面 PB2d) / FlyingDirectNavigator(飞行直飞) 两个 IPathAgent 后端之上的唯一门面,对外暴露 SetMode/Approach/MoveTo/Face/Stop(声明意图,执行在内部)。

Tech Stack: Unity 2022.3 (C# 9)、PathBerserker2d、Animancer、自研 BrainGraph、NUnit EditMode 测试、Unity MCP(端口 7890)做 PlayMode 验证。

前置状态: P1(删死代码 BD_*/Opsive/宏) 已完成 (commit e894a9d),编译 0 错误。本计划从 P2 起。设计见 Docs/superpowers/specs/2026-07-10-enemy-locomotion-refactor-design.md

全局约定:

  • 每个 Task 结束提交一次;提交信息中文 + 类型前缀。
  • 编译验证统一用:unity_execute_code 触发 AssetDatabase.Refresh() + RequestScriptCompilation(),等待后 unity_get_compilation_errors severity=error 期望 count=0。下称"编译门"。
  • PlayMode 验证统一:unity_play_mode playunity_execute_code 断言 → unity_play_mode stop。TestRoomA 为活动场景,E001 实例名含 "CaoZhi"。
  • 禁止在 AI 脚本(AiScript/PerceptionStateMachine)里出现 velocity/朝向/动画的实现;只允许声明意图。物理原生约束保持(寻路只给方向、velocity 执行、碰撞体底部 y=0)。

File Structure

新建:

  • Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs — 统一移动执行器 + LocomotionMode/PatrolStrategy 枚举 + IEnemyLocomotion 接口。
  • Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs — 测试用 locomotion spy(记录调用)。

修改:

  • Assets/_Game/Scripts/AI/IAiContext.csIMover MoverIEnemyLocomotion Locomotion(注:接口在 Enemies 程序集,见 Task 说明)。
  • Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs — 去 IMover 实现,暴露 Locomotion
  • Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.csAddAbilityStateAddLocomotionState(状态绑意图)。
  • Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs — 声明每状态 locomotion 意图 + Chase 能力。
  • Assets/_Game/Scripts/Enemies/Abilities/ContactChaseAbility.cs — 内部 MoveToLocomotion.Approach
  • Assets/_Game/Scripts/Enemies/EnemyBase.cs — 缓存/暴露 Locomotion;删 SetAiPhase/AiPhase 相关(P4)。
  • Assets/_Game/Scripts/Enemies/EnemyMovement.cs — 删对 EnemyStatsSO 的速度直读(P5)。
  • Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs — 删死字段(P5)。
  • Assets/_Game/Scripts/Enemies/EnemyAnimationConfigSO.cs / 动画驱动 — locomotion 模式→步态 clip(P4)。
  • Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs — PlaceE001 挂 EnemyLocomotion、去三协程能力。
  • Assets/Tests/EditMode/AI/AiRuntimeTests.csFakeAiContext.csMoverLocomotion spy。

删除:

  • Assets/_Game/Scripts/AI/IMover.cs(+meta)
  • Assets/_Game/Scripts/Enemies/Abilities/IdleAbility.csPatrolAbility.csAlertAbility.cs(+meta)
  • Assets/_Game/Data/Enemies/E001/Abilities/ABL_E001_Idle.assetABL_E001_Patrol.asset+ Alert 的处理见 Task
  • Assets/_Game/Scripts/Enemies/AiPhase.cs(+meta)(P4)

接口归属说明: IEnemyLocomotion 引用 LocomotionMode(Enemies 命名空间概念)。为避免 BaseGames.AI 程序集反向依赖 Enemies,采用与现有 IEnemyActor 相同的模式:IAiContext.Locomotion类型放在 BaseGames.AI 里定义为 IEnemyLocomotionAI 程序集内),LocomotionMode/PatrolStrategy 枚举也放 AI 程序集(纯枚举无依赖),EnemyLocomotion MonoBehaviour 在 Enemies 程序集实现该接口。这与 ISensor/ICombatant 一致。


P2 — 引入 EnemyLocomotion(与旧 API 并存,不改 AI)

本阶段只新增组件并接到 E001,Model A 协程能力暂留、AI 不改。目的:EnemyLocomotion 能独立驱动 Idle/Patrol/Face/Approach。

Task 1: 定义 IEnemyLocomotion 接口与枚举(AI 程序集)

Files:

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

  • Step 1: 创建接口文件

using UnityEngine;

namespace BaseGames.AI
{
    /// <summary>移动执行器的行为模式。</summary>
    public enum LocomotionMode { Idle, Patrol, Face, Approach }

    /// <summary>巡逻策略(Patrol 模式下的具体走法)。</summary>
    public enum PatrolStrategy { Wander, Pace, Waypoints }

    /// <summary>
    /// 敌人移动执行器的声明式接口。AI/能力只"声明意图",执行在实现内部完成
    /// AI 不 actuate)。实现见 BaseGames.Enemies.EnemyLocomotion。
    /// </summary>
    public interface IEnemyLocomotion
    {
        void SetMode(LocomotionMode mode);   // Idle(停) / Patrol(按配置策略游走)
        void Approach(Transform target);     // 持续跟随,派生 RunSpeed
        void MoveTo(Vector2 point);          // 一次性目标点
        void Face(Vector2 lookAt);           // 停 + 朝向
        void Stop();
        LocomotionMode CurrentMode { get; }
        bool IsMoving { get; }
    }
}
  • Step 2: 编译门 — 触发编译,unity_get_compilation_errors 期望 count=0。

  • Step 3: 提交

git add Assets/_Game/Scripts/AI/IEnemyLocomotion.cs Assets/_Game/Scripts/AI/IEnemyLocomotion.cs.meta
git commit -m "feat(ai): 新增 IEnemyLocomotion 接口与 LocomotionMode/PatrolStrategy 枚举"

Task 2: 实现 EnemyLocomotion 组件(委托现有 EnemyBase/Nav

Files:

  • Create: Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs

  • Step 1: 创建组件P2 只实现 Wander 巡逻;Pace/Waypoints 在 P6

using UnityEngine;
using BaseGames.AI;

namespace BaseGames.Enemies
{
    /// <summary>
    /// 敌人移动执行器:唯一的移动/朝向入口。AI 状态与能力经 IEnemyLocomotion
    /// 声明意图,本组件每帧把当前模式翻译为对 EnemyBase/IPathAgent 的调用。
    /// 取代散落的 MoveTo/StopMovement/FacePlayer/FaceTarget 直调与三个协程能力。
    /// </summary>
    [DisallowMultipleComponent]
    public sealed class EnemyLocomotion : MonoBehaviour, IEnemyLocomotion
    {
        [Header("巡逻策略(P6 实现 Pace/Waypoints;当前仅 Wander")]
        [SerializeField] private PatrolStrategy _patrolStrategy = PatrolStrategy.Wander;

        private EnemyBase _enemy;
        private LocomotionMode _mode = LocomotionMode.Idle;
        private Transform _approachTarget;
        private Vector2 _facePoint;

        public LocomotionMode CurrentMode => _mode;
        public bool IsMoving => _enemy != null && _enemy.Nav != null && _enemy.Nav.IsMoving;

        private void Awake()
        {
            _enemy = GetComponentInParent<EnemyBase>();
            if (_enemy == null)
                Debug.LogError("[EnemyLocomotion] 找不到 EnemyBase。", this);
        }

        // ── IEnemyLocomotion(声明意图,不立即 actuate 之外的副作用)──
        public void SetMode(LocomotionMode mode)
        {
            if (_mode == mode) return;
            _mode = mode;
            if (mode == LocomotionMode.Idle) _enemy?.StopMovement();
            if (mode == LocomotionMode.Patrol && _enemy?.Stats != null)
                _enemy.Nav?.SetSpeed(_enemy.Stats.WalkSpeed);
        }

        public void Approach(Transform target)
        {
            _mode = LocomotionMode.Approach;
            _approachTarget = target;
            if (_enemy?.Stats != null) _enemy.Nav?.SetSpeed(_enemy.Stats.RunSpeed);
        }

        public void MoveTo(Vector2 point)
        {
            _mode = LocomotionMode.Approach;
            _approachTarget = null;
            _enemy?.MoveTo(point);
        }

        public void Face(Vector2 lookAt)
        {
            _mode = LocomotionMode.Face;
            _facePoint = lookAt;
        }

        public void Stop()
        {
            _mode = LocomotionMode.Idle;
            _enemy?.StopMovement();
        }

        // ── 每帧把模式翻译成执行 ──
        private void Update()
        {
            if (_enemy == null) return;
            switch (_mode)
            {
                case LocomotionMode.Idle:
                    break; // SetMode(Idle) 已停;保持
                case LocomotionMode.Patrol:
                    TickPatrol();
                    break;
                case LocomotionMode.Face:
                    _enemy.StopMovement();
                    _enemy.FaceTarget(_facePoint);
                    break;
                case LocomotionMode.Approach:
                    if (_approachTarget != null) _enemy.MoveTo(_approachTarget.position);
                    break;
            }
        }

        private void TickPatrol()
        {
            var nav = _enemy.Nav;
            if (nav == null) return;
            switch (_patrolStrategy)
            {
                case PatrolStrategy.Wander:
                    if (!nav.IsMoving) nav.WalkToRandom();
                    break;
                // Pace / WaypointsP6 实现
                default:
                    if (!nav.IsMoving) nav.WalkToRandom();
                    break;
            }
        }
    }
}
  • Step 2: 编译门 — count=0。

  • Step 3: 提交

git add Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs.meta
git commit -m "feat(enemy): 新增 EnemyLocomotion 执行器(Idle/Patrol-Wander/Face/Approach)"

Task 3: 缓存并暴露 EnemyBase.Locomotion

Files:

  • Modify: Assets/_Game/Scripts/Enemies/EnemyBase.cs(字段区 + Awake 获取 + 属性)

关键约束(已核实): EnemyLocomotionBaseGames.Enemies.Navigation 程序集,该程序集引用 BaseGames.Enemies;因此 EnemyBase(在 BaseGames.Enemies不能引用具体类 EnemyLocomotion(会造成循环依赖)。必须用接口 IEnemyLocomotion(在 BaseGames.AIEnemies 已引用)+ 运行时 GetComponent 发现——与现有 IPathAgent _navEnemyBase.cs:60,568)完全相同的模式。不要[SerializeField] 具体类型。

  • Step 1: 加字段与属性(放在 _nav(约 EnemyBase.cs:60)字段旁,同 Nav 属性风格约 :216;确保文件已 using BaseGames.AI;
        // 移动执行器(IEnemyLocomotion;由 EnemyLocomotion 在 Navigation 程序集实现)
        protected IEnemyLocomotion _locomotion;

属性(放在 public IPathAgent Nav => _nav; 旁):

        public IEnemyLocomotion Locomotion => _locomotion;
  • Step 2: Awake 获取(紧邻 _nav = GetComponent<IPathAgent>() ?? new NullPathAgent();,约 EnemyBase.cs:568
            _locomotion = GetComponentInChildren<IEnemyLocomotion>(true);
  • Step 3: 编译门 — count=0。

  • Step 4: 提交

git add Assets/_Game/Scripts/Enemies/EnemyBase.cs
git commit -m "feat(enemy): EnemyBase 缓存并暴露 Locomotion"

Task 4: 脚手架 PlaceE001 挂载 EnemyLocomotion 并绑定引用;重生成 prefab

Files:

  • Modify: Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.csPlaceE001_CaoZhi 内)

  • Step 1: 在 PlaceE001 里挂组件using BaseGames.Enemies; 已在文件顶部)

var brain = GetOrAddComponent<EnemyAiBrain>(go); 之前插入(_locomotion 由 EnemyBase.Awake 经 GetComponentInChildren<IEnemyLocomotion> 自动发现,无需 AssignReference——它非序列化字段):

            GetOrAddComponent<EnemyLocomotion>(go);
  • Step 2: 编译门 — count=0。

  • Step 3: 重生成 E001 prefab(脚手架为权威)

unity_execute_code 反射调用 PlaceAndSaveEnemyPrefab("ENM_CaoZhi", PlaceE001_CaoZhi, removeSceneInstance:true)(同既往做法)。注意此调用较慢、桥接可能短时超时,超时后 unity_editor_ping 等恢复再继续。

  • Step 4: 用 prefab 实例替换 TestRoomA 场景实例并保存(保留位置;PrefabUtility.InstantiatePrefabEditorSceneManager.SaveScene)。

  • Step 5: 验证 prefab 挂了 EnemyLocomotion

unity_execute_code:加载 prefab,断言 root.GetComponentInChildren<BaseGames.Enemies.EnemyLocomotion>(true) != null。(_locomotion 是运行时 GetComponentInChildren 发现的非序列化字段,故在 PlayMode(Task 5)验证 EnemyBase.Locomotion != null,此处只验组件存在。)

  • Step 6: 提交
git add Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs Assets/_Game/Prefabs/Enemies/E001/ENM_CaoZhi.prefab Assets/_Game/Scenes/Testings/TestRoomA.unity
git commit -m "feat(scaffold): PlaceE001 挂载 EnemyLocomotion 并绑定引用,重生成 prefab"

Task 5: PlayMode 冒烟验证 EnemyLocomotion 独立可用

Files: 无(仅 MCP 验证)

  • Step 1: 进入 PlayModeunity_play_mode play,等待 ~3s。

  • Step 2: 直接驱动 locomotion(绕过 AI)并断言

unity_execute_code

var e = UnityEngine.Object.FindObjectsOfType<BaseGames.Enemies.EnemyBase>(true).FirstOrDefault(x=>x.name.Contains("CaoZhi"));
var loco = e.Locomotion;
loco.Approach(e.PlayerTransform);   // 让它朝玩家移动
return "mode=" + loco.CurrentMode;  // 期望 Approach

等待 ~2s 后再断言 loco.IsMoving == true(或位置发生变化)。再 loco.SetMode(BaseGames.AI.LocomotionMode.Idle),断言随后 IsMoving == false

  • Step 3: console 0 报错unity_console_log type=error count=0。

  • Step 4: 退出 PlayModeunity_play_mode stop。(无代码改动,无需提交。)


P3 — AI 改绑 locomotion 意图,删协程能力/IMover

执行顺序调整(Unity 全程序集编译约束): Unity 里测试程序集编译失败会阻断整个项目编译与 MCP 编译门,因此不能提交"编译红"的测试。P3 实际执行顺序改为 Task 7 → Task 8 → Task 6 → Task 9 → 10 → 11:先做接口切换(Task 7,含创建 FakeLocomotion 并修好现有 AiRuntimeTests/FakeAiContext),再做 AddLocomotionState(Task 8),最后补 PerceptionStateMachineTests(Task 6,此时全部类型已存在,测试应为绿——作为回归/特征测试,而非 red-first)。每次提交保持编译绿。

Task 6: 编写失败测试 — PerceptionStateMachine 状态设置正确 locomotion 意图

Files:

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

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

  • Create/Modify: Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs

  • Step 1: 创建 FakeLocomotion spy

using System.Collections.Generic;
using UnityEngine;
using BaseGames.AI;

namespace BaseGames.Tests.EditMode.AI
{
    public sealed class FakeLocomotion : IEnemyLocomotion
    {
        public List<string> Calls = new List<string>();
        public LocomotionMode CurrentMode { get; private set; }
        public bool IsMoving { get; set; }
        public void SetMode(LocomotionMode mode) { CurrentMode = mode; Calls.Add("SetMode:" + mode); }
        public void Approach(Transform t) { CurrentMode = LocomotionMode.Approach; Calls.Add("Approach"); }
        public void MoveTo(Vector2 p) { Calls.Add("MoveTo"); }
        public void Face(Vector2 p) { CurrentMode = LocomotionMode.Face; Calls.Add("Face"); }
        public void Stop() { CurrentMode = LocomotionMode.Idle; Calls.Add("Stop"); }
    }
}
  • Step 2: 在 FakeAiContext 用 Locomotion 替换 Mover

FakeAiContextpublic FakeMover M ... / public IMover Mover => M; 替换为:

        public FakeLocomotion L = new FakeLocomotion();
        public IEnemyLocomotion Locomotion => L;

FakeMover 类可整体删除。)

  • Step 3: 写测试(先失败)——用一个最小 AiScriptPerceptionStateMachine 构图,驱动到各态断言意图。因 PerceptionStateMachine 目前还是 AddAbilityState,此测试会编译失败或断言失败。
using NUnit.Framework;
using BaseGames.AI;
using BaseGames.Enemies;

namespace BaseGames.Tests.EditMode.AI
{
    public class PerceptionStateMachineTests
    {
        static AiGraph Graph()
        {
            var b = new BrainBuilder();
            PerceptionStateMachine.Add(b, new PerceptionStateMachine.Config
            {
                Idle = "Idle", Patrol = "Patrol", Alert = "Alert", Chase = "Chase",
                Death = "Death", Entry = "Idle", Rest = "Patrol",
                IdleMode = LocomotionMode.Idle,
                PatrolMode = LocomotionMode.Patrol,
                AlertMode = LocomotionMode.Face,
                ChaseAbilityId = "chase",
            });
            return b.Build();
        }

        [Test]
        public void IdleState_SetsIdleMode()
        {
            var ctx = new FakeAiContext();
            var rt = new AiRuntime(Graph(), ctx);
            Assert.AreEqual(LocomotionMode.Idle, ctx.L.CurrentMode);
        }

        [Test]
        public void ChaseState_TriggersChaseAbility_NotLocomotionDirectly()
        {
            var ctx = new FakeAiContext();
            var rt = new AiRuntime(Graph(), ctx);
            ctx.S.InChaseZoneValue = true;   // 见下方 FakeSensor 字段
            rt.Tick(0.1f);
            Assert.AreEqual("Chase", rt.CurrentStateName);
            CollectionAssert.Contains(ctx.C.Calls, "UseAbility:chase");
        }
    }
}

(若 FakeSensor/FakeCombatInChaseZoneValue/Calls 字段,在其 Fake 中补上最小实现——见 Step 记录。)

  • Step 4: 运行 EditMode 测试,确认失败Unity Test Runner → EditMode,或 MCP 运行测试)。期望:编译失败(Config.IdleMode 不存在)或断言失败。这是预期红。

  • Step 5: 提交测试(红)

git add Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs
git commit -m "test(ai): PerceptionStateMachine locomotion 意图测试(先失败)"

Task 7: 切换 IAiContext.Mover → Locomotion,删 IMover

Files:

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

  • Delete: Assets/_Game/Scripts/AI/IMover.cs(+meta)

  • Modify: Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs

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

  • Step 1: IAiContext 换成员

IAiContext.csIMover Mover { get; } 改为 IEnemyLocomotion Locomotion { get; }

  • Step 2: 删 IMover.cs
rm Assets/_Game/Scripts/AI/IMover.cs Assets/_Game/Scripts/AI/IMover.cs.meta
  • Step 3: EnemyBrainContext 实现 Locomotion,删 IMover 实现块

  • 类声明去掉 IMover

  • 删除 // ---- IMover ---- 整段(MoveTo/FacePlayer/Stop/WalkRandom/LookAround/UseChaseSpeed/UsePatrolSpeed)。

  • public IMover Mover => this; 改为 public IEnemyLocomotion Locomotion => _enemy.Locomotion;

  • Step 4: 修 AiRuntimeTests 里的 c.Mover.* 引用——那些测试用 Mover 当通用副作用 spy,改用 Locomotion spy 语义:

  • .Tick(c => c.Mover.WalkRandom()).Tick(c => c.Locomotion.SetMode(LocomotionMode.Patrol))

  • .OnEnter(c => c.Mover.FacePlayer()).OnEnter(c => c.Locomotion.Face(c.Sensor.LastKnown))

  • .OnExit(c => c.Mover.Stop()).OnExit(c => c.Locomotion.Stop())

  • .Tick(c => c.Mover.MoveTo(c.Sensor.LastKnown)).Tick(c => c.Locomotion.MoveTo(c.Sensor.LastKnown))

  • .OnEnter(c => c.Mover.LookAround()).OnEnter(c => c.Locomotion.SetMode(LocomotionMode.Idle))

  • 断言里 ctx.M.Callsctx.L.Calls,字符串相应改(如 "WalkRandom""SetMode:Patrol""FacePlayer""Face""Stop""Stop""LookAround""SetMode:Idle")。

  • Step 5: 编译门 — count=0。

  • Step 6: 提交

git add -A Assets/_Game/Scripts/AI Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs Assets/Tests/EditMode/AI/AiRuntimeTests.cs
git commit -m "refactor(ai): IAiContext.Mover→Locomotion,删除 IMover"

Task 8: PerceptionStateMachine 改 AddLocomotionState(让 Task 6 测试转绿)

Files:

  • Modify: Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs

  • Step 1: 重写 Config 与状态构建

ConfigIdleAbilityId/PatrolAbilityId/AlertAbilityId 换成 locomotion 意图字段,保留 ChaseAbilityId

        public sealed class Config
        {
            public string Idle = "Idle", Patrol = "Patrol", Alert = "Alert",
                          Chase = "Chase", Death = "Death", Entry = "Idle", Rest = "Patrol";
            public LocomotionMode IdleMode   = LocomotionMode.Idle;
            public LocomotionMode PatrolMode = LocomotionMode.Patrol;
            public LocomotionMode AlertMode  = LocomotionMode.Face; // 停+朝向玩家
            public string ChaseAbilityId;                            // Chase 走能力
            public string DeathAbilityId;                            // 通常留空
        }

状态构建:Idle/Patrol/Alert 用 locomotion 意图;Chase 用能力;转换规则不变(照抄现有 .To(...).When(...))。新增私有助手:

        static BrainBuilder.StateBuilder AddLocomotionState(BrainBuilder b, string state, LocomotionMode mode, bool facePlayer)
        {
            return b.State(state)
                .OnEnter(x => Apply(x, mode, facePlayer))
                .Tick(x => Apply(x, mode, facePlayer))
                .OnExit(x => x.Locomotion.Stop());
        }
        static void Apply(IAiContext x, LocomotionMode mode, bool facePlayer)
        {
            if (facePlayer && x is IEnemyActor && x.Sensor != null)
                { /* Face 需玩家点,见下 */ }
            switch (mode)
            {
                case LocomotionMode.Face: x.Locomotion.Face(x.Sensor.LastKnown); break;
                default: x.Locomotion.SetMode(mode); break;
            }
        }
        static BrainBuilder.StateBuilder AddAbilityState(BrainBuilder b, string state, string abilityId)
        {
            return b.State(state)
                .OnEnter(x => EnsureAbility(x, abilityId))
                .Tick(x => EnsureAbility(x, abilityId))
                .OnExit(x => x.Combat.InterruptAbilities());
        }

说明:FaceSensor.LastKnownEnemyBrainContext.Refresh 每帧更新为玩家位置),避免 AI 直接引用玩家 TransformAddAbilityState 仅 Chase/Death 用。EnsureAbility/HasAlert 保持原实现。

Add(b,c)Idle/Patrol 用 AddLocomotionState(b, c.Idle, c.IdleMode, false) / (c.Patrol, c.PatrolMode, false)Alert 用 AddLocomotionState(b, c.Alert, c.AlertMode, true)Chase 用 AddAbilityState(b, c.Chase, c.ChaseAbilityId)Death 用 AddAbilityState(b, c.Death, c.DeathAbilityId)。转换 .To().When() 全部照抄现有。

  • Step 2: 运行 Task 6 的 EditMode 测试,转绿。期望 IdleState_SetsIdleModeChaseState_TriggersChaseAbility PASS。

  • Step 3: 编译门 — count=0。

  • Step 4: 提交

git add Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs
git commit -m "refactor(ai): PerceptionStateMachine 状态改绑 locomotion 意图,测试转绿"

Task 9: E001CaoZhiAi 声明意图;ContactChaseAbility 用 Approach;删三协程能力/SO

Files:

  • Modify: Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs

  • Modify: Assets/_Game/Scripts/Enemies/Abilities/ContactChaseAbility.cs

  • Delete: IdleAbility.csPatrolAbility.csAlertAbility.cs(+meta)、ABL_E001_Idle.assetABL_E001_Patrol.asset(+meta)

  • Step 1: E001CaoZhiAi 用意图 Config

        protected override void Build(BrainBuilder b)
        {
            PerceptionStateMachine.Add(b, new PerceptionStateMachine.Config
            {
                Idle = "Idle_Disguise", Patrol = "Move_Patrol", Alert = "Alert",
                Chase = "Chase", Death = "Death", Entry = "Idle_Disguise", Rest = "Move_Patrol",
                IdleMode = LocomotionMode.Idle,
                PatrolMode = LocomotionMode.Patrol,
                AlertMode = LocomotionMode.Face,
                ChaseAbilityId = "e001_chase",
            });
        }

(加 using BaseGames.AI; 若缺。)

  • Step 2: ContactChaseAbility 内部改 Approach

ExecuteCoroutine 里的追击循环由 _enemy.MoveTo(_enemy.PlayerTransform.position) 改为经 locomotion

  • 起始:if (_enemy.Nav != null && _enemy.Stats != null) _enemy.Nav.SetSpeed(_enemy.Stats.RunSpeed); 保留即可(Approach 内也会设),并改追击循环体为:
            _enemy.Locomotion.Approach(_enemy.PlayerTransform);
            while (_enemy.PlayerTransform != null)
                yield return null;

Approach 每帧由 EnemyLocomotion.Update 维持朝玩家移动,能力不再逐帧 MoveTo。)CleanupChase() 保持(关接触伤害);OnInterrupted 里追加 _enemy.Locomotion.Stop();。删除 SetAiPhase(AiPhase.Chase) 行(P4 会统一删,但这里先删避免编译依赖 AiPhase;若 P4 未做,暂留由 P4 清)。

注:SetAiPhase 的删除统一在 P4;本 Task 若 AiPhase 尚存则保留该行,Approach 改造与之无关。

  • Step 3: 删三协程能力与两个 SO
rm Assets/_Game/Scripts/Enemies/Abilities/IdleAbility.cs Assets/_Game/Scripts/Enemies/Abilities/IdleAbility.cs.meta
rm Assets/_Game/Scripts/Enemies/Abilities/PatrolAbility.cs Assets/_Game/Scripts/Enemies/Abilities/PatrolAbility.cs.meta
rm Assets/_Game/Scripts/Enemies/Abilities/AlertAbility.cs Assets/_Game/Scripts/Enemies/Abilities/AlertAbility.cs.meta
rm Assets/_Game/Data/Enemies/E001/Abilities/ABL_E001_Idle.asset Assets/_Game/Data/Enemies/E001/Abilities/ABL_E001_Idle.asset.meta
rm Assets/_Game/Data/Enemies/E001/Abilities/ABL_E001_Patrol.asset Assets/_Game/Data/Enemies/E001/Abilities/ABL_E001_Patrol.asset.meta

ABL_E001_Alert.asset 是否删见 Task 10:Alert 不再是能力,其 SO 也应删;此处一并 rm 它 +meta。)

  • Step 4: 编译门 — count=0(若报 AiPhase 相关错,说明 Step 2 注保留项处理不当,回退保留 SetAiPhase 行)。

  • Step 5: 提交

git add -A Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs Assets/_Game/Scripts/Enemies/Abilities Assets/_Game/Data/Enemies/E001/Abilities
git commit -m "refactor(ai): E001 状态改声明 locomotion 意图;Chase 走 Approach;删三协程能力/SO"

Task 10: 脚手架 PlaceE001 去三能力子节点;重生成 prefab;替换场景实例

Files:

  • Modify: Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.csPlaceE001

  • Step 1: 删掉 IdleAbility_Idle/PatrolAbility_Patrol/AlertAbility_Alert 三节点创建与其 AssignAsset——回到只有 ContactChaseAbility_Chase(保留其 _config=ABL_E001_Chase_contactDamage 绑定)。删除对应的 GetOrAddComponent<IdleAbility>/PatrolAbility/AlertAbilityAssignAsset(...ABL_E001_Idle/Patrol/Alert...) 行。

  • Step 2: 编译门 — count=0。

  • Step 3: 重生成 prefab(反射调 PlaceAndSaveEnemyPrefab("ENM_CaoZhi", PlaceE001_CaoZhi, removeSceneInstance:true),超时则等桥接恢复)。

  • Step 4: 替换 TestRoomA 场景实例并保存(同 Task 4 Step 4)。

  • Step 5: 验证 prefab 只剩 ContactChaseAbility,且挂了 EnemyLocomotionunity_execute_code 断言 GetComponentsInChildren<EnemyAbilityBase>() 只含 ContactChaseAbilityEnemyLocomotion 存在)。

  • Step 6: 提交

git add Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs Assets/_Game/Prefabs/Enemies/E001/ENM_CaoZhi.prefab Assets/_Game/Scenes/Testings/TestRoomA.unity
git commit -m "refactor(scaffold): PlaceE001 去三协程能力子节点,重生成 prefab"

Task 11: PlayMode 验证 E001 四态经 locomotion 驱动

Files:

  • Step 1: PlayMode play,等 ~3s。

  • Step 2: 玩家在近处 → 断言 Chasestate=ChaseLocomotion.CurrentMode=Approach、ContactChaseAbility running=true、接触伤害在追击时开启。

  • Step 3: 把敌人移到远处(80,5),等 ~3s → 断言 de-escalationstate=Move_PatrolLocomotion.CurrentMode=Patrol、敌人在游走(位置变化 / IsMoving)。

  • Step 4: 断言 AI 脚本零 actuation:确认 E001CaoZhiAi/PerceptionStateMachine 源码不含 velocity/朝向实现(人工核对 + grep:rg -n "velocity|MovePosition|transform\.position\s*=" Assets/_Game/Scripts/Enemies/AIBrain 期望 0 命中)。

  • Step 5: console 0 报错;退出 PlayMode。(无代码改动,无需提交。)


P4 — 删 AiPhase,动画由 locomotion 驱动

Task 12: EnemyLocomotion 驱动步态动画

Files:

  • Modify: Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs

  • 参考: Assets/_Game/Scripts/Enemies/EnemyAnimationConfigSO.csIdle/Walk/Run/Alert clip 字段)

  • Step 1: 在 EnemyLocomotion 缓存 Animancer + AnimConfig,按模式播步态

Awake 里取 _enemy.Animancer 与 AnimConfig(经 EnemyBase 暴露的 _animConfig 访问器;若无访问器,在 EnemyBase 加 public EnemyAnimationConfigSO AnimConfig => _animConfig;)。在模式切换时播对应 clip

        void PlayGait(LocomotionMode mode)
        {
            if (_animancer == null || _animConfig == null) return;
            var clip = mode switch
            {
                LocomotionMode.Idle     => _animConfig.Idle,
                LocomotionMode.Patrol   => _animConfig.Walk,
                LocomotionMode.Face     => _animConfig.Alert,
                LocomotionMode.Approach => _animConfig.Run,
                _ => null,
            };
            if (clip != null) _animancer.Play(clip);
        }

SetMode/Approach/Face/Stop 模式变化处调用 PlayGait(_mode)(仅在模式真正变化时,避免每帧重播)。

  • Step 2: 编译门 — count=0。

  • Step 3: 提交

git add Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs Assets/_Game/Scripts/Enemies/EnemyBase.cs
git commit -m "feat(enemy): EnemyLocomotion 按模式驱动步态动画(Idle/Walk/Alert/Run)"

Task 13: 删除 AiPhase 枚举与 SetAiPhase,迁移调用点

Files:

  • Delete: Assets/_Game/Scripts/Enemies/AiPhase.cs(+meta)

  • Modify: EnemyBase.cs(删 SetAiPhase/_currentAiPhase/CurrentAiPhase/OnAiPhaseChanged/_autoPlayPhaseAnimation/phase→clip switch

  • Modify: 所有能力里的 SetAiPhase(...) 调用(ContactChaseAbilityAppearAbilityAnimatedCeilingDropAbility 等)

  • Modify: ReceiveAlert 的判据;gizmo/EnemyDebugOverlay 读 AiPhase 处

  • Step 1: 先移除所有 SetAiPhase(...) 调用——grep 定位:rg -n "SetAiPhase|CurrentAiPhase|AiPhase\.|OnAiPhaseChanged" Assets/_Game/Scripts。逐处删除或替换:

    • 能力里 _enemy.SetAiPhase(AiPhase.X) 直接删(动画已由 locomotion/能力自身 clip 驱动)。
    • ReceiveAlertif (CurrentAiPhase == Chase || Combat) return; 的"已交战不降级"判据,改为读 AI 状态:新增 EnemyBase.IsEngaged(由 EnemyAiBrain 暴露当前状态名是否为 Chase/攻击态)或简单 bool _engaged 由 Chase 能力 Execute/结束时置位。选后者:ContactChaseAbility 起始 _enemy.SetEngaged(true)CleanupChase_enemy.SetEngaged(false)EnemyBasebool IsEngaged + SetEngaged
    • gizmo/overlay 读 phase 处改读 EnemyAiBrain 当前状态名(Brain 已暴露)或删可视化。
  • Step 2: 删 EnemyBase 内 AiPhase 成员与 phase→clip switchSetAiPhase 方法体、字段、事件、_autoPlayPhaseAnimation)。

  • Step 3: 删 AiPhase.cs

rm Assets/_Game/Scripts/Enemies/AiPhase.cs Assets/_Game/Scripts/Enemies/AiPhase.cs.meta
  • Step 4: 编译门 — count=0(把所有残留 AiPhase 引用清干净)。

  • Step 5: PlayMode 回归:E001 四态动画正确(待机=Idle、巡逻=Walk、警觉=Alert、追击=Run),受击/死亡 clip 能压过步态;console 0 报错。

  • Step 6: 提交

git add -A Assets/_Game/Scripts/Enemies
git commit -m "refactor(enemy): 删除 AiPhase 影子层,动画统一由 locomotion/反应态/能力驱动"

P5 — EnemyStatsSO 死字段清理 + 速度单一来源

Task 14: 删死字段,速度经 Locomotion 单一来源

Files:

  • Modify: Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs

  • Modify: Assets/_Game/Scripts/Enemies/EnemyMovement.cs(删 _config 速度直读)

  • Modify: Assets/_Game/Scripts/Enemies/EnemyStats.cs(删对应 pass-through 属性)

  • Step 1: 先 grep 确认无运行期读取——对每个待删字段跑:rg -n "AttackDamage|AttackRange|DetectRange|DetectAngleDeg|EyeOffset|LOSBlockingMask|AlertDuration|InvestigateDuration|KnockbackForce|HitStunDuration|heavyHitThreshold" Assets/_Game/Scripts。确认仅 Editor bestiary/gizmo 引用(这些一并处理或保留 gizmo 用的最小项)。

  • Step 2: 删字段——从 EnemyStatsSO.cs 删除:AttackDamageAttackRangeDetectRangeDetectAngleDegEyeOffsetLOSBlockingMaskAlertDurationInvestigateDurationKnockbackForceHitStunDurationHitTierConfig.heavyHitThreshold。同步删 EnemyStats.cs 对应 pass-through 属性(若有 reader 则改指向留存字段)。Editor 里引用被删字段处(EnemyModule.cs bestiary 显示)改为不显示或显示 DamageSourceSO/sensor 槽的权威值。

  • Step 3: EnemyMovement 速度单一来源——删 EnemyMovement 内对 EnemyStatsSO _config 的速度直读(_config.WalkSpeed/RunSpeed 处),改由 EnemyLocomotion/Nav.SetSpeed 提供的 PendingInput.MoveSpeed 驱动;_config 若仅用于速度则整个移除该序列化引用。

  • Step 4: 编译门 — count=0。

  • Step 5: PlayMode 回归E001 巡逻速度=WalkSpeed、追击速度=RunSpeed,行为不变;console 0 报错。

  • Step 6: 提交

git add -A Assets/_Game/Scripts/Enemies
git commit -m "refactor(enemy): 删 EnemyStatsSO ~11 个死字段,速度单一来源"

P6 — 巡逻策略补全(Pace / Waypoints

Task 15: 实现 Pace(撞墙/悬崖翻向踱步)

Files:

  • Modify: Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs

  • Step 1: 加 Pace 参数与逻辑——参照已删 BD_Patrol 的踱步语义(MoveInDirection(_dir) 每帧,遇 IsWallAhead || IsLedgeAhead 翻向)。EnemyLocomotion 加 int _paceDir = 1; 与序列化的墙/崖检测引用(复用 EnemyMovementIsWallAhead/IsLedgeAhead 若存在;否则用 WallDetector):

                case PatrolStrategy.Pace:
                    if (_enemy.Movement != null &&
                        (_enemy.Movement.IsWallAhead || _enemy.Movement.IsLedgeAhead))
                        _paceDir = -_paceDir;
                    _enemy.MoveInDirection(_paceDir);
                    break;

(若 EnemyBase.Movement/IsWallAhead/IsLedgeAhead/MoveInDirection 命名不同,按实际签名调整——实现前先 grep 确认。)

  • Step 2: 编译门 — count=0。

  • Step 3: EditMode/PlayMode 验证——把 E001(或一测试敌人)_patrolStrategy=Pace,PlayMode 观察其在平台上来回踱步、遇墙/崖翻向。

  • Step 4: 提交

git add Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs
git commit -m "feat(enemy): EnemyLocomotion 巡逻策略 Pace(撞墙/悬崖翻向踱步)"

Task 16: 实现 Waypoints(路点序列巡逻)

Files:

  • Modify: Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs

  • Step 1: 加 Waypoints 参数与逻辑——参照已删 BD_PatrolWaypoints(有序 Transform[]/Vector2[]loop 或 ping-pongMoveTo(waypoint) 到达推进 index):

        [Header("Waypoints 策略")]
        [SerializeField] private Transform[] _waypoints;
        [SerializeField] private bool _pingPong;
        private int _wpIndex; private int _wpDir = 1;
        // TickPatrol 的 Waypoints 分支:
                case PatrolStrategy.Waypoints:
                    if (_waypoints != null && _waypoints.Length > 0)
                    {
                        var nav = _enemy.Nav;
                        if (nav != null && !nav.IsMoving)
                        {
                            AdvanceWaypoint();
                            _enemy.MoveTo(_waypoints[_wpIndex].position);
                        }
                    }
                    break;

AdvanceWaypoint()loop 时 _wpIndex = (_wpIndex+1) % lenping-pong 时到端点翻 _wpDir

  • Step 2: 编译门 — count=0。

  • Step 3: PlayMode 验证——放置带 2-3 个路点的测试敌人,观察按序巡逻 + loop/ping-pong。

  • Step 4: 提交

git add Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs
git commit -m "feat(enemy): EnemyLocomotion 巡逻策略 Waypoints(路点序列 loop/ping-pong)"

收尾

Task 17: 全量自检 + 更新记忆

  • Step 1: 运行项目自检CLAUDE.md §3):Validate All ScriptableObjectsValidate Address KeysPhysics2D Layer Matrix Check——期望全绿。
  • Step 2: 最终 PlayMode 全链回归:E001 待机(伪装)→巡逻→警觉→追击(接触伤害)→死亡 + de-escalationconsole 0 报错。
  • Step 3: grep 确认清理彻底rg -n "AiPhase|IMover|IdleAbility|PatrolAbility|AlertAbility|WalkRandom\(\)" Assets/_Game/Scripts 仅剩预期(如 Nav.WalkToRandom 属正常)。
  • Step 4: 更新记忆 ai_decision_only_delegates_abilities.md / enemy_nav_movement_architecture.md:记录 Model A 已被 EnemyLocomotion 取代、执行层单一化、AiPhase 已删。

Self-Review(作者已核对)

  • Spec 覆盖:§3.1 EnemyLocomotion→P2/Task2;§3.2 AI 用法→P3/Task8-9;§3.3 删 AiPhase→P4;§3.4 Stats→P5;§4 E002-E06 范式→文档(不实现);§5 分期→P2-P6;§6 测试→各 Task 的 EditMode/PlayMode 步骤;§7 飞行缺口→不实现(记录在案)。P1 已完成。
  • 占位符:无 TBD/TODO;每个代码步给出实际代码;对"实现前需按实际签名 grep 确认"的动态点已显式标注(Pace/Waypoints 依赖 EnemyMovement 现有 API)。
  • 类型一致IEnemyLocomotion(Task1) 全程一致使用;LocomotionMode 值 Idle/Patrol/Face/Approach 贯穿;Config 字段 IdleMode/PatrolMode/AlertMode/ChaseAbilityId(Task8) 与 E001 用法(Task9) 一致;FakeLocomotion(Task6) 与 IAiContext.Locomotion(Task7) 匹配。
  • 已知风险点:飞行怪走 FlyingDirectNavigator(本计划 E001 为地面怪,不触发飞行分支,飞行接入留待 E002-E06 落地时按 spec §7)。