36 KiB
敌人能力配置迁移到 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";→ Bashsleep 9→unity_get_compilation_errorsseverity=error 期望 count=0。绝不在unity_execute_code内用Thread.Sleep(会冻结 Unity 主线程);跨调用等待用 Bashsleep。桥接卡死则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<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:
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 之前)插入:
/// <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: 提交
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
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。删除后字段区变为:
[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: 提交
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
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。字段区改为:
[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: 提交
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
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。字段区改为:
[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: 提交
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
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;。字段区改为:
[Header("落地后")]
[SerializeField] private BodyContactDamage _contactDamage;
private AnimatedCeilingDropAbilitySO _cfg;
private Rigidbody2D _rb;
(b) 现有 Awake 重写内补解析(保留 base.Awake() 与 _rb 缓存):
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: 提交
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
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。字段区改为:
[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: 提交
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:
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:
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:
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;,加:
private AppearAbilitySO _cfg;
protected override void Awake()
{
base.Awake();
_cfg = ResolveConfig<AppearAbilitySO>();
}
ExecuteCoroutine 改为:
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 尾部改:
_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 改:
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: 提交
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 行)。在其后加非泛型重载(复用同样的存在性检查/建目录/保存逻辑):
/// <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)):
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接受类型 + 调用点透传
改签名与实现:
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: 提交
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/… 序列化字段:
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-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<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,非缺陷)。