# 16 · 支撑模块 > **命名空间** `BaseGames.Localization`、`BaseGames.Achievements`、`BaseGames.Debug`、`BaseGames.Accessibility` > **程序集** 各自独立 asmdef,均为可选模块(`#if UNITY_EDITOR` / `#if PLATFORM_*` 条件编译) > **路径** `Assets/Scripts/Support/` --- ## 目录 1. [LocalizationManager](#1-localizationmanager) 2. [AchievementManager](#2-achievementmanager) 3. [PlatformManager(Steam 集成)](#3-platformmanager) 4. [DebugCheatSystem](#4-debugcheatsystem) 5. [AntiSoftlockSystem](#5-antisoftlocksystem) 6. [AccessibilityManager](#6-accessibilitymanager) 7. [支撑事件频道清单](#7-支撑事件频道清单) --- ## 1. LocalizationManager ```csharp // 路径: Assets/Scripts/Support/Localization/LocalizationManager.cs // Unity Localization 包(com.unity.localization)的轻量封装 // 游戏内所有文本通过此类获取,不直接引用 LocalizationSettings public static class LocalizationManager { // 当前语言 public static Locale ActiveLocale => LocalizationSettings.SelectedLocale; // 同步获取本地化字符串(Locale 已完全加载时使用) public static string Get(string tableKey, string entryKey) { var op = LocalizationSettings.StringDatabase.GetLocalizedString(tableKey, entryKey); return op.IsDone ? op.Result : entryKey; } // 异步获取(在等待 Locale 初始化的场景中使用) public static async Task GetAsync(string tableKey, string entryKey) { var op = LocalizationSettings.StringDatabase.GetLocalizedStringAsync(tableKey, entryKey); return await op.Task; } // 切换语言(由 SettingsPanelController 的语言下拉框调用) public static void SetLocale(string localeCode) { var locale = LocalizationSettings.AvailableLocales.Locales .FirstOrDefault(l => l.Identifier.Code == localeCode); if (locale != null) LocalizationSettings.SelectedLocale = locale; } // 快捷常量:String Table 名称 public const string Table_UI = "UI"; public const string Table_Dialogue = "Dialogue"; public const string Table_Items = "Items"; public const string Table_Enemies = "Enemies"; } ``` --- ## 1.1 LanguageManagerSO — 语言切换 SO 单例 > **Design 来源**:[22_LocalizationSystem](../Design/22_LocalizationSystem.md) §4 `LocalizationManager`(静态工具类,见 §1)负责文本查询;`LanguageManagerSO` 负责持久化语言偏好并在设置界面切换语言。 ```csharp // 路径: Assets/ScriptableObjects/Localization/LanguageManager.asset // SO 单例:通过 [SerializeField] 注入到语言设置面板等消费组件 // 消费者:SettingsPanelController、DialogueLocalizationBridge 等 [CreateAssetMenu(menuName = "Localization/LanguageManager")] public class LanguageManagerSO : ScriptableObject { // PlayerPrefs 持久化键 private const string PrefKey = "SelectedLocale"; /// 切换语言并持久化选择 public void SetLocale(string localeCode) { var locale = LocalizationSettings.AvailableLocales.Locales .FirstOrDefault(l => l.Identifier.Code == localeCode); if (locale != null) { LocalizationSettings.SelectedLocale = locale; PlayerPrefs.SetString(PrefKey, localeCode); } } /// 获取当前语言代码(默认 zh-CN) public string GetCurrentLocaleCode() => LocalizationSettings.SelectedLocale?.Identifier.Code ?? "zh-CN"; /// 游戏启动时从 PlayerPrefs 读取上次选择的语言 public void LoadSavedLocale() => SetLocale(PlayerPrefs.GetString(PrefKey, "zh-CN")); } ``` > **注意**:静态 `LocalizationManager.SetLocale()` 仅改变运行时语言,不持久化;`LanguageManagerSO.SetLocale()` 两者兼顾,设置界面应使用后者。 --- ## 2. AchievementManager ### 2.1 AchievementSO — 成就数据 ```csharp // 路径: Assets/Scripts/Support/Achievements/AchievementSO.cs namespace BaseGames.Achievement { [CreateAssetMenu(menuName = "Achievement/Achievement")] public class AchievementSO : ScriptableObject { [Header("基础信息")] public string achievementId; // 全局唯一 ID,如 "Ach_SlayBoss_Forest" public string displayName; [TextArea(2, 5)] public string description; [TextArea(2, 5)] public string hiddenDescription; // 未解锁时显示的提示(空=完全隐藏) [Header("外观")] public Sprite icon; public Sprite hiddenIcon; // 未解锁时显示的占位图标 [Header("分类")] public AchievementType type; // 故事/收集/挑战/隐藏 public AchievementTier tier; // 铜/银/金(展示用) [Header("解锁条件")] public AchievementCondition[] conditions; // AND 逻辑:全部满足才解锁 [Header("奖励(可选)")] public bool grantsNotch; // 解锁额外 Notch 槽 } public enum AchievementType { Story, Collection, Challenge, Hidden } public enum AchievementTier { Bronze, Silver, Gold } } ``` ### 2.2 AchievementCondition — ScriptableObject 策略模式 ```csharp // 路径: Assets/Scripts/Support/Achievements/AchievementCondition.cs namespace BaseGames.Achievement { /// /// 成就解锁条件抽象基类。每种条件一个 SO 子类,可自由组合。 /// public abstract class AchievementCondition : ScriptableObject { public abstract void RegisterListeners(AchievementManager manager); public abstract void UnregisterListeners(AchievementManager manager); public abstract bool IsMet(AchievementRuntimeState state); } } ``` 内置条件类型一览: | SO 子类 | 参数 | 说明 | |---------|------|------| | `DefeatedBossCondition` | `bossId: string` | 击败指定 Boss | | `DefeatedAllBossesCondition` | — | 击败全部 Boss | | `EnteredRegionCondition` | `regionId: RegionId` | 到达特定区域 | | `MapExplorationCondition` | `minPercent: float` | 探索地图百分比 | | `CollectedItemCondition` | `itemId: string` | 收集指定物品 | | `CollectedAllCharmsCondition` | — | 集满全部 Charm | | `UnlockedAllAbilitiesCondition` | — | 解锁全部能力 | | `NoHealRunCondition` | — | 不使用治疗通关 | | `TimedBossKillCondition` | `bossId`, `maxSeconds` | 限时击败 Boss | | `ParryCountCondition` | `requiredCount: int` | 弹反指定次数 | | `NailClashCountCondition` | `requiredCount: int` | 拼刀指定次数 | | `EventTriggeredCondition` | `eventChannelSO` | 监听任意 VoidEvent | ```csharp // 示例:DefeatedBossCondition [CreateAssetMenu(menuName = "Achievement/Condition/DefeatedBoss")] public class DefeatedBossCondition : AchievementCondition { public string bossId; public override void RegisterListeners(AchievementManager manager) => manager.OnBossDefeated += Evaluate; public override void UnregisterListeners(AchievementManager manager) => manager.OnBossDefeated -= Evaluate; void Evaluate(string defeatedBossId, AchievementRuntimeState state) { if (defeatedBossId == bossId) state.SetConditionMet(this); } public override bool IsMet(AchievementRuntimeState state) => state.IsConditionMet(this); } ``` ### 2.3 AchievementManager — 运行时管理器 ```csharp // 路径: Assets/Scripts/Support/Achievements/AchievementManager.cs namespace BaseGames.Achievement { public class AchievementManager : MonoBehaviour, ISaveable { [Header("成就列表")] [SerializeField] AchievementSO[] _allAchievements; [Header("事件频道(订阅)")] [SerializeField] StringEventChannelSO _onBossDefeated; [SerializeField] StringEventChannelSO _onCollectiblePickedUp; [SerializeField] IntEventChannelSO _onAbilityUnlocked; [SerializeField] StringEventChannelSO _onRoomEntered; [SerializeField] VoidEventChannelSO _onParrySuccess; [SerializeField] VoidEventChannelSO _onNailClash; [Header("事件频道(发布)")] [SerializeField] AchievementEventChannelSO _onAchievementUnlocked; // 内部中继 C# 事件,供 AchievementCondition 子类订阅 public event Action OnBossDefeated; public event Action OnCollectiblePickedUp; public event Action OnAbilityUnlocked; public event Action OnRoomEntered; public event Action OnParrySuccess; public event Action OnNailClash; readonly Dictionary _states = new(); void Awake() { foreach (var ach in _allAchievements) _states[ach.achievementId] = new AchievementRuntimeState(ach); } void OnEnable() { _onBossDefeated.OnEventRaised += id => { OnBossDefeated?.Invoke(id); EvaluateAll(); }; _onCollectiblePickedUp.OnEventRaised += id => { OnCollectiblePickedUp?.Invoke(id); EvaluateAll(); }; _onAbilityUnlocked.OnEventRaised += v => { OnAbilityUnlocked?.Invoke(v); EvaluateAll(); }; _onRoomEntered.OnEventRaised += id => { OnRoomEntered?.Invoke(id); EvaluateAll(); }; _onParrySuccess.OnEventRaised += () => { OnParrySuccess?.Invoke(); EvaluateAll(); }; _onNailClash.OnEventRaised += () => { OnNailClash?.Invoke(); EvaluateAll(); }; foreach (var ach in _allAchievements) foreach (var cond in ach.conditions) cond.RegisterListeners(this); } void OnDisable() { foreach (var ach in _allAchievements) foreach (var cond in ach.conditions) cond.UnregisterListeners(this); } void EvaluateAll() { foreach (var ach in _allAchievements) { var state = _states[ach.achievementId]; if (state.IsUnlocked) continue; if (Array.TrueForAll(ach.conditions, c => c.IsMet(state))) Unlock(ach, state); } } void Unlock(AchievementSO ach, AchievementRuntimeState state) { state.IsUnlocked = true; _onAchievementUnlocked.Raise(ach); // → AchievementToast + Analytics // Steam 平台同步 #if STEAMWORKS_NET PlatformManager.UnlockAchievement(ach.achievementId); #endif } // ── ISaveable ──────────────────────────────────────────────────── public void OnSave(SaveData data) { data.Achievements.Unlocked = _states .Where(kv => kv.Value.IsUnlocked) .Select(kv => kv.Key) .ToList(); } public void OnLoad(SaveData data) { foreach (var id in data.Achievements.Unlocked) if (_states.TryGetValue(id, out var state)) state.IsUnlocked = true; } } public class AchievementRuntimeState { public bool IsUnlocked { get; set; } readonly HashSet _metConditions = new(); public AchievementRuntimeState(AchievementSO ach) { // 由 OnLoad 驱动;Awake 时默认未解锁 } public void SetConditionMet(AchievementCondition cond) => _metConditions.Add(cond); public bool IsConditionMet(AchievementCondition cond) => _metConditions.Contains(cond); } } ``` ### 2.4 AchievementConditionDrawer — 自定义 PropertyDrawer > **痛点**:`AchievementSO.conditions` 是 `AchievementCondition[]`(ScriptableObject 子类数组),默认 Inspector 每个元素仅显示对象引用字段,策划无法在 Inspector 中一眼看出当前配置了哪些条件及其参数。本 Drawer 内联展示各 Condition SO 的关键字段,并在头部显示条件类型的中文名。 ```csharp // 路径: Assets/Scripts/Editor/Achievements/AchievementSOEditor.cs // 程序集: BaseGames.Editor(Editor Only) #if UNITY_EDITOR using UnityEditor; using UnityEngine; namespace BaseGames.Editor.Achievements { [CustomEditor(typeof(AchievementSO))] public class AchievementSOEditor : UnityEditor.Editor { private static readonly Dictionary _conditionLabels = new() { { typeof(DefeatedBossCondition), "击败 Boss" }, { typeof(DefeatedAllBossesCondition), "击败全部 Boss" }, { typeof(EnteredRegionCondition), "到达区域" }, { typeof(MapExplorationCondition), "地图探索 %" }, { typeof(CollectedItemCondition), "收集物品" }, { typeof(CollectedAllCharmsCondition), "集满全部 Charm" }, { typeof(UnlockedAllAbilitiesCondition), "解锁全部能力" }, { typeof(NoHealRunCondition), "无治疗通关" }, { typeof(TimedBossKillCondition), "限时击败 Boss" }, { typeof(ParryCountCondition), "弹反 N 次" }, { typeof(NailClashCountCondition), "拼刀 N 次" }, { typeof(EventTriggeredCondition), "监听事件" }, }; private SerializedProperty _conditionsProp; void OnEnable() => _conditionsProp = serializedObject.FindProperty("conditions"); public override void OnInspectorGUI() { serializedObject.Update(); DrawPropertiesExcluding(serializedObject, "conditions"); EditorGUILayout.Space(8); EditorGUILayout.LabelField("解锁条件(AND)", EditorStyles.boldLabel); for (int i = 0; i < _conditionsProp.arraySize; i++) { var elemProp = _conditionsProp.GetArrayElementAtIndex(i); var condSO = elemProp.objectReferenceValue as AchievementCondition; string label = condSO != null && _conditionLabels.TryGetValue(condSO.GetType(), out var n) ? $"{n} [{condSO.name}]" : (condSO?.GetType().Name ?? "(未指定)"); EditorGUILayout.BeginVertical(EditorStyles.helpBox); EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField(label, EditorStyles.boldLabel); // 如果是关联 SO,提供快速 Ping 按钮 if (condSO != null && GUILayout.Button("↗", GUILayout.Width(24))) EditorGUIUtility.PingObject(condSO); // 删除按钮 if (GUILayout.Button("✕", GUILayout.Width(24))) { _conditionsProp.DeleteArrayElementAtIndex(i); serializedObject.ApplyModifiedProperties(); break; } EditorGUILayout.EndHorizontal(); // 内联展开 SO 的可序列化字段(只读) if (condSO != null) { var innerSO = new SerializedObject(condSO); innerSO.Update(); var prop = innerSO.GetIterator(); prop.NextVisible(true); // 跳过 m_Script while (prop.NextVisible(false)) EditorGUILayout.PropertyField(prop, true); innerSO.ApplyModifiedProperties(); } else { EditorGUILayout.PropertyField(elemProp, GUIContent.none); } EditorGUILayout.EndVertical(); EditorGUILayout.Space(2); } if (GUILayout.Button("+ 添加条件 SO")) _conditionsProp.arraySize++; serializedObject.ApplyModifiedProperties(); } } } #endif ``` --- ## 3. PlatformManager(ServiceLocator 模式) > **参见** Design/46_PlatformIntegration.md §2 — 完整接口规范 ```csharp // 路径: Assets/Scripts/Support/Platform/IPlatformService.cs namespace BaseGames.Platform { /// /// 平台服务抽象接口。所有游戏逻辑通过此接口调用,不直接引用 Steamworks 等 SDK 类。 /// public interface IPlatformService { // ── 成就 ───────────────────────────────────────────────────────── void UnlockAchievement(string achievementId); bool IsAchievementUnlocked(string achievementId); // ── 统计数据(用于成就进度跟踪)────────────────────────────────── void SetStat(string statId, int value); void IncrementStat(string statId, int increment = 1); int GetStat(string statId); // ── 云存档 ──────────────────────────────────────────────────────── Task CloudSaveAsync(string fileName, byte[] data); Task CloudLoadAsync(string fileName); bool IsCloudAvailable { get; } // ── Rich Presence ──────────────────────────────────────────────── void SetRichPresence(string key, string value); void ClearRichPresence(); // ── 振动 ────────────────────────────────────────────────────────── void Rumble(float lowFreq, float highFreq, float duration); void StopRumble(); // ── 生命周期 ────────────────────────────────────────────────────── void Initialize(); void RunCallbacks(); // 每帧 Update 中调用,处理 SDK 回调 void Shutdown(); } } ``` ```csharp // 路径: Assets/Scripts/Support/Platform/SteamPlatformService.cs #if UNITY_STANDALONE && STEAMWORKS_NET namespace BaseGames.Platform { public class SteamPlatformService : IPlatformService { public bool IsCloudAvailable => SteamManager.Initialized && SteamRemoteStorage.IsCloudEnabledForApp(); // ── 成就 ────────────────────────────────────────────────────── public void UnlockAchievement(string id) { if (!SteamManager.Initialized) return; SteamUserStats.SetAchievement(id); SteamUserStats.StoreStats(); } public bool IsAchievementUnlocked(string id) { SteamUserStats.GetAchievement(id, out bool unlocked); return unlocked; } // ── 统计 ────────────────────────────────────────────────────── public void SetStat(string id, int v) { if (!SteamManager.Initialized) return; SteamUserStats.SetStat(id, v); } public void IncrementStat(string id, int inc = 1) { int cur = GetStat(id); SetStat(id, cur + inc); } public int GetStat(string id) { SteamUserStats.GetStat(id, out int v); return v; } // ── 云存档(二进制,UTF-8 序列化在 SaveSystem 层完成)────────── public async Task CloudSaveAsync(string fileName, byte[] data) { if (!IsCloudAvailable) return false; return await Task.Run(() => SteamRemoteStorage.FileWrite(fileName, data, data.Length)); } public async Task CloudLoadAsync(string fileName) { if (!IsCloudAvailable || !SteamRemoteStorage.FileExists(fileName)) return null; int size = SteamRemoteStorage.GetFileSize(fileName); var buf = new byte[size]; await Task.Run(() => SteamRemoteStorage.FileRead(fileName, buf, size)); return buf; } // ── Rich Presence ────────────────────────────────────────────── public void SetRichPresence(string k, string v) => SteamFriends.SetRichPresence(k, v); public void ClearRichPresence() => SteamFriends.ClearRichPresence(); // ── 振动 ────────────────────────────────────────────────────── public void Rumble(float l, float h, float dur) { ushort lo = (ushort)(l * 65535); ushort hi = (ushort)(h * 65535); SteamController.TriggerVibration(SteamController.GetConnectedControllers()[0], lo, hi); } public void StopRumble() => Rumble(0f, 0f, 0f); // ── 生命周期 ─────────────────────────────────────────────────── public void Initialize() => SteamAPI.Init(); public void RunCallbacks() => SteamAPI.RunCallbacks(); public void Shutdown() => SteamAPI.Shutdown(); } } #endif ``` ```csharp // 路径: Assets/Scripts/Support/Platform/NullPlatformService.cs namespace BaseGames.Platform { public class NullPlatformService : IPlatformService { public bool IsCloudAvailable => false; public void UnlockAchievement(string id) => Debug.Log($"[Platform:Null] Achievement: {id}"); public bool IsAchievementUnlocked(string id) => false; public void SetStat(string id, int v) { } public void IncrementStat(string id, int inc = 1) { } public int GetStat(string id) => 0; public Task CloudSaveAsync(string f, byte[] d) => Task.FromResult(false); public Task CloudLoadAsync(string f) => Task.FromResult(null); public void SetRichPresence(string k, string v) { } public void ClearRichPresence() { } public void Rumble(float l, float h, float dur) { } public void StopRumble() { } public void Initialize() { } public void RunCallbacks() { } public void Shutdown() { } } } ``` ```csharp // 路径: Assets/Scripts/Support/Platform/PlatformBootstrap.cs // 挂载在 Persistent 场景的 Bootstrap GameObject public class PlatformBootstrap : MonoBehaviour { void Awake() { IPlatformService service; #if UNITY_STANDALONE && STEAMWORKS_NET service = new SteamPlatformService(); #elif UNITY_SWITCH service = new SwitchPlatformService(); // 预留,Switch 构建时接入 #else service = new NullPlatformService(); #endif service.Initialize(); ServiceLocator.Register(service); } void Update() => ServiceLocator.Get()?.RunCallbacks(); void OnApplicationQuit() => ServiceLocator.Get()?.Shutdown(); } ``` --- ## 4. DebugCheatSystem ```csharp // 路径: Assets/Scripts/Support/Debug/DebugCheatSystem.cs // 仅在 Editor 或 Development Build 中编译 #if UNITY_EDITOR || DEVELOPMENT_BUILD public class DebugCheatSystem : MonoBehaviour { [Header("快捷键")] [SerializeField] private KeyCode _toggleConsoleKey = KeyCode.BackQuote; // ⚠️ SceneLoader 无 Instance 单例(Architecture 03 §3,事件驱动);通过事件频道触发加载 [SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest; private bool _consoleOpen; private string _input = ""; private void Update() { if (Input.GetKeyDown(_toggleConsoleKey)) _consoleOpen = !_consoleOpen; } private void OnGUI() { if (!_consoleOpen) return; _input = GUI.TextField(new Rect(10, 10, 400, 30), _input); if (Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Return) { ExecuteCommand(_input.Trim()); _input = ""; } } private void ExecuteCommand(string cmd) { // ⚠️ PlayerController 无 Instance 单例(Architecture 05 §2);Debug 上下文用 FindObjectOfType var player = FindObjectOfType(); var parts = cmd.Split(' '); switch (parts[0].ToLower()) { case "godmode": player?.Stats.SetGodMode(true); break; case "addgeo" when parts.Length > 1 && int.TryParse(parts[1], out var geo): player?.Stats.AddGeo(geo); break; case "teleport" when parts.Length > 1: // ⚠️ 通过事件频道触发(SceneLoader 无 Instance;Architecture 03 §3) _onSceneLoadRequest.Raise(new SceneLoadRequest { SceneName = parts[1], EntryTransitionId = "Default" }); break; case "unlock" when parts.Length > 1: player?.OnAbilityUnlocked(parts[1]); break; case "killall": // ⚠️ DamageInfo 无单参数构造函数(Architecture 06 §1);必须使用 Builder 模式 var killDmg = new DamageInfo.Builder().SetRaw(99999).Build(); foreach (var e in FindObjectsOfType()) e.TakeDamage(killDmg); break; default: Debug.Log($"[Cheat] 未知命令: {cmd}"); break; } } } #endif ``` --- ## 5. AntiSoftlockSystem ```csharp // 路径: Assets/Scripts/Support/AntiSoftlock/AntiSoftlockSystem.cs // 检测卡关(玩家长时间静止 + 无法移动 + 无法交互)→ 提示 / 传送 public class AntiSoftlockSystem : MonoBehaviour { [SerializeField] private float _softlockDetectionTime = 60f; // 无任何输入的秒数 [SerializeField] private InputReaderSO _inputReader; [SerializeField] private VoidEventChannelSO _onSoftlockDetected; // 发布:检测到卡关 // ⚠️ SceneLoader 无 Instance 单例(Architecture 03 §3,事件驱动);通过事件频道触发加载 [SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest; private float _idleTime; private Vector2 _lastPlayerPos; private bool _promptShown; private void Update() { // ⚠️ PlayerController 无 Instance(Architecture 05 §2);AntiSoftlock 在 Persistent 场景, // 通过 FindObjectOfType 获取(Debug/支撑系统可接受,非热路径) var player = FindObjectOfType(); if (player == null) return; var playerPos = (Vector2)player.transform.position; if (Vector2.Distance(playerPos, _lastPlayerPos) > 0.1f) { _lastPlayerPos = playerPos; _idleTime = 0f; _promptShown = false; return; } _idleTime += Time.deltaTime; if (_idleTime >= _softlockDetectionTime && !_promptShown) { _promptShown = true; _onSoftlockDetected.Raise(); // UIManager 显示"是否传送到最近存档点"对话框 } } // 由 UI 确认按钮调用 public void TeleportToLastSavePoint() { // ⚠️ 通过事件频道触发(SceneLoader 无 Instance;Architecture 03 §3) _onSceneLoadRequest.Raise(new SceneLoadRequest { SceneName = SaveManager.LastCheckpointScene, EntryTransitionId = SaveManager.LastCheckpointSpawnId, IsRespawn = true }); } } ``` ### 5.1 RoomEscapeInfoSO — 设计时房间逃离配置 每个房间场景在设计时必须挂载此 SO,记录"最低能力集合即可离开此房间"。 编辑器工具自动验证可达性,如发现无法逃离的死局则标红警告。 ```csharp // 路径: Assets/Scripts/Support/AntiSoftlock/RoomEscapeInfoSO.cs namespace BaseGames.Progression { [CreateAssetMenu(menuName = "Progression/RoomEscapeInfo")] public class RoomEscapeInfoSO : ScriptableObject { [Header("房间标识")] public string sceneAddress; // 对应 Addressable 场景地址 [Header("逃离要求(满足任一路线即视为可逃离)")] public EscapeRoute[] escapeRoutes; [Header("单向入口警告")] public bool hasOneWayEntry; // 是否有单向进入点(如跌落入口) [TextArea(1, 3)] public string designerNotes; [Serializable] public class EscapeRoute { public string routeLabel; // 如 "向左回到 Forest_Main" public string targetSceneAddress; // 逃离到达的目标房间 public AbilityType[] requiredAbilities; // 空 = 无需任何能力即可离开 } } } ``` ### 5.2 HardAbilityGate — 增强型能力门(Sequence Break 防护) ```csharp // 路径: Assets/Scripts/Support/AntiSoftlock/HardAbilityGate.cs namespace BaseGames.Progression { /// /// 增强型能力门:除了检查能力标志,还检测玩家是否"物理上真的能做到"。 /// 用于防止玩家用精准时机绕过只检查标志的 AbilityGate(见 Design/49 §4.2)。 /// public class HardAbilityGate : AbilityGate { [Header("额外物理验证")] [SerializeField] bool _requirePhysicalValidation = false; // 编辑器工具标记"此门已验证可能被绕过" [SerializeField] bool _sequenceBreakRisk = false; protected override bool EvaluateAccess() { if (!base.EvaluateAccess()) return false; if (!_requirePhysicalValidation) return true; // 检查能力实际已激活(非仅标志为 true) return _playerStats != null && _playerStats.IsAbilityActuallyUnlocked(_requiredAbility); } } } ``` --- ## 6. AccessibilityManager > **参见** Design/62_Accessibility_System.md — 完整规范 > **重要声明**:无障碍功能 ≠ 简单模式。无障碍选项服务于有不同能力需求的玩家,与难度系统**独立运作**。 ### AccessibilitySettingsSO(数据容器) ```csharp // 路径: Assets/ScriptableObjects/Accessibility/AccessibilitySettings.asset [CreateAssetMenu(menuName = "Accessibility/AccessibilitySettings")] public class AccessibilitySettingsSO : ScriptableObject { // ── 视觉无障碍 ──────────────────────────────────────────────────────── [Header("色盲模式")] public ColorBlindMode colorBlindMode = ColorBlindMode.None; public bool highContrastMode = false; public float gameContrastBoost = 0f; // 0~1.0 // ── 运动无障碍 ──────────────────────────────────────────────────────── [Header("运动敏感度")] public bool disableScreenShake = false; public bool disableCameraMotion = false; public float cameraMotionScale = 1f; // 0~1.0(0 = 完全关闭) public bool reduceParticleEffects = false; // 减少粒子密度 50% public bool disableFlashingEffects = false; // 禁止 > 3Hz 的闪烁 public int flashFrequencyLimit = 3; // 光敏保护:最大闪光频率(Hz) // ── 字幕 ────────────────────────────────────────────────────────────── [Header("字幕系统")] public bool subtitlesEnabled = false; public bool sfxSubtitlesEnabled = false; // 环境音/危险音效文字提示 public float subtitleFontSizeMultiplier = 1f; // 0.75~2.0 public bool subtitleBackgroundEnabled = true; public float subtitleBackgroundOpacity = 0.7f; public bool speakerNameEnabled = true; // ── 输入辅助 ────────────────────────────────────────────────────────── [Header("输入辅助")] public bool autoParryAssist = false; // 自动弹反辅助 public float parryWindowExtension = 0f; // 弹反窗口扩展(秒),0~0.2 public bool holdToMash = false; // 长按替代连按(QTE 逃脱等) public bool stickyJump = false; // 跳跃键释放容忍(松手后 0.1s 仍可截断可变跳高) public bool autoClimb = false; // 接触墙面自动开始攀爬 // ── 音频无障碍 ──────────────────────────────────────────────────────── [Header("音频无障碍")] public bool monoAudio = false; // 单声道模式 public float leftRightBalance = 0f; // -1~+1 public bool visualDangerIndicator = false; // 视觉危险提示(代替音效提示) } public enum ColorBlindMode { None, // 无(默认) Protanopia, // 红色盲 Deuteranopia, // 绿色盲 Tritanopia, // 蓝黄色盲 Achromatopsia, // 全色盲(高对比灰度) } ``` ### AccessibilityManager ```csharp // 路径: Assets/Scripts/Support/Accessibility/AccessibilityManager.cs public class AccessibilityManager : MonoBehaviour { public static AccessibilityManager Instance { get; private set; } [SerializeField] private AccessibilitySettingsSO _settings; public AccessibilitySettingsSO Settings => _settings; // ── Event Channels(Raise 方)─────────────────────────────────────── [SerializeField] private ColorBlindModeEventChannelSO _onColorBlindModeChanged; [SerializeField] private BoolEventChannelSO _onHighContrastChanged; [SerializeField] private BoolEventChannelSO _onSubtitlesChanged; [SerializeField] private BoolEventChannelSO _onScreenShakeChanged; private void Awake() { if (Instance != null) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); } // SettingsPanelController 调用的公开 API public void ApplySettings() { _onColorBlindModeChanged.Raise(_settings.colorBlindMode); _onHighContrastChanged.Raise(_settings.highContrastMode); _onSubtitlesChanged.Raise(_settings.subtitlesEnabled); _onScreenShakeChanged.Raise(!_settings.disableScreenShake); // 传递给 NiceVibrations(由 Settings 全局开关控制) // HapticController 无直接字段;通过 Feel 的 Haptics 全局开关处理 } // 各具体操作方法(由 UI 控件调用后触发 ApplySettings) public void SetColorBlindMode(ColorBlindMode mode) { _settings.colorBlindMode = mode; ApplySettings(); } public void SetAutoParryAssist(bool v) { _settings.autoParryAssist = v; ApplySettings(); } public void SetParryWindowExtension(float sec) { _settings.parryWindowExtension = sec; ApplySettings(); } public void SetDisableScreenShake(bool v) { _settings.disableScreenShake = v; ApplySettings(); } public void SetCameraMotionScale(float s) { _settings.cameraMotionScale = s; ApplySettings(); } public void SetMonoAudio(bool v) { _settings.monoAudio = v; ApplySettings(); } public void SetVisualDangerIndicator(bool v) { _settings.visualDangerIndicator = v; ApplySettings(); } // ... 其余选项同理 // 供 FeedbackSystem 查询屏幕震动权限 public static bool CanPlayScreenShake() => Instance == null || !Instance.Settings.disableScreenShake; } ``` ### ColorBlindFilter(URP Renderer Feature) ```csharp // 路径: Assets/Scripts/Accessibility/ColorBlindFilter.cs // URP 2D 后处理:最终合成阶段应用色彩矩阵变换 public class ColorBlindFilter : ScriptableRendererFeature { [SerializeField] ColorBlindMode _mode; // 色彩矩阵(3×3,基于 Brettel et al. 1997 算法) private static readonly Dictionary _matrices = new() { [ColorBlindMode.Protanopia] = new Matrix4x4(/*...*/), [ColorBlindMode.Deuteranopia] = new Matrix4x4(/*...*/), [ColorBlindMode.Tritanopia] = new Matrix4x4(/*...*/), [ColorBlindMode.Achromatopsia]= new Matrix4x4(/*...*/), }; public override void Create() { /* 初始化 RenderPass */ } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (_mode == ColorBlindMode.None) return; renderer.EnqueuePass(new ColorBlindPass(_matrices[_mode])); } // AccessibilityManager 调用以切换模式 public void SetMode(ColorBlindMode mode) => _mode = mode; } ``` **资产位置**:在 URP 2D Renderer Data(`Assets/Settings/URP2DRenderer.asset`)中添加此 Feature。 > **`parryWindowExtension` 集成**:`ParrySystem` 在计算弹反窗口时需读取此值: > `float window = _config.ParryWindowDuration + (AccessibilityManager.Instance?.Settings.parryWindowExtension ?? 0f);` --- ## 7. 支撑事件频道清单 | 资产名 | 类型 | Raise 方 | Subscribe 方 | |--------|------|---------|-------------| | `EVT_AchievementUnlocked` | `AchievementEventChannelSO` | `AchievementManager` | `ToastManager`(显示成就 Toast)| | `EVT_SoftlockDetected` | `VoidEventChannelSO` | `AntiSoftlockSystem` | `UIManager`(显示确认对话框)| | `EVT_ColorBlindModeChanged` | `ColorBlindModeEventChannelSO` | `AccessibilityManager` | `ColorBlindFilter` URP Renderer Feature | | `EVT_HighContrastChanged` | `BoolEventChannelSO` | `AccessibilityManager` | `UIManager`(高对比度 Theme)+ `OutlinePass` | | `EVT_SubtitlesChanged` | `BoolEventChannelSO` | `AccessibilityManager` | `DialogueBox`(字幕显隐)| | `EVT_ScreenShakeChanged` | `BoolEventChannelSO` | `AccessibilityManager` | `FeedbackSystem`(MMF 震动开关)| --- ## 8. AnalyticsManager ```csharp // 路径: Assets/Scripts/Support/Analytics/AnalyticsManager.cs // 游戏行为数据收集(开发用途:热点图、死亡统计、卡关点分析) // 正式发布版无网络上报;仅写入本地日志供开发分析 public class AnalyticsManager : MonoBehaviour { [SerializeField] private bool _enabledInRelease = false; // 正式包默认关闭 [SerializeField] private string _logPath; // 本地日志路径 private StreamWriter _writer; private void Awake() { #if !UNITY_EDITOR && !DEVELOPMENT_BUILD if (!_enabledInRelease) { enabled = false; return; } #endif var path = string.IsNullOrEmpty(_logPath) ? Path.Combine(Application.persistentDataPath, "analytics.jsonl") : _logPath; _writer = new StreamWriter(path, append: true); } private void OnDestroy() => _writer?.Close(); // 记录一条分析事件(JSONL 格式) public void Track(string eventName, Dictionary properties = null) { if (!enabled) return; var payload = new Dictionary { ["event"] = eventName, ["timestamp"] = DateTime.UtcNow.ToString("o"), ["session"] = Time.realtimeSinceStartup }; if (properties != null) foreach (var kv in properties) payload[kv.Key] = kv.Value; _writer?.WriteLine(JsonConvert.SerializeObject(payload)); } // ── 常用跟踪快捷方法 ──────────────────────────────────────────── public void TrackDeath(string sceneName, Vector2 position, string cause) => Track("player_death", new() { ["scene"] = sceneName, ["pos_x"] = position.x, ["pos_y"] = position.y, ["cause"] = cause }); public void TrackBossDefeated(string bossId, float elapsedSeconds) => Track("boss_defeated", new() { ["boss_id"] = bossId, ["time_s"] = elapsedSeconds }); public void TrackAbilityUnlocked(string abilityId) => Track("ability_unlocked", new() { ["ability"] = abilityId }); } ``` --- ## 9. SpeedrunTimer ```csharp // 路径: Assets/Scripts/Support/Speedrun/SpeedrunTimer.cs // 游戏内时间(IGT)计时器:排除加载、过场、暂停时间 // 在 HUD 角落显示(仅当 Settings 中开启时) public class SpeedrunTimer : MonoBehaviour, ISaveable { [SerializeField] private BoolEventChannelSO _onGameplayActive; // Gameplay 状态 = 计时 [SerializeField] private TMP_Text _display; // HUD 角落 Text(可隐藏) [SerializeField] private GlobalSettingsSO _settings; private float _igt; // 累计 In-Game Time(秒) private bool _isRunning; private void OnEnable() { _onGameplayActive.OnEventRaised += SetRunning; UpdateDisplay(); } private void OnDisable() => _onGameplayActive.OnEventRaised -= SetRunning; private void SetRunning(bool active) { _isRunning = active; if (_display != null) _display.gameObject.SetActive(active && _settings.ShowSpeedrunTimer); } private void Update() { if (!_isRunning) return; _igt += Time.unscaledDeltaTime; // 不受 timeScale 影响(暂停 timeScale=0 时不计时) UpdateDisplay(); } private void UpdateDisplay() { if (_display == null) return; var ts = TimeSpan.FromSeconds(_igt); _display.text = $"{ts.Hours:D2}:{ts.Minutes:D2}:{ts.Seconds:D2}.{ts.Milliseconds / 10:D2}"; } public float TotalSeconds => _igt; // ── ISaveable ──────────────────────────────────────────────────── public void OnSave(SaveData data) => data.Stats.DistanceTraveled = _igt; // 复用字段(或扩展 StatsSaveData) public void OnLoad(SaveData data) { _igt = data.Stats.DistanceTraveled; UpdateDisplay(); } } ``` **GlobalSettingsSO 新增字段**(追加至 §6 AccessibilityManager 的补充字段): ```csharp public bool ShowSpeedrunTimer = false; // 默认隐藏,由设置界面开关控制 ``` --- ## 10. SOValidationRunner — SO 数据合法性全局校验 > **路径**:`Assets/Editor/Validation/SOValidationRunner.cs`(Editor-only) > **动机**:`[Range]` / `[Min]` Attribute 在 Inspector 手动输入时有效,但脚本赋值、JSON 导入或复制粘贴时无约束;统一校验工具在构建前 / 按需运行时捕获数据异常。 ### IValidatable 接口 ```csharp // 路径: Assets/Scripts/Core/Validation/IValidatable.cs // 任何 ScriptableObject 或 MonoBehaviour 可选择性实现此接口,声明自身合法性规则 namespace BaseGames.Core { public interface IValidatable { /// /// 返回验证失败信息列表;列表为空表示数据合法。 /// System.Collections.Generic.IEnumerable Validate(); } } ``` **实现示例**(`DifficultyScalerSO` 扩展): ```csharp // DifficultyScalerSO 新增(已有代码末尾追加接口声明) public class DifficultyScalerSO : ScriptableObject, IValidatable { // ... 现有字段 ... public IEnumerable Validate() { if (PlayerMaxHPMultiplier <= 0) yield return $"[{name}] PlayerMaxHPMultiplier 必须 > 0"; if (EnemyDamageMultiplier <= 0) yield return $"[{name}] EnemyDamageMultiplier 必须 > 0"; if (level == DifficultyLevel.SteelSoul && !InstantDeathOnZeroHP) yield return $"[{name}] SteelSoul 难度必须 InstantDeathOnZeroHP = true"; } } ``` ### SOValidationRunner 实现 ```csharp // 路径: Assets/Editor/Validation/SOValidationRunner.cs using UnityEditor; using UnityEditor.Build; using UnityEditor.Build.Reporting; using System.Collections.Generic; public class SOValidationRunner : IPreprocessBuildWithReport { public int callbackOrder => 1; // 在 AddressKeyValidator(0) 之后 public void OnPreprocessBuild(BuildReport report) { var (errors, warnings) = RunAll(); if (errors.Count > 0) throw new BuildFailedException( $"[SOValidationRunner] {errors.Count} 处 SO 数据错误,构建中止:\n" + string.Join("\n", errors)); foreach (var w in warnings) UnityEngine.Debug.LogWarning(w); } [MenuItem("Tools/Validate All ScriptableObjects")] public static void ValidateMenu() { var (errors, warnings) = RunAll(); if (errors.Count == 0 && warnings.Count == 0) { UnityEngine.Debug.Log("[SOValidationRunner] ✅ 所有 SO 数据均合法。"); return; } foreach (var w in warnings) UnityEngine.Debug.LogWarning(w); foreach (var e in errors) UnityEngine.Debug.LogError(e); UnityEngine.Debug.Log( $"[SOValidationRunner] 校验完成:{errors.Count} 错误,{warnings.Count} 警告。"); } private static (List errors, List warnings) RunAll() { var errors = new List(); var warnings = new List(); // 查找 AssetDatabase 中所有实现 IValidatable 的 ScriptableObject var guids = AssetDatabase.FindAssets("t:ScriptableObject"); foreach (var guid in guids) { var path = AssetDatabase.GUIDToAssetPath(guid); var so = AssetDatabase.LoadAssetAtPath(path); if (so is IValidatable validatable) { foreach (var msg in validatable.Validate()) { bool isError = msg.StartsWith("[") && msg.Contains("必须"); if (isError) errors.Add($"❌ {msg} ({path})"); else warnings.Add($"⚠️ {msg} ({path})"); } } } return (errors, warnings); } } ``` **扩展约定**: | SO 类型 | 建议校验项 | |---------|-----------| | `DamageSourceSO` | `BaseDamage > 0`, `DamageMultiplier > 0` | | `DifficultyScalerSO` | 各倍率 > 0,SteelSoul 规则一致性 | | `AttackPatternSO` | 至少 1 个 AttackEntry,Weight > 0 | | `CharmSO` | Effects 不为空,Slot 编号不重复 | | `QuestSO` | Objectives 不为空,所有 Objective 有唯一 ID |