Files
zeling_v2/Docs_Dev/superpowers/plans/2026-07-21-enemy-ability-config-to-so.md
T

744 lines
36 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.
# 敌人能力配置迁移到 AbilitySO 子类 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:** 把 8 个能力 MonoBehaviour 上直接 `[SerializeField]` 挂载的全部数值/资产参数(动画片段、速度、时长、次数、LayerMask 等)迁移到对应的 `EnemyAbilitySO` 子类 SO;场景对象组件引用(BodyContactDamage/HitBox/HurtBox)保留在脚本。
**Architecture:** `EnemyAbilitySO` 保持基类;为 8 个能力各建 `XxxAbilitySO : EnemyAbilitySO` 子类(放 `Abilities/Configs/`,带 `[CreateAssetMenu]`)承载其参数。`EnemyAbilityBase` 新增 `ResolveConfig<T>()`:把 `_config` 解析为期望子类型,类型不符**显式报错**(无兜底)。每个能力 MonoBehaviour 删除迁走字段、`Awake` 缓存类型化 `_cfg`、使用处改读 `_cfg.xxx``_cfg==null` 时安全短路。更新向导 `CharacterWizardWindow` 的批量创建入口按能力类型建正确子类。不动任何 prefab/资产(用户手动重生成)。
**Tech Stack:** Unity 2022.3 (C# 9)、Animancer(`ClipTransition`)、ScriptableObject、UnityEditor、Unity Test Framework(EditMode)、Unity MCP(端口 7890) 做编译门与结构验证。
**设计文档:** `Docs_Dev/superpowers/specs/2026-07-21-enemy-ability-config-to-so-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_execute_code` 内用 `Thread.Sleep`(会冻结 Unity 主线程);跨调用等待用 Bash `sleep`。桥接卡死则 `unity_editor_ping` 等恢复;保持 Unity 窗口前台。多实例时选 `zeling_v2`
- 分支已在 `refactor/ability-config-to-so`。每个 Task 结束提交一次(中文 + 类型前缀)。每次只 `git add` 本 Task 涉及的文件。
- **不修改任何 prefab / `ABL_*.asset`**(用户后续手动重生成替换)。
- 字段命名:SO 内公有字段去掉下划线前缀(对齐 `EnemyAbilitySO`/`EnemyAttackSO` 公有字段风格),保留原 `[Header]/[Tooltip]/[Min]/[Range]` 语义。
---
## 关键事实(实现前必读,均已核实)
- `EnemyAbilityBase``Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs`):`[SerializeField] protected EnemyAbilitySO _config;`(第 22 行);`protected virtual void Awake()`(第 46 行,做依赖缓存);`protected EnemyBase _enemy; AnimancerComponent _animancer; Transform _transform;`。子类 MonoBehaviour 可访问 `_config`protected)。
- `EnemyAbilitySO`(同目录):公有字段 `abilityId, attackSequence(EnemyAttackSO[]), cooldown, telegraphVfxKey, telegraphDuration, interruptOnHurt, interruptOnStagger, …, exclusionGroup, priority`。带 `[CreateAssetMenu(menuName="BaseGames/Enemies/Enemy Ability", fileName="EAB_")]`
- `ClipTransition` 是 Animancer 结构体,可作 public 字段序列化到 SO;`clip.Clip` 取底层 `AnimationClip`
- 部分能力读 `_config?.attackSequence?[0].damageSource`RepeatSlam/CeilingHangStrike/MeleeVulnerability)——这是**基类字段**,迁移后 `_config` 仍指向子类实例(is-a EnemyAbilitySO),这些行**保持不变**。
- EditMode 测试程序集 `Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef` 已引用 `BaseGames.Enemies`,可直接测试能力类型。
- 能力→子类 SO 映射(供 Task 8 向导用,来自 `SceneObjectPlacerTool` 组件挂载):
- `ContactChaseAbility``ContactChaseAbilitySO`E001 Chase、E006 Chase
- `CeilingHangStrikeAbility``CeilingHangStrikeAbilitySO`E002 CeilingStrike
- `AnimatedCeilingDropAbility``AnimatedCeilingDropAbilitySO`E003 Fall
- `AppearAbility``AppearAbilitySO`E004 Appear
- `RepeatSlamAbility``RepeatSlamAbilitySO`E004 HeadSlam
- `FacePlayerAbility``FacePlayerAbilitySO`E004 Flip
- `MeleeAttackAbility`/`ProjectileAttackAbility`/`LeapAttackAbility` → 基类 `EnemyAbilitySO`attackSequence 型,不迁移;E004/E005 Bite、Acid、E006 Leap
- E001 Alerte001_alert)→ 基类 `EnemyAbilitySO`(无对应迁移能力)
- `MeleeVulnerabilityAbility`/`PlayClipAbility` → 各自子类 SO(框架能力,当前无向导 def 映射,仅 CreateAssetMenu
---
## Task 1: 基类 `ResolveConfig<T>` + EditMode 测试
**Files:**
- Modify: `Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs`
- Create: `Assets/Tests/EditMode/Abilities/AbilityConfigResolveTests.cs`
- [ ] **Step 1: 写失败测试**
创建 `Assets/Tests/EditMode/Abilities/AbilityConfigResolveTests.cs`
```csharp
using System.Collections;
using System.Text.RegularExpressions;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using BaseGames.Enemies.Abilities;
namespace BaseGames.Tests.EditMode.Abilities
{
public class AbilityConfigResolveTests
{
// 测试专用 SO 子类型(不依赖真实 8 个子类,保持本 Task 自包含)
private sealed class ProbeConfigSO : EnemyAbilitySO { }
private sealed class OtherConfigSO : EnemyAbilitySO { }
// 测试探针:跳过基类 Awake 依赖装配,暴露 protected 成员
private sealed class ProbeAbility : EnemyAbilityBase
{
protected override void Awake() { /* 跳过依赖装配,避免测试噪声 */ }
protected override IEnumerator ExecuteCoroutine() { yield break; }
public void SetConfig(EnemyAbilitySO c) => _config = c;
public T Probe<T>() where T : EnemyAbilitySO => ResolveConfig<T>();
}
private ProbeAbility Make() => new GameObject("probe").AddComponent<ProbeAbility>();
[Test]
public void ResolveConfig_CorrectType_ReturnsInstance()
{
var probe = Make();
var cfg = ScriptableObject.CreateInstance<ProbeConfigSO>();
probe.SetConfig(cfg);
Assert.AreSame(cfg, probe.Probe<ProbeConfigSO>());
Object.DestroyImmediate(cfg);
Object.DestroyImmediate(probe.gameObject);
}
[Test]
public void ResolveConfig_WrongType_LogsError_ReturnsNull()
{
var probe = Make();
probe.SetConfig(ScriptableObject.CreateInstance<OtherConfigSO>());
LogAssert.Expect(LogType.Error, new Regex("_config 需为"));
Assert.IsNull(probe.Probe<ProbeConfigSO>());
Object.DestroyImmediate(probe.gameObject);
}
[Test]
public void ResolveConfig_Null_LogsError_ReturnsNull()
{
var probe = Make();
probe.SetConfig(null);
LogAssert.Expect(LogType.Error, new Regex("_config 需为"));
Assert.IsNull(probe.Probe<ProbeConfigSO>());
Object.DestroyImmediate(probe.gameObject);
}
}
}
```
- [ ] **Step 2: 编译门确认测试因缺 `ResolveConfig` 编译失败**`unity_get_compilation_errors` 应报 `ResolveConfig` 不存在(预期红)。
- [ ] **Step 3: 在 `EnemyAbilityBase` 加 `ResolveConfig<T>`**
`FaceTarget` 方法之后(类内、`EnemyAbilityWaits` 之前)插入:
```csharp
/// <summary>把 _config 解析为期望的子类型;类型不符/为空时显式报错(不回退)。</summary>
protected T ResolveConfig<T>() where T : EnemyAbilitySO
{
if (_config is T typed) return typed;
Debug.LogError($"[{GetType().Name}] _config 需为 {typeof(T).Name}" +
$"实际 = {(_config != null ? _config.GetType().Name : "null")}。请重建为正确的子类 SO。", this);
return null;
}
```
- [ ] **Step 4: 编译门** → count=0。
- [ ] **Step 5: 跑 EditMode 测试**Unity Test Runner
`unity_execute_code`(port 7890) 通过反射跑测试类(或用编辑器 Test Runner)。若用反射直跑:调用三个 `[Test]` 方法,捕获 `AssertionException``LogAssert.Expect` 需在 Test Runner 环境内生效,若反射直跑不便,改为在编辑器 `Window > General > Test Runner > EditMode > Run` 手动确认 3/3 通过(在报告中注明验证方式)。期望:3 通过。
- [ ] **Step 6: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs Assets/Tests/EditMode/Abilities/AbilityConfigResolveTests.cs
git commit -m "feat(enemy): EnemyAbilityBase 加 ResolveConfig<T> 类型化配置解析(不匹配显式报错) + EditMode 测试"
```
---
## Task 2: ContactChaseAbilitySO + 迁移 ContactChaseAbility(参考样板)
**Files:**
- Create: `Assets/_Game/Scripts/Enemies/Abilities/Configs/ContactChaseAbilitySO.cs`
- Modify: `Assets/_Game/Scripts/Enemies/Abilities/ContactChaseAbility.cs`
- [ ] **Step 1: 建子类 SO**
```csharp
using Animancer;
using UnityEngine;
namespace BaseGames.Enemies.Abilities
{
/// <summary>ContactChaseAbility 的配置:起手/循环/收招动画 + 冲刺速度。</summary>
[CreateAssetMenu(menuName = "BaseGames/Enemies/Abilities/Contact Chase Ability", fileName = "ABL_")]
public class ContactChaseAbilitySO : EnemyAbilitySO
{
[Header("动画(起手 / 循环 / 收招)")]
public ClipTransition startClip; // Skill_Start:张口(一次性)
public ClipTransition loopClip; // Skill_Loop:快速爬行追击(循环)
public ClipTransition endClip; // Skill_End:收招(一次性)
[Header("冲刺")]
[Tooltip("冲刺速度;<=0 时回退用 EnemyStatsSO.RunSpeed(追击速度)")]
public float dashSpeed = 0f;
}
}
```
- [ ] **Step 2: 迁移 MonoBehaviour**
先 Read `ContactChaseAbility.cs` 全文。做以下改动:
(a) 删除字段块(第 16-19 行 `_startClip/_loopClip/_endClip` 与第 24-26 行 `_dashSpeed`),**保留** `[Header("接触伤害")] _contactDamage`。删除后字段区变为:
```csharp
[Header("接触伤害")]
[SerializeField] private BodyContactDamage _contactDamage;
private ContactChaseAbilitySO _cfg;
protected override void Awake()
{
base.Awake();
_cfg = ResolveConfig<ContactChaseAbilitySO>();
}
```
(b) 使用处替换(`ExecuteCoroutine` 顶部加空配置守卫):
- `ExecuteCoroutine` 第一行 `Phase = AbilityRunState.Active;` 前加:`if (_cfg == null) yield break;`
- `_startClip.Clip``_cfg.startClip.Clip`(两处:判空 + `LockAnim(_startClip.Clip.length)` + `_animancer.Play(_startClip)` + `EnemyAbilityWaits.Get(_startClip.Clip.length)`
- `_dashSpeed``_cfg.dashSpeed`
- `_loopClip.Clip`/`_animancer.Play(_loopClip)``_cfg.loopClip.Clip`/`_animancer.Play(_cfg.loopClip)`
- `_endClip` 全部(`OnInterrupted``PlaySkillEnd`)→ `_cfg.endClip``OnInterrupted` 内条件改为 `if (reason == InterruptReason.ExternalRequest && _cfg != null && _cfg.endClip.Clip != null)``PlaySkillEnd` 首行改为 `if (_cfg == null || _cfg.endClip.Clip == null) yield break;`
> 场景引用 `_contactDamage` 及其 `DisableContact()/Cleanup()` 逻辑不动。
- [ ] **Step 3: 编译门** → count=0。
- [ ] **Step 4: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/Abilities/Configs/ContactChaseAbilitySO.cs Assets/_Game/Scripts/Enemies/Abilities/ContactChaseAbility.cs
git commit -m "refactor(enemy): ContactChase 参数迁入 ContactChaseAbilitySO(子类)"
```
---
## Task 3: RepeatSlamAbilitySO + 迁移 RepeatSlamAbility
**Files:**
- Create: `Assets/_Game/Scripts/Enemies/Abilities/Configs/RepeatSlamAbilitySO.cs`
- Modify: `Assets/_Game/Scripts/Enemies/Abilities/RepeatSlamAbility.cs`
- [ ] **Step 1: 建子类 SO**
```csharp
using Animancer;
using UnityEngine;
namespace BaseGames.Enemies.Abilities
{
/// <summary>RepeatSlamAbility 的配置:起手/循环/收招动画 + 砸地行为参数。</summary>
[CreateAssetMenu(menuName = "BaseGames/Enemies/Abilities/Repeat Slam Ability", fileName = "ABL_")]
public class RepeatSlamAbilitySO : EnemyAbilitySO
{
[Header("动画")]
public ClipTransition startClip;
public ClipTransition loopClip;
public ClipTransition endClip;
[Header("行为配置")]
[Tooltip("每次砸地时 HitBox 激活时长(秒)")]
public float hitActiveTime = 0.15f;
[Tooltip("砸地次数")]
public int slamCount = 2;
[Tooltip("最后一次砸地收招后额外等待时间(秒)")]
public float staggerDuration = 1.2f;
}
}
```
- [ ] **Step 2: 迁移 MonoBehaviour**
Read `RepeatSlamAbility.cs` 全文。改动:
(a) 删除第 15-18 行动画字段块与第 23-29 行 `_hitActiveTime/_slamCount/_staggerDuration`**保留** `[Header("打击")] HitBox _hitBox`。字段区改为:
```csharp
[Header("打击")]
[SerializeField] private HitBox _hitBox;
private RepeatSlamAbilitySO _cfg;
protected override void Awake()
{
base.Awake();
_cfg = ResolveConfig<RepeatSlamAbilitySO>();
}
```
(b) 使用处替换:
- `ExecuteCoroutine``Phase = AbilityRunState.Active;` 前加 `if (_cfg == null) yield break;`
- `_startClip``_cfg.startClip`(判空 + Play + `Get(_startClip.Clip.length)`
- `_slamCount``_cfg.slamCount`
- `_loopClip``_cfg.loopClip``_hitActiveTime``_cfg.hitActiveTime`(第 46、52 行)
- `_endClip``_cfg.endClip``_staggerDuration``_cfg.staggerDuration`(第 59-66 行两处)
- `_config?.attackSequence?.Length > 0 ? _config.attackSequence[0].damageSource : null` **保持不变**(基类字段)。
- `OnInterrupted``_hitBox?.Deactivate();` 不动。
- [ ] **Step 3: 编译门** → count=0。
- [ ] **Step 4: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/Abilities/Configs/RepeatSlamAbilitySO.cs Assets/_Game/Scripts/Enemies/Abilities/RepeatSlamAbility.cs
git commit -m "refactor(enemy): RepeatSlam 参数迁入 RepeatSlamAbilitySO(子类)"
```
---
## Task 4: CeilingHangStrikeAbilitySO + 迁移 CeilingHangStrikeAbility
**Files:**
- Create: `Assets/_Game/Scripts/Enemies/Abilities/Configs/CeilingHangStrikeAbilitySO.cs`
- Modify: `Assets/_Game/Scripts/Enemies/Abilities/CeilingHangStrikeAbility.cs`
- [ ] **Step 1: 建子类 SO**
```csharp
using Animancer;
using UnityEngine;
namespace BaseGames.Enemies.Abilities
{
/// <summary>CeilingHangStrikeAbility 的配置:出击/悬挂/收回动画 + 悬挂窗口时长。</summary>
[CreateAssetMenu(menuName = "BaseGames/Enemies/Abilities/Ceiling Hang Strike Ability", fileName = "ABL_")]
public class CeilingHangStrikeAbilitySO : EnemyAbilitySO
{
[Header("动画")]
public ClipTransition strikeClip;
public ClipTransition loopClip;
public ClipTransition endClip;
[Header("行为")]
[Tooltip("脆弱悬挂窗口持续时间(秒)")]
public float hangDuration = 2f;
}
}
```
- [ ] **Step 2: 迁移 MonoBehaviour**
Read `CeilingHangStrikeAbility.cs`。改动:
(a) 删第 15-18 行动画字段块 + 第 24-26 行 `_hangDuration`**保留** `[Header("碰撞")] HitBox _attackHitBox; HurtBox _hurtBox`。字段区改为:
```csharp
[Header("碰撞")]
[SerializeField] private HitBox _attackHitBox;
[SerializeField] private HurtBox _hurtBox;
private CeilingHangStrikeAbilitySO _cfg;
protected override void Awake()
{
base.Awake();
_cfg = ResolveConfig<CeilingHangStrikeAbilitySO>();
}
```
(b) 使用处替换:
- `ExecuteCoroutine``Phase = AbilityRunState.Active;` 前加 `if (_cfg == null) yield break;`
- `_strikeClip``_cfg.strikeClip`(判空 + Play + `Get(_strikeClip.Clip.length)`
- `_loopClip``_cfg.loopClip`
- `_hangDuration``_cfg.hangDuration`
- `_endClip``_cfg.endClip`
- `_config?.attackSequence?...damageSource` **保持不变**
- `_attackHitBox`/`_hurtBox` 相关不动。
- [ ] **Step 3: 编译门** → count=0。
- [ ] **Step 4: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/Abilities/Configs/CeilingHangStrikeAbilitySO.cs Assets/_Game/Scripts/Enemies/Abilities/CeilingHangStrikeAbility.cs
git commit -m "refactor(enemy): CeilingHangStrike 参数迁入 CeilingHangStrikeAbilitySO(子类)"
```
---
## Task 5: AnimatedCeilingDropAbilitySO + 迁移 AnimatedCeilingDropAbility
**Files:**
- Create: `Assets/_Game/Scripts/Enemies/Abilities/Configs/AnimatedCeilingDropAbilitySO.cs`
- Modify: `Assets/_Game/Scripts/Enemies/Abilities/AnimatedCeilingDropAbility.cs`
- [ ] **Step 1: 建子类 SO**
```csharp
using Animancer;
using UnityEngine;
namespace BaseGames.Enemies.Abilities
{
/// <summary>AnimatedCeilingDropAbility 的配置:下落动画 + 物理下落/落地参数。</summary>
[CreateAssetMenu(menuName = "BaseGames/Enemies/Abilities/Animated Ceiling Drop Ability", fileName = "ABL_")]
public class AnimatedCeilingDropAbilitySO : EnemyAbilitySO
{
[Header("动画")]
[Tooltip("下落循环动画(旋转下落姿态,循环播放直到落地)")]
public ClipTransition fallLoopClip;
[Header("物理下落")]
[Tooltip("切换 Dynamic 后使用的重力倍率")]
public float fallGravityScale = 3.5f;
[Tooltip("下落超时保护(秒),超时后强制继续执行")]
public float maxFallTime = 3f;
public LayerMask groundMask;
[Header("落地后")]
[Tooltip("落地后恢复帧延迟(秒),之后切换为 Patrol 阶段")]
public float recoveryTime = 0.1f;
}
}
```
- [ ] **Step 2: 迁移 MonoBehaviour**
Read `AnimatedCeilingDropAbility.cs`。注意本类**已有** `Awake` 重写(缓存 `_rb`)。改动:
(a) 删第 16-29 行的动画/物理/落地数值字段(`_fallLoopClip/_fallGravityScale/_maxFallTime/_groundMask/_recoveryTime`),**保留** `[SerializeField] private BodyContactDamage _contactDamage;``private Rigidbody2D _rb;`。字段区改为:
```csharp
[Header("落地后")]
[SerializeField] private BodyContactDamage _contactDamage;
private AnimatedCeilingDropAbilitySO _cfg;
private Rigidbody2D _rb;
```
(b) 现有 `Awake` 重写内补解析(保留 `base.Awake()``_rb` 缓存):
```csharp
protected override void Awake()
{
base.Awake();
_cfg = ResolveConfig<AnimatedCeilingDropAbilitySO>();
_rb = GetComponentInParent<Rigidbody2D>();
}
```
(c) 使用处替换:
- `ExecuteCoroutine``Phase = AbilityRunState.Active;` 前加 `if (_cfg == null) yield break;`
- `_fallLoopClip``_cfg.fallLoopClip`
- `_fallGravityScale``_cfg.fallGravityScale`
- `_maxFallTime``_cfg.maxFallTime`
- `_recoveryTime``_cfg.recoveryTime`
- `IsGrounded()``_groundMask``_cfg.groundMask`(注意:`IsGrounded` 是普通方法,`_cfg` 为实例字段可访问;若 `_cfg` 可能为 null,此路径仅在 `ExecuteCoroutine` 守卫通过后调用,安全)
- `_contactDamage``_rb``OnInterrupted` 不动。
- [ ] **Step 3: 编译门** → count=0。
- [ ] **Step 4: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/Abilities/Configs/AnimatedCeilingDropAbilitySO.cs Assets/_Game/Scripts/Enemies/Abilities/AnimatedCeilingDropAbility.cs
git commit -m "refactor(enemy): AnimatedCeilingDrop 参数迁入 AnimatedCeilingDropAbilitySO(子类)"
```
---
## Task 6: MeleeVulnerabilityAbilitySO + 迁移 MeleeVulnerabilityAbility
**Files:**
- Create: `Assets/_Game/Scripts/Enemies/Abilities/Configs/MeleeVulnerabilityAbilitySO.cs`
- Modify: `Assets/_Game/Scripts/Enemies/Abilities/MeleeVulnerabilityAbility.cs`
- [ ] **Step 1: 建子类 SO**
```csharp
using Animancer;
using UnityEngine;
namespace BaseGames.Enemies.Abilities
{
/// <summary>MeleeVulnerabilityAbility 的配置:攻击动画 + 打击归一化时机 + 后摇脆弱时长。</summary>
[CreateAssetMenu(menuName = "BaseGames/Enemies/Abilities/Melee Vulnerability Ability", fileName = "ABL_")]
public class MeleeVulnerabilityAbilitySO : EnemyAbilitySO
{
[Header("动画")]
public ClipTransition attackClip;
[Header("打击时间(0~1 归一化,基于 Clip 时长)")]
[Range(0f, 1f)] public float hitEnterT = 0.30f;
[Range(0f, 1f)] public float hitExitT = 0.60f;
[Header("后摇脆弱窗口")]
[Tooltip("HurtBox 激活时间(秒)")]
public float staggerDuration = 1.0f;
}
}
```
- [ ] **Step 2: 迁移 MonoBehaviour**
Read `MeleeVulnerabilityAbility.cs`。改动:
(a) 删第 15-16 行 `_attackClip`、第 22-24 行 `_hitEnterT/_hitExitT`、第 26-28 行 `_staggerDuration`**保留** `[Header("碰撞")] HitBox _hitBox; HurtBox _hurtBox`。字段区改为:
```csharp
[Header("碰撞")]
[SerializeField] private HitBox _hitBox;
[SerializeField] private HurtBox _hurtBox;
private MeleeVulnerabilityAbilitySO _cfg;
protected override void Awake()
{
base.Awake();
_cfg = ResolveConfig<MeleeVulnerabilityAbilitySO>();
}
```
(b) 使用处替换:
- `ExecuteCoroutine``Phase = AbilityRunState.Active;` 前加 `if (_cfg == null) yield break;`
- `_attackClip``_cfg.attackClip`(判空 + `len = _attackClip.Clip.length` + Play
- `_hitEnterT``_cfg.hitEnterT``_hitExitT``_cfg.hitExitT`
- `_staggerDuration``_cfg.staggerDuration`
- `_config?.attackSequence?...damageSource` **保持不变**
- `_hitBox`/`_hurtBox``OnInterrupted` 不动。
- [ ] **Step 3: 编译门** → count=0。
- [ ] **Step 4: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/Abilities/Configs/MeleeVulnerabilityAbilitySO.cs Assets/_Game/Scripts/Enemies/Abilities/MeleeVulnerabilityAbility.cs
git commit -m "refactor(enemy): MeleeVulnerability 参数迁入 MeleeVulnerabilityAbilitySO(子类)"
```
---
## Task 7: 三个单片段能力(Appear / FacePlayer / PlayClip)子类 SO + 迁移
**Files:**
- Create: `Assets/_Game/Scripts/Enemies/Abilities/Configs/AppearAbilitySO.cs`
- Create: `Assets/_Game/Scripts/Enemies/Abilities/Configs/FacePlayerAbilitySO.cs`
- Create: `Assets/_Game/Scripts/Enemies/Abilities/Configs/PlayClipAbilitySO.cs`
- Modify: `Assets/_Game/Scripts/Enemies/Abilities/AppearAbility.cs`
- Modify: `Assets/_Game/Scripts/Enemies/Abilities/FacePlayerAbility.cs`
- Modify: `Assets/_Game/Scripts/Enemies/Abilities/PlayClipAbility.cs`
- [ ] **Step 1: 建三个子类 SO**
`AppearAbilitySO.cs`:
```csharp
using Animancer;
using UnityEngine;
namespace BaseGames.Enemies.Abilities
{
/// <summary>AppearAbility 的配置:出场动画。</summary>
[CreateAssetMenu(menuName = "BaseGames/Enemies/Abilities/Appear Ability", fileName = "ABL_")]
public class AppearAbilitySO : EnemyAbilitySO
{
public ClipTransition appearClip;
}
}
```
`FacePlayerAbilitySO.cs`:
```csharp
using Animancer;
using UnityEngine;
namespace BaseGames.Enemies.Abilities
{
/// <summary>FacePlayerAbility 的配置:转身/翻转动画。</summary>
[CreateAssetMenu(menuName = "BaseGames/Enemies/Abilities/Face Player Ability", fileName = "ABL_")]
public class FacePlayerAbilitySO : EnemyAbilitySO
{
public ClipTransition faceClip;
}
}
```
`PlayClipAbilitySO.cs`:
```csharp
using Animancer;
using UnityEngine;
namespace BaseGames.Enemies.Abilities
{
/// <summary>PlayClipAbility 的配置:待播放的单个动画 Clip。</summary>
[CreateAssetMenu(menuName = "BaseGames/Enemies/Abilities/Play Clip Ability", fileName = "ABL_")]
public class PlayClipAbilitySO : EnemyAbilitySO
{
public ClipTransition clip;
}
}
```
- [ ] **Step 2: 迁移 AppearAbility**
Read `AppearAbility.cs`。删 `[SerializeField] private ClipTransition _appearClip;`,加:
```csharp
private AppearAbilitySO _cfg;
protected override void Awake()
{
base.Awake();
_cfg = ResolveConfig<AppearAbilitySO>();
}
```
`ExecuteCoroutine` 改为:
```csharp
protected override IEnumerator ExecuteCoroutine()
{
Phase = AbilityRunState.Active;
if (_cfg == null || _cfg.appearClip.Clip == null) yield break;
_animancer.Play(_cfg.appearClip);
yield return EnemyAbilityWaits.Get(_cfg.appearClip.Clip.length);
}
```
- [ ] **Step 3: 迁移 FacePlayerAbility**
Read `FacePlayerAbility.cs`。删 `_faceClip`,加同款 `_cfg`(类型 `FacePlayerAbilitySO`) + `Awake``CanUse` 重写不动。`ExecuteCoroutine` 尾部改:
```csharp
_enemy.FacePlayer();
if (_cfg == null || _cfg.faceClip.Clip == null) yield break;
_animancer.Play(_cfg.faceClip);
yield return EnemyAbilityWaits.Get(_cfg.faceClip.Clip.length);
```
- [ ] **Step 4: 迁移 PlayClipAbility**
Read `PlayClipAbility.cs`。删 `_clip`,加同款 `_cfg`(类型 `PlayClipAbilitySO`) + `Awake``ExecuteCoroutine` 改:
```csharp
protected override IEnumerator ExecuteCoroutine()
{
Phase = AbilityRunState.Active;
if (_cfg == null || _cfg.clip.Clip == null) yield break;
_animancer.Play(_cfg.clip);
yield return EnemyAbilityWaits.Get(_cfg.clip.Clip.length);
}
```
- [ ] **Step 5: 编译门** → count=0。
- [ ] **Step 6: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/Abilities/Configs/AppearAbilitySO.cs Assets/_Game/Scripts/Enemies/Abilities/Configs/FacePlayerAbilitySO.cs Assets/_Game/Scripts/Enemies/Abilities/Configs/PlayClipAbilitySO.cs Assets/_Game/Scripts/Enemies/Abilities/AppearAbility.cs Assets/_Game/Scripts/Enemies/Abilities/FacePlayerAbility.cs Assets/_Game/Scripts/Enemies/Abilities/PlayClipAbility.cs
git commit -m "refactor(enemy): Appear/FacePlayer/PlayClip 参数迁入各自子类 SO"
```
---
## Task 8: 脚手架——向导按能力类型创建正确子类 SO
**Files:**
- Modify: `Assets/_Game/Scripts/Editor/Shared/EditorScaffoldUtils.cs`(加 Type 版 `CreateSOAsset` 重载)
- Modify: `Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs`def 带类型 + 透传)
- [ ] **Step 1: `EditorScaffoldUtils` 加 Type 版 `CreateSOAsset` 重载**
Read `EditorScaffoldUtils.cs` 现有泛型 `CreateSOAsset<T>`(约 154 行)。在其后加非泛型重载(复用同样的存在性检查/建目录/保存逻辑):
```csharp
/// <summary>按运行时 Type 创建 SO 资产(用于需要动态子类型的场景,如能力子类 SO)。</summary>
public static ScriptableObject CreateSOAsset(System.Type soType, string folder, string assetName)
{
string dir = folder.TrimEnd('/');
string path = $"{dir}/{assetName}.asset";
if (AssetDatabase.LoadAssetAtPath<ScriptableObject>(path) != null)
{
Debug.LogWarning($"[EditorScaffoldUtils] 资产已存在,跳过创建:{path}");
return null;
}
EnsureFolder(dir);
var asset = ScriptableObject.CreateInstance(soType);
AssetDatabase.CreateAsset(asset, path);
AssetDatabase.SaveAssets();
return asset;
}
```
> 若现有泛型版有额外步骤(如 Selection/Ping),读后与之保持一致;此重载只需产出资产。
- [ ] **Step 2: `GetEnemyAbilityDefs` 加 SO 类型**
`CharacterWizardWindow.cs`,把 `GetEnemyAbilityDefs` 的返回元组从 `(string ablName, string ablId)` 扩为 `(string ablName, string ablId, System.Type soType)`,按关键事实映射填类型(未迁移的用基类 `typeof(EnemyAbilitySO)`):
```csharp
private static (string ablName, string ablId, System.Type soType)[] GetEnemyAbilityDefs(string enemyId) => enemyId switch
{
"E001" => new[] { ("Alert", "e001_alert", typeof(EnemyAbilitySO)),
("Chase", "e001_chase", typeof(ContactChaseAbilitySO)) },
"E002" => new[] { ("CeilingStrike", "e002_ceiling_strike", typeof(CeilingHangStrikeAbilitySO)) },
"E003" => new[] { ("Fall", "e003_fall", typeof(AnimatedCeilingDropAbilitySO)) },
"E004" => new[] { ("Appear", "e004_appear", typeof(AppearAbilitySO)),
("Bite", "e004_bite", typeof(EnemyAbilitySO)),
("HeadSlam", "e004_headslam", typeof(RepeatSlamAbilitySO)),
("Acid", "e004_acid", typeof(EnemyAbilitySO)),
("Flip", "e004_flip", typeof(FacePlayerAbilitySO)) },
"E005" => new[] { ("Bite", "e005_bite", typeof(EnemyAbilitySO)),
("Acid", "e005_acid", typeof(EnemyAbilitySO)) },
"E006" => new[] { ("Leap", "e006_leap", typeof(EnemyAbilitySO)),
("Chase", "e006_chase", typeof(ContactChaseAbilitySO)) },
_ => System.Array.Empty<(string, string, System.Type)>(),
};
```
文件顶部若未 `using BaseGames.Enemies.Abilities;` 则补上(这些子类型在该命名空间)。
- [ ] **Step 3: `CreateEnemyAbilitySO` 接受类型 + 调用点透传**
改签名与实现:
```csharp
private static void CreateEnemyAbilitySO(string enemyId, string ablName, string ablId, System.Type soType)
{
string dir = $"Assets/_Game/Data/Enemies/{enemyId}/Abilities";
string name = $"ABL_{enemyId}_{ablName}";
var so = EditorScaffoldUtils.CreateSOAsset(soType, dir, name) as EnemyAbilitySO;
if (so != null)
{
so.abilityId = ablId;
EditorUtility.SetDirty(so);
AssetDatabase.SaveAssets();
}
}
```
更新两处调用点:
- 工厂按钮循环(约 280-285 行):`foreach (var (ablName, ablId, soType) in GetEnemyAbilityDefs(id))` 且捕获 `soType`,按钮 lambda 改 `CreateEnemyAbilitySO(id, capturedName, capturedId, capturedType)`(新增 `var capturedType = soType;`)。
- `CreateAllEnemySOs`(约 625-626 行):`foreach (var (ablName, ablId, soType) in GetEnemyAbilityDefs(id)) CreateEnemyAbilitySO(id, ablName, ablId, soType);`
- `RefreshSOStatus` 里若有 `foreach (var (ablName, ablId) in ...)`(约 773 行 items.Add)也改为解构三元组(忽略 `soType` 用弃元 `_`)。
- [ ] **Step 4: 编译门** → count=0。
- [ ] **Step 5: 提交**
```bash
git add Assets/_Game/Scripts/Editor/Shared/EditorScaffoldUtils.cs Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs
git commit -m "feat(wizard): 敌人能力 SO 按能力类型创建正确子类(向导批量创建产出子类 SO)"
```
---
## Task 9: 端到端结构验证 + 自检 + 收尾
**Files:** 无(仅验证)
- [ ] **Step 1: 结构验证(MCP)——子类 SO 有预期字段、能力已改读 SO**
`unity_execute_code`(port 7890):反射确认 8 个子类型存在且暴露预期字段;确认对应能力 MonoBehaviour **不再**有旧的 `_startClip/_dashSpeed/…` 序列化字段:
```csharp
var sb=new System.Text.StringBuilder();
var asm = System.AppDomain.CurrentDomain.GetAssemblies();
System.Type Find(string n)=>asm.SelectMany(a=>{try{return a.GetTypes();}catch{return new System.Type[0];}}).FirstOrDefault(x=>x.Name==n);
void CheckSO(string so, string[] fields){ var t=Find(so); if(t==null){sb.AppendLine(so+": MISSING"); return;} foreach(var f in fields){ var fi=t.GetField(f); sb.AppendLine(so+"."+f+": "+(fi!=null?fi.FieldType.Name:"MISSING")); } sb.AppendLine(so+" base="+t.BaseType.Name); }
CheckSO("ContactChaseAbilitySO", new[]{"startClip","loopClip","endClip","dashSpeed"});
CheckSO("RepeatSlamAbilitySO", new[]{"startClip","loopClip","endClip","hitActiveTime","slamCount","staggerDuration"});
CheckSO("CeilingHangStrikeAbilitySO", new[]{"strikeClip","loopClip","endClip","hangDuration"});
CheckSO("AnimatedCeilingDropAbilitySO", new[]{"fallLoopClip","fallGravityScale","maxFallTime","groundMask","recoveryTime"});
CheckSO("MeleeVulnerabilityAbilitySO", new[]{"attackClip","hitEnterT","hitExitT","staggerDuration"});
CheckSO("AppearAbilitySO", new[]{"appearClip"});
CheckSO("FacePlayerAbilitySO", new[]{"faceClip"});
CheckSO("PlayClipAbilitySO", new[]{"clip"});
// 能力 MonoBehaviour 不应再有旧序列化字段
var bindings = System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic;
void NoOldField(string comp, string oldField){ var t=Find(comp); var fi=t?.GetField(oldField, bindings); sb.AppendLine(comp+" 残留 "+oldField+"="+(fi!=null)); }
NoOldField("ContactChaseAbility","_startClip"); NoOldField("ContactChaseAbility","_dashSpeed");
NoOldField("RepeatSlamAbility","_slamCount"); NoOldField("AnimatedCeilingDropAbility","_groundMask");
NoOldField("MeleeVulnerabilityAbility","_hitEnterT"); NoOldField("AppearAbility","_appearClip");
return sb.ToString();
```
期望:各 SO 字段类型齐全、`base=EnemyAbilitySO`;各能力「残留 = False」。
- [ ] **Step 2: 每个子类 `[CreateAssetMenu]` 可见性抽检**:在编辑器 `Assets > Create > BaseGames/Enemies/Abilities/` 下应能看到 8 个新菜单项(人工抽查 1-2 个,或反射确认类型带 `CreateAssetMenuAttribute`)。
- [ ] **Step 3: SO 自检**:菜单 `BaseGames/Tools/Validation/Validate All ScriptableObjects``unity_execute_menu_item`)——期望无因新子类型产生的新错误(现有 `ABL_*.asset` 仍是基类,属用户待重建,若校验器对缺失字段报警属预期,记录即可,不在本 Task 修资产)。
- [ ] **Step 4: grep 确认能力脚本无残留迁移字段声明**
```
rg -n "SerializeField.*(ClipTransition|_dashSpeed|_slamCount|_hitActiveTime|_hangDuration|_fallGravityScale|_maxFallTime|_groundMask|_recoveryTime|_hitEnterT|_hitExitT|_staggerDuration)" Assets/_Game/Scripts/Enemies/Abilities/*.cs
```
期望:0 命中(8 个能力的迁移字段声明都已删除;`Configs/` 子目录里的是 public 字段,不带 `[SerializeField]`,不会命中)。
- [ ] **Step 5: 更新记忆** `editor_scaffold_tools.md` 或新增记忆:记录「敌人能力参数现由 `XxxAbilitySO : EnemyAbilitySO` 子类承载;`EnemyAbilityBase.ResolveConfig<T>` 类型不符显式报错;场景组件引用(BodyContactDamage/HitBox/HurtBox)仍在 MonoBehaviour;向导按能力类型创建子类 SO;现有 ABL_ 资产需重建为子类」。
---
## Self-Review(作者已核对)
- **Spec 覆盖**:§A 子类 SO→Task 2-78 个子类);§B `ResolveConfig` 显式报错→Task 1;§C MonoBehaviour 删字段+读 SO+空守卫→Task 2-7;§D 脚手架/创建入口→Task 8;§E 迁移(用户重建)→全程不动资产 + Task 9 记录;验证→各 Task 编译门 + Task 1 单测 + Task 9 结构验证。场景引用保留→每个迁移 Task 显式「保留」。
- **占位符**:无 TBD;每个 SO 给完整文件;每个 MonoBehaviour 给「删哪些行 + 新字段/Awake 块 + 使用处替换清单 + 空守卫」;Task 8 给完整映射表与调用点。
- **类型一致**`ResolveConfig<T>() where T : EnemyAbilitySO → T`;子类名 `ContactChaseAbilitySO/RepeatSlamAbilitySO/CeilingHangStrikeAbilitySO/AnimatedCeilingDropAbilitySO/MeleeVulnerabilityAbilitySO/AppearAbilitySO/FacePlayerAbilitySO/PlayClipAbilitySO` 贯穿 Task 2-9;字段名(`startClip/loopClip/endClip/dashSpeed/hitActiveTime/slamCount/staggerDuration/hangDuration/strikeClip/fallLoopClip/fallGravityScale/maxFallTime/groundMask/recoveryTime/attackClip/hitEnterT/hitExitT/appearClip/faceClip/clip`)在 SO 定义与 MonoBehaviour 使用处一致;`CreateSOAsset(Type,…)` 重载签名一致。
- **已知风险**(1) `_config.attackSequence[...].damageSource` 保持用基类 `_config`(未改 `_cfg`),因 `_cfg` 可能因类型错为 null 而 `_config` 仍有效——已在相关 Task 显式「保持不变」。(2) EditMode 反射跑 `LogAssert` 可能需真 Test Runner——Task 1 Step 5 给了回退(编辑器 Test Runner 手动跑)。(3) 现有 17 个 `ABL_` 资产仍是基类,运行时会触发 `ResolveConfig` 报错——这是**预期的显式报错**,提示用户重建(符合 §6,非缺陷)。