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

1061 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<string, bool>` 存档方案每次查询需 `.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<ICharmEffect> 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.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 实现
```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<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
```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<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
```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<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区域定义
```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);
// 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`(更新 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` |