1184 lines
47 KiB
Markdown
1184 lines
47 KiB
Markdown
# 16 · 支撑模块
|
||
|
||
> **命名空间** `BaseGames.Localization`、`BaseGames.Achievements`、`BaseGames.Debug`、`BaseGames.Accessibility`
|
||
> **程序集** 各自独立 asmdef,均为可选模块(`#if UNITY_EDITOR` / `#if PLATFORM_*` 条件编译)
|
||
> **路径** `Assets/Scripts/Support/`
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [LocalizationManager](#1-localizationmanager)
|
||
2. [AchievementManager](#2-achievementmanager)
|
||
3. [PlatformManager(Steam 集成)](#3-platformmanager)
|
||
4. [DebugCheatSystem](#4-debugcheatsystem)
|
||
5. [AntiSoftlockSystem](#5-antisoftlocksystem)
|
||
6. [AccessibilityManager](#6-accessibilitymanager)
|
||
7. [支撑事件频道清单](#7-支撑事件频道清单)
|
||
|
||
---
|
||
|
||
## 1. LocalizationManager
|
||
|
||
```csharp
|
||
// 路径: Assets/Scripts/Support/Localization/LocalizationManager.cs
|
||
// Unity Localization 包(com.unity.localization)的轻量封装
|
||
// 游戏内所有文本通过此类获取,不直接引用 LocalizationSettings
|
||
public static class LocalizationManager
|
||
{
|
||
// 当前语言
|
||
public static Locale ActiveLocale => LocalizationSettings.SelectedLocale;
|
||
|
||
// 同步获取本地化字符串(Locale 已完全加载时使用)
|
||
public static string Get(string tableKey, string entryKey)
|
||
{
|
||
var op = LocalizationSettings.StringDatabase.GetLocalizedString(tableKey, entryKey);
|
||
return op.IsDone ? op.Result : entryKey;
|
||
}
|
||
|
||
// 异步获取(在等待 Locale 初始化的场景中使用)
|
||
public static async Task<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.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 — 完整接口规范
|
||
|
||
```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 §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
|
||
|
||
```csharp
|
||
// 路径: Assets/Scripts/Support/AntiSoftlock/AntiSoftlockSystem.cs
|
||
// 检测卡关(玩家长时间静止 + 无法移动 + 无法交互)→ 提示 / 传送
|
||
public class AntiSoftlockSystem : MonoBehaviour
|
||
{
|
||
[SerializeField] private float _softlockDetectionTime = 60f; // 无任何输入的秒数
|
||
[SerializeField] private InputReaderSO _inputReader;
|
||
[SerializeField] private VoidEventChannelSO _onSoftlockDetected; // 发布:检测到卡关
|
||
// ⚠️ SceneLoader 无 Instance 单例(Architecture 03 §3,事件驱动);通过事件频道触发加载
|
||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
|
||
|
||
private float _idleTime;
|
||
private Vector2 _lastPlayerPos;
|
||
private bool _promptShown;
|
||
|
||
private void Update()
|
||
{
|
||
// ⚠️ PlayerController 无 Instance(Architecture 05 §2);AntiSoftlock 在 Persistent 场景,
|
||
// 通过 FindObjectOfType 获取(Debug/支撑系统可接受,非热路径)
|
||
var player = FindObjectOfType<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,记录"最低能力集合即可离开此房间"。
|
||
编辑器工具自动验证可达性,如发现无法逃离的死局则标红警告。
|
||
|
||
```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.0(0 = 完全关闭)
|
||
public bool reduceParticleEffects = false; // 减少粒子密度 50%
|
||
public bool disableFlashingEffects = false; // 禁止 > 3Hz 的闪烁
|
||
public int flashFrequencyLimit = 3; // 光敏保护:最大闪光频率(Hz)
|
||
|
||
// ── 字幕 ──────────────────────────────────────────────────────────────
|
||
[Header("字幕系统")]
|
||
public bool subtitlesEnabled = false;
|
||
public bool sfxSubtitlesEnabled = false; // 环境音/危险音效文字提示
|
||
public float subtitleFontSizeMultiplier = 1f; // 0.75~2.0
|
||
public bool subtitleBackgroundEnabled = true;
|
||
public float subtitleBackgroundOpacity = 0.7f;
|
||
public bool speakerNameEnabled = true;
|
||
|
||
// ── 输入辅助 ──────────────────────────────────────────────────────────
|
||
[Header("输入辅助")]
|
||
public bool autoParryAssist = false; // 自动弹反辅助
|
||
public float parryWindowExtension = 0f; // 弹反窗口扩展(秒),0~0.2
|
||
public bool holdToMash = false; // 长按替代连按(QTE 逃脱等)
|
||
public bool stickyJump = false; // 跳跃键释放容忍(松手后 0.1s 仍可截断可变跳高)
|
||
public bool autoClimb = false; // 接触墙面自动开始攀爬
|
||
|
||
// ── 音频无障碍 ────────────────────────────────────────────────────────
|
||
[Header("音频无障碍")]
|
||
public bool monoAudio = false; // 单声道模式
|
||
public float leftRightBalance = 0f; // -1~+1
|
||
public bool visualDangerIndicator = false; // 视觉危险提示(代替音效提示)
|
||
}
|
||
|
||
public enum ColorBlindMode
|
||
{
|
||
None, // 无(默认)
|
||
Protanopia, // 红色盲
|
||
Deuteranopia, // 绿色盲
|
||
Tritanopia, // 蓝黄色盲
|
||
Achromatopsia, // 全色盲(高对比灰度)
|
||
}
|
||
```
|
||
|
||
### AccessibilityManager
|
||
|
||
```csharp
|
||
// 路径: Assets/Scripts/Support/Accessibility/AccessibilityManager.cs
|
||
public class AccessibilityManager : MonoBehaviour
|
||
{
|
||
public static AccessibilityManager Instance { get; private set; }
|
||
|
||
[SerializeField] private AccessibilitySettingsSO _settings;
|
||
public AccessibilitySettingsSO Settings => _settings;
|
||
|
||
// ── Event Channels(Raise 方)───────────────────────────────────────
|
||
[SerializeField] private ColorBlindModeEventChannelSO _onColorBlindModeChanged;
|
||
[SerializeField] private BoolEventChannelSO _onHighContrastChanged;
|
||
[SerializeField] private BoolEventChannelSO _onSubtitlesChanged;
|
||
[SerializeField] private BoolEventChannelSO _onScreenShakeChanged;
|
||
|
||
private void Awake()
|
||
{
|
||
if (Instance != null) { Destroy(gameObject); return; }
|
||
Instance = this;
|
||
DontDestroyOnLoad(gameObject);
|
||
}
|
||
|
||
// SettingsPanelController 调用的公开 API
|
||
public void ApplySettings()
|
||
{
|
||
_onColorBlindModeChanged.Raise(_settings.colorBlindMode);
|
||
_onHighContrastChanged.Raise(_settings.highContrastMode);
|
||
_onSubtitlesChanged.Raise(_settings.subtitlesEnabled);
|
||
_onScreenShakeChanged.Raise(!_settings.disableScreenShake);
|
||
// 传递给 NiceVibrations(由 Settings 全局开关控制)
|
||
// HapticController 无直接字段;通过 Feel 的 Haptics 全局开关处理
|
||
}
|
||
|
||
// 各具体操作方法(由 UI 控件调用后触发 ApplySettings)
|
||
public void SetColorBlindMode(ColorBlindMode mode) { _settings.colorBlindMode = mode; ApplySettings(); }
|
||
public void SetAutoParryAssist(bool v) { _settings.autoParryAssist = v; ApplySettings(); }
|
||
public void SetParryWindowExtension(float sec) { _settings.parryWindowExtension = sec; ApplySettings(); }
|
||
public void SetDisableScreenShake(bool v) { _settings.disableScreenShake = v; ApplySettings(); }
|
||
public void SetCameraMotionScale(float s) { _settings.cameraMotionScale = s; ApplySettings(); }
|
||
public void SetMonoAudio(bool v) { _settings.monoAudio = v; ApplySettings(); }
|
||
public void SetVisualDangerIndicator(bool v) { _settings.visualDangerIndicator = v; ApplySettings(); }
|
||
// ... 其余选项同理
|
||
|
||
// 供 FeedbackSystem 查询屏幕震动权限
|
||
public static bool CanPlayScreenShake()
|
||
=> Instance == null || !Instance.Settings.disableScreenShake;
|
||
}
|
||
```
|
||
|
||
### ColorBlindFilter(URP Renderer Feature)
|
||
|
||
```csharp
|
||
// 路径: Assets/Scripts/Accessibility/ColorBlindFilter.cs
|
||
// URP 2D 后处理:最终合成阶段应用色彩矩阵变换
|
||
public class ColorBlindFilter : ScriptableRendererFeature
|
||
{
|
||
[SerializeField] ColorBlindMode _mode;
|
||
|
||
// 色彩矩阵(3×3,基于 Brettel et al. 1997 算法)
|
||
private static readonly Dictionary<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` | 各倍率 > 0,SteelSoul 规则一致性 |
|
||
| `AttackPatternSO` | 至少 1 个 AttackEntry,Weight > 0 |
|
||
| `CharmSO` | Effects 不为空,Slot 编号不重复 |
|
||
| `QuestSO` | Objectives 不为空,所有 Objective 有唯一 ID |
|