地图系统
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 已订阅)。
|
||||
Reference in New Issue
Block a user