Files
zeling_v2/Docs/Architecture/09_ProgressionModule.md
2026-05-12 15:34:08 +08:00

40 KiB
Raw Permalink Blame History

09 · 进度模块

命名空间 BaseGames.PlayerBaseGames.EquipmentBaseGames.SkillsBaseGames.Progression
程序集 BaseGames.EquipmentBaseGames.Skills
路径 Assets/Scripts/Player/Assets/Scripts/World/Assets/Scripts/Equipment/Assets/Scripts/Skills/
依赖 BaseGames.Core.EventsBaseGames.CombatBaseGames.Player


目录

  1. AbilityType 枚举
  2. AbilityGate能力门禁
  3. 装备系统 — CharmSO
  4. ICharmEffect 接口
  5. 内置 CharmEffect 实现
  6. EquipmentManager
  7. ToolSO主动工具
  8. 技能系统 — FormSkillSO
  9. SkillManager
  10. SkillModifierRegistry
  11. RegionDefinitionSO
  12. ProgressLock
  13. BossProgressTracker
  14. HPContainerPickup
  15. ProgressionEventChannel 清单

1. AbilityType 枚举

架构决策 (2026-05):改为 [Flags] uint bitmask

  • Dictionary<string, bool> 存档方案每次查询需 .ToString() 装箱 + 字典哈希,在 AbilityGate.Start() 及 ParrySystem.TryActivateParry() 等热路径产生不必要开销
  • 改为 bitmask 后:存档只存一个 uint,查询为单次位运算 (_flags & ability) != 0,兼容序列化
  • 新增能力只需追加新的 1 << N,禁止修改已有枚举值(防止存档数据错位)
  • 本节以当前仓库中的 Assets/Scripts/Player/AbilityType.cs 为准;历史文档中的旧命名不再作为当前实现事实来源
// 路径: Assets/Scripts/Player/AbilityType.cs
// 所有可解锁能力的位标志枚举PlayerStats.HasAbility() 依赖此
[System.Flags]
public enum AbilityType : uint
{
    None         = 0,

    // 移动能力
    WallCling    = 1u << 0,   // 贴墙悬挂
    WallJump     = 1u << 1,   // 墙跳
    Dash         = 1u << 2,   // 地面冲刺
    AirDash      = 1u << 3,   // 空中冲刺(二段冲刺)
    DoubleJump   = 1u << 4,   // 二段跳
    SuperJump    = 1u << 5,   // 超级跳(聚气跳)
    Swim         = 1u << 6,   // 游泳(液体中自由移动)
    Dive         = 1u << 7,   // 下劈(空中下突)

    // 法术能力
    Spell1       = 1u << 8,   // 法术槽 1策划自定义
    Spell2       = 1u << 9,   // 法术槽 2
    Spell3       = 1u << 10,  // 法术槽 3

    // 灵魄形态
    SpiritForm   = 1u << 11,  // 灵魄形态切换
    SpiritDash   = 1u << 12,  // 灵魄冲刺(穿透地形)

    // 战斗能力
    Parry        = 1u << 13,  // 格挡/弹反
    ChargeAttack = 1u << 14,  // 蓄力攻击
    DownSlash    = 1u << 15,  // 下斩

    // 互动能力
    Interact     = 1u << 16,  // 互动NPC/机关)
    FastTravel   = 1u << 17,  // 快速旅行解锁

    // 组合掩码
    AllMovement  = WallCling | WallJump | Dash | AirDash | DoubleJump | SuperJump | Swim | Dive,
    AllSpells    = Spell1 | Spell2 | Spell3,
    AllSpirit    = SpiritForm | SpiritDash,
}

2. AbilityGate

// 路径: Assets/Scripts/World/AbilityGate.cs
// 物理关卡障碍,阻挡未解锁某能力的玩家
[RequireComponent(typeof(Collider2D))]
public class AbilityGate : MonoBehaviour
{
    [SerializeField] private AbilityType _requiredAbility;
    [SerializeField] private GameObject  _blockingObject;  // 关卡障碍物 GO禁/启用)
    [SerializeField] private GameObject  _hintUI;          // 提示 UI如能力图标 + "???"
    [SerializeField] private string      _gateId;          // 存档用

    [Header("Event Channels")]
    // AbilityTypeEventChannelSO 已在 02_EventSystem §3 定义(替换旧 StringEventChannelSO
    [SerializeField] private AbilityTypeEventChannelSO _onAbilityUnlocked;  // EVT_AbilityUnlocked

    // _saveData 由 GameInitializer 在 Awake 时注入(零耦合,避免 SaveManager.Instance
    private SaveData _saveData;

    private void Start()
    {
        // 位运算查询:无需 ToString + 字典哈希
        bool hasAbility = _saveData != null
            && (_saveData.Player.AbilityFlags & (uint)_requiredAbility) != 0;
        _blockingObject.SetActive(!hasAbility);
        if (_hintUI != null) _hintUI.SetActive(!hasAbility);
    }

    private void OnEnable()  => _onAbilityUnlocked.OnEventRaised += OnAbilityUnlocked;
    private void OnDisable() => _onAbilityUnlocked.OnEventRaised -= OnAbilityUnlocked;

    private void OnAbilityUnlocked(AbilityType ability)
    {
        if (ability != _requiredAbility) return;
        _blockingObject.SetActive(false);
        if (_hintUI != null) _hintUI.SetActive(false);
        // P1播放解锁动画如荆棘收缩、道路开通特效
    }

    public void Open() => _blockingObject.SetActive(false);
}

3. 装备系统 — CharmSO

// 路径: Assets/Scripts/Equipment/CharmSO.cs
[CreateAssetMenu(menuName = "Equipment/Charm")]
public class CharmSO : ScriptableObject
{
    [Header("Identity")]
    public string  charmId;        // 全局唯一 ID如 "Charm_QuickSlash"
    public string  displayNameKey; // 本地化 Key
    [TextArea(2,4)]
    public string  descriptionKey;

    [Header("Visual")]
    public Sprite  icon;
    public Color   glowColor;

    [Header("Slot Cost")]
    [Range(1,4)]
    public int     notchCost;      // 占用笔记数1~4

    [Header("Effects")]
    [SerializeReference]
    public List<ICharmEffect> effects;  // 多态序列化([SerializeReference]

    [Header("Lore")]
    public bool    isUnique;       // 唯一物品
    public string  unlockHint;
}

资产路径Assets/ScriptableObjects/Equipment/Charms/
命名Charm_{Name}.asset


4. ICharmEffect 接口

// 路径: Assets/Scripts/Equipment/ICharmEffect.cs
[System.Serializable]
public interface ICharmEffect
{
    void OnEquip(EquipmentContext ctx);
    void OnUnequip(EquipmentContext ctx);
    string GetEffectDescription();
}

// 上下文:避免接口直接依赖具体类
public struct EquipmentContext
{
    public PlayerStats           Stats;
    public PlayerFeedback        Feedback;
    public EventChannelRegistry  Events;    // SO 事件频道注册表
    public SkillModifierRegistry SkillMods; // 技能修改器注册表
    public WeaponManager         WeaponMgr; // 武器切换管理器
}

4.1 CharmEffectDrawer — 自定义 PropertyDrawer

痛点Unity 默认的 [SerializeReference] Inspector 体验极差——添加/删除效果需要右键 "Manage References",类型名显示 C# 全称。策划每日在 CharmSO Inspector 中增减效果,该 Drawer 是必须的。

// 路径: Assets/Scripts/Editor/Equipment/CharmEffectDrawer.cs
// 程序集: BaseGames.EditorEditor Only
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;

namespace BaseGames.Editor.Equipment
{
    /// <summary>
    /// 为 CharmSO.effectsList&lt;ICharmEffect&gt;)提供友好的 Inspector 体验:
    /// - 下拉菜单选类型(显示中文名而非 C# 全称)
    /// - 每条效果展开显示字段 + GetEffectDescription() 预览文字
    /// - 支持拖动重排、单条删除
    /// </summary>
    [CustomEditor(typeof(CharmSO))]
    public class CharmSOEditor : UnityEditor.Editor
    {
        // 已注册的所有 ICharmEffect 实现类型(反射收集,仅 Editor 运行)
        private static readonly System.Type[] _effectTypes = CollectEffectTypes();

        // 每种类型的显示名(对应策划友好名称)
        private static readonly Dictionary<System.Type, string> _typeLabels = new()
        {
            { typeof(StatModifierEffect),  "属性加成" },
            { typeof(AttackSpeedEffect),   "攻击速度" },
            { typeof(OnHitEffect),         "命中触发" },
            { typeof(OnTakeDamageEffect),  "受击触发" },
            { typeof(SoulGainEffect),      "灵力获取" },
            { typeof(InvincibilityEffect), "无敌帧延长" },
            // 新增效果类型在此追加
        };

        private SerializedProperty _effectsProp;

        void OnEnable() => _effectsProp = serializedObject.FindProperty("effects");

        public override void OnInspectorGUI()
        {
            serializedObject.Update();
            DrawPropertiesExcluding(serializedObject, "effects");

            EditorGUILayout.Space(8);
            EditorGUILayout.LabelField("Effects", EditorStyles.boldLabel);

            for (int i = 0; i < _effectsProp.arraySize; i++)
            {
                var elemProp = _effectsProp.GetArrayElementAtIndex(i);
                var effect   = elemProp.managedReferenceValue as ICharmEffect;
                string label = effect != null && _typeLabels.TryGetValue(effect.GetType(), out var n)
                    ? n : (effect?.GetType().Name ?? "null");

                EditorGUILayout.BeginVertical(EditorStyles.helpBox);
                EditorGUILayout.BeginHorizontal();
                EditorGUILayout.LabelField(label, EditorStyles.boldLabel);

                // 删除按钮
                if (GUILayout.Button("✕", GUILayout.Width(24)))
                {
                    _effectsProp.DeleteArrayElementAtIndex(i);
                    serializedObject.ApplyModifiedProperties();
                    break;
                }
                EditorGUILayout.EndHorizontal();

                EditorGUILayout.PropertyField(elemProp, GUIContent.none, true);

                // 预览描述文字
                if (effect != null)
                    EditorGUILayout.LabelField(effect.GetEffectDescription(),
                        EditorStyles.miniLabel);

                EditorGUILayout.EndVertical();
                EditorGUILayout.Space(2);
            }

            // 添加按钮(下拉菜单)
            if (GUILayout.Button(" 添加效果"))
            {
                var menu = new GenericMenu();
                foreach (var t in _effectTypes)
                {
                    var captured = t;
                    string label = _typeLabels.GetValueOrDefault(t, t.Name);
                    menu.AddItem(new GUIContent(label), false, () =>
                    {
                        _effectsProp.arraySize++;
                        _effectsProp.GetArrayElementAtIndex(_effectsProp.arraySize - 1)
                            .managedReferenceValue = System.Activator.CreateInstance(captured);
                        serializedObject.ApplyModifiedProperties();
                    });
                }
                menu.ShowAsContext();
            }

            serializedObject.ApplyModifiedProperties();
        }

        private static System.Type[] CollectEffectTypes()
        {
            var baseType = typeof(ICharmEffect);
            return System.AppDomain.CurrentDomain.GetAssemblies()
                .SelectMany(a => a.GetTypes())
                .Where(t => t.IsClass && !t.IsAbstract && baseType.IsAssignableFrom(t))
                .ToArray();
        }
    }
}
#endif

5. 内置 CharmEffect 实现

// 路径: Assets/Scripts/Equipment/Effects/

// ── 属性加成 ─────────────────────────────────────────────
[Serializable]
public class StatModifierEffect : ICharmEffect
{
    public StatType statType;      // MaxHP / AttackDamage / MoveSpeed / JumpHeight / SoulGain / Defense
    public float    flatBonus;     // 固定加成(如 +1 HP
    public float    percentBonus;  // 百分比加成(如 +0.2 = +20%

    public void OnEquip(EquipmentContext ctx)   => ctx.Stats.AddModifier(statType, flatBonus, percentBonus);
    public void OnUnequip(EquipmentContext ctx) => ctx.Stats.RemoveModifier(statType, flatBonus, percentBonus);
    public string GetEffectDescription() => $"{statType}: +{flatBonus} +{percentBonus*100:0}%";
}

// ── 攻击速度加成 ──────────────────────────────────────────
[Serializable]
public class AttackSpeedEffect : ICharmEffect
{
    [Range(0.1f, 2.0f)]
    public float speedMultiplier = 1.2f;   // 动画速度倍率(如 1.2 = 加速 20%
    public void OnEquip(EquipmentContext ctx)   => ctx.Stats.AnimatorSpeedMultiplier += (speedMultiplier - 1f);
    public void OnUnequip(EquipmentContext ctx) => ctx.Stats.AnimatorSpeedMultiplier -= (speedMultiplier - 1f);
    public string GetEffectDescription() => $"攻击速度 +{(speedMultiplier - 1) * 100:0}%";
}

// ── 命中触发效果 ──────────────────────────────────────────
[Serializable]
public class OnHitEffect : ICharmEffect
{
    public OnHitEffectType effectType;  // ApplyPoison / ApplyFire / KnockbackBoost
    [Range(0f, 1f)]
    public float           chance;      // 触发概率0~1

    private DamageInfoEventChannelSO _onHitChannel;

    public void OnEquip(EquipmentContext ctx)
    {
        _onHitChannel = ctx.Events.Get<DamageInfoEventChannelSO>("OnHitConfirmed");
        _onHitChannel.OnEventRaised += HandleHit;
    }
    public void OnUnequip(EquipmentContext ctx) => _onHitChannel.OnEventRaised -= HandleHit;

    private void HandleHit(DamageInfo info)
    {
        if (UnityEngine.Random.value > chance) return;
        // 触发对应效果(由 StatusEffectManager 处理,见 06_CombatModule §12
    }
    public string GetEffectDescription() => $"命中时 {chance * 100:0}% 概率附加 {effectType}";
}

// ── 灵魂法术强化 ──────────────────────────────────────────
[Serializable]
public class SoulSpellEffect : ICharmEffect
{
    public SpellType spellType;         // SoulAttack / HealingWave
    public int       soulCostReduction; // 减少消耗 Soul 点数

    public void OnEquip(EquipmentContext ctx)
        => ctx.Stats.RegisterSpellModifier(spellType, soulCostReduction, 0f);
    public void OnUnequip(EquipmentContext ctx)
        => ctx.Stats.UnregisterSpellModifier(spellType, soulCostReduction, 0f);
    public string GetEffectDescription() => $"{spellType} 消耗减少 {soulCostReduction} Soul";
}

// ── 技能数值修改 ──────────────────────────────────────────
[Serializable]
public class SkillNumericModifierEffect : ICharmEffect
{
    public string    TargetSkillId;
    public SkillStat Stat;           // enum: Damage, Cost, Cooldown, Range, Duration
    public float     Delta;
    public bool      IsPercent;

    public void OnEquip(EquipmentContext ctx)   => ctx.SkillMods.Register(TargetSkillId, Stat, Delta, IsPercent);
    public void OnUnequip(EquipmentContext ctx) => ctx.SkillMods.Unregister(TargetSkillId, Stat, Delta, IsPercent);
    public string GetEffectDescription() => $"{TargetSkillId}.{Stat} {(Delta >= 0 ? "+" : "")}{Delta}";
}

// ── 技能插槽替换 ──────────────────────────────────────────
[Serializable]
public class SkillSlotOverrideEffect : ICharmEffect
{
    public SkillSlotOverride overrideData;  // targetForm / targetSlot / replacementSkill / priority

    public void OnEquip(EquipmentContext ctx)   => ctx.SkillMods.AddSlotOverride(overrideData);
    public void OnUnequip(EquipmentContext ctx) => ctx.SkillMods.RemoveSlotOverride(overrideData);
    public string GetEffectDescription()
    {
        string formStr = overrideData.targetForm != null ? overrideData.targetForm.name : "所有形态";
        string skillName = overrideData.replacementSkill != null ? overrideData.replacementSkill.displayNameKey : "null";
        return $"{formStr}的 {overrideData.targetSlot} 替换为 [{skillName}]";
    }
}

// ── 武器替换 ──────────────────────────────────────────────
[Serializable]
public class WeaponOverrideEffect : ICharmEffect
{
    public string    targetFormId;      // 目标形态 ID留空 = 所有形态)
    public WeaponSO  replacementWeapon; // 替换武器 SO

    public void OnEquip(EquipmentContext ctx)   => ctx.WeaponMgr.SetOverride(targetFormId, replacementWeapon);
    public void OnUnequip(EquipmentContext ctx) => ctx.WeaponMgr.ClearOverride(targetFormId);
    public string GetEffectDescription()
    {
        string formStr = string.IsNullOrEmpty(targetFormId) ? "所有形态" : targetFormId;
        string wName   = replacementWeapon != null ? replacementWeapon.displayName : "null";
        return $"{formStr}的武器替换为 [{wName}]";
    }
}

6. EquipmentManager

// 路径: Assets/Scripts/Equipment/EquipmentManager.cs
public class EquipmentManager : MonoBehaviour, ISaveable
{
    [Header("配置")]
    [SerializeField] private EquipmentConfigSO _config;        // 初始 Notch 数量等
    [SerializeField] private CharmCatalogSO   _charmCatalog;  // CharmSO 查找表Assets/Data/Equipment/CharmCatalog.asset

    [Header("Event Channels")]
    [SerializeField] private CharmEventChannelSO  _onCharmEquipped;
    [SerializeField] private CharmEventChannelSO  _onCharmUnequipped;
    [SerializeField] private VoidEventChannelSO   _onEquipmentChanged;

    private List<CharmSO> _equipped   = new(4);
    private List<CharmSO> _collected  = new(32);
    private int _currentNotchCapacity;

    private EquipmentContext _ctx;

    private void Awake()
    {
        _ctx = new EquipmentContext
        {
            Stats     = GetComponent<PlayerStats>(),
            Feedback  = GetComponent<PlayerFeedback>(),
            Events    = EventChannelRegistry.Instance,
            SkillMods = GetComponent<SkillModifierRegistry>(),
            WeaponMgr = GetComponent<WeaponManager>(),
        };
        _currentNotchCapacity = _config != null ? _config.initialNotchCount : 3;
    }

    public int UsedNotches   => _equipped.Sum(c => c.notchCost);
    public int TotalNotches  => _currentNotchCapacity;
    public IReadOnlyList<CharmSO> Equipped   => _equipped;
    public IReadOnlyList<CharmSO> Collected  => _collected;

    /// <summary>装备魅力。返回失败原因null = 成功)</summary>
    public string TryEquipCharm(CharmSO charm)
    {
        if (_equipped.Contains(charm)) return "已经装备";
        if (!_collected.Contains(charm)) return "尚未收集此魅力";
        if (UsedNotches + charm.notchCost > _currentNotchCapacity)
            return $"笔记不足(需要 {charm.notchCost},剩余 {_currentNotchCapacity - UsedNotches}";

        _equipped.Add(charm);
        foreach (var fx in charm.effects) fx.OnEquip(_ctx);
        _onCharmEquipped.Raise(charm);
        _onEquipmentChanged.Raise();
        return null;
    }

    public void UnequipCharm(CharmSO charm)
    {
        if (!_equipped.Remove(charm)) return;
        foreach (var fx in charm.effects) fx.OnUnequip(_ctx);
        _onCharmUnequipped.Raise(charm);
        _onEquipmentChanged.Raise();
    }

    public void AddToCollection(string charmId)
    {
        // 通过 _charmCatalog.Find(charmId) 查找 CharmSO去重后加入 _collected
        if (_charmCatalog == null) return;
        var charm = _charmCatalog.Find(charmId);
        if (charm != null && !_collected.Contains(charm)) _collected.Add(charm);
    }

    public void IncreaseNotches(int amount) => _currentNotchCapacity += amount;

    // 存档集成ISaveable
    public void OnSave(SaveData data)
    {
        data.Equipment.EquippedCharmIds = _equipped.Select(c => c.charmId).ToArray();
        data.Equipment.OwnedCharmIds   = _collected.Select(c => c.charmId).ToArray();
        data.Equipment.NotchesUsed     = UsedNotches;
    }
    public void OnLoad(SaveData data)
    {
        // 清除当前装备,恢复 _collected再装备 _equipped
        foreach (var c in _equipped.ToList()) UnequipCharm(c);
        _collected.Clear();
        if (data.Equipment.OwnedCharmIds != null)
            foreach (var id in data.Equipment.OwnedCharmIds) AddToCollection(id);
        if (data.Equipment.EquippedCharmIds != null)
            foreach (var id in data.Equipment.EquippedCharmIds)
            {
                var charm = _charmCatalog?.Find(id);
                if (charm != null) TryEquipCharm(charm);
            }
        _onEquipmentChanged.Raise();
    }
}

7. ToolSO

// 路径: Assets/Scripts/Equipment/ToolSO.cs
// 主动工具(道具类,通常有限使用次数)
[CreateAssetMenu(menuName = "Equipment/Tool")]
public class ToolSO : ScriptableObject
{
    public string   toolId;
    public string   displayNameKey;
    public Sprite   icon;
    public int      maxUses;        // -1 = 无限

    [SerializeReference]
    public IToolEffect effect;      // 工具使用效果(多态)
}

public interface IToolEffect
{
    void Use(PlayerController player);
}

// 典型实现:治疗药水
[Serializable]
public class HealToolEffect : IToolEffect
{
    public int HealAmount;
    public void Use(PlayerController player) => player.Stats.HealHP(HealAmount);
}

7.5 ToolSlotManager 与 ToolHUD

// 路径: Assets/Scripts/Equipment/ToolSlotManager.cs
// 管理玩家的 2 个工具槽(装备、使用、冷却)
public class ToolSlotManager : MonoBehaviour, ISaveable
{
    private const int SlotCount = 2;

    [SerializeField] private ToolSO[]         _slots = new ToolSO[SlotCount];
    [SerializeField] private int[]            _remainingUses = new int[SlotCount];  // -1 = 无限
    [SerializeField] private ToolCatalogSO    _toolCatalog;                         // ToolSO 查找表Assets/Data/Equipment/ToolCatalog.asset
    [SerializeField] private ToolUsedEventChannelSO _onToolUsed;

    // 当前冷却倒计时(秒)
    private float[] _cooldowns = new float[SlotCount];

    // ── 装备 ────────────────────────────────────────────────────────────
    public void EquipTool(int slotIndex, ToolSO tool)
    {
        if (slotIndex < 0 || slotIndex >= SlotCount) return;
        _slots[slotIndex]         = tool;
        _remainingUses[slotIndex] = tool != null ? tool.maxUses : 0;
        _cooldowns[slotIndex]     = 0f;
    }

    // ── 使用 ────────────────────────────────────────────────────────────
    public bool TryUseTool(int slotIndex, PlayerController player)
    {
        if (slotIndex < 0 || slotIndex >= SlotCount) return false;
        var tool = _slots[slotIndex];
        if (tool == null) return false;
        if (_cooldowns[slotIndex] > 0) return false;
        if (_remainingUses[slotIndex] == 0) return false;   // 已耗尽(无限 = -1 不触发)

        tool.effect.Use(player);

        if (_remainingUses[slotIndex] > 0) _remainingUses[slotIndex]--;
        _cooldowns[slotIndex] = tool is IToolCooldown tc ? tc.CooldownDuration : 0f;

        _onToolUsed.Raise(new ToolUsedPayload { SlotIndex = slotIndex, Tool = tool });
        return true;
    }

    private void Update()
    {
        for (int i = 0; i < SlotCount; i++)
            if (_cooldowns[i] > 0f) _cooldowns[i] -= Time.deltaTime;
    }

    // ── 查询 ────────────────────────────────────────────────────────────
    public ToolSO  GetTool(int slotIndex)          => _slots[slotIndex];
    public float   GetCooldownRatio(int slotIndex) =>
        _slots[slotIndex] is IToolCooldown tc && tc.CooldownDuration > 0
            ? _cooldowns[slotIndex] / tc.CooldownDuration : 0f;
    public int     GetRemainingUses(int slotIndex) => _remainingUses[slotIndex];

    // ── ISaveable ────────────────────────────────────────────────────────
    public void OnSave(SaveData data)
    {
        data.Tools.ToolSlot0 = _slots[0]?.toolId;   // 注:工具数据归属 SaveData.Tools不在 Equipment
        data.Tools.ToolSlot1 = _slots[1]?.toolId;
    }
    public void OnLoad(SaveData data)
    {
        // 重置冷却,通过 _toolCatalog.Find() 恢复两个槽位的 ToolSO 引用
        for (int i = 0; i < SlotCount; i++) _cooldowns[i] = 0f;
        EquipTool(0, _toolCatalog?.Find(data.Tools.ToolSlot0));
        EquipTool(1, _toolCatalog?.Find(data.Tools.ToolSlot1));
    }
}

// 可选接口:带冷却时间的工具
public interface IToolCooldown
{
    float CooldownDuration { get; }
}

// 路径: Assets/Scripts/UI/ToolHUD.cs
// HUD 层:显示 2 个工具槽的图标 + 剩余次数 + 冷却遮罩
public class ToolHUD : MonoBehaviour
{
    [SerializeField] private ToolSlotUI[]      _slots;      // 2 个 ToolSlotUI 组件
    [SerializeField] private ToolSlotManager   _slotManager;
    [SerializeField] private ToolUsedEventChannelSO _onToolUsed;

    private void OnEnable()  => _onToolUsed.OnEventRaised += RefreshSlot;
    private void OnDisable() => _onToolUsed.OnEventRaised -= RefreshSlot;

    private void RefreshSlot(ToolUsedPayload payload)
        => _slots[payload.SlotIndex].Refresh(
               _slotManager.GetTool(payload.SlotIndex),
               _slotManager.GetRemainingUses(payload.SlotIndex),
               _slotManager.GetCooldownRatio(payload.SlotIndex));

    private void Update()
    {
        // 实时更新冷却遮罩
        for (int i = 0; i < _slots.Length; i++)
            _slots[i].SetCooldownFill(_slotManager.GetCooldownRatio(i));
    }
}

// 单个槽 UI图标 + 剩余次数文本 + 冷却遮罩 Image
public class ToolSlotUI : MonoBehaviour
{
    [SerializeField] private Image   _icon;
    [SerializeField] private TMP_Text _usesText;
    [SerializeField] private Image   _cooldownMask;   // FillAmount 冷却遮罩

    public void Refresh(ToolSO tool, int remainingUses, float cooldownRatio)
    {
        _icon.sprite  = tool != null ? tool.icon : null;
        _usesText.text = remainingUses < 0 ? "∞" : remainingUses.ToString();
        SetCooldownFill(cooldownRatio);
    }

    public void SetCooldownFill(float ratio) => _cooldownMask.fillAmount = ratio;
}

8. 技能系统 — FormSkillSO

// 路径: Assets/Scripts/Skills/FormSkillSO.cs
[CreateAssetMenu(menuName = "Skills/FormSkill")]
public class FormSkillSO : ScriptableObject
{
    [Header("Identity")]
    public string  skillId;
    public string  displayNameKey;
    [TextArea(1,3)] public string descriptionKey;
    public Sprite  icon;

    [Header("Resource")]
    public SkillResourceType resourceType; // SoulPower / SpiritPower
    public int     baseCost;
    public float   cooldown;

    [Header("Animation")]
    public ClipTransition castAnimation;
    public float   castLockDuration;   // 秒

    [Header("Effect")]
    public SkillEffectType effectType;
    public DamageSourceSO  damageSource;

    [Header("Projectile")]
    public ProjectileConfigSO projectileConfig;
    public bool    isHoming;
    public bool    holdForContinuous;

    [Header("Dash")]
    public float   dashForce;
    public float   dashDuration;
    public bool    isInvincibleDuringDash;

    [Header("Explosion")]
    public float   explosionDelay;
    public float   explosionRadius;

    [Header("Feedback")]
    public FeedbackPresetSO castFeedback;

    [Header("HitBox Prefab")]
    // 近战/爆炸技能的命中盒 Prefab内含 SkillHitBoxInstance + HitBox 组件
    // 投射物技能此字段留空——由 ProjectileConfigSO.WeaponPrefab 负责
    // 命名规范: Assets/Prefabs/Skills/SKL_{skillId}_HitBox.prefab
    public GameObject SkillHitBoxPrefab;
}

public enum SkillResourceType { SoulPower, SpiritPower }
public enum SkillEffectType
{
    MeleeAoE, Projectile, BarrierAura, GroundDive,
    DragonKick, WraithDash, ShadowDecoy, DelayedExplosion
}

9. SkillManager

// 路径: Assets/Scripts/Skills/SkillManager.cs
// 挂在 Player 上,处理所有技能逻辑
public class SkillManager : MonoBehaviour
{
    [SerializeField] private PlayerStats    _stats;
    [SerializeField] private PlayerController _controller;
    [SerializeField] private InputReaderSO  _input;
    [SerializeField] private SkillModifierRegistry _modifiers;
    [SerializeField] private ObjectPoolManager _pool;

    private FormSkillSO _soulSkill, _spirit1, _spirit2;    // 当前形态技能
    private float _soulCooldown, _spirit1Cooldown, _spirit2Cooldown;

    private void OnEnable()
    {
        _input.SoulSkillEvent             += TrySoulSkill;
        _input.SpiritSkill1StartedEvent   += TrySpiritSkill1;
        _input.SpiritSkill2StartedEvent   += TrySpiritSkill2;
    }
    private void OnDisable()
    {
        _input.SoulSkillEvent             -= TrySoulSkill;
        _input.SpiritSkill1StartedEvent   -= TrySpiritSkill1;
        _input.SpiritSkill2StartedEvent   -= TrySpiritSkill2;
    }

    // 切换形态时由 FormController 调用
    public void UpdateSkillSet(FormSkillSO soul, FormSkillSO spirit1, FormSkillSO spirit2);

    private void TrySoulSkill();
    // 1. 校验 cooldown
    // 2. _stats.ConsumeSoulPower(finalCost) → 失败 return
    // 3. _controller.ForceState(SoulSkillState)
    // 4a. 如果 skill.SkillHitBoxPrefab != null
    //       Instantiate(skill.SkillHitBoxPrefab, _skillSocket) → 获取 SkillHitBoxInstance
    //       inst.Activate(skill.damageSource, transform)
    //       inst.AutoDestroyAfter(skill.castLockDuration)
    // 4b. 如果 skill.projectileConfig != null由 Projectile 系统负责(见 06_CombatModule §7

    private int GetFinalCost(FormSkillSO skill);
    // baseCost 经 SkillModifierRegistry 调整后的最终消耗

    [SerializeField] private Transform _skillSocket;   // [SkillSocket] 子节点引用
}

SkillHitBoxInstance

// 路径: Assets/Scripts/Combat/SkillHitBoxInstance.cs
// 挂载于技能 HitBox Prefab 根节点(与 WeaponInstance 对应,但生命周期更短)
// 命名规范: Assets/Prefabs/Skills/SKL_{skillId}_HitBox.prefab
//
// Prefab 内部层级示例(近战 AoE 技能):
//   [SKL_SkySlash_HitBox]
//   └── [HitBox]          ← 扇形/圆形 PolygonCollider2D形状由美术决定
//       └── HitBox.cs
public class SkillHitBoxInstance : MonoBehaviour
{
    [SerializeField] private HitBox[] _hitBoxes;  // 技能可有多个 HitBox多段伤害

    public System.Action<DamageInfo> OnHitConfirmed;

    private void Awake()
    {
        foreach (var hb in _hitBoxes)
            hb.OnHitConfirmed += info => OnHitConfirmed?.Invoke(info);
    }

    // 激活所有 HitBox持续 duration 秒后自动 Destroy
    public void Activate(DamageSourceSO source, Transform attacker)
    {
        foreach (var hb in _hitBoxes)
            hb.Activate(source, attacker);
    }

    public void AutoDestroyAfter(float duration)
        => Destroy(gameObject, duration);

    private void OnDestroy()
    {
        foreach (var hb in _hitBoxes) hb.Deactivate();
    }
}

---

## 10. SkillModifierRegistry

```csharp
// 路径: Assets/Scripts/Skills/SkillModifierRegistry.cs
// 收集所有魅力对技能数值的修改SkillManager 查询最终有效参数
public class SkillModifierRegistry
{
    private Dictionary<string, Dictionary<SkillStat, float>> _overrides = new();

    public void Register(string skillId, SkillStat stat, float delta, bool isPercent);
    public void Unregister(string skillId, SkillStat stat, float delta, bool isPercent);

    /// <summary>
    /// 对给定技能叠加所有已注册修改器,返回一次性快照供 SkillManager 使用。
    /// 替代逐字段查询的 GetModifiedValue(),一次调用即可获取全部有效参数。
    /// </summary>
    public EffectiveSkillParams GetEffectiveParams(FormSkillSO skill);

    // 向后兼容:单字段查询(内部调用 GetEffectiveParams 后提取)
    public float GetModifiedValue(string skillId, SkillStat stat, float baseVal);
}

public enum SkillStat { Damage, Cost, Cooldown, Range, Duration }

/// <summary>
/// 所有数值修改器叠加后的运行时参数快照,由 SkillModifierRegistry.GetEffectiveParams() 生成,
/// 传入 SkillManager.CastRoutine() 和 ExecuteEffect()。
/// </summary>
public struct EffectiveSkillParams
{
    public FormSkillSO      baseSkill;          // 原始 SO 引用(不变,供判断 effectType
    public int              effectiveCost;      // 修改后消耗量
    public float            effectiveCooldown;  // 修改后冷却(秒)
    public float            damageMult;         // 伤害倍率1.0 = 无增益)
    public float            rangeMult;          // 范围倍率AoE 半径 / 障壁半径 / 爆炸半径)
    public FeedbackPresetSO effectiveFeedback;  // 最终特效预设护符可替换null = 回退原始)
    public ClipTransition   effectiveAnimation; // 最终施法动画护符可替换null = 回退原始)

    /// <summary>以技能 SO 默认值初始化,无任何修改器加成。</summary>
    public static EffectiveSkillParams FromBase(FormSkillSO skill) => new()
    {
        baseSkill          = skill,
        effectiveCost      = skill.baseCost,
        effectiveCooldown  = skill.cooldown,
        damageMult         = 1f,
        rangeMult          = 1f,
        effectiveFeedback  = null,
        effectiveAnimation = null,
    };
}

11. RegionDefinitionSO区域定义

// 路径: Assets/Scripts/Progression/RegionDefinitionSO.cs
// 每个区域一个 SO 资产,集中管理区域元数据
[CreateAssetMenu(menuName = "Progression/RegionDefinition")]
public class RegionDefinitionSO : ScriptableObject
{
    public string  regionId;             // 如 "Cave"(与 AudioZone.regionId 一致)
    public string  displayName;          // 如 "腐蚀洞穴"
    public Color   mapColor;             // 地图 UI 上该区域的颜色标识
    public Sprite  mapIconSprite;        // P1地图图标

    [Header("解锁条件")]
    public string  requiredBossDefeated; // 空字符串 = 无条件
    public AbilityType requiredAbility;  // None = 无要求(默认值 0

    [Header("关联房间")]
    public string[] roomSceneNames;      // 该区域包含的所有场景名
    public string   bossSceneName;       // Boss 房间场景名
    public string   entrySceneName;      // 从外部进入该区域的第一个房间
}

资产路径Assets/ScriptableObjects/Progression/Regions/
命名Region_{RegionId}.asset(如 Region_Forest.asset

区域 ID 对照表

区域 ID 中文名 Boss 开放条件
Forest 扎根森林 Boss_SpiderGuard 无(起始区域)
Cave 腐蚀洞穴 Boss_CorrosionWorm 击败 Boss_SpiderGuard
Ruins 坍塌废墟 Boss_RuinsKnight 获得 Dash 能力
Abyss 深渊裂隙 Boss_AbyssThroat 击败 Boss_RuinsKnight
Core 核心熔炉 FinalBoss 击败 Boss_AbyssThroat

12. ProgressLock进程锁

// 路径: Assets/Scripts/Progression/ProgressLock.cs
// 单向/永久性阻挡,需满足特定条件(击败 Boss 或持有道具)才能解锁
public class ProgressLock : MonoBehaviour
{
    [Header("解锁条件")]
    [SerializeField] private string _requiredBossId;   // 空 = 不检查 Boss
    [SerializeField] private string _requiredItemId;   // 空 = 不检查道具P1

    [Header("物理表现")]
    [SerializeField] private GameObject _lockedVisuals;   // 锁住状态视觉
    [SerializeField] private GameObject _unlockedVisuals; // 开启状态视觉(可 null
    [SerializeField] private Collider2D _blockCollider;

    [Header("存档")]
    [SerializeField] private string _lockId;             // 唯一 ID存档记录开启状态

    [Header("Event Channels")]
    [SerializeField] private StringEventChannelSO _onBossDefeated;    // EVT_BossDefeated

    private void Start()
    {
        bool isUnlocked = CheckUnlocked();
        ApplyState(isUnlocked);
        if (!isUnlocked)
            _onBossDefeated.OnEventRaised += OnBossDefeated;
    }

    private void OnDestroy() => _onBossDefeated.OnEventRaised -= OnBossDefeated;

    private void OnBossDefeated(string bossId)
    {
        if (_requiredBossId == bossId && CheckUnlocked())
            ApplyState(true);
    }

    private bool CheckUnlocked()
    {
        var save = SaveManager.Instance.Data;
        if (!string.IsNullOrEmpty(_requiredBossId) && !save.World.DefeatedBossIds.Contains(_requiredBossId))
            return false;
        return save.World.OpenedDoors.Contains(_lockId);
    }

    private void ApplyState(bool unlocked)
    {
        _blockCollider.enabled = !unlocked;
        _lockedVisuals.SetActive(!unlocked);
        if (_unlockedVisuals != null)
            _unlockedVisuals.SetActive(unlocked);
    }
}

13. BossProgressTracker

// 路径: Assets/Scripts/Progression/BossProgressTracker.cs
// 轻量辅助组件,挂载在 Boss 房间的 BossTrigger 同一对象上
public class BossProgressTracker : MonoBehaviour
{
    [SerializeField] private string   _bossId;                   // 如 "Boss_SpiderGuard"
    [SerializeField] private string[] _unlocksProgressLockIds;   // 击败后解锁哪些 ProgressLock

    [Header("Event Channels")]
    [SerializeField] private StringEventChannelSO _onBossDefeated;       // 监听
    [SerializeField] private StringEventChannelSO _onBossDefeatedForSave; // 广播→SaveSystem

    private void OnEnable()  => _onBossDefeated.OnEventRaised += OnBossDefeated;
    private void OnDisable() => _onBossDefeated.OnEventRaised -= OnBossDefeated;

    private void OnBossDefeated(string bossId)
    {
        if (bossId != _bossId) return;
        // 1. 通过事件频道通知 SaveSystem零耦合
        _onBossDefeatedForSave.Raise(bossId);
        // SaveSystem 收到后data.World.DefeatedBossIds.Add(bossId); 并解锁相关 ProgressLock
    }
}

14. HPContainerPickup

// 路径: Assets/Scripts/Progression/HPContainerPickup.cs
// HP 容器:永久 MaxHP +2 的可拾取物件
public class HPContainerPickup : MonoBehaviour
{
    [SerializeField] private string _collectibleId;      // 存档用唯一 ID
    [SerializeField] private InputReaderSO _inputReader;

    [Header("Event Channels")]
    [SerializeField] private StringEventChannelSO _onMaxHPContainerPickedUp; // → SaveSystem
    [SerializeField] private IntEventChannelSO    _onMaxHPChanged;           // → HUDController

    private void OnTriggerEnter2D(Collider2D other)
    {
        if (!other.CompareTag("Player")) return;
        var save = SaveManager.Instance.Data;
        if (save != null && save.World.CollectedIds.Contains(_collectibleId)) return;
        StartCoroutine(PickupSequence());
    }

    private IEnumerator PickupSequence()
    {
        _inputReader.EnableGameplayInput(false);
        gameObject.SetActive(false);

        // Feel MMF_Player 播放获取特效(外部引用或 GetComponent
        yield return new WaitForSeconds(0.8f);

        // 零耦合:通过事件频道通知 SaveSystem
        _onMaxHPContainerPickedUp.Raise(_collectibleId);
        // SaveSystemdata.Player.MaxHP += 2; data.World.CollectedIds.Add(id); Save();

        yield return new WaitForSeconds(0.5f);
        _inputReader.EnableGameplayInput(true);
    }
}

15. Progression 事件频道清单

资产名 类型 Raise 方 Subscribe 方
EVT_CharmEquipped VoidEventChannelSO EquipmentManager HUDController(更新 HUDAnalyticsManager
EVT_CharmCollected StringEventChannelSO Collectible EquipmentManager(加入收藏)、AchievementManager
EVT_AbilityUnlocked StringEventChannelSOabilityId PlayerStats.UnlockAbility AbilityGateHUDController(弹窗)、AchievementManager
EVT_NotchIncreased IntEventChannelSO ShopController EquipmentManager
EVT_SkillSetChanged VoidEventChannelSO FormController SkillHUD