# 存档与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`**,不引入 UniTask 或 Cysharp。所有 async 方法遵循: - 公开 API:`async Task` / `async Task` - 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()` 或 `ServiceLocator.GetOrDefault()` 获取。**不允许**直接持有 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` 在 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"); _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(assetPath); if (cfg == null) { cfg = ScriptableObject.CreateInstance(); 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 { /// /// 全局区域注册表 SO。 /// 收录项目中所有 RegionDefinitionSO,支持按场景名反查所属区域。 /// 资产路径:Assets/_Game/Data/Progression/RegionRegistry.asset /// [CreateAssetMenu(menuName = "BaseGames/Progression/RegionRegistry", fileName = "RegionRegistry")] public class RegionRegistrySO : ScriptableObject { [SerializeField] private RegionDefinitionSO[] _regions; // 缓存,首次查询时构建 private Dictionary _sceneToRegion; /// 根据场景名(SaveMeta.SavePointId 所在的场景)查找所属区域;未找到返回 null。 public RegionDefinitionSO FindBySceneName(string sceneName) { if (string.IsNullOrEmpty(sceneName)) return null; BuildCacheIfNeeded(); _sceneToRegion.TryGetValue(sceneName, out var region); return region; } /// 根据 regionId 直接查找;未找到返回 null。 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( 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; // ★ 新增: /// 存档点所在区域 ID,对应 RegionDefinitionSO.regionId;无区域时为 null。 public string RegionId; } ``` ### 2-B-4:GameSaveManager.GetSlotSummaryAsync 填充 RegionId **文件:** `Assets/_Game/Scripts/Core/Save/GameSaveManager.cs` `GetSlotSummaryAsync` 已通过 `JObject` 部分解析 JSON 获取 `Meta` 字段。 在已有的 `summary.SceneName = metaObj[...].Value()` 行之后追加: ```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`:填入固定路径如 `/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()` 获取服务,若组件在服务注册前 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(); 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 路径格式:/space, /e, /leftButton ... ICN_Xbox.asset → /buttonSouth (A), /buttonNorth (Y), /buttonWest (X), /buttonEast (B), /leftTrigger, /rightTrigger, /leftShoulder, /rightShoulder, /start, /select, /leftStickPress, /rightStickPress ... ICN_PlayStation.asset → 路径与 Xbox 相同(/buttonSouth 等),但 Sprite 换为 ✕/△/□/○ 图标 ICN_Switch.asset → 路径与 Xbox 相同,Sprite 换为 A/B/X/Y(Switch 布局)图标 ``` > PS 和 Switch 图标集与 Xbox 使用**相同的 BindingPath**(`/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 的条件分支 |