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

1184 lines
47 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. [PlatformManagerSteam 集成)](#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<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](../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";
/// <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 — 成就数据
```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
{
/// <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 |
```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<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.conditions` 是 `AchievementCondition[]`ScriptableObject 子类数组),默认 Inspector 每个元素仅显示对象引用字段,策划无法在 Inspector 中一眼看出当前配置了哪些条件及其参数。本 Drawer 内联展示各 Condition SO 的关键字段,并在头部显示条件类型的中文名。
```csharp
// 路径: 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 — 完整接口规范
```csharp
// 路径: 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();
}
}
```
```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<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
```
```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<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() { }
}
}
```
```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<IPlatformService>(service);
}
void Update() => ServiceLocator.Get<IPlatformService>()?.RunCallbacks();
void OnApplicationQuit() => ServiceLocator.Get<IPlatformService>()?.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 §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
```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 无 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记录"最低能力集合即可离开此房间"。
编辑器工具自动验证可达性,如发现无法逃离的死局则标红警告。
```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
{
/// <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数据容器
```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.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
```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 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
```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<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 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<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
```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
{
/// <summary>
/// 返回验证失败信息列表;列表为空表示数据合法。
/// </summary>
System.Collections.Generic.IEnumerable<string> Validate();
}
}
```
**实现示例**`DifficultyScalerSO` 扩展):
```csharp
// 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 实现
```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<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 |