From a1f9122153c5df074cb67e48edb3803060146363 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 25 May 2026 13:21:41 +0800 Subject: [PATCH] =?UTF-8?q?UI=E7=9B=B8=E5=85=B3=E4=BC=98=E5=8C=96=E8=A1=A5?= =?UTF-8?q?=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Tests/PlayMode.meta | 8 + .../PlayMode/BaseGames.Tests.PlayMode.asmdef | 24 +++ .../BaseGames.Tests.PlayMode.asmdef.meta | 7 + .../Tests/PlayMode/ColorblindApplierTests.cs | 37 ++++ .../PlayMode/ColorblindApplierTests.cs.meta | 11 + .../PlayMode/RequiredFieldValidatorTests.cs | 49 +++++ .../RequiredFieldValidatorTests.cs.meta | 11 + Assets/Tests/PlayMode/ToastManagerTests.cs | 79 ++++++++ .../Tests/PlayMode/ToastManagerTests.cs.meta | 11 + Assets/Tests/PlayMode/UIManagerSmokeTest.cs | 94 +++++++++ .../Tests/PlayMode/UIManagerSmokeTest.cs.meta | 11 + Assets/Tests/PlayMode/UITweenTests.cs | 60 ++++++ Assets/Tests/PlayMode/UITweenTests.cs.meta | 11 + Assets/_Game/Scripts/Core/GlobalSettingsSO.cs | 29 +++ Assets/_Game/Scripts/Core/ISettingsService.cs | 6 + .../Scripts/Core/RequiredFieldAttribute.cs | 55 +++++ .../Core/RequiredFieldAttribute.cs.meta | 11 + Assets/_Game/Scripts/Core/SettingsManager.cs | 29 ++- Assets/_Game/Scripts/Editor/Inspector.meta | 8 + .../Editor/Inspector/RequiredFieldDrawer.cs | 58 ++++++ .../Inspector/RequiredFieldDrawer.cs.meta | 11 + .../Editor/Modules/EventChannelModule.cs | 189 +++++++++++++++++ .../Editor/Modules/EventChannelModule.cs.meta | 11 + .../Accessibility/AccessibilityManager.cs | 4 + Assets/_Game/Scripts/UI/BaseGames.UI.asmdef | 4 +- Assets/_Game/Scripts/UI/CharmCardView.cs | 95 +++++++++ Assets/_Game/Scripts/UI/CharmCardView.cs.meta | 11 + Assets/_Game/Scripts/UI/CharmEquipPanel.cs | 56 +++--- Assets/_Game/Scripts/UI/FloatingDamageText.cs | 44 ++-- .../Scripts/UI/FloatingDamageTickSystem.cs | 102 ++++++++++ .../UI/FloatingDamageTickSystem.cs.meta | 11 + Assets/_Game/Scripts/UI/HUD/BossHPBar.cs | 14 +- Assets/_Game/Scripts/UI/HUD/HUDController.cs | 7 + .../_Game/Scripts/UI/LoadingScreenManager.cs | 21 +- .../UI/Menus/SettingsPanelController.cs | 42 ++++ Assets/_Game/Scripts/UI/SubtitleManager.cs | 128 ++++++++++++ .../_Game/Scripts/UI/SubtitleManager.cs.meta | 11 + Assets/_Game/Scripts/UI/Theme.meta | 8 + .../_Game/Scripts/UI/Theme/UIThemeApplier.cs | 99 +++++++++ .../Scripts/UI/Theme/UIThemeApplier.cs.meta | 11 + Assets/_Game/Scripts/UI/Theme/UIThemeRole.cs | 39 ++++ .../Scripts/UI/Theme/UIThemeRole.cs.meta | 11 + Assets/_Game/Scripts/UI/Theme/UIThemeSO.cs | 46 +++++ .../_Game/Scripts/UI/Theme/UIThemeSO.cs.meta | 11 + Assets/_Game/Scripts/UI/ToastManager.cs | 12 +- Assets/_Game/Scripts/UI/UIManager.cs | 190 ++++++++++++++---- Assets/_Game/Scripts/UI/Utility.meta | 8 + .../Scripts/UI/Utility/ColorblindApplier.cs | 66 ++++++ .../UI/Utility/ColorblindApplier.cs.meta | 11 + .../Scripts/UI/Utility/UIScaleApplier.cs | 67 ++++++ .../Scripts/UI/Utility/UIScaleApplier.cs.meta | 11 + Assets/_Game/Scripts/UI/Utility/UITween.cs | 131 ++++++++++++ .../_Game/Scripts/UI/Utility/UITween.cs.meta | 11 + zeling_v2.sln | 18 +- 54 files changed, 2008 insertions(+), 112 deletions(-) create mode 100644 Assets/Tests/PlayMode.meta create mode 100644 Assets/Tests/PlayMode/BaseGames.Tests.PlayMode.asmdef create mode 100644 Assets/Tests/PlayMode/BaseGames.Tests.PlayMode.asmdef.meta create mode 100644 Assets/Tests/PlayMode/ColorblindApplierTests.cs create mode 100644 Assets/Tests/PlayMode/ColorblindApplierTests.cs.meta create mode 100644 Assets/Tests/PlayMode/RequiredFieldValidatorTests.cs create mode 100644 Assets/Tests/PlayMode/RequiredFieldValidatorTests.cs.meta create mode 100644 Assets/Tests/PlayMode/ToastManagerTests.cs create mode 100644 Assets/Tests/PlayMode/ToastManagerTests.cs.meta create mode 100644 Assets/Tests/PlayMode/UIManagerSmokeTest.cs create mode 100644 Assets/Tests/PlayMode/UIManagerSmokeTest.cs.meta create mode 100644 Assets/Tests/PlayMode/UITweenTests.cs create mode 100644 Assets/Tests/PlayMode/UITweenTests.cs.meta create mode 100644 Assets/_Game/Scripts/Core/RequiredFieldAttribute.cs create mode 100644 Assets/_Game/Scripts/Core/RequiredFieldAttribute.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Inspector.meta create mode 100644 Assets/_Game/Scripts/Editor/Inspector/RequiredFieldDrawer.cs create mode 100644 Assets/_Game/Scripts/Editor/Inspector/RequiredFieldDrawer.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Modules/EventChannelModule.cs create mode 100644 Assets/_Game/Scripts/Editor/Modules/EventChannelModule.cs.meta create mode 100644 Assets/_Game/Scripts/UI/CharmCardView.cs create mode 100644 Assets/_Game/Scripts/UI/CharmCardView.cs.meta create mode 100644 Assets/_Game/Scripts/UI/FloatingDamageTickSystem.cs create mode 100644 Assets/_Game/Scripts/UI/FloatingDamageTickSystem.cs.meta create mode 100644 Assets/_Game/Scripts/UI/SubtitleManager.cs create mode 100644 Assets/_Game/Scripts/UI/SubtitleManager.cs.meta create mode 100644 Assets/_Game/Scripts/UI/Theme.meta create mode 100644 Assets/_Game/Scripts/UI/Theme/UIThemeApplier.cs create mode 100644 Assets/_Game/Scripts/UI/Theme/UIThemeApplier.cs.meta create mode 100644 Assets/_Game/Scripts/UI/Theme/UIThemeRole.cs create mode 100644 Assets/_Game/Scripts/UI/Theme/UIThemeRole.cs.meta create mode 100644 Assets/_Game/Scripts/UI/Theme/UIThemeSO.cs create mode 100644 Assets/_Game/Scripts/UI/Theme/UIThemeSO.cs.meta create mode 100644 Assets/_Game/Scripts/UI/Utility.meta create mode 100644 Assets/_Game/Scripts/UI/Utility/ColorblindApplier.cs create mode 100644 Assets/_Game/Scripts/UI/Utility/ColorblindApplier.cs.meta create mode 100644 Assets/_Game/Scripts/UI/Utility/UIScaleApplier.cs create mode 100644 Assets/_Game/Scripts/UI/Utility/UIScaleApplier.cs.meta create mode 100644 Assets/_Game/Scripts/UI/Utility/UITween.cs create mode 100644 Assets/_Game/Scripts/UI/Utility/UITween.cs.meta 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