Files
zeling_v2/Docs/Architecture/16_SupportingModules.md
2026-05-08 11:04:00 +08:00

47 KiB
Raw Blame History

16 · 支撑模块

命名空间 BaseGames.LocalizationBaseGames.AchievementsBaseGames.DebugBaseGames.Accessibility
程序集 各自独立 asmdef均为可选模块#if UNITY_EDITOR / #if PLATFORM_* 条件编译)
路径 Assets/Scripts/Support/


目录

  1. LocalizationManager
  2. AchievementManager
  3. PlatformManagerSteam 集成)
  4. DebugCheatSystem
  5. AntiSoftlockSystem
  6. AccessibilityManager
  7. 支撑事件频道清单

1. LocalizationManager

// 路径: 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<string> 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 §4

LocalizationManager(静态工具类,见 §1负责文本查询LanguageManagerSO 负责持久化语言偏好并在设置界面切换语言。

// 路径: Assets/ScriptableObjects/Localization/LanguageManager.asset
// SO 单例:通过 [SerializeField] 注入到语言设置面板等消费组件
// 消费者SettingsPanelController、DialogueLocalizationBridge 等
[CreateAssetMenu(menuName = "Localization/LanguageManager")]
public class LanguageManagerSO : ScriptableObject
{
    // PlayerPrefs 持久化键
    private const string PrefKey = "SelectedLocale";

    /// <summary>切换语言并持久化选择</summary>
    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);
        }
    }

    /// <summary>获取当前语言代码(默认 zh-CN</summary>
    public string GetCurrentLocaleCode()
        => LocalizationSettings.SelectedLocale?.Identifier.Code ?? "zh-CN";

    /// <summary>游戏启动时从 PlayerPrefs 读取上次选择的语言</summary>
    public void LoadSavedLocale()
        => SetLocale(PlayerPrefs.GetString(PrefKey, "zh-CN"));
}

注意:静态 LocalizationManager.SetLocale() 仅改变运行时语言,不持久化;LanguageManagerSO.SetLocale() 两者兼顾,设置界面应使用后者。


2. AchievementManager

2.1 AchievementSO — 成就数据

// 路径: 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 策略模式

// 路径: Assets/Scripts/Support/Achievements/AchievementCondition.cs
namespace BaseGames.Achievement
{
    /// <summary>
    /// 成就解锁条件抽象基类。每种条件一个 SO 子类,可自由组合。
    /// </summary>
    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
// 示例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 — 运行时管理器

// 路径: 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<string>  OnBossDefeated;
        public event Action<string>  OnCollectiblePickedUp;
        public event Action<int>     OnAbilityUnlocked;
        public event Action<string>  OnRoomEntered;
        public event Action          OnParrySuccess;
        public event Action          OnNailClash;

        readonly Dictionary<string, AchievementRuntimeState> _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<AchievementCondition> _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.conditionsAchievementCondition[]ScriptableObject 子类数组),默认 Inspector 每个元素仅显示对象引用字段,策划无法在 Inspector 中一眼看出当前配置了哪些条件及其参数。本 Drawer 内联展示各 Condition SO 的关键字段,并在头部显示条件类型的中文名。

// 路径: Assets/Scripts/Editor/Achievements/AchievementSOEditor.cs
// 程序集: BaseGames.EditorEditor Only
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;

namespace BaseGames.Editor.Achievements
{
    [CustomEditor(typeof(AchievementSO))]
    public class AchievementSOEditor : UnityEditor.Editor
    {
        private static readonly Dictionary<System.Type, string> _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. PlatformManagerServiceLocator 模式)

参见 Design/46_PlatformIntegration.md §2 — 完整接口规范

// 路径: Assets/Scripts/Support/Platform/IPlatformService.cs
namespace BaseGames.Platform
{
    /// <summary>
    /// 平台服务抽象接口。所有游戏逻辑通过此接口调用,不直接引用 Steamworks 等 SDK 类。
    /// </summary>
    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<bool>   CloudSaveAsync(string fileName, byte[] data);
        Task<byte[]> 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();
    }
}
// 路径: 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<bool> CloudSaveAsync(string fileName, byte[] data)
        {
            if (!IsCloudAvailable) return false;
            return await Task.Run(() =>
                SteamRemoteStorage.FileWrite(fileName, data, data.Length));
        }
        public async Task<byte[]> 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
// 路径: 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<bool>   CloudSaveAsync(string f, byte[] d) => Task.FromResult(false);
        public Task<byte[]> CloudLoadAsync(string f)           => Task.FromResult<byte[]>(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()                                { }
    }
}
// 路径: 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<IPlatformService>(service);
    }

    void Update()           => ServiceLocator.Get<IPlatformService>()?.RunCallbacks();
    void OnApplicationQuit() => ServiceLocator.Get<IPlatformService>()?.Shutdown();
}

4. DebugCheatSystem

// 路径: 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 §2Debug 上下文用 FindObjectOfType
        var player = FindObjectOfType<PlayerController>();
        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 无 InstanceArchitecture 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<EnemyBase>()) e.TakeDamage(killDmg);
                break;
            default:
                Debug.Log($"[Cheat] 未知命令: {cmd}");
                break;
        }
    }
}
#endif

5. AntiSoftlockSystem

// 路径: 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 无 InstanceArchitecture 05 §2AntiSoftlock 在 Persistent 场景,
        // 通过 FindObjectOfType 获取Debug/支撑系统可接受,非热路径)
        var player = FindObjectOfType<PlayerController>();
        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 无 InstanceArchitecture 03 §3
        _onSceneLoadRequest.Raise(new SceneLoadRequest
        {
            SceneName         = SaveManager.LastCheckpointScene,
            EntryTransitionId = SaveManager.LastCheckpointSpawnId,
            IsRespawn         = true
        });
    }
}

5.1 RoomEscapeInfoSO — 设计时房间逃离配置

每个房间场景在设计时必须挂载此 SO记录"最低能力集合即可离开此房间"。 编辑器工具自动验证可达性,如发现无法逃离的死局则标红警告。

// 路径: 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 防护)

// 路径: Assets/Scripts/Support/AntiSoftlock/HardAbilityGate.cs
namespace BaseGames.Progression
{
    /// <summary>
    /// 增强型能力门:除了检查能力标志,还检测玩家是否"物理上真的能做到"。
    /// 用于防止玩家用精准时机绕过只检查标志的 AbilityGate见 Design/49 §4.2)。
    /// </summary>
    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数据容器

// 路径: 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.00 = 完全关闭)
    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

// 路径: 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 ChannelsRaise 方)───────────────────────────────────────
    [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;
}

ColorBlindFilterURP Renderer Feature

// 路径: Assets/Scripts/Accessibility/ColorBlindFilter.cs
// URP 2D 后处理:最终合成阶段应用色彩矩阵变换
public class ColorBlindFilter : ScriptableRendererFeature
{
    [SerializeField] ColorBlindMode _mode;

    // 色彩矩阵3×3基于 Brettel et al. 1997 算法)
    private static readonly Dictionary<ColorBlindMode, Matrix4x4> _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 DataAssets/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 FeedbackSystemMMF 震动开关)

8. AnalyticsManager

// 路径: 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<string, object> properties = null)
    {
        if (!enabled) return;

        var payload = new Dictionary<string, object>
        {
            ["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

// 路径: 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 的补充字段):

public bool ShowSpeedrunTimer = false;   // 默认隐藏,由设置界面开关控制

10. SOValidationRunner — SO 数据合法性全局校验

路径Assets/Editor/Validation/SOValidationRunner.csEditor-only
动机[Range] / [Min] Attribute 在 Inspector 手动输入时有效但脚本赋值、JSON 导入或复制粘贴时无约束;统一校验工具在构建前 / 按需运行时捕获数据异常。

IValidatable 接口

// 路径: Assets/Scripts/Core/Validation/IValidatable.cs
// 任何 ScriptableObject 或 MonoBehaviour 可选择性实现此接口,声明自身合法性规则
namespace BaseGames.Core
{
    public interface IValidatable
    {
        /// <summary>
        /// 返回验证失败信息列表;列表为空表示数据合法。
        /// </summary>
        System.Collections.Generic.IEnumerable<string> Validate();
    }
}

实现示例DifficultyScalerSO 扩展):

// DifficultyScalerSO 新增(已有代码末尾追加接口声明)
public class DifficultyScalerSO : ScriptableObject, IValidatable
{
    // ... 现有字段 ...

    public IEnumerable<string> 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 实现

// 路径: 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<string> errors, List<string> warnings) RunAll()
    {
        var errors   = new List<string>();
        var warnings = new List<string>();

        // 查找 AssetDatabase 中所有实现 IValidatable 的 ScriptableObject
        var guids = AssetDatabase.FindAssets("t:ScriptableObject");
        foreach (var guid in guids)
        {
            var path = AssetDatabase.GUIDToAssetPath(guid);
            var so   = AssetDatabase.LoadAssetAtPath<UnityEngine.ScriptableObject>(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 各倍率 > 0SteelSoul 规则一致性
AttackPatternSO 至少 1 个 AttackEntryWeight > 0
CharmSO Effects 不为空Slot 编号不重复
QuestSO Objectives 不为空,所有 Objective 有唯一 ID