37 KiB
存档与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 管理:
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-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 协程末尾追加焦点设置:
// 在 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),并追加字段与方法:
// ★ 追加字段(在 [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 输入动作回调中追加频道发布:
[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 下):
[SerializeField] private VoidEventChannelSO _onUICancelPressed;
在 OnEnable 中追加订阅(_subs 已存在):
_onUICancelPressed?.Subscribe(HandleUICancelPressed).AddTo(_subs);
追加处理方法:
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
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 加载:
// 原代码(需删除):
// 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
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 同组):
[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
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
配置步骤:
- 在 Project 窗口右键 → Create → BaseGames/Progression/RegionRegistry,保存为
RegionRegistry.asset - 在 Inspector 的
_regions数组中注册所有RegionDefinitionSO资产(Assets/_Game/Data/Progression/Regions/) - 每新增区域 SO 时同步补充到此数组
2-B-3:SlotSummary 追加区域字段
文件: Assets/_Game/Scripts/Core/Save/SaveData.cs(SlotSummary 类)
在现有字段末尾追加(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-4:GameSaveManager.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正向依赖扩大)。
RegionId(string)通过SlotSummary传递给 UI,UI 再通过自持有的RegionRegistrySOSerializeField 引用查背景图——两层都只持有 string,保持解耦。
2-B-5:SaveSlotUI 显示区域背景图
文件: Assets/_Game/Scripts/UI/Menus/SaveSlotController.cs(SaveSlotUI 类内)
在现有 [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.asmdef的references数组中添加"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-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
// 修改前:
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() 方法开头追加服务重试逻辑:
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 内部变量对齐):
[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:
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-3:SaveMigrator 版本补丁
文件: 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×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 的条件分支 |