多轮审查和修复

This commit is contained in:
2026-05-12 15:34:08 +08:00
parent f55d2a57c3
commit ebbbb7332e
805 changed files with 838724 additions and 1905 deletions

View File

@@ -6,7 +6,9 @@
---
## 当前状态:**架构完整度 100%**
## ⚠️ 当前状态:**架构主干已完成,少量条目仍为设计稿或文档先行**
> 说明本索引区分“主干已实现”和“类型级完全一致”。少量条目在模块层已落地但具体接口名或辅助类型仍停留在设计文档中不能再统一标记为“100% 完整”。
---
@@ -14,11 +16,11 @@
| 维度 | 状态 |
|------|------|
| 核心游戏循环(移动/战斗/存档) | ✅ 完整 |
| 核心游戏循环(移动/战斗/存档) | ⚠️ 主干完整Save 扩展校验条目仍有文档先行内容 |
| 敌人与 Boss 基础框架 | ✅ 完整(含 BossSkillModule §23 |
| UI / 音频 / 事件系统 | ✅ 完整 |
| UI / 音频 / 事件系统 | ⚠️ 主流程完整;少量 UI 抽象接口仍未完全落地 |
| 世界交互与地图 | ✅ 完整(含 MovingPlatform/CrumblePlatform/SkillInteractable §11-15 |
| 进度/装备/技能 | ✅ 完整 |
| 进度/装备/技能 | ⚠️ 主体完整AbilityType 文档需以当前代码实现回写 |
| 叙事与对话 | ✅ 完整(含 CutsceneSO/CutsceneTrigger |
| 玩家能力扩展系统 | ✅ 完整Shield §20、SwimState §21、DifficultyModule §19 |
| 世界互动机制 | ✅ 完整Puzzle §21、LootSystem §07、LiquidSwim §21 |
@@ -29,7 +31,7 @@
## 二、逐 Design 文档覆盖矩阵
> 标记说明:✅ 覆盖 | ⚠️ 部分覆盖 | ❌ 未覆盖 | 📖 非技术文档(艺术/叙事/策划,不需要 Architecture
> 标记说明:✅ 覆盖 | ⚠️ 部分覆盖 | 📝 文档先行/设计稿 | ❌ 未覆盖 | 📖 非技术文档(艺术/叙事/策划,不需要 Architecture
| # | Design 文档 | Architecture 对应 | 状态 | 说明 |
|---|------------|-----------------|------|------|
@@ -42,11 +44,11 @@
| 07 | FeedbackSystem | **18_VFXFeedbackModule** | ✅ | FeedbackConfigSO/VFXPool/HitFXSpawner 全部定义 |
| 08 | WorldSystem | 08_WorldModule | ✅ | 完整(含 §11-15 MovingPlatform/MagicWall/SoftTerrain/PhantomInteractable |
| 09 | EditorExtensions | — | 📖 | 编辑器工具指南,无需独立 Architecture |
| 10 | UISystem | 10_UIModule | | 完整 |
| 10 | UISystem | 10_UIModule | ⚠️ | 主流程完整BossHPBar 已实现,但 IBossHPProvider 目前仍主要停留在文档抽象层 |
| 11 | GameManager | 03_CoreModule | ✅ | 完整(含 DifficultyManager 初始化顺序引用) |
| 12 | AudioSystem | 11_AudioModule | ✅ | 完整 |
| 13 | ProjectileSystem | 06_CombatModule §5 | ✅ | ProjectileConfigSO、LinearProjectile、ParryableProjectile 已定义 |
| 14 | ProgressionSystem | 09_ProgressionModule | | 完整 |
| 14 | ProgressionSystem | 09_ProgressionModule | ⚠️ | 主系统完整AbilityType 以当前代码枚举为准,旧文档示例已回收 |
| 15 | DialogueSystem | 14_NarrativeModule | ✅ | 完整(含 CutsceneSO/CutsceneTrigger |
| 16 | MapSystem | 15_MapShopModule §1 | ✅ | 完整 |
| 17 | EquipmentSystem | 09_ProgressionModule | ✅ | 完整 |
@@ -57,13 +59,13 @@
| 22 | LocalizationSystem | 16_SupportingModules §1 | ✅ | 完整 |
| 23 | GameFeelTuningGuide | — | 📖 | 数值调参指南,无需 Architecture |
| 24 | GroundDetectionSystem | 05_PlayerModule §3 | ✅ | 合并入 PlayerMovementGroundDetectionConfigSO 已定义 |
| 25 | InputRebindingUI | 04_InputModule §6 | | RebindPanel/ConflictDetector/RebindPersistence 已定义 |
| 25 | InputRebindingUI | 04_InputModule §6 | ⚠️ | RebindPanel/ConflictDetector 已实现;RebindPersistence 当前作为 InputReaderSO 内部持久化能力,而非独立类型 |
| 26 | WallMechanicsSystem | 05_PlayerModule §3 | ✅ | 合并入 PlayerMovementWallMechanicsConfigSO 已定义 |
| 27 | PerformanceBudgetGuide | — | 📖 | 性能预算指南,无需 Architecture |
| 28 | ShopSystem | 15_MapShopModule §2 | ✅ | 完整 |
| 29 | DifficultyModesGuide | **19_DifficultyModule** | ✅ | DifficultyLevel enum/DifficultyScalerSO/DifficultyManager 全部定义 |
| 30 | ShieldMechanicsSystem | **20_ShieldModule** | ✅ | ShieldComponent/ShieldConfigSO/IShieldable 全部定义HurtBox 护盾管道已修正 |
| 31 | SaveDataSchema | 12_SaveModule | | 完整(含 EmergencySaveService/CrashReporter §9 |
| 31 | SaveDataSchema | 12_SaveModule | ⚠️ | SaveData/SaveManager/EmergencySaveService/CrashReporter 已实现SaveValidator 与 IDlcSaveExtension 仍属文档先行条目 |
| 32 | AchievementSystem | 16_SupportingModules §2 | ✅ | 完整 |
| 33 | EnemyLootSystem | 07_EnemyModule §14 | ✅ | LootTableSO/LootResolver/LootPickup 已定义EnemyBase.Die() 已集成 |
| 34 | EventChainSystem | 14_NarrativeModule §5-6 | ✅ | 完整 |

View File

@@ -52,14 +52,18 @@ public class RoomTransition : MonoBehaviour
{
[Header("Config")]
[SerializeField] private string _transitionId; // 唯一 ID目标出口用于匹配出生点
[SerializeField] private string _targetSceneName; // 目标场景AddressKeys 常量)
[SerializeField] private string _targetSceneAddress; // 目标场景 Addressable keyAddressKeys 常量)
[SerializeField] private string _targetTransitionId; // 目标场景中对应出口的 ID
[SerializeField] private bool _requiresKeyItem; // 是否需要持有钥匙物品
[SerializeField] private string _requiredItemId; // 钥匙物品 ID
[SerializeField] private bool _autoTrigger = true; // true = 进入触发器自动触发false = 需交互
[SerializeField] private bool _requiresKeyItem; // 是否需要持有鑰匙物品
[SerializeField] private string _requiredItemId; // 鑰匙物品 ID
[Header("Event Channel")]
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
[Header("世界状态")]
[SerializeField] private WorldStateRegistry _worldState; // 持有物品检查
// 玩家进入触发器
private void OnTriggerEnter2D(Collider2D other)
{
@@ -75,7 +79,12 @@ public class RoomTransition : MonoBehaviour
});
}
private bool HasItem(string itemId); // 查询 PlayerStats 或 Inventory
private bool HasItem(string itemId)
{
if (string.IsNullOrEmpty(itemId)) return true;
if (_worldState == null) return false; // 未配置则拦截(警告日志)
return _worldState.IsCollected(itemId);
}
// Editor在 Scene View 显示箭头 Gizmo
private void OnDrawGizmos();
@@ -222,8 +231,12 @@ public class Collectible : MonoBehaviour
private void Despawn(); // 归还对象池
// 敌人死亡时生成 Geo Collectible(由 EnemyBase 调用)
public static void SpawnGeo(Vector2 position, int amount, ObjectPoolManager pool);
// ―― 运行时配置(由 CollectibleSpawner 在实例化后调用)――――――――――――――――
/// <summary>将此 Collectible 配置为 Geo 掉落。</summary>
public void SetGeo(int amount);
/// <summary>将此 Collectible 配置为道具掉落。</summary>
public void SetItem(string itemId);
}
public enum CollectibleType { Geo, Item, HPOrb }
@@ -231,6 +244,30 @@ public enum CollectibleType { Geo, Item, HPOrb }
---
## 5b. CollectibleSpawner
```csharp
// 路径: Assets/Scripts/World/CollectibleSpawner.cs + CollectibleSpawnerConfig.cs
// 封装 Collectible Prefab 实例化逻辑,供 LootResolver 等静态调用。
// CollectibleSpawnerConfig 挂在 Persistent 场景 [World] GameObject 上持有 Prefab 引用,
// Awake 调用 CollectibleSpawner.Register(this) 注入静态配置。
public static class CollectibleSpawner
{
internal static void Register(CollectibleSpawnerConfig config);
public static void SpawnGeo(Vector2 position, int amount); // 实例化 GeoPrefab 并调用 SetGeo(amount)
public static void SpawnItem(Vector2 position, string itemId); // 实例化 ItemPrefab 并调用 SetItem(itemId)
}
public class CollectibleSpawnerConfig : MonoBehaviour
{
[SerializeField] internal GameObject GeoPrefab;
[SerializeField] internal GameObject ItemPrefab;
private void Awake() => CollectibleSpawner.Register(this);
}
```
---
## 6. AbilityUnlock
```csharp

View File

@@ -1,8 +1,8 @@
# 09 · 进度模块
> **命名空间** `BaseGames.Equipment`、`BaseGames.Skills`、`BaseGames.Progression`
> **命名空间** `BaseGames.Player`、`BaseGames.Equipment`、`BaseGames.Skills`、`BaseGames.Progression`
> **程序集** `BaseGames.Equipment`、`BaseGames.Skills`
> **路径** `Assets/Scripts/Equipment/`、`Assets/Scripts/Skills/`
> **路径** `Assets/Scripts/Player/`、`Assets/Scripts/World/`、`Assets/Scripts/Equipment/`、`Assets/Scripts/Skills/`
> **依赖** `BaseGames.Core.Events`、`BaseGames.Combat`、`BaseGames.Player`
---
@@ -33,6 +33,7 @@
> - 原 `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
@@ -40,36 +41,41 @@
[System.Flags]
public enum AbilityType : uint
{
None = 0,
None = 0,
// 移动
WallCling = 1u << 0,
WallJump = 1u << 1,
Dash = 1u << 2,
AerialDash = 1u << 3, // 空中冲刺(默认锁定,升级后解锁
InvincibleDash= 1u << 4, // 冲刺全程无敌Dash 的升级版,默认锁定)
DoubleJump = 1u << 5,
ClimbVines = 1u << 6,
Swim = 1u << 7, // 游泳LiquidZone 内切换 SwimState
// 移动能力
WallCling = 1u << 0, // 贴墙悬挂
WallJump = 1u << 1, // 墙跳
Dash = 1u << 2, // 地面冲刺
AirDash = 1u << 3, // 空中冲刺(二段冲刺
DoubleJump = 1u << 4, // 二段跳
SuperJump = 1u << 5, // 超级跳(聚气跳)
Swim = 1u << 6, // 游泳(液体中自由移动)
Dive = 1u << 7, // 下劈(空中下突
// 战斗
Parry = 1u << 8,
Spring = 1u << 9, // 灵泉反弹
UseTools = 1u << 10,
// 法术能力
Spell1 = 1u << 8, // 法术槽 1策划自定义
Spell2 = 1u << 9, // 法术槽 2
Spell3 = 1u << 10, // 法术槽 3
// 互动
ReadShrine = 1u << 11,
UseGrapple = 1u << 12,
// 灵魄形态
SpiritForm = 1u << 11, // 灵魄形态切换
SpiritDash = 1u << 12, // 灵魄冲刺(穿透地形)
// 预留扩展位13~31新能力在此追加禁止复用已有值
// 战斗能力
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,
}
// ── SaveMigrator 兼容说明 ─────────────────────────────────────────────────
// SaveMeta.Version < "2.1" 的存档仍持有 Dictionary<string, bool> Abilities
// SaveMigrator.MigrateV2ToV21 负责将其转换为 AbilityFlags uint
// foreach (var kv in old.Abilities)
// if (kv.Value && Enum.TryParse<AbilityType>(kv.Key, out var a))
// newFlags |= (uint)a;
```
---
@@ -418,10 +424,11 @@ public class WeaponOverrideEffect : ICharmEffect
```csharp
// 路径: Assets/Scripts/Equipment/EquipmentManager.cs
public class EquipmentManager : MonoBehaviour
public class EquipmentManager : MonoBehaviour, ISaveable
{
[Header("配置")]
[SerializeField] private EquipmentConfigSO _config; // 初始 Notch 数量等
[SerializeField] private EquipmentConfigSO _config; // 初始 Notch 数量等
[SerializeField] private CharmCatalogSO _charmCatalog; // CharmSO 查找表Assets/Data/Equipment/CharmCatalog.asset
[Header("Event Channels")]
[SerializeField] private CharmEventChannelSO _onCharmEquipped;
@@ -475,13 +482,38 @@ public class EquipmentManager : MonoBehaviour
_onEquipmentChanged.Raise();
}
public void AddToCollection(string charmId); // 加入收藏(存档)
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;
// 存档集成
public EquipmentSaveData GetSaveData();
public void LoadSaveData(EquipmentSaveData data);
// 存档集成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();
}
}
```
@@ -531,6 +563,7 @@ public class ToolSlotManager : MonoBehaviour, ISaveable
[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;
// 当前冷却倒计时(秒)
@@ -582,7 +615,13 @@ public class ToolSlotManager : MonoBehaviour, ISaveable
data.Tools.ToolSlot0 = _slots[0]?.toolId; // 注:工具数据归属 SaveData.Tools不在 Equipment
data.Tools.ToolSlot1 = _slots[1]?.toolId;
}
public void OnLoad(SaveData data) { /* 从 data.Tools 恢复 ToolSO 引用 */ }
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));
}
}
// 可选接口:带冷却时间的工具

View File

@@ -284,100 +284,101 @@ namespace BaseGames.VFX
/// <summary>
/// ParticleSystem 专用对象池,挂在 Persistent 场景 [VFXPool] GameObject 上。
/// 粒子播放完成(或超过 MaxLifetime后自动回池调用方无需手动归还。
/// 不依赖 UniTask使用 Coroutine 驱动回收。
/// </summary>
public class VFXPool : MonoBehaviour
{
public static VFXPool Instance { get; private set; }
/// <summary>
/// 全局兜底超时(秒)。单个特效可在 Prefab 的 VFXPoolEntry 组件上覆盖。
/// 防止循环粒子或异常长时间特效永不回池导致内存膨胀。
/// </summary>
[SerializeField, Min(1f)] private float _globalMaxLifetime = 10f;
readonly Dictionary<AssetReferenceGameObject, Queue<ParticleSystem>> _pools = new();
private readonly Dictionary<AssetReferenceGameObject, Queue<ParticleSystem>> _pools
= new Dictionary<AssetReferenceGameObject, Queue<ParticleSystem>>();
void Awake() => Instance = this;
private void Awake() => Instance = this;
/// <summary>
/// 在世界坐标播放一次特效Fire-and-forgetUniTask 自动回池)。
/// 在世界坐标播放一次特效Fire-and-forgetCoroutine 自动回池)。
/// </summary>
/// <param name="maxLifetime">
/// &gt; 0 时覆盖全局超时;≤ 0 时使用 <see cref="_globalMaxLifetime"/>。
/// </param>
public async UniTaskVoid Play(AssetReferenceGameObject vfxRef,
Vector3 position,
Quaternion rotation = default,
float maxLifetime = 0f)
public void Play(AssetReferenceGameObject vfxRef,
Vector3 position,
Quaternion rotation = default,
float maxLifetime = 0f)
{
var ps = await GetOrCreateAsync(vfxRef);
float limit = maxLifetime > 0f ? maxLifetime : _globalMaxLifetime;
StartCoroutine(PlayCoroutine(vfxRef, position, rotation, maxLifetime));
}
/// <summary>预热:预先创建若干实例避免首次播放卡顿。</summary>
public void Warmup(AssetReferenceGameObject vfxRef, int count)
{
StartCoroutine(WarmupCoroutine(vfxRef, count));
}
private IEnumerator PlayCoroutine(AssetReferenceGameObject vfxRef,
Vector3 position, Quaternion rotation, float maxLifetime)
{
ParticleSystem ps = null;
if (!TryDequeue(vfxRef, out ps))
{
var op = Addressables.InstantiateAsync(vfxRef, transform);
yield return op;
if (op.Result == null) { Debug.LogError($"[VFXPool] Failed: {vfxRef.RuntimeKey}"); yield break; }
ps = op.Result.GetComponent<ParticleSystem>();
if (ps == null) { Addressables.ReleaseInstance(op.Result); yield break; }
ps.gameObject.SetActive(false);
}
ps.transform.SetPositionAndRotation(position, rotation);
ps.gameObject.SetActive(true);
ps.Play();
// 双重退出条件:粒子自然结束 OR 超时强制回收
using var cts = new System.Threading.CancellationTokenSource(
System.TimeSpan.FromSeconds(limit));
try
float limit = maxLifetime > 0f ? maxLifetime : _globalMaxLifetime;
float elapsed = 0f;
while (elapsed < limit && ps.IsAlive(true))
{
await UniTask.WaitUntil(() => !ps.IsAlive(true),
cancellationToken: cts.Token);
elapsed += Time.deltaTime;
yield return null;
}
catch (System.OperationCanceledException)
if (ps.IsAlive(true))
{
// 超时:强制停止
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
Debug.LogWarning(
$"[VFXPool] '{vfxRef.RuntimeKey}' 超过 {limit:F1}s 强制回收。" +
"请检查粒子是否设为 Loop 或 Duration 过长。");
Debug.LogWarning($"[VFXPool] '{vfxRef.RuntimeKey}' 超过 {limit:F1}s 强制回收。");
}
ps.gameObject.SetActive(false);
_pools[vfxRef].Enqueue(ps);
Enqueue(vfxRef, ps);
}
/// <summary>
/// 预热:预先创建若干实例避免首次播放卡顿。
/// </summary>
public async UniTask WarmupAsync(AssetReferenceGameObject vfxRef, int count)
private IEnumerator WarmupCoroutine(AssetReferenceGameObject vfxRef, int count)
{
for (int i = 0; i < count; i++)
{
var go = await Addressables.InstantiateAsync(vfxRef, transform).Task;
var ps = go.GetComponent<ParticleSystem>();
var op = Addressables.InstantiateAsync(vfxRef, transform);
yield return op;
if (op.Result == null) continue;
var ps = op.Result.GetComponent<ParticleSystem>();
if (ps == null) { Addressables.ReleaseInstance(op.Result); continue; }
ps.gameObject.SetActive(false);
if (!_pools.ContainsKey(vfxRef)) _pools[vfxRef] = new Queue<ParticleSystem>();
_pools[vfxRef].Enqueue(ps);
Enqueue(vfxRef, ps);
}
}
async UniTask<ParticleSystem> GetOrCreateAsync(AssetReferenceGameObject vfxRef)
private bool TryDequeue(AssetReferenceGameObject key, out ParticleSystem ps)
{
if (_pools.TryGetValue(vfxRef, out var q) && q.Count > 0)
return q.Dequeue();
ps = null;
return _pools.TryGetValue(key, out var q) && q.Count > 0 && (ps = q.Dequeue()) != null;
}
// 池不存在时初始化
if (!_pools.ContainsKey(vfxRef))
_pools[vfxRef] = new Queue<ParticleSystem>();
var go = await Addressables.InstantiateAsync(vfxRef, transform).Task;
return go.GetComponent<ParticleSystem>();
private void Enqueue(AssetReferenceGameObject key, ParticleSystem ps)
{
if (!_pools.ContainsKey(key)) _pools[key] = new Queue<ParticleSystem>();
_pools[key].Enqueue(ps);
}
}
}
```
**`maxLifetime` 使用指南**
| 特效类型 | 建议 `maxLifetime` | 说明 |
|----------|-------------------|------|
| 命中火花 / 数字 | 默认3s | 时长已知,无需覆盖 |
| 爆炸 / 大范围 | `5f` | 稍长,粒子散逸需时间 |
| Boss 相位特效 | `15f` | 长动画,须显式指定 |
| 环境氛围循环粒子 | **不使用 VFXPool** | Loop 粒子应手动管理生命周期 |
---
## 7. VFXCatalogSO — VFX 映射字典
@@ -448,16 +449,32 @@ namespace BaseGames.VFX
/// </summary>
public class HitFXSpawner : MonoBehaviour
{
[SerializeField] HitConfirmedEventChannelSO _onHitConfirmed;
[SerializeField] VFXCatalogSO _catalog;
[SerializeField] private HitConfirmedEventChannelSO _onHitConfirmed;
[SerializeField] private VFXCatalogSO _catalog;
void OnEnable() => _onHitConfirmed.OnEventRaised += HandleHit;
void OnDisable() => _onHitConfirmed.OnEventRaised -= HandleHit;
void HandleHit(HitInfo info)
private void Awake()
{
if (_catalog.TryGetHitFX(info.DamageInfo.HitFxType, out var vfxRef))
VFXPool.Instance.Play(vfxRef, info.HitPoint).Forget();
if (_catalog != null)
_catalog.Initialize();
}
private void OnEnable()
{
if (_onHitConfirmed != null)
_onHitConfirmed.OnEventRaised += HandleHit;
}
private void OnDisable()
{
if (_onHitConfirmed != null)
_onHitConfirmed.OnEventRaised -= HandleHit;
}
private void HandleHit(HitInfo info)
{
if (_catalog == null || VFXPool.Instance == null) return;
if (_catalog.TryGetHitFX(info.DamageInfo.FxType, out var vfxRef))
VFXPool.Instance.Play(vfxRef, info.HitPoint);
}
}
}
@@ -474,32 +491,48 @@ namespace BaseGames.VFX
/// 受击白闪效果,通过 Material Property Block 修改 Shader 参数。
/// 挂在玩家/敌人的 SpriteRenderer 所在 GameObject 上。
/// 不复制 Material避免 GC通过 MaterialPropertyBlock 实现。
/// 若上一次闪白未结束则重置计时器(支持连击打断重置)。
/// </summary>
public class HurtFlashController : MonoBehaviour
{
[SerializeField] SpriteRenderer _renderer;
[SerializeField] FeedbackConfigSO _config;
[SerializeField] private SpriteRenderer _renderer;
[SerializeField] private FeedbackConfigSO _config;
static readonly int FlashColorID = Shader.PropertyToID("_FlashColor");
static readonly int FlashAmountID = Shader.PropertyToID("_FlashAmount");
private static readonly int FlashColorID = Shader.PropertyToID("_FlashColor");
private static readonly int FlashAmountID = Shader.PropertyToID("_FlashAmount");
MaterialPropertyBlock _block;
private MaterialPropertyBlock _block;
private Coroutine _flashCoroutine;
void Awake() => _block = new MaterialPropertyBlock();
private void Awake()
{
if (_renderer == null)
_renderer = GetComponent<SpriteRenderer>();
_block = new MaterialPropertyBlock();
}
/// <summary>触发一次受击白闪(由 IFeedbackPlayer.PlayTakeHit 间接调用)。</summary>
public async UniTaskVoid Flash(CancellationToken ct = default)
/// <summary>触发一次受击白闪。若上一次未结束则重置计时器。</summary>
public void Flash()
{
if (_flashCoroutine != null)
StopCoroutine(_flashCoroutine);
_flashCoroutine = StartCoroutine(FlashCoroutine());
}
private IEnumerator FlashCoroutine()
{
SetFlash(1f);
yield return new WaitForSeconds(_config != null ? _config.HurtFlashDuration : 0.12f);
SetFlash(0f);
_flashCoroutine = null;
}
private void SetFlash(float amount)
{
_renderer.GetPropertyBlock(_block);
_block.SetColor(FlashColorID, _config.HurtFlashColor);
_block.SetFloat(FlashAmountID, 1f);
_renderer.SetPropertyBlock(_block);
await UniTask.Delay(
TimeSpan.FromSeconds(_config.HurtFlashDuration),
cancellationToken: ct);
_block.SetFloat(FlashAmountID, 0f);
if (_config != null)
_block.SetColor(FlashColorID, _config.HurtFlashColor);
_block.SetFloat(FlashAmountID, amount);
_renderer.SetPropertyBlock(_block);
}
}
@@ -519,21 +552,28 @@ namespace BaseGames.VFX
/// 形态切换时替换玩家精灵调色板。
/// 通过 Texture2D 查找表LUTShader 实现,不换 Sprite 资产。
/// 挂在玩家 SpriteRenderer 所在 GameObject 上。
/// 由 FormController 在切换形态时调用 ApplyPalette(FormType)。
/// </summary>
public class PaletteSwapSystem : MonoBehaviour
{
[SerializeField] SpriteRenderer _renderer;
[SerializeField] PaletteCatalogSO _catalog;
[SerializeField] private SpriteRenderer _renderer;
[SerializeField] private PaletteCatalogSO _catalog;
static readonly int PaletteTexID = Shader.PropertyToID("_PaletteTex");
private static readonly int PaletteTexID = Shader.PropertyToID("_PaletteTex");
MaterialPropertyBlock _block;
private MaterialPropertyBlock _block;
void Awake() => _block = new MaterialPropertyBlock();
private void Awake()
{
if (_renderer == null)
_renderer = GetComponent<SpriteRenderer>();
_block = new MaterialPropertyBlock();
}
/// <summary>切换到指定形态的调色板。由 FormController 调用。</summary>
/// <summary>切换到指定形态的调色板。由 FormController.SwitchForm() 调用。</summary>
public void ApplyPalette(FormType form)
{
if (_catalog == null || _renderer == null) return;
if (!_catalog.TryGetPalette(form, out var tex)) return;
_renderer.GetPropertyBlock(_block);
_block.SetTexture(PaletteTexID, tex);
@@ -544,13 +584,16 @@ namespace BaseGames.VFX
[CreateAssetMenu(menuName = "VFX/PaletteCatalog")]
public class PaletteCatalogSO : ScriptableObject
{
public PaletteEntry[] entries;
[SerializeField] private PaletteEntry[] _entries;
public bool TryGetPalette(FormType form, out Texture2D tex)
{
foreach (var e in entries)
if (_entries != null)
{
if (e.form == form) { tex = e.paletteLUT; return true; }
foreach (var e in _entries)
{
if (e.form == form) { tex = e.paletteLUT; return true; }
}
}
tex = null;
return false;
@@ -560,8 +603,8 @@ namespace BaseGames.VFX
[Serializable]
public struct PaletteEntry
{
public FormType form;
public Texture2D paletteLUT; // 1D 查找表纹理256×1 px
public FormType form;
public Texture2D paletteLUT; // 1D 查找表纹理256×1 px
}
}
```
@@ -575,97 +618,129 @@ namespace BaseGames.VFX
{
/// <summary>
/// 后处理 Volume 分区管理器,挂在 Persistent 场景 [PostProcess] GameObject 上。
/// 通过 DOTween 平滑 blend Weight监听游戏状态事件。
/// 通过 Coroutine 平滑 blend Weight监听游戏状态/Boss/死亡/胜利事件。
/// 注意:水下后处理由 UnderwaterPostProcessingControllerWorld.Liquid独立负责此组件不处理。
/// </summary>
public class PostProcessManager : MonoBehaviour
{
[Header("Volume 引用Persistent 场景内)")]
[SerializeField] Volume _underwaterVolume; // Priority=10
[SerializeField] Volume _bossArenaVolume; // Priority=10
[SerializeField] Volume _deathVolume; // Priority=20
[SerializeField] Volume _victoryVolume; // Priority=10
[SerializeField] private Volume _bossArenaVolume; // Priority=10
[SerializeField] private Volume _deathVolume; // Priority=20
[SerializeField] private Volume _victoryVolume; // Priority=10
[Header("事件频道")]
[SerializeField] VoidEventChannelSO _onLiquidEntered;
[SerializeField] VoidEventChannelSO _onLiquidExited;
[SerializeField] VoidEventChannelSO _onBossFightStarted;
[SerializeField] VoidEventChannelSO _onBossFightEnded;
[SerializeField] VoidEventChannelSO _onPlayerDied;
[SerializeField] VoidEventChannelSO _onPlayerRespawned;
[SerializeField] VoidEventChannelSO _onBossDefeated;
[SerializeField] private VoidEventChannelSO _onBossFightStarted;
[SerializeField] private VoidEventChannelSO _onBossFightEnded;
[SerializeField] private VoidEventChannelSO _onPlayerDied;
[SerializeField] private VoidEventChannelSO _onPlayerRespawned;
[SerializeField] private VoidEventChannelSO _onBossDefeated;
[SerializeField] float _blendDuration = 0.4f;
[SerializeField] private float _blendDuration = 0.4f;
private Volume[] _nonDefaultVolumes;
private Volume[] _managedVolumes;
private Coroutine _blendCoroutine;
private void Awake()
{
_nonDefaultVolumes = new[] { _underwaterVolume, _bossArenaVolume, _deathVolume, _victoryVolume };
_managedVolumes = new[] { _bossArenaVolume, _deathVolume, _victoryVolume };
}
private void OnEnable()
{
_onLiquidEntered.OnEventRaised += () => BlendTo(_underwaterVolume);
_onLiquidExited.OnEventRaised += ResetAll;
_onBossFightStarted.OnEventRaised += () => BlendTo(_bossArenaVolume);
_onBossFightEnded.OnEventRaised += ResetAll;
_onPlayerDied.OnEventRaised += () => BlendTo(_deathVolume);
_onPlayerRespawned.OnEventRaised += ResetAll;
_onBossDefeated.OnEventRaised += () => BlendTo(_victoryVolume);
if (_onBossFightStarted != null) _onBossFightStarted.OnEventRaised += HandleBossFightStarted;
if (_onBossFightEnded != null) _onBossFightEnded.OnEventRaised += HandleBossFightEnded;
if (_onPlayerDied != null) _onPlayerDied.OnEventRaised += HandlePlayerDied;
if (_onPlayerRespawned != null) _onPlayerRespawned.OnEventRaised += HandlePlayerRespawned;
if (_onBossDefeated != null) _onBossDefeated.OnEventRaised += HandleBossDefeated;
}
private void OnDisable()
{
_onLiquidEntered.OnEventRaised -= () => BlendTo(_underwaterVolume);
_onLiquidExited.OnEventRaised -= ResetAll;
_onBossFightStarted.OnEventRaised -= () => BlendTo(_bossArenaVolume);
_onBossFightEnded.OnEventRaised -= ResetAll;
_onPlayerDied.OnEventRaised -= () => BlendTo(_deathVolume);
_onPlayerRespawned.OnEventRaised -= ResetAll;
_onBossDefeated.OnEventRaised -= () => BlendTo(_victoryVolume);
if (_onBossFightStarted != null) _onBossFightStarted.OnEventRaised -= HandleBossFightStarted;
if (_onBossFightEnded != null) _onBossFightEnded.OnEventRaised -= HandleBossFightEnded;
if (_onPlayerDied != null) _onPlayerDied.OnEventRaised -= HandlePlayerDied;
if (_onPlayerRespawned != null) _onPlayerRespawned.OnEventRaised -= HandlePlayerRespawned;
if (_onBossDefeated != null) _onBossDefeated.OnEventRaised -= HandleBossDefeated;
}
private void HandleBossFightStarted() => BlendTo(_bossArenaVolume);
private void HandleBossFightEnded() => ResetAll();
private void HandlePlayerDied() => BlendTo(_deathVolume);
private void HandlePlayerRespawned() => ResetAll();
private void HandleBossDefeated() => BlendTo(_victoryVolume);
private void BlendTo(Volume target)
{
foreach (var v in _nonDefaultVolumes)
DOTween.To(() => v.weight, x => v.weight = x, 0f, _blendDuration)
.SetAutoKill(true).SetLink(gameObject);
DOTween.To(() => target.weight, x => target.weight = x, 1f, _blendDuration)
.SetAutoKill(true).SetLink(gameObject);
if (_blendCoroutine != null) StopCoroutine(_blendCoroutine);
_blendCoroutine = StartCoroutine(BlendCoroutine(target, 1f));
}
private void ResetAll()
{
foreach (var v in _nonDefaultVolumes)
DOTween.To(() => v.weight, x => v.weight = x, 0f, _blendDuration)
.SetAutoKill(true).SetLink(gameObject);
if (_blendCoroutine != null) StopCoroutine(_blendCoroutine);
_blendCoroutine = StartCoroutine(ResetAllCoroutine());
}
private IEnumerator BlendCoroutine(Volume target, float targetWeight)
{
float elapsed = 0f;
float[] startWeights = new float[_managedVolumes.Length];
for (int i = 0; i < _managedVolumes.Length; i++)
startWeights[i] = _managedVolumes[i] != null ? _managedVolumes[i].weight : 0f;
while (elapsed < _blendDuration)
{
elapsed += Time.unscaledDeltaTime;
float t = Mathf.Clamp01(elapsed / _blendDuration);
for (int i = 0; i < _managedVolumes.Length; i++)
{
if (_managedVolumes[i] == null) continue;
float dest = _managedVolumes[i] == target ? targetWeight : 0f;
_managedVolumes[i].weight = Mathf.Lerp(startWeights[i], dest, t);
}
yield return null;
}
}
private IEnumerator ResetAllCoroutine()
{
float elapsed = 0f;
float[] startWeights = new float[_managedVolumes.Length];
for (int i = 0; i < _managedVolumes.Length; i++)
startWeights[i] = _managedVolumes[i] != null ? _managedVolumes[i].weight : 0f;
while (elapsed < _blendDuration)
{
elapsed += Time.unscaledDeltaTime;
float t = Mathf.Clamp01(elapsed / _blendDuration);
for (int i = 0; i < _managedVolumes.Length; i++)
{
if (_managedVolumes[i] == null) continue;
_managedVolumes[i].weight = Mathf.Lerp(startWeights[i], 0f, t);
}
yield return null;
}
}
}
}
```
> **⚠️ 实现说明**
> - 使用 `Time.unscaledDeltaTime`,暂停期间 blend 不受 `Time.timeScale` 影响
> - 水下 Volume`_underwaterVolume`)由 `World.Liquid` 模块中的 `UnderwaterPostProcessingController` 独立管理,不在此组件中
> - 不使用 DOTween不使用 Lambda 订阅Lambda 无法从事件中移除)
### Volume 结构与 Profile 参数
```
Persistent 场景 [PostProcess]:
├── Volume_Default Priority=0 Weight=1.0(始终生效基础 Profile
├── Volume_Underwater Priority=10 Weight=0进水时 blend 到 1.0
├── Volume_Underwater Priority=10 Weight=0由 UnderwaterPostProcessingController 管理
├── Volume_BossArena Priority=10 Weight=0Boss 战开始时 blend 到 1.0
├── Volume_Death Priority=20 Weight=0玩家死亡时 blend 到 1.0
└── Volume_Victory Priority=10 Weight=0Boss 击败时 blend 到 1.0
```
| Volume | Bloom | Color Grading | Vignette | Chromatic Aberration |
|--------|-------|--------------|----------|---------------------|
| Default | Intensity 0.3 | 正常 | 0.2 | 关闭 |
| Underwater | 0.1 | 青绿 Filter -0.3 | 0.45 | 0.4 |
| BossArena | 0.5 | 饱和度 +20% | 0.35 | 0.15 |
| Death | 0 | 去饱和度 -80% | 0.7(黑色)| 0.8 |
| Victory | 0.8(白色)| 亮度 +0.4 | 0 | 0 |
> **DOTween 规范**:所有 `DOTween.To()` 必须链式调用 `.SetAutoKill(true).SetLink(gameObject)`;禁止使用 `DOTween.KillAll()`。
---
## 12. RegionLightController
@@ -676,28 +751,64 @@ namespace BaseGames.VFX
/// <summary>
/// 区域进出时平滑切换 Global Light 2D 颜色和强度。
/// 挂在 Persistent 场景 [Lighting] GameObject 上,监听 OnRegionEntered 事件频道。
/// 使用 Coroutine 实现平滑过渡,不依赖 DOTween。
/// </summary>
public class RegionLightController : MonoBehaviour
{
[SerializeField] Light2D _globalLight;
[SerializeField] RegionLightCatalogSO _catalog; // RegionId → 颜色 + 强度
[SerializeField] StringEventChannelSO _onRegionEntered;
[SerializeField] float _transitionDuration = 1.5f;
[SerializeField] private Light2D _globalLight;
[SerializeField] private RegionLightCatalogSO _catalog;
[SerializeField] private StringEventChannelSO _onRegionEntered;
[SerializeField] private float _transitionDuration = 1.5f;
private void OnEnable() => _onRegionEntered.OnEventRaised += OnRegionEntered;
private void OnDisable() => _onRegionEntered.OnEventRaised -= OnRegionEntered;
private Coroutine _colorCoroutine;
private Coroutine _intensityCoroutine;
private void OnEnable()
{
if (_onRegionEntered != null)
_onRegionEntered.OnEventRaised += OnRegionEntered;
}
private void OnDisable()
{
if (_onRegionEntered != null)
_onRegionEntered.OnEventRaised -= OnRegionEntered;
}
private void OnRegionEntered(string regionId)
{
if (_catalog == null || _globalLight == null) return;
if (!_catalog.TryGet(regionId, out var config)) return;
DOTween.To(() => _globalLight.color,
x => _globalLight.color = x,
config.Color, _transitionDuration)
.SetAutoKill(true).SetLink(gameObject);
DOTween.To(() => _globalLight.intensity,
x => _globalLight.intensity = x,
config.Intensity, _transitionDuration)
.SetAutoKill(true).SetLink(gameObject);
if (_colorCoroutine != null) StopCoroutine(_colorCoroutine);
if (_intensityCoroutine != null) StopCoroutine(_intensityCoroutine);
_colorCoroutine = StartCoroutine(TweenColor(_globalLight.color, config.Color, _transitionDuration));
_intensityCoroutine = StartCoroutine(TweenIntensity(_globalLight.intensity, config.Intensity, _transitionDuration));
}
private IEnumerator TweenColor(Color from, Color to, float duration)
{
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
_globalLight.color = Color.Lerp(from, to, Mathf.Clamp01(elapsed / duration));
yield return null;
}
_globalLight.color = to;
}
private IEnumerator TweenIntensity(float from, float to, float duration)
{
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
_globalLight.intensity = Mathf.Lerp(from, to, Mathf.Clamp01(elapsed / duration));
yield return null;
}
_globalLight.intensity = to;
}
}
@@ -712,13 +823,16 @@ namespace BaseGames.VFX
[Range(0f, 1f)] public float Intensity;
}
[SerializeField] RegionLightConfig[] _entries;
[SerializeField] private RegionLightConfig[] _entries;
public bool TryGet(string regionId, out RegionLightConfig cfg)
{
foreach (var e in _entries)
if (_entries != null)
{
if (e.regionId == regionId) { cfg = e; return true; }
foreach (var e in _entries)
{
if (e.regionId == regionId) { cfg = e; return true; }
}
}
cfg = default;
return false;

View File

@@ -1,7 +1,7 @@
# 20 · 护盾模块Shield Module
> **命名空间** `BaseGames.Player.Shield`
> **程序集** `BaseGames.Player`(并入玩家程序集
> **命名空间** `BaseGames.Combat`
> **程序集** `BaseGames.Combat``Assets/Scripts/Combat/`
> **依赖** `BaseGames.Core.Events` · `BaseGames.Combat`DamageInfo · HurtBox· `BaseGames.UI`HUDController
> **Design 来源** [30_ShieldMechanicsSystem](../Design/30_ShieldMechanicsSystem.md)
@@ -86,7 +86,7 @@ _hurtBox.SetShieldable(_shieldComponent);
## 3. ShieldConfigSO
```csharp
namespace BaseGames.Player.Shield
namespace BaseGames.Combat
{
[CreateAssetMenu(menuName = "Player/ShieldConfig")]
public class ShieldConfigSO : ScriptableObject
@@ -122,20 +122,19 @@ namespace BaseGames.Player.Shield
## 4. ShieldComponent
```csharp
namespace BaseGames.Player.Shield
namespace BaseGames.Combat
{
/// <summary>
/// 挂在 PlayerController 子节点 [Shield] 上。
/// 在 HurtBox 和 IDamageablePlayerStats之间担当拦截层。
/// </summary>
[DefaultExecutionOrder(-40)]
public class ShieldComponent : MonoBehaviour, IShieldable
{
// ── Inspector ───────────────────────────────────────
[SerializeField] ShieldConfigSO _config;
[SerializeField] IntEventChannelSO _onShieldHPChanged; // 广播当前耐久整数
[SerializeField] VoidEventChannelSO _onShieldBroken;
[SerializeField] VoidEventChannelSO _onShieldRestored;
[SerializeField] IntEventChannelSO _onShieldHPChanged; // 广播当前耐久整数
[SerializeField] VoidEventChannelSO _onShieldBrokenChannel;
[SerializeField] VoidEventChannelSO _onShieldRestoredChannel;
[SerializeField] DifficultyChangedEventChannel _onDifficultyChanged;
// ── Runtime State ────────────────────────────────────
@@ -208,7 +207,7 @@ namespace BaseGames.Player.Shield
_currentShieldHP = 0;
_isBroken = true;
_brokenTimer = 0f;
_onShieldBroken.Raise();
_onShieldBrokenChannel.Raise();
}
_onShieldHPChanged.Raise(_currentShieldHP); // 更新 ShieldBarUI
@@ -223,7 +222,7 @@ namespace BaseGames.Player.Shield
_currentShieldHP = _config.MaxShieldHP;
_isBroken = false;
_brokenTimer = 0f;
_onShieldRestored.Raise();
_onShieldRestoredChannel.Raise();
}
/// <summary>存档加载时恢复护盾状态。由 PlayerController.LoadFromSaveData() 调用。</summary>
@@ -255,7 +254,7 @@ namespace BaseGames.Player.Shield
## 5. IShieldable 接口
```csharp
namespace BaseGames.Player.Shield
namespace BaseGames.Combat
{
/// <summary>
/// 可拥有护盾的实体接口。HurtBox 持有此接口引用,在受击时优先检查护盾。
@@ -301,21 +300,21 @@ public class ShieldBarUI : MonoBehaviour
[Header("Event Channels")]
[SerializeField] IntEventChannelSO _onShieldHPChanged; // 订阅耐久变化
[SerializeField] VoidEventChannelSO _onShieldBroken;
[SerializeField] VoidEventChannelSO _onShieldRestored;
[SerializeField] VoidEventChannelSO _onShieldBrokenChannel;
[SerializeField] VoidEventChannelSO _onShieldRestoredChannel;
void OnEnable()
{
_onShieldHPChanged.OnEventRaised += RefreshFill;
_onShieldBroken.OnEventRaised += ShowBroken;
_onShieldRestored.OnEventRaised += HideBroken;
_onShieldHPChanged.OnEventRaised += RefreshFill;
_onShieldBrokenChannel.OnEventRaised += ShowBroken;
_onShieldRestoredChannel.OnEventRaised += HideBroken;
}
void OnDisable()
{
_onShieldHPChanged.OnEventRaised -= RefreshFill;
_onShieldBroken.OnEventRaised -= ShowBroken;
_onShieldRestored.OnEventRaised -= HideBroken;
_onShieldHPChanged.OnEventRaised -= RefreshFill;
_onShieldBrokenChannel.OnEventRaised -= ShowBroken;
_onShieldRestoredChannel.OnEventRaised -= HideBroken;
}
private void RefreshFill(int currentHP)

View File

@@ -202,16 +202,21 @@ namespace BaseGames.Quest
public bool unlocksAbility = false; // ⚠️ AbilityType 无 None 值,用 bool 标识是否解锁能力(架构 09 §1
public AbilityType unlockedAbility; // 仅当 unlocksAbility == true 时有效
[Header("物品发放事件")]
[Tooltip("EVT_CollectiblePickup向 QuestManager/EquipmentManager 广播 itemId")]
[SerializeField] private StringEventChannelSO _onCollectiblePickup;
/// <summary>将奖励应用到游戏状态(由 QuestManager.CompleteQuest 调用)。</summary>
public void Apply(PlayerStats player)
{
if (geo > 0) player.AddGeo(geo);
if (soulBonus > 0) player.ExtendSoulMax(soulBonus);
if (soulBonus > 0) player.AddSoulPower(soulBonus);
if (unlocksAbility) // ⚠️ 替代 AbilityType.None 判断
player.UnlockAbility(unlockedAbility);
// 物品/护符通过 InventoryManager 发放
foreach (var id in itemIds)
InventoryManager.Instance.AddItem(id);
// 物品/护符通过 EVT_CollectiblePickup 事件频道广播(InventoryManager 不存在于本项目)
if (itemIds != null && _onCollectiblePickup != null)
foreach (var id in itemIds)
_onCollectiblePickup.Raise(id);
}
}
}

View File

@@ -18,7 +18,7 @@
| [06](./06_CombatModule.md) | 战斗模块 | DamageInfo、HitBox、HurtBox、Parry、Projectile、StatusEffects | 04、05、13、30、54 |
| [07](./07_EnemyModule.md) | 敌人模块 | EnemyBase、EnemyStats、AI Tasks、Navigation、Boss Patterns、Telegraph | 06、19、47、48 |
| [08](./08_WorldModule.md) | 世界模块 | 场景结构、RoomTransition、SavePoint、Collectible、HazardZone、WorldStateRegistry | 08、34、49 |
| [09](./09_ProgressionModule.md) | 进度模块 | AbilityGate、Equipment/Charms、Skills/Spells、Quest、Challenge | 14、17、21、37、38、39 |
| [09](./09_ProgressionModule.md) | 进度模块 | AbilityType、AbilityGate、Equipment/Charms、Skills/Spells、Quest、Challenge | 14、17、21、37、38、39 |
| [10](./10_UIModule.md) | UI 模块 | UIManager、HUD、PauseMenu、DeathScreen、Panel 层级、UI Toolkit 规范 | 10、53_HUDSpec 参考 74 |
| [11](./11_AudioModule.md) | 音频模块 | AudioManager、BGMController、SFX Pool、AudioZone、FMOD 集成 | 12、63 |
| [12](./12_SaveModule.md) | 存档模块 | SaveData schemaC# 完整结构、SaveManager、ISaveStorage、SaveMigrator、Checksum | 31 |
@@ -27,12 +27,12 @@
| [15](./15_MapShopModule.md) | 地图与商店模块 | MapManager、RoomReveal、FastTravel、ShopController、ShopInventorySO | 16、28 |
| [16](./16_SupportingModules.md) | 支撑模块 | Localization、Platform Integration、Analytics、Achievement、Tutorial、Debug | 22、32、42、45、46、55 |
| [17](./17_CameraModule.md) | 摄像机模块 | CameraStateController、Cinemachine 虚拟相机、Zone-based 切换、CameraBounds | 03、26 |
| [18](./18_VFXFeedbackModule.md) | VFX 与反馈模块 | FeedbackPresetSO、IFeedbackPlayer、HitFxPool、ScreenShake、FeedbackEventChannelSO | 04、12 |
| [18](./18_VFXFeedbackModule.md) | VFX 与反馈模块 | FeedbackConfigSO、VFXPool、HitFXSpawner、HurtFlashController、PostProcessManager | 04、12 |
| [19](./19_DifficultyModule.md) | 难度模块 | DifficultySettingsSO、DifficultyManager、IScalable、SteelSoul 模式 | 11 |
| [20](./20_ShieldModule.md) | 护盾模块 | ShieldComponent、ShieldConfigSO、IShieldable、护盾破碎/恢复管道 | 05、13 |
| [21](./21_LiquidPuzzleModule.md) | 液体谜题模块 | LiquidSimulator、LiquidTile、LiquidTriggerZone、SwimState、HazardLiquid | 08、41 |
| [21](./21_LiquidPuzzleModule.md) | 液体谜题模块 | LiquidZone、LiquidPhysicsConfigSO、SwimState、PuzzleSwitch/PuzzleReceiver、WorldMarker | 08、41 |
| [22](./22_QuestChallengeModule.md) | 任务与挑战模块 | QuestManager、QuestSO、QuestObjectiveSO、ChallengeRoom、QuestEventChannelSO | 37、38、39 |
| [23](./23_BossSkillModule.md) | Boss 技能模块 | BossSkillSO、BossSkillExecutor、SkillSequenceSO、VulnerabilityWindow、BossPhaseController | 19、47、48 |
| [23](./23_BossSkillModule.md) | Boss 技能模块 | BossSkillSO、BossSkillExecutor、SkillSequenceSO、VulnerabilityWindow、WeakPointSystem | 19、47、48 |
| [24](./24_AnimEventModule.md) | 动画事件模块 | PlayerAnimationEvents、EnemyAnimationEvents、AnimEventBridge、Animancer 事件回调 | 03 |
---