47 KiB
16 · 支撑模块
命名空间
BaseGames.Localization、BaseGames.Achievements、BaseGames.Debug、BaseGames.Accessibility
程序集 各自独立 asmdef,均为可选模块(#if UNITY_EDITOR/#if PLATFORM_*条件编译)
路径Assets/Scripts/Support/
目录
- LocalizationManager
- AchievementManager
- PlatformManager(Steam 集成)
- DebugCheatSystem
- AntiSoftlockSystem
- AccessibilityManager
- 支撑事件频道清单
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.conditions是AchievementCondition[](ScriptableObject 子类数组),默认 Inspector 每个元素仅显示对象引用字段,策划无法在 Inspector 中一眼看出当前配置了哪些条件及其参数。本 Drawer 内联展示各 Condition SO 的关键字段,并在头部显示条件类型的中文名。
// 路径: 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<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. PlatformManager(ServiceLocator 模式)
参见 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 §2);Debug 上下文用 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 无 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<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 无 Instance(Architecture 05 §2);AntiSoftlock 在 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 无 Instance;Architecture 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.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
// 路径: 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)
// 路径: 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
// 路径: 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.cs(Editor-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 |
各倍率 > 0,SteelSoul 规则一致性 |
AttackPatternSO |
至少 1 个 AttackEntry,Weight > 0 |
CharmSO |
Effects 不为空,Slot 编号不重复 |
QuestSO |
Objectives 不为空,所有 Objective 有唯一 ID |