调整文档

This commit is contained in:
2026-07-21 10:22:43 +08:00
parent 2d4b3cfd96
commit a7307a5a07
29 changed files with 17 additions and 80 deletions
@@ -0,0 +1,634 @@
# BrainGraph 接通敌人阶段(E001 + 脚手架一键)实现计划
> **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:** 让脚手架一键放置的 E001(草蛭)敌人,其 AI 完全由自研 BrainGraph 驱动、且**不挂任何 `BehaviorTree` 组件**:新增按 id 解析共享图的注册表、把 EnemyBase 子系统包成能力接口的上下文、承载 `AiRuntime``EnemyAiBrain` 组件、E001 的 AiScript 定义,移除 EnemyBase 的 Opsive BT 集成,并在 `SceneObjectPlacerTool` 里自动挂载/绑定。
**Architecture:** `EnemyAiBrain : MonoBehaviour``_definitionId`(string)`Awake``AiDefinitionRegistry.GetGraph(id)` 取每类型共享的不可变 `AiGraph`,构造 `EnemyBrainContext`(实现 `IAiContext`+四能力接口,委托 EnemyBase 子系统)与 `AiRuntime``Update``Tick`。EnemyBase 只在死亡处 `Send(AiSignal.Died)``OnSpawn``ResetBrain()`,受击让位由 `IsControllable` 门自动处理。脚手架把 `EnemyAiBrain` 挂上并 `AssignString(_definitionId,"E001")`
**Tech Stack:** Unity 2022.3C# 9, Unity Test Framework (EditMode), asmdef, Addressables, MCP(编译/测试/PlayMode 验证)。
**依赖:** BrainGraph 第 1 阶段核心运行时已合并 master(`BaseGames.AI`AiGraph/BrainBuilder/AiRuntime/AiScript/[AiDefinition]/IAiContext/4 接口/Blackboard)。
**Spec** `Docs/superpowers/specs/2026-07-03-enemy-ai-framework-design.md`(本阶段覆盖 §4 适配、§7 组件与脚手架、§8 迁移第 1-2 步的 E001 部分;LOD/AiScheduler、能力编排 sugar、Boss、参数 SO 热调、批量删 BD/Opsive 仍属后续)。
---
## 关键前提(来自代码探查,务必按真实结构对接)
- `SceneObjectPlacerTool.PlaceE001_CaoZhi(EnemyBodyColliderType)``Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs:439`)用 `GetOrAddComponent<T>``AssignReference`/`AssignAsset`/`AssignString`(该文件内 private static,见 :2204/:2244/:2257/:2333)装配敌人;末尾 `report.Add("★ 挂载行为树…E001_CaoZhi.asset。")`(**:524**)是要替换的接线点。E001 已带 `HurtBox` + `ContactDamageZone`(`BodyContactDamage`)——**接触即伤害**,无需攻击能力。
- `EnemyBase``Assets/_Game/Scripts/Enemies/EnemyBase.cs`)暴露:`IsPlayerVisible()``IsPlayerInRange(float)``PlayerTransform``Nav`(`IPathAgent`,含 `IsMoving`/`WalkToRandom`)、`MoveTo(Vector2)``FacePlayer()``StopMovement()``BeginLookAround()``Abilities`(`EnemyAbilityRegistry`)、`IsAlive``CurrentState`(`EnemyStateType`)、`Stats`(`EnemyStats`, `CurrentHP`)、`StatsSO`(`MaxHP`)。`EnemyStateType{Controlled,Hurt,Stagger,KnockUp,Dead}``IPoolable` 只有 `OnSpawn`/`OnDespawn`
- `EnemyBase` 的 Opsive BT 集成全部在 `#if GRAPH_DESIGNER` 内(精确行号见 Task 5)。
- 已关 Domain Reload:静态注册表须 `RuntimeInitializeOnLoadMethod` 重置。
## 文件结构(本阶段)
新增(`BaseGames.AI``Assets/_Game/Scripts/AI/`):
- `AiDefinitionRegistry.cs` — 反射按 id 提供共享 AiGraph。
新增(`BaseGames.Enemies``Assets/_Game/Scripts/Enemies/AIBrain/`):
- `EnemyBrainContext.cs` — 实现 IAiContext+4 接口,委托 EnemyBase 子系统。
- `EnemyAiBrain.cs` — MonoBehaviour,承载 AiRuntime。
- `Ai/E001CaoZhiAi.cs``[AiDefinition("E001")]` 的 AiScript。
修改:
- `Assets/_Game/Scripts/Enemies/BaseGames.Enemies.asmdef` — references 增加 `BaseGames.AI`
- `Assets/_Game/Scripts/Enemies/EnemyBase.cs` — 移除 BT 集成,加 brain 引用/Send(Died)/OnSpawn 重置。
- `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs``PlaceE001_CaoZhi``EnemyAiBrain` + 绑 id。
测试(`Assets/Tests/EditMode/AI/`):
- `AiDefinitionRegistryTests.cs`
> 命名/注释禁参考游戏名(CLAUDE.md §4);根因暴露、禁下游兜底(§6);创建走脚手架(§2)。
---
## Task 1: `AiDefinitionRegistry`(反射按 id 取共享图)
**Files:**
- Create: `Assets/_Game/Scripts/AI/AiDefinitionRegistry.cs`
- Create: `Assets/Tests/EditMode/AI/AiDefinitionRegistryTests.cs`
- [ ] **Step 1: 写失败测试 `AiDefinitionRegistryTests.cs`**
```csharp
using NUnit.Framework;
using BaseGames.AI;
namespace BaseGames.Tests.EditMode.AI
{
// 顶层测试定义,供反射注册表发现
[AiDefinition("__test_grunt")]
internal sealed class TestGruntAi : AiScript
{
protected override void Build(BrainBuilder b)
{
b.Entry("A");
b.State("A").To("B").When(c => c.Sensor.SeesPlayer(), "SeesPlayer");
b.State("B");
}
}
public class AiDefinitionRegistryTests
{
[Test]
public void Has_FindsRegisteredDefinition()
{
Assert.IsTrue(AiDefinitionRegistry.Has("__test_grunt"));
}
[Test]
public void GetGraph_ResolvesById()
{
var g = AiDefinitionRegistry.GetGraph("__test_grunt");
Assert.AreEqual("A", g.EntryState);
}
[Test]
public void GetGraph_SameId_ReturnsSharedInstance()
{
Assert.AreSame(
AiDefinitionRegistry.GetGraph("__test_grunt"),
AiDefinitionRegistry.GetGraph("__test_grunt"));
}
[Test]
public void GetGraph_UnknownId_Throws()
{
Assert.Throws<System.InvalidOperationException>(
() => AiDefinitionRegistry.GetGraph("__nope__"));
}
}
}
```
- [ ] **Step 2: 运行确认失败**
Run: EditMode `AiDefinitionRegistryTests`(经 MCP`unity_advanced_tool``unity_testing_run_tests` testMode=EditMode)。
Expected: 编译失败 —— `AiDefinitionRegistry` 未定义。
- [ ] **Step 3: 实现 `AiDefinitionRegistry.cs`**
```csharp
using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
namespace BaseGames.AI
{
/// <summary>
/// 反射收集所有 [AiDefinition] 的 AiScript 子类,按 id 提供共享 AiGraphflyweight)。
/// 同一 id 全实例共享同一 AiScript 实例 → 同一张不可变 AiGraph。
/// </summary>
public static class AiDefinitionRegistry
{
static Dictionary<string, AiScript> _byId;
// 项目已关闭 Domain Reload:静态缓存需在进入播放时重置,保证按最新类型重建。
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void ResetOnPlay() => _byId = null;
static void EnsureBuilt()
{
if (_byId != null) return;
_byId = new Dictionary<string, AiScript>();
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
Type[] types;
try { types = asm.GetTypes(); }
catch (ReflectionTypeLoadException e) { types = e.Types; }
if (types == null) continue;
for (int i = 0; i < types.Length; i++)
{
var t = types[i];
if (t == null || t.IsAbstract || !typeof(AiScript).IsAssignableFrom(t)) continue;
var attr = t.GetCustomAttribute<AiDefinitionAttribute>();
if (attr == null) continue;
if (_byId.ContainsKey(attr.Id))
throw new InvalidOperationException(
$"AiDefinitionRegistry: 重复的 AI 定义 id '{attr.Id}'{t.FullName})。");
_byId[attr.Id] = (AiScript)Activator.CreateInstance(t);
}
}
}
public static bool Has(string id)
{
EnsureBuilt();
return _byId.ContainsKey(id);
}
/// <summary>取 id 对应的共享 AiGraph;不存在则显式抛错(根因暴露,不做兜底)。</summary>
public static AiGraph GetGraph(string id)
{
EnsureBuilt();
if (!_byId.TryGetValue(id, out var script))
throw new InvalidOperationException(
$"AiDefinitionRegistry: 未找到 AI 定义 id '{id}'。请确认存在 [AiDefinition(\"{id}\")] 的 AiScript 子类。");
return script.GetOrBuildGraph();
}
public static IEnumerable<string> Ids
{
get { EnsureBuilt(); return _byId.Keys; }
}
}
}
```
- [ ] **Step 4: 运行确认通过**
Run: EditMode `AiDefinitionRegistryTests`
Expected: PASS4 项)。全量测试无失败。
- [ ] **Step 5: 提交**
```bash
git add Assets/_Game/Scripts/AI/AiDefinitionRegistry.cs Assets/_Game/Scripts/AI/AiDefinitionRegistry.cs.meta \
Assets/Tests/EditMode/AI/AiDefinitionRegistryTests.cs Assets/Tests/EditMode/AI/AiDefinitionRegistryTests.cs.meta
git commit -m "feat(ai): AiDefinitionRegistry——反射按id提供共享AiGraph(关域重载重置)"
```
---
## Task 2: `BaseGames.Enemies` 引用 `BaseGames.AI` + `EnemyBrainContext`
**Files:**
- Modify: `Assets/_Game/Scripts/Enemies/BaseGames.Enemies.asmdef`
- Create: `Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs`
无独立单测(依赖 EnemyBase MonoBehaviour,行为由 Task 7 集成验证);本任务编译校验。
- [ ] **Step 1: 给 `BaseGames.Enemies.asmdef` 的 references 增加 `"BaseGames.AI"`**
先 Read `Assets/_Game/Scripts/Enemies/BaseGames.Enemies.asmdef`,在 `references` 数组里加入一行 `"BaseGames.AI"`(放在 `"BaseGames.Core"` 之后即可)。其余字段不动。
- [ ] **Step 2: 创建 `Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs`**
```csharp
using UnityEngine;
using BaseGames.AI;
namespace BaseGames.Enemies
{
/// <summary>
/// 把 EnemyBase 的子系统包装成 BrainGraph 的能力接口(每敌人一实例)。
/// 状态逻辑只依赖这些接口,不直接引用具体子系统。
/// </summary>
public sealed class EnemyBrainContext : IAiContext, ISensor, IMover, ICombatant, IActorVitals
{
readonly EnemyBase _enemy;
readonly Blackboard _blackboard = new Blackboard();
Vector2 _lastKnown;
float _lostTimer;
public EnemyBrainContext(EnemyBase enemy) { _enemy = enemy; }
// ---- IAiContext ----
public ISensor Sensor => this;
public IMover Mover => this;
public ICombatant Combat => this;
public IActorVitals Vitals => this;
public Blackboard Blackboard => _blackboard;
/// <summary>每帧由 EnemyAiBrain 在 Tick 之前调用,维护最后已知位置与丢失计时。</summary>
public void Refresh(float dt)
{
if (_enemy.IsPlayerVisible() && _enemy.PlayerTransform != null)
{
_lastKnown = _enemy.PlayerTransform.position;
_lostTimer = 0f;
}
else
{
_lostTimer += dt;
}
}
/// <summary>对象池复用时清空临时态。</summary>
public void ResetScratch()
{
_blackboard.Clear();
_lastKnown = _enemy.transform.position;
_lostTimer = 0f;
}
// ---- ISensor ----
public bool SeesPlayer() => _enemy.IsPlayerVisible();
public bool InRange(float range) => _enemy.IsPlayerInRange(range);
public bool LostFor(float seconds) => !_enemy.IsPlayerVisible() && _lostTimer >= seconds;
public Vector2 LastKnown => _lastKnown;
// ---- IMover ----
public void MoveTo(Vector2 target) => _enemy.MoveTo(target);
public void FacePlayer() => _enemy.FacePlayer();
public void Stop() => _enemy.StopMovement();
public void WalkRandom()
{
var nav = _enemy.Nav;
if (nav != null && !nav.IsMoving) nav.WalkToRandom();
}
public void LookAround() => _enemy.BeginLookAround();
// ---- ICombatant ----
public bool UseAbility(string abilityId)
{
var a = _enemy.Abilities?.Get(abilityId);
return a != null && a.Execute();
}
public bool IsAbilityRunning(string abilityId = null)
{
var reg = _enemy.Abilities;
if (reg == null) return false;
if (abilityId != null)
{
var a = reg.Get(abilityId);
return a != null && a.IsRunning;
}
var all = reg.All;
for (int i = 0; i < all.Count; i++)
if (all[i].IsRunning) return true;
return false;
}
public bool NoAbilityRunning => !IsAbilityRunning();
public bool CanUseAbility(string abilityId)
{
var a = _enemy.Abilities?.Get(abilityId);
return a != null && a.CanUse;
}
// ---- IActorVitals ----
public bool IsAlive => _enemy.IsAlive;
public bool IsControllable => _enemy.CurrentState == EnemyStateType.Controlled;
public float HpPercent
{
get
{
var so = _enemy.StatsSO;
return so != null && so.MaxHP > 0 ? (float)_enemy.Stats.CurrentHP / so.MaxHP : 0f;
}
}
public bool HpBelow(float ratio) => HpPercent < ratio;
}
}
```
> 若某个成员的 EnemyBase 属性/方法名与上面不符(如 `Nav` 无 `IsMoving`),**先 Read `EnemyBase.cs`/`IPathAgent.cs`/`EnemyAbilityRegistry.cs` 核对真实签名再调整**——不要臆造。`EnemyStateType` 若是 `EnemyBase` 的嵌套类型,则写 `EnemyBase.EnemyStateType.Controlled`。
- [ ] **Step 3: 刷新并确认编译零错误**
`unity_execute_menu_item`("Assets/Refresh") → `unity_get_compilation_errors`(severity 'error') = 0。
- [ ] **Step 4: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/BaseGames.Enemies.asmdef \
Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs.meta \
Assets/_Game/Scripts/Enemies/AIBrain.meta
git commit -m "feat(ai): EnemyBrainContext——EnemyBase子系统适配为BrainGraph能力接口"
```
---
## Task 3: `EnemyAiBrain` 组件
**Files:**
- Create: `Assets/_Game/Scripts/Enemies/AIBrain/EnemyAiBrain.cs`
- [ ] **Step 1: 创建 `EnemyAiBrain.cs`**
```csharp
using UnityEngine;
using BaseGames.AI;
namespace BaseGames.Enemies
{
/// <summary>
/// 把 BrainGraph 决策层挂到敌人上:按 id 取共享 AiGraph、构造 AiRuntime、逐帧推进。
/// 取代旧的 Opsive BehaviorTree 组件。
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(EnemyBase))]
public sealed class EnemyAiBrain : MonoBehaviour
{
[Tooltip("对应 [AiDefinition(id)] AI id E001")]
[SerializeField] string _definitionId;
EnemyBase _enemy;
EnemyBrainContext _context;
AiRuntime _runtime;
public string DefinitionId => _definitionId;
public string CurrentStateName => _runtime != null ? _runtime.CurrentStateName : "(none)";
public bool IsSuspended => _runtime != null && _runtime.IsSuspended;
public AiRuntime Runtime => _runtime;
void Awake()
{
_enemy = GetComponent<EnemyBase>();
if (string.IsNullOrEmpty(_definitionId))
{
// 根因暴露:漏配 id 直接报错,不静默兜底。
Debug.LogError($"EnemyAiBrain 未配置 _definitionId{name}", this);
enabled = false;
return;
}
var graph = AiDefinitionRegistry.GetGraph(_definitionId); // 不存在则抛异常
_context = new EnemyBrainContext(_enemy);
_runtime = new AiRuntime(graph, _context);
}
void Update()
{
if (_runtime == null) return;
float dt = Time.deltaTime;
_context.Refresh(dt);
_runtime.Tick(dt);
}
/// <summary>对象池复用时由 EnemyBase.OnSpawn 调用:回到 Entry、清临时态。</summary>
public void ResetBrain()
{
_context?.ResetScratch();
_runtime?.Reset();
}
/// <summary>信号入口,供 EnemyBase 转发死亡等事件到决策层。</summary>
public void Send(AiSignal signal) => _runtime?.Send(signal);
}
}
```
> 设计说明:本阶段每帧 Tick(不做 LOD 节流)——LOD/错峰调度属后续 AiScheduler 阶段。`AiRuntime.Reset()` 已清 Blackboard,但 `EnemyBrainContext` 的 `_lastKnown/_lostTimer` 另需 `ResetScratch()` 清(`AiRuntime` 不知道 context 内部字段)。
- [ ] **Step 2: 刷新并确认编译零错误**
`unity_execute_menu_item`("Assets/Refresh") → `unity_get_compilation_errors`(error) = 0。
- [ ] **Step 3: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/AIBrain/EnemyAiBrain.cs Assets/_Game/Scripts/Enemies/AIBrain/EnemyAiBrain.cs.meta
git commit -m "feat(ai): EnemyAiBrain 组件——承载 AiRuntime、逐帧推进、池复用重置、信号入口"
```
---
## Task 4: E001 的 AiScript 定义
**Files:**
- Create: `Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs`
- [ ] **Step 1: 创建 `E001CaoZhiAi.cs`**
```csharp
using BaseGames.AI;
namespace BaseGames.Enemies
{
/// <summary>
/// E001(草蛭)的 AI 定义:巡逻 → 追击(接触即伤害,无需攻击能力)→ 搜查 → 巡逻。
/// 数值目前用字面量常量;后续可外挂 SO 做热调。
/// </summary>
[AiDefinition("E001")]
public sealed class E001CaoZhiAi : AiScript
{
const float ContactRange = 1.2f; // 贴近判定(仅用于 Chase→Search 前的接近,接触伤害由 BodyContactDamage 触发)
const float LoseTimeout = 2.5f; // 丢失玩家判定
const float SearchTime = 3f; // 搜查后返回巡逻
protected override void Build(BrainBuilder b)
{
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)) // 冲向玩家,接触即由 BodyContactDamage 造成伤害
.To("Search").When(c => c.Sensor.LostFor(LoseTimeout), "LostFor");
b.State("Search")
.OnEnter(c => c.Mover.LookAround())
.To("Chase").When(c => c.Sensor.SeesPlayer(), "SeesPlayer")
.To("Patrol").After(SearchTime);
b.State("Dead")
.OnEnter(c => c.Mover.Stop());
}
}
}
```
> `ContactRange` 常量暂未用于转换(接触伤害由碰撞触发);保留供后续若加"贴脸减速/停顿"用。如实现时发现 E001 需要一个显式攻击能力,再加 Combat 态与 `c.Combat.UseAbility(...)`(先 Read E001 prefab 的 Abilities 子节点确认真实 abilityId)。
- [ ] **Step 2: 刷新并确认编译零错误;确认注册表能发现 E001**
`unity_execute_menu_item`("Assets/Refresh") → `unity_get_compilation_errors`(error) = 0。
`unity_execute_code`(port 7890) 执行断言:`BaseGames.AI.AiDefinitionRegistry.Has("E001")` 应为 true`GetGraph("E001").EntryState` 应为 `"Patrol"`
- [ ] **Step 3: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs.meta \
Assets/_Game/Scripts/Enemies/AIBrain/Ai.meta
git commit -m "feat(ai): E001(草蛭) AiScript 定义——巡逻/追击/搜查(接触伤害杂兵)"
```
---
## Task 5: EnemyBase 移除 Opsive BT 集成 + 接 EnemyAiBrain
**Files:**
- Modify: `Assets/_Game/Scripts/Enemies/EnemyBase.cs`
这是本阶段最需谨慎的改动。**务必先完整 Read `EnemyBase.cs` 确认每个 `#if GRAPH_DESIGNER` 块与目标方法的真实当前内容与行号**(下面行号为探查快照,可能因前面提交而漂移)。逐块处理、每步后刷新确认零编译错误。
- [ ] **Step 1: 先 Read `EnemyBase.cs`,定位所有 `#if GRAPH_DESIGNER ... #endif` 块**(用 Grep `GRAPH_DESIGNER`)。预期位置:using(~:8-10)、LOD 间隔字段(~:60-72)、调试字段(~:135-137)、`BehaviorTree` 属性(~:257-259)、`SetAggroTickRate`(~:376-380)、`SetAiPhase` 内 BT 间隔 switch(~:471-483)、Awake 挂树(~:606-618)、Update tick(~:629-648)、Update 调试写(~:654-656)、BT 字段(~:697-702)、`StopBehaviorTree()`(~:709-714)、OnSpawn(~:807-809)、OnDespawn(~:823-825)。
- [ ] **Step 2: 删除所有 Opsive/BT 专属代码**
-`#if GRAPH_DESIGNER using Opsive...#endif`
-`_btIdleTickInterval``_btCombatTickInterval` 等 LOD 间隔字段与 `_dbg_BtTickInterval` 调试字段。
-`public BehaviorTree BehaviorTree => _behaviorTree;` 属性。
- 删 Awake 里 `#if GRAPH_DESIGNER … _behaviorTree = GetComponent<BehaviorTree>() … #endif` 整块。
- 删 Update 里 `#if GRAPH_DESIGNER … _behaviorTree.Tick() … #endif` 整块与其后调试写行。
-`_behaviorTree/_btManualMode/_btTickTimer/_btCurrentInterval` 字段。
-`StopBehaviorTree()` 方法**体内**的 BT 调用;见 Step 4 关于该 public 方法的处理。
- 删 OnSpawn/OnDespawn 里的 `#if GRAPH_DESIGNER … _behaviorTree… #endif` 块(OnSpawn 的替换见 Step 4)。
- `SetAggroTickRate(bool)`:删其 BT 间隔赋值;若方法体因此为空,保留为空方法体并加注释 `// LOD tick 速率控制迁移到后续 AiScheduler 阶段`(不要删方法,避免改动调用方)。
- `SetAiPhase(...)`:只删其中设置 `_btCurrentInterval` 的 switch**保留** `_currentAiPhase` 赋值、`OnAiPhaseChanged` 广播、阶段动画等其余逻辑。
- [ ] **Step 3: 加 EnemyAiBrain 引用与 Awake 获取**
- 加字段:`private EnemyAiBrain _brain;`
- 在 Awake 的子系统收集处(如 `_abilities.CollectFrom(gameObject);` 附近)加:`_brain = GetComponent<EnemyAiBrain>();`
- [ ] **Step 4: 死亡发信号 + 池复用重置**
-`PerformDeath()`(死亡结算处,原先调用 `StopBehaviorTree`/`ForceState(Dead)` 的地方)加:`_brain?.Send(BaseGames.AI.AiSignal.Died);`
- `StopBehaviorTree()` 现无 BT 可停:把方法体改为 `_brain?.Send(BaseGames.AI.AiSignal.Died);`(保留方法名,因 `EnemyDeathSequence` 等外部调用它;语义变为"通知决策层终止")。若 Step 4 已在 PerformDeath 直接 Send,则 `StopBehaviorTree` 保留同一 Send 以兼容既有调用点即可(幂等,重复 Died 事件不会二次转换)。
-`OnSpawn()`(原 `#if GRAPH_DESIGNER _behaviorTree?.StartBehavior(); #endif` 处)替换为:`_brain?.ResetBrain();`
- [ ] **Step 5: 逐步刷新并确认零编译错误**
每删/改一处后 `unity_execute_menu_item`("Assets/Refresh") → `unity_get_compilation_errors`(error)。全部处理完应为 **0 error**。注意:删除 BT 字段后若有残留引用(如 debug inspector),一并清理。
- [ ] **Step 6: 跑全量 EditMode 测试确认无回归**
`unity_advanced_tool``unity_testing_run_tests`(EditMode) → `unity_testing_get_job`:应无失败(此改动不涉及 EditMode 测试,但确认未破坏编译面)。
- [ ] **Step 7: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/EnemyBase.cs
git commit -m "refactor(ai): EnemyBase 移除 Opsive BT 集成,改接 EnemyAiBrain(Died信号/OnSpawn重置)"
```
---
## Task 6: 脚手架一键挂载 `EnemyAiBrain``SceneObjectPlacerTool`
**Files:**
- Modify: `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs`
- [ ] **Step 1: 定位 `PlaceE001_CaoZhi` 里的行为树 TODO 提示行**
先 Read 该方法(~:439-530),找到 `report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E001_CaoZhi.asset。");`~:524)。
- [ ] **Step 2: 用真实挂载替换该提示行**
把该 `report.Add("★ …")` 行替换为(放在 `SetupPerceptionSystemSlots(...)` 之后、方法收尾之前):
```csharp
// BrainGraph AI:挂载决策组件并绑定定义 id(取代旧的 Opsive BehaviorTree
var brain = GetOrAddComponent<EnemyAiBrain>(go);
AssignString(brain, "_definitionId", "E001", report);
report.Add("✔ 已挂载 EnemyAiBrain 并绑定 AI 定义 id=E001BrainGraph)。");
```
> `EnemyAiBrain` 在 `BaseGames.Enemies``SceneObjectPlacerTool` 是 Editor 程序集,需确认 Editor asmdef 已引用 `BaseGames.Enemies`(放置工具本就 new/操作 EnemyBase,故必然已引用——若编译报找不到类型,检查 Editor asmdef references)。文件顶部按需加 `using BaseGames.Enemies;`。`AssignString` 为该文件内既有 private static 辅助(~:2333)。
- [ ] **Step 3: 刷新并确认编译零错误**
`unity_execute_menu_item`("Assets/Refresh") → `unity_get_compilation_errors`(error) = 0。
- [ ] **Step 4: 提交**
```bash
git add Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs
git commit -m "feat(ai): 脚手架 PlaceE001 一键挂载 EnemyAiBrain 并绑定 id=E001"
```
---
## Task 7: 集成验证(脚手架一键 → PlayMode 驱动 → 无 BehaviorTree
**Files:** 无(经 MCP 驱动 Unity 验证;如需可加临时验证脚本但完成后删除)。
- [ ] **Step 1: 准备一个干净场景并确认活动场景**
用 MCP`unity_scene_new`(或打开一个测试场景)。**务必确认活动场景正确**(避免污染其他场景,见项目约束)。记录场景名。
- [ ] **Step 2: 脚手架一键放置 E001**
用 MCP `unity_execute_menu_item`(menuPath `"BaseGames/Scene/Place/Enemy E001 (草蛭)"`, port 7890)。
然后 `unity_scene_hierarchy` / `unity_gameobject_info` 找到新建的 `ENM_CaoZhi`
- [ ] **Step 3: 静态断言——挂了 EnemyAiBrain、绑了 id、且无 BehaviorTree**
`unity_component_get_properties`(或 `unity_gameobject_info`)确认 `ENM_CaoZhi`
-`EnemyAiBrain` 组件,其 `_definitionId == "E001"`
- **没有** `BehaviorTree`Opsive)组件。
- [ ] **Step 4: PlayMode 冒烟——决策层随玩家远近推进**
- 确保场景内有玩家(或用 MCP 在玩家可被 E001 感知的位置放一个带 Player 标签/层的对象;若测试场景无玩家系统,可退而验证 Step 5 的状态读取)。
- `unity_play_mode`(enter) → 等一帧。
- `unity_execute_code`(port 7890) 读取:`GameObject.Find("ENM_CaoZhi").GetComponent<BaseGames.Enemies.EnemyAiBrain>().CurrentStateName`
- 无玩家在感知范围时应为 `"Patrol"`(证明 AiRuntime 在 Tick、状态机在跑)。
- 让玩家进入 E001 感知范围(移动玩家 Transform 到近处),再读 `CurrentStateName` 应变为 `"Chase"`;移开并等待 `LoseTimeout` 后应变 `"Search"`
- `unity_play_mode`(exit)。
- [ ] **Step 5: 记录验证结论**
在计划文件或提交信息里记录:脚手架一键产出的 E001 由 BrainGraph 驱动、无 BehaviorTree 组件、状态随感知推进。若无玩家系统导致 Chase/Search 未能触发,至少确认 Patrol 态在跑 + 无 BehaviorTree + 注册表解析 E001 成功,并记录"完整行为待真实关卡验证"。
- [ ] **Step 6: 运行项目自检(合规)**
用 MCP `unity_execute_menu_item` 依次跑(若菜单存在):
- `BaseGames/Tools/Validation/Validate All ScriptableObjects`
- `BaseGames/Addressables/Validate Address Keys`
- `BaseGames/Tools/Maintenance/Physics2D Layer Matrix/Check`
记录无新增错误。
- [ ] **Step 7: 提交验证产物(若有)/收尾**
```bash
# 若 Step 1 新建了测试场景且需要保留,按规范命名保存;否则不提交场景。
git add -- <仅本任务需保留的文件>
git commit -m "test(ai): 集成验证——脚手架一键 E001 跑在 BrainGraph 上、无 BehaviorTree"
```
> ⚠️ 只提交本任务确需保留的文件;不要 `git add -A`(仓库有既存无关改动)。
---
## 后续(不属本阶段)
- LOD/错峰 tickAiScheduler)、能力编排 sugar、参数 SO 热调(把 E001 字面量迁到 SO)。
- 其余敌人(E002-E006)与 Boss(ChaoFeng,多阶段/加权技能/弱点)迁移;`CharacterWizardWindow` 若要显式选 AI 定义再加下拉。
- 全部敌人达到 parity 后:删 49 个 `BD_*``BaseGames.Enemies.AI` 去 Opsive 引用、移除 `com.opsive.*` 包与 `GRAPH_DESIGNER` define。
- 交付面:MermaidExporter / AiDebugService / 运行时调试器窗口。
---
## 自检记录(writing-plans self-review
- **Spec 覆盖**:注册表(§7.1)、上下文适配(§4/单类实现,用户已选)、EnemyAiBrain 组件(§7.1)、EnemyBase 去 BT+信号+池复用重置(§7.1/§3.6/§3.8)、脚手架接线(§7.3/§2)、E001 试点(§8)、集成验证(§11.1)。LOD/编排/参数SO/其余敌人/删BD 明确列入后续。
- **占位扫描**:无 TBD。ContactRange 常量未用于转换已注明用途;ability 相关按"接触伤害"简化,注明若需攻击能力再加。
- **类型一致性**`AiDefinitionRegistry.GetGraph/Has/Ids``EnemyBrainContext`(实现 IAiContext+4 接口)、`EnemyAiBrain.CurrentStateName/ResetBrain/Send/DefinitionId``AiRuntime.Reset/Send/CurrentStateName/IsSuspended``AiScript.GetOrBuildGraph``[AiDefinition]` 跨任务一致。
- **风险点**Task 5EnemyBase 手术)最需谨慎——已要求先 Read 核对真实行号、逐块刷新验证;Task 2 的 EnemyBase 成员名以真实签名为准(要求核对不臆造);Task 7 依赖测试场景有玩家,已给降级验证路径。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,792 @@
# 敌人执行层统一 (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)。
@@ -0,0 +1,379 @@
# 敌人碰撞体 Sprite 驱动 + 统一 Box + 全员接触伤害区 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:** 让脚手架 `SceneObjectPlacerTool` 生成的每个敌人(含 Boss)的主体/HurtBox/ContactDamageZone 三者统一为 BoxCollider2D、同尺寸、底部对齐 y=0,尺寸由向导默认 Sprite 的包围盒推导(留空回退硬编码),且每个敌人都有 ContactDamageZone。
**Architecture:**`SceneObjectPlacerTool` 新增两个静态助手(`SpriteSizeOr``SetupHurtAndContactBoxes`),把各 `PlaceExxx` 里参差的 Capsule/Circle HurtBox/接触伤害创建统一替换为"主体 Box(size) + 助手建 HurtBox/ContactDamageZone(Box, size, trigger, 底部对齐)"`CharacterWizardWindow` 加一个可选默认 Sprite 字段并透传。只改这两个编辑器脚本,不动任何 prefab。
**Tech Stack:** Unity 2022.3 (C# 9)、UnityEditor、UI Toolkit(向导)、Unity MCP(端口 7890)做编译门与放置验证。
**设计文档:** `Docs/superpowers/specs/2026-07-10-enemy-sprite-driven-box-colliders-design.md`
**全局约定:**
- **编译门**`unity_execute_code`(port 7890) 跑 `AssetDatabase.Refresh(); CompilationPipeline.RequestScriptCompilation(); return "ok";` → Bash `sleep 9``unity_get_compilation_errors` severity=error 期望 count=0。桥接超时则 `unity_editor_ping` 等恢复再重试;长会话保持 Unity 窗口前台(失焦会卡死队列)。
- 每个 Task 结束提交一次(中文 + 类型前缀)。分支已在 `fix/e001-chase-facing-and-cd-patrol`(或按需另开)。
- **不修改任何 prefab**(用户后续手动重生成替换)。
- **放置验证**`PlaceExxx` 会在活动场景创建一个敌人 GameObject。验证时先确认活动场景是 TestRoomA(或任意可写场景),放置后读该对象的碰撞体,验证完 `DestroyImmediate` 清理,避免污染场景。
---
## 关键事实(实现前必读,均已核实)
- `EnemyBodyColliderType { Box, Capsule, Circle }`(SceneObjectPlacerTool.cs:44)`CreateBodyCollider(GameObject, EnemyBodyColliderType, Vector2 size)` 建主体碰撞体并 `AlignColliderBottomToPivot`
- `AlignColliderBottomToPivot(Collider2D)`Box/Capsule 设 `offset.y = size.y*0.5`Circle 设 `offset.y = radius` —— 底部对齐 y=0。
- `EnsureCollidersAreTriggers(GameObject)` 已存在:把节点全部 Collider2D 设 `isTrigger=true`
- `GetOrCreateChild(Transform, string)``GetOrAddComponent<T>(GameObject)``SetLayer(GameObject, string, List<string>)``AssignReference(Object, string, Object, List<string>)``FindFirstAsset(params string[])` 均已存在。
- `SetupSpriteRenderer(GameObject) → SpriteRenderer`:若 sr.sprite 为空则填一个默认 Square;返回 sr。
- 各敌人现有 fallback 主体尺寸(Vector2)E001=0.6×0.8、E002=0.5×0.7、E003=0.5×0.6、E004=0.8×1.2、E005=0.9×1.0、E006=0.7×1.0、ChaoFeng=1.2×2.0、PlaceEnemy(通用)=0.7×0.9、PlaceBossEnemy(通用)=1.5×2.5。
- E001 接触伤害现有接线:`BodyContactDamage.enabled=false`(伪装期不伤,追击能力开)`ContactChaseAbility._contactDamage` 绑该 BodyContactDamageContactDamageZone 的 `HitBox._defaultSource``FindFirstAsset("CMB_DS_EnemyBody","DS_EnemyBody")`
- 命名空间:`BaseGames.Combat`(HurtBox/HitBox)、`BaseGames.Enemies`(BodyContactDamage)。SceneObjectPlacerTool 顶部已 `using` 这些。
---
## Task 1: 新增两个助手 `SpriteSizeOr` / `SetupHurtAndContactBoxes`
**Files:**
- Modify: `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs`(紧邻现有 `EnsureCollidersAreTriggers` 助手之后)
- [ ] **Step 1: 加两个助手**
`EnsureCollidersAreTriggers` 方法之后插入:
```csharp
/// <summary>Sprite 有则用其世界包围盒尺寸(sprite.bounds.size),否则回退。</summary>
private static Vector2 SpriteSizeOr(Sprite s, Vector2 fallback)
=> s != null ? (Vector2)s.bounds.size : fallback;
/// <summary>
/// 建/更新 HurtBox 与 ContactDamageZone 两个子节点:均为 BoxCollider2D、同尺寸 size、
/// 底部对齐 y=0、isTrigger=true。ContactDamageZone 带 HitBox + BodyContactDamage。
/// contactEnabledBodyContactDamage 初始启用状态(不需要接触伤害的敌人可传 false 或后续置节点非激活)。
/// 返回 (hurtBox, bodyContact, contactHitBox) 供上层按需接线(如伤害源/能力引用)。
/// </summary>
private static (HurtBox hurt, BodyContactDamage contact, HitBox contactHitBox)
SetupHurtAndContactBoxes(GameObject root, Vector2 size, bool contactEnabled, List<string> report)
{
// HurtBox 子节点
var hurtT = GetOrCreateChild(root.transform, "HurtBox");
SetLayer(hurtT.gameObject, "EnemyHurtBox", report);
var hurtCol = GetOrAddComponent<BoxCollider2D>(hurtT.gameObject);
hurtCol.size = size;
hurtCol.isTrigger = true;
AlignColliderBottomToPivot(hurtCol);
var hurtBox = GetOrAddComponent<HurtBox>(hurtT.gameObject);
EnsureCollidersAreTriggers(hurtT.gameObject);
// ContactDamageZone 子节点
var contactT = GetOrCreateChild(root.transform, "ContactDamageZone");
SetLayer(contactT.gameObject, "EnemyHitBox", report);
var contactCol = GetOrAddComponent<BoxCollider2D>(contactT.gameObject);
contactCol.size = size;
contactCol.isTrigger = true;
AlignColliderBottomToPivot(contactCol);
var contactHitBox = GetOrAddComponent<HitBox>(contactT.gameObject);
var bodyContact = GetOrAddComponent<BodyContactDamage>(contactT.gameObject);
bodyContact.enabled = contactEnabled;
EnsureCollidersAreTriggers(contactT.gameObject);
return (hurtBox, bodyContact, contactHitBox);
}
```
注意:`HurtBox`/`HitBox``BaseGames.Combat``BodyContactDamage``BaseGames.Enemies`,文件顶部已 using。`GetOrAddComponent<BoxCollider2D>` 若节点已有别的 Collider2D 不会移除——但本任务不改现有 prefab,全新放置的节点只会有这一个 Box,无残留。
- [ ] **Step 2: 编译门** → count=0(此时助手未被调用,只验证语法/引用)。
- [ ] **Step 3: 提交**
```bash
git add Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs
git commit -m "feat(scaffold): 新增 SpriteSizeOr / SetupHurtAndContactBoxes 助手(Box 统一 HurtBox/接触伤害区)"
```
---
## Task 2: E001 改用助手 + sprite 参数(参考样板)
**Files:**
- Modify: `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs``PlaceE001_CaoZhi`
- [ ] **Step 1: 改签名加 sprite 参数**
把重载:
```csharp
public static void PlaceE001_CaoZhi() => PlaceE001_CaoZhi(EnemyBodyColliderType.Box);
public static void PlaceE001_CaoZhi(EnemyBodyColliderType bodyCollider)
```
改为:
```csharp
public static void PlaceE001_CaoZhi() => PlaceE001_CaoZhi(EnemyBodyColliderType.Box, null);
public static void PlaceE001_CaoZhi(EnemyBodyColliderType bodyCollider, Sprite defaultSprite = null)
```
- [ ] **Step 2: 用 sprite 推导 size、赋 SpriteRenderer、走助手**
E001 现有片段(约 457-489 行)大致为:
```csharp
Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(0.6f, 0.8f));
...
SpriteRenderer sr1 = SetupSpriteRenderer(visual.gameObject);
...
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report);
CapsuleCollider2D hurtCap = GetOrAddComponent<CapsuleCollider2D>(hurtBoxT.gameObject);
hurtCap.isTrigger = true;
hurtCap.size = new Vector2(0.55f, 0.75f);
AlignColliderBottomToPivot(hurtCap);
HurtBox hurtBox = GetOrAddComponent<HurtBox>(hurtBoxT.gameObject);
EnsureCollidersAreTriggers(hurtBoxT.gameObject);
Transform contactT = GetOrCreateChild(go.transform, "ContactDamageZone");
SetLayer(contactT.gameObject, "EnemyHitBox", report);
CircleCollider2D contactCol = GetOrAddComponent<CircleCollider2D>(contactT.gameObject);
contactCol.isTrigger = true;
contactCol.radius = 0.4f;
AlignColliderBottomToPivot(contactCol);
HitBox contactHitBox = GetOrAddComponent<HitBox>(contactT.gameObject);
BodyContactDamage bodyContact = GetOrAddComponent<BodyContactDamage>(contactT.gameObject);
bodyContact.enabled = false;
EnsureCollidersAreTriggers(contactT.gameObject);
```
改为(size 用 sprite,主体 Box(size)HurtBox/ContactDamageZone 走助手;E001 接触伤害仍禁用+由能力控制):
```csharp
Vector2 size = SpriteSizeOr(defaultSprite, new Vector2(0.6f, 0.8f));
Collider2D body = CreateBodyCollider(go, bodyCollider, size);
...
SpriteRenderer sr1 = SetupSpriteRenderer(visual.gameObject);
if (defaultSprite != null) sr1.sprite = defaultSprite;
...
var (hurtBox, bodyContact, contactHitBox) =
SetupHurtAndContactBoxes(go, size, contactEnabled: false, report); // E001 伪装期不伤,追击能力开
```
保留后续对 `bodyContact` / `contactHitBox` / `hurtBox` 的接线不变,即:
- `AssignReference(enemyBase, "_hurtBox", hurtBox, report)` 若原有(用返回的 hurtBox)。
- `AssignReference(chaseAbility, "_contactDamage", bodyContact, report)` 保留。
- 伤害源:`Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody", "DS_EnemyBody"); if (dmgSrc != null) AssignReference(contactHitBox, "_defaultSource", dmgSrc, report);` 保留。
> 实现前 Read 当前 `PlaceE001_CaoZhi` 全文,把上面被替换段之外的接线原样保留(只换碰撞体创建方式与 size 来源)。`sr1` 变量名沿用现有。
- [ ] **Step 3: 编译门** → count=0。
- [ ] **Step 4: 放置验证(MCP)**
`unity_execute_code`(port 7890):反射调用 `PlaceE001_CaoZhi(EnemyBodyColliderType.Box, null)`(无 sprite→回退 0.6×0.8),然后校验并清理:
```csharp
var t = System.AppDomain.CurrentDomain.GetAssemblies().SelectMany(a=>{try{return a.GetTypes();}catch{return new System.Type[0];}}).FirstOrDefault(x=>x.Name=="SceneObjectPlacerTool");
var m = t.GetMethod("PlaceE001_CaoZhi", new[]{ t.GetNestedType("EnemyBodyColliderType") ?? typeof(int), typeof(UnityEngine.Sprite) });
// 若上面拿不到,改用带默认参数的 2-参重载:
m = t.GetMethods().First(x=>x.Name=="PlaceE001_CaoZhi" && x.GetParameters().Length==2);
var enumType = m.GetParameters()[0].ParameterType;
m.Invoke(null, new object[]{ System.Enum.ToObject(enumType, 0), null });
var go = UnityEngine.GameObject.Find("ENM_CaoZhi");
var sb=new System.Text.StringBuilder();
foreach(var name in new[]{"","HurtBox","ContactDamageZone"}){
var node = name==""? go.transform : go.transform.Find(name);
var col = node.GetComponent<UnityEngine.Collider2D>();
string label = name==""? "BODY" : name;
if(col is UnityEngine.BoxCollider2D b)
sb.AppendLine(label+": Box size="+b.size.ToString("F2")+" isTrigger="+b.isTrigger+" offset.y="+b.offset.y.ToString("F3")+" (期望 offset.y=size.y/2="+(b.size.y*0.5f).ToString("F3")+")");
else sb.AppendLine(label+": "+(col!=null?col.GetType().Name:"NO COLLIDER"));
}
sb.AppendLine("ContactDamageZone.BodyContactDamage.enabled="+go.transform.Find("ContactDamageZone").GetComponent<BaseGames.Enemies.BodyContactDamage>().enabled+" (E001 期望 false)");
UnityEngine.Object.DestroyImmediate(go); // 清理
return sb.ToString();
```
期望:BODY/HurtBox/ContactDamageZone 都是 Box、size=(0.60,0.80)、HurtBox/Contact isTrigger=True、BODY isTrigger=False、三者 offset.y=size.y/2、ContactDamage.enabled=False。
- [ ] **Step 5: 提交**
```bash
git add Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs
git commit -m "refactor(scaffold): E001 用统一助手建 Box HurtBox/接触伤害区 + sprite 尺寸参数"
```
---
## Task 3: E002-E006 改用助手 + sprite 参数
**Files:**
- Modify: `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs``PlaceE002_HuangZhi``PlaceE003_YouZhi_Enemy``PlaceE004_ZhiMu_Enemy``PlaceE005_FeiZhi_Enemy``PlaceE006_Huan`
对每个敌人应用**相同变换**(下方 fallback size 逐敌人不同):
- [ ] **Step 1: 每个敌人改签名加 sprite 参数**
例(E002)
```csharp
public static void PlaceE002_HuangZhi() => PlaceE002_HuangZhi(EnemyBodyColliderType.Box, null);
public static void PlaceE002_HuangZhi(EnemyBodyColliderType bodyCollider, Sprite defaultSprite = null)
```
E003/E004/E005/E006 同理(保持各自方法名)。
- [ ] **Step 2: 每个敌人替换碰撞体创建**
对每个 `PlaceExxx`Read 其当前实现,然后:
1. 主体:`Vector2 size = SpriteSizeOr(defaultSprite, <fallback>);``CreateBodyCollider(go, bodyCollider, size);`(替换原 `new Vector2(...)` 硬编码)。
2. SpriteRenderer:找到该敌人的 `SetupSpriteRenderer(...)` 调用(变量如 sr/sr3/sr5),其后加 `if (defaultSprite != null) <sr>.sprite = defaultSprite;`
3. HurtBox + ContactDamageZone:删除该敌人现有的 HurtBox(Capsule) 与(若有)hit/ContactDamageZone(Circle) 创建代码,改为:
`var (hurtBox, bodyContact, contactHitBox) = SetupHurtAndContactBoxes(go, size, contactEnabled: true, report);`
E002/E004/E005 原本**没有** ContactDamageZone——现在统一补上,contactEnabled=true。)
4. 保留该敌人原有对 `hurtBox` 的接线(如 `AssignReference(enemyBase, "_hurtBox", hurtBox, report)`;用返回的 hurtBox 替换原局部变量名)。若该敌人的某个攻击能力原本引用了旧 HurtBox 变量,改用返回的 `hurtBox`
5. 伤害源接线(统一加,若该敌人原本没有):`Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody", "DS_EnemyBody"); if (dmgSrc != null) AssignReference(contactHitBox, "_defaultSource", dmgSrc, report);`
**逐敌人 fallback size**
- E002 `PlaceE002_HuangZhi``new Vector2(0.5f, 0.7f)`
- E003 `PlaceE003_YouZhi_Enemy``new Vector2(0.5f, 0.6f)`
- E004 `PlaceE004_ZhiMu_Enemy``new Vector2(0.8f, 1.2f)`
- E005 `PlaceE005_FeiZhi_Enemy``new Vector2(0.9f, 1.0f)`
- E006 `PlaceE006_Huan``new Vector2(0.7f, 1.0f)`
> 注意:某些敌人(如 E002)的攻击能力有独立的 `_attackHitBox` / `_hurtBox` 引用——这些是**能力自己的 HitBox**,与本任务的 HurtBox/ContactDamageZone 无关,勿动;只替换"主体受击 HurtBox"与"接触伤害 ContactDamageZone"两处。实现前务必 Read 每个 PlaceExxx 全文分清。
- [ ] **Step 3: 编译门** → count=0。
- [ ] **Step 4: 放置验证(MCP)**
对 E002-E006 各放置一次并校验(同 Task 2 的验证代码,替换方法名与期望 GameObject 名 ENM_HuangZhi/ENM_YouZhi/ENM_ZhiMu/ENM_FeiZhi/ENM_Huan,期望 size=各 fallback、三者 Box+底部对齐、HurtBox/Contact isTrigger=True、ContactDamage.enabled=True)。每次验证后 `DestroyImmediate` 清理。
- [ ] **Step 5: 提交**
```bash
git add Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs
git commit -m "refactor(scaffold): E002-E006 统一 Box HurtBox/接触伤害区 + sprite 尺寸(补全缺失的接触伤害区)"
```
---
## Task 4: ChaoFeng(Boss) + 通用 PlaceEnemy/PlaceBossEnemy 改用助手 + sprite
**Files:**
- Modify: `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs``PlaceChaoFeng``PlaceEnemy``PlaceBossEnemy`
- [ ] **Step 1: PlaceChaoFeng 加 sprite 参数 + 助手**
```csharp
public static void PlaceChaoFeng() => PlaceChaoFeng(EnemyBodyColliderType.Box, null);
public static void PlaceChaoFeng(EnemyBodyColliderType bodyCollider, Sprite defaultSprite = null)
```
体内:`Vector2 size = SpriteSizeOr(defaultSprite, new Vector2(1.2f, 2.0f));``CreateBodyCollider(go, bodyCollider, size);`SpriteRenderer 后加 sprite 赋值;HurtBox 段(Capsule)替换为 `var (hurtBox, bodyContact, contactHitBox) = SetupHurtAndContactBoxes(go, size, contactEnabled: true, report);`(Boss 原**无** ContactDamageZone,现补上,默认启用,策划按需置非激活);保留 Boss 原有 hurtBox 接线;加伤害源接线(同上)。
- [ ] **Step 2: 通用 PlaceEnemy / PlaceBossEnemy 同样处理**
`PlaceEnemy(EnemyBodyColliderType, Sprite defaultSprite=null)` fallback `new Vector2(0.7f, 0.9f)``PlaceBossEnemy(...)` fallback `new Vector2(1.5f, 2.5f)`。各自:size 用 sprite、CreateBodyCollider(size)、sprite 赋 Sr、`SetupHurtAndContactBoxes(go, size, contactEnabled: true, report)`(若原本没有 HurtBox/ContactDamageZone 则新增;有则替换)。加无参重载 `=> PlaceXxx(EnemyBodyColliderType.Box, null)` 若需要。
- [ ] **Step 3: 编译门** → count=0。
- [ ] **Step 4: 放置验证(MCP)**:放置 ChaoFeng(ENM_ChaoFeng),校验三者 Box、size=(1.20,2.00)、HurtBox/Contact isTrigger=True、底部对齐、ContactDamageZone 存在。`DestroyImmediate` 清理。
- [ ] **Step 5: 提交**
```bash
git add Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs
git commit -m "refactor(scaffold): ChaoFeng + 通用 Place 统一 Box HurtBox/接触伤害区 + sprite 尺寸"
```
---
## Task 5: 向导 CharacterWizardWindow 加默认 Sprite 字段并透传
**Files:**
- Modify: `Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs`
- [ ] **Step 1: 加字段**
在字段区(约 `_enemyBodyCollider` 声明附近)加:
```csharp
private Sprite _defaultSprite; // 敌人/Boss 默认外观 Sprite:碰撞体尺寸依据(可留空)
```
- [ ] **Step 2: 小怪 Tab 加 ObjectField**
`RefreshEnemyTabContent` 里的 `colliderField`(EnumField "主体碰撞器类型")之后加:
```csharp
var spriteField = new UnityEditor.UIElements.ObjectField("默认外观 Sprite(碰撞体尺寸依据,可留空)")
{ objectType = typeof(Sprite), allowSceneObjects = false, value = _defaultSprite };
spriteField.RegisterValueChangedCallback(evt => _defaultSprite = evt.newValue as Sprite);
container.Add(spriteField);
```
- [ ] **Step 3: 小怪放置调用透传 sprite**
`PlaceSpecificEnemy(string id, EnemyBodyColliderType bodyCollider)` 改签名加 `Sprite defaultSprite`
```csharp
private static void PlaceSpecificEnemy(string id, SceneObjectPlacerTool.EnemyBodyColliderType bodyCollider, Sprite defaultSprite)
{
switch (id)
{
case "E001": SceneObjectPlacerTool.PlaceE001_CaoZhi(bodyCollider, defaultSprite); break;
case "E002": SceneObjectPlacerTool.PlaceE002_HuangZhi(bodyCollider, defaultSprite); break;
case "E003": SceneObjectPlacerTool.PlaceE003_YouZhi_Enemy(bodyCollider, defaultSprite); break;
case "E004": SceneObjectPlacerTool.PlaceE004_ZhiMu_Enemy(bodyCollider, defaultSprite); break;
case "E005": SceneObjectPlacerTool.PlaceE005_FeiZhi_Enemy(bodyCollider, defaultSprite); break;
case "E006": SceneObjectPlacerTool.PlaceE006_Huan(bodyCollider, defaultSprite); break;
default:
Debug.LogError($"[CharacterWizardWindow] 未注册的敌人 id '{id}'。");
SceneObjectPlacerTool.PlaceEnemy(bodyCollider, defaultSprite);
break;
}
}
```
并把调用处 `PlaceSpecificEnemy(id, _enemyBodyCollider)` 改为 `PlaceSpecificEnemy(id, _enemyBodyCollider, _defaultSprite)``RefreshEnemyTabContent` 里的 `MakeSceneButton(sceneLabel, () => PlaceSpecificEnemy(id, _enemyBodyCollider))`)。
- [ ] **Step 4: Boss Tab 加 ObjectField + 透传**
`BuildBossTab` 的 "放置嘲风到场景并绑定 SO" 按钮之前加同款 spriteField(绑 `_defaultSprite`);把该按钮改为 `MakeSceneButton("放置嘲风到场景并绑定 SO", () => SceneObjectPlacerTool.PlaceChaoFeng(SceneObjectPlacerTool.EnemyBodyColliderType.Box, _defaultSprite))`
- [ ] **Step 5: 编译门** → count=0。
- [ ] **Step 6: 提交**
```bash
git add Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs
git commit -m "feat(wizard): 敌人/Boss 加默认外观 Sprite 字段并透传给脚手架(碰撞体尺寸依据)"
```
---
## Task 6: 端到端 Sprite 驱动验证 + 收尾
**Files:** 无(仅 MCP 验证)
- [ ] **Step 1: 用一张真实 Sprite 走一次放置**,验证碰撞体尺寸 = sprite 包围盒
`unity_execute_code`(port 7890)
```csharp
// 取任意一张 sprite 资产
var spriteGuid = UnityEditor.AssetDatabase.FindAssets("t:Sprite").FirstOrDefault();
var sprite = spriteGuid!=null ? UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.Sprite>(UnityEditor.AssetDatabase.GUIDToAssetPath(spriteGuid)) : null;
if(sprite==null) return "no sprite asset found";
var t = System.AppDomain.CurrentDomain.GetAssemblies().SelectMany(a=>{try{return a.GetTypes();}catch{return new System.Type[0];}}).FirstOrDefault(x=>x.Name=="SceneObjectPlacerTool");
var m = t.GetMethods().First(x=>x.Name=="PlaceE002_HuangZhi" && x.GetParameters().Length==2);
var enumType = m.GetParameters()[0].ParameterType;
m.Invoke(null, new object[]{ System.Enum.ToObject(enumType, 0), sprite });
var go = UnityEngine.GameObject.Find("ENM_HuangZhi");
var body = go.GetComponent<UnityEngine.BoxCollider2D>();
var sb=new System.Text.StringBuilder();
sb.AppendLine("sprite.bounds.size="+((UnityEngine.Vector2)sprite.bounds.size).ToString("F3"));
sb.AppendLine("body Box size="+body.size.ToString("F3")+" (期望=sprite.bounds.size)");
sb.AppendLine("HurtBox size="+go.transform.Find("HurtBox").GetComponent<UnityEngine.BoxCollider2D>().size.ToString("F3"));
sb.AppendLine("Contact size="+go.transform.Find("ContactDamageZone").GetComponent<UnityEngine.BoxCollider2D>().size.ToString("F3"));
sb.AppendLine("SpriteRenderer.sprite="+(go.GetComponentInChildren<UnityEngine.SpriteRenderer>()?.sprite?.name ?? "null"));
UnityEngine.Object.DestroyImmediate(go);
return sb.ToString();
```
期望:body/HurtBox/Contact size 三者一致且 = `sprite.bounds.size`SpriteRenderer.sprite = 该 sprite 名。
- [ ] **Step 2: 自检**`BaseGames/Tools/Maintenance/Physics2D Layer Matrix/Check` 期望无新增不符(层未变)。
- [ ] **Step 3: grep 确认无残留 Capsule/Circle HurtBox/接触伤害创建**
```
rg -n "CapsuleCollider2D hurt|CircleCollider2D contact|CircleCollider2D hit" Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs
```
期望:0 命中(HurtBox/接触伤害区的 Capsule/Circle 创建都已换成助手;主体的 CreateBodyCollider 里 Capsule/Circle 分支保留属正常)。
- [ ] **Step 4: 更新记忆** `editor_scaffold_tools.md` / `scaffold_tools_required.md`:记录敌人碰撞体现由 sprite 驱动、三者统一 Box+底部对齐、全员有 ContactDamageZone。
---
## Self-Review(作者已核对)
- **Spec 覆盖**:§A 向导 sprite 字段→Task 5;§B 助手→Task 1、各敌人改造→Task 2/3/4(含补全 4 个缺失 ContactDamageZone、E001 contactEnabled=false 特例、伤害源接线);§C 不动 prefab→全程无 prefab 操作;§D 验证→各 Task Step 4 + Task 6。主体默认 Box→CreateBodyCollider 默认分支;底部对齐→助手内 AlignColliderBottomToPivot。
- **占位符**:无 TBD;助手给完整代码;per-enemy 改造给"读现有→套模式+具体 fallback size+接线说明",因每个 PlaceExxx 的能力接线各异,显式要求实现前 Read 全文(属机械变换)。
- **类型一致**`SpriteSizeOr(Sprite,Vector2)→Vector2``SetupHurtAndContactBoxes(GameObject,Vector2,bool,List<string>)→(HurtBox,BodyContactDamage,HitBox)` 全程一致;`EnemyBodyColliderType` 参数名/默认值一致;`PlaceExxx(bodyCollider, defaultSprite=null)` 签名贯穿 Task 2-5。
- **已知风险**:各 PlaceExxx 内的攻击能力自带 HitBox(如 E002 `_attackHitBox`)与本任务的主体 HurtBox/ContactDamageZone 不同,Task 3 已显式提示勿混淆——实现前必须 Read 每个方法全文。