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

793 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 敌人执行层统一 (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`(攻击)。`EnemyLocomotion``EnemyNavAgent`(地面 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 play``unity_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.cs``IMover Mover``IEnemyLocomotion Locomotion`(注:接口在 Enemies 程序集,见 Task 说明)。
- `Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs` — 去 `IMover` 实现,暴露 `Locomotion`
- `Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs``AddAbilityState``AddLocomotionState`(状态绑意图)。
- `Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs` — 声明每状态 locomotion 意图 + Chase 能力。
- `Assets/_Game/Scripts/Enemies/Abilities/ContactChaseAbility.cs` — 内部 `MoveTo``Locomotion.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.cs``FakeAiContext.cs``Mover``Locomotion` spy。
**删除:**
- `Assets/_Game/Scripts/AI/IMover.cs`(+meta)
- `Assets/_Game/Scripts/Enemies/Abilities/IdleAbility.cs``PatrolAbility.cs``AlertAbility.cs`(+meta)
- `Assets/_Game/Data/Enemies/E001/Abilities/ABL_E001_Idle.asset``ABL_E001_Patrol.asset`+ Alert 的处理见 Task
- `Assets/_Game/Scripts/Enemies/AiPhase.cs`(+meta)(P4)
**接口归属说明:** `IEnemyLocomotion` 引用 `LocomotionMode`(Enemies 命名空间概念)。为避免 `BaseGames.AI` 程序集反向依赖 Enemies,采用与现有 `IEnemyActor` 相同的模式:`IAiContext.Locomotion` 的**类型**放在 `BaseGames.AI` 里定义为 `IEnemyLocomotion`AI 程序集内),`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: 创建接口文件**
```csharp
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: 提交**
```bash
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
```csharp
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: 提交**
```bash
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 获取 + 属性)
> **关键约束(已核实):** `EnemyLocomotion` 在 `BaseGames.Enemies.Navigation` 程序集,该程序集引用 `BaseGames.Enemies`;因此 `EnemyBase`(在 `BaseGames.Enemies`**不能引用具体类 `EnemyLocomotion`**(会造成循环依赖)。必须用接口 `IEnemyLocomotion`(在 `BaseGames.AI`Enemies 已引用)+ 运行时 `GetComponent` 发现——与现有 `IPathAgent _nav``EnemyBase.cs:60,568`)完全相同的模式。**不要**用 `[SerializeField]` 具体类型。
- [ ] **Step 1: 加字段与属性**(放在 `_nav`(约 `EnemyBase.cs:60`)字段旁,同 `Nav` 属性风格约 `:216`;确保文件已 `using BaseGames.AI;`
```csharp
// 移动执行器(IEnemyLocomotion;由 EnemyLocomotion 在 Navigation 程序集实现)
protected IEnemyLocomotion _locomotion;
```
属性(放在 `public IPathAgent Nav => _nav;` 旁):
```csharp
public IEnemyLocomotion Locomotion => _locomotion;
```
- [ ] **Step 2: Awake 获取**(紧邻 `_nav = GetComponent<IPathAgent>() ?? new NullPathAgent();`,约 `EnemyBase.cs:568`
```csharp
_locomotion = GetComponentInChildren<IEnemyLocomotion>(true);
```
- [ ] **Step 3: 编译门** — count=0。
- [ ] **Step 4: 提交**
```bash
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.cs`PlaceE001_CaoZhi 内)
- [ ] **Step 1: 在 PlaceE001 里挂组件**`using BaseGames.Enemies;` 已在文件顶部)
`var brain = GetOrAddComponent<EnemyAiBrain>(go);` 之前插入(`_locomotion` 由 EnemyBase.Awake 经 `GetComponentInChildren<IEnemyLocomotion>` 自动发现,**无需 AssignReference**——它非序列化字段):
```csharp
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.InstantiatePrefab``EditorSceneManager.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: 提交**
```bash
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: 进入 PlayMode**`unity_play_mode play`,等待 ~3s。
- [ ] **Step 2: 直接驱动 locomotion(绕过 AI)并断言**
`unity_execute_code`
```csharp
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: 退出 PlayMode**`unity_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**
```csharp
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**
`FakeAiContext``public FakeMover M ...` / `public IMover Mover => M;` 替换为:
```csharp
public FakeLocomotion L = new FakeLocomotion();
public IEnemyLocomotion Locomotion => L;
```
`FakeMover` 类可整体删除。)
- [ ] **Step 3: 写测试(先失败)**——用一个最小 `AiScript``PerceptionStateMachine` 构图,驱动到各态断言意图。因 `PerceptionStateMachine` 目前还是 `AddAbilityState`,此测试会编译失败或断言失败。
```csharp
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`/`FakeCombat``InChaseZoneValue`/`Calls` 字段,在其 Fake 中补上最小实现——见 Step 记录。)
- [ ] **Step 4: 运行 EditMode 测试,确认失败**Unity Test Runner → EditMode,或 MCP 运行测试)。期望:编译失败(`Config.IdleMode` 不存在)或断言失败。这是预期红。
- [ ] **Step 5: 提交测试(红)**
```bash
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.cs``IMover Mover { get; }` 改为 `IEnemyLocomotion Locomotion { get; }`
- [ ] **Step 2: 删 IMover.cs**
```bash
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.Calls``ctx.L.Calls`,字符串相应改(如 `"WalkRandom"``"SetMode:Patrol"``"FacePlayer"``"Face"``"Stop"``"Stop"``"LookAround"``"SetMode:Idle"`)。
- [ ] **Step 5: 编译门** — count=0。
- [ ] **Step 6: 提交**
```bash
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 与状态构建**
`Config``IdleAbilityId/PatrolAbilityId/AlertAbilityId` 换成 locomotion 意图字段,保留 `ChaseAbilityId`
```csharp
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(...)`)。新增私有助手:
```csharp
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());
}
```
说明:`Face``Sensor.LastKnown``EnemyBrainContext.Refresh` 每帧更新为玩家位置),避免 AI 直接引用玩家 Transform`AddAbilityState` 仅 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_SetsIdleMode``ChaseState_TriggersChaseAbility` PASS。
- [ ] **Step 3: 编译门** — count=0。
- [ ] **Step 4: 提交**
```bash
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.cs``PatrolAbility.cs``AlertAbility.cs`(+meta)、`ABL_E001_Idle.asset``ABL_E001_Patrol.asset`(+meta)
- [ ] **Step 1: E001CaoZhiAi 用意图 Config**
```csharp
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 内也会设),并改追击循环体为:
```csharp
_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**
```bash
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: 提交**
```bash
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.cs`PlaceE001
- [ ] **Step 1: 删掉 IdleAbility_Idle/PatrolAbility_Patrol/AlertAbility_Alert 三节点创建与其 AssignAsset**——回到只有 `ContactChaseAbility_Chase`(保留其 `_config=ABL_E001_Chase``_contactDamage` 绑定)。删除对应的 `GetOrAddComponent<IdleAbility>/PatrolAbility/AlertAbility``AssignAsset(...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,且挂了 EnemyLocomotion**`unity_execute_code` 断言 `GetComponentsInChildren<EnemyAbilityBase>()` 只含 `ContactChaseAbility``EnemyLocomotion` 存在)。
- [ ] **Step 6: 提交**
```bash
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: 玩家在近处 → 断言 Chase**`state=Chase``Locomotion.CurrentMode=Approach`、ContactChaseAbility running=true、接触伤害在追击时开启。
- [ ] **Step 3: 把敌人移到远处(80,5),等 ~3s → 断言 de-escalation**`state=Move_Patrol``Locomotion.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.cs``Idle/Walk/Run/Alert` clip 字段)
- [ ] **Step 1: 在 EnemyLocomotion 缓存 Animancer + AnimConfig,按模式播步态**
Awake 里取 `_enemy.Animancer` 与 AnimConfig(经 EnemyBase 暴露的 `_animConfig` 访问器;若无访问器,在 EnemyBase 加 `public EnemyAnimationConfigSO AnimConfig => _animConfig;`)。在模式切换时播对应 clip
```csharp
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: 提交**
```bash
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(...)` 调用(`ContactChaseAbility``AppearAbility``AnimatedCeilingDropAbility` 等)
- Modify: `ReceiveAlert` 的判据;gizmo/`EnemyDebugOverlay` 读 AiPhase 处
- [ ] **Step 1: 先移除所有 `SetAiPhase(...)` 调用**——grep 定位:`rg -n "SetAiPhase|CurrentAiPhase|AiPhase\.|OnAiPhaseChanged" Assets/_Game/Scripts`。逐处删除或替换:
- 能力里 `_enemy.SetAiPhase(AiPhase.X)` 直接删(动画已由 locomotion/能力自身 clip 驱动)。
- `ReceiveAlert``if (CurrentAiPhase == Chase || Combat) return;` 的"已交战不降级"判据,改为读 AI 状态:新增 `EnemyBase.IsEngaged`(由 `EnemyAiBrain` 暴露当前状态名是否为 Chase/攻击态)或简单 `bool _engaged` 由 Chase 能力 Execute/结束时置位。选后者:`ContactChaseAbility` 起始 `_enemy.SetEngaged(true)``CleanupChase``_enemy.SetEngaged(false)``EnemyBase``bool IsEngaged` + `SetEngaged`
- gizmo/overlay 读 phase 处改读 `EnemyAiBrain` 当前状态名(`Brain` 已暴露)或删可视化。
- [ ] **Step 2: 删 EnemyBase 内 AiPhase 成员与 phase→clip switch**`SetAiPhase` 方法体、字段、事件、`_autoPlayPhaseAnimation`)。
- [ ] **Step 3: 删 AiPhase.cs**
```bash
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: 提交**
```bash
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` 删除:`AttackDamage``AttackRange``DetectRange``DetectAngleDeg``EyeOffset``LOSBlockingMask``AlertDuration``InvestigateDuration``KnockbackForce``HitStunDuration``HitTierConfig.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: 提交**
```bash
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;` 与序列化的墙/崖检测引用(复用 `EnemyMovement``IsWallAhead`/`IsLedgeAhead` 若存在;否则用 `WallDetector`):
```csharp
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: 提交**
```bash
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-pong`MoveTo(waypoint)` 到达推进 index):
```csharp
[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) % len`ping-pong 时到端点翻 `_wpDir`
- [ ] **Step 2: 编译门** — count=0。
- [ ] **Step 3: PlayMode 验证**——放置带 2-3 个路点的测试敌人,观察按序巡逻 + loop/ping-pong。
- [ ] **Step 4: 提交**
```bash
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 ScriptableObjects``Validate Address Keys``Physics2D 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)。