Files
zeling_v2/Docs/Plan/存档与UI商业化完善计划-01.md
2026-06-05 18:41:33 +08:00

885 lines
37 KiB
Markdown
Raw Permalink Blame History

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