地图系统

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

View File

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

View File

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

View File

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

View File

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