地图系统

This commit is contained in:
2026-06-05 18:41:33 +08:00
parent 613f2a4d13
commit fe4fd60083
234 changed files with 33090 additions and 4899 deletions

View File

@@ -0,0 +1,299 @@
# Persistent 场景配置手册
> 文件位置:`Docs/Guides/03_Persistent_Scene_Setup_Guide.md`
> 版本1.0 · 适用项目zeling_v2
---
## 目录
1. [架构概览](#1-架构概览)
2. [快速开始:脚手架工具](#2-快速开始脚手架工具)
3. [自动生成内容速查](#3-自动生成内容速查)
4. [手动配置项详解](#4-手动配置项详解)
- 4.1 [SceneFade配置 MMF_Player 淡入淡出效果](#41-scenefade配置-mmf_player-淡入淡出效果)
- 4.2 [SplashScreenController美术资源替换](#42-splashscreencontroller美术资源替换)
- 4.3 [LoadingScreenManager背景图与提示文字](#43-loadingscreenmanager背景图与提示文字)
- 4.4 [HUDControllerUI 资源绑定](#44-hudcontroller-ui-资源绑定)
- 4.5 [AudioManagerAudioMixer 指定](#45-audiomanager-audiomixer-指定)
- 4.6 [PauseMenuController按钮文本与样式](#46-pausemenucontroller按钮文本与样式)
5. [事件频道速查表](#5-事件频道速查表)
6. [场景切换完整时序](#6-场景切换完整时序)
7. [常见问题排查](#7-常见问题排查)
---
## 1. 架构概览
Persistent 场景在整个游戏生命周期内**常驻内存**,承载所有跨场景的全局系统。
游戏场景(关卡、主菜单等)以 Additive 方式叠加在它之上。
```
[Persistent Scene]
├── [SERVICES] ← 纯逻辑SceneService, GameManager, AudioManager ...
└── [UI]
├── UIRoot ← UIManager面板栈管理
│ ├── HUD Canvas / HUDRoot
│ ├── DeathScreen Canvas / DeathScreenRoot
│ ├── PauseMenuRoot
│ ├── SettingsRoot
│ ├── MapRoot
│ └── ShopRoot
├── Canvas_Splash ← SplashScreenController启动演出
├── Canvas_Loading ← LoadingScreenManager加载遮罩
└── SYS_SceneFade ← SceneFadeController场景切换黑幕
├── FeedbackFadeOut ← SceneFeedback + MMF_Player ★需手动配置
└── FeedbackFadeIn ← SceneFeedback + MMF_Player ★需手动配置
```
**调用链(场景切换):**
```
RoomTransition / DoorTransition游戏场景
→ ServiceLocator<ISceneService>.RequestTransition(request)
→ SceneService.LoadSceneCoroutine()
→ EVT_FadeOutRequest.Raise()
→ 等待 _sceneFadeDuration默认 0.4 s
→ 加载目标场景
→ EVT_FadeInRequest.Raise()
EVT_FadeOutRequest / EVT_FadeInRequest
→ SceneFadeControllerPersistent 场景)
→ SceneFeedback.Play()
→ MMF_Player.PlayFeedbacks() ← 实际视觉效果在此配置
```
---
## 2. 快速开始:脚手架工具
**空的 Persistent 场景** 中执行:
```
菜单栏 → BaseGames → Scene → Setup → Scaffold Persistent Scene
```
脚手架完成后会在编辑器底部输出一份报告,列出所有已自动绑定的内容及仍需手动处理的项目。
**请务必阅读该报告再继续。**
> **前提条件**
> 运行脚手架前,先执行 `BaseGames → Scene → Setup → Create All Event Channel Assets`
> 确保所有事件频道 SO 资产已生成,脚手架才能自动绑定引用。
---
## 3. 自动生成内容速查
脚手架会自动完成以下所有节点的创建与字段绑定:
| 节点 / 组件 | 自动创建子节点 | 自动绑定字段 |
|---|---|---|
| `UIRoot / UIManager` | HUD Canvas、DeathScreen Canvas、PauseMenuRoot、SettingsRoot、MapRoot、ShopRoot | `_hudRoot``_deathScreenRoot``_addressablePanelParent``_panels[4]`、全部事件频道 |
| `DeathScreenRoot / DeathScreenController` | `RespawnButton`Button`DeathMessage`TextMeshProUGUI | `_btnRespawn``_deathMessage``_onDeathScreenConfirmed` |
| `PauseMenuRoot / PauseMenuController` | `Btn_Resume``Btn_Settings``Btn_MainMenu``Btn_Quit`(各带 Button | 4 个按钮引用、`_onResumeRequested``_onSceneLoadRequest` |
| `Canvas_Splash / SplashScreenController` | `StudioLogo`CanvasGroup`GameTitle`CanvasGroup | `_splashRoot``_studioLogoGroup``_gameTitleGroup`、事件频道 |
| `Canvas_Loading / LoadingScreenManager` | `LoadingRoot``ProgressBarFill`Image.Filled`TipText`TextMeshProUGUI | `_loadingRoot``_progressFill``_tipText`、事件频道 |
| `SYS_SceneFade / SceneFadeController` | `FeedbackFadeOut`MMF_Player + SceneFeedback`FeedbackFadeIn`MMF_Player + SceneFeedback | `_fadeOut``_fadeIn``_onFadeOutRequest``_onFadeInRequest` |
---
## 4. 手动配置项详解
### 4.1 SceneFade配置 MMF_Player 淡入淡出效果
这是**唯一的核心视觉必配项**,脚手架已创建好节点和组件,但 `MMF_Player` 内部的具体效果需要手动添加。
**节点路径:**
```
[UI] → SYS_SceneFade → FeedbackFadeOut (淡出:画面变黑)
[UI] → SYS_SceneFade → FeedbackFadeIn (淡入:画面显现)
```
**配置步骤FeedbackFadeOut**
1. 选中 `FeedbackFadeOut` GameObject
2. 在 Inspector 中找到 `MMF Player` 组件,点击 **`+`** 添加 Feedback
3. 选择 `UI > Canvas Group Alpha`(推荐)或 `Rendering > PostProcessing`
4. 配置参数:
- **目标 CanvasGroup**:创建一个全屏黑色 Image 挂在 `SYS_SceneFade` 下,添加 `CanvasGroup`,将其拖入
- **Alpha From**`0`**Alpha To**`1`(淡出 = 变黑)
- **Duration**`0.35 s`(需 ≤ `SceneService._sceneFadeDuration`,默认 `0.4 s`
5. 配置 `FeedbackFadeIn`(镜像操作):
- **Alpha From**`1`**Alpha To**`0`(淡入 = 显现)
- Duration 同上
> **重要约束**
> MMF_Player 的总时长Duration**必须 ≤ `SceneService._sceneFadeDuration`(默认 0.4 s**
> 否则淡出动画尚未播完,场景已开始加载,视觉上会有闪烁。
> 如需更长的过渡,在 `SERVICES → SceneService` Inspector 中同步调大 `_sceneFadeDuration`。
**推荐全屏遮罩层级结构:**
```
SYS_SceneFade
├── ScreenOverlay ← Image全屏黑色+ CanvasGroupalpha 0
│ 需在此 Canvas 上设置 Sort Order 高于所有其他 UI Canvas
├── FeedbackFadeOut ← SceneFeedback + MMF_Playertarget: ScreenOverlay.CanvasGroup
└── FeedbackFadeIn ← SceneFeedback + MMF_Playertarget: ScreenOverlay.CanvasGroup
```
---
### 4.2 SplashScreenController美术资源替换
脚手架已创建 `StudioLogo``GameTitle` 子节点(带 `CanvasGroup`),默认为空。
| 子节点 | 需要添加 | 说明 |
|---|---|---|
| `StudioLogo` | `Image` 组件 + 工作室 Logo 贴图 | 设置 Preserve Aspect = true |
| `GameTitle` | `Image` 组件 + 游戏标题图 | 同上 |
时序参数(可在 `SplashScreenController` Inspector 中调整):
| 字段 | 默认值 | 说明 |
|---|---|---|
| `_fadeInDuration` | `0.8 s` | 每个 Logo 的淡入时长 |
| `_holdDuration` | `1.5 s` | 停留时长 |
| `_fadeOutDuration` | `0.6 s` | 淡出时长 |
| `_stageGapDuration` | `0.3 s` | 两段演出之间的间隔 |
---
### 4.3 LoadingScreenManager背景图与提示文字
脚手架已创建进度条 Image 和 TipText以下字段仍需手动填写
| 字段 | 类型 | 说明 |
|---|---|---|
| `_backgroundArts` | `Image[]` | 加载画面随机背景图数组,留空则无背景 |
| `_tipMessages` | `string[]` | 本地化 key 列表(对应 Localization `UI` 表),留空则不显示提示文字 |
| `_minDisplayTime` | `float`(默认 0.5 s | 加载极快时的最短显示时间,避免画面闪烁 |
**进度条设置确认:**
`ProgressBarFill` Image 已由脚手架设置为 `Image.Type = Filled``FillMethod = Horizontal`
确认 `Fill Origin = Left`,并将图片 Import Settings 的 `Wrap Mode` 设为 `Clamp`
---
### 4.4 HUDControllerUI 资源绑定
`HUDController` 依赖较多图标/文本/进度条 Prefab均需在 Inspector 手动绑定。
具体字段参见 `Assets/_Game/Scripts/UI/HUD/HUDController.cs` 中的 `[SerializeField]` 注释。
> HUD 资源较多,建议参考美术规范文档 `Docs/Design/UI_HUD_Spec.md`(如已存在)进行配置。
---
### 4.5 AudioManagerAudioMixer 指定
**节点:** `[SERVICES] → AudioManager`
| 字段 | 说明 |
|---|---|
| `_mixer` | 拖入 `Assets/_Game/Audio/MainMixer.mixer`(或对应的 AudioMixer 资产) |
| `_masterVolumeParam` | AudioMixer 中 Master 音量参数名(默认 `"MasterVolume"` |
| `_bgmVolumeParam` | BGM 音量参数名(默认 `"BGMVolume"` |
| `_sfxVolumeParam` | SFX 音量参数名(默认 `"SFXVolume"` |
脚手架已自动创建 2 个 BGM Source 和 6 个 SFX Source 并绑定。
---
### 4.6 PauseMenuController按钮文本与样式
脚手架已创建 4 个按钮子节点(`Btn_Resume` / `Btn_Settings` / `Btn_MainMenu` / `Btn_Quit`),但节点仅含 `Button` 组件,没有视觉内容。
每个按钮需要添加:
- `Image` 组件(或替换为 `TextMeshProUGUI` 子节点作为标签)
- 根据 UI 设计规范配置 Sprite / 颜色 / Navigation
---
## 5. 事件频道速查表
以下是 Persistent 场景相关的全部事件频道。
所有资产由 `BaseGames → Scene → Setup → Create All Event Channel Assets` 自动生成到 `Assets/_Game/Events/`
| SO 名称 | 类型 | 发布者 | 订阅者 |
|---|---|---|---|
| `EVT_FadeOutRequest` | VoidEventChannelSO | SceneService | SceneFadeController |
| `EVT_FadeInRequest` | VoidEventChannelSO | SceneService | SceneFadeController |
| `EVT_LoadingStarted` | VoidEventChannelSO | SceneService / SceneLoader | LoadingScreenManager |
| `EVT_LoadingComplete` | VoidEventChannelSO | SceneService / SceneLoader | LoadingScreenManager |
| `EVT_LoadingProgressUpdated` | FloatEventChannelSO | SceneLoader | LoadingScreenManager |
| `EVT_SplashStartRequest` | VoidEventChannelSO | BootSequencer | SplashScreenController |
| `EVT_SplashComplete` | VoidEventChannelSO | SplashScreenController | BootSequencer |
| `EVT_SceneLoadRequest` | SceneLoadRequestEventChannelSO | DoorTransition / RoomTransition / PauseMenuController | SceneService |
| `EVT_GameStateChanged` | GameStateEventChannelSO | GameManager | UIManager |
| `EVT_PauseRequested` | VoidEventChannelSO | InputHandler | UIManager |
| `EVT_DeathScreenConfirmed` | VoidEventChannelSO | DeathScreenController | DeathRespawnService |
| `EVT_ResumeRequested` | VoidEventChannelSO | PauseMenuController | GameManager |
---
## 6. 场景切换完整时序
```
玩家触碰门触发器
├─ DoorTransition.OnTriggerEnter2D()
│ └─ ServiceLocator<ISceneService>.Get().RequestTransition(request)
└─ SceneService.LoadSceneCoroutine()
├─ 1. EVT_FadeOutRequest.Raise()
│ └─ SceneFadeController → FeedbackFadeOut.Play()
│ └─ MMF_Player → 画面变黑(≤ 0.4 s
├─ 2. yield WaitForSeconds(_sceneFadeDuration) ← 等待黑幕完成
├─ 3. [可选] EVT_LoadingStarted.Raise()
│ └─ LoadingScreenManager.Show()
├─ 4. UnloadOldScene / LoadNewSceneAddressables
├─ 5. [可选] EVT_LoadingComplete.Raise()
│ └─ LoadingScreenManager.Hide()
└─ 6. EVT_FadeInRequest.Raise()
└─ SceneFadeController → FeedbackFadeIn.Play()
└─ MMF_Player → 画面显现(≤ 0.4 s
```
---
## 7. 常见问题排查
### Q运行后画面直接跳转没有淡入淡出效果
**原因:** `FeedbackFadeOut` / `FeedbackFadeIn``MMF_Player` 中没有添加任何 Feedback。
**解决:** 参见 [4.1 节](#41-scenefade配置-mmf_player-淡入淡出效果) 添加 `Canvas Group Alpha` Feedback。
---
### Q淡出动画播放到一半画面就跳转了
**原因:** `MMF_Player` 总时长超过了 `SceneService._sceneFadeDuration`(默认 0.4 s
**解决:** 缩短 MMF_Player 的 Duration或在 `SceneService` Inspector 中加大 `_sceneFadeDuration`
---
### Q脚手架运行后报告显示某事件频道未找到
**原因:** 事件频道资产尚未生成。
**解决:** 先执行 `BaseGames → Scene → Setup → Create All Event Channel Assets`,再重新运行 `Scaffold Persistent Scene`
---
### QUIManager 的 `_panels` 数组为空
**原因:** 可能在旧版本的 Persistent 场景上重新运行了脚手架(幂等逻辑生效,但 `_panels` 已存在数据)。
**解决:** 在 Inspector 中展开 `UIManager._panels`,确认 4 个条目Pause / Settings / Map / Shop及其 `root` 引用均正确指向对应的 GameObject。
---
### Q暂停菜单打开后按钮没有响应
**可能原因 1** `_onResumeRequested` 未绑定(脚手架未找到对应 SO。检查 `PauseMenuController` Inspector。
**可能原因 2** `EventSystem` 缺失。确认 Persistent 场景中存在 `EventSystem` GameObject脚手架会自动创建

View File

@@ -0,0 +1,553 @@
# 存档系统与数据持久化手册
> 文件位置:`Docs/Guides/04_SaveSystem_DataPersistence_Guide.md`
> 版本1.0 · 适用项目zeling_v2
---
## 目录
1. [架构概览](#1-架构概览)
2. [SaveData 数据模型](#2-savedata-数据模型)
3. [存档读写完整时序](#3-存档读写完整时序)
- 3.1 [保存流程Save](#31-保存流程save)
- 3.2 [加载流程Load](#32-加载流程load)
- 3.3 [场景切换时的世界状态恢复](#33-场景切换时的世界状态恢复)
4. [ISaveable 接入模式](#4-isaveable-接入模式)
5. [存档槽管理与 UI 集成](#5-存档槽管理与-ui-集成)
6. [序列化与完整性校验](#6-序列化与完整性校验)
7. [存档迁移SaveMigrator](#7-存档迁移savemigrator)
8. [Inspector 配置指南](#8-inspector-配置指南)
9. [扩展指南](#9-扩展指南)
10. [商业对标评估](#10-商业对标评估)
11. [常见问题排查](#11-常见问题排查)
---
## 1. 架构概览
存档系统由四个层次组成严格单向依赖Core 层不持有任何 Unity UI 引用:
```
┌───────────────────────────────────────────────────────────┐
│ UI 层SaveSlotController / SaveSlotUI
│ 展示槽摘要、触发存档槽确认 │
└────────────────────┬──────────────────────────────────────┘
│ EVT_SlotConfirmedIntEventChannelSO
┌────────────────────▼──────────────────────────────────────┐
│ 服务接口层ISaveService / ISaveableRegistry
│ SaveServiceAdapter — 将 MonoBehaviour API 包装为接口 │
└────────────────────┬──────────────────────────────────────┘
│ ServiceLocator<ISaveService>.Get()
┌────────────────────▼──────────────────────────────────────┐
│ 管理器层GameSaveManager
│ 协调 ISaveable 集合、调用 ISaveStorage、管理内存状态 │
└────────────────────┬──────────────────────────────────────┘
┌────────────────────▼──────────────────────────────────────┐
│ 存储层ISaveStorage / LocalFileStorage
│ 负责实际的磁盘 IOJSON 文件,可替换为云存档) │
└───────────────────────────────────────────────────────────┘
横切关注点:
ISaveable ← 各游戏组件Player、WorldObject、Quest 等)实现此接口
SaveData ← 唯一的内存数据容器,各 ISaveable 读写其子节点
```
**核心设计原则:**
- `GameSaveManager` 不感知任何具体业务数据——它只是一个调度者;业务数据由各 `ISaveable` 组件自行读写 `SaveData` 的对应字段。
- `ISaveStorage` 是磁盘操作的唯一入口,方便在测试或平台适配时替换为内存存储或云存储。
- 所有跨层通信通过 `ServiceLocator` 或 SO 事件频道进行Core 层与 UI 层完全解耦。
---
## 2. SaveData 数据模型
`SaveData` 是游戏唯一的持久化数据容器,序列化为 JSON 写入磁盘。
### 顶层结构
```csharp
public class SaveData
{
public SaveMeta Meta; // 元数据:版本、时间戳、校验和
public PlayerSaveData Player; // 玩家HP、零珠、形态、死亡影
public EquipmentSaveData Equipment; // 装备:符文、凹槽
public WorldSaveData World; // 世界访问过的场景、已开门、已击败Boss、已拾取物品
public MapSaveData Map; // 地图:已探索房间、标记、传送点
public QuestSaveData Quests; // 任务:状态、目标进度
public AchievementSaveData Achievements;
public ToolsSaveData Tools; // 工具:已装备、已持有
public ChallengeRoomsSaveData ChallengeRooms; // 挑战间:最高分、最佳时间
public EventChainsSaveData EventChains; // 事件链:完成标记、世界旗标
public ShopsSaveData Shops; // 商店:已售出独特道具、购买次数
public InventorySaveData Inventory; // 背包:物品数量、新物品列表
public JournalSaveData Journal; // 图鉴:已发现敌人、已解锁传说
public StatsSaveData Stats; // 统计:击杀数、死亡数、移动距离
public NGPlusSaveData NGPlus; // NG+null = 非 NG+ 存档)
public TutorialSaveData Tutorial; // 教程:已完成提示 ID
public SettingsSaveData Settings; // 设置:语言偏好等
public Dictionary<string, JObject> DLC; // DLC 扩展点(开放式)
}
```
### SaveMeta 元数据
| 字段 | 类型 | 说明 |
|---|---|---|
| `Version` | `int` | 对应 `SaveMigrator.CurrentVersion`,用于向前迁移 |
| `SlotIndex` | `int` | 02 为普通槽98 = QuickSave |
| `LastSaved` | `string` | ISO 8601 时间戳(如 `"2026-06-04T14:30:00Z"`|
| `Playtime` | `float` | 累计游戏秒数 |
| `SavePointId` | `string` | 最后使用的检查点/存档点 ID |
| `NGPlusCount` | `int` | NG+ 周目计数0 = 初周目)|
| `SaveCount` | `int` | 累计保存次数(用于存档槽 UI 徽章显示)|
| `Checksum` | `string` | HMAC-SHA256Base64用于完整性校验 |
| `IsSteelSoul` | `bool` | 钢铁之魂模式(一命通关)标志,锁定后不可修改 |
### 摘要数据SlotSummary
`GetSlotSummaryAsync(int slot)` 返回用于 UI 展示的轻量对象,**不加载完整 SaveData**
| 字段 | 说明 |
|---|---|
| `Playtime` | 游戏时长(格式化后用于 UI 显示)|
| `LocationName` | 上次存档的场景显示名(本地化 Key|
| `Currency` | 零珠数量 |
| `MaxHP` | 当前最大血量 |
| `IsSteelSoul` | 是否为钢铁之魂存档UI 显示特殊徽章)|
| `HasData` | 该槽是否有存档(空槽 = false|
---
## 3. 存档读写完整时序
### 3.1 保存流程Save
```
触发点:检查点交互 / 自动存档触发器 / 手动调用 ISaveService.SaveAsync(slot)
├─ GameSaveManager.SaveAsync(slot)
│ │
│ ├─ 1. await _saveLock.WaitAsync() ← 防止并发写入
│ │
│ ├─ 2. 更新 Meta
│ │ └─ Meta.LastSaved = DateTime.UtcNow.ToString("o")
│ │ Meta.Playtime += (Time.time - _sessionStartTime)
│ │ Meta.SaveCount += 1
│ │
│ ├─ 3. 遍历 _saveablesHashSet<ISaveable>
│ │ └─ foreach ISaveable s → s.OnSave(_current)
│ │ (各组件将自身状态写入 _current 对应字段)
│ │
│ ├─ 4. 序列化 + 注入校验和
│ │ ├─ _current.Meta.Checksum = null ← 归零,确保 HMAC 计算一致
│ │ ├─ json = JsonConvert.SerializeObject(_current)
│ │ ├─ hmac = ComputeHMAC(json)
│ │ └─ json = InjectChecksum(json, hmac) ← 字符串替换,避免二次序列化
│ │
│ └─ 5. _storage.WriteAsync(slot, json)
│ └─ 写入本地文件(路径:{persistentDataPath}/saves/slot_{slot}.json
└─ _saveLock.Release()
副作用:
EVT_SaveCompleted可选─► 触发存档点 UI 动画(旋转图标 → 对号)
```
### 3.2 加载流程Load
```
触发点:主菜单「继续」选择存档 / 死亡后在检查点复活
├─ GameSaveManager.LoadAsync(slot)
│ │
│ ├─ 1. json = await _storage.ReadAsync(slot)
│ │
│ ├─ 2. data = JsonConvert.DeserializeObject<SaveData>(json)
│ │
│ ├─ 3. 校验和验证
│ │ ├─ extractedHMAC = data.Meta.Checksum
│ │ ├─ data.Meta.Checksum = null
│ │ ├─ recomputedHMAC = ComputeHMAC(JsonConvert.SerializeObject(data))
│ │ └─ if (extractedHMAC != recomputedHMAC)
│ │ IsSteelSoul → 拒绝加载(防存档篡改作弊)
│ │ Normal Mode → 记录警告,允许加载(兼容旧版本存档)
│ │
│ ├─ 4. SaveMigrator.Migrate(ref data)
│ │ └─ 若 data.Meta.Version < CurrentVersion执行版本迁移补丁
│ │
│ ├─ 5. _current = data_currentSlot = slot
│ │
│ └─ 6. 遍历 _saveables此时已注册的组件
│ └─ foreach ISaveable s → s.OnLoad(_current)
└─ 加载完成
注意:
加载流程不触发 EVT_SceneLoadRequest。
场景切换由调用方MainMenuController在加载前发起。
OnLoad 在新场景的 OnEnable 阶段由 ISaveableRegistry 驱动。
```
### 3.3 场景切换时的世界状态恢复
```
SceneService.LoadSceneCoroutine()
├─ (省略)淡出 → 加载场景 → 等待一帧
├─ EVT_SceneWorldStateRestored.Raise()
│ └─ 新场景中所有已 OnEnable 的 ISaveable 组件收到通知
│ └─ 重新调用 ISaveableRegistry.OnSceneRestored()
│ └─ foreach ISaveable → s.OnLoad(_current)
│ (仅恢复场景相关状态:已拾取物品、已销毁对象、已开门等)
└─ EVT_FadeInRequest.Raise() ← 世界状态恢复完成后再淡入,避免闪烁
```
---
## 4. ISaveable 接入模式
### 接口定义
```csharp
public interface ISaveable
{
void OnSave(SaveData saveData); // 将自身状态写入 saveData保存前调用
void OnLoad(SaveData saveData); // 从 saveData 恢复自身状态(加载后调用)
}
```
### 标准接入模板
```csharp
public class MyWorldObject : MonoBehaviour, ISaveable
{
[SerializeField] private string _uniqueId; // Inspector 中配置唯一 ID场景内唯一
private ISaveableRegistry _registry;
private bool _isCollected;
private void OnEnable()
{
_registry = ServiceLocator.Get<ISaveableRegistry>();
_registry.Register(this); // 注册:若已加载则立即调用 OnLoad
}
private void OnDisable()
{
_registry?.Unregister(this); // 取消注册:防止悬空引用
}
public void OnSave(SaveData saveData)
{
if (_isCollected)
saveData.World.CollectedItemIds.Add(_uniqueId);
}
public void OnLoad(SaveData saveData)
{
_isCollected = saveData.World.CollectedItemIds.Contains(_uniqueId);
gameObject.SetActive(!_isCollected); // 已拾取则隐藏
}
}
```
### 接入规则
| 规则 | 原因 |
|---|---|
| **必须**在 `OnEnable` 注册,`OnDisable` 取消注册 | 防止场景卸载后 GameSaveManager 持有悬空组件引用 |
| `OnSave` 只写,`OnLoad` 只读 | 避免在 OnLoad 中触发副作用(如粒子、音频)导致加载时闪烁 |
| `_uniqueId` 必须在场景内唯一 | 存档数据以 ID 为键,重复 ID 会造成存档互相覆盖 |
| 不要在 `OnSave/OnLoad` 中操作 Transform 或启动 Coroutine | 此时帧时序不稳定,应改在 `OnLoad` 后的首帧 Update 中执行 |
---
## 5. 存档槽管理与 UI 集成
### 存档槽 UI 职责分离
```
SaveSlotControllerScriptableObject 事件驱动)
├─ OnEnable()
│ └─ RefreshAsync()
│ └─ 并行调用 ISaveService.GetSlotSummaryAsync(0/1/2)
│ └─ 更新 SaveSlotUI[i](卡片显示)
├─ 模式NewGame
│ ├─ 空槽点击 → 显示 NewGameModeController普通 / 钢铁之魂选择)
│ └─ 已占用槽点击 → 显示 ConfirmDialogController覆盖确认
│ └─ 确认 → 显示 NewGameModeController
└─ 模式Continue
└─ 有存档槽点击 → ISaveService.LoadAsync(slot)
→ Raise EVT_SlotConfirmed(slot)
→ MainMenuController.HandleSlotConfirmed()
→ Raise EVT_SceneLoadRequest
```
### 新游戏创建流程
```
NewGameModeController 玩家选择(普通 / 钢铁之魂)
└─ ISaveService.CreateSlot(slotIndex, steelSoul: bool)
├─ new SaveData(),写入默认值
├─ Meta.IsSteelSoul = steelSoul
├─ Meta.SlotIndex = slotIndex
└─ 存入内存 _current不立即写磁盘
└─ Raise EVT_SlotConfirmed(slotIndex)
└─ MainMenuController 触发场景加载
└─ 首次进入 Gameplay 后的第一个检查点交互触发首次写盘
```
### 存档槽 UI 元素规范
| UI 元素 | 数据来源 | 备注 |
|---|---|---|
| 存档时长 | `SlotSummary.Playtime` | 格式化为 `HH:MM:SS` |
| 最后位置 | `SlotSummary.LocationName`(本地化 Key | 通过 LocalizationService 解析 |
| 零珠数量 | `SlotSummary.Currency` | 显示硬币图标 + 数值 |
| 血量进度 | `SlotSummary.MaxHP` | 用于显示心形图标数 |
| 钢铁之魂徽章 | `SlotSummary.IsSteelSoul` | 条件激活特殊 UI 装饰 |
| 空槽占位 | `!SlotSummary.HasData` | 显示「空」或「点击开始」提示 |
---
## 6. 序列化与完整性校验
### JSON 序列化配置
- 库:`Newtonsoft.Json``JsonConvert`
- 设置:`NullValueHandling.Ignore`(减少文件体积)
- 日期格式ISO 8601`"o"` 格式)
### HMAC-SHA256 校验流程
```
保存时:
1. _current.Meta.Checksum = null
2. json = Serialize(_current)
3. hmac = HMACSHA256.ComputeHash(Encoding.UTF8.GetBytes(json), _secretKey)
4. json = json.Replace("\"Checksum\":null", "\"Checksum\":\"" + base64hmac + "\"")
写盘 → json含校验和
加载时:
1. json = 从磁盘读取
2. data = Deserialize(json)
3. stored = data.Meta.Checksum
4. data.Meta.Checksum = null
5. expected = HMACSHA256.ComputeHash(Serialize(data))
6. if stored != expected:
IsSteelSoul → 拒绝(防篡改保护)
Normal → 警告日志,允许(向后兼容)
```
> **注意**`_secretKey` 在 `GameSaveManager` Inspector 中配置(`[SerializeField] private string _hmacKey`)。
> 不可将 Key 硬编码在代码中。生产发布前应使用随机生成的唯一 Key 替换默认值。
---
## 7. 存档迁移SaveMigrator
当代码层 `SaveMigrator.CurrentVersion` > 存档文件 `Meta.Version` 时,迁移器自动执行。
### 添加新迁移补丁
`SaveMigrator.cs``Migrate()` 方法中追加:
```csharp
// 版本 2 → 3为 Inventory 增加 FavoriteSlots 字段
if (data.Meta.Version < 3)
{
data.Inventory ??= new InventorySaveData();
data.Inventory.FavoriteSlots ??= new List<string>();
data.Meta.Version = 3;
}
```
### 版本历史规范
| 版本 | 变更说明 | 迁移操作 |
|---|---|---|
| 1 | 初始版本 | — |
| 2 | 新增 `Stats.DistanceTraveled` | 默认 `0f`,无需迁移 |
| 3 | 新增 `Inventory.FavoriteSlots` | 初始化为空列表 |
> 每次存档结构变动都**必须**递增版本号并编写迁移补丁,否则旧存档加载时会报 `NullReferenceException`。
---
## 8. Inspector 配置指南
### GameSaveManager挂载在 Persistent 场景 `[SERVICES]` 下)
| Inspector 字段 | 赋值 | 说明 |
|---|---|---|
| `_storage` | `LocalFileStorage` 组件引用 | 磁盘 IO 实现;测试时可替换为 InMemorySaveStorage |
| `_hmacKey` | 任意非空字符串(生产环境用随机 UUID| 校验和计算密钥 |
| `_onSaveCompleted`(可选) | `EVT_SaveCompleted`VoidEventChannelSO| 触发存档点 UI 反馈动画 |
### LocalFileStorage与 GameSaveManager 同节点)
| Inspector 字段 | 赋值 | 说明 |
|---|---|---|
| `_saveDirectory` | 留空(默认 `Application.persistentDataPath/saves/`| 可覆盖为绝对路径(仅测试用)|
| `_fileExtension` | `".json"` | 存档文件后缀 |
### SaveServiceAdapter与 GameSaveManager 同节点)
无需配置——它是一个纯粹的接口转发层,仅需保证与 `GameSaveManager` 在同一 GameObject 上。
### 事件频道速查
| SO 名称 | 类型 | 发布者 | 订阅者 |
|---|---|---|---|
| `EVT_SlotConfirmed` | IntEventChannelSO | SaveSlotController | MainMenuController |
| `EVT_SaveCompleted`(可选) | VoidEventChannelSO | GameSaveManager | SavePointControllerUI 动画)|
| `EVT_SceneWorldStateRestored` | VoidEventChannelSO | SceneService | (所有 ISaveable 组件间接响应)|
---
## 9. 扩展指南
### 添加新的存档子数据节点
1. 新建 `[Serializable]` 数据类,如 `SkillsSaveData`
2.`SaveData` 中添加公开字段:`public SkillsSaveData Skills;`
3. 递增 `SaveMigrator.CurrentVersion` 并添加迁移补丁(初始化默认值)。
4. 在需要持久化的组件中实现 `ISaveable.OnSave` / `OnLoad` 读写 `saveData.Skills`
### 接入云存档iCloud / Google Play Games
1. 实现 `ISaveStorage` 接口,新建 `CloudSaveStorage.cs`
2.`GameServiceRegistrar.Awake()` 中根据平台选择注入:
```csharp
#if UNITY_IOS
_saveStorage = GetComponent<iCloudSaveStorage>();
#elif UNITY_ANDROID
_saveStorage = GetComponent<GooglePlaySaveStorage>();
#else
_saveStorage = GetComponent<LocalFileStorage>();
#endif
ServiceLocator.Register<ISaveStorage>(_saveStorage);
```
3. 云存档冲突解决策略建议:以 `Meta.SaveCount` 最大者为准(总是取保存次数更多的版本)。
### 添加存档槽截图预览
在 `GameSaveManager.SaveAsync()` 末尾注入:
```csharp
// 截图后以 Texture2D PNG 压缩存储到 Meta.PreviewImageBase64
var tex = await ScreenCapture.CaptureScreenshotAsTextureAsync();
_current.Meta.PreviewImageBase64 = Convert.ToBase64String(tex.EncodeToPNG());
```
在 `SaveSlotUI` 中使用 `Meta.PreviewImageBase64` 创建 `Sprite` 并显示。
### 实现自动存档(定时 + 过图触发)
```csharp
// 在 PlayerController 的场景加载完成回调中:
private void HandleSceneWorldStateRestored()
{
ServiceLocator.Get<ISaveService>().SaveAsync(_currentSlot);
}
// 在 GameManager 的定时协程中:
private IEnumerator AutoSaveLoop()
{
while (true)
{
yield return new WaitForSeconds(300f); // 每 5 分钟
if (_fsm.Current == GameStateId.Gameplay)
await ServiceLocator.Get<ISaveService>().SaveAsync(_currentSlot);
}
}
```
---
## 10. 商业对标评估
以下评估以"丝之歌"级别商业 2D 动作游戏Steam/主机发行标准)为基准,逐项审核当前存档系统的完备性与商业成熟度。
### ✅ 已达到商业标准
| 维度 | 当前实现 | 评价 |
|---|---|---|
| **三存档槽** | SlotIndex 0/1/2 | 符合行业惯例,足够主流平台需求 |
| **钢铁之魂保护** | IsSteelSoul + HMAC 拒绝加载被篡改存档 | 与 Hollow Knight 同等保护级别 |
| **存档摘要展示** | SlotSummary时长/位置/零珠/血量)| 满足玩家快速辨识存档内容的需求 |
| **存档版本迁移** | SaveMigrator 补丁链 | 支持无缝热更新存档结构 |
| **DLC 扩展点** | `Dictionary<string, JObject>` | 未来 DLC 内容无需修改核心存档类 |
| **NG+ 数据** | `NGPlusSaveData` 节点保留 | 为周目系统预留专用空间 |
| **ISaveable 自注册** | OnEnable/OnDisable 生命周期驱动 | 无需手动在 Manager 中维护组件列表 |
| **存储层抽象** | `ISaveStorage` 接口 | 云存档接入改动范围仅限适配层 |
### ⚠️ 建议改进项
| 维度 | 当前状态 | 建议 |
|---|---|---|
| **存档截图预览** | 未实现 | 主流商业游戏(如《原神》《空洞骑士》续作)普遍提供。建议在 Save 时截图并以 Base64 存入 Meta |
| **自动存档策略** | 未见独立的 AutoSaveService 实现 | 建议以两种触发结合:场景加载完成(过图自动存)+ 定时(每 5 分钟)|
| **存档文件加密** | 当前仅 HMAC 完整性校验,未加密 | PC 平台可接受若有主机认证要求PS/Xbox需配合平台 SDK 云加密 |
| **存档大小监控** | 未见大小上限检测 | 主机平台认证通常要求存档 ≤ 12 MB建议在 SaveAsync 后记录日志文件大小 |
| **QuickSave 槽编号** | 使用 `SlotIndex = 98`(魔法数字)| 建议用具名常量 `SaveSlots.QuickSave = 98` 替换,避免他处误用 |
| **存档损坏降级恢复** | Normal 模式下校验失败仅记录警告 | 建议在校验失败时尝试加载同槽的 `.bak` 备份文件(每次存档前备份一次)|
| **写入原子性** | 直接覆盖目标文件 | 建议先写 `.tmp` 文件,写入成功后再原子重命名,防止写入中断导致存档损坏 |
### ❌ 商业发行前必须补全
| 维度 | 当前状态 | 影响 |
|---|---|---|
| **HMAC 密钥管理** | `_hmacKey` 为 Inspector 可见字段 | 若使用默认值,钢铁之魂保护形同虚设;正式构建前必须在构建管线中注入随机密钥并从 Inspector 中移除 |
| **平台存档路径合规** | 仅实现 `persistentDataPath` 本地存储 | PS/Xbox 认证要求使用平台 SDK 的存档 APIPS: `savedata://`Xbox: `XboxStorage`|
---
## 11. 常见问题排查
### ❌ 加载存档后部分对象状态未恢复(仍显示默认状态)
**原因 1** 目标组件的 `OnEnable` 在 `EVT_SceneWorldStateRestored` 触发**之后**才执行(异步实例化的 Prefab
**解决:** 在组件的 `OnEnable` 中检查 `_registry.IsLoaded`,若为 `true` 则立即调用 `OnLoad(_registry.CurrentData)`。
**原因 2** 忘记在 `OnDisable` 调用 `_registry.Unregister(this)`,导致旧实例残留,新实例的 `OnLoad` 被跳过。
**解决:** 确保每个 `ISaveable.OnEnable` 都对应一个 `OnDisable.Unregister`。
---
### ❌ 点击「继续」后游戏场景加载但世界对象全部重置
**原因:** `MainMenuController.HandleSlotConfirmed` 在调用 `ISaveService.LoadAsync` 之前就发起了 `EVT_SceneLoadRequest`,导致新场景的 `ISaveable` 在 `OnLoad` 之前已触发 `EVT_SceneWorldStateRestored`。
**解决:** 调用顺序必须为:`LoadAsync(slot)` → `await` → `Raise(EVT_SceneLoadRequest)`。
---
### ❌ 存档校验失败:`Invalid checksum` 警告频繁出现
**原因:** 多个编辑器/构建版本使用了不同的 `_hmacKey` 值,旧存档无法通过当前 Key 验证。
**解决:**
1. 开发阶段:统一使用版本控制中记录的固定 Key在 `ProjectSettings` 或 `StreamingAssets` 中管理)。
2. 生产阶段:构建流水线注入唯一 Key每个游戏版本的 Key 不变(只在大版本号更迭时更换)。
---
### ❌ 存档文件体积异常(> 500 KB
**原因:** 某个 ISaveable 在 `OnSave` 中写入了图像数据、大型列表或未过滤的字典。
**解决:** 在 `SaveAsync` 后添加日志:
```csharp
Debug.Log($"[SaveManager] Slot {slot} size: {Encoding.UTF8.GetByteCount(json)} bytes");
```
逐一排查各 `ISaveable.OnSave` 调用前后的数据差量,定位膨胀来源。
---
*文档最后更新2026-06-04*

View File

@@ -0,0 +1,520 @@
# UI 系统架构手册
> 文件位置:`Docs/Guides/05_UISystem_Architecture_Guide.md`
> 版本1.0 · 适用项目zeling_v2
---
## 目录
1. [架构概览](#1-架构概览)
2. [UIManager 面板栈](#2-uimanager-面板栈)
3. [GameState 驱动的 UI 显隐逻辑](#3-gamestate-驱动的-ui-显隐逻辑)
4. [主菜单流程MainMenuController](#4-主菜单流程mainmenucontroller)
5. [存档槽选择面板SaveSlotController](#5-存档槽选择面板saveslotcontroller)
6. [Loading Screen 体验设计](#6-loading-screen-体验设计)
7. [游戏内覆盖层 UIPause / Death / HUD](#7-游戏内覆盖层-uipause--death--hud)
8. [输入焦点管理IFocusable](#8-输入焦点管理ifocusable)
9. [Inspector 配置指南](#9-inspector-配置指南)
10. [扩展指南](#10-扩展指南)
11. [商业对标评估](#11-商业对标评估)
12. [常见问题排查](#12-常见问题排查)
---
## 1. 架构概览
UI 系统采用**面板栈Panel Stack+ 游戏状态驱动**的混合模式,将 UI 生命周期与游戏逻辑彻底解耦:
```
┌────────────────────────────────────────────────────────────────────┐
│ GameManagerFSM
│ └─ Raise EVT_GameStateChanged(GameStateId) │
└────────────────────────┬───────────────────────────────────────────┘
│ (SO 事件频道,跨程序集)
┌────────────────────────▼───────────────────────────────────────────┐
│ UIManagerIUIManager
│ ├─ 面板栈Stack<GameObject>
│ │ ├─ OpenPanel(PanelId / GameObject) │
│ │ └─ CloseTopPanel() │
│ ├─ 状态驱动的根节点HUDRoot / DeathScreenRoot
│ └─ 订阅各 Open/Close 事件频道Pause / Map / Shop / Inventory …)│
└────────────────────────┬───────────────────────────────────────────┘
┌───────────────┼───────────────────────────┐
▼ ▼ ▼
静态面板(预设) Addressable 面板(按需加载) 状态根节点
PauseMenuRoot InventoryPanel异步 HUDRoot
SettingsRoot MapPanel异步 DeathScreenRoot
ShopRoot …
```
**层级与渲染顺序Sort Order**
| 层级 | Sort Order | 说明 |
|---|---|---|
| HUD Canvas | 10 | 永远在游戏世界之上 |
| 菜单 / 面板 Canvas | 20 | 暂停、商店、符文等 |
| Loading Screen Canvas | 99 | 覆盖所有游戏 UI |
| Splash Screen Canvas | 100 | 仅在启动时显示 |
| Scene Fade Overlay Canvas | 110 | 最顶层,场景切换遮罩 |
---
## 2. UIManager 面板栈
### 核心数据结构
```csharp
private Stack<GameObject> _panelStack; // 当前打开的面板顺序
private HashSet<GameObject> _openPanelSet; // O(1) 成员检查
private Dictionary<PanelId, GameObject> _panelRegistry; // 静态面板注册表
private Dictionary<PanelId, AddressablePanelHandle> _addressableHandles; // 异步面板缓存
```
### 面板操作 API
| 方法 | 说明 |
|---|---|
| `OpenPanel(PanelId id)` | 查注册表 → 若为 Addressable 则异步加载 → 激活 → 压栈 |
| `OpenPanel(GameObject panel)` | 直接引用压栈(幂等:已在栈中则不重复压)|
| `CloseTopPanel()` | 弹栈 → 隐藏 → 恢复下层 `IFocusable.OnFocusRestored()` |
| `CloseAllPanels()` | 清空栈,隐藏所有面板 |
| `RegisterPanel(PanelId, GameObject)` | 运行时动态注册(供场景级 UI 使用)|
### 面板栈行为规范
```
初始状态Gameplay栈空HUDRoot 可见
玩家按暂停:
OpenPanel(PanelId.Pause)
Stack → [PauseMenuRoot]
玩家在暂停菜单打开设置:
OpenPanel(PanelId.Settings)
Stack → [PauseMenuRoot, SettingsRoot]
玩家按取消Cancel 输入):
CloseTopPanel() → 隐藏 SettingsRoot恢复 PauseMenuRoot 焦点
Stack → [PauseMenuRoot]
玩家按恢复:
CloseTopPanel() → 隐藏 PauseMenuRoot
Stack → [](空)
GameManager 响应 EVT_ResumeRequested → FSM: Paused → Gameplay
```
> **原则:** 面板栈只管 UI 层级,不管游戏状态。游戏状态转换始终由 `GameManager` 通过 FSM 负责。
> `UIManager.OpenPanel(Pause)` 和 `GameManager.TransitionTo(Paused)` 是两个独立的调用,
> 它们分别由 `EVT_PauseRequested` 触发UIManager 订阅开面板GameManager 订阅切状态)。
---
## 3. GameState 驱动的 UI 显隐逻辑
`UIManager` 订阅 `EVT_GameStateChanged`,根据新状态决定 HUD 和 DeathScreen 的可见性:
```
EVT_GameStateChanged(GameStateId state)
├─ Gameplay / BossFight → HUDRoot.SetActive(true) DeathScreenRoot.SetActive(false)
├─ Dead → HUDRoot.SetActive(false) DeathScreenRoot.SetActive(true)
├─ Paused → 不改变(保持 HUD 可见,仅叠加暂停面板)
├─ MainMenu → HUDRoot.SetActive(false) DeathScreenRoot.SetActive(false)
│ CloseAllPanels()
├─ LoadingScene → HUDRoot.SetActive(false) DeathScreenRoot.SetActive(false)
└─ Cutscene → HUDRoot.SetActive(false)(不显示 UI
```
**UIManager 不处理哪些事情:**
- 不决定是否进入 Paused 状态(那是 GameManager 的职责)
- 不监听场景加载事件(那是 SceneService 的职责)
- 不直接修改 GameStateMachine只通过 Raise 事件频道间接驱动)
---
## 4. 主菜单流程MainMenuController
`MainMenuController` 挂载在 `Scene_MainMenu` 的 Canvas 上,随场景一起加载/卸载。
### 完整状态机MainMenuController 内部)
```
[初始化]
└─ EVT_GameStateChanged(MainMenu) 到达
├─ 播放按钮组入场动画_mainButtonsRect 下移 → 回弹)
└─ RefreshContinueButton()
└─ ISaveService.HasAnySave() → 「继续」按钮是否可点击
─────────────────────────────
按钮点击逻辑:
Btn_NewGame → _saveSlotController.SetMode(NewGame)
_saveSlotPanel.SetActive(true)
Btn_Continue → _saveSlotController.SetMode(Continue)
_saveSlotPanel.SetActive(true)
Btn_Settings → _settingsPanel.SetActive(true)
Btn_Credits → _creditsPanel.SetActive(true)
Btn_Quit → Application.Quit()
─────────────────────────────
EVT_SlotConfirmed(slotIndex) 到达(由 SaveSlotController 发布)
├─ _saveSlotPanel.SetActive(false)
├─ _currentSlot = slotIndex
└─ Raise EVT_SceneLoadRequest({ Key: _firstGameSceneKey, Type: Scene, ShowLoading: true })
└─ 游戏场景加载流程启动(参见 01_BootFlow_Setup_Guide.md 第 2 节)
```
### Inspector 字段速查MainMenuController
| 字段 | 类型 | 说明 |
|---|---|---|
| `_firstGameSceneKey` | `string` | 第一个游戏场景的 Addressable Key**必填**|
| `_onGameStateChanged` | GameStateEventChannelSO | 监听 FSM 状态变化 |
| `_onSceneLoadRequest` | SceneLoadRequestEventChannelSO | 发布场景加载请求 |
| `_onSlotConfirmed` | IntEventChannelSO | 监听存档槽确认 |
| `_mainButtonsGroup` | `CanvasGroup` | 入场动画目标 |
| `_mainButtonsRect` | `RectTransform` | 入场位移动画目标 |
| `_saveSlotPanel` | `GameObject` | 存档槽选择面板根节点 |
| `_settingsPanel` | `GameObject` | 设置面板根节点 |
| `_creditsPanel` | `GameObject` | 制作人员表面板根节点 |
| `_saveSlotController` | `SaveSlotController` | 存档槽逻辑控制器 |
| `_btnNewGame/Continue/Settings/Credits/Quit` | `Button` | 各主按钮引用 |
---
## 5. 存档槽选择面板SaveSlotController
### 模式与交互逻辑
```
SaveSlotController.SetMode(mode)
├─ mode = Continue
│ ├─ 有存档的槽:可点击 → LoadAsync(slot) → Raise EVT_SlotConfirmed(slot)
│ └─ 空槽:灰显不可交互
└─ mode = NewGame
├─ 空槽点击 ────────────────────────────────────► NewGameModeController.Show()
│ 玩家选择普通 / 钢铁之魂
│ → ISaveService.CreateSlot(slot, steelSoul)
│ → Raise EVT_SlotConfirmed(slot)
└─ 有存档的槽点击 → ConfirmDialogController.Show("是否覆盖?")
├─ 确认 ────────────────────► NewGameModeController.Show()(同上)
└─ 取消 ────────────────────► 关闭 ConfirmDialog回到槽列表
```
### SaveSlotUI 卡片渲染
每张存档卡调用 `ISaveService.GetSlotSummaryAsync(i)` 刷新,渲染规则:
| 状态 | 显示内容 |
|---|---|
| `HasData = false` | 「空存档」占位Continue 模式下灰显 |
| `HasData = true, IsSteelSoul = false` | 时长 + 位置 + 零珠 + HP |
| `HasData = true, IsSteelSoul = true` | 同上,叠加钢铁之魂特殊徽章与边框 |
---
## 6. Loading Screen 体验设计
### 视觉层次结构LoadingScreenManager
```
Canvas_Loading (Sort Order 99)
└── LoadingRoot默认 SetActive=false
├── BackgroundImage[] ← 随机显示一张艺术图
├── ProgressBarFill ← Image.Filled, FillMethod=Horizontal
└── TipText ← TextMeshProUGUI显示随机本地化提示
```
### 体验节奏控制
| 参数 | 默认值 | 说明 |
|---|---|---|
| `_minDisplayTime` | `0.5s` | 防止快速设备上的 Loading Screen 一闪而过(视觉抖动)|
| 进度更新频率 | 每帧( `Update` 中 Raise| 进度条连续平滑 |
| 进度范围 | 0 → 0.9Addressables 下载)→ 1.0(加载完成)| 避免进度条长时间卡在 100% |
### 进度数据流
```
SceneLoader每帧轮询 AsyncOperationHandle
└─ Raise EVT_LoadingProgressUpdated(handle.PercentComplete * 0.9f)
└─ LoadingScreenManager.SetProgress(value)
└─ _progressFill.fillAmount = value
加载完成:
SceneLoader.Raise(EVT_LoadingProgressUpdated, 1.0f)
SceneLoader.Raise(EVT_LoadingComplete)
└─ LoadingScreenManager.StartCoroutine(HideAfterMinTime())
└─ yield WaitForSeconds(remainingMinTime)
└─ _loadingRoot.SetActive(false)
```
---
## 7. 游戏内覆盖层 UIPause / Death / HUD
### 暂停菜单PauseMenuController
挂载在 `UIRoot/PauseMenuRoot`,随 Persistent 场景常驻。
| 按钮 | 行为 | 事件 |
|---|---|---|
| 恢复 | Raise EVT_ResumeRequested → GameManager FSM 切回 Gameplay | VoidEventChannelSO |
| 设置 | UIManager.OpenPanel(PanelId.Settings)(面板栈叠加)| — |
| 返回主菜单 | Raise EVT_SceneLoadRequest({ Scene_MainMenu, Scene, NoLoading })| SceneLoadRequestEventChannelSO |
| 退出游戏 | Application.Quit() | — |
### 死亡画面DeathScreenController
```
EVT_GameStateChanged(Dead) 到达
└─ UIManager 激活 DeathScreenRoot
└─ DeathScreenController.OnEnable()
└─ StartCoroutine(ShowAfterDelay(_showDelay=1.5s))
└─ 1.5 秒后:显示复活按钮 + 设置焦点
玩家点击复活按钮:
DeathScreenController.Raise(EVT_DeathScreenConfirmed)
└─ DeathRespawnService.StartRespawnCoroutine()
└─ 从最近检查点复活
```
**`_showDelay` 设计意图:** 给死亡动画(击退、溶解特效)留出播放窗口,避免死亡画面在视觉上覆盖动画高光时刻。
### HUDHUDController
HUD 挂载在 `UIRoot/HUD Canvas`,始终在最低优先级的 UI 层Sort Order 10
GameState → Gameplay / BossFight 时显示,其余状态隐藏。
具体字段配置参见 `Docs/Design/UI_HUD_Spec.md`(美术规范文档)。
---
## 8. 输入焦点管理IFocusable
面板实现 `IFocusable` 接口后,`UIManager.CloseTopPanel()` 会在弹出顶层面板后自动调用下层面板的 `OnFocusRestored()`,将 EventSystem 焦点恢复到该面板的默认第一个可交互元素。
```csharp
public interface IFocusable
{
void OnFocusRestored(); // 设置 EventSystem.SetSelectedGameObject(defaultButton)
}
```
**接入示例PauseMenuController**
```csharp
public class PauseMenuController : MonoBehaviour, IFocusable
{
[SerializeField] private Button _firstButton; // Inspector 中指定第一个按钮
public void OnFocusRestored()
{
EventSystem.current.SetSelectedGameObject(_firstButton.gameObject);
}
}
```
**为什么需要 IFocusable**
| 场景 | 不处理焦点 | 处理焦点 |
|---|---|---|
| 从设置面板退回暂停面板 | 无法通过手柄操作,焦点丢失 | 焦点自动回到暂停菜单的第一个按钮 |
| 死亡画面出现 | 需要手动移动手柄选中复活按钮 | 自动聚焦,可直接按确认键复活 |
| PC 键盘操作 | Tab 键循环焦点失效 | 始终从正确的第一个元素开始循环 |
---
## 9. Inspector 配置指南
### UIManager`UIRoot` GameObjectPersistent 场景)
| Inspector 字段 | 赋值 | 说明 |
|---|---|---|
| `_hudRoot` | `UIRoot/HUD Canvas` | HUD 根节点 |
| `_deathScreenRoot` | `UIRoot/DeathScreen Canvas` | 死亡画面根节点 |
| `_panels` | 数组4项| 静态面板注册Pause / Settings / Map / Shop |
| `_onGameStateChanged` | `EVT_GameStateChanged` | 订阅状态变化 |
| `_onPauseRequested` | `EVT_PauseRequested` | 订阅暂停请求 |
| `_onMapOpen` | `EVT_MapOpen`(可选)| 订阅地图打开请求 |
| `_onShopOpen` | `EVT_ShopOpen`(可选)| 订阅商店打开请求 |
**`_panels` 数组元素格式:**
| PanelId | Root GameObject |
|---|---|
| `Pause` | `UIRoot/PauseMenuRoot` |
| `Settings` | `UIRoot/SettingsRoot` |
| `Map` | `UIRoot/MapRoot` |
| `Shop` | `UIRoot/ShopRoot` |
### 事件频道速查UI 相关)
| SO 名称 | 类型 | 发布者 | 订阅者 |
|---|---|---|---|
| `EVT_GameStateChanged` | GameStateEventChannelSO | GameManager | UIManager / MainMenuController |
| `EVT_PauseRequested` | VoidEventChannelSO | InputReader | GameManager / UIManager |
| `EVT_ResumeRequested` | VoidEventChannelSO | PauseMenuController | GameManager |
| `EVT_DeathScreenConfirmed` | VoidEventChannelSO | DeathScreenController | DeathRespawnService |
| `EVT_SlotConfirmed` | IntEventChannelSO | SaveSlotController | MainMenuController |
| `EVT_MapOpen`(可选) | VoidEventChannelSO | InputReader / HUD 按钮 | UIManager |
| `EVT_ShopOpen`(可选) | VoidEventChannelSO | NPC 交互 | UIManager |
---
## 10. 扩展指南
### 添加新的游戏内面板(如技能树)
1.`Assets/_Game/Scenes/Persistent.unity``UIRoot` 下新建 `SkillTreeRoot`,初始 `SetActive(false)`
2.`PanelId` 枚举中添加 `SkillTree`
3.`UIManager._panels` 数组中注册:`PanelId.SkillTree → SkillTreeRoot`
4. 创建 `EVT_SkillTreeOpen`VoidEventChannelSO并在 `UIManager.OnEnable` 中订阅 `_onSkillTreeOpen → () => OpenPanel(PanelId.SkillTree)`
5.`InputReader` 中添加 `SkillTree` 输入动作,触发时 Raise `EVT_SkillTreeOpen`
### 使用 Addressable 按需加载大型面板
适合一次性使用的大型面板(如藏品图鉴、成就列表),避免 Persistent 场景启动内存占用过高:
1. 将面板 Prefab 标记为 Addressable设置 Address 为 `"UI_Journal"`
2.`UIManager._addressablePanels` 中注册:`PanelId.Journal → Address: "UI_Journal"`
3. 调用 `UIManager.OpenPanel(PanelId.Journal)`——内部自动执行 `Addressables.LoadAssetAsync → Instantiate → 压栈`
4. 已加载的 Handle 会被缓存(`_addressableHandles`),第二次打开无需重新下载。
### 添加过场动画期间的 UI 屏蔽
`UIManager``EVT_GameStateChanged(Cutscene)` 分支:
```csharp
case GameStateId.Cutscene:
_hudRoot.SetActive(false);
CloseAllPanels();
_inputBlocker.SetActive(true); // 全屏透明 Raycast Target屏蔽所有点击输入
break;
```
### 实现「按任意键继续」的过渡画面
`UIManager` 中监听 `EVT_GameStateChanged(LoadingScene)`
```csharp
case GameStateId.LoadingScene:
// 等待输入后才真正触发场景加载(视觉节奏控制)
StartCoroutine(WaitForAnyKeyThenLoad());
break;
```
---
## 11. 商业对标评估
以下评估以"丝之歌"级别商业 2D 动作游戏Steam/主机发行标准)为基准,逐项审核当前 UI 系统的完备性与玩家体验成熟度。
### ✅ 已达到商业标准
| 维度 | 当前实现 | 评价 |
|---|---|---|
| **面板栈管理** | Stack<GameObject> + O(1) 成员检查 | 支持任意深度的嵌套面板,避免重复打开 |
| **状态驱动 UI** | EVT_GameStateChanged 统一驱动 HUD/Death 可见性 | UI 层零侵入游戏逻辑 |
| **组件-事件解耦** | UIManager/MainMenuController 不持有 GameManager 直接引用 | Core 程序集不依赖 UI 程序集(反向依赖零) |
| **Loading 防闪烁** | `_minDisplayTime = 0.5s` | 消除快速设备上的 Loading Screen 闪烁问题 |
| **加载进度可视化** | 进度 0→1 连续更新0.9 上限留出 100% 假象 | 与主流 AAA 游戏相同的用户感知优化 |
| **死亡节奏控制** | `_showDelay = 1.5s` 延迟显示复活按钮 | 保护死亡动画的视觉高光,符合《空洞骑士》级别体验 |
| **Addressable 面板** | 异步加载 + Handle 缓存 | 大型面板不占用启动内存 |
### ⚠️ 建议改进项
| 维度 | 当前状态 | 建议 |
|---|---|---|
| **手柄导航完整性** | IFocusable 接口已定义,但未见所有面板都实现 | 对 Pause / Death / Settings / SaveSlot 的每个面板补全 `OnFocusRestored`,主机认证前必须测试全程无鼠标操作 |
| **输入方案自适应 UI** | 未见键盘 vs 手柄按钮图标自动切换 | 建议监听 `InputSystem.onActionChange` 事件,切换 Sprite Atlas键盘: `[Space]` → 手柄: `[A]`|
| **主菜单背景** | 按钮组有入场动画,但无背景动效 | 商业发布版本通常有视差滚动背景或粒子动效,配合 BGM 形成视听配合 |
| **存档槽截图预览** | 当前槽卡片无截图 | 补全后与 `04_SaveSystem_DataPersistence_Guide.md` 第 9 节「存档截图预览」联动 |
| **过渡动画统一管理** | 各面板自行处理 SetActive无统一的打开/关闭动画 | 建议在 UIManager.OpenPanel / CloseTopPanel 中统一调用 DOTween / LeanTween 淡入淡出,保持全局一致的 UI 节奏感 |
| **Loading 提示文字本地化** | `_tipMessages` 为 string[] 存储 Key需手动填写 | 建议改为 `LocalizedStringTable` 引用,确保多语言运行时不遗漏 |
### ❌ 商业发行前必须补全
| 维度 | 当前状态 | 影响 |
|---|---|---|
| **主机 UI 合规TRC/TCR** | 未见针对 PS5/Xbox Series 的按钮提示图标覆盖 | Sony TRC 要求所有交互提示图标与手柄型号实时匹配DualSense vs DualShock 4 |
| **无障碍选项** | 未见字体大小调整、色盲模式、振动开关等 | Steam 无障碍标签与主机认证的基础要求;建议至少提供字体缩放和高对比模式 |
| **ESC / 返回键全局处理** | 未见统一的 Cancel 输入处理器 | 当前各面板自行响应 Cancel可能出现 ESC 无法关闭某个面板的问题;建议在 UIManager 层统一处理 Cancel → CloseTopPanel |
---
## 12. 常见问题排查
### ❌ 暂停后按 ESC/Cancel 无法关闭设置面板,只能关闭暂停菜单
**原因:** 设置面板没有订阅 `Cancel` 输入动作,`UIManager` 也没有全局 Cancel 处理。
**解决:**`UIManager.Update()` 中添加全局 Cancel 输入监听:
```csharp
private void Update()
{
if (_inputReader.IsCancelPressed && _panelStack.Count > 0)
CloseTopPanel();
}
```
---
### ❌ 从主菜单返回游戏时 HUD 不显示
**原因:** `EVT_GameStateChanged(Gameplay)` 触发时,`UIManager``OnEnable` 尚未执行(场景还在加载)。
**解决:** `UIManager.OnEnable` 必须在 Persistent 场景中运行(它始终存在),不应随主菜单场景卸载。检查 `UIManager` 是否错误地挂载在主菜单场景的 Canvas 上而非 Persistent 场景的 `UIRoot` 上。
---
### ❌ 手柄操作时焦点在面板打开后丢失(无高亮按钮)
**原因:** 面板没有实现 `IFocusable`,或实现了但 `_firstButton` 为空引用。
**解决:**
1. 确认面板组件实现了 `IFocusable`
2. 在 Inspector 中为 `_firstButton` 赋值(面板打开时应选中的默认按钮)。
3. 确保面板在 `SetActive(true)` 后的下一帧 `OnEnable` 调用 `EventSystem.SetSelectedGameObject`(使用 `StartCoroutine` 延迟一帧执行,避免 EventSystem 还未刷新)。
---
### ❌ Addressable 面板第一次打开慢,之后快
**行为正常。** 第一次调用 `Addressables.LoadAssetAsync` 会进行网络/磁盘下载,之后 Handle 被缓存。
如果希望消除首次延迟,可在游戏启动的 `PreloadCoroutine` 中预热特定面板:
```csharp
await Addressables.DownloadDependenciesAsync("UI_Journal");
```
---
### ❌ 主菜单场景卸载后 MainMenuController 事件订阅没有清理,导致空引用报错
**原因:** `OnEnable` 中订阅了 SO 事件频道,但 `OnDisable` 中没有取消订阅,场景卸载后 SO 仍持有对已销毁对象的回调引用。
**解决:** 确保 `MainMenuController.OnDisable` 中对所有 `_onXxx?.Unsubscribe(HandleXxx)` 显式取消订阅,或使用 `EventSubscription` Dispose 模式(框架已提供):
```csharp
private EventSubscription _stateSubscription;
private void OnEnable()
=> _stateSubscription = _onGameStateChanged.Subscribe(HandleGameStateChanged);
private void OnDisable()
=> _stateSubscription?.Dispose();
```
---
*文档最后更新2026-06-04*

View File

@@ -0,0 +1,394 @@
# 地图系统制作手册(策划 / 开发)
> 文件位置:`Docs/Guides/06_MapSystem_Authoring_Guide.md`
> 版本1.0 · 适用项目zeling_v2 · 对标丝之歌Hollow Knight: Silksong的全屏大地图
本手册指导**策划与开发人员**完成银河恶魔城地图系统的全流程制作:从环境装配、单个房间的地图数据制作、世界布局,到本地化 / 输入提示 / 资源合规,并附运行时验证与常见问题排查。
---
## 目录
1. [架构概览](#1-架构概览)
2. [一次性环境准备(每个工程做一次)](#2-一次性环境准备每个工程做一次)
3. [快速开始:为一个房间制作地图(端到端)](#3-快速开始为一个房间制作地图端到端)
4. [工具速查表](#4-工具速查表)
5. [MapRoomDataSO 字段详解](#5-maproomdataso-字段详解)
6. [世界布局与格子校准](#6-世界布局与格子校准)
7. [底图美术管线](#7-底图美术管线)
8. [文字本地化接入](#8-文字本地化接入)
9. [输入提示图标接入](#9-输入提示图标接入)
10. [资源管理合规(必须经 AssetLoader](#10-资源管理合规必须经-assetloader)
11. [运行时验证](#11-运行时验证)
12. [常见问题排查](#12-常见问题排查)
---
## 1. 架构概览
地图系统是**事件驱动 + ServiceLocator 解耦**的,分三层:
```
数据层Edit Time 配置)
├── MapRoomDataSO 每个房间一个RoomId / RegionId / GridPosition / GridSize / RoomOutlineTex / Exits / RoomFlags
└── MapDatabaseSO 全局房间索引(所有 MapRoomDataSO 的数组 + O(1) 空间索引)
服务层Persistent 场景 [Services]boot 注册)
├── MapManager IMapService —— 探索状态Unknown/Explored/Mapped、ISaveable
├── MapPinManager IPinService —— 玩家自定义标记
├── TeleportService ITeleportService —— 传送点解锁/传送
└── MapPlayerTracker IPlayerPositionProvider —— 挂在【玩家】上,世界坐标→房间格;进房广播 EVT_RoomEntered
UI 层Persistent 场景UIManager PanelStack 管理)
└── Map Canvas / MapPanel 全屏大地图:平移/缩放、房间格、当前房间高亮、玩家点、Pin、迷雾、探索进度、区域名、传送选点、关闭提示
```
**核心数据流:**
```
玩家移动
└─ MapPlayerTracker挂玩家按世界坐标算出所在房间格
├─ OnRoomChanged → 小地图/UI 刷新(如启用)
└─ Raise EVT_RoomEntered(roomId)
├─ MapManager → 标记该房间 Explored地图揭示
├─ RoomStreamingManager → 重算流式集
└─ EventChainManager → 触发房间条件
按地图键
└─ Raise EVT_MapOpen
└─ UIManager.OpenPanel(PanelId.Map)
└─ MapPanel 渲染所有房间格(按 MapDatabase + 各房间可见性)
```
> **不使用右上角小地图**:本项目按需求只保留全屏大地图,地图 UI 脚手架不再搭建 MinimapHUD。
---
## 2. 一次性环境准备(每个工程做一次)
地图系统的服务与 UI 都需先装配进 **Persistent 场景**,且**必须保存到磁盘**。打开 `Persistent.unity` 后依次执行:
### 2.1 创建事件频道与基础资产(若尚未创建)
```
菜单栏 → BaseGames → Scene → Setup → Create All Event Channel Assets
菜单栏 → BaseGames → Setup → Create Project Assets
```
确认存在以下事件频道(`Assets/_Game/Data/Events/``EVT_RoomEntered``EVT_MapUpdated``EVT_RegionChanged``EVT_MapOpen``EVT_LanguageChanged``EVT_InputDeviceChanged`
### 2.2 装配地图运行时管理器
```
菜单栏 → BaseGames → Scene → Setup → Scaffold Map Managers
```
`[Persistent]/[Services]` 下创建并绑定:`MapManager`(绑 MapDatabase + EVT_RoomEntered/MapUpdated/RegionChanged`MapPinManager``TeleportService`
### 2.3 装配本地化与输入图标服务
```
菜单栏 → BaseGames → Scene → Setup → Scaffold Localization & Input Services
```
`[Services]` 下创建并绑定:`LocalizationManager`(文字本地化)、`InputDeviceDetector` + `InputIconService`(按键提示图标,绑 EVT_InputDeviceChanged
> ⚠ **没有这一步,运行时所有 UI 文字会显示成本地化 Key如 `MAP_CLOSE_HINT`)、按键图标不显示。**
### 2.4 装配地图 UI
```
菜单栏 → BaseGames → Scene → Setup → Scaffold Map UI
```
创建 `Map Canvas → MapPanel`含探索进度、关闭提示、Tooltip、传送确认框 + MapTeleportConfirmController、HUD 下区域名横幅),并登记 `UIManager._panels[Map]`。同时生成占位预制 `UI_Map_RoomCell` / `UI_Map_Pin` / `UI_Map_ExitConnector``MapPinConfig.asset`
### 2.5 给【玩家】挂 MapPlayerTracker
地图玩家定位与进房自动揭示依赖 `MapPlayerTracker`,它**挂在玩家对象上**(如 `Assets/_Game/Prefabs/Player/Player.prefab` 根节点):
| 字段 | 值 |
|---|---|
| `_playerTransform` | 玩家根 Transform |
| `_databaseOverride` | 与 MapManager 同一个 `MapDatabaseSO`(可留空,运行时从 IMapService 取) |
| `_onRoomEntered` | `EVT_RoomEntered`(进房自动揭示的关键) |
| `_worldUnitsPerCell` | 1 格对应的世界单位(默认 18见 [§6 校准](#6-世界布局与格子校准) |
| `_worldOriginOffset` | 世界原点偏移(默认 (0,0) |
### 2.6 ★ 保存 Persistent 场景(务必)
```
File → Save或 Ctrl+S保存 Persistent.unity
```
> ⚠ **极重要**:上面装配的服务/UI 都是场景对象,**必须保存到磁盘**。否则任何"重载场景"的操作(包括 Room Capture Baker 烘焙时的场景切换/恢复)都会丢失未保存的改动,导致运行时服务消失、地图与文字失效。
---
## 3. 快速开始:为一个房间制作地图(端到端)
`TestRoomA` 为例。前提:房间场景已搭好(含 `RoomController``CameraArea`且环境准备§2已完成并保存。
### 步骤一览
| # | 操作 | 工具 | 产出 |
|---|---|---|---|
| 1 | 创建房间数据 SO | 见下 | `Assets/_Game/Data/Map/Rooms/{RoomId}.asset` |
| 2 | 派生格子尺寸 / 布局位置 | Room Capture Baker / Map Layout Editor | `GridPosition` / `GridSize` |
| 3 | 烘焙底图 | Room Capture Baker | PNG + `RoomOutlineTex` |
| 4 | 加入数据库 | Map Layout Editor / 手动 | `MapDatabase.AllRooms` |
| 5 | 设场景房间 ID | 房间场景 Inspector | `RoomController._roomId` |
| 6 | 实测 | Debug 入口 + Play | 全屏地图渲染 |
### 3.1 创建 MapRoomDataSO
- 路径 / 命名:`Assets/_Game/Data/Map/Rooms/{RoomId}.asset`**RoomId 必须与场景文件名一致**(如 `TestRoomA`)。
- 创建方式:`Project 窗口右键 → Create → BaseGames → World → Map → RoomData`,或由 `SceneScaffoldTools.ScaffoldGameRoom` 搭房间时自动生成模板。
- 填写:`RoomId``RegionId`(如 `FengXian` / `Test`)。`GridPosition/GridSize` 下一步自动派生。
### 3.2 打开地图制作工具并指定数据库
```
菜单栏 → BaseGames → Map → Room Capture Baker
```
`MapDatabase``Assets/_Game/Data/World/Map/MapDatabase.asset`)拖入顶部字段。窗口列出库内所有房间。
### 3.3 派生格子尺寸GridSize
在窗口「格子布局派生」区,设 `世界单位/格``世界原点偏移`**与 MapPlayerTracker 一致**(默认 18 / (0,0)),然后:
- 点某房间行的 **「派生格子」**,或顶部 **「派生全部房间格子布局」**。
- 工具会打开房间场景,取 `CameraArea.VisibleBounds` 并集 ÷ 世界单位/格floor 下界、ceil 上界),写入 `GridPosition` / `GridSize`
> 派生得到的 **GridSize尺寸可直接用****GridPosition位置仅在房间场景本身就摆在世界真实坐标时才正确**。多个房间若各自摆在场景原点附近,派生位置会重叠——此时位置改用 [§6 世界布局](#6-世界布局与格子校准) 手动摆放。
### 3.4 烘焙底图RoomOutlineTex
在 Room Capture Baker
- 确认参数:`输出目录`(默认 `Assets/_Game/Art/Map/RoomCaptures`)、`每世界单位像素``透明背景`(推荐)、`临时全局光`(推荐,避免 URP 2D 离屏渲染偏黑)、`回填 RoomOutlineTex`(推荐)。
- 点房间行 **「烘焙」** 或顶部 **「烘焙全部房间」**。
- 工具会逐房间打开场景、用正交相机按可视范围渲染,输出 `{RoomId}.png` 并回填到 `MapRoomDataSO.RoomOutlineTex`
> 这张 PNG 是**给美术加工的底图**(不是最终美术)。美术在其上描绘 / 风格化成清晰的地图块后,**覆盖同名 PNG** 即自动回到游戏。
### 3.5 加入 MapDatabase
-`Map Layout Editor`(见 §4或在 `MapDatabase` Inspector 的 `AllRooms` 数组中加入该 SO。
- 新建房间 SO 时,若勾选了某个 MapDatabase 为默认库,`MapRoomAutoRegister` 会自动注册。
### 3.6 设置场景房间 ID进房自动揭示
打开房间场景,选中 `RoomController`,在 `_roomId` 填写与 RoomId 一致的值(如 `TestRoomA`)。**保存该场景。**
> 缺这一步:进入该房间不会自动标记为已探索(地图上显示为未知/迷雾)。
### 3.7 实测
见 [§11 运行时验证](#11-运行时验证)。
---
## 4. 工具速查表
| 工具 | 菜单 | 用途 |
|---|---|---|
| **Room Capture Baker** | `BaseGames → Map → Room Capture Baker` | 烘焙房间底图 + **派生 GridPosition/GridSize** |
| **Map Layout Editor** | `BaseGames → Map → Map Layout Editor` | 全局俯视布局:拖拽摆放房间、按区域着色、红色高亮重叠/重复、画出口连线、Play 时叠玩家位置 |
| **MapRoomDataSO 编辑器** | 选中 `MapRoomDataSO` | Scene View 中拖拽角点改 GridPosition/GridSize一键居中 |
| **MapDatabase 编辑器** | 选中 `MapDatabaseSO` | 房间列表、`ValidateAll`(重复 ID / 格子重叠 / 出口悬空)、打开布局编辑器 |
| **Scaffold Map Managers** | `BaseGames → Scene → Setup → Scaffold Map Managers` | 装配 MapManager/MapPinManager/TeleportService |
| **Scaffold Localization & Input Services** | `BaseGames → Scene → Setup → Scaffold Localization & Input Services` | 装配 LocalizationManager/InputDeviceDetector/InputIconService |
| **Scaffold Map UI** | `BaseGames → Scene → Setup → Scaffold Map UI` | 搭建 MapPanel 全屏地图 + 传送确认 + 区域名横幅 |
| **资源用法校验** | `BaseGames → Tools → Validation → Validate Resource Usage` | 扫描禁止的 `Resources.Load` / 散落 `Addressables.*` |
| **调试进首关** | `BaseGames → Debug → Enter First Room (Play)` | Play 模式下绕过菜单直接进首关,用于测地图 |
---
## 5. MapRoomDataSO 字段详解
| 字段 | 类型 | 说明 |
|---|---|---|
| `RoomId` | string | **与场景文件名一致**,全库唯一。空格会被自动修剪 |
| `RegionId` | string | 所属区域(用于区域名横幅与区域探索进度)。建议本地化(见 §8 |
| `DisplayName` | string | 地图 Tooltip 显示名。**填本地化 Key**(如 `ROOM_TESTROOMA_NAME`UI 自动解析;非 Key 则原样显示 |
| `GridPosition` | Vector2Int | 房间在世界地图格上的左下角坐标(单位:格)。可派生或手动布局 |
| `GridSize` | Vector2Int | 房间占据的格数(宽×高),每轴最小 1。建议由工具派生 |
| `RoomOutlineTex` | Texture2D | 房间在地图上显示的底图(截图或美术加工后的图)。空则回退到纯色格 |
| `RoomFlags` | RoomType[Flags] | 房间类型可多选BossRoom/SavePoint/Shop/Merchant/Challenge/TeleportStation。决定地图图标 |
| `MapIconOverride` | Sprite | 自定义图标,覆盖按 RoomFlags 的自动选择 |
| `Exits` | RoomExitData[] | 出口(目标房间 ID / 方向 / 格子位置 / 过渡类型),用于全屏地图画连接线 |
| `EstimatedMemoryKB` | int | 流式内存预算估值(与地图渲染无关) |
---
## 6. 世界布局与格子校准
### 6.1 GridSize 与 GridPosition 的来源
- **GridSize尺寸**:来自房间实际大小,建议用 Room Capture Baker 的「派生格子」自动算。
- **GridPosition位置**:是**世界地图布局的设计决策**——决定房间在整张地图上相对其它房间的位置。
- 若房间场景内容就摆在世界真实坐标,派生即正确。
- 否则(各房间都摆在场景原点附近)→ 用 **Map Layout Editor** 拖拽摆放,或在 SO/Scene View 中手动设置,**确保房间之间不重叠**(用 MapDatabase 的 `ValidateAll` 检查)。
### 6.2 worldUnitsPerCell 校准
`_worldUnitsPerCell`MapPlayerTracker 上,默认 18决定"多少世界单位 = 1 个地图格",直接影响房间在地图上的占格大小与玩家点定位精度。
- **必须三处一致**MapPlayerTracker、Room Capture Baker 的派生参数、以及实际关卡设计。
- 房间占格"偏大/偏小"时,调大/调小该值后**重新派生格子**即可校准。
- `_worldOriginOffset` 用于关卡世界原点不在地图 (0,0) 时对齐。
---
## 7. 底图美术管线
```
策划:搭房间场景(含 CameraArea / Ground Tilemap
→ Room Capture Baker「烘焙」正交相机渲染场景 → 透明底 PNG给美术的底图
→ 美术:在 PNG 上描绘 / 风格化成清晰的地图块(对标丝之歌手绘观感)
→ 覆盖同名 PNG → 运行时由 RoomOutlineTex 自动显示
```
- 底图默认透明背景,便于抠出房间形状。
- 截图偏暗时,提高 Baker 的「全局光强度」。
- 大房间提高「每世界单位像素」或调大「最大边像素」以保证清晰度(注意纹理内存)。
---
## 8. 文字本地化接入
项目使用**自研本地化系统**`BaseGames.Localization`**禁止硬编码面向玩家的文字**。
### 8.1 数据位置与格式
- JSON 表:`Assets/_Game/Data/Localization/{语言}/{表}.json`(经 Addressables地址 `Localization/{语言}/{表}`**不在 Resources**)。
- 语言:`ChineseSimplified` / `English` / `Japanese` / `Korean`。表:`UI` / `Dialogue` / `Quest`
- 格式:`{ "entries": [ { "key": "MAP_CLOSE_HINT", "value": "关闭地图" } ] }`Key 用 `UPPER_SNAKE_CASE`
### 8.2 地图相关 Key已内置于 UI 表,按需补全各语言)
| Key | 含义 |
|---|---|
| `MAP_PROGRESS_GLOBAL` | 全局探索进度格式(如 `已探索 {0:P0}` |
| `MAP_PROGRESS_REGION` | 区域进度格式(如 `{1}{0:P0}` |
| `MAP_CLOSE_HINT` | 关闭地图提示标签 |
| `TELEPORT_CONFIRM_TITLE` / `TELEPORT_CONFIRM_BODY` | 传送确认框标题 / 正文前缀 |
| `CONFIRM_YES` / `CONFIRM_NO` | 确认 / 取消按钮 |
| `REGION_{REGIONID}_NAME` | 区域显示名(区域名横幅 / 进度) |
| `ROOM_{ROOMID}_NAME` | 房间显示名Tooltip填到 `MapRoomDataSO.DisplayName` |
### 8.3 接入方式
- **静态 UI 文本**:在 TMP_Text 上挂 `LocalizedText` 组件,填 `Key`/`Table`,语言切换自动刷新。
- **代码动态文本**`LocalizationManager.Get(key, table)` / `GetFormat(key, table, args)`
- **房间 / 区域名**:把本地化 Key 填进 `MapRoomDataSO.DisplayName` / `RegionDefinitionSO`UI 自动解析。
### 8.4 中文字体CJK
> ⚠ 中文显示成 □ 方块时,是**缺中文 TMP 字体**所致(英文不受影响)。需导入 CJK 字体生成 TMP SDF 字体、创建 `LanguageFontConfigSO``BaseGames → Localization → Language Font Config`),并在 `LocalizedText._fontConfig` 引用。这是字体资产配置,非地图逻辑。
---
## 9. 输入提示图标接入
地图里的操作提示(关闭 / 缩放 / 放置标记等)须用**输入图标系统**(随键鼠/手柄自适应),**不硬编码"按 Tab"之类文字**。
- 提示行结构:`InputIconImage`(按键图标,随设备自适应) + `LocalizedText`(本地化标签)。地图脚手架的关闭提示已是此结构(动作名 `Cancel`)。
- `InputIconImage`ByActionName 模式)填 InputActions 中的动作名(如 `Cancel` / `Interact`),运行时由 `InputIconService` 解析当前设备的按键 Sprite。
- 图标资源4 套 `InputDeviceIconSetSO`(键鼠 / Xbox / PlayStation / Switch**Input Icon Studio** 编辑器工具配置 `绑定路径 → Sprite`,并赋给 `InputIconService` 的对应字段。
> ⚠ 未配置图标集 SO 时,图标位置显示为空白/占位框(标签仍正确)。这是美术资产配置。
---
## 10. 资源管理合规(必须经 AssetLoader
项目统一用 **Addressables****禁止 `Resources.Load`**,且不直接散落调用 `Addressables.*`——一律经门面 `BaseGames.Core.Assets.AssetLoader`
- 地图相关资源(房间底图、本地化表、配置)均已 Addressable 化、经 AssetLoader 加载。
- 提交前运行 `BaseGames → Tools → Validation → Validate Resource Usage` 确认 **0 违规**
- 设计师在 Inspector 拖拽的 `AssetReference` 引用属正常用法,不算违规。
---
## 11. 运行时验证
### 11.1 用调试入口快速进房(推荐)
1. 编辑器进入 **Play**(默认从 Persistent 启动到主菜单)。
2. 菜单 `BaseGames → Debug → Enter First Room (Play)` —— 绕过新游戏菜单,直接加载首关(`Scene_Game_Chapter1`)并生成玩家。
3. 玩家进房 → `MapPlayerTracker` 自动广播 `EVT_RoomEntered` → 房间标记为已探索。
4. 触发 `EVT_MapOpen`(或对应输入)打开全屏地图,应看到:房间格(带底图)、玩家点、探索进度、关闭提示。
### 11.2 通过正式流程
主菜单 → New Game → 选存档槽/模式 → 进入首关 → 游玩进房 → 按地图键看全屏地图(房间随探索逐格揭示)。
### 11.3 检查清单
- [ ] 进房后该房间在地图上由迷雾变为已探索。
- [ ] 房间格显示底图RoomOutlineTex玩家点位置正确。
- [ ] 探索进度文字正确(如 `已探索 X%`),非 Key。
- [ ] 关闭/操作提示显示按键图标 + 本地化标签。
- [ ] 多房间时相对位置正确、无重叠(`ValidateAll` 通过)。
---
## 12. 常见问题排查
### Q运行时 UI 文字显示成 `MAP_CLOSE_HINT` / 进度只显示 `100%` 没有前缀
**原因:** Persistent 场景里**没有 LocalizationManager**(本地化服务未装配或未保存)。
**解决:** 执行 `Scaffold Localization & Input Services` 装配,**并保存 Persistent**。确认 `[Services]` 下存在 `LocalizationManager`
---
### Q装配过的服务运行时又消失了
**原因:** 装配后**未保存 Persistent 场景**;随后任何重载场景的操作(如 Room Capture Baker 烘焙时切换/恢复场景)会从磁盘重载,丢失未保存改动。
**解决:** 装配后**立即保存 Persistent**§2.6)。这是最容易踩的坑。
---
### Q中文显示成 □ 方块(英文正常)
**原因:** 缺中文 TMP 字体(与本地化逻辑无关)。
**解决:** 导入 CJK 字体生成 TMP SDF`LanguageFontConfigSO`,在 `LocalizedText._fontConfig` 引用§8.4)。
---
### Q地图打开后房间是黑的 / 不显示底图
**可能原因 1** 房间未探索 → 显示未知(黑/迷雾)。确认 `RoomController._roomId` 已填且进过该房间。
**可能原因 2** `RoomOutlineTex` 未赋值 → 显示纯色格。用 Room Capture Baker 烘焙并回填。
**可能原因 3** 底图本身偏暗、单房间在地图上占比小。由美术加工底图,或调高 Baker 全局光。
---
### Q多个房间在地图上叠在一起
**原因:** 各房间的 `GridPosition` 重叠(常见于"派生位置"时各房间场景都摆在原点附近)。
**解决:****Map Layout Editor** 拖拽摆放到不重叠的位置;`MapDatabase``ValidateAll` 会红色高亮重叠房间。
---
### Q房间在地图上占格过大/过小
**原因:** `worldUnitsPerCell` 与关卡尺度不匹配。
**解决:** 调整 MapPlayerTracker 与 Room Capture Baker 的 `世界单位/格`**重新派生格子**§6.2)。
---
### Q按键提示图标不显示只有空白框 + 文字)
**原因:** `InputDeviceIconSetSO` 图标集未创建/未配置 Sprite`InputIconService` 未装配。
**解决:** 装配输入服务§2.3);用 Input Icon Studio 配置 4 套图标集并赋给 InputIconService§9
---
### Q打开全屏地图没反应
**可能原因 1** `UIManager._panels` 未登记 `Map`。重跑 `Scaffold Map UI`(会自动登记)。
**可能原因 2** `EVT_MapOpen` 未被任何输入触发。确认输入绑定会 Raise 该频道UIManager 已订阅)。

View File

@@ -0,0 +1,884 @@
# 存档与UI商业化完善计划 — 01v1.1 去重修订版)
> 依据《04_SaveSystem_DataPersistence_Guide》《05_UISystem_Architecture_Guide》商业对标评估结论
> 结合源码深度阅读整理。
> **v1.1 修订**:经逐文件核查,已移除计划中与框架现有实现重复的任务:
> - ✅ **P0-AIFocusable + CloseTopPanel**:接口与 UIManager 机制已完整实现7 个面板已接入;仅 2 个面板有遗漏(降级为小修复)。
> - ✅ **P1-AAutoSaveService**已完整实现8 个事件触发点),计划整节删除。
> - ✅ **P2-ASwitch 支持)**`InputDeviceType.SwitchController` / `InputDeviceDetector` / `InputIconService._switchSet` / `ICN_Switch.asset` 均已完整实现Switch 无需额外开发。
> - ⚠️ **P2-B无障碍/设置)**`SettingsPanelController` 已有 UIScale 滑条与色盲模式 Dropdown但存储层`SettingsSaveData`)缺字段、持久化未接线,需补全存储链路而非从零实现。
---
## 架构关键约束
> ⚠️ **在编写任何代码前,必须理解以下约束,否则将产生运行时错误或与框架冲突。**
### 1. 异步规范
框架**只使用标准 `Task` / `Task<T>`**,不引入 UniTask 或 Cysharp。所有 async 方法遵循:
- 公开 API`async Task` / `async Task<bool>`
- UI 回调(不等待结果):`_ = SomeAsync()``RunFireAndForget(task, context)` 模式
- OnEnable 中启动的异步操作:必须配 `CancellationTokenSource _cts`,在 OnDisable 中 `Cancel()` + `Dispose()`
### 2. 事件订阅规范
所有事件频道订阅必须使用 `CompositeDisposable _subs` 管理:
```csharp
private readonly CompositeDisposable _subs = new();
private void OnEnable()
{
_onGameStateChanged?.Subscribe(HandleGameStateChanged).AddTo(_subs);
}
private void OnDisable()
{
_subs.Clear();
}
```
`CompositeDisposable` 定义在 `BaseGames.Core.Events`。**不允许**直接使用 `+=` / `-=` 操作 SO 事件频道(`Dispose` 模式是硬约束)。
### 3. 字段命名规范
| 类型 | 规则 | 示例 |
|------|------|------|
| `[SerializeField] private` | `_camelCase` | `_defaultFocusButton` |
| 公开属性 | `PascalCase` | `public bool IsCapturing` |
| 事件处理方法 | `Handle{Name}` | `HandleGameStateChanged` |
| 按钮点击处理方法 | `On{Name}Clicked` | `OnNewGameClicked` |
| 异步方法 | 末尾加 `Async` | `RefreshAsync` / `SaveAsync` |
### 4. 服务注册约束
新增服务必须在 `GameServiceRegistrar.Awake()` 中注册(`[DefaultExecutionOrder(-2000)]`),其他系统的 Awake 中通过 `ServiceLocator.Get<TInterface>()``ServiceLocator.GetOrDefault<TInterface>()` 获取。**不允许**直接持有 MonoBehaviour 引用作为跨场景服务使用。
### 5. SaveableMonoBehaviour 基类
需要持久化的组件应继承 `SaveableMonoBehaviour``BaseGames.Core.Save`)而非手动管理注册,该基类已封装 OnEnable/OnDisable 自注册逻辑。
### 6. ISaveable 生命周期约束
`OnSave` 只写、`OnLoad` 只读,两者**不能**播放音效、触发动画、启动 Coroutine 或执行 GameObject 操作。这些副作用须移至 `OnLoad` 后首帧的 Update / 单次 Coroutine 中处理。
---
## 优先级总览
| 编号 | 特性 | 优先级 | 工作量 | 影响范围 | 状态 |
|------|------|--------|--------|---------|------|
| P0-A | IFocusable 补全2 个面板遗漏)| **P0** | XS | DeathScreenController / SaveSlotController | ⚠️ 部分遗漏 |
| P0-B | Cancel / ESC 全局关闭逻辑 | **P0** | S | UIManager + InputReaderSO | ❌ 未实现 |
| P0-C | HMAC 密钥安全管理 | **P0** | S | GameSaveManager + 构建流程 | ❌ 硬编码 |
| ~~P1-A~~ | ~~AutoSaveService~~ | — | — | — | ✅ **已完整实现,删除** |
| P1-B | 存档槽区域背景图 | P1 | M | RegionDefinitionSO / SlotSummary / SaveSlotUI | ❌ 未实现 |
| P2-A | 输入图标系统优化(修复冗余刷新 + null 重试)| P2 | XS | InputIconImage / InputDeviceIconSwitcher | ⚠️ 已实现,含 Switch小修复 |
| P2-B | 设置持久化UIScale + 色盲模式存储链路)| P2 | S | SettingsSaveData / SettingsPanelController | ⚠️ UI 已有,存储缺失 |
---
## P0-AIFocusable 补全(小修复)
> **现有状态(已核查):**
> `IFocusable` 接口、`UIManager.CloseTopPanel()` 焦点恢复机制**已完整实现**。
> `PauseMenuController`、`SettingsPanelController`、`InventoryHubPanel`、`ItemInventoryPanel`、`QuestLogPanel`、`CharmEquipPanel`、`ShopPanelUI` 共 7 个面板已正确接入。
> **仅以下 2 个面板遗漏,需补充。**
### A-1DeathScreenController — 在 ShowAfterDelay 末尾补充焦点设置
**文件:** `Assets/_Game/Scripts/UI/HUD/DeathScreenController.cs`(具体路径以实际为准)
`DeathScreenController` 不走面板栈(由 `UIManager` 按 GameState 直接 `SetActive`),无需实现 `IFocusable`
只需在已有的 `ShowAfterDelay` 协程末尾追加焦点设置:
```csharp
// 在 ShowAfterDelay 协程末尾(显示按钮之后)追加:
if (EventSystem.current != null && _btnRespawn != null)
EventSystem.current.SetSelectedGameObject(_btnRespawn.gameObject);
```
**Inspector 配置:** 无需额外字段,`_btnRespawn``DeathScreenController` 中已有。
### A-2SaveSlotController — 实现 IFocusable
**文件:** `Assets/_Game/Scripts/UI/Menus/SaveSlotController.cs`
`SaveSlotController` 经由面板栈管理,需实现 `IFocusable` 以便从设置面板/确认对话框返回时自动恢复焦点:
在类声明处追加接口(`:` 后加 `, IFocusable`),并追加字段与方法:
```csharp
// ★ 追加字段(在 [Header("Event Channels")] 之前):
[Header("焦点")]
[SerializeField] private Button _defaultFocusButton; // Inspector赋值第一个存档槽的 SelectButton
// ★ 实现接口:
public void OnFocusRestored()
{
// 延一帧:避免 EventSystem 在同帧 SetActive 后尚未刷新
StartCoroutine(RestoreFocusNextFrame());
}
private System.Collections.IEnumerator RestoreFocusNextFrame()
{
yield return null;
if (EventSystem.current != null && _defaultFocusButton != null)
EventSystem.current.SetSelectedGameObject(_defaultFocusButton.gameObject);
}
```
**Inspector 配置:** `_defaultFocusButton``SaveSlotPanel/_slotUIs[0]/_selectButton`
### 验证标准
| 场景 | 预期行为 |
|------|---------|
| 玩家死亡,死亡画面出现 1.5s 后 | 复活按钮自动获得焦点,可直接按手柄 A 复活 |
| 主菜单 → 存档槽 → 覆盖确认对话框 → 取消 | 焦点回到存档槽第一个按钮 |
---
## P0-BCancel / ESC 全局关闭逻辑
### 目标
用户按 ESC键盘或手柄 B/Circle 时UIManager 自动关闭当前栈顶面板,不需要每个面板各自监听 Cancel 输入。
### 2-B-1添加 EVT_UICancelPressed 事件频道
`BaseGames → Tools → Create Event Channel Assets` 中或手动创建:
```
Assets/_Game/Data/Events/UI/EVT_UICancelPressed.asset (VoidEventChannelSO)
```
> 使用独立 SO 而非直接引用 `InputReaderSO`,保持 UIManager 对输入系统的解耦。
### 2-B-2InputReaderSO 发布 Cancel 事件
**文件:** `Assets/_Game/Scripts/Input/InputReaderSO.cs`
**命名空间:** `BaseGames.Input`(或对应命名空间)
在已有的 `UICancel` 输入动作回调中追加频道发布:
```csharp
[Header("UI Cancel 频道")]
[SerializeField] private BaseGames.Core.Events.VoidEventChannelSO _onUICancelPressed;
// 已有的 UICancel 输入动作回调(方法名可能为 OnUICancelPerformed 或类似):
private void OnUICancelPerformed(InputAction.CallbackContext context)
{
// 现有逻辑(如果有)保持不变
_onUICancelPressed?.Raise();
}
```
**Inspector 配置:** `InputReaderSO._onUICancelPressed``EVT_UICancelPressed.asset`
### 2-B-3UIManager 订阅并全局处理
**文件:** `Assets/_Game/Scripts/UI/UIManager.cs`
在现有 `[SerializeField]` 区追加字段(在 Event Channels Header 下):
```csharp
[SerializeField] private VoidEventChannelSO _onUICancelPressed;
```
`OnEnable` 中追加订阅(`_subs` 已存在):
```csharp
_onUICancelPressed?.Subscribe(HandleUICancelPressed).AddTo(_subs);
```
追加处理方法:
```csharp
private void HandleUICancelPressed()
{
if (_panelStack.Count > 0)
CloseTopPanel();
}
```
**Inspector 配置:** `UIManager._onUICancelPressed``EVT_UICancelPressed.asset`
> **为何不在 Update() 中轮询 Input.GetKeyDown(KeyCode.Escape)**
> 项目使用 Unity InputSystem`InputReaderSO`),不混用旧 Input API。
> 通过 SO 事件频道发布 Cancel 信号,保持 UIManager 与 InputSystem 解耦。
### 验证标准
| 场景 | 预期行为 |
|------|---------|
| 面板栈为空时按 ESC | 无反应(不报错)|
| 暂停面板打开时按 ESC | 暂停面板关闭焦点恢复P0-A 联动)|
| 设置面板叠加在暂停面板上时按 ESC | 关闭设置面板,暂停面板恢复焦点 |
| 死亡画面时按 ESC | 无反应DeathScreen 不在面板栈中,独立状态节点)|
---
## P0-CHMAC 密钥安全管理
### 目标
将当前硬编码于 `GameSaveManager.cs` 的 HMAC 密钥 `"ZelingV2SaveIntegrity_v2_9a3f7c1b"` 移出源代码,通过构建流水线注入,避免密钥随代码仓库泄露。
### 3-C-1新建 SaveSecurityConfig ScriptableObject
**文件:** `Assets/_Game/Scripts/Core/Save/SaveSecurityConfig.cs`
**命名空间:** `BaseGames.Core.Save`
```csharp
using UnityEngine;
namespace BaseGames.Core.Save
{
// 不使用 CreateAssetMenu — 由构建脚本创建,不暴露给策划
public sealed class SaveSecurityConfig : ScriptableObject
{
// 不加 [SerializeField],防止在普通 Inspector 中显示
// 使用 internal 允许 Editor 构建脚本访问
[HideInInspector]
public string HmacKey = string.Empty;
}
}
```
**资产路径:** `Assets/_Game/Data/Core/SaveSecurityConfig.asset`
**Resources 路径:** `Assets/Resources/SaveSecurityConfig.asset`(用于同步加载,不走 Addressables
> 使用 `Resources` 而非 Addressables`GameServiceRegistrar.Awake()` 是同步方法,
> Addressables 的异步加载不适用于此阶段。`Resources.Load<T>` 在 Awake 中安全。
### 3-C-2在 GameSaveManager 中使用配置
**文件:** `Assets/_Game/Scripts/Core/Save/GameSaveManager.cs`
将现有硬编码密钥替换为从 `SaveSecurityConfig` 加载:
```csharp
// 原代码(需删除):
// private const string HmacSecret = "ZelingV2SaveIntegrity_v2_9a3f7c1b";
// ★ 替换为:
private static string _hmacSecret;
// 在 Initialize() 方法中(由 GameServiceRegistrar.Awake 调用)加载配置:
public void Initialize(ISaveStorage storage)
{
_storage = storage;
// ★ 新增:加载密钥配置
var cfg = Resources.Load<SaveSecurityConfig>("SaveSecurityConfig");
_hmacSecret = (cfg != null && !string.IsNullOrEmpty(cfg.HmacKey))
? cfg.HmacKey
: "ZelingV2SaveIntegrity_v2_9a3f7c1b_FALLBACK"; // 开发期兜底
if (cfg == null || string.IsNullOrEmpty(cfg.HmacKey))
Debug.LogWarning("[SaveSecurity] ⚠ SaveSecurityConfig 未找到或密钥为空,使用开发期兜底密钥。正式构建前必须修复。");
}
```
### 3-C-3Editor 构建脚本自动注入密钥
**文件:** `Assets/_Game/Scripts/Editor/Build/SaveKeyInjector.cs`
**命名空间:** `BaseGames.Editor.Build`
```csharp
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using BaseGames.Core.Save;
using System;
using UnityEngine;
namespace BaseGames.Editor.Build
{
public class SaveKeyInjector : IPreprocessBuildWithReport
{
public int callbackOrder => 0;
public void OnPreprocessBuild(BuildReport report)
{
// 方案 A从环境变量读取CI/CD 流水线注入)
var key = Environment.GetEnvironmentVariable("ZELING_SAVE_HMAC_KEY");
// 方案 B从项目本地密钥文件读取不提交到 Git
if (string.IsNullOrEmpty(key))
{
const string keyFilePath = "Assets/_Game/Data/Core/.save_key";
if (System.IO.File.Exists(keyFilePath))
key = System.IO.File.ReadAllText(keyFilePath).Trim();
}
if (string.IsNullOrEmpty(key))
{
Debug.LogWarning("[SaveKeyInjector] 未找到 HMAC 密钥,构建将使用开发期兜底密钥。");
return;
}
// 写入 Resources 资产
const string assetPath = "Assets/Resources/SaveSecurityConfig.asset";
var cfg = AssetDatabase.LoadAssetAtPath<SaveSecurityConfig>(assetPath);
if (cfg == null)
{
cfg = ScriptableObject.CreateInstance<SaveSecurityConfig>();
AssetDatabase.CreateAsset(cfg, assetPath);
}
cfg.HmacKey = key;
EditorUtility.SetDirty(cfg);
AssetDatabase.SaveAssets();
Debug.Log("[SaveKeyInjector] ✅ HMAC 密钥已注入 SaveSecurityConfig。");
}
}
}
```
**.gitignore 追加(防止密钥文件提交):**
```
Assets/_Game/Data/Core/.save_key
Assets/_Game/Data/Core/.save_key.meta
```
### 验证标准
| 场景 | 预期行为 |
|------|---------|
| 本地开发(无密钥文件)| Console 输出警告,使用兜底密钥,游戏正常运行 |
| 创建 `.save_key` 文件 | 构建时 Console 输出 `✅ HMAC 密钥已注入` |
| 设置环境变量 `ZELING_SAVE_HMAC_KEY` | CI 构建自动注入,无需本地文件 |
| 不同密钥加载旧存档 | Normal 模式警告但允许SteelSoul 模式:拒绝加载 |
---
## ~~P1-AAutoSaveService~~ ✅ 已完整实现,无需开发
> **核查结论:** `Assets/_Game/Scripts/Core/AutoSaveService.cs` 已完整实现125 行),
> 挂载在 Persistent 场景中,以事件驱动方式触发自动存档。
> **触发点8 个):** SceneLoaded / BossFightEnded / AbilityUnlocked / ShopPurchase / CollectiblePickup / MaxHPContainerPickedUp / DoorOpened / QuestStateChanged。
> 内置防抖(`_cooldownSeconds = 2f``IsEnabled` 开关可在教程/过场段临时禁用。
> **本节已从开发计划中删除,下方直接进入 P1-B。**
---
## P1-B存档槽区域背景图
### 目标
存档槽卡片根据存档点所属区域(`RegionDefinitionSO`)显示对应的美术背景图,替代纯文本的区域名称展示,提升玩家辨识存档内容的直观性。
**方案:** 通过已有的 `RegionDefinitionSO``BaseGames.Progression`)扩展一个 `saveSlotBackground` Sprite 字段;新增 `RegionRegistrySO` 支持按场景名反查区域;`SlotSummary` 增加 `RegionId``SaveSlotUI` 根据 `RegionId` 查表并显示背景图。
### 2-B-1RegionDefinitionSO 追加背景图字段
**文件:** `Assets/_Game/Scripts/Progression/RegionDefinitionSO.cs`
`[Header("Map")]` 下追加(与 `mapIconSprite` 同组):
```csharp
[Header("存档槽展示")]
[Tooltip("存档槽卡片背景图,建议尺寸与卡片比例一致(如 480×270")]
public Sprite saveSlotBackground;
```
> **美术规范:** 背景图放于 `Assets/_Game/Art/UI/SaveSlot/` 下,
> 命名规则:`SaveSlot_BG_{RegionId}.png`(如 `SaveSlot_BG_Cave.png`)。
> 建议尺寸480×27016:9Import SettingsSpriteCompressionNormal Quality。
### 2-B-2RegionRegistrySO — 场景名反查区域
**文件:** `Assets/_Game/Scripts/Progression/RegionRegistrySO.cs`
**命名空间:** `BaseGames.Progression`
```csharp
using System.Collections.Generic;
using UnityEngine;
namespace BaseGames.Progression
{
/// <summary>
/// 全局区域注册表 SO。
/// 收录项目中所有 RegionDefinitionSO支持按场景名反查所属区域。
/// 资产路径Assets/_Game/Data/Progression/RegionRegistry.asset
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Progression/RegionRegistry",
fileName = "RegionRegistry")]
public class RegionRegistrySO : ScriptableObject
{
[SerializeField] private RegionDefinitionSO[] _regions;
// 缓存,首次查询时构建
private Dictionary<string, RegionDefinitionSO> _sceneToRegion;
/// <summary>根据场景名SaveMeta.SavePointId 所在的场景)查找所属区域;未找到返回 null。</summary>
public RegionDefinitionSO FindBySceneName(string sceneName)
{
if (string.IsNullOrEmpty(sceneName)) return null;
BuildCacheIfNeeded();
_sceneToRegion.TryGetValue(sceneName, out var region);
return region;
}
/// <summary>根据 regionId 直接查找;未找到返回 null。</summary>
public RegionDefinitionSO FindById(string regionId)
{
if (string.IsNullOrEmpty(regionId)) return null;
if (_regions == null) return null;
foreach (var r in _regions)
if (r != null && r.regionId == regionId) return r;
return null;
}
private void BuildCacheIfNeeded()
{
if (_sceneToRegion != null) return;
_sceneToRegion = new Dictionary<string, RegionDefinitionSO>(
System.StringComparer.OrdinalIgnoreCase);
if (_regions == null) return;
foreach (var region in _regions)
{
if (region == null) continue;
if (region.roomSceneNames != null)
foreach (var scene in region.roomSceneNames)
if (!string.IsNullOrEmpty(scene))
_sceneToRegion[scene] = region;
if (!string.IsNullOrEmpty(region.bossSceneName))
_sceneToRegion[region.bossSceneName] = region;
}
}
// 编辑器下资产重新导入时清理缓存
private void OnValidate() => _sceneToRegion = null;
}
}
```
**资产路径:** `Assets/_Game/Data/Progression/RegionRegistry.asset`
**配置步骤:**
1. 在 Project 窗口右键 → Create → BaseGames/Progression/RegionRegistry保存为 `RegionRegistry.asset`
2. 在 Inspector 的 `_regions` 数组中注册所有 `RegionDefinitionSO` 资产(`Assets/_Game/Data/Progression/Regions/`
3. 每新增区域 SO 时同步补充到此数组
### 2-B-3SlotSummary 追加区域字段
**文件:** `Assets/_Game/Scripts/Core/Save/SaveData.cs``SlotSummary` 类)
在现有字段末尾追加SlotSummary 是非序列化纯内存类,改动不影响存档文件):
```csharp
public class SlotSummary
{
// 现有字段(保持不变):
// public int SlotIndex; public float Playtime; public string LastSaved;
// public string SceneName; public string ActiveFormId;
// public int CurrentLingZhu; public int MaxHP; public bool IsSteelSoul;
// ★ 新增:
/// <summary>存档点所在区域 ID对应 RegionDefinitionSO.regionId无区域时为 null。</summary>
public string RegionId;
}
```
### 2-B-4GameSaveManager.GetSlotSummaryAsync 填充 RegionId
**文件:** `Assets/_Game/Scripts/Core/Save/GameSaveManager.cs`
`GetSlotSummaryAsync` 已通过 `JObject` 部分解析 JSON 获取 `Meta` 字段。
在已有的 `summary.SceneName = metaObj[...].Value<string>()` 行之后追加:
```csharp
// 根据 SceneName 反查区域 ID通过 RegionRegistrySO不加载完整存档
// RegionRegistrySO 由 GameSaveManager.Initialize 时一次性加载并缓存
summary.RegionId = _regionRegistry?.FindBySceneName(summary.SceneName)?.regionId;
```
`GameSaveManager` 中追加字段与初始化:
```csharp
// ★ 新增字段(序列化,由 GameServiceRegistrar 或 Inspector 赋值)
[SerializeField] private RegionRegistrySO _regionRegistry;
```
> **为何在 GameSaveManager 而非 SaveSlotUI 做查找:**
> `GetSlotSummaryAsync` 已走部分 JSON 解析,是填充摘要数据的唯一位置;
> UI 层(`SaveSlotUI`)不应持有 Progression 程序集引用(避免 `BaseGames.UI → BaseGames.Progression` 正向依赖扩大)。
> `RegionId`string通过 `SlotSummary` 传递给 UIUI 再通过自持有的 `RegionRegistrySO` SerializeField 引用查背景图——两层都只持有 string保持解耦。
### 2-B-5SaveSlotUI 显示区域背景图
**文件:** `Assets/_Game/Scripts/UI/Menus/SaveSlotController.cs``SaveSlotUI` 类内)
在现有 `[Header("槽位状态")]` 之前追加字段:
```csharp
[Header("区域背景图")]
[SerializeField] private Image _backgroundImage; // 卡片背景 Image 组件
[SerializeField] private RegionRegistrySO _regionRegistry; // 与 GameSaveManager 共用同一 SO 资产
[SerializeField] private Sprite _fallbackBackground; // 无区域时的默认背景(如通用迷雾图)
```
> **程序集引用**`SaveSlotUI` 所在的 `BaseGames.UI.asmdef` 已引用 `BaseGames.Progression`(确认 `.asmdef` 中包含),
> 若未包含,在 `BaseGames.UI.asmdef` 的 `references` 数组中添加 `"BaseGames.Progression"`。
在现有 `Refresh(SlotSummary summary, SaveSlotPanelMode mode)` 方法末尾追加:
```csharp
// ★ 新增:区域背景图
RefreshBackground(summary);
```
追加私有方法:
```csharp
private void RefreshBackground(SlotSummary summary)
{
if (_backgroundImage == null) return;
Sprite bg = null;
if (summary != null && !string.IsNullOrEmpty(summary.RegionId))
bg = _regionRegistry?.FindById(summary.RegionId)?.saveSlotBackground;
_backgroundImage.sprite = bg != null ? bg : _fallbackBackground;
_backgroundImage.enabled = _backgroundImage.sprite != null;
}
```
### 2-B-6Inspector 配置
| 组件 | 字段 | 赋值 |
|------|------|------|
| `GameSaveManager`Persistent 场景)| `_regionRegistry` | `RegionRegistry.asset` |
| `SaveSlotUI[0/1/2]`MainMenu 场景)| `_backgroundImage` | 各卡片的 Background Image 组件 |
| `SaveSlotUI[0/1/2]` | `_regionRegistry` | `RegionRegistry.asset`(同一 SO|
| `SaveSlotUI[0/1/2]` | `_fallbackBackground` | `SaveSlot_BG_Default.png`(默认背景)|
> **提示:** `RegionRegistry.asset` 被 GameSaveManager 和所有 SaveSlotUI 共用,
> Inspector 引用同一个 SO 实例即可运行时缓存仅构建一次OnValidate 会清理)。
### 验证标准
| 场景 | 预期行为 |
|------|---------|
| 存档点在 Cave 区域 | 存档槽显示 `SaveSlot_BG_Cave.png` |
| 存档点不在任何已注册区域 | 显示 `_fallbackBackground`(默认图)|
| 空槽(无存档)| `summary == null`,显示默认图或不显示 |
| 新增区域后忘记添加到 RegionRegistry | `RegionId = null`,显示默认图,不崩溃 |
| 区域 SO 的 `saveSlotBackground` 未填写 | `bg = null`,显示默认图,不崩溃 |
---
## P2-A输入图标系统优化现有实现审查
### 现有实现评估
经源码核查,项目**已完整实现**四设备输入图标系统,涵盖:
| 文件 | 职责 | 状态 |
|------|------|------|
| `InputDeviceType.cs` | 设备类型枚举KB、Xbox、PS、Switch| ✅ 完整 |
| `InputDeviceDetector.cs` | 监听 InputSystem 原始事件识别设备Raise `InputDeviceTypeEventChannelSO` | ✅ 完整 |
| `IInputIconService.cs` | 服务接口,支持改键跟随 | ✅ 完整 |
| `InputIconService.cs` | 接口实现4 图标集 + 方案过滤 + `OnIconSetChanged` C# 事件 | ✅ 完整 |
| `InputIconImage.cs` | UI 组件,`ByActionName`(跟随改键)/ `ByBindingPath`(固定路径)双模式 | ✅ 完整 |
| `InputDeviceIconSwitcher.cs` | 设备切换时调用 `InputIconImage.RefreshAll()` | ⚠️ 冗余,见下方 |
| `ICN_Keyboard/Xbox/PlayStation/Switch.asset` | 4 套图标集资产 | ✅ 已创建 |
**使用方式(已可直接使用):**
在任意 `Image` 组件上挂载 `InputIconImage`,选择查询模式:
- `ByActionName`(推荐):填入 Action 名称如 `"Interact"`,自动跟随当前设备和改键
- `ByBindingPath`:填入固定路径如 `<Keyboard>/space`,用于教程/装饰性说明
### 发现问题双重刷新double refresh
**根因:** `InputIconImage.OnEnable` 订阅了 `IInputIconService.OnIconSetChanged` C# 事件,设备切换时服务直接调用各实例的 `Refresh()`。同时 `InputDeviceIconSwitcher.OnDeviceChanged` 也调用 `InputIconImage.RefreshAll()`,导致每次设备切换 **每个 InputIconImage 被 Refresh 两次**
```
设备切换 → InputDeviceDetector → Raise _onDeviceChanged
├─ InputIconService.HandleDeviceChanged()
│ 更新 _activeSet → 触发 OnIconSetChanged → 每个 InputIconImage.Refresh() ← 第1次
└─ InputDeviceIconSwitcher.OnDeviceChanged()
→ InputIconImage.RefreshAll()
→ 每个 InputIconImage.Refresh() ← 第2次冗余
```
双重刷新本身不影响正确性Refresh 是幂等操作),但在大量 InputIconImage 存在时(教程界面、操作提示密集 HUD会造成无效 CPU 开销。
**次要问题:** `InputIconImage.OnEnable` 通过 `ServiceLocator.GetOrDefault<IInputIconService>()` 获取服务,若组件在服务注册前 Enable边缘情况`_iconService``null` 且后续不会重试——`RefreshAll()` 时也不会补救,导致该图标永久空白。
### 2-A-1修复双重刷新
**方案:** 移除 `InputDeviceIconSwitcher` 中的 `RefreshAll()` 调用。
`InputIconImage` 已通过订阅 `OnIconSetChanged` 自主刷新,`InputDeviceIconSwitcher` 无需再重复驱动。
**文件:** `Assets/_Game/Scripts/UI/InputDeviceIconSwitcher.cs`
```csharp
// 修改前:
private void OnDeviceChanged(InputDeviceType _)
{
InputIconImage.RefreshAll(); // ← 删除此行
}
// 修改后:
private void OnDeviceChanged(InputDeviceType _)
{
// InputIconImage 已通过 IInputIconService.OnIconSetChanged 事件自主刷新,
// 无需在此处再次调用 RefreshAll()。
// InputDeviceIconSwitcher 保留用于将来可能挂载其他设备切换响应逻辑。
}
```
> `InputDeviceIconSwitcher` 组件本身保留,不删除:
> 其 `_onDeviceChanged` 订阅保持完整的事件链路;将来若需要在设备切换时做其他 UI 响应(如切换操作提示文本、播放反馈动画),仍在此处扩展,而非直接修改 `InputIconService`。
### 2-A-2修复 InputIconImage 服务为 null 时的静默失败
**文件:** `Assets/_Game/Scripts/UI/InputDeviceIconSwitcher.cs``InputIconImage` 类内)
`Refresh()` 方法开头追加服务重试逻辑:
```csharp
public void Refresh()
{
if (_image == null) return;
// ★ 修复:服务为 null 时重试(处理组件在服务注册前 Enable 的边缘情况)
if (_iconService == null)
{
_iconService = ServiceLocator.GetOrDefault<IInputIconService>();
if (_iconService != null)
_iconService.OnIconSetChanged += Refresh; // 补订阅
}
// 原有逻辑保持不变...
Sprite sprite = null;
if (_mode == LookupMode.ByActionName && !string.IsNullOrEmpty(_actionName))
sprite = _iconService?.GetActionIcon(_actionName);
else if (_mode == LookupMode.ByBindingPath && !string.IsNullOrEmpty(_bindingPath))
sprite = _iconService?.GetPathIcon(_bindingPath);
if (sprite != null) { _image.sprite = sprite; _image.enabled = true; }
else _image.enabled = false;
}
```
### 验证标准
| 场景 | 预期行为 |
|------|---------|
| 键盘操作时 | InputIconImage 显示键盘图标 |
| 插入 Xbox 手柄并按键 | 图标自动切换为 Xbox 图标Console 无重复 Refresh 日志 |
| 插入 PS 手柄并按键 | 图标切换为 PlayStation 图标DualSense/DualShock 均识别)|
| 插入 Switch Pro Controller 并按键 | 图标切换为 Switch 图标 |
| 玩家改键后 | ByActionName 模式自动显示新绑定按键图标 |
| 图标集某按键 Sprite 未配置 | Image 自动隐藏(`enabled = false`),不报空引用 |
### 图标集资产配置规范
图标集资产已存在于 `Assets/_Game/Data/UI/InputIcons/`,美术补全时遵循:
```
ICN_Keyboard.asset → 每个 InputAction 在键盘上的绑定路径 → Sprite
路径格式:<Keyboard>/space, <Keyboard>/e, <Mouse>/leftButton ...
ICN_Xbox.asset → <Gamepad>/buttonSouth (A), /buttonNorth (Y),
/buttonWest (X), /buttonEast (B),
/leftTrigger, /rightTrigger, /leftShoulder, /rightShoulder,
/start, /select, /leftStickPress, /rightStickPress ...
ICN_PlayStation.asset → 路径与 Xbox 相同(<Gamepad>/buttonSouth 等),但 Sprite 换为 ✕/△/□/○ 图标
ICN_Switch.asset → 路径与 Xbox 相同Sprite 换为 A/B/X/YSwitch 布局)图标
```
> PS 和 Switch 图标集与 Xbox 使用**相同的 BindingPath**`<Gamepad>/buttonSouth` 等),
> 图标切换仅替换 Sprite路径查找逻辑不变。这是 InputIconService 的设计意图。
---
## P2-B设置持久化UIScale + 色盲模式)
> **现有状态(已核查):**
> `SettingsPanelController` 已有 `_uiScaleSlider`0.81.5 范围)和 `_colorblindDropdown`None/Prot/Deut/Trit控件并实现 `SetUIScale` / `SetColorblindMode` 回调。
> **缺失**`SettingsSaveData` 无对应字段,数值不持久化,每次重启复位。
> **本节目标**:补全存储层,将现有 UI 控件的值接入 ISaveable 存档系统。
### B-1SettingsSaveData 追加存储字段
**文件:** `Assets/_Game/Scripts/Core/Save/SaveData.cs``SettingsSaveData` 类)
在现有 `Language` 字段末尾追加(字段名与 `SettingsPanelController` 内部变量对齐):
```csharp
[Serializable]
public class SettingsSaveData
{
public string Language = "zh-CN"; // 现有字段
// ★ 新增:
[Range(0.8f, 1.5f)] public float UIScale = 1.0f; // 对应 _uiScaleSlider 范围
public int ColorblindMode = 0; // 0=None,1=Prot,2=Deut,3=Trit
public bool ScreenShake = true; // 对应 _screenShakeToggle顺便补全
}
```
> **SaveMigrator 版本递增**:追加字段后必须在 `SaveMigrator.cs` 中递增 `CurrentVersion` 并添加迁移补丁(初始化为默认值),否则旧存档加载时字段为 null/0 可能与滑条范围不符。
### B-2SettingsPanelController 接入 ISaveable
**文件:** `Assets/_Game/Scripts/UI/Menus/SettingsPanelController.cs`
`SettingsPanelController` 已继承 `SaveableMonoBehaviour`(核查确认)则直接补全 `OnSave` / `OnLoad`;若未继承,在类声明处追加 `, ISaveable` 并手动管理注册(参考 P0-A 约束规范)。
**追加 OnSave / OnLoad**
```csharp
public override void OnSave(SaveData saveData)
{
saveData.Settings.UIScale = _uiScaleSlider != null ? _uiScaleSlider.value : 1.0f;
saveData.Settings.ColorblindMode = _colorblindDropdown != null ? _colorblindDropdown.value : 0;
saveData.Settings.ScreenShake = _screenShakeToggle != null && _screenShakeToggle.isOn;
}
public override void OnLoad(SaveData saveData)
{
// 仅更新内存状态,不触发 UI 副作用OnLoad 约束)
_pendingUIScale = saveData.Settings.UIScale;
_pendingColorblind = saveData.Settings.ColorblindMode;
_pendingScreenShake = saveData.Settings.ScreenShake;
_settingsPendingApply = true;
}
```
在已有的 `OnEnable`(面板打开时)中,从 pending 值刷新滑条位置:
```csharp
private void OnEnable()
{
// 现有订阅逻辑...
// ★ 追加:从存档还原 UI 控件初始值
if (_settingsPendingApply)
{
if (_uiScaleSlider != null) _uiScaleSlider.SetValueWithoutNotify(_pendingUIScale);
if (_colorblindDropdown != null) _colorblindDropdown.SetValueWithoutNotify(_pendingColorblind);
if (_screenShakeToggle != null) _screenShakeToggle.SetIsOnWithoutNotify(_pendingScreenShake);
_settingsPendingApply = false;
// 立即应用效果(面板激活后)
SetUIScale(_pendingUIScale);
SetColorblindMode(_pendingColorblind);
}
}
// 追加字段:
private float _pendingUIScale = 1.0f;
private int _pendingColorblind = 0;
private bool _pendingScreenShake = true;
private bool _settingsPendingApply;
```
> **SetValueWithoutNotify 用意**:避免滑条 `onValueChanged` 事件在 OnLoad 阶段触发,符合 ISaveable 规范OnLoad 不触发副作用)。
### B-3SaveMigrator 版本补丁
**文件:** `Assets/_Game/Scripts/Core/Save/SaveMigrator.cs`
在迁移链末尾追加(假设当前版本为 `"2.2"`,则升为 `"2.3"`
```csharp
// 版本 2.2 → 2.3:为 SettingsSaveData 补充 UIScale / ColorblindMode / ScreenShake
if (IsVersionBelow(data.Meta.Version, "2.3"))
{
data.Settings ??= new SettingsSaveData();
// 字段有默认值构造函数已覆盖,无需显式赋值
data.Meta.Version = "2.3";
}
```
### 验证标准
| 场景 | 预期行为 |
|------|---------|
| 调大 UIScale 滑条 → 存档 → 重启 | 重启后滑条位置和 UI 缩放与调整后一致 |
| 切换色盲模式 → 存档 → 重启 | 色盲模式保持 |
| 旧版存档(无 UIScale 字段)| SaveMigrator 迁移后补默认值,不报错 |
---
## 资产路径规范
```
Assets/_Game/
├── Scripts/Core/Save/
│ └── SaveSecurityConfig.cs ← P0-C新建
├── Scripts/Progression/
│ └── RegionRegistrySO.cs ← P1-B新建
├── Scripts/Editor/Build/
│ └── SaveKeyInjector.cs ← P0-C新建
├── Data/Core/
│ └── .save_key ← P0-C不提交 Git本地密钥文件
├── Data/Events/UI/
│ └── EVT_UICancelPressed.asset ← P0-B新建VoidEventChannelSO
├── Data/Progression/
│ ├── RegionRegistry.asset ← P1-B新建收录所有 RegionDefinitionSO
│ └── Regions/
│ └── Region_*.asset ← 现有,补填 saveSlotBackground 字段
├── Art/UI/SaveSlot/
│ ├── SaveSlot_BG_Default.png ← P1-B美术制作默认背景
│ └── SaveSlot_BG_{RegionId}.png ← P1-B每区域一张480×270Sprite
├── Art/UI/InputIcons/ ← 已存在
│ ├── ICN_Keyboard.asset ← 现有,美术补全缺失路径映射
│ ├── ICN_Xbox.asset ← 现有,美术补全
│ ├── ICN_PlayStation.asset ← 现有,美术补全
│ └── ICN_Switch.asset ← 现有Switch 已完整实现),美术补全
└── Resources/
└── SaveSecurityConfig.asset ← P0-C开发期提交空占位构建时注入密钥
已存在,无需新建:
Core/AutoSaveService.cs ✅ 已实现
UI/IFocusable.cs ✅ 已实现
UI/InputDeviceDetector.cs ✅ 包含 Switch 检测
UI/InputIconService.cs ✅ 包含 _switchSet
UI/InputDeviceType.csSwitchController ✅ 已实现
UI/Menus/SettingsPanelController.cs ✅ UIScale+色盲控件已有,补存储层
```
---
## 实现顺序
| 阶段 | 编号 | 内容 | 新建文件 | 修改文件 |
|------|------|------|---------|---------|
| 1 | P0-A | DeathScreenController 焦点 + SaveSlotController IFocusable | — | DeathScreenController.cs, SaveSlotController.cs |
| 1 | P0-B | Cancel/ESC 全局关闭 | EVT_UICancelPressed.asset | InputReaderSO.cs, UIManager.cs |
| 2 | P0-C | HMAC 密钥外部化 | SaveSecurityConfig.cs, SaveKeyInjector.cs, Resources/SaveSecurityConfig.asset | GameSaveManager.cs |
| 3 | P1-B | RegionRegistrySO + RegionDefinitionSO 背景字段 | RegionRegistrySO.cs, RegionRegistry.asset | RegionDefinitionSO.cs, SaveData.cs, GameSaveManager.cs, SaveSlotController.cs |
| 3 | P1-B | 美术配合:各区域 SO 填写 saveSlotBackground | — | Region_*.asset |
| 4 | P2-A | 输入图标系统修复(双重刷新 + null 重试)| — | InputDeviceIconSwitcher.csInputIconImage 类内)|
| 4 | P2-A | 美术配合:补全 4 套图标集(含 Switch缺失路径映射 | — | ICN_Keyboard/Xbox/PlayStation/Switch.asset |
| 5 | P2-B | 设置持久化SettingsSaveData + SettingsPanelController ISaveable| — | SaveData.cs, SettingsPanelController.cs, SaveMigrator.cs |
---
## 待确认项
| # | 问题 | 影响范围 |
|---|------|---------|
| Q1 | Cancel 按键优先级:部分界面(如过场对话框)不希望被 ESC 关闭,是否需要 `IUnclosable` 标记接口? | UIManager |
| Q2 | AutoSaveService 自动存档是否需要 HUD 视觉反馈(右下角短暂存档图标)?`EVT_SaveCompleted` 已存在,只需 HUD 订阅 | AutoSaveService + HUD |
| Q3 | 存档槽背景图卡片比例16:9 / 4:3 / 自由?影响美术制作规范(建议确认后统一 480×270 或其他)| 美术 / UI 设计 |
| Q4 | 区域背景图是否区分 Boss 战场景与普通房间?(同区域 Boss 房可能希望显示不同背景)| RegionDefinitionSO 是否需要 `bossSceneBackground` 独立字段 |
| Q5 | UIScale 应用粒度:全局 `CanvasScaler` 统一缩放,还是仅影响特定 TMP 字号?| SettingsPanelController + Canvas 配置 |
| Q6 | `SettingsPanelController` 是否已继承 `SaveableMonoBehaviour`?影响 P2-B 的接入写法 | 确认后去掉 P2-B 的条件分支 |

Binary file not shown.