# 敌人能力配置迁移到 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()`:把 `_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 Alert(e001_alert)→ 基类 `EnemyAbilitySO`(无对应迁移能力) - `MeleeVulnerabilityAbility`/`PlayClipAbility` → 各自子类 SO(框架能力,当前无向导 def 映射,仅 CreateAssetMenu) --- ## Task 1: 基类 `ResolveConfig` + 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() where T : EnemyAbilitySO => ResolveConfig(); } private ProbeAbility Make() => new GameObject("probe").AddComponent(); [Test] public void ResolveConfig_CorrectType_ReturnsInstance() { var probe = Make(); var cfg = ScriptableObject.CreateInstance(); probe.SetConfig(cfg); Assert.AreSame(cfg, probe.Probe()); Object.DestroyImmediate(cfg); Object.DestroyImmediate(probe.gameObject); } [Test] public void ResolveConfig_WrongType_LogsError_ReturnsNull() { var probe = Make(); probe.SetConfig(ScriptableObject.CreateInstance()); LogAssert.Expect(LogType.Error, new Regex("_config 需为")); Assert.IsNull(probe.Probe()); 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()); Object.DestroyImmediate(probe.gameObject); } } } ``` - [ ] **Step 2: 编译门确认测试因缺 `ResolveConfig` 编译失败** → `unity_get_compilation_errors` 应报 `ResolveConfig` 不存在(预期红)。 - [ ] **Step 3: 在 `EnemyAbilityBase` 加 `ResolveConfig`** 在 `FaceTarget` 方法之后(类内、`EnemyAbilityWaits` 之前)插入: ```csharp /// 把 _config 解析为期望的子类型;类型不符/为空时显式报错(不回退)。 protected T ResolveConfig() 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 类型化配置解析(不匹配显式报错) + 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 { /// ContactChaseAbility 的配置:起手/循环/收招动画 + 冲刺速度。 [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(); } ``` (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 { /// RepeatSlamAbility 的配置:起手/循环/收招动画 + 砸地行为参数。 [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(); } ``` (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 { /// CeilingHangStrikeAbility 的配置:出击/悬挂/收回动画 + 悬挂窗口时长。 [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(); } ``` (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 { /// AnimatedCeilingDropAbility 的配置:下落动画 + 物理下落/落地参数。 [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(); _rb = GetComponentInParent(); } ``` (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 { /// MeleeVulnerabilityAbility 的配置:攻击动画 + 打击归一化时机 + 后摇脆弱时长。 [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(); } ``` (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 { /// AppearAbility 的配置:出场动画。 [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 { /// FacePlayerAbility 的配置:转身/翻转动画。 [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 { /// PlayClipAbility 的配置:待播放的单个动画 Clip。 [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(); } ``` `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`(约 154 行)。在其后加非泛型重载(复用同样的存在性检查/建目录/保存逻辑): ```csharp /// 按运行时 Type 创建 SO 资产(用于需要动态子类型的场景,如能力子类 SO)。 public static ScriptableObject CreateSOAsset(System.Type soType, string folder, string assetName) { string dir = folder.TrimEnd('/'); string path = $"{dir}/{assetName}.asset"; if (AssetDatabase.LoadAssetAtPath(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` 类型不符显式报错;场景组件引用(BodyContactDamage/HitBox/HurtBox)仍在 MonoBehaviour;向导按能力类型创建子类 SO;现有 ABL_ 资产需重建为子类」。 --- ## Self-Review(作者已核对) - **Spec 覆盖**:§A 子类 SO→Task 2-7(8 个子类);§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() 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,非缺陷)。