地图系统
This commit is contained in:
299
Docs/Guides/03_Persistent_Scene_Setup_Guide.md
Normal file
299
Docs/Guides/03_Persistent_Scene_Setup_Guide.md
Normal 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 [HUDController:UI 资源绑定](#44-hudcontroller-ui-资源绑定)
|
||||
- 4.5 [AudioManager:AudioMixer 指定](#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
|
||||
→ SceneFadeController(Persistent 场景)
|
||||
→ 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(全屏黑色)+ CanvasGroup(alpha 0)
|
||||
│ 需在此 Canvas 上设置 Sort Order 高于所有其他 UI Canvas
|
||||
├── FeedbackFadeOut ← SceneFeedback + MMF_Player(target: ScreenOverlay.CanvasGroup)
|
||||
└── FeedbackFadeIn ← SceneFeedback + MMF_Player(target: 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 HUDController:UI 资源绑定
|
||||
|
||||
`HUDController` 依赖较多图标/文本/进度条 Prefab,均需在 Inspector 手动绑定。
|
||||
具体字段参见 `Assets/_Game/Scripts/UI/HUD/HUDController.cs` 中的 `[SerializeField]` 注释。
|
||||
|
||||
> HUD 资源较多,建议参考美术规范文档 `Docs/Design/UI_HUD_Spec.md`(如已存在)进行配置。
|
||||
|
||||
---
|
||||
|
||||
### 4.5 AudioManager:AudioMixer 指定
|
||||
|
||||
**节点:** `[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 / LoadNewScene(Addressables)
|
||||
│
|
||||
├─ 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`。
|
||||
|
||||
---
|
||||
|
||||
### Q:UIManager 的 `_panels` 数组为空
|
||||
|
||||
**原因:** 可能在旧版本的 Persistent 场景上重新运行了脚手架(幂等逻辑生效,但 `_panels` 已存在数据)。
|
||||
**解决:** 在 Inspector 中展开 `UIManager._panels`,确认 4 个条目(Pause / Settings / Map / Shop)及其 `root` 引用均正确指向对应的 GameObject。
|
||||
|
||||
---
|
||||
|
||||
### Q:暂停菜单打开后按钮没有响应
|
||||
|
||||
**可能原因 1:** `_onResumeRequested` 未绑定(脚手架未找到对应 SO)。检查 `PauseMenuController` Inspector。
|
||||
**可能原因 2:** `EventSystem` 缺失。确认 Persistent 场景中存在 `EventSystem` GameObject(脚手架会自动创建)。
|
||||
553
Docs/Guides/04_SaveSystem_DataPersistence_Guide.md
Normal file
553
Docs/Guides/04_SaveSystem_DataPersistence_Guide.md
Normal 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_SlotConfirmed(IntEventChannelSO)
|
||||
┌────────────────────▼──────────────────────────────────────┐
|
||||
│ 服务接口层(ISaveService / ISaveableRegistry) │
|
||||
│ SaveServiceAdapter — 将 MonoBehaviour API 包装为接口 │
|
||||
└────────────────────┬──────────────────────────────────────┘
|
||||
│ ServiceLocator<ISaveService>.Get()
|
||||
┌────────────────────▼──────────────────────────────────────┐
|
||||
│ 管理器层(GameSaveManager) │
|
||||
│ 协调 ISaveable 集合、调用 ISaveStorage、管理内存状态 │
|
||||
└────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────▼──────────────────────────────────────┐
|
||||
│ 存储层(ISaveStorage / LocalFileStorage) │
|
||||
│ 负责实际的磁盘 IO(JSON 文件,可替换为云存档) │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
|
||||
横切关注点:
|
||||
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` | 0–2 为普通槽,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-SHA256(Base64),用于完整性校验 |
|
||||
| `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. 遍历 _saveables(HashSet<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 职责分离
|
||||
|
||||
```
|
||||
SaveSlotController(ScriptableObject 事件驱动)
|
||||
│
|
||||
├─ 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 | SavePointController(UI 动画)|
|
||||
| `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 云加密 |
|
||||
| **存档大小监控** | 未见大小上限检测 | 主机平台认证通常要求存档 ≤ 1–2 MB,建议在 SaveAsync 后记录日志文件大小 |
|
||||
| **QuickSave 槽编号** | 使用 `SlotIndex = 98`(魔法数字)| 建议用具名常量 `SaveSlots.QuickSave = 98` 替换,避免他处误用 |
|
||||
| **存档损坏降级恢复** | Normal 模式下校验失败仅记录警告 | 建议在校验失败时尝试加载同槽的 `.bak` 备份文件(每次存档前备份一次)|
|
||||
| **写入原子性** | 直接覆盖目标文件 | 建议先写 `.tmp` 文件,写入成功后再原子重命名,防止写入中断导致存档损坏 |
|
||||
|
||||
### ❌ 商业发行前必须补全
|
||||
|
||||
| 维度 | 当前状态 | 影响 |
|
||||
|---|---|---|
|
||||
| **HMAC 密钥管理** | `_hmacKey` 为 Inspector 可见字段 | 若使用默认值,钢铁之魂保护形同虚设;正式构建前必须在构建管线中注入随机密钥并从 Inspector 中移除 |
|
||||
| **平台存档路径合规** | 仅实现 `persistentDataPath` 本地存储 | PS/Xbox 认证要求使用平台 SDK 的存档 API(PS: `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*
|
||||
520
Docs/Guides/05_UISystem_Architecture_Guide.md
Normal file
520
Docs/Guides/05_UISystem_Architecture_Guide.md
Normal 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. [游戏内覆盖层 UI(Pause / 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 生命周期与游戏逻辑彻底解耦:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ GameManager(FSM) │
|
||||
│ └─ Raise EVT_GameStateChanged(GameStateId) │
|
||||
└────────────────────────┬───────────────────────────────────────────┘
|
||||
│ (SO 事件频道,跨程序集)
|
||||
┌────────────────────────▼───────────────────────────────────────────┐
|
||||
│ UIManager(IUIManager) │
|
||||
│ ├─ 面板栈(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.9(Addressables 下载)→ 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. 游戏内覆盖层 UI(Pause / 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` 设计意图:** 给死亡动画(击退、溶解特效)留出播放窗口,避免死亡画面在视觉上覆盖动画高光时刻。
|
||||
|
||||
### HUD(HUDController)
|
||||
|
||||
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` GameObject,Persistent 场景)
|
||||
|
||||
| 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*
|
||||
394
Docs/Guides/06_MapSystem_Authoring_Guide.md
Normal file
394
Docs/Guides/06_MapSystem_Authoring_Guide.md
Normal 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 已订阅)。
|
||||
884
Docs/Plan/存档与UI商业化完善计划-01.md
Normal file
884
Docs/Plan/存档与UI商业化完善计划-01.md
Normal file
@@ -0,0 +1,884 @@
|
||||
# 存档与UI商业化完善计划 — 01(v1.1 去重修订版)
|
||||
|
||||
> 依据《04_SaveSystem_DataPersistence_Guide》《05_UISystem_Architecture_Guide》商业对标评估结论,
|
||||
> 结合源码深度阅读整理。
|
||||
> **v1.1 修订**:经逐文件核查,已移除计划中与框架现有实现重复的任务:
|
||||
> - ✅ **P0-A(IFocusable + CloseTopPanel)**:接口与 UIManager 机制已完整实现,7 个面板已接入;仅 2 个面板有遗漏(降级为小修复)。
|
||||
> - ✅ **P1-A(AutoSaveService)**:已完整实现(8 个事件触发点),计划整节删除。
|
||||
> - ✅ **P2-A(Switch 支持)**:`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-A:IFocusable 补全(小修复)
|
||||
|
||||
> **现有状态(已核查):**
|
||||
> `IFocusable` 接口、`UIManager.CloseTopPanel()` 焦点恢复机制**已完整实现**。
|
||||
> `PauseMenuController`、`SettingsPanelController`、`InventoryHubPanel`、`ItemInventoryPanel`、`QuestLogPanel`、`CharmEquipPanel`、`ShopPanelUI` 共 7 个面板已正确接入。
|
||||
> **仅以下 2 个面板遗漏,需补充。**
|
||||
|
||||
### A-1:DeathScreenController — 在 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-2:SaveSlotController — 实现 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-B:Cancel / 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-2:InputReaderSO 发布 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-3:UIManager 订阅并全局处理
|
||||
|
||||
**文件:** `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-C:HMAC 密钥安全管理
|
||||
|
||||
### 目标
|
||||
|
||||
将当前硬编码于 `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-3:Editor 构建脚本自动注入密钥
|
||||
|
||||
**文件:** `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-A:AutoSaveService~~ ✅ 已完整实现,无需开发
|
||||
|
||||
> **核查结论:** `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-1:RegionDefinitionSO 追加背景图字段
|
||||
|
||||
**文件:** `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×270(16:9),Import Settings:Sprite,Compression:Normal Quality。
|
||||
|
||||
### 2-B-2:RegionRegistrySO — 场景名反查区域
|
||||
|
||||
**文件:** `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-3:SlotSummary 追加区域字段
|
||||
|
||||
**文件:** `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-4:GameSaveManager.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` 传递给 UI,UI 再通过自持有的 `RegionRegistrySO` SerializeField 引用查背景图——两层都只持有 string,保持解耦。
|
||||
|
||||
### 2-B-5:SaveSlotUI 显示区域背景图
|
||||
|
||||
**文件:** `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-6:Inspector 配置
|
||||
|
||||
| 组件 | 字段 | 赋值 |
|
||||
|------|------|------|
|
||||
| `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/Y(Switch 布局)图标
|
||||
```
|
||||
|
||||
> PS 和 Switch 图标集与 Xbox 使用**相同的 BindingPath**(`<Gamepad>/buttonSouth` 等),
|
||||
> 图标切换仅替换 Sprite,路径查找逻辑不变。这是 InputIconService 的设计意图。
|
||||
|
||||
---
|
||||
|
||||
## P2-B:设置持久化(UIScale + 色盲模式)
|
||||
|
||||
> **现有状态(已核查):**
|
||||
> `SettingsPanelController` 已有 `_uiScaleSlider`(0.8–1.5 范围)和 `_colorblindDropdown`(None/Prot/Deut/Trit)控件并实现 `SetUIScale` / `SetColorblindMode` 回调。
|
||||
> **缺失**:`SettingsSaveData` 无对应字段,数值不持久化,每次重启复位。
|
||||
> **本节目标**:补全存储层,将现有 UI 控件的值接入 ISaveable 存档系统。
|
||||
|
||||
### B-1:SettingsSaveData 追加存储字段
|
||||
|
||||
**文件:** `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-2:SettingsPanelController 接入 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-3:SaveMigrator 版本补丁
|
||||
|
||||
**文件:** `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×270,Sprite)
|
||||
├── 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.cs(SwitchController) ✅ 已实现
|
||||
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.cs(InputIconImage 类内)|
|
||||
| 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.
Reference in New Issue
Block a user