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

37 KiB
Raw Blame History

存档与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 方法遵循:

  • 公开 APIasync Task / async Task<bool>
  • UI 回调(不等待结果):_ = SomeAsync()RunFireAndForget(task, context) 模式
  • OnEnable 中启动的异步操作:必须配 CancellationTokenSource _cts,在 OnDisable 中 Cancel() + Dispose()

2. 事件订阅规范

所有事件频道订阅必须使用 CompositeDisposable _subs 管理:

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 基类

需要持久化的组件应继承 SaveableMonoBehaviourBaseGames.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() 焦点恢复机制已完整实现
PauseMenuControllerSettingsPanelControllerInventoryHubPanelItemInventoryPanelQuestLogPanelCharmEquipPanelShopPanelUI 共 7 个面板已正确接入。
仅以下 2 个面板遗漏,需补充。

A-1DeathScreenController — 在 ShowAfterDelay 末尾补充焦点设置

文件: Assets/_Game/Scripts/UI/HUD/DeathScreenController.cs(具体路径以实际为准)

DeathScreenController 不走面板栈(由 UIManager 按 GameState 直接 SetActive),无需实现 IFocusable
只需在已有的 ShowAfterDelay 协程末尾追加焦点设置:

// 在 ShowAfterDelay 协程末尾(显示按钮之后)追加:
if (EventSystem.current != null && _btnRespawn != null)
    EventSystem.current.SetSelectedGameObject(_btnRespawn.gameObject);

Inspector 配置: 无需额外字段,_btnRespawnDeathScreenController 中已有。

A-2SaveSlotController — 实现 IFocusable

文件: Assets/_Game/Scripts/UI/Menus/SaveSlotController.cs

SaveSlotController 经由面板栈管理,需实现 IFocusable 以便从设置面板/确认对话框返回时自动恢复焦点:

在类声明处追加接口(: 后加 , IFocusable),并追加字段与方法:

// ★ 追加字段(在 [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 配置: _defaultFocusButtonSaveSlotPanel/_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 输入动作回调中追加频道发布:

[Header("UI Cancel 频道")]
[SerializeField] private BaseGames.Core.Events.VoidEventChannelSO _onUICancelPressed;

// 已有的 UICancel 输入动作回调(方法名可能为 OnUICancelPerformed 或类似):
private void OnUICancelPerformed(InputAction.CallbackContext context)
{
    // 现有逻辑(如果有)保持不变
    _onUICancelPressed?.Raise();
}

Inspector 配置: InputReaderSO._onUICancelPressedEVT_UICancelPressed.asset

2-B-3UIManager 订阅并全局处理

文件: Assets/_Game/Scripts/UI/UIManager.cs

在现有 [SerializeField] 区追加字段(在 Event Channels Header 下):

[SerializeField] private VoidEventChannelSO _onUICancelPressed;

OnEnable 中追加订阅(_subs 已存在):

_onUICancelPressed?.Subscribe(HandleUICancelPressed).AddTo(_subs);

追加处理方法:

private void HandleUICancelPressed()
{
    if (_panelStack.Count > 0)
        CloseTopPanel();
}

Inspector 配置: UIManager._onUICancelPressedEVT_UICancelPressed.asset

为何不在 Update() 中轮询 Input.GetKeyDown(KeyCode.Escape)
项目使用 Unity InputSystemInputReaderSO),不混用旧 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

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 而非 AddressablesGameServiceRegistrar.Awake() 是同步方法,
Addressables 的异步加载不适用于此阶段。Resources.Load<T> 在 Awake 中安全。

3-C-2在 GameSaveManager 中使用配置

文件: Assets/_Game/Scripts/Core/Save/GameSaveManager.cs

将现有硬编码密钥替换为从 SaveSecurityConfig 加载:

// 原代码(需删除):
// 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

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 = 2fIsEnabled 开关可在教程/过场段临时禁用。
本节已从开发计划中删除,下方直接进入 P1-B。


P1-B存档槽区域背景图

目标

存档槽卡片根据存档点所属区域(RegionDefinitionSO)显示对应的美术背景图,替代纯文本的区域名称展示,提升玩家辨识存档内容的直观性。

方案: 通过已有的 RegionDefinitionSOBaseGames.Progression)扩展一个 saveSlotBackground Sprite 字段;新增 RegionRegistrySO 支持按场景名反查区域;SlotSummary 增加 RegionIdSaveSlotUI 根据 RegionId 查表并显示背景图。

2-B-1RegionDefinitionSO 追加背景图字段

文件: Assets/_Game/Scripts/Progression/RegionDefinitionSO.cs

[Header("Map")] 下追加(与 mapIconSprite 同组):

[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

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.csSlotSummary 类)

在现有字段末尾追加SlotSummary 是非序列化纯内存类,改动不影响存档文件):

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>() 行之后追加:

// 根据 SceneName 反查区域 ID通过 RegionRegistrySO不加载完整存档
// RegionRegistrySO 由 GameSaveManager.Initialize 时一次性加载并缓存
summary.RegionId = _regionRegistry?.FindBySceneName(summary.SceneName)?.regionId;

GameSaveManager 中追加字段与初始化:

// ★ 新增字段(序列化,由 GameServiceRegistrar 或 Inspector 赋值)
[SerializeField] private RegionRegistrySO _regionRegistry;

为何在 GameSaveManager 而非 SaveSlotUI 做查找:
GetSlotSummaryAsync 已走部分 JSON 解析,是填充摘要数据的唯一位置;
UI 层(SaveSlotUI)不应持有 Progression 程序集引用(避免 BaseGames.UI → BaseGames.Progression 正向依赖扩大)。
RegionIdstring通过 SlotSummary 传递给 UIUI 再通过自持有的 RegionRegistrySO SerializeField 引用查背景图——两层都只持有 string保持解耦。

2-B-5SaveSlotUI 显示区域背景图

文件: Assets/_Game/Scripts/UI/Menus/SaveSlotController.csSaveSlotUI 类内)

在现有 [Header("槽位状态")] 之前追加字段:

[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.asmdefreferences 数组中添加 "BaseGames.Progression"

在现有 Refresh(SlotSummary summary, SaveSlotPanelMode mode) 方法末尾追加:

// ★ 新增:区域背景图
RefreshBackground(summary);

追加私有方法:

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 配置

组件 字段 赋值
GameSaveManagerPersistent 场景) _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边缘情况_iconServicenull 且后续不会重试——RefreshAll() 时也不会补救,导致该图标永久空白。

2-A-1修复双重刷新

方案: 移除 InputDeviceIconSwitcher 中的 RefreshAll() 调用。
InputIconImage 已通过订阅 OnIconSetChanged 自主刷新,InputDeviceIconSwitcher 无需再重复驱动。

文件: Assets/_Game/Scripts/UI/InputDeviceIconSwitcher.cs

// 修改前:
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.csInputIconImage 类内)

Refresh() 方法开头追加服务重试逻辑:

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 已有 _uiScaleSlider0.81.5 范围)和 _colorblindDropdownNone/Prot/Deut/Trit控件并实现 SetUIScale / SetColorblindMode 回调。
缺失SettingsSaveData 无对应字段,数值不持久化,每次重启复位。
本节目标:补全存储层,将现有 UI 控件的值接入 ISaveable 存档系统。

B-1SettingsSaveData 追加存储字段

文件: Assets/_Game/Scripts/Core/Save/SaveData.csSettingsSaveData 类)

在现有 Language 字段末尾追加(字段名与 SettingsPanelController 内部变量对齐):

[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

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 值刷新滑条位置:

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"

// 版本 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 的条件分支