diff --git a/Assets/Tests/PlayMode.meta b/Assets/Tests/PlayMode.meta
new file mode 100644
index 0000000..5de0e06
--- /dev/null
+++ b/Assets/Tests/PlayMode.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 71c6d91864f353542af76dc4bdd78175
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/PlayMode/BaseGames.Tests.PlayMode.asmdef b/Assets/Tests/PlayMode/BaseGames.Tests.PlayMode.asmdef
new file mode 100644
index 0000000..deada6f
--- /dev/null
+++ b/Assets/Tests/PlayMode/BaseGames.Tests.PlayMode.asmdef
@@ -0,0 +1,24 @@
+{
+ "name": "BaseGames.Tests.PlayMode",
+ "rootNamespace": "BaseGames.Tests.PlayMode",
+ "references": [
+ "BaseGames.Core",
+ "BaseGames.Core.Events",
+ "BaseGames.UI",
+ "UnityEngine.TestRunner",
+ "Unity.TextMeshPro"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": true,
+ "precompiledReferences": [
+ "nunit.framework.dll"
+ ],
+ "autoReferenced": false,
+ "defineConstraints": [
+ "UNITY_INCLUDE_TESTS"
+ ],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/Assets/Tests/PlayMode/BaseGames.Tests.PlayMode.asmdef.meta b/Assets/Tests/PlayMode/BaseGames.Tests.PlayMode.asmdef.meta
new file mode 100644
index 0000000..7f3eca3
--- /dev/null
+++ b/Assets/Tests/PlayMode/BaseGames.Tests.PlayMode.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: ba2164f377b449148960bb029fea13e7
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/PlayMode/ColorblindApplierTests.cs b/Assets/Tests/PlayMode/ColorblindApplierTests.cs
new file mode 100644
index 0000000..f8c5ada
--- /dev/null
+++ b/Assets/Tests/PlayMode/ColorblindApplierTests.cs
@@ -0,0 +1,37 @@
+using System.Collections;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+using BaseGames.Core;
+using BaseGames.UI.Utility;
+
+namespace BaseGames.Tests.PlayMode
+{
+ ///
+ /// ColorblindApplier 测试:写入 Shader 全局变量。
+ ///
+ public class ColorblindApplierTests
+ {
+ private GameObject _host;
+
+ [SetUp] public void SetUp() { _host = new GameObject("ColorblindHost"); }
+ [TearDown] public void TearDown() { Object.DestroyImmediate(_host); }
+
+ [UnityTest]
+ public IEnumerator ApplyMode_WritesShaderGlobals()
+ {
+ var applier = _host.AddComponent();
+ applier.ApplyMode(ColorblindMode.Deuteranopia);
+ yield return null;
+ Assert.AreEqual((int)ColorblindMode.Deuteranopia,
+ Shader.GetGlobalInt(Shader.PropertyToID("_GlobalColorblindMode")));
+ Assert.AreEqual(1f,
+ Shader.GetGlobalFloat(Shader.PropertyToID("_GlobalColorblindStrength")), 0.001f);
+
+ applier.ApplyMode(ColorblindMode.None);
+ yield return null;
+ Assert.AreEqual(0,
+ Shader.GetGlobalInt(Shader.PropertyToID("_GlobalColorblindMode")));
+ }
+ }
+}
diff --git a/Assets/Tests/PlayMode/ColorblindApplierTests.cs.meta b/Assets/Tests/PlayMode/ColorblindApplierTests.cs.meta
new file mode 100644
index 0000000..4a50411
--- /dev/null
+++ b/Assets/Tests/PlayMode/ColorblindApplierTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 07ca55485ed3db14e822d55d1116e5d3
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/PlayMode/RequiredFieldValidatorTests.cs b/Assets/Tests/PlayMode/RequiredFieldValidatorTests.cs
new file mode 100644
index 0000000..8625d7b
--- /dev/null
+++ b/Assets/Tests/PlayMode/RequiredFieldValidatorTests.cs
@@ -0,0 +1,49 @@
+using NUnit.Framework;
+using UnityEngine;
+using BaseGames.Core;
+
+namespace BaseGames.Tests.PlayMode
+{
+ ///
+ /// RequiredFieldValidator 反射扫描测试。
+ ///
+ public class RequiredFieldValidatorTests
+ {
+ private class Sample : MonoBehaviour
+ {
+ [RequiredField] public GameObject Required;
+ [RequiredField("提示文本")] public string RequiredString;
+ public GameObject Optional;
+ }
+
+ [Test]
+ public void MissingField_LogsWarning()
+ {
+ var go = new GameObject("ReqHost");
+ var s = go.AddComponent();
+ s.Required = null;
+ s.RequiredString = "";
+
+ // 期待两条警告(Required + RequiredString)
+ UnityEngine.TestTools.LogAssert.Expect(LogType.Warning,
+ new System.Text.RegularExpressions.Regex(@"\[RequiredField\] Sample\.Required.*"));
+ UnityEngine.TestTools.LogAssert.Expect(LogType.Warning,
+ new System.Text.RegularExpressions.Regex(@"\[RequiredField\] Sample\.RequiredString.*"));
+ RequiredFieldValidator.ValidateAll(s);
+
+ Object.DestroyImmediate(go);
+ }
+
+ [Test]
+ public void FilledField_NoWarning()
+ {
+ var go = new GameObject("ReqHost2");
+ var s = go.AddComponent();
+ s.Required = go;
+ s.RequiredString = "ok";
+
+ RequiredFieldValidator.ValidateAll(s); // 不应有警告
+ Object.DestroyImmediate(go);
+ }
+ }
+}
diff --git a/Assets/Tests/PlayMode/RequiredFieldValidatorTests.cs.meta b/Assets/Tests/PlayMode/RequiredFieldValidatorTests.cs.meta
new file mode 100644
index 0000000..a4830c5
--- /dev/null
+++ b/Assets/Tests/PlayMode/RequiredFieldValidatorTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 5afa9356c16c2954f899e0d092be37b3
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/PlayMode/ToastManagerTests.cs b/Assets/Tests/PlayMode/ToastManagerTests.cs
new file mode 100644
index 0000000..945864f
--- /dev/null
+++ b/Assets/Tests/PlayMode/ToastManagerTests.cs
@@ -0,0 +1,79 @@
+using System.Collections;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.UI;
+using UnityEngine.TestTools;
+using TMPro;
+using BaseGames.UI;
+
+namespace BaseGames.Tests.PlayMode
+{
+ ///
+ /// ToastManager 队列行为测试:
+ /// · Enqueue 一条后 Toast 激活并最终隐藏
+ /// · 连续 Enqueue 3 条按序串行播放
+ ///
+ /// 不依赖事件频道(直接调用 Enqueue),不依赖本地化(标题/正文为常量字符串)。
+ ///
+ public class ToastManagerTests
+ {
+ private GameObject _host;
+ private ToastManager _mgr;
+ private ToastNotification _toast;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _host = new GameObject("ToastHost");
+
+ // Toast 预制:CanvasGroup + 子文本
+ var toastGO = new GameObject("Toast", typeof(CanvasGroup));
+ toastGO.transform.SetParent(_host.transform);
+ toastGO.SetActive(false);
+ _toast = toastGO.AddComponent();
+ // 反射注入 _displayDuration / _fadeDuration 减为短值,缩短测试时长
+ SetPrivate(_toast, "_displayDuration", 0.05f);
+ SetPrivate(_toast, "_fadeDuration", 0.02f);
+
+ _mgr = _host.AddComponent();
+ SetPrivate(_mgr, "_toast", _toast);
+ _host.SetActive(false);
+ _host.SetActive(true); // 触发 Awake/OnEnable
+ }
+
+ [TearDown]
+ public void TearDown() { Object.DestroyImmediate(_host); }
+
+ [UnityTest]
+ public IEnumerator Enqueue_ShowsThenHides()
+ {
+ _mgr.Enqueue("T", "B", null);
+ yield return null;
+ Assert.IsTrue(_toast.gameObject.activeSelf, "入队后 Toast 应当激活");
+
+ // 总时长 ~= 0.02+0.05+0.02 = 0.09s,再 + 队列等待 0.1s
+ yield return new WaitForSecondsRealtime(0.5f);
+ Assert.IsFalse(_toast.gameObject.activeSelf, "Toast 自动隐藏未生效");
+ }
+
+ [UnityTest]
+ public IEnumerator MultipleEnqueue_PlaysSerially()
+ {
+ _mgr.Enqueue("A", "1", null);
+ _mgr.Enqueue("B", "2", null);
+ yield return null;
+ Assert.IsTrue(_toast.gameObject.activeSelf, "第一条应当立即播放");
+ // 不严格验证内容(涉及私有字段),只验证活动状态推进。
+ yield return new WaitForSecondsRealtime(1.0f);
+ Assert.IsFalse(_toast.gameObject.activeSelf, "两条串行播放后应当全部结束");
+ }
+
+ // ── 反射工具 ──────────────────────────────────────────────────────
+ private static void SetPrivate(object target, string fieldName, object value)
+ {
+ var f = target.GetType().GetField(fieldName,
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ f?.SetValue(target, value);
+ }
+ }
+}
diff --git a/Assets/Tests/PlayMode/ToastManagerTests.cs.meta b/Assets/Tests/PlayMode/ToastManagerTests.cs.meta
new file mode 100644
index 0000000..8fd3b27
--- /dev/null
+++ b/Assets/Tests/PlayMode/ToastManagerTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ee01b829ae0f99445b90f0561e2ccae1
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/PlayMode/UIManagerSmokeTest.cs b/Assets/Tests/PlayMode/UIManagerSmokeTest.cs
new file mode 100644
index 0000000..324d8fb
--- /dev/null
+++ b/Assets/Tests/PlayMode/UIManagerSmokeTest.cs
@@ -0,0 +1,94 @@
+using System.Collections;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+using BaseGames.UI;
+
+namespace BaseGames.Tests.PlayMode
+{
+ ///
+ /// UIManager 烟雾测试:验证面板栈基本不变式。
+ ///
+ /// 覆盖:
+ /// · OpenPanel → CloseTopPanel 后栈为空;
+ /// · 嵌套打开(A → B → CloseTop)后 A 仍处于激活态;
+ /// · 重复 OpenPanel 同一面板不会双压栈(HashSet 去重)。
+ ///
+ /// 注:测试只覆盖框架行为,不验证业务面板(CharmPanel 等)的内部逻辑。
+ ///
+ public class UIManagerSmokeTest
+ {
+ private GameObject _hostGO;
+ private UIManager _manager;
+ private GameObject _panelA;
+ private GameObject _panelB;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _hostGO = new GameObject("UIManager_Host");
+ _manager = _hostGO.AddComponent();
+ _panelA = new GameObject("PanelA");
+ _panelB = new GameObject("PanelB");
+ _panelA.SetActive(false);
+ _panelB.SetActive(false);
+
+ // 强制 OnEnable 触发(AddComponent 同帧已触发,这里二次启用保证 ServiceLocator 状态)
+ _hostGO.SetActive(false);
+ _hostGO.SetActive(true);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ Object.DestroyImmediate(_panelA);
+ Object.DestroyImmediate(_panelB);
+ Object.DestroyImmediate(_hostGO);
+ }
+
+ [UnityTest]
+ public IEnumerator OpenThenCloseTop_PanelDeactivated()
+ {
+ _manager.OpenPanel(_panelA);
+ yield return null;
+
+ Assert.IsTrue(_panelA.activeSelf, "OpenPanel 应当激活面板");
+
+ _manager.CloseTopPanel();
+ yield return null;
+
+ Assert.IsFalse(_panelA.activeSelf, "CloseTopPanel 应当反激活栈顶面板");
+ }
+
+ [UnityTest]
+ public IEnumerator NestedOpen_PreviousPanelHiddenAndRestored()
+ {
+ _manager.OpenPanel(_panelA);
+ _manager.OpenPanel(_panelB);
+ yield return null;
+
+ Assert.IsFalse(_panelA.activeSelf, "嵌套打开时上一层应被隐藏");
+ Assert.IsTrue(_panelB.activeSelf, "新打开的面板应当激活");
+
+ _manager.CloseTopPanel();
+ yield return null;
+
+ Assert.IsTrue(_panelA.activeSelf, "关闭栈顶后上一层应当恢复");
+ Assert.IsFalse(_panelB.activeSelf, "关闭后栈顶面板应当反激活");
+ }
+
+ [UnityTest]
+ public IEnumerator DoubleOpenSamePanel_NoStackDuplication()
+ {
+ _manager.OpenPanel(_panelA);
+ _manager.OpenPanel(_panelA);
+ yield return null;
+
+ // 关闭一次后栈应为空
+ _manager.CloseTopPanel();
+ yield return null;
+
+ Assert.IsFalse(_panelA.activeSelf, "重复打开应被去重;关闭一次后即应隐藏");
+ }
+ }
+}
diff --git a/Assets/Tests/PlayMode/UIManagerSmokeTest.cs.meta b/Assets/Tests/PlayMode/UIManagerSmokeTest.cs.meta
new file mode 100644
index 0000000..adb700d
--- /dev/null
+++ b/Assets/Tests/PlayMode/UIManagerSmokeTest.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 953f5cb03f10b1d4db3d8da7121ba910
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/PlayMode/UITweenTests.cs b/Assets/Tests/PlayMode/UITweenTests.cs
new file mode 100644
index 0000000..33cc712
--- /dev/null
+++ b/Assets/Tests/PlayMode/UITweenTests.cs
@@ -0,0 +1,60 @@
+using System.Collections;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.UI;
+using UnityEngine.TestTools;
+using BaseGames.UI;
+
+namespace BaseGames.Tests.PlayMode
+{
+ ///
+ /// UITween 静态库测试:验证补间在终态吸附、零时长立即返回、null 安全。
+ ///
+ public class UITweenTests
+ {
+ private GameObject _go;
+
+ [SetUp] public void SetUp() { _go = new GameObject("TweenHost", typeof(RectTransform), typeof(CanvasGroup)); }
+ [TearDown] public void TearDown() { Object.DestroyImmediate(_go); }
+
+ [UnityTest]
+ public IEnumerator MoveAnchored_ReachesTarget()
+ {
+ var rect = _go.GetComponent();
+ rect.anchoredPosition = Vector2.zero;
+ yield return _go.AddComponent().Run(UITween.MoveAnchored(rect, new Vector2(100, 50), 0.05f));
+ Assert.AreEqual(new Vector2(100, 50), rect.anchoredPosition);
+ }
+
+ [UnityTest]
+ public IEnumerator FadeCanvasGroup_ReachesTarget()
+ {
+ var cg = _go.GetComponent();
+ cg.alpha = 0f;
+ yield return _go.AddComponent().Run(UITween.FadeCanvasGroup(cg, 1f, 0.05f));
+ Assert.AreEqual(1f, cg.alpha, 0.001f);
+ }
+
+ [UnityTest]
+ public IEnumerator ZeroDuration_SnapsImmediately()
+ {
+ var rect = _go.GetComponent();
+ yield return _go.AddComponent().Run(UITween.MoveAnchored(rect, new Vector2(7, 7), 0f));
+ Assert.AreEqual(new Vector2(7, 7), rect.anchoredPosition);
+ }
+
+ [UnityTest]
+ public IEnumerator NullTarget_NoException()
+ {
+ yield return _go.AddComponent().Run(UITween.MoveAnchored(null, Vector2.one, 0.05f));
+ yield return _go.AddComponent().Run(UITween.FadeCanvasGroup(null, 1f, 0.05f));
+ Assert.Pass();
+ }
+
+ /// 挂宿主跑协程的辅助 MonoBehaviour(测试场景无 EventSystem)。
+ private class TestRunner : MonoBehaviour
+ {
+ public IEnumerator Run(IEnumerator inner) { yield return StartCoroutine(inner); }
+ }
+ }
+}
diff --git a/Assets/Tests/PlayMode/UITweenTests.cs.meta b/Assets/Tests/PlayMode/UITweenTests.cs.meta
new file mode 100644
index 0000000..02ec1d9
--- /dev/null
+++ b/Assets/Tests/PlayMode/UITweenTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 65492216fce745f41b6a7352763938e2
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Core/GlobalSettingsSO.cs b/Assets/_Game/Scripts/Core/GlobalSettingsSO.cs
index 97a3633..beda518 100644
--- a/Assets/_Game/Scripts/Core/GlobalSettingsSO.cs
+++ b/Assets/_Game/Scripts/Core/GlobalSettingsSO.cs
@@ -3,6 +3,17 @@ using BaseGames.Core.Events;
namespace BaseGames.Core
{
+ ///
+ /// 色盲滤镜模式。运行时由后期处理(如 URP Volume)读取并切换对应的 LUT/Shader。
+ ///
+ public enum ColorblindMode
+ {
+ None = 0,
+ Protanopia = 1,
+ Deuteranopia = 2,
+ Tritanopia = 3,
+ }
+
///
/// 游戏全局设置数据(运行时值)。
///
@@ -21,6 +32,16 @@ namespace BaseGames.Core
public string Language = "zh-CN";
public bool ShowSpeedrunTimer = false;
+
+ // ── 可访问性 ────────────────────────────────────────────────────────
+ [Tooltip("UI 整体缩放系数(0.8 ~ 1.5),通过 CanvasScaler 应用。")]
+ public float UIScale = 1f;
+
+ [Tooltip("色盲滤镜模式。")]
+ public ColorblindMode ColorblindMode = ColorblindMode.None;
+
+ [Tooltip("镜头/UI 震动开关;关闭后受击晃动、命中冲击等屏幕震动被屏蔽。")]
+ public bool ScreenShakeEnabled = true;
}
///
@@ -46,6 +67,11 @@ namespace BaseGames.Core
[Header("Speedrun")]
public bool ShowSpeedrunTimer = false;
+ [Header("Accessibility")]
+ [Range(0.8f, 1.5f)] public float DefaultUIScale = 1f;
+ public ColorblindMode DefaultColorblindMode = ColorblindMode.None;
+ public bool DefaultScreenShakeEnabled = true;
+
/// 将 SO 默认值填入 GlobalSettingsData。
public GlobalSettingsData CreateDefault() => new GlobalSettingsData
{
@@ -58,6 +84,9 @@ namespace BaseGames.Core
FullScreen = DefaultFullScreen,
Language = DefaultLanguage,
ShowSpeedrunTimer = ShowSpeedrunTimer,
+ UIScale = DefaultUIScale,
+ ColorblindMode = DefaultColorblindMode,
+ ScreenShakeEnabled = DefaultScreenShakeEnabled,
};
}
}
diff --git a/Assets/_Game/Scripts/Core/ISettingsService.cs b/Assets/_Game/Scripts/Core/ISettingsService.cs
index 9ebd7b5..e44d9d6 100644
--- a/Assets/_Game/Scripts/Core/ISettingsService.cs
+++ b/Assets/_Game/Scripts/Core/ISettingsService.cs
@@ -17,6 +17,12 @@ namespace BaseGames.Core
void SetVSync(bool enabled);
void SetTargetFrameRate(int fps);
void SetLanguage(string localeCode);
+
+ // ── 可访问性 ────────────────────────────────────────────────────────
+ void SetUIScale(float scale);
+ void SetColorblindMode(ColorblindMode mode);
+ void SetScreenShakeEnabled(bool enabled);
+
void Save();
}
}
diff --git a/Assets/_Game/Scripts/Core/RequiredFieldAttribute.cs b/Assets/_Game/Scripts/Core/RequiredFieldAttribute.cs
new file mode 100644
index 0000000..13f1ca0
--- /dev/null
+++ b/Assets/_Game/Scripts/Core/RequiredFieldAttribute.cs
@@ -0,0 +1,55 @@
+using UnityEngine;
+
+namespace BaseGames.Core
+{
+ ///
+ /// 标记一个序列化字段必填。运行期 / Inspector 漏配时给出明确提示,
+ /// 减少策划"为什么没显示"的排查成本。
+ ///
+ /// 用法:[SerializeField, RequiredField] private GameObject _root;
+ ///
+ /// 表现:
+ /// - Inspector 中字段未赋值时显示红色 HelpBox 并加红框(见 Editor/RequiredFieldDrawer.cs)。
+ /// - 调用方在 OnValidate / Awake 中可调用 RequiredFieldValidator.ValidateAll(this) 触发运行期警告。
+ ///
+ [System.AttributeUsage(System.AttributeTargets.Field, AllowMultiple = false, Inherited = true)]
+ public class RequiredFieldAttribute : PropertyAttribute
+ {
+ public readonly string Hint;
+ public RequiredFieldAttribute(string hint = null) { Hint = hint; }
+ }
+
+ /// 运行期辅助:在 OnValidate / Awake 中调用,扫描自身被 [RequiredField] 标注的字段。
+ public static class RequiredFieldValidator
+ {
+ ///
+ /// 反射扫描 target 上所有 [RequiredField] 字段,未赋值时 Debug.LogWarning。
+ /// 建议仅在 OnValidate / Awake 中调用(运行时调用反射有性能开销)。
+ ///
+ public static void ValidateAll(Object target)
+ {
+ if (target == null) return;
+ var type = target.GetType();
+ var fields = type.GetFields(System.Reflection.BindingFlags.Instance
+ | System.Reflection.BindingFlags.Public
+ | System.Reflection.BindingFlags.NonPublic);
+ foreach (var f in fields)
+ {
+ var attr = (RequiredFieldAttribute)System.Attribute.GetCustomAttribute(f, typeof(RequiredFieldAttribute));
+ if (attr == null) continue;
+ var value = f.GetValue(target);
+ if (IsNullOrMissingRef(value))
+ {
+ var hint = string.IsNullOrEmpty(attr.Hint) ? "" : $"({attr.Hint})";
+ Debug.LogWarning($"[RequiredField] {type.Name}.{f.Name} 未赋值{hint}!", target);
+ }
+ }
+ }
+
+ private static bool IsNullOrMissingRef(object value)
+ {
+ if (value is Object uo) return uo == null; // 含 Missing Reference 的"虚假 null"也算
+ return value == null;
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Core/RequiredFieldAttribute.cs.meta b/Assets/_Game/Scripts/Core/RequiredFieldAttribute.cs.meta
new file mode 100644
index 0000000..cd8e3e5
--- /dev/null
+++ b/Assets/_Game/Scripts/Core/RequiredFieldAttribute.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ee796ace5d7a52643a001ca1968b6e28
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Core/SettingsManager.cs b/Assets/_Game/Scripts/Core/SettingsManager.cs
index 9d01a86..74d3f15 100644
--- a/Assets/_Game/Scripts/Core/SettingsManager.cs
+++ b/Assets/_Game/Scripts/Core/SettingsManager.cs
@@ -1,3 +1,4 @@
+using System;
using System.IO;
using UnityEngine;
@@ -5,6 +6,8 @@ namespace BaseGames.Core
{
///
/// 全局设置管理器。从 GlobalSettingsSO 读取默认值,从文件加载用户覆盖。
+ /// 任何 Setter 调用 Save() 后会触发 静态事件,
+ /// 供 UIScaleApplier / ColorblindApplier / CameraShake 等订阅。
///
[DefaultExecutionOrder(-800)]
public class SettingsManager : MonoBehaviour, ISettingsService
@@ -18,6 +21,9 @@ namespace BaseGames.Core
public GlobalSettingsData Current => _current;
+ /// 设置变更后触发(用于 UIScaleApplier、色盲滤镜、Camera Shake 等订阅)。
+ public static event Action SettingsChanged;
+
private void Awake()
{
ServiceLocator.Register(this);
@@ -28,6 +34,7 @@ namespace BaseGames.Core
{
_current = Load() ?? _defaultSettings?.CreateDefault() ?? new GlobalSettingsData();
Apply(_current);
+ SettingsChanged?.Invoke(_current);
}
private GlobalSettingsData Load()
@@ -55,6 +62,7 @@ namespace BaseGames.Core
{
Debug.LogWarning($"[SettingsManager] 设置保存失败: {e.Message}");
}
+ SettingsChanged?.Invoke(_current);
}
private void Apply(GlobalSettingsData data)
@@ -66,13 +74,13 @@ namespace BaseGames.Core
Screen.fullScreenMode = FullScreenMode.FullScreenWindow;
}
- // ── 音量设置(调用 AudioManager)────────────────────
+ // ── 音量设置(调用 AudioManager)─────────────────────────────────────
public void SetMasterVolume(float v) { _current.MasterVolume = v; Save(); }
public void SetBGMVolume(float v) { _current.BGMVolume = v; Save(); }
public void SetSFXVolume(float v) { _current.SFXVolume = v; Save(); }
public void SetAmbientVolume(float v) { _current.AmbientVolume = v; Save(); }
- // ── 画面设置 ──────────────────────────────────────────────────────
+ // ── 画面设置 ──────────────────────────────────────────────────────────
public void SetResolution(int w, int h, FullScreenMode mode)
{
Screen.SetResolution(w, h, mode);
@@ -98,6 +106,23 @@ namespace BaseGames.Core
Save();
}
+ // ── 可访问性 ──────────────────────────────────────────────────────────
+ public void SetUIScale(float scale)
+ {
+ _current.UIScale = Mathf.Clamp(scale, 0.5f, 2f);
+ Save();
+ }
+ public void SetColorblindMode(ColorblindMode mode)
+ {
+ _current.ColorblindMode = mode;
+ Save();
+ }
+ public void SetScreenShakeEnabled(bool enabled)
+ {
+ _current.ScreenShakeEnabled = enabled;
+ Save();
+ }
+
private void OnDestroy()
{
ServiceLocator.Unregister(this);
diff --git a/Assets/_Game/Scripts/Editor/Inspector.meta b/Assets/_Game/Scripts/Editor/Inspector.meta
new file mode 100644
index 0000000..18ae184
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Inspector.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: bb77d9e506b335e46860387632561634
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Editor/Inspector/RequiredFieldDrawer.cs b/Assets/_Game/Scripts/Editor/Inspector/RequiredFieldDrawer.cs
new file mode 100644
index 0000000..58702fd
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Inspector/RequiredFieldDrawer.cs
@@ -0,0 +1,58 @@
+using UnityEditor;
+using UnityEngine;
+using BaseGames.Core;
+
+namespace BaseGames.Editor.Inspector
+{
+ ///
+ /// [RequiredField] 的 Inspector 绘制:未赋值时显示红色 HelpBox。
+ ///
+ [CustomPropertyDrawer(typeof(RequiredFieldAttribute))]
+ public class RequiredFieldDrawer : PropertyDrawer
+ {
+ public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
+ {
+ bool missing = IsMissing(property);
+ if (missing)
+ {
+ var msgHeight = EditorGUIUtility.singleLineHeight * 1.6f;
+ var msgRect = new Rect(position.x, position.y, position.width, msgHeight);
+ var attr = (RequiredFieldAttribute)attribute;
+ var hint = string.IsNullOrEmpty(attr.Hint) ? "" : $" ({attr.Hint})";
+ EditorGUI.HelpBox(msgRect, $"必填字段未赋值{hint}", MessageType.Error);
+
+ var fieldRect = new Rect(position.x, position.y + msgHeight + 2, position.width,
+ EditorGUIUtility.singleLineHeight);
+ var prev = GUI.color; GUI.color = new Color(1f, 0.6f, 0.6f);
+ EditorGUI.PropertyField(fieldRect, property, label);
+ GUI.color = prev;
+ }
+ else
+ {
+ EditorGUI.PropertyField(position, property, label, true);
+ }
+ }
+
+ public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
+ {
+ bool missing = IsMissing(property);
+ float baseH = EditorGUI.GetPropertyHeight(property, label, true);
+ return missing ? baseH + EditorGUIUtility.singleLineHeight * 1.6f + 4f : baseH;
+ }
+
+ private static bool IsMissing(SerializedProperty p)
+ {
+ switch (p.propertyType)
+ {
+ case SerializedPropertyType.ObjectReference:
+ return p.objectReferenceValue == null;
+ case SerializedPropertyType.String:
+ return string.IsNullOrEmpty(p.stringValue);
+ case SerializedPropertyType.ExposedReference:
+ return p.exposedReferenceValue == null;
+ default:
+ return false;
+ }
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Editor/Inspector/RequiredFieldDrawer.cs.meta b/Assets/_Game/Scripts/Editor/Inspector/RequiredFieldDrawer.cs.meta
new file mode 100644
index 0000000..b8cbc4e
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Inspector/RequiredFieldDrawer.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 7fbd662bb1183a549b6b2c63b8fd86f6
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Editor/Modules/EventChannelModule.cs b/Assets/_Game/Scripts/Editor/Modules/EventChannelModule.cs
new file mode 100644
index 0000000..7b7dcfc
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Modules/EventChannelModule.cs
@@ -0,0 +1,189 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace BaseGames.Editor.Modules
+{
+ ///
+ /// DataHub 事件频道反查模块 —— 列出项目中所有 EventChannelSO 子类资产;
+ /// 选中后可扫描全项目中引用该频道的 Prefab / ScriptableObject,
+ /// 解决"谁发布/谁订阅了某条事件"在大型项目中难以追踪的问题。
+ ///
+ /// 反查实现:遍历所有 Prefab 与 SO 资产,用 AssetDatabase.GetDependencies
+ /// 检查依赖链是否包含目标频道资产路径。首次扫描会耗时较长(取决于资产规模),
+ /// 之后按选中频道增量计算。
+ ///
+ public class EventChannelModule : IDataModule, IDataModuleOrdered
+ {
+ public string ModuleId => "eventchannel";
+ public string DisplayName => "事件频道";
+ public string IconName => "d_AudioMixerController Icon";
+ public int DisplayOrder => 200;
+
+ private ListView _channelList;
+ private readonly List _allChannels = new();
+ private ScriptableObject _selected;
+ private VisualElement _refsContainer;
+
+ private struct EventChannelEntry
+ {
+ public string TypeName;
+ public string AssetPath;
+ public ScriptableObject Asset;
+ }
+
+ public void Initialize() => Refresh();
+
+ public void OnActivated() => Refresh();
+
+ private void Refresh()
+ {
+ _allChannels.Clear();
+ // 扫描所有 ScriptableObject 子类,筛选名称以 "EventChannelSO" 结尾的(项目约定)。
+ var types = TypeCache.GetTypesDerivedFrom();
+ foreach (var t in types)
+ {
+ if (t.IsAbstract) continue;
+ if (!t.Name.EndsWith("EventChannelSO", StringComparison.Ordinal)) continue;
+ var guids = AssetDatabase.FindAssets($"t:{t.Name}");
+ foreach (var g in guids)
+ {
+ var path = AssetDatabase.GUIDToAssetPath(g);
+ var asset = AssetDatabase.LoadAssetAtPath(path);
+ if (asset == null) continue;
+ _allChannels.Add(new EventChannelEntry
+ {
+ TypeName = t.Name,
+ AssetPath = path,
+ Asset = asset
+ });
+ }
+ }
+ _allChannels.Sort((a, b) =>
+ {
+ int c = string.Compare(a.TypeName, b.TypeName, StringComparison.Ordinal);
+ return c != 0 ? c : string.Compare(a.AssetPath, b.AssetPath, StringComparison.Ordinal);
+ });
+ _channelList?.Rebuild();
+ }
+
+ public void BuildListPane(VisualElement container, Action onSelected)
+ {
+ var header = new Label($"频道总数:{_allChannels.Count}");
+ header.style.unityFontStyleAndWeight = FontStyle.Bold;
+ header.style.paddingLeft = 6;
+ header.style.paddingTop = 4;
+ container.Add(header);
+
+ _channelList = new ListView(_allChannels, 20,
+ () => new Label { style = { paddingLeft = 6 } },
+ (ve, i) =>
+ {
+ var entry = _allChannels[i];
+ var label = (Label)ve;
+ label.text = $"[{entry.TypeName}] {System.IO.Path.GetFileNameWithoutExtension(entry.AssetPath)}";
+ label.tooltip = entry.AssetPath;
+ });
+ _channelList.style.flexGrow = 1;
+ _channelList.selectionChanged += sel =>
+ {
+ var first = sel.FirstOrDefault();
+ if (first is EventChannelEntry e)
+ {
+ _selected = e.Asset;
+ onSelected?.Invoke(e.Asset);
+ }
+ };
+ container.Add(_channelList);
+ }
+
+ public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
+ {
+ _selected = selected as ScriptableObject;
+ container.Clear();
+
+ if (_selected == null)
+ {
+ var hint = new Label("请在左侧选择一条频道资产。");
+ hint.style.paddingLeft = 8; hint.style.paddingTop = 8;
+ container.Add(hint);
+ return;
+ }
+
+ // Header
+ var title = new Label($"{_selected.GetType().Name} · {_selected.name}");
+ title.style.unityFontStyleAndWeight = FontStyle.Bold;
+ title.style.fontSize = 14;
+ title.style.paddingLeft = 8; title.style.paddingTop = 6;
+ container.Add(title);
+
+ var pathLabel = new Label(AssetDatabase.GetAssetPath(_selected))
+ {
+ style = { paddingLeft = 8, paddingBottom = 6, color = new StyleColor(new Color(0.7f, 0.7f, 0.7f)) }
+ };
+ container.Add(pathLabel);
+
+ var scanBtn = new Button(() => ScanReferences(_selected))
+ { text = "扫描全项目引用(Prefab / ScriptableObject)" };
+ scanBtn.style.marginLeft = 8; scanBtn.style.marginRight = 8;
+ container.Add(scanBtn);
+
+ _refsContainer = new VisualElement();
+ _refsContainer.style.paddingLeft = 8; _refsContainer.style.paddingTop = 6;
+ container.Add(_refsContainer);
+ }
+
+ private void ScanReferences(ScriptableObject channel)
+ {
+ if (channel == null || _refsContainer == null) return;
+ _refsContainer.Clear();
+
+ var targetPath = AssetDatabase.GetAssetPath(channel);
+ if (string.IsNullOrEmpty(targetPath)) return;
+
+ var found = new List();
+ try
+ {
+ EditorUtility.DisplayProgressBar("扫描引用", "正在扫描 Prefab 与 SO …", 0f);
+
+ var prefabGuids = AssetDatabase.FindAssets("t:Prefab");
+ var soGuids = AssetDatabase.FindAssets("t:ScriptableObject");
+ var all = prefabGuids.Concat(soGuids).ToArray();
+
+ for (int i = 0; i < all.Length; i++)
+ {
+ var p = AssetDatabase.GUIDToAssetPath(all[i]);
+ if (p == targetPath) continue;
+ if (i % 64 == 0)
+ EditorUtility.DisplayProgressBar("扫描引用", p, (float)i / all.Length);
+
+ var deps = AssetDatabase.GetDependencies(p, recursive: false);
+ if (System.Array.IndexOf(deps, targetPath) >= 0)
+ found.Add(p);
+ }
+ }
+ finally { EditorUtility.ClearProgressBar(); }
+
+ var summary = new Label($"找到 {found.Count} 个直接引用:");
+ summary.style.unityFontStyleAndWeight = FontStyle.Bold;
+ _refsContainer.Add(summary);
+
+ foreach (var path in found)
+ {
+ var btn = new Button(() =>
+ {
+ var obj = AssetDatabase.LoadMainAssetAtPath(path);
+ EditorGUIUtility.PingObject(obj);
+ Selection.activeObject = obj;
+ })
+ { text = path };
+ btn.style.unityTextAlign = TextAnchor.MiddleLeft;
+ btn.style.marginTop = 2;
+ _refsContainer.Add(btn);
+ }
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Editor/Modules/EventChannelModule.cs.meta b/Assets/_Game/Scripts/Editor/Modules/EventChannelModule.cs.meta
new file mode 100644
index 0000000..6cc3f67
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Modules/EventChannelModule.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9f9fe819b8566b04494f41329e5e37e9
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Support/Accessibility/AccessibilityManager.cs b/Assets/_Game/Scripts/Support/Accessibility/AccessibilityManager.cs
index a8c12a7..12b8edc 100644
--- a/Assets/_Game/Scripts/Support/Accessibility/AccessibilityManager.cs
+++ b/Assets/_Game/Scripts/Support/Accessibility/AccessibilityManager.cs
@@ -1,6 +1,10 @@
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
+// 项目内存在两份同名枚举(Core.ColorblindMode 与 Core.Events.ColorblindMode)。
+// AccessibilitySettingsSO / ColorBlindFilter / ColorblindModeEventChannelSO 使用 Events 版本,
+// 因此在本文件中明确以 Events 版本消歧,避免 CS0104。
+using ColorblindMode = BaseGames.Core.Events.ColorblindMode;
namespace BaseGames.Support.Accessibility
{
diff --git a/Assets/_Game/Scripts/UI/BaseGames.UI.asmdef b/Assets/_Game/Scripts/UI/BaseGames.UI.asmdef
index 3a11d75..997bc7a 100644
--- a/Assets/_Game/Scripts/UI/BaseGames.UI.asmdef
+++ b/Assets/_Game/Scripts/UI/BaseGames.UI.asmdef
@@ -20,7 +20,9 @@
"BaseGames.Combat.StatusEffects",
"BaseGames.Spells",
"BaseGames.Quest",
- "BaseGames.Skills"
+ "BaseGames.Skills",
+ "Unity.Addressables",
+ "Unity.ResourceManager"
],
"autoReferenced": true,
"overrideReferences": false,
diff --git a/Assets/_Game/Scripts/UI/CharmCardView.cs b/Assets/_Game/Scripts/UI/CharmCardView.cs
new file mode 100644
index 0000000..1c8ad77
--- /dev/null
+++ b/Assets/_Game/Scripts/UI/CharmCardView.cs
@@ -0,0 +1,95 @@
+using System;
+using UnityEngine;
+using UnityEngine.UI;
+using TMPro;
+using BaseGames.Equipment;
+using BaseGames.Localization;
+
+namespace BaseGames.UI
+{
+ ///
+ /// 护符卡片视图组件。
+ ///
+ /// 设计动机:取代 中基于
+ /// + 数组索引的脆弱绑定方式,
+ /// 通过 Inspector 显式序列化引用,避免每次重建列表时的反射开销与索引漂移风险。
+ ///
+ /// 用法:在卡片 Prefab 上挂载此组件并连接子节点引用,
+ /// 仅需调用 即可完成数据 → UI 的同步。
+ ///
+ [DisallowMultipleComponent]
+ public class CharmCardView : MonoBehaviour
+ {
+ [Header("Visual")]
+ [SerializeField] private Image _icon;
+ [SerializeField] private GameObject _iconRoot; // 可选:无 icon 时整体隐藏
+
+ [Header("Text")]
+ [SerializeField] private TMP_Text _nameText;
+ [SerializeField] private TMP_Text _notchCostText;
+ [SerializeField] private TMP_Text _descriptionText;
+ [SerializeField] private TMP_Text _equipBadgeText; // 已装备角标 ("✓")
+
+ [Header("Interaction")]
+ [SerializeField] private Button _actionButton;
+
+ ///
+ /// 将护符数据绑定到视图。
+ ///
+ /// 数据源。
+ /// 是否已装备(决定按钮行为与角标显示)。
+ /// 点击装备时回调(仅 = false 时使用)。
+ /// 点击卸下时回调(仅 = true 时使用)。
+ public void Bind(CharmSO charm,
+ bool isEquipped,
+ Action onEquip,
+ Action onUnequip)
+ {
+ if (charm == null) return;
+
+ // ── 图标 ────────────────────────────────────────────────────────
+ if (_icon != null)
+ {
+ _icon.sprite = charm.icon;
+ _icon.enabled = charm.icon != null;
+ }
+ if (_iconRoot != null) _iconRoot.SetActive(charm.icon != null);
+
+ // ── 文本(本地化容错:未找到键时回落到 charmId / 空字符串)──────
+ if (_nameText != null)
+ {
+ string loc = LocalizationManager.Get(charm.displayNameKey, LocalizationTable.Items);
+ _nameText.text = !string.IsNullOrEmpty(loc) && loc != charm.displayNameKey
+ ? loc : charm.charmId;
+ }
+ if (_notchCostText != null)
+ _notchCostText.text = charm.notchCost.ToString();
+
+ if (_descriptionText != null)
+ {
+ string loc = LocalizationManager.Get(charm.descriptionKey, LocalizationTable.Items);
+ _descriptionText.text = !string.IsNullOrEmpty(loc) && loc != charm.descriptionKey
+ ? loc : string.Empty;
+ }
+ if (_equipBadgeText != null)
+ _equipBadgeText.text = isEquipped ? "✓" : string.Empty;
+
+ // ── 按钮 ────────────────────────────────────────────────────────
+ if (_actionButton != null)
+ {
+ _actionButton.onClick.RemoveAllListeners();
+ var captured = charm;
+ if (isEquipped)
+ {
+ if (onUnequip != null)
+ _actionButton.onClick.AddListener(() => onUnequip(captured));
+ }
+ else
+ {
+ if (onEquip != null)
+ _actionButton.onClick.AddListener(() => onEquip(captured));
+ }
+ }
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/UI/CharmCardView.cs.meta b/Assets/_Game/Scripts/UI/CharmCardView.cs.meta
new file mode 100644
index 0000000..bd38320
--- /dev/null
+++ b/Assets/_Game/Scripts/UI/CharmCardView.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 0aa9daa814d61c94aaed9b6db2ac1fdf
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/UI/CharmEquipPanel.cs b/Assets/_Game/Scripts/UI/CharmEquipPanel.cs
index 54b3be6..bf76ad1 100644
--- a/Assets/_Game/Scripts/UI/CharmEquipPanel.cs
+++ b/Assets/_Game/Scripts/UI/CharmEquipPanel.cs
@@ -139,12 +139,38 @@ namespace BaseGames.UI
go.transform.SetParent(parent, worldPositionStays: false);
go.SetActive(true);
- // Icon(第一个 Image)
+ // 优先使用预绑定视图组件(推荐);若 Prefab 未挂载则回落到反射查找以保证向后兼容。
+ var view = go.GetComponent();
+ if (view != null)
+ {
+ view.Bind(charm, isEquipped, OnEquipClicked, OnUnequipClicked);
+ }
+ else
+ {
+ FallbackBindByReflection(go, charm, isEquipped);
+ }
+
+ return go;
+ }
+
+ private void OnEquipClicked(CharmSO charm)
+ {
+ string err = _manager?.TryEquipCharm(charm);
+ if (err != null) Debug.LogWarning($"[CharmEquipPanel] 装备失败: {err}");
+ }
+
+ private void OnUnequipClicked(CharmSO charm) => _manager?.UnequipCharm(charm);
+
+ ///
+ /// 兼容旧 Prefab(未挂载 )的反射绑定。
+ /// 仅作为过渡方案;首次开发完成后建议在 Editor 中强制要求新组件。
+ ///
+ private void FallbackBindByReflection(GameObject go, CharmSO charm, bool isEquipped)
+ {
var images = go.GetComponentsInChildren(includeInactive: true);
if (images.Length > 0 && charm.icon != null)
images[0].sprite = charm.icon;
- // 文本(约定:TMP_Text[0] = 名称, TMP_Text[1] = 凹槽消耗, TMP_Text[2] = 描述)
var texts = go.GetComponentsInChildren(includeInactive: true);
if (texts.Length > 0)
{
@@ -152,9 +178,7 @@ namespace BaseGames.UI
texts[0].text = string.IsNullOrEmpty(name) || name == charm.displayNameKey
? charm.charmId : name;
}
- if (texts.Length > 1)
- texts[1].text = charm.notchCost.ToString();
-
+ if (texts.Length > 1) texts[1].text = charm.notchCost.ToString();
if (texts.Length > 2)
{
string desc = LocalizationManager.Get(charm.descriptionKey, LocalizationTable.Items);
@@ -162,34 +186,18 @@ namespace BaseGames.UI
? string.Empty : desc;
}
- // 按钮(约定:第一个 Button = 装备/卸下切换)
var btn = go.GetComponentInChildren