From 0ad4acc484c4ed40735824f00ba71a8809385c87 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Tue, 21 Jul 2026 11:04:36 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20=E6=95=8C=E4=BA=BA=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E9=85=8D=E7=BD=AE=E8=BF=81=E7=A7=BB=E5=88=B0=20Abilit?= =?UTF-8?q?ySO=20=E5=AD=90=E7=B1=BB=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-21-enemy-ability-config-to-so.md | 743 ++++++++++++++++++ 1 file changed, 743 insertions(+) create mode 100644 Docs_Dev/superpowers/plans/2026-07-21-enemy-ability-config-to-so.md diff --git a/Docs_Dev/superpowers/plans/2026-07-21-enemy-ability-config-to-so.md b/Docs_Dev/superpowers/plans/2026-07-21-enemy-ability-config-to-so.md new file mode 100644 index 00000000..9b09a19b --- /dev/null +++ b/Docs_Dev/superpowers/plans/2026-07-21-enemy-ability-config-to-so.md @@ -0,0 +1,743 @@ +# 敌人能力配置迁移到 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,非缺陷)。