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