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

36 KiB
Raw Blame History

敌人能力配置迁移到 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 9unity_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] 语义。

关键事实(实现前必读,均已核实)

  • EnemyAbilityBaseAssets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs):[SerializeField] protected EnemyAbilitySO _config;(第 22 行);protected virtual void Awake()(第 46 行,做依赖缓存);protected EnemyBase _enemy; AnimancerComponent _animancer; Transform _transform;。子类 MonoBehaviour 可访问 _configprotected)。
  • 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].damageSourceRepeatSlam/CeilingHangStrike/MeleeVulnerability)——这是基类字段,迁移后 _config 仍指向子类实例(is-a EnemyAbilitySO),这些行保持不变
  • EditMode 测试程序集 Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef 已引用 BaseGames.Enemies,可直接测试能力类型。
  • 能力→子类 SO 映射(供 Task 8 向导用,来自 SceneObjectPlacerTool 组件挂载):
    • ContactChaseAbilityContactChaseAbilitySOE001 Chase、E006 Chase
    • CeilingHangStrikeAbilityCeilingHangStrikeAbilitySOE002 CeilingStrike
    • AnimatedCeilingDropAbilityAnimatedCeilingDropAbilitySOE003 Fall
    • AppearAbilityAppearAbilitySOE004 Appear
    • RepeatSlamAbilityRepeatSlamAbilitySOE004 HeadSlam
    • FacePlayerAbilityFacePlayerAbilitySOE004 Flip
    • MeleeAttackAbility/ProjectileAttackAbility/LeapAttackAbility → 基类 EnemyAbilitySOattackSequence 型,不迁移;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

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: 在 EnemyAbilityBaseResolveConfig<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] 方法,捕获 AssertionExceptionLogAssert.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 全部(OnInterruptedPlaySkillEnd)→ _cfg.endClipOnInterrupted 内条件改为 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) 使用处替换:

  • ExecuteCoroutinePhase = 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) 使用处替换:

  • ExecuteCoroutinePhase = 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) 使用处替换:

  • ExecuteCoroutinePhase = 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_rbOnInterrupted 不动。

  • 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) 使用处替换:

  • ExecuteCoroutinePhase = 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/_hurtBoxOnInterrupted 不动。

  • 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) + AwakeCanUse 重写不动。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) + AwakeExecuteCoroutine 改:

        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.csdef 带类型 + 透传)

  • 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 ScriptableObjectsunity_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,非缺陷)。