40 KiB
40 KiB
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
目录
- AbilityType 枚举
- AbilityGate(能力门禁)
- 装备系统 — CharmSO
- ICharmEffect 接口
- 内置 CharmEffect 实现
- EquipmentManager
- ToolSO(主动工具)
- 技能系统 — FormSkillSO
- SkillManager
- SkillModifierRegistry
- RegionDefinitionSO
- ProgressLock
- BossProgressTracker
- HPContainerPickup
- ProgressionEventChannel 清单
1. AbilityType 枚举
架构决策 (2026-05):改为
[Flags] uintbitmask
- 原
Dictionary<string, bool>存档方案每次查询需.ToString()装箱 + 字典哈希,在 AbilityGate.Start() 及 ParrySystem.TryActivateParry() 等热路径产生不必要开销- 改为 bitmask 后:存档只存一个
uint,查询为单次位运算(_flags & ability) != 0,兼容序列化- 新增能力只需追加新的
1 << N,禁止修改已有枚举值(防止存档数据错位)- 本节以当前仓库中的
Assets/Scripts/Player/AbilityType.cs为准;历史文档中的旧命名不再作为当前实现事实来源
// 路径: Assets/Scripts/Player/AbilityType.cs
// 所有可解锁能力的位标志枚举;PlayerStats.HasAbility() 依赖此
[System.Flags]
public enum AbilityType : uint
{
None = 0,
// 移动能力
WallCling = 1u << 0, // 贴墙悬挂
WallJump = 1u << 1, // 墙跳
Dash = 1u << 2, // 地面冲刺
AirDash = 1u << 3, // 空中冲刺(二段冲刺)
DoubleJump = 1u << 4, // 二段跳
SuperJump = 1u << 5, // 超级跳(聚气跳)
Swim = 1u << 6, // 游泳(液体中自由移动)
Dive = 1u << 7, // 下劈(空中下突)
// 法术能力
Spell1 = 1u << 8, // 法术槽 1(策划自定义)
Spell2 = 1u << 9, // 法术槽 2
Spell3 = 1u << 10, // 法术槽 3
// 灵魄形态
SpiritForm = 1u << 11, // 灵魄形态切换
SpiritDash = 1u << 12, // 灵魄冲刺(穿透地形)
// 战斗能力
Parry = 1u << 13, // 格挡/弹反
ChargeAttack = 1u << 14, // 蓄力攻击
DownSlash = 1u << 15, // 下斩
// 互动能力
Interact = 1u << 16, // 互动(NPC/机关)
FastTravel = 1u << 17, // 快速旅行解锁
// 组合掩码
AllMovement = WallCling | WallJump | Dash | AirDash | DoubleJump | SuperJump | Swim | Dive,
AllSpells = Spell1 | Spell2 | Spell3,
AllSpirit = SpiritForm | SpiritDash,
}
2. AbilityGate
// 路径: Assets/Scripts/World/AbilityGate.cs
// 物理关卡障碍,阻挡未解锁某能力的玩家
[RequireComponent(typeof(Collider2D))]
public class AbilityGate : MonoBehaviour
{
[SerializeField] private AbilityType _requiredAbility;
[SerializeField] private GameObject _blockingObject; // 关卡障碍物 GO(禁/启用)
[SerializeField] private GameObject _hintUI; // 提示 UI(如能力图标 + "???")
[SerializeField] private string _gateId; // 存档用
[Header("Event Channels")]
// AbilityTypeEventChannelSO 已在 02_EventSystem §3 定义(替换旧 StringEventChannelSO)
[SerializeField] private AbilityTypeEventChannelSO _onAbilityUnlocked; // EVT_AbilityUnlocked
// _saveData 由 GameInitializer 在 Awake 时注入(零耦合,避免 SaveManager.Instance)
private SaveData _saveData;
private void Start()
{
// 位运算查询:无需 ToString + 字典哈希
bool hasAbility = _saveData != null
&& (_saveData.Player.AbilityFlags & (uint)_requiredAbility) != 0;
_blockingObject.SetActive(!hasAbility);
if (_hintUI != null) _hintUI.SetActive(!hasAbility);
}
private void OnEnable() => _onAbilityUnlocked.OnEventRaised += OnAbilityUnlocked;
private void OnDisable() => _onAbilityUnlocked.OnEventRaised -= OnAbilityUnlocked;
private void OnAbilityUnlocked(AbilityType ability)
{
if (ability != _requiredAbility) return;
_blockingObject.SetActive(false);
if (_hintUI != null) _hintUI.SetActive(false);
// P1:播放解锁动画(如荆棘收缩、道路开通特效)
}
public void Open() => _blockingObject.SetActive(false);
}
3. 装备系统 — CharmSO
// 路径: Assets/Scripts/Equipment/CharmSO.cs
[CreateAssetMenu(menuName = "Equipment/Charm")]
public class CharmSO : ScriptableObject
{
[Header("Identity")]
public string charmId; // 全局唯一 ID,如 "Charm_QuickSlash"
public string displayNameKey; // 本地化 Key
[TextArea(2,4)]
public string descriptionKey;
[Header("Visual")]
public Sprite icon;
public Color glowColor;
[Header("Slot Cost")]
[Range(1,4)]
public int notchCost; // 占用笔记数(1~4)
[Header("Effects")]
[SerializeReference]
public List<ICharmEffect> effects; // 多态序列化([SerializeReference])
[Header("Lore")]
public bool isUnique; // 唯一物品
public string unlockHint;
}
资产路径:Assets/ScriptableObjects/Equipment/Charms/
命名:Charm_{Name}.asset
4. ICharmEffect 接口
// 路径: Assets/Scripts/Equipment/ICharmEffect.cs
[System.Serializable]
public interface ICharmEffect
{
void OnEquip(EquipmentContext ctx);
void OnUnequip(EquipmentContext ctx);
string GetEffectDescription();
}
// 上下文:避免接口直接依赖具体类
public struct EquipmentContext
{
public PlayerStats Stats;
public PlayerFeedback Feedback;
public EventChannelRegistry Events; // SO 事件频道注册表
public SkillModifierRegistry SkillMods; // 技能修改器注册表
public WeaponManager WeaponMgr; // 武器切换管理器
}
4.1 CharmEffectDrawer — 自定义 PropertyDrawer
痛点:Unity 默认的
[SerializeReference]Inspector 体验极差——添加/删除效果需要右键 "Manage References",类型名显示 C# 全称。策划每日在 CharmSO Inspector 中增减效果,该 Drawer 是必须的。
// 路径: Assets/Scripts/Editor/Equipment/CharmEffectDrawer.cs
// 程序集: BaseGames.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 实现
// 路径: Assets/Scripts/Equipment/Effects/
// ── 属性加成 ─────────────────────────────────────────────
[Serializable]
public class StatModifierEffect : ICharmEffect
{
public StatType statType; // MaxHP / AttackDamage / MoveSpeed / JumpHeight / SoulGain / Defense
public float flatBonus; // 固定加成(如 +1 HP)
public float percentBonus; // 百分比加成(如 +0.2 = +20%)
public void OnEquip(EquipmentContext ctx) => ctx.Stats.AddModifier(statType, flatBonus, percentBonus);
public void OnUnequip(EquipmentContext ctx) => ctx.Stats.RemoveModifier(statType, flatBonus, percentBonus);
public string GetEffectDescription() => $"{statType}: +{flatBonus} +{percentBonus*100:0}%";
}
// ── 攻击速度加成 ──────────────────────────────────────────
[Serializable]
public class AttackSpeedEffect : ICharmEffect
{
[Range(0.1f, 2.0f)]
public float speedMultiplier = 1.2f; // 动画速度倍率(如 1.2 = 加速 20%)
public void OnEquip(EquipmentContext ctx) => ctx.Stats.AnimatorSpeedMultiplier += (speedMultiplier - 1f);
public void OnUnequip(EquipmentContext ctx) => ctx.Stats.AnimatorSpeedMultiplier -= (speedMultiplier - 1f);
public string GetEffectDescription() => $"攻击速度 +{(speedMultiplier - 1) * 100:0}%";
}
// ── 命中触发效果 ──────────────────────────────────────────
[Serializable]
public class OnHitEffect : ICharmEffect
{
public OnHitEffectType effectType; // ApplyPoison / ApplyFire / KnockbackBoost
[Range(0f, 1f)]
public float chance; // 触发概率(0~1)
private DamageInfoEventChannelSO _onHitChannel;
public void OnEquip(EquipmentContext ctx)
{
_onHitChannel = ctx.Events.Get<DamageInfoEventChannelSO>("OnHitConfirmed");
_onHitChannel.OnEventRaised += HandleHit;
}
public void OnUnequip(EquipmentContext ctx) => _onHitChannel.OnEventRaised -= HandleHit;
private void HandleHit(DamageInfo info)
{
if (UnityEngine.Random.value > chance) return;
// 触发对应效果(由 StatusEffectManager 处理,见 06_CombatModule §12)
}
public string GetEffectDescription() => $"命中时 {chance * 100:0}% 概率附加 {effectType}";
}
// ── 灵魂法术强化 ──────────────────────────────────────────
[Serializable]
public class SoulSpellEffect : ICharmEffect
{
public SpellType spellType; // SoulAttack / HealingWave
public int soulCostReduction; // 减少消耗 Soul 点数
public void OnEquip(EquipmentContext ctx)
=> ctx.Stats.RegisterSpellModifier(spellType, soulCostReduction, 0f);
public void OnUnequip(EquipmentContext ctx)
=> ctx.Stats.UnregisterSpellModifier(spellType, soulCostReduction, 0f);
public string GetEffectDescription() => $"{spellType} 消耗减少 {soulCostReduction} Soul";
}
// ── 技能数值修改 ──────────────────────────────────────────
[Serializable]
public class SkillNumericModifierEffect : ICharmEffect
{
public string TargetSkillId;
public SkillStat Stat; // enum: Damage, Cost, Cooldown, Range, Duration
public float Delta;
public bool IsPercent;
public void OnEquip(EquipmentContext ctx) => ctx.SkillMods.Register(TargetSkillId, Stat, Delta, IsPercent);
public void OnUnequip(EquipmentContext ctx) => ctx.SkillMods.Unregister(TargetSkillId, Stat, Delta, IsPercent);
public string GetEffectDescription() => $"{TargetSkillId}.{Stat} {(Delta >= 0 ? "+" : "")}{Delta}";
}
// ── 技能插槽替换 ──────────────────────────────────────────
[Serializable]
public class SkillSlotOverrideEffect : ICharmEffect
{
public SkillSlotOverride overrideData; // targetForm / targetSlot / replacementSkill / priority
public void OnEquip(EquipmentContext ctx) => ctx.SkillMods.AddSlotOverride(overrideData);
public void OnUnequip(EquipmentContext ctx) => ctx.SkillMods.RemoveSlotOverride(overrideData);
public string GetEffectDescription()
{
string formStr = overrideData.targetForm != null ? overrideData.targetForm.name : "所有形态";
string skillName = overrideData.replacementSkill != null ? overrideData.replacementSkill.displayNameKey : "null";
return $"{formStr}的 {overrideData.targetSlot} 替换为 [{skillName}]";
}
}
// ── 武器替换 ──────────────────────────────────────────────
[Serializable]
public class WeaponOverrideEffect : ICharmEffect
{
public string targetFormId; // 目标形态 ID(留空 = 所有形态)
public WeaponSO replacementWeapon; // 替换武器 SO
public void OnEquip(EquipmentContext ctx) => ctx.WeaponMgr.SetOverride(targetFormId, replacementWeapon);
public void OnUnequip(EquipmentContext ctx) => ctx.WeaponMgr.ClearOverride(targetFormId);
public string GetEffectDescription()
{
string formStr = string.IsNullOrEmpty(targetFormId) ? "所有形态" : targetFormId;
string wName = replacementWeapon != null ? replacementWeapon.displayName : "null";
return $"{formStr}的武器替换为 [{wName}]";
}
}
6. EquipmentManager
// 路径: Assets/Scripts/Equipment/EquipmentManager.cs
public class EquipmentManager : MonoBehaviour, ISaveable
{
[Header("配置")]
[SerializeField] private EquipmentConfigSO _config; // 初始 Notch 数量等
[SerializeField] private CharmCatalogSO _charmCatalog; // CharmSO 查找表(Assets/Data/Equipment/CharmCatalog.asset)
[Header("Event Channels")]
[SerializeField] private CharmEventChannelSO _onCharmEquipped;
[SerializeField] private CharmEventChannelSO _onCharmUnequipped;
[SerializeField] private VoidEventChannelSO _onEquipmentChanged;
private List<CharmSO> _equipped = new(4);
private List<CharmSO> _collected = new(32);
private int _currentNotchCapacity;
private EquipmentContext _ctx;
private void Awake()
{
_ctx = new EquipmentContext
{
Stats = GetComponent<PlayerStats>(),
Feedback = GetComponent<PlayerFeedback>(),
Events = EventChannelRegistry.Instance,
SkillMods = GetComponent<SkillModifierRegistry>(),
WeaponMgr = GetComponent<WeaponManager>(),
};
_currentNotchCapacity = _config != null ? _config.initialNotchCount : 3;
}
public int UsedNotches => _equipped.Sum(c => c.notchCost);
public int TotalNotches => _currentNotchCapacity;
public IReadOnlyList<CharmSO> Equipped => _equipped;
public IReadOnlyList<CharmSO> Collected => _collected;
/// <summary>装备魅力。返回失败原因(null = 成功)</summary>
public string TryEquipCharm(CharmSO charm)
{
if (_equipped.Contains(charm)) return "已经装备";
if (!_collected.Contains(charm)) return "尚未收集此魅力";
if (UsedNotches + charm.notchCost > _currentNotchCapacity)
return $"笔记不足(需要 {charm.notchCost},剩余 {_currentNotchCapacity - UsedNotches})";
_equipped.Add(charm);
foreach (var fx in charm.effects) fx.OnEquip(_ctx);
_onCharmEquipped.Raise(charm);
_onEquipmentChanged.Raise();
return null;
}
public void UnequipCharm(CharmSO charm)
{
if (!_equipped.Remove(charm)) return;
foreach (var fx in charm.effects) fx.OnUnequip(_ctx);
_onCharmUnequipped.Raise(charm);
_onEquipmentChanged.Raise();
}
public void AddToCollection(string charmId)
{
// 通过 _charmCatalog.Find(charmId) 查找 CharmSO,去重后加入 _collected
if (_charmCatalog == null) return;
var charm = _charmCatalog.Find(charmId);
if (charm != null && !_collected.Contains(charm)) _collected.Add(charm);
}
public void IncreaseNotches(int amount) => _currentNotchCapacity += amount;
// 存档集成(ISaveable)
public void OnSave(SaveData data)
{
data.Equipment.EquippedCharmIds = _equipped.Select(c => c.charmId).ToArray();
data.Equipment.OwnedCharmIds = _collected.Select(c => c.charmId).ToArray();
data.Equipment.NotchesUsed = UsedNotches;
}
public void OnLoad(SaveData data)
{
// 清除当前装备,恢复 _collected,再装备 _equipped
foreach (var c in _equipped.ToList()) UnequipCharm(c);
_collected.Clear();
if (data.Equipment.OwnedCharmIds != null)
foreach (var id in data.Equipment.OwnedCharmIds) AddToCollection(id);
if (data.Equipment.EquippedCharmIds != null)
foreach (var id in data.Equipment.EquippedCharmIds)
{
var charm = _charmCatalog?.Find(id);
if (charm != null) TryEquipCharm(charm);
}
_onEquipmentChanged.Raise();
}
}
7. ToolSO
// 路径: Assets/Scripts/Equipment/ToolSO.cs
// 主动工具(道具类,通常有限使用次数)
[CreateAssetMenu(menuName = "Equipment/Tool")]
public class ToolSO : ScriptableObject
{
public string toolId;
public string displayNameKey;
public Sprite icon;
public int maxUses; // -1 = 无限
[SerializeReference]
public IToolEffect effect; // 工具使用效果(多态)
}
public interface IToolEffect
{
void Use(PlayerController player);
}
// 典型实现:治疗药水
[Serializable]
public class HealToolEffect : IToolEffect
{
public int HealAmount;
public void Use(PlayerController player) => player.Stats.HealHP(HealAmount);
}
7.5 ToolSlotManager 与 ToolHUD
// 路径: Assets/Scripts/Equipment/ToolSlotManager.cs
// 管理玩家的 2 个工具槽(装备、使用、冷却)
public class ToolSlotManager : MonoBehaviour, ISaveable
{
private const int SlotCount = 2;
[SerializeField] private ToolSO[] _slots = new ToolSO[SlotCount];
[SerializeField] private int[] _remainingUses = new int[SlotCount]; // -1 = 无限
[SerializeField] private ToolCatalogSO _toolCatalog; // ToolSO 查找表(Assets/Data/Equipment/ToolCatalog.asset)
[SerializeField] private ToolUsedEventChannelSO _onToolUsed;
// 当前冷却倒计时(秒)
private float[] _cooldowns = new float[SlotCount];
// ── 装备 ────────────────────────────────────────────────────────────
public void EquipTool(int slotIndex, ToolSO tool)
{
if (slotIndex < 0 || slotIndex >= SlotCount) return;
_slots[slotIndex] = tool;
_remainingUses[slotIndex] = tool != null ? tool.maxUses : 0;
_cooldowns[slotIndex] = 0f;
}
// ── 使用 ────────────────────────────────────────────────────────────
public bool TryUseTool(int slotIndex, PlayerController player)
{
if (slotIndex < 0 || slotIndex >= SlotCount) return false;
var tool = _slots[slotIndex];
if (tool == null) return false;
if (_cooldowns[slotIndex] > 0) return false;
if (_remainingUses[slotIndex] == 0) return false; // 已耗尽(无限 = -1 不触发)
tool.effect.Use(player);
if (_remainingUses[slotIndex] > 0) _remainingUses[slotIndex]--;
_cooldowns[slotIndex] = tool is IToolCooldown tc ? tc.CooldownDuration : 0f;
_onToolUsed.Raise(new ToolUsedPayload { SlotIndex = slotIndex, Tool = tool });
return true;
}
private void Update()
{
for (int i = 0; i < SlotCount; i++)
if (_cooldowns[i] > 0f) _cooldowns[i] -= Time.deltaTime;
}
// ── 查询 ────────────────────────────────────────────────────────────
public ToolSO GetTool(int slotIndex) => _slots[slotIndex];
public float GetCooldownRatio(int slotIndex) =>
_slots[slotIndex] is IToolCooldown tc && tc.CooldownDuration > 0
? _cooldowns[slotIndex] / tc.CooldownDuration : 0f;
public int GetRemainingUses(int slotIndex) => _remainingUses[slotIndex];
// ── ISaveable ────────────────────────────────────────────────────────
public void OnSave(SaveData data)
{
data.Tools.ToolSlot0 = _slots[0]?.toolId; // 注:工具数据归属 SaveData.Tools,不在 Equipment
data.Tools.ToolSlot1 = _slots[1]?.toolId;
}
public void OnLoad(SaveData data)
{
// 重置冷却,通过 _toolCatalog.Find() 恢复两个槽位的 ToolSO 引用
for (int i = 0; i < SlotCount; i++) _cooldowns[i] = 0f;
EquipTool(0, _toolCatalog?.Find(data.Tools.ToolSlot0));
EquipTool(1, _toolCatalog?.Find(data.Tools.ToolSlot1));
}
}
// 可选接口:带冷却时间的工具
public interface IToolCooldown
{
float CooldownDuration { get; }
}
// 路径: Assets/Scripts/UI/ToolHUD.cs
// HUD 层:显示 2 个工具槽的图标 + 剩余次数 + 冷却遮罩
public class ToolHUD : MonoBehaviour
{
[SerializeField] private ToolSlotUI[] _slots; // 2 个 ToolSlotUI 组件
[SerializeField] private ToolSlotManager _slotManager;
[SerializeField] private ToolUsedEventChannelSO _onToolUsed;
private void OnEnable() => _onToolUsed.OnEventRaised += RefreshSlot;
private void OnDisable() => _onToolUsed.OnEventRaised -= RefreshSlot;
private void RefreshSlot(ToolUsedPayload payload)
=> _slots[payload.SlotIndex].Refresh(
_slotManager.GetTool(payload.SlotIndex),
_slotManager.GetRemainingUses(payload.SlotIndex),
_slotManager.GetCooldownRatio(payload.SlotIndex));
private void Update()
{
// 实时更新冷却遮罩
for (int i = 0; i < _slots.Length; i++)
_slots[i].SetCooldownFill(_slotManager.GetCooldownRatio(i));
}
}
// 单个槽 UI(图标 + 剩余次数文本 + 冷却遮罩 Image)
public class ToolSlotUI : MonoBehaviour
{
[SerializeField] private Image _icon;
[SerializeField] private TMP_Text _usesText;
[SerializeField] private Image _cooldownMask; // FillAmount 冷却遮罩
public void Refresh(ToolSO tool, int remainingUses, float cooldownRatio)
{
_icon.sprite = tool != null ? tool.icon : null;
_usesText.text = remainingUses < 0 ? "∞" : remainingUses.ToString();
SetCooldownFill(cooldownRatio);
}
public void SetCooldownFill(float ratio) => _cooldownMask.fillAmount = ratio;
}
8. 技能系统 — FormSkillSO
// 路径: Assets/Scripts/Skills/FormSkillSO.cs
[CreateAssetMenu(menuName = "Skills/FormSkill")]
public class FormSkillSO : ScriptableObject
{
[Header("Identity")]
public string skillId;
public string displayNameKey;
[TextArea(1,3)] public string descriptionKey;
public Sprite icon;
[Header("Resource")]
public SkillResourceType resourceType; // SoulPower / SpiritPower
public int baseCost;
public float cooldown;
[Header("Animation")]
public ClipTransition castAnimation;
public float castLockDuration; // 秒
[Header("Effect")]
public SkillEffectType effectType;
public DamageSourceSO damageSource;
[Header("Projectile")]
public ProjectileConfigSO projectileConfig;
public bool isHoming;
public bool holdForContinuous;
[Header("Dash")]
public float dashForce;
public float dashDuration;
public bool isInvincibleDuringDash;
[Header("Explosion")]
public float explosionDelay;
public float explosionRadius;
[Header("Feedback")]
public FeedbackPresetSO castFeedback;
[Header("HitBox Prefab")]
// 近战/爆炸技能的命中盒 Prefab,内含 SkillHitBoxInstance + HitBox 组件
// 投射物技能此字段留空——由 ProjectileConfigSO.WeaponPrefab 负责
// 命名规范: Assets/Prefabs/Skills/SKL_{skillId}_HitBox.prefab
public GameObject SkillHitBoxPrefab;
}
public enum SkillResourceType { SoulPower, SpiritPower }
public enum SkillEffectType
{
MeleeAoE, Projectile, BarrierAura, GroundDive,
DragonKick, WraithDash, ShadowDecoy, DelayedExplosion
}
9. SkillManager
// 路径: Assets/Scripts/Skills/SkillManager.cs
// 挂在 Player 上,处理所有技能逻辑
public class SkillManager : MonoBehaviour
{
[SerializeField] private PlayerStats _stats;
[SerializeField] private PlayerController _controller;
[SerializeField] private InputReaderSO _input;
[SerializeField] private SkillModifierRegistry _modifiers;
[SerializeField] private ObjectPoolManager _pool;
private FormSkillSO _soulSkill, _spirit1, _spirit2; // 当前形态技能
private float _soulCooldown, _spirit1Cooldown, _spirit2Cooldown;
private void OnEnable()
{
_input.SoulSkillEvent += TrySoulSkill;
_input.SpiritSkill1StartedEvent += TrySpiritSkill1;
_input.SpiritSkill2StartedEvent += TrySpiritSkill2;
}
private void OnDisable()
{
_input.SoulSkillEvent -= TrySoulSkill;
_input.SpiritSkill1StartedEvent -= TrySpiritSkill1;
_input.SpiritSkill2StartedEvent -= TrySpiritSkill2;
}
// 切换形态时由 FormController 调用
public void UpdateSkillSet(FormSkillSO soul, FormSkillSO spirit1, FormSkillSO spirit2);
private void TrySoulSkill();
// 1. 校验 cooldown
// 2. _stats.ConsumeSoulPower(finalCost) → 失败 return
// 3. _controller.ForceState(SoulSkillState)
// 4a. 如果 skill.SkillHitBoxPrefab != null:
// Instantiate(skill.SkillHitBoxPrefab, _skillSocket) → 获取 SkillHitBoxInstance
// inst.Activate(skill.damageSource, transform)
// inst.AutoDestroyAfter(skill.castLockDuration)
// 4b. 如果 skill.projectileConfig != null:由 Projectile 系统负责(见 06_CombatModule §7)
private int GetFinalCost(FormSkillSO skill);
// baseCost 经 SkillModifierRegistry 调整后的最终消耗
[SerializeField] private Transform _skillSocket; // [SkillSocket] 子节点引用
}
SkillHitBoxInstance
// 路径: Assets/Scripts/Combat/SkillHitBoxInstance.cs
// 挂载于技能 HitBox Prefab 根节点(与 WeaponInstance 对应,但生命周期更短)
// 命名规范: Assets/Prefabs/Skills/SKL_{skillId}_HitBox.prefab
//
// Prefab 内部层级示例(近战 AoE 技能):
// [SKL_SkySlash_HitBox]
// └── [HitBox] ← 扇形/圆形 PolygonCollider2D,形状由美术决定
// └── HitBox.cs
public class SkillHitBoxInstance : MonoBehaviour
{
[SerializeField] private HitBox[] _hitBoxes; // 技能可有多个 HitBox(多段伤害)
public System.Action<DamageInfo> OnHitConfirmed;
private void Awake()
{
foreach (var hb in _hitBoxes)
hb.OnHitConfirmed += info => OnHitConfirmed?.Invoke(info);
}
// 激活所有 HitBox,持续 duration 秒后自动 Destroy
public void Activate(DamageSourceSO source, Transform attacker)
{
foreach (var hb in _hitBoxes)
hb.Activate(source, attacker);
}
public void AutoDestroyAfter(float duration)
=> Destroy(gameObject, duration);
private void OnDestroy()
{
foreach (var hb in _hitBoxes) hb.Deactivate();
}
}
---
## 10. SkillModifierRegistry
```csharp
// 路径: Assets/Scripts/Skills/SkillModifierRegistry.cs
// 收集所有魅力对技能数值的修改;SkillManager 查询最终有效参数
public class SkillModifierRegistry
{
private Dictionary<string, Dictionary<SkillStat, float>> _overrides = new();
public void Register(string skillId, SkillStat stat, float delta, bool isPercent);
public void Unregister(string skillId, SkillStat stat, float delta, bool isPercent);
/// <summary>
/// 对给定技能叠加所有已注册修改器,返回一次性快照供 SkillManager 使用。
/// 替代逐字段查询的 GetModifiedValue(),一次调用即可获取全部有效参数。
/// </summary>
public EffectiveSkillParams GetEffectiveParams(FormSkillSO skill);
// 向后兼容:单字段查询(内部调用 GetEffectiveParams 后提取)
public float GetModifiedValue(string skillId, SkillStat stat, float baseVal);
}
public enum SkillStat { Damage, Cost, Cooldown, Range, Duration }
/// <summary>
/// 所有数值修改器叠加后的运行时参数快照,由 SkillModifierRegistry.GetEffectiveParams() 生成,
/// 传入 SkillManager.CastRoutine() 和 ExecuteEffect()。
/// </summary>
public struct EffectiveSkillParams
{
public FormSkillSO baseSkill; // 原始 SO 引用(不变,供判断 effectType)
public int effectiveCost; // 修改后消耗量
public float effectiveCooldown; // 修改后冷却(秒)
public float damageMult; // 伤害倍率(1.0 = 无增益)
public float rangeMult; // 范围倍率(AoE 半径 / 障壁半径 / 爆炸半径)
public FeedbackPresetSO effectiveFeedback; // 最终特效预设(护符可替换,null = 回退原始)
public ClipTransition effectiveAnimation; // 最终施法动画(护符可替换,null = 回退原始)
/// <summary>以技能 SO 默认值初始化,无任何修改器加成。</summary>
public static EffectiveSkillParams FromBase(FormSkillSO skill) => new()
{
baseSkill = skill,
effectiveCost = skill.baseCost,
effectiveCooldown = skill.cooldown,
damageMult = 1f,
rangeMult = 1f,
effectiveFeedback = null,
effectiveAnimation = null,
};
}
11. RegionDefinitionSO(区域定义)
// 路径: Assets/Scripts/Progression/RegionDefinitionSO.cs
// 每个区域一个 SO 资产,集中管理区域元数据
[CreateAssetMenu(menuName = "Progression/RegionDefinition")]
public class RegionDefinitionSO : ScriptableObject
{
public string regionId; // 如 "Cave"(与 AudioZone.regionId 一致)
public string displayName; // 如 "腐蚀洞穴"
public Color mapColor; // 地图 UI 上该区域的颜色标识
public Sprite mapIconSprite; // P1:地图图标
[Header("解锁条件")]
public string requiredBossDefeated; // 空字符串 = 无条件
public AbilityType requiredAbility; // None = 无要求(默认值 0)
[Header("关联房间")]
public string[] roomSceneNames; // 该区域包含的所有场景名
public string bossSceneName; // Boss 房间场景名
public string entrySceneName; // 从外部进入该区域的第一个房间
}
资产路径:Assets/ScriptableObjects/Progression/Regions/
命名:Region_{RegionId}.asset(如 Region_Forest.asset)
区域 ID 对照表:
| 区域 ID | 中文名 | Boss | 开放条件 |
|---|---|---|---|
Forest |
扎根森林 | Boss_SpiderGuard | 无(起始区域) |
Cave |
腐蚀洞穴 | Boss_CorrosionWorm | 击败 Boss_SpiderGuard |
Ruins |
坍塌废墟 | Boss_RuinsKnight | 获得 Dash 能力 |
Abyss |
深渊裂隙 | Boss_AbyssThroat | 击败 Boss_RuinsKnight |
Core |
核心熔炉 | FinalBoss | 击败 Boss_AbyssThroat |
12. ProgressLock(进程锁)
// 路径: Assets/Scripts/Progression/ProgressLock.cs
// 单向/永久性阻挡,需满足特定条件(击败 Boss 或持有道具)才能解锁
public class ProgressLock : MonoBehaviour
{
[Header("解锁条件")]
[SerializeField] private string _requiredBossId; // 空 = 不检查 Boss
[SerializeField] private string _requiredItemId; // 空 = 不检查道具(P1)
[Header("物理表现")]
[SerializeField] private GameObject _lockedVisuals; // 锁住状态视觉
[SerializeField] private GameObject _unlockedVisuals; // 开启状态视觉(可 null)
[SerializeField] private Collider2D _blockCollider;
[Header("存档")]
[SerializeField] private string _lockId; // 唯一 ID,存档记录开启状态
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onBossDefeated; // EVT_BossDefeated
private void Start()
{
bool isUnlocked = CheckUnlocked();
ApplyState(isUnlocked);
if (!isUnlocked)
_onBossDefeated.OnEventRaised += OnBossDefeated;
}
private void OnDestroy() => _onBossDefeated.OnEventRaised -= OnBossDefeated;
private void OnBossDefeated(string bossId)
{
if (_requiredBossId == bossId && CheckUnlocked())
ApplyState(true);
}
private bool CheckUnlocked()
{
var save = SaveManager.Instance.Data;
if (!string.IsNullOrEmpty(_requiredBossId) && !save.World.DefeatedBossIds.Contains(_requiredBossId))
return false;
return save.World.OpenedDoors.Contains(_lockId);
}
private void ApplyState(bool unlocked)
{
_blockCollider.enabled = !unlocked;
_lockedVisuals.SetActive(!unlocked);
if (_unlockedVisuals != null)
_unlockedVisuals.SetActive(unlocked);
}
}
13. BossProgressTracker
// 路径: Assets/Scripts/Progression/BossProgressTracker.cs
// 轻量辅助组件,挂载在 Boss 房间的 BossTrigger 同一对象上
public class BossProgressTracker : MonoBehaviour
{
[SerializeField] private string _bossId; // 如 "Boss_SpiderGuard"
[SerializeField] private string[] _unlocksProgressLockIds; // 击败后解锁哪些 ProgressLock
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onBossDefeated; // 监听
[SerializeField] private StringEventChannelSO _onBossDefeatedForSave; // 广播→SaveSystem
private void OnEnable() => _onBossDefeated.OnEventRaised += OnBossDefeated;
private void OnDisable() => _onBossDefeated.OnEventRaised -= OnBossDefeated;
private void OnBossDefeated(string bossId)
{
if (bossId != _bossId) return;
// 1. 通过事件频道通知 SaveSystem(零耦合)
_onBossDefeatedForSave.Raise(bossId);
// SaveSystem 收到后:data.World.DefeatedBossIds.Add(bossId); 并解锁相关 ProgressLock
}
}
14. HPContainerPickup
// 路径: Assets/Scripts/Progression/HPContainerPickup.cs
// HP 容器:永久 MaxHP +2 的可拾取物件
public class HPContainerPickup : MonoBehaviour
{
[SerializeField] private string _collectibleId; // 存档用唯一 ID
[SerializeField] private InputReaderSO _inputReader;
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onMaxHPContainerPickedUp; // → SaveSystem
[SerializeField] private IntEventChannelSO _onMaxHPChanged; // → HUDController
private void OnTriggerEnter2D(Collider2D other)
{
if (!other.CompareTag("Player")) return;
var save = SaveManager.Instance.Data;
if (save != null && save.World.CollectedIds.Contains(_collectibleId)) return;
StartCoroutine(PickupSequence());
}
private IEnumerator PickupSequence()
{
_inputReader.EnableGameplayInput(false);
gameObject.SetActive(false);
// Feel MMF_Player 播放获取特效(外部引用或 GetComponent)
yield return new WaitForSeconds(0.8f);
// 零耦合:通过事件频道通知 SaveSystem
_onMaxHPContainerPickedUp.Raise(_collectibleId);
// 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 |