# 09 · 进度模块 > **命名空间** `BaseGames.Player`、`BaseGames.Equipment`、`BaseGames.Skills`、`BaseGames.Progression` > **程序集** `BaseGames.Equipment`、`BaseGames.Skills` > **路径** `Assets/Scripts/Player/`、`Assets/Scripts/World/`、`Assets/Scripts/Equipment/`、`Assets/Scripts/Skills/` > **依赖** `BaseGames.Core.Events`、`BaseGames.Combat`、`BaseGames.Player` --- ## 目录 1. [AbilityType 枚举](#1-abilitytype-枚举) 2. [AbilityGate(能力门禁)](#2-abilitygate) 3. [装备系统 — CharmSO](#3-装备系统--charmso) 4. [ICharmEffect 接口](#4-icharmeffect-接口) 5. [内置 CharmEffect 实现](#5-内置-charmeffect-实现) 6. [EquipmentManager](#6-equipmentmanager) 7. [ToolSO(主动工具)](#7-toolso) 8. [技能系统 — FormSkillSO](#8-技能系统--formskillso) 9. [SkillManager](#9-skillmanager) 10. [SkillModifierRegistry](#10-skillmodifierregistry) 11. [RegionDefinitionSO](#11-regiondefinitionso区域定义) 12. [ProgressLock](#12-progresslock进程锁) 13. [BossProgressTracker](#13-bossprogresstracker) 14. [HPContainerPickup](#14-hpcontainerpickup) 15. [ProgressionEventChannel 清单](#15-progression-事件频道清单) --- ## 1. AbilityType 枚举 > **架构决策 (2026-05):改为 `[Flags] uint` bitmask** > - 原 `Dictionary` 存档方案每次查询需 `.ToString()` 装箱 + 字典哈希,在 AbilityGate.Start() 及 ParrySystem.TryActivateParry() 等热路径产生不必要开销 > - 改为 bitmask 后:存档只存一个 `uint`,查询为单次位运算 `(_flags & ability) != 0`,兼容序列化 > - **新增能力只需追加新的 `1 << N`,禁止修改已有枚举值**(防止存档数据错位) > - 本节以当前仓库中的 `Assets/Scripts/Player/AbilityType.cs` 为准;历史文档中的旧命名不再作为当前实现事实来源 ```csharp // 路径: 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 ```csharp // 路径: 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 ```csharp // 路径: 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 effects; // 多态序列化([SerializeReference]) [Header("Lore")] public bool isUnique; // 唯一物品 public string unlockHint; } ``` **资产路径**:`Assets/ScriptableObjects/Equipment/Charms/` **命名**:`Charm_{Name}.asset` --- ## 4. ICharmEffect 接口 ```csharp // 路径: 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 是必须的。 ```csharp // 路径: Assets/Scripts/Editor/Equipment/CharmEffectDrawer.cs // 程序集: BaseGames.Editor(Editor Only) #if UNITY_EDITOR using UnityEditor; using UnityEngine; namespace BaseGames.Editor.Equipment { /// /// 为 CharmSO.effects(List<ICharmEffect>)提供友好的 Inspector 体验: /// - 下拉菜单选类型(显示中文名而非 C# 全称) /// - 每条效果展开显示字段 + GetEffectDescription() 预览文字 /// - 支持拖动重排、单条删除 /// [CustomEditor(typeof(CharmSO))] public class CharmSOEditor : UnityEditor.Editor { // 已注册的所有 ICharmEffect 实现类型(反射收集,仅 Editor 运行) private static readonly System.Type[] _effectTypes = CollectEffectTypes(); // 每种类型的显示名(对应策划友好名称) private static readonly Dictionary _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 实现 ```csharp // 路径: 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("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 ```csharp // 路径: 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 _equipped = new(4); private List _collected = new(32); private int _currentNotchCapacity; private EquipmentContext _ctx; private void Awake() { _ctx = new EquipmentContext { Stats = GetComponent(), Feedback = GetComponent(), Events = EventChannelRegistry.Instance, SkillMods = GetComponent(), WeaponMgr = GetComponent(), }; _currentNotchCapacity = _config != null ? _config.initialNotchCount : 3; } public int UsedNotches => _equipped.Sum(c => c.notchCost); public int TotalNotches => _currentNotchCapacity; public IReadOnlyList Equipped => _equipped; public IReadOnlyList Collected => _collected; /// 装备魅力。返回失败原因(null = 成功) 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 ```csharp // 路径: 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 ```csharp // 路径: 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 ```csharp // 路径: 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 ```csharp // 路径: 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 ```csharp // 路径: 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 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> _overrides = new(); public void Register(string skillId, SkillStat stat, float delta, bool isPercent); public void Unregister(string skillId, SkillStat stat, float delta, bool isPercent); /// /// 对给定技能叠加所有已注册修改器,返回一次性快照供 SkillManager 使用。 /// 替代逐字段查询的 GetModifiedValue(),一次调用即可获取全部有效参数。 /// public EffectiveSkillParams GetEffectiveParams(FormSkillSO skill); // 向后兼容:单字段查询(内部调用 GetEffectiveParams 后提取) public float GetModifiedValue(string skillId, SkillStat stat, float baseVal); } public enum SkillStat { Damage, Cost, Cooldown, Range, Duration } /// /// 所有数值修改器叠加后的运行时参数快照,由 SkillModifierRegistry.GetEffectiveParams() 生成, /// 传入 SkillManager.CastRoutine() 和 ExecuteEffect()。 /// 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 = 回退原始) /// 以技能 SO 默认值初始化,无任何修改器加成。 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(区域定义) ```csharp // 路径: 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(进程锁) ```csharp // 路径: 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 ```csharp // 路径: 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 ```csharp // 路径: 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); // SaveSystem:data.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`(更新 HUD)、`AnalyticsManager` | | `EVT_CharmCollected` | `StringEventChannelSO` | `Collectible` | `EquipmentManager`(加入收藏)、`AchievementManager` | | `EVT_AbilityUnlocked` | `StringEventChannelSO`(abilityId) | `PlayerStats.UnlockAbility` | `AbilityGate`、`HUDController`(弹窗)、`AchievementManager` | | `EVT_NotchIncreased` | `IntEventChannelSO` | `ShopController` | `EquipmentManager` | | `EVT_SkillSetChanged` | `VoidEventChannelSO` | `FormController` | `SkillHUD` |