1061 lines
40 KiB
Markdown
1061 lines
40 KiB
Markdown
# 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.Editor(Editor Only)
|
||
#if UNITY_EDITOR
|
||
using UnityEditor;
|
||
using UnityEngine;
|
||
|
||
namespace BaseGames.Editor.Equipment
|
||
{
|
||
/// <summary>
|
||
/// 为 CharmSO.effects(List<ICharmEffect>)提供友好的 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);
|
||
// 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` |
|