From 3c812cfb41cffc8180dabec14018e241efbdbb93 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 25 May 2026 11:54:37 +0800 Subject: [PATCH] =?UTF-8?q?=20UI=E7=B3=BB=E7=BB=9F=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/_Game/Resources.meta | 8 + Assets/_Game/Resources/Localization.meta | 8 + .../Localization/ChineseSimplified.meta | 8 + .../Localization/ChineseSimplified/UI.json | 7 + .../ChineseSimplified/UI.json.meta | 7 + .../_Game/Resources/Localization/English.meta | 8 + .../Resources/Localization/English/UI.json | 7 + .../Localization/English/UI.json.meta | 7 + .../Resources/Localization/Japanese.meta | 8 + .../Resources/Localization/Japanese/UI.json | 7 + .../Localization/Japanese/UI.json.meta | 7 + .../_Game/Resources/Localization/Korean.meta | 8 + .../Resources/Localization/Korean/UI.json | 7 + .../Localization/Korean/UI.json.meta | 7 + .../_Game/Scripts/Core/Assets/AddressKeys.cs | 3 + .../IWorldStateReader.cs.meta} | 2 +- .../WorldFlagRegistrySO.cs.meta} | 2 +- .../_Game/Scripts/Dialogue/DialogueActorSO.cs | 12 +- .../Scripts/Dialogue/DialogueSequenceSO.cs | 21 +- Assets/_Game/Scripts/Dialogue/DialogueUI.cs | 8 +- .../_Game/Scripts/Dialogue/InteractableNPC.cs | 6 +- .../Dialogue/InteractionPromptController.cs | 3 + Assets/_Game/Scripts/Dialogue/NpcSO.cs | 13 +- .../DialogueVariantPreviewWindow.cs.meta | 11 + .../Scripts/Editor/Dialogue/NpcSOEditor.cs | 29 +- .../Editor/Dialogue/NpcSOEditor.cs.meta | 11 + Assets/_Game/Scripts/Editor/Localization.meta | 8 + .../Localization/LocalizationCsvTool.cs | 388 ++++++++++++ .../Localization/LocalizationCsvTool.cs.meta | 11 + .../LocalizationKeyPickerWindow.cs | 298 +++++++++ .../LocalizationKeyPickerWindow.cs.meta | 11 + .../Localization/LocalizedTextEditor.cs | 143 +++++ .../Localization/LocalizedTextEditor.cs.meta | 11 + .../Scripts/Editor/Modules/ActorModule.cs | 3 +- .../Scripts/Editor/Modules/DialogueModule.cs | 13 +- .../Editor/Modules/EventChainModule.cs | 6 +- .../Editor/Modules/EventChainModule.cs.meta | 11 + .../Scripts/Editor/Modules/FlagAuditModule.cs | 4 +- .../Editor/Modules/FlagAuditModule.cs.meta | 11 + .../Editor/Modules/IdCodegenModule.cs.meta | 11 + .../Editor/Modules/LocalizationAuditModule.cs | 572 ++++++++++++++++++ .../Modules/LocalizationAuditModule.cs.meta | 11 + .../_Game/Scripts/Editor/Modules/NpcModule.cs | 9 +- .../Scripts/Editor/Modules/QuestModule.cs | 59 +- .../Scripts/Editor/Modules/WeaponModule.cs | 103 +--- .../Quest/QuestOverviewEditorWindow.cs.meta | 11 + .../Editor/Quest/QuestSOEditor.cs.meta | 11 + .../Editor/Shared/AssetCreationWizard.cs.meta | 11 + .../Scripts/Editor/Shared/DataHubEditorKit.cs | 56 ++ .../Editor/Shared/DataHubEditorKit.cs.meta | 11 + .../_Game/Scripts/Editor/Shared/SoListPane.cs | 1 - Assets/_Game/Scripts/Editor/UI.meta | 8 + .../Scripts/Editor/UI/UIManagerEditor.cs | 121 ++++ .../Scripts/Editor/UI/UIManagerEditor.cs.meta | 11 + .../Equipment/BaseGames.Equipment.asmdef | 3 +- Assets/_Game/Scripts/Equipment/CharmSO.cs | 11 +- .../Scripts/Equipment/EquipmentManager.cs | 4 +- .../Scripts/Equipment/IEquipmentService.cs | 31 + .../Equipment/IEquipmentService.cs.meta | 11 + .../Scripts/Equipment/IToolSlotService.cs | 22 + .../Equipment/IToolSlotService.cs.meta | 11 + Assets/_Game/Scripts/Equipment/ToolSO.cs | 12 +- .../Scripts/Equipment/ToolSlotManager.cs | 15 +- .../BaseGames.Localization.asmdef | 34 +- .../Scripts/Localization/ILocalizableAsset.cs | 42 ++ .../Localization/ILocalizableAsset.cs.meta | 11 + .../Localization/ILocalizationService.cs | 39 +- .../Localization/LanguageFontConfigSO.cs | 59 ++ .../Localization/LanguageFontConfigSO.cs.meta | 11 + .../Localization/LocalizationManager.cs | 260 ++++++-- .../Scripts/Localization/LocalizationTable.cs | 39 ++ .../Localization/LocalizationTable.cs.meta | 11 + .../Scripts/Localization/LocalizedText.cs | 144 +++++ .../Localization/LocalizedText.cs.meta | 11 + .../Scripts/Quest/BaseGames.Quest.asmdef | 3 +- Assets/_Game/Scripts/Quest/IQuestManager.cs | 7 + .../Quest/QuestEventChannelRegistry.cs.meta | 11 + Assets/_Game/Scripts/Quest/QuestGiver.cs | 2 +- Assets/_Game/Scripts/Quest/QuestManager.cs | 9 + Assets/_Game/Scripts/Quest/QuestSO.cs | 16 +- .../Scripts/Skills/BaseGames.Skills.asmdef | 3 +- Assets/_Game/Scripts/Skills/FormSkillSO.cs | 12 +- .../Scripts/Spells/BaseGames.Spells.asmdef | 3 +- Assets/_Game/Scripts/Spells/ISpellService.cs | 26 + .../Scripts/Spells/ISpellService.cs.meta | 11 + Assets/_Game/Scripts/Spells/SpellManager.cs | 12 +- Assets/_Game/Scripts/Spells/SpellSO.cs | 13 +- .../Support/Localization/LanguageManagerSO.cs | 51 -- .../Localization/LocalizationManager.cs | 59 -- .../Scripts/Tutorial/ContextualHintTrigger.cs | 2 +- .../_Game/Scripts/Tutorial/TutorialHintUI.cs | 75 ++- Assets/_Game/Scripts/UI/BaseGames.UI.asmdef | 6 +- Assets/_Game/Scripts/UI/CharmEquipPanel.cs | 233 +++++++ .../_Game/Scripts/UI/CharmEquipPanel.cs.meta | 11 + Assets/_Game/Scripts/UI/FloatingDamageText.cs | 6 +- Assets/_Game/Scripts/UI/HUD/BossHPBar.cs | 42 +- Assets/_Game/Scripts/UI/HUD/HUDController.cs | 5 + .../Scripts/UI/HUD/QuestTrackerWidget.cs | 223 +++++++ .../Scripts/UI/HUD/QuestTrackerWidget.cs.meta | 11 + .../_Game/Scripts/UI/HUD/SpellSlotWidget.cs | 126 ++++ .../Scripts/UI/HUD/SpellSlotWidget.cs.meta | 11 + .../_Game/Scripts/UI/HUD/StatusEffectHUD.cs | 185 ++++++ .../Scripts/UI/HUD/StatusEffectHUD.cs.meta | 11 + Assets/_Game/Scripts/UI/IFocusable.cs | 19 + Assets/_Game/Scripts/UI/IFocusable.cs.meta | 11 + Assets/_Game/Scripts/UI/IInputIconService.cs | 6 + Assets/_Game/Scripts/UI/IUIManager.cs | 23 + Assets/_Game/Scripts/UI/IUIManager.cs.meta | 11 + .../Scripts/UI/InputDeviceIconSwitcher.cs | 26 +- Assets/_Game/Scripts/UI/InputIconService.cs | 16 +- .../_Game/Scripts/UI/LoadingScreenManager.cs | 2 +- .../Scripts/UI/MainMenu/MainMenuController.cs | 7 +- .../Scripts/UI/Menus/DeathScreenController.cs | 23 +- .../Scripts/UI/Menus/PauseMenuController.cs | 41 +- .../Scripts/UI/Menus/SaveSlotController.cs | 104 +++- .../UI/Menus/SettingsPanelController.cs | 24 +- .../Scripts/UI/Settings/RebindActionRow.cs | 3 +- Assets/_Game/Scripts/UI/SkillTreePanel.cs | 220 +++++++ .../_Game/Scripts/UI/SkillTreePanel.cs.meta | 11 + Assets/_Game/Scripts/UI/ToastManager.cs | 33 +- Assets/_Game/Scripts/UI/ToolHUD.cs | 46 +- Assets/_Game/Scripts/UI/UIManager.cs | 163 ++++- .../World/Map/BaseGames.World.Map.asmdef | 1 + Assets/_Game/Scripts/World/Map/MapPanel.cs | 13 +- .../Scripts/World/Map/RegionNameDisplay.cs | 23 +- .../World/Shop/BaseGames.World.Shop.asmdef | 5 +- Assets/_Game/Scripts/World/Shop/ShopItemSO.cs | 6 + .../_Game/Scripts/World/Shop/ShopPanelUI.cs | 240 ++++++++ .../Scripts/World/Shop/ShopPanelUI.cs.meta | 11 + zeling_v2.sln | 12 +- 130 files changed, 4738 insertions(+), 477 deletions(-) create mode 100644 Assets/_Game/Resources.meta create mode 100644 Assets/_Game/Resources/Localization.meta create mode 100644 Assets/_Game/Resources/Localization/ChineseSimplified.meta create mode 100644 Assets/_Game/Resources/Localization/ChineseSimplified/UI.json create mode 100644 Assets/_Game/Resources/Localization/ChineseSimplified/UI.json.meta create mode 100644 Assets/_Game/Resources/Localization/English.meta create mode 100644 Assets/_Game/Resources/Localization/English/UI.json create mode 100644 Assets/_Game/Resources/Localization/English/UI.json.meta create mode 100644 Assets/_Game/Resources/Localization/Japanese.meta create mode 100644 Assets/_Game/Resources/Localization/Japanese/UI.json create mode 100644 Assets/_Game/Resources/Localization/Japanese/UI.json.meta create mode 100644 Assets/_Game/Resources/Localization/Korean.meta create mode 100644 Assets/_Game/Resources/Localization/Korean/UI.json create mode 100644 Assets/_Game/Resources/Localization/Korean/UI.json.meta rename Assets/_Game/Scripts/{Support/Localization/LanguageManagerSO.cs.meta => Core/IWorldStateReader.cs.meta} (83%) rename Assets/_Game/Scripts/{Support/Localization/LocalizationManager.cs.meta => Core/WorldFlagRegistrySO.cs.meta} (83%) create mode 100644 Assets/_Game/Scripts/Editor/Dialogue/DialogueVariantPreviewWindow.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Localization.meta create mode 100644 Assets/_Game/Scripts/Editor/Localization/LocalizationCsvTool.cs create mode 100644 Assets/_Game/Scripts/Editor/Localization/LocalizationCsvTool.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Localization/LocalizationKeyPickerWindow.cs create mode 100644 Assets/_Game/Scripts/Editor/Localization/LocalizationKeyPickerWindow.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Localization/LocalizedTextEditor.cs create mode 100644 Assets/_Game/Scripts/Editor/Localization/LocalizedTextEditor.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Modules/EventChainModule.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Modules/FlagAuditModule.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Modules/IdCodegenModule.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Modules/LocalizationAuditModule.cs create mode 100644 Assets/_Game/Scripts/Editor/Modules/LocalizationAuditModule.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Quest/QuestOverviewEditorWindow.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Quest/QuestSOEditor.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Shared/AssetCreationWizard.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Shared/DataHubEditorKit.cs create mode 100644 Assets/_Game/Scripts/Editor/Shared/DataHubEditorKit.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/UI.meta create mode 100644 Assets/_Game/Scripts/Editor/UI/UIManagerEditor.cs create mode 100644 Assets/_Game/Scripts/Editor/UI/UIManagerEditor.cs.meta create mode 100644 Assets/_Game/Scripts/Equipment/IEquipmentService.cs create mode 100644 Assets/_Game/Scripts/Equipment/IEquipmentService.cs.meta create mode 100644 Assets/_Game/Scripts/Equipment/IToolSlotService.cs create mode 100644 Assets/_Game/Scripts/Equipment/IToolSlotService.cs.meta create mode 100644 Assets/_Game/Scripts/Localization/ILocalizableAsset.cs create mode 100644 Assets/_Game/Scripts/Localization/ILocalizableAsset.cs.meta create mode 100644 Assets/_Game/Scripts/Localization/LanguageFontConfigSO.cs create mode 100644 Assets/_Game/Scripts/Localization/LanguageFontConfigSO.cs.meta create mode 100644 Assets/_Game/Scripts/Localization/LocalizationTable.cs create mode 100644 Assets/_Game/Scripts/Localization/LocalizationTable.cs.meta create mode 100644 Assets/_Game/Scripts/Localization/LocalizedText.cs create mode 100644 Assets/_Game/Scripts/Localization/LocalizedText.cs.meta create mode 100644 Assets/_Game/Scripts/Quest/QuestEventChannelRegistry.cs.meta create mode 100644 Assets/_Game/Scripts/Spells/ISpellService.cs create mode 100644 Assets/_Game/Scripts/Spells/ISpellService.cs.meta delete mode 100644 Assets/_Game/Scripts/Support/Localization/LanguageManagerSO.cs delete mode 100644 Assets/_Game/Scripts/Support/Localization/LocalizationManager.cs create mode 100644 Assets/_Game/Scripts/UI/CharmEquipPanel.cs create mode 100644 Assets/_Game/Scripts/UI/CharmEquipPanel.cs.meta create mode 100644 Assets/_Game/Scripts/UI/HUD/QuestTrackerWidget.cs create mode 100644 Assets/_Game/Scripts/UI/HUD/QuestTrackerWidget.cs.meta create mode 100644 Assets/_Game/Scripts/UI/HUD/SpellSlotWidget.cs create mode 100644 Assets/_Game/Scripts/UI/HUD/SpellSlotWidget.cs.meta create mode 100644 Assets/_Game/Scripts/UI/HUD/StatusEffectHUD.cs create mode 100644 Assets/_Game/Scripts/UI/HUD/StatusEffectHUD.cs.meta create mode 100644 Assets/_Game/Scripts/UI/IFocusable.cs create mode 100644 Assets/_Game/Scripts/UI/IFocusable.cs.meta create mode 100644 Assets/_Game/Scripts/UI/IUIManager.cs create mode 100644 Assets/_Game/Scripts/UI/IUIManager.cs.meta create mode 100644 Assets/_Game/Scripts/UI/SkillTreePanel.cs create mode 100644 Assets/_Game/Scripts/UI/SkillTreePanel.cs.meta create mode 100644 Assets/_Game/Scripts/World/Shop/ShopPanelUI.cs create mode 100644 Assets/_Game/Scripts/World/Shop/ShopPanelUI.cs.meta diff --git a/Assets/_Game/Resources.meta b/Assets/_Game/Resources.meta new file mode 100644 index 0000000..6ec1c5f --- /dev/null +++ b/Assets/_Game/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5c84345088e4b444fa3691e4463195e6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Resources/Localization.meta b/Assets/_Game/Resources/Localization.meta new file mode 100644 index 0000000..7083c86 --- /dev/null +++ b/Assets/_Game/Resources/Localization.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b95c3d78f6a24cf4b895ff9d7e2152c0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Resources/Localization/ChineseSimplified.meta b/Assets/_Game/Resources/Localization/ChineseSimplified.meta new file mode 100644 index 0000000..83b9a77 --- /dev/null +++ b/Assets/_Game/Resources/Localization/ChineseSimplified.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c2f2d7dcc4913cd47a822348a0a382c0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Resources/Localization/ChineseSimplified/UI.json b/Assets/_Game/Resources/Localization/ChineseSimplified/UI.json new file mode 100644 index 0000000..5212026 --- /dev/null +++ b/Assets/_Game/Resources/Localization/ChineseSimplified/UI.json @@ -0,0 +1,7 @@ +{ + "entries": [ + { "key": "TOAST_ACHIEVEMENT_TITLE", "value": "成就解锁" }, + { "key": "TOAST_ABILITY_TITLE", "value": "能力获得" }, + { "key": "REBIND_WAITING_PROMPT", "value": "按下新按键…" } + ] +} diff --git a/Assets/_Game/Resources/Localization/ChineseSimplified/UI.json.meta b/Assets/_Game/Resources/Localization/ChineseSimplified/UI.json.meta new file mode 100644 index 0000000..e07df91 --- /dev/null +++ b/Assets/_Game/Resources/Localization/ChineseSimplified/UI.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4b467b2bd496d6a48bf14743bf6dc030 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Resources/Localization/English.meta b/Assets/_Game/Resources/Localization/English.meta new file mode 100644 index 0000000..3107a1e --- /dev/null +++ b/Assets/_Game/Resources/Localization/English.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c88709988862d0449b435bba8e369238 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Resources/Localization/English/UI.json b/Assets/_Game/Resources/Localization/English/UI.json new file mode 100644 index 0000000..814a9ed --- /dev/null +++ b/Assets/_Game/Resources/Localization/English/UI.json @@ -0,0 +1,7 @@ +{ + "entries": [ + { "key": "TOAST_ACHIEVEMENT_TITLE", "value": "Achievement Unlocked" }, + { "key": "TOAST_ABILITY_TITLE", "value": "Ability Acquired" }, + { "key": "REBIND_WAITING_PROMPT", "value": "Press New Key…" } + ] +} diff --git a/Assets/_Game/Resources/Localization/English/UI.json.meta b/Assets/_Game/Resources/Localization/English/UI.json.meta new file mode 100644 index 0000000..f64a6b2 --- /dev/null +++ b/Assets/_Game/Resources/Localization/English/UI.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b2c33f73665e4b149acab3559ee26bca +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Resources/Localization/Japanese.meta b/Assets/_Game/Resources/Localization/Japanese.meta new file mode 100644 index 0000000..cc91ef4 --- /dev/null +++ b/Assets/_Game/Resources/Localization/Japanese.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 560d5306a24338a43b2b5bc363f3be73 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Resources/Localization/Japanese/UI.json b/Assets/_Game/Resources/Localization/Japanese/UI.json new file mode 100644 index 0000000..6315d16 --- /dev/null +++ b/Assets/_Game/Resources/Localization/Japanese/UI.json @@ -0,0 +1,7 @@ +{ + "entries": [ + { "key": "TOAST_ACHIEVEMENT_TITLE", "value": "実績アンロック" }, + { "key": "TOAST_ABILITY_TITLE", "value": "アビリティ獲得" }, + { "key": "REBIND_WAITING_PROMPT", "value": "新しいキーを押してください…" } + ] +} diff --git a/Assets/_Game/Resources/Localization/Japanese/UI.json.meta b/Assets/_Game/Resources/Localization/Japanese/UI.json.meta new file mode 100644 index 0000000..cc32466 --- /dev/null +++ b/Assets/_Game/Resources/Localization/Japanese/UI.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 82fc68d5bb02982459902d03c24068ab +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Resources/Localization/Korean.meta b/Assets/_Game/Resources/Localization/Korean.meta new file mode 100644 index 0000000..8c93e48 --- /dev/null +++ b/Assets/_Game/Resources/Localization/Korean.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d13f190ae2f8d744cae1f9cfbf3d2081 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Resources/Localization/Korean/UI.json b/Assets/_Game/Resources/Localization/Korean/UI.json new file mode 100644 index 0000000..9f9744e --- /dev/null +++ b/Assets/_Game/Resources/Localization/Korean/UI.json @@ -0,0 +1,7 @@ +{ + "entries": [ + { "key": "TOAST_ACHIEVEMENT_TITLE", "value": "업적 잊금" }, + { "key": "TOAST_ABILITY_TITLE", "value": "능력 획득" }, + { "key": "REBIND_WAITING_PROMPT", "value": "새 키를 누르세요…" } + ] +} diff --git a/Assets/_Game/Resources/Localization/Korean/UI.json.meta b/Assets/_Game/Resources/Localization/Korean/UI.json.meta new file mode 100644 index 0000000..dd13473 --- /dev/null +++ b/Assets/_Game/Resources/Localization/Korean/UI.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: df65e76e977a65640a33c3b4a4321155 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Core/Assets/AddressKeys.cs b/Assets/_Game/Scripts/Core/Assets/AddressKeys.cs index 3f351ae..f4efd17 100644 --- a/Assets/_Game/Scripts/Core/Assets/AddressKeys.cs +++ b/Assets/_Game/Scripts/Core/Assets/AddressKeys.cs @@ -20,6 +20,9 @@ namespace BaseGames.Core.Assets /// Addressable key,用于 Addressables.LoadSceneAsync。 public const string SceneMainMenu = "Scene_MainMenu"; + /// Addressable key,第一个游戏章节场景。 + public const string SceneGameChapter1 = "Scene_Game_Chapter1"; + // ── Player ────────────────────────────────────────────────────── public const string PrefabPlayer = "PLY_Player"; diff --git a/Assets/_Game/Scripts/Support/Localization/LanguageManagerSO.cs.meta b/Assets/_Game/Scripts/Core/IWorldStateReader.cs.meta similarity index 83% rename from Assets/_Game/Scripts/Support/Localization/LanguageManagerSO.cs.meta rename to Assets/_Game/Scripts/Core/IWorldStateReader.cs.meta index 9d2432a..ae3524a 100644 --- a/Assets/_Game/Scripts/Support/Localization/LanguageManagerSO.cs.meta +++ b/Assets/_Game/Scripts/Core/IWorldStateReader.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 0305b3bda1379324883e51f0fb0d5cb4 +guid: 6453128890f813248a8067d3e3c919cd MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/_Game/Scripts/Support/Localization/LocalizationManager.cs.meta b/Assets/_Game/Scripts/Core/WorldFlagRegistrySO.cs.meta similarity index 83% rename from Assets/_Game/Scripts/Support/Localization/LocalizationManager.cs.meta rename to Assets/_Game/Scripts/Core/WorldFlagRegistrySO.cs.meta index 9b41b76..cbcbe8e 100644 --- a/Assets/_Game/Scripts/Support/Localization/LocalizationManager.cs.meta +++ b/Assets/_Game/Scripts/Core/WorldFlagRegistrySO.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 46d9a88adb6ede743a783e306209d4e2 +guid: 748a2d0bc197fa0448028ab28d0309f2 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs b/Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs index 54c8398..0fa5eaf 100644 --- a/Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs +++ b/Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs @@ -1,9 +1,11 @@ +using System.Collections.Generic; using UnityEngine; +using BaseGames.Localization; namespace BaseGames.Dialogue { /// - /// 对话角色定义 SO(架构 14_NarrativeModule §3)。 + /// (架构 14_NarrativeModule §3)。 /// 将 NPC 的显示名、头像、对话气泡颜色集中在一处管理。 /// DialogueLine.actor 引用此 SO,修改头像/名称只需改一个资产, /// 无需批量编辑所有对话行。 @@ -11,7 +13,7 @@ namespace BaseGames.Dialogue /// 资产路径:Assets/_Game/Data/Dialogue/Actors/Actor_{actorId}.asset /// [CreateAssetMenu(menuName = "BaseGames/Dialogue/DialogueActor")] - public class DialogueActorSO : ScriptableObject + public class DialogueActorSO : ScriptableObject, ILocalizableAsset { [Header("标识")] [Tooltip("唯一 ID,如 \"NPC_Elder\",供 DialogueLine 引用")] @@ -77,5 +79,11 @@ namespace BaseGames.Dialogue } } #endif + + public IEnumerable GetLocalizationKeys() + { + if (!string.IsNullOrEmpty(nameKey)) + yield return new LocalizationKeyRef(nameKey, "Dialogue", nameof(nameKey)); + } } } diff --git a/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs b/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs index b5fa8f8..cab736e 100644 --- a/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs +++ b/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs @@ -1,5 +1,7 @@ +using System.Collections.Generic; using UnityEngine; using BaseGames.Core; +using BaseGames.Localization; namespace BaseGames.Dialogue { @@ -80,7 +82,7 @@ namespace BaseGames.Dialogue /// 资产路径: Assets/ScriptableObjects/Dialogue/DLG_{NpcId}_{Context}.asset /// [CreateAssetMenu(menuName = "BaseGames/Dialogue/DialogueSequence")] - public class DialogueSequenceSO : ScriptableObject + public class DialogueSequenceSO : ScriptableObject, ILocalizableAsset { [Header("标识")] [Tooltip("序列唯一 ID,如 \"DLG_Elder_Quest_Available\"。OnValidate 会自动以资产名填充,也可手动指定。")] @@ -320,5 +322,22 @@ namespace BaseGames.Dialogue return false; } #endif + + public IEnumerable GetLocalizationKeys() + { + if (lines == null) yield break; + foreach (var line in lines) + { + if (!string.IsNullOrEmpty(line.textKey)) + yield return new LocalizationKeyRef(line.textKey, "Dialogue", "lines.textKey"); + // speakerNameKey only relevant when actor is absent (override path) + if (line.actor == null && !string.IsNullOrEmpty(line.speakerNameKey)) + yield return new LocalizationKeyRef(line.speakerNameKey, "Dialogue", "lines.speakerNameKey"); + if (line.choices != null) + foreach (var choice in line.choices) + if (!string.IsNullOrEmpty(choice.textKey)) + yield return new LocalizationKeyRef(choice.textKey, "Dialogue", "lines.choices.textKey"); + } + } } } diff --git a/Assets/_Game/Scripts/Dialogue/DialogueUI.cs b/Assets/_Game/Scripts/Dialogue/DialogueUI.cs index 1304471..1e5b43f 100644 --- a/Assets/_Game/Scripts/Dialogue/DialogueUI.cs +++ b/Assets/_Game/Scripts/Dialogue/DialogueUI.cs @@ -90,7 +90,7 @@ namespace BaseGames.Dialogue bool hasSpeaker = !string.IsNullOrEmpty(resolvedNameKey); if (_speakerNamePanel != null) _speakerNamePanel.SetActive(hasSpeaker); if (hasSpeaker && _speakerNameText != null) - _speakerNameText.text = LocalizationManager.Get(resolvedNameKey, "Dialogue"); + _speakerNameText.text = LocalizationManager.Get(resolvedNameKey, LocalizationTable.Dialogue); // 说话人名称框背景颜色(accentColor):有 actor 时着色,无 actor 时还原默认色 if (_speakerNameBackground != null) @@ -145,7 +145,7 @@ namespace BaseGames.Dialogue } else { - _dialogueText.text = LocalizationManager.Get(key, "Dialogue"); + _dialogueText.text = LocalizationManager.Get(key, LocalizationTable.Dialogue); } } IsTyping = false; @@ -200,7 +200,7 @@ namespace BaseGames.Dialogue var (go, btn, lbl) = _choicePool[i]; go.SetActive(true); if (lbl != null) - lbl.text = LocalizationManager.Get(choices[i].textKey ?? "", "Dialogue"); + lbl.text = LocalizationManager.Get(choices[i].textKey ?? "", LocalizationTable.Dialogue); if (btn != null) { btn.onClick.RemoveAllListeners(); @@ -253,7 +253,7 @@ namespace BaseGames.Dialogue } else { - text = LocalizationManager.Get(line.textKey, "Dialogue"); + text = LocalizationManager.Get(line.textKey, LocalizationTable.Dialogue); } // 复用缓存 StringBuilder,避免每行 new 分配;TMP SetText(StringBuilder) 零分配 diff --git a/Assets/_Game/Scripts/Dialogue/InteractableNPC.cs b/Assets/_Game/Scripts/Dialogue/InteractableNPC.cs index 8b7cb41..eb90c2c 100644 --- a/Assets/_Game/Scripts/Dialogue/InteractableNPC.cs +++ b/Assets/_Game/Scripts/Dialogue/InteractableNPC.cs @@ -39,7 +39,7 @@ namespace BaseGames.Dialogue { if (!string.IsNullOrEmpty(_interactPromptKey)) { - var resolved = LocalizationManager.Get(_interactPromptKey, "UI"); + var resolved = LocalizationManager.Get(_interactPromptKey, LocalizationTable.UI); if (!string.IsNullOrEmpty(resolved)) return resolved; } return "对话"; @@ -81,7 +81,11 @@ namespace BaseGames.Dialogue } // ── 子类覆盖点 ────────────────────────────────────────────────────── + /// 组件启用时调用。子类可覆盖且应调用 base.OnEnable()。 + protected virtual void OnEnable() { } + /// 组件禁用时调用。子类可覆盖且应调用 base.OnDisable()。 + protected virtual void OnDisable() { } /// 交互前置逻辑(如任务接收/完成判断)。子类覆盖此方法。 protected virtual void Interact_Internal(Transform player) { } diff --git a/Assets/_Game/Scripts/Dialogue/InteractionPromptController.cs b/Assets/_Game/Scripts/Dialogue/InteractionPromptController.cs index 6e9df5c..ce89486 100644 --- a/Assets/_Game/Scripts/Dialogue/InteractionPromptController.cs +++ b/Assets/_Game/Scripts/Dialogue/InteractionPromptController.cs @@ -73,6 +73,9 @@ namespace BaseGames.Dialogue private void Update() { + // 完全隐藏且不需要淡出时,跳过所有计算 + if (!_visible && _alpha <= 0f) return; + // 位置偏移(世界空间气泡) if (_offset != Vector3.zero) transform.position = (_npc != null ? _npc.transform.position : transform.parent.position) + _offset; diff --git a/Assets/_Game/Scripts/Dialogue/NpcSO.cs b/Assets/_Game/Scripts/Dialogue/NpcSO.cs index 0f6652b..0b0a9f3 100644 --- a/Assets/_Game/Scripts/Dialogue/NpcSO.cs +++ b/Assets/_Game/Scripts/Dialogue/NpcSO.cs @@ -1,4 +1,6 @@ +using System.Collections.Generic; using UnityEngine; +using BaseGames.Localization; namespace BaseGames.Dialogue { @@ -14,7 +16,7 @@ namespace BaseGames.Dialogue /// 资产路径:Assets/_Game/Data/NPC/NPC_{npcId}.asset /// [CreateAssetMenu(menuName = "BaseGames/NPC/NPC")] - public class NpcSO : ScriptableObject + public class NpcSO : ScriptableObject, ILocalizableAsset { [Header("标识")] [Tooltip("NPC 唯一 ID,如 \"NPC_Elder\"。需与 InteractableNPC._npcId 保持一致。")] @@ -90,5 +92,14 @@ namespace BaseGames.Dialogue } } #endif + + public IEnumerable GetLocalizationKeys() + { + string table = string.IsNullOrEmpty(localizationTable) ? "UI" : localizationTable; + if (!string.IsNullOrEmpty(nameKey)) + yield return new LocalizationKeyRef(nameKey, table, nameof(nameKey)); + if (!string.IsNullOrEmpty(interactPromptKey)) + yield return new LocalizationKeyRef(interactPromptKey, "UI", nameof(interactPromptKey)); + } } } diff --git a/Assets/_Game/Scripts/Editor/Dialogue/DialogueVariantPreviewWindow.cs.meta b/Assets/_Game/Scripts/Editor/Dialogue/DialogueVariantPreviewWindow.cs.meta new file mode 100644 index 0000000..e44549a --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Dialogue/DialogueVariantPreviewWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ae50b43961101f54f9b0c8c42f833c52 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs b/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs index e400987..bc221f2 100644 --- a/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs +++ b/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs @@ -40,8 +40,8 @@ namespace BaseGames.Editor.Dialogue if (string.IsNullOrEmpty(resolved)) { EditorGUILayout.HelpBox( - $"nameKey「{npc.nameKey}」在本地化表「{npc.localizationTable}」中未找到对应文本(或 LocalizationManager 未初始化)。\n" + - "请检查本地化表中是否存在此 Key。", + $"nameKey「{npc.nameKey}」在本地化表「{npc.localizationTable}」中未找到对应文本。\n" + + "请检查本地化 JSON 文件中是否存在此 Key。", MessageType.Warning); } else @@ -51,10 +51,15 @@ namespace BaseGames.Editor.Dialogue s_previewStyle); } - // ── 跳转到本地化文件 ──────────────────────────────────────────── + // ── 操作按钮行 ───────────────────────────────────────────────── EditorGUILayout.Space(4); EditorGUILayout.BeginHorizontal(); GUILayout.FlexibleSpace(); + if (GUILayout.Button("刷新预览", GUILayout.Width(80))) + { + BaseGames.Localization.LocalizationManager.ClearEditorPreviewCache(); + Repaint(); + } if (GUILayout.Button($"跳转到本地化文件({npc.localizationTable} 表)", GUILayout.Width(220))) { PingLocalizationFile(npc.localizationTable); @@ -63,21 +68,13 @@ namespace BaseGames.Editor.Dialogue } /// - /// 尝试通过 LocalizationManager(若已加载)解析 nameKey; - /// 如未初始化或找不到 Key,返回 null。 + /// 编辑器预览:直接从 Resources 读取本地化文本,无需运行时服务实例。 + /// 找不到时返回 null(让上层显示 warning 而非 key 本身)。 /// - private static string TryResolveNameKey(string key, string table = "UI") + private static string TryResolveNameKey(string key, string table = BaseGames.Localization.LocalizationTable.UI) { - try - { - // LocalizationManager.Get 在编辑器下可能返回空字符串(未初始化),视为未找到 - var resolved = BaseGames.Localization.LocalizationManager.Get(key, table); - return string.IsNullOrEmpty(resolved) ? null : resolved; - } - catch - { - return null; - } + // GetEditorPreview 直接读 JSON,编辑器下不依赖 ServiceLocator 实例 + return BaseGames.Localization.LocalizationManager.GetEditorPreview(key, table); } /// diff --git a/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs.meta b/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs.meta new file mode 100644 index 0000000..b617b04 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9c09dcd9907ab854da42443d37ff52f9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Localization.meta b/Assets/_Game/Scripts/Editor/Localization.meta new file mode 100644 index 0000000..a3dfd13 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Localization.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5512b3a2ed772ad45a18a6340924f961 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Localization/LocalizationCsvTool.cs b/Assets/_Game/Scripts/Editor/Localization/LocalizationCsvTool.cs new file mode 100644 index 0000000..8779658 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Localization/LocalizationCsvTool.cs @@ -0,0 +1,388 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEngine; +using BaseGames.Localization; + +namespace BaseGames.Editor.Localization +{ + /// + /// 本地化 CSV 导入/导出工具。 + /// + /// 每个表导出为独立 CSV,列顺序:key, ChineseSimplified, English, Japanese, Korean。 + /// 文件存放路径:Assets/_Game/Localization/Export/{TableName}.csv + /// + /// 导入:读取 CSV → 回写 Resources/Localization/{Language}/{TableName}.json + /// + /// 菜单:BaseGames / Localization / CSV 导入导出工具 + /// + public class LocalizationCsvTool : EditorWindow + { + [MenuItem("BaseGames/Localization/CSV 导入导出工具")] + private static void Open() + { + var win = GetWindow("本地化 CSV 工具"); + win.minSize = new Vector2(480, 360); + } + + // ── 状态 ───────────────────────────────────────────────────────────── + + private static readonly Language[] s_allLanguages = + (Language[])Enum.GetValues(typeof(Language)); + + private static readonly string[] s_allTables = + { + LocalizationTable.UI, + LocalizationTable.Dialogue, + LocalizationTable.Quest, + LocalizationTable.Spells, + LocalizationTable.Skills, + LocalizationTable.Items, + LocalizationTable.Character, + LocalizationTable.Tutorial, + }; + + private const string ExportDir = "Assets/_Game/Localization/Export"; + + private readonly bool[] _exportSelected = new bool[s_allTables.Length]; + private readonly bool[] _importSelected = new bool[s_allTables.Length]; + private string _statusMessage = ""; + private MessageType _statusType = MessageType.None; + private Vector2 _scroll; + + // ── GUI ─────────────────────────────────────────────────────────────── + + private void OnGUI() + { + _scroll = EditorGUILayout.BeginScrollView(_scroll); + + EditorGUILayout.Space(6); + GUILayout.Label("📤 导出 → CSV", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + $"将 Resources/Localization/ 中的 JSON 表导出为 CSV 文件。\n" + + $"目标目录:{ExportDir}/", + MessageType.Info); + + EditorGUI.indentLevel++; + for (int i = 0; i < s_allTables.Length; i++) + _exportSelected[i] = EditorGUILayout.Toggle(s_allTables[i], _exportSelected[i]); + EditorGUI.indentLevel--; + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("全选")) SetAll(_exportSelected, true); + if (GUILayout.Button("全不选")) SetAll(_exportSelected, false); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(4); + if (GUILayout.Button("▶ 导出选中表", GUILayout.Height(30))) + RunExport(); + + EditorGUILayout.Space(16); + EditorGUILayout.LabelField("", GUI.skin.horizontalSlider); + + GUILayout.Label("📥 导入 ← CSV", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + $"读取 {ExportDir}/ 中的 CSV,回写至 Resources/Localization/ JSON 文件。\n" + + "已有 Key 覆盖,新增 Key 追加,不删除多余 Key。", + MessageType.Info); + + EditorGUI.indentLevel++; + for (int i = 0; i < s_allTables.Length; i++) + _importSelected[i] = EditorGUILayout.Toggle(s_allTables[i], _importSelected[i]); + EditorGUI.indentLevel--; + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("全选")) SetAll(_importSelected, true); + if (GUILayout.Button("全不选")) SetAll(_importSelected, false); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(4); + if (GUILayout.Button("▶ 导入选中表", GUILayout.Height(30))) + RunImport(); + + EditorGUILayout.Space(8); + if (!string.IsNullOrEmpty(_statusMessage)) + EditorGUILayout.HelpBox(_statusMessage, _statusType); + + EditorGUILayout.Space(6); + EditorGUILayout.EndScrollView(); + } + + // ── 导出 ───────────────────────────────────────────────────────────── + + private void RunExport() + { + EnsureDirectory(ExportDir); + int exported = 0; + + try + { + for (int ti = 0; ti < s_allTables.Length; ti++) + { + if (!_exportSelected[ti]) continue; + string tableName = s_allTables[ti]; + + EditorUtility.DisplayProgressBar("导出 CSV", $"导出 {tableName}…", (float)ti / s_allTables.Length); + + // 收集所有语言的字典(以第一个语言的 Key 集合为主键集) + var langDicts = new Dictionary>(); + var allKeys = new SortedSet(StringComparer.Ordinal); + + foreach (var lang in s_allLanguages) + { + LocalizationManager.ClearEditorPreviewCache(); + var dict = LocalizationManager.GetEditorTable(lang, tableName); + if (dict != null) + { + langDicts[lang] = dict; + foreach (var k in dict.Keys) allKeys.Add(k); + } + } + + if (allKeys.Count == 0) + { + Debug.LogWarning($"[CsvTool] 表「{tableName}」无数据,跳过导出。"); + continue; + } + + var sb = new StringBuilder(); + // 表头 + sb.Append("key"); + foreach (var lang in s_allLanguages) + sb.Append(',').Append(lang); + sb.AppendLine(); + + // 数据行 + foreach (var key in allKeys) + { + sb.Append(CsvEscape(key)); + foreach (var lang in s_allLanguages) + { + string val = ""; + if (langDicts.TryGetValue(lang, out var d)) + d.TryGetValue(key, out val); + sb.Append(',').Append(CsvEscape(val ?? "")); + } + sb.AppendLine(); + } + + string csvPath = $"{ExportDir}/{tableName}.csv"; + File.WriteAllText(csvPath, sb.ToString(), Encoding.UTF8); + exported++; + } + } + finally + { + EditorUtility.ClearProgressBar(); + } + + AssetDatabase.Refresh(); + SetStatus($"✅ 成功导出 {exported} 个表到 {ExportDir}/", MessageType.Info); + } + + // ── 导入 ───────────────────────────────────────────────────────────── + + private void RunImport() + { + int imported = 0; + int errors = 0; + + try + { + for (int ti = 0; ti < s_allTables.Length; ti++) + { + if (!_importSelected[ti]) continue; + string tableName = s_allTables[ti]; + string csvPath = Path.Combine(Path.GetDirectoryName(Application.dataPath)!, $"{ExportDir}/{tableName}.csv"); + + EditorUtility.DisplayProgressBar("导入 CSV", $"导入 {tableName}…", (float)ti / s_allTables.Length); + + if (!File.Exists(csvPath)) + { + Debug.LogWarning($"[CsvTool] CSV 文件不存在,跳过:{ExportDir}/{tableName}.csv"); + continue; + } + + try + { + var rows = ParseCsv(File.ReadAllText(csvPath, Encoding.UTF8)); + if (rows.Count < 2) continue; + + // 表头行解析出列对应的语言 + var header = rows[0]; + var langCols = new List<(int col, Language lang)>(); + for (int col = 1; col < header.Count; col++) + { + if (Enum.TryParse(header[col].Trim(), out var lang)) + langCols.Add((col, lang)); + } + + // 为每个语言准备合并后的字典 + var mergedDicts = new Dictionary>(); + foreach (var (_, lang) in langCols) + { + LocalizationManager.ClearEditorPreviewCache(); + var existing = LocalizationManager.GetEditorTable(lang, tableName) + ?? new Dictionary(StringComparer.Ordinal); + mergedDicts[lang] = new Dictionary(existing, StringComparer.Ordinal); + } + + // 用 CSV 数据覆盖/追加 + for (int row = 1; row < rows.Count; row++) + { + var cells = rows[row]; + if (cells.Count == 0 || string.IsNullOrWhiteSpace(cells[0])) continue; + string key = cells[0]; + foreach (var (col, lang) in langCols) + { + string val = col < cells.Count ? cells[col] : ""; + mergedDicts[lang][key] = val; + } + } + + // 写回 JSON + foreach (var (lang, dict) in mergedDicts) + { + string jsonPath = GetJsonPath(lang, tableName); + EnsureDirectory(Path.GetDirectoryName(jsonPath)); + File.WriteAllText(jsonPath, DictToJson(dict), Encoding.UTF8); + } + + imported++; + } + catch (Exception ex) + { + Debug.LogError($"[CsvTool] 导入「{tableName}」失败:{ex.Message}"); + errors++; + } + } + } + finally + { + EditorUtility.ClearProgressBar(); + } + + AssetDatabase.Refresh(); + LocalizationManager.ClearEditorPreviewCache(); + + string msg = errors == 0 + ? $"✅ 成功导入 {imported} 个表。" + : $"⚠ 导入 {imported} 个表,{errors} 个失败(见控制台)。"; + SetStatus(msg, errors == 0 ? MessageType.Info : MessageType.Warning); + } + + // ── 工具函数 ───────────────────────────────────────────────────────── + + /// 将 value 按 RFC 4180 规范包裹(含逗号/双引号/换行时用双引号包裹,内部双引号转义为 "")。 + private static string CsvEscape(string value) + { + if (value == null) return ""; + bool needsQuote = value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r'); + if (!needsQuote) return value; + return "\"" + value.Replace("\"", "\"\"") + "\""; + } + + /// 简单 CSV 解析器,支持 RFC 4180 带引号字段(含换行)。 + private static List> ParseCsv(string text) + { + var rows = new List>(); + var row = new List(); + var cell = new StringBuilder(); + bool inQuote = false; + int i = 0; + + while (i < text.Length) + { + char c = text[i]; + if (inQuote) + { + if (c == '"') + { + if (i + 1 < text.Length && text[i + 1] == '"') + { cell.Append('"'); i += 2; } + else + { inQuote = false; i++; } + } + else + { cell.Append(c); i++; } + } + else + { + if (c == '"') + { inQuote = true; i++; } + else if (c == ',') + { row.Add(cell.ToString()); cell.Clear(); i++; } + else if (c == '\r') + { + row.Add(cell.ToString()); cell.Clear(); + rows.Add(row); row = new List(); + if (i + 1 < text.Length && text[i + 1] == '\n') i++; + i++; + } + else if (c == '\n') + { row.Add(cell.ToString()); cell.Clear(); rows.Add(row); row = new List(); i++; } + else + { cell.Append(c); i++; } + } + } + + if (cell.Length > 0 || row.Count > 0) + { + row.Add(cell.ToString()); + rows.Add(row); + } + + return rows; + } + + /// 将字符串字典序列化为最小 JSON 对象(UTF-8,适合直接写入 Resources JSON)。 + private static string DictToJson(Dictionary dict) + { + var sb = new StringBuilder(); + sb.AppendLine("{"); + int written = 0; + foreach (var kv in dict) + { + sb.Append(" ").Append(JsonString(kv.Key)).Append(": ").Append(JsonString(kv.Value)); + if (++written < dict.Count) sb.Append(','); + sb.AppendLine(); + } + sb.Append('}'); + return sb.ToString(); + } + + private static string JsonString(string s) + { + if (s == null) return "\"\""; + s = s.Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\n", "\\n") + .Replace("\r", "\\r") + .Replace("\t", "\\t"); + return $"\"{s}\""; + } + + private static string GetJsonPath(Language lang, string tableName) + => $"Assets/Resources/Localization/{lang}/{tableName}.json"; + + private static void EnsureDirectory(string dir) + { + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + } + + private static void SetAll(bool[] arr, bool v) + { + for (int i = 0; i < arr.Length; i++) arr[i] = v; + } + + private void SetStatus(string msg, MessageType type) + { + _statusMessage = msg; + _statusType = type; + Repaint(); + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Localization/LocalizationCsvTool.cs.meta b/Assets/_Game/Scripts/Editor/Localization/LocalizationCsvTool.cs.meta new file mode 100644 index 0000000..ff847f3 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Localization/LocalizationCsvTool.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8b7198a6db4ff314b9fadc980f266744 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Localization/LocalizationKeyPickerWindow.cs b/Assets/_Game/Scripts/Editor/Localization/LocalizationKeyPickerWindow.cs new file mode 100644 index 0000000..b19ce41 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Localization/LocalizationKeyPickerWindow.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; +using BaseGames.Localization; + +namespace BaseGames.Editor.Localization +{ + /// + /// 带搜索框的本地化 Key 选择器窗口。 + /// 替代 GenericMenu,支持 1000+ 条目的快速模糊搜索(key 和译文均可搜索)。 + /// + /// 键盘操作: + /// ↑ / ↓ — 上下移动高亮行 + /// Enter — 确认选择当前高亮行 + /// Esc — 关闭窗口(不选中任何 key) + /// + /// 用法: + /// + /// LocalizationKeyPickerWindow.Show(table, currentKey, selectedKey => { + /// _keyProp.stringValue = selectedKey; + /// serializedObject.ApplyModifiedProperties(); + /// }); + /// + /// + public class LocalizationKeyPickerWindow : EditorWindow + { + // ── 状态 ────────────────────────────────────────────────────────────── + private string _searchText = ""; + private Vector2 _scroll; + private List<(string key, string preview)> _allEntries = new(); + private List<(string key, string preview)> _filtered = new(); + private Action _onSelected; + private string _currentKey; + private int _hoveredIndex = -1; + private int _keyboardIndex = -1; // 键盘当前高亮行 + + // 预缓存样式,避免每帧 new GUIStyle + private GUIStyle _keyStyle; + private GUIStyle _previewStyle; + + private const float RowHeight = 40f; + private const float SearchHeight = 22f; + + // ── 公开入口 ────────────────────────────────────────────────────────── + + /// + /// 打开 Key 选择器窗口。 + /// + /// 要从哪张表加载 Key(先查简体中文,找不到查英文)。 + /// 当前已选 Key(高亮显示)。 + /// 选中 Key 后的回调。 + public static void Show(string table, string currentKey, Action onSelected) + { + var dict = LocalizationManager.GetEditorTable(Language.ChineseSimplified, table) + ?? LocalizationManager.GetEditorTable(Language.English, table); + + if (dict == null || dict.Count == 0) + { + EditorUtility.DisplayDialog("Key 选择器", + $"表「{table}」尚无可用 Key。\n" + + $"请先在 Resources/Localization/{{语言}}/{table}.json 中添加条目。", + "确定"); + return; + } + + var win = CreateInstance(); + win.titleContent = new GUIContent($"Key 选择器 — {table}"); + win.minSize = new Vector2(440, 520); + win._currentKey = currentKey; + win._onSelected = onSelected; + win._allEntries = dict + .Select(kvp => (kvp.Key, kvp.Value)) + .OrderBy(t => t.Item1, StringComparer.Ordinal) + .ToList(); + win.ApplyFilter(); + + // 把键盘索引初始化到当前 key 所在行 + int idx = win._filtered.FindIndex(t => t.key == currentKey); + if (idx >= 0) + { + win._keyboardIndex = idx; + win._scroll = new Vector2(0, idx * RowHeight); + } + + win.ShowUtility(); + } + + // ── GUI ─────────────────────────────────────────────────────────────── + + private void OnGUI() + { + EnsureStyles(); + HandleKeyboardInput(); // 在绘制之前处理按键,当帧即生效 + DrawSearchBar(); + DrawEntryCount(); + DrawList(); + } + + // ── 键盘导航 ────────────────────────────────────────────────────────── + + private void HandleKeyboardInput() + { + var e = Event.current; + if (e.type != EventType.KeyDown) return; + + switch (e.keyCode) + { + case KeyCode.UpArrow: + MoveKeyboardSelection(-1); + e.Use(); + break; + + case KeyCode.DownArrow: + MoveKeyboardSelection(+1); + e.Use(); + break; + + case KeyCode.Return: + case KeyCode.KeypadEnter: + if (_keyboardIndex >= 0 && _keyboardIndex < _filtered.Count) + { + _onSelected?.Invoke(_filtered[_keyboardIndex].key); + e.Use(); + Close(); + } + break; + + case KeyCode.Escape: + e.Use(); + Close(); + break; + } + } + + private void MoveKeyboardSelection(int delta) + { + if (_filtered.Count == 0) return; + + _keyboardIndex = _keyboardIndex < 0 + ? (delta > 0 ? 0 : _filtered.Count - 1) + : Mathf.Clamp(_keyboardIndex + delta, 0, _filtered.Count - 1); + + ScrollToKeyboardIndex(); + Repaint(); + } + + /// 调整滚动位置,保证 _keyboardIndex 行始终在可见视口内。 + private void ScrollToKeyboardIndex() + { + float viewportH = position.height - SearchHeight - 30f; + float rowTop = _keyboardIndex * RowHeight; + float rowBot = rowTop + RowHeight; + + if (rowTop < _scroll.y) + _scroll.y = rowTop; + else if (rowBot > _scroll.y + viewportH) + _scroll.y = rowBot - viewportH; + } + + // ── 搜索栏 ──────────────────────────────────────────────────────────── + + private void DrawSearchBar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + GUI.SetNextControlName("SearchField"); + var newSearch = EditorGUILayout.TextField(_searchText, EditorStyles.toolbarSearchField); + if (GUILayout.Button("✕", EditorStyles.toolbarButton, GUILayout.Width(22))) + newSearch = ""; + EditorGUILayout.EndHorizontal(); + + if (newSearch != _searchText) + { + _searchText = newSearch; + _hoveredIndex = -1; + _keyboardIndex = -1; + ApplyFilter(); + } + } + + private void DrawEntryCount() + { + EditorGUILayout.LabelField( + $"共 {_filtered.Count} 个结果(总 {_allEntries.Count} 条) | ↑↓ 导航 Enter 选中 Esc 关闭", + EditorStyles.centeredGreyMiniLabel); + EditorGUILayout.Space(2); + } + + // ── 列表(虚拟渲染)────────────────────────────────────────────────── + + private void DrawList() + { + float viewportH = position.height - SearchHeight - 30f; + + _scroll = EditorGUILayout.BeginScrollView(_scroll, + GUILayout.Height(viewportH)); + + int firstVisible = Mathf.Max(0, (int)(_scroll.y / RowHeight) - 1); + int lastVisible = Mathf.Min(_filtered.Count - 1, + (int)((_scroll.y + viewportH) / RowHeight) + 1); + + // 顶部占位(未渲染行) + if (firstVisible > 0) + GUILayout.Space(firstVisible * RowHeight); + + for (int i = firstVisible; i <= lastVisible && i < _filtered.Count; i++) + { + var (key, preview) = _filtered[i]; + bool isCurrent = string.Equals(key, _currentKey, StringComparison.Ordinal); + bool isKeyboard = i == _keyboardIndex; + bool isHovered = i == _hoveredIndex; + + var rowRect = GUILayoutUtility.GetRect(0, RowHeight, GUILayout.ExpandWidth(true)); + + // 背景优先级:当前选中 > 键盘高亮 > 鼠标悬停 + Color bg = isCurrent ? new Color(0.25f, 0.55f, 1f, 0.25f) + : isKeyboard ? new Color(0.40f, 0.75f, 0.4f, 0.20f) + : isHovered ? new Color(1f, 1f, 1f, 0.05f) + : Color.clear; + + if (bg.a > 0) + EditorGUI.DrawRect(rowRect, bg); + + // 当前 key 左边蓝色竖条 + if (isCurrent) + EditorGUI.DrawRect(new Rect(rowRect.x, rowRect.y, 3, rowRect.height), + new Color(0.3f, 0.7f, 1f, 1f)); + // 键盘选中绿色竖条 + else if (isKeyboard) + EditorGUI.DrawRect(new Rect(rowRect.x, rowRect.y, 3, rowRect.height), + new Color(0.4f, 0.9f, 0.4f, 1f)); + + // Key 文本 + EditorGUI.LabelField( + new Rect(rowRect.x + 8, rowRect.y + 4, rowRect.width - 12, 18), + key, _keyStyle); + + // 预览文本(绿色) + string previewText = preview.Length > 60 ? preview[..60] + "…" : preview; + EditorGUI.LabelField( + new Rect(rowRect.x + 8, rowRect.y + 22, rowRect.width - 12, 14), + previewText, _previewStyle); + + // 鼠标交互 + var ev = Event.current; + if (rowRect.Contains(ev.mousePosition)) + { + if (_hoveredIndex != i) { _hoveredIndex = i; Repaint(); } + + if (ev.type == EventType.MouseDown && ev.button == 0) + { + _onSelected?.Invoke(key); + ev.Use(); + Close(); + return; + } + } + } + + // 底部占位(未渲染行) + int remaining = _filtered.Count - lastVisible - 1; + if (remaining > 0) + GUILayout.Space(remaining * RowHeight); + + EditorGUILayout.EndScrollView(); + } + + // ── 辅助 ────────────────────────────────────────────────────────────── + + private void ApplyFilter() + { + if (string.IsNullOrEmpty(_searchText)) + { + _filtered = new List<(string, string)>(_allEntries); + return; + } + + _filtered = _allEntries + .Where(t => t.key.IndexOf(_searchText, StringComparison.OrdinalIgnoreCase) >= 0 + || t.preview.IndexOf(_searchText, StringComparison.OrdinalIgnoreCase) >= 0) + .ToList(); + } + + private void EnsureStyles() + { + _keyStyle ??= new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 11, + }; + _previewStyle ??= new GUIStyle(EditorStyles.miniLabel) + { + normal = { textColor = new Color(0.5f, 0.88f, 0.5f) }, + fontSize = 10, + }; + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Localization/LocalizationKeyPickerWindow.cs.meta b/Assets/_Game/Scripts/Editor/Localization/LocalizationKeyPickerWindow.cs.meta new file mode 100644 index 0000000..8771cf3 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Localization/LocalizationKeyPickerWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f25bdb0dfede0f24f863e08c78db8a87 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Localization/LocalizedTextEditor.cs b/Assets/_Game/Scripts/Editor/Localization/LocalizedTextEditor.cs new file mode 100644 index 0000000..82d977a --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Localization/LocalizedTextEditor.cs @@ -0,0 +1,143 @@ +using UnityEditor; +using UnityEngine; +using BaseGames.Localization; + +namespace BaseGames.Editor.Localization +{ + /// + /// 自定义 Inspector。 + /// 在 key / table 字段下方实时预览解析后的本地化文本,无需进入 Play Mode。 + /// 支持模拟格式化参数预览(逗号分隔)。 + /// + [CustomEditor(typeof(LocalizedText))] + public class LocalizedTextEditor : UnityEditor.Editor + { + private static GUIStyle s_foundStyle; + + private SerializedProperty _keyProp; + private SerializedProperty _tableProp; + + // 编辑器内模拟格式化参数(逗号分隔字符串,仅用于预览,不持久化) + private string _previewFormatArgs = ""; + + private void OnEnable() + { + _keyProp = serializedObject.FindProperty("_key"); + _tableProp = serializedObject.FindProperty("_table"); + } + + public override void OnInspectorGUI() + { + serializedObject.Update(); + DrawDefaultInspector(); + serializedObject.ApplyModifiedProperties(); + + if (s_foundStyle == null) + { + s_foundStyle = new GUIStyle(EditorStyles.helpBox) + { + fontSize = 11, + alignment = TextAnchor.MiddleLeft, + padding = new RectOffset(8, 8, 4, 4), + }; + s_foundStyle.normal.textColor = new Color(0.55f, 0.90f, 0.55f); + } + + string key = _keyProp?.stringValue; + string table = _tableProp?.stringValue ?? LocalizationTable.UI; + if (string.IsNullOrEmpty(key)) return; + + EditorGUILayout.Space(4); + + // ── 格式化参数模拟输入 ────────────────────────────────────────── + EditorGUI.BeginChangeCheck(); + _previewFormatArgs = EditorGUILayout.TextField( + new GUIContent("预览参数", "模拟格式化参数,逗号分隔。例:\"100, 玩家名\" → 代入 {0} {1}"), + _previewFormatArgs); + bool argsChanged = EditorGUI.EndChangeCheck(); + if (argsChanged) Repaint(); + + // ── 预览文本 ──────────────────────────────────────────────────── + string rawPreview = LocalizationManager.GetEditorPreview(key, table); + + if (string.IsNullOrEmpty(rawPreview)) + { + EditorGUILayout.HelpBox( + $"Key「{key}」在表「{table}」中未找到(简体中文表与英文表均未命中)。\n" + + $"请检查 Resources/Localization/{{Language}}/{table}.json 文件。", + MessageType.Warning); + } + else + { + string displayText = ApplyPreviewArgs(rawPreview, _previewFormatArgs); + bool hasArgs = !string.IsNullOrWhiteSpace(_previewFormatArgs); + + string label = hasArgs + ? $"▸ 预览(参数展开):{displayText}" + : $"▸ 预览(简体中文):{displayText}"; + EditorGUILayout.LabelField(label, s_foundStyle); + + if (hasArgs && displayText == rawPreview) + EditorGUILayout.HelpBox( + "格式化参数未能代入(模板中可能没有 {0} 占位符,或参数数量不足)。", + MessageType.Info); + } + + EditorGUILayout.Space(2); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("选择 Key ▾", GUILayout.Width(90))) + LocalizationKeyPickerWindow.Show(table, key, selectedKey => + { + _keyProp.stringValue = selectedKey; + serializedObject.ApplyModifiedProperties(); + ((LocalizedText)target).UpdateEditorPreview(); + Repaint(); + }); + if (GUILayout.Button("刷新预览", GUILayout.Width(80))) + { + LocalizationManager.ClearEditorPreviewCache(); + ((LocalizedText)target).UpdateEditorPreview(); + Repaint(); + } + if (GUILayout.Button($"跳转到表文件({table})", GUILayout.Width(160))) + PingLocalizationFile(table); + EditorGUILayout.EndHorizontal(); + } + + /// 将逗号分隔的参数字符串解析为 object[] 并代入模板。 + private static string ApplyPreviewArgs(string template, string argsInput) + { + if (string.IsNullOrWhiteSpace(argsInput)) return template; + + // 按逗号分割,保留空白(策划可能输入 "100, ") + string[] parts = argsInput.Split(','); + var args = new object[parts.Length]; + for (int i = 0; i < parts.Length; i++) + args[i] = parts[i].Trim(); + + try { return string.Format(template, args); } + catch { return template; } + } + + private static void PingLocalizationFile(string tableName) + { + string[] guids = AssetDatabase.FindAssets( + $"t:TextAsset {tableName}", + new[] { "Assets/Resources/Localization" }); + + foreach (var guid in guids) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + if (!path.EndsWith($"/{tableName}.json", System.StringComparison.OrdinalIgnoreCase)) continue; + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset == null) continue; + EditorGUIUtility.PingObject(asset); + Selection.activeObject = asset; + return; + } + Debug.LogWarning($"[LocalizedTextEditor] 未找到本地化表文件:Resources/Localization/…/{tableName}.json"); + } + + } +} diff --git a/Assets/_Game/Scripts/Editor/Localization/LocalizedTextEditor.cs.meta b/Assets/_Game/Scripts/Editor/Localization/LocalizedTextEditor.cs.meta new file mode 100644 index 0000000..5474895 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Localization/LocalizedTextEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2de9be8c918457447ad647c1af2b3c14 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Modules/ActorModule.cs b/Assets/_Game/Scripts/Editor/Modules/ActorModule.cs index 6f00a3a..d88c7c3 100644 --- a/Assets/_Game/Scripts/Editor/Modules/ActorModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/ActorModule.cs @@ -4,6 +4,7 @@ using UnityEditor.UIElements; using UnityEngine; using UnityEngine.UIElements; using BaseGames.Dialogue; +using BaseGames.Localization; namespace BaseGames.Editor.Modules { @@ -81,7 +82,7 @@ namespace BaseGames.Editor.Modules // 名称:优先显示本地化实际文本,回退到 key 本身 string nameDisplay = string.IsNullOrEmpty(a.nameKey) ? "(未设置)" - : (BaseGames.Localization.LocalizationManager.GetEditorPreview(a.nameKey, "Dialogue") ?? a.nameKey); + : (BaseGames.Localization.LocalizationManager.GetEditorPreview(a.nameKey, LocalizationTable.Dialogue) ?? a.nameKey); SkillModule.AddChip(card, "名称", nameDisplay); if (!string.IsNullOrEmpty(a.nameKey)) SkillModule.AddChip(card, "名称 Key", a.nameKey); diff --git a/Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs b/Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs index 2f147c0..f78512c 100644 --- a/Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs @@ -6,6 +6,7 @@ using UnityEngine.UIElements; using BaseGames.Dialogue; using BaseGames.Editor.Dialogue; using BaseGames.Editor.Shared; +using BaseGames.Localization; namespace BaseGames.Editor.Modules { @@ -97,9 +98,9 @@ namespace BaseGames.Editor.Modules }; } - filterRow.Add(QuestModule.MakeFilterChip("有变体", v => { filterVariants = v; RebuildFilter(); })); - filterRow.Add(QuestModule.MakeFilterChip("有分支", v => { filterBranches = v; RebuildFilter(); })); - filterRow.Add(QuestModule.MakeFilterChip("无语音", v => { filterNoVoice = v; RebuildFilter(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("有变体", v => { filterVariants = v; RebuildFilter(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("有分支", v => { filterBranches = v; RebuildFilter(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("无语音", v => { filterNoVoice = v; RebuildFilter(); })); container.Add(_listPane); _listPane.Refresh(); @@ -219,7 +220,7 @@ namespace BaseGames.Editor.Modules string speakerKey = line.ResolvedNameKey; if (!string.IsNullOrEmpty(speakerKey)) { - var speakerResolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(speakerKey, "Dialogue"); + var speakerResolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(speakerKey, LocalizationTable.Dialogue); bool speakerMissing = speakerResolved == null; string speakerText = speakerMissing ? speakerKey : speakerResolved; var spk = new Label(speakerText + ":"); @@ -255,7 +256,7 @@ namespace BaseGames.Editor.Modules } else { - var resolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(line.textKey, "Dialogue"); + var resolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(line.textKey, LocalizationTable.Dialogue); if (resolved != null) { textPreview = resolved; @@ -454,7 +455,7 @@ namespace BaseGames.Editor.Modules string GetLoc(string key) { if (locCache.TryGetValue(key, out var v)) return v; - v = BaseGames.Localization.LocalizationManager.GetEditorPreview(key, "Dialogue"); + v = BaseGames.Localization.LocalizationManager.GetEditorPreview(key, LocalizationTable.Dialogue); locCache[key] = v; return v; } diff --git a/Assets/_Game/Scripts/Editor/Modules/EventChainModule.cs b/Assets/_Game/Scripts/Editor/Modules/EventChainModule.cs index a4a15e8..c7050e8 100644 --- a/Assets/_Game/Scripts/Editor/Modules/EventChainModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/EventChainModule.cs @@ -74,9 +74,9 @@ namespace BaseGames.Editor.Modules }; } - filterRow.Add(QuestModule.MakeFilterChip("可重复", v => { filterRepeatable = v; RebuildFilter(); })); - filterRow.Add(QuestModule.MakeFilterChip("无条件", v => { filterNoCondition = v; RebuildFilter(); })); - filterRow.Add(QuestModule.MakeFilterChip("无动作", v => { filterNoAction = v; RebuildFilter(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("可重复", v => { filterRepeatable = v; RebuildFilter(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("无条件", v => { filterNoCondition = v; RebuildFilter(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("无动作", v => { filterNoAction = v; RebuildFilter(); })); container.Add(_listPane); _listPane.Refresh(); diff --git a/Assets/_Game/Scripts/Editor/Modules/EventChainModule.cs.meta b/Assets/_Game/Scripts/Editor/Modules/EventChainModule.cs.meta new file mode 100644 index 0000000..65476a1 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/EventChainModule.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c65f27c4b0792904087283cc4e901118 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Modules/FlagAuditModule.cs b/Assets/_Game/Scripts/Editor/Modules/FlagAuditModule.cs index 0e95ba6..61859da 100644 --- a/Assets/_Game/Scripts/Editor/Modules/FlagAuditModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/FlagAuditModule.cs @@ -81,8 +81,8 @@ namespace BaseGames.Editor.Modules filterRow.style.paddingBottom = 3; container.Add(filterRow); - filterRow.Add(QuestModule.MakeFilterChip("仅孤立", v => { _filterOrphan = v; RebuildList(); })); - filterRow.Add(QuestModule.MakeFilterChip("仅未注册", v => { _filterUnregistered = v; RebuildList(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("仅孤立", v => { _filterOrphan = v; RebuildList(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("仅未注册", v => { _filterUnregistered = v; RebuildList(); })); // 列表 ScrollView var scroll = new ScrollView(); diff --git a/Assets/_Game/Scripts/Editor/Modules/FlagAuditModule.cs.meta b/Assets/_Game/Scripts/Editor/Modules/FlagAuditModule.cs.meta new file mode 100644 index 0000000..b857bcd --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/FlagAuditModule.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: de4bd4ec2a39e3c4e89649069cb0f6e2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Modules/IdCodegenModule.cs.meta b/Assets/_Game/Scripts/Editor/Modules/IdCodegenModule.cs.meta new file mode 100644 index 0000000..96ca632 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/IdCodegenModule.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4cbbfd509dce2f0489febf07574b652a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Modules/LocalizationAuditModule.cs b/Assets/_Game/Scripts/Editor/Modules/LocalizationAuditModule.cs new file mode 100644 index 0000000..685e46c --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/LocalizationAuditModule.cs @@ -0,0 +1,572 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; +using BaseGames.Localization; + +namespace BaseGames.Editor.Modules +{ + /// + /// DataHub 本地化审计模块。 + /// 通过 接口扫描项目中所有 ScriptableObject 的本地化 Key, + /// 与 Resources/Localization/ JSON 表比对,列出缺失条目和命名不规范条目。 + /// + /// 菜单入口:DataHub → "本地化审计" + /// + public class LocalizationAuditModule : IDataModule, IDataModuleOrdered + { + public string ModuleId => "localization-audit"; + public string DisplayName => "本地化审计"; + public string IconName => "d_UnityEditor.InspectorWindow"; + public int DisplayOrder => 135; + + // Key 命名规范:UPPER_SNAKE_CASE(大写字母、数字、下划线,首字符必须是大写字母) + private static readonly Regex s_keyPattern = new(@"^[A-Z][A-Z0-9_]*$", RegexOptions.Compiled); + + // ── 数据 ───────────────────────────────────────────────────────────── + + private readonly List _issues = new(); + private readonly List _namingIssues = new(); + private readonly List _availableLanguages = new(); + private int _totalLanguageCount; + private bool _hasScanned; + + private class AuditIssue + { + public string key; + public string table; + public string soPath; + public string fieldName; + public UnityEngine.Object asset; + public readonly List missingLanguages = new(); + } + + private class NamingIssue + { + public string key; + public string table; + public string soPath; + public string fieldName; + public UnityEngine.Object asset; + } + + // ── UI 引用 ─────────────────────────────────────────────────────────── + + private VisualElement _listItems; + private Label _summaryLabel; + private VisualElement _detailRoot; + private VisualElement _namingSection; + private bool _filterMissingAll, _filterMissingPartial, _filterNamingIssue; + private string _filterTableName = ""; + + // ── IDataModule ─────────────────────────────────────────────────────── + + public void Initialize() { } + + public void BuildListPane(VisualElement container, Action onSelected) + { + // 扫描 + 导出 按钮行 + var btnRow = new VisualElement(); + btnRow.style.flexDirection = FlexDirection.Row; + btnRow.style.marginTop = 8; + btnRow.style.marginLeft = 8; + btnRow.style.marginRight = 8; + btnRow.style.marginBottom = 4; + container.Add(btnRow); + + var scanBtn = new Button(RunScan) { text = "🔍 扫描本地化缺失" }; + scanBtn.style.flexGrow = 1; + scanBtn.style.marginRight = 4; + btnRow.Add(scanBtn); + + var exportBtn = new Button(ExportReport) { text = "📄 导出报告" }; + exportBtn.style.width = 90; + btnRow.Add(exportBtn); + + _summaryLabel = new Label("尚未扫描,点击左侧按钮开始。"); + _summaryLabel.style.fontSize = 10; + _summaryLabel.style.opacity = 0.6f; + _summaryLabel.style.paddingLeft = 10; + _summaryLabel.style.marginBottom = 4; + container.Add(_summaryLabel); + + // 过滤行 + var filterRow = new VisualElement(); + filterRow.style.flexDirection = FlexDirection.Row; + filterRow.style.flexWrap = Wrap.Wrap; + filterRow.style.paddingLeft = 6; + filterRow.style.paddingRight = 6; + filterRow.style.paddingBottom = 3; + container.Add(filterRow); + + filterRow.Add(DataHubEditorKit.MakeFilterChip("全部语言缺失", v => { _filterMissingAll = v; RebuildList(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("部分语言缺失", v => { _filterMissingPartial = v; RebuildList(); })); + // "命名不规范"Chip 现在只控制命名折叠区展开/折叠,不再隐藏缺失列表 + filterRow.Add(DataHubEditorKit.MakeFilterChip("展开命名问题", v => { _filterNamingIssue = v; RebuildList(); })); + + // 表名过滤输入框 + var tableField = new TextField("表名过滤") { value = "" }; + tableField.style.paddingLeft = 6; + tableField.style.paddingRight = 6; + tableField.style.marginBottom = 3; + tableField.RegisterValueChangedCallback(e => + { + _filterTableName = e.newValue?.Trim() ?? ""; + RebuildList(); + }); + container.Add(tableField); + + var scroll = new ScrollView { style = { flexGrow = 1 } }; + container.Add(scroll); + + _listItems = new VisualElement(); + scroll.Add(_listItems); + + _namingSection = new VisualElement(); + scroll.Add(_namingSection); + } + + public void BuildDetailPane(VisualElement container, UnityEngine.Object selected) + { + _detailRoot = container; + RebuildDetail(null); + } + + public void OnActivated() { } + + // ── 扫描 ────────────────────────────────────────────────────────────── + + private void RunScan() + { + _issues.Clear(); + _namingIssues.Clear(); + _availableLanguages.Clear(); + LocalizationManager.ClearEditorPreviewCache(); + _hasScanned = true; + + DiscoverLanguages(); + _totalLanguageCount = _availableLanguages.Count; + + ScanAllLocalizableAssets(); + + int total = _issues.Count; + int misAll = _issues.Count(i => i.missingLanguages.Count == _totalLanguageCount); + int naming = _namingIssues.Count; + + _summaryLabel.text = total == 0 && naming == 0 + ? $"✅ 全部通过!已检查 {_totalLanguageCount} 个语言。" + : $"⚠ {total} 个缺失问题(全语言缺失 {misAll} 个),{naming} 个命名不规范。"; + + RebuildList(); + } + + // ── 语言发现 ────────────────────────────────────────────────────────── + + private void DiscoverLanguages() + { + string root = "Assets/Resources/Localization"; + if (!AssetDatabase.IsValidFolder(root)) return; + + foreach (var langFolder in AssetDatabase.GetSubFolders(root)) + { + string langName = Path.GetFileName(langFolder); + if (Enum.TryParse(langName, out var lang)) + _availableLanguages.Add(lang); + } + } + + // ── 通用 ILocalizableAsset 扫描 ─────────────────────────────────────── + + private void ScanAllLocalizableAssets() + { + string[] guids = AssetDatabase.FindAssets("t:ScriptableObject"); + int total = guids.Length; + + try + { + for (int i = 0; i < total; i++) + { + if (EditorUtility.DisplayCancelableProgressBar( + "本地化审计", $"扫描中… ({i + 1}/{total})", (float)(i + 1) / total)) + break; + + string path = AssetDatabase.GUIDToAssetPath(guids[i]); + var so = AssetDatabase.LoadAssetAtPath(path); + if (so == null || so is not ILocalizableAsset loc) continue; + + foreach (var keyRef in loc.GetLocalizationKeys()) + { + CheckKey(so, keyRef.Key, keyRef.Table, keyRef.FieldName); + CheckKeyNamingConvention(so, keyRef.Key, keyRef.Table, keyRef.FieldName, path); + } + } + } + finally + { + EditorUtility.ClearProgressBar(); + } + } + + // ── 导出审计报告 ────────────────────────────────────────────────────── + + private void ExportReport() + { + if (!_hasScanned) + { + EditorUtility.DisplayDialog("导出报告", "请先执行扫描,再导出报告。", "确定"); + return; + } + + string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + string dir = Path.Combine(Application.dataPath, "_Game", "Localization"); + Directory.CreateDirectory(dir); + string filePath = Path.Combine(dir, $"AuditReport_{timestamp}.txt"); + + using var sw = new StreamWriter(filePath, false, System.Text.Encoding.UTF8); + + sw.WriteLine("===================================================="); + sw.WriteLine(" 本地化审计报告"); + sw.WriteLine($" 生成时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + sw.WriteLine("===================================================="); + sw.WriteLine(); + sw.WriteLine($"缺失条目:{_issues.Count} 个"); + sw.WriteLine($"命名不规范:{_namingIssues.Count} 个"); + sw.WriteLine(); + + if (_issues.Count > 0) + { + sw.WriteLine("── 缺失翻译 ────────────────────────────────────────"); + foreach (var issue in _issues) + { + sw.WriteLine($" [{issue.table}] {issue.key}"); + sw.WriteLine($" 字段:{issue.fieldName}"); + sw.WriteLine($" 路径:{issue.soPath}"); + sw.WriteLine($" 缺失语言:{string.Join(", ", issue.missingLanguages)}"); + sw.WriteLine(); + } + } + + if (_namingIssues.Count > 0) + { + sw.WriteLine("── 命名不规范(应为 UPPER_SNAKE_CASE)────────────────"); + foreach (var ni in _namingIssues) + { + sw.WriteLine($" [{ni.table}] {ni.key}"); + sw.WriteLine($" 字段:{ni.fieldName}"); + sw.WriteLine($" 路径:{ni.soPath}"); + sw.WriteLine(); + } + } + + sw.WriteLine("===================================================="); + sw.Flush(); + + AssetDatabase.Refresh(); + + string relPath = $"Assets/_Game/Localization/AuditReport_{timestamp}.txt"; + var asset = AssetDatabase.LoadAssetAtPath(relPath); + if (asset != null) EditorGUIUtility.PingObject(asset); + + EditorUtility.DisplayDialog("导出成功", + $"报告已保存至:\n{filePath}", "打开文件夹"); + EditorUtility.RevealInFinder(filePath); + } + + // ── Key 检查 ────────────────────────────────────────────────────────── + + + private void CheckKey(UnityEngine.Object asset, string key, string table, string fieldName) + { + if (string.IsNullOrEmpty(key)) return; + + var issue = new AuditIssue + { + key = key, + table = table, + fieldName = fieldName, + soPath = AssetDatabase.GetAssetPath(asset), + asset = asset, + }; + + foreach (var lang in _availableLanguages) + { + var dict = LocalizationManager.GetEditorTable(lang, table); + if (dict == null || !dict.ContainsKey(key)) + issue.missingLanguages.Add(lang.ToString()); + } + + if (issue.missingLanguages.Count > 0) + _issues.Add(issue); + } + + private void CheckKeyNamingConvention(UnityEngine.Object asset, string key, string table, + string fieldName, string path) + { + if (string.IsNullOrEmpty(key)) return; + if (s_keyPattern.IsMatch(key)) return; + + _namingIssues.Add(new NamingIssue + { + key = key, + table = table, + fieldName = fieldName, + soPath = path, + asset = asset, + }); + } + + // ── 列表 / 详情 UI ──────────────────────────────────────────────────── + + private AuditIssue _selectedIssue; + + private void RebuildList() + { + _listItems.Clear(); + _namingSection.Clear(); + if (!_hasScanned) return; + + // ── 缺失问题列表(始终显示,不受命名 Chip 影响)───────────────── + var filtered = _issues.AsEnumerable(); + + if (_filterMissingAll) + filtered = filtered.Where(i => i.missingLanguages.Count == _totalLanguageCount); + else if (_filterMissingPartial) + filtered = filtered.Where(i => i.missingLanguages.Count > 0 && i.missingLanguages.Count < _totalLanguageCount); + + if (!string.IsNullOrEmpty(_filterTableName)) + filtered = filtered.Where(i => i.table.IndexOf(_filterTableName, StringComparison.OrdinalIgnoreCase) >= 0); + + var list = filtered.ToList(); + + if (list.Count == 0) + { + _listItems.Add(new Label("无缺失问题或无匹配结果。") { style = { paddingLeft = 10, opacity = 0.5f } }); + } + else + { + foreach (var issue in list) + { + var row = BuildMissingRow(issue); + _listItems.Add(row); + } + } + + // ── 命名不规范列表(独立折叠区)──────────────────────────────── + if (_namingIssues.Count > 0) + { + var foldout = new Foldout + { + text = $"命名不规范 Key({_namingIssues.Count} 个)", + value = _filterNamingIssue, + }; + foldout.style.paddingLeft = 4; + _namingSection.Add(foldout); + + var namingFiltered = _namingIssues.AsEnumerable(); + if (!string.IsNullOrEmpty(_filterTableName)) + namingFiltered = namingFiltered.Where(i => i.table.IndexOf(_filterTableName, StringComparison.OrdinalIgnoreCase) >= 0); + + foreach (var ni in namingFiltered) + { + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Row; + row.style.paddingLeft = 8; + row.style.paddingRight = 8; + row.style.paddingTop = 3; + row.style.paddingBottom = 3; + row.style.borderBottomWidth = 1; + row.style.borderBottomColor = new StyleColor(new Color(0.3f, 0.3f, 0.3f, 0.4f)); + row.style.backgroundColor = new StyleColor(new Color(0.2f, 0.2f, 0.45f, 0.25f)); + + var capturedNi = ni; + row.RegisterCallback(_ => + { + if (capturedNi.asset != null) + { + EditorGUIUtility.PingObject(capturedNi.asset); + Selection.activeObject = capturedNi.asset; + } + RebuildNamingDetail(capturedNi); + }); + + var lbl = new Label($"[{ni.table}] {ni.key} ({ni.fieldName})"); + lbl.style.flexGrow = 1; + lbl.style.fontSize = 11; + row.Add(lbl); + + var hint = new Label("应为 UPPER_SNAKE_CASE"); + hint.style.fontSize = 10; + hint.style.opacity = 0.6f; + row.Add(hint); + + foldout.Add(row); + } + } + } + + private VisualElement BuildMissingRow(AuditIssue issue) + { + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Row; + row.style.paddingLeft = 8; + row.style.paddingRight = 8; + row.style.paddingTop = 4; + row.style.paddingBottom = 4; + row.style.borderBottomWidth = 1; + row.style.borderBottomColor = new StyleColor(new Color(0.3f, 0.3f, 0.3f, 0.4f)); + + var captured = issue; + row.RegisterCallback(_ => + { + _selectedIssue = captured; + RebuildDetail(captured); + if (captured.asset != null) + EditorGUIUtility.PingObject(captured.asset); + }); + + bool allMissing = issue.missingLanguages.Count == _totalLanguageCount; + row.style.backgroundColor = new StyleColor(allMissing + ? new Color(0.45f, 0.15f, 0.05f, 0.35f) + : new Color(0.40f, 0.35f, 0.00f, 0.25f)); + + var left = new Label($"[{issue.table}] {issue.key}"); + left.style.flexGrow = 1; + left.style.fontSize = 11; + left.style.unityFontStyleAndWeight = FontStyle.Bold; + row.Add(left); + + var right = new Label(string.Join(", ", issue.missingLanguages)); + right.style.fontSize = 10; + right.style.opacity = 0.7f; + row.Add(right); + + return row; + } + + private void RebuildDetail(AuditIssue issue) + { + if (_detailRoot == null) return; + _detailRoot.Clear(); + + if (issue == null) + { + _detailRoot.Add(new Label("← 选择左侧条目查看详情。") { style = { paddingLeft = 16, paddingTop = 16, opacity = 0.5f } }); + return; + } + + var title = new Label($"Key:{issue.key}"); + title.style.fontSize = 14; + title.style.unityFontStyleAndWeight = FontStyle.Bold; + title.style.paddingLeft = 12; + title.style.paddingTop = 10; + title.style.paddingBottom = 6; + _detailRoot.Add(title); + + AddDetailRow(_detailRoot, "表名", issue.table); + AddDetailRow(_detailRoot, "字段", issue.fieldName); + AddDetailRow(_detailRoot, "资产路径", issue.soPath); + + _detailRoot.Add(new Label("缺失语言:") { style = { paddingLeft = 12, paddingTop = 8, fontSize = 11, unityFontStyleAndWeight = FontStyle.Bold } }); + foreach (var lang in issue.missingLanguages) + _detailRoot.Add(new Label($" • {lang}") { style = { paddingLeft = 20, fontSize = 11 } }); + + _detailRoot.Add(new VisualElement { style = { height = 8 } }); + var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, paddingLeft = 10, paddingRight = 10 } }; + _detailRoot.Add(btnRow); + + var pingBtn = new Button(() => + { + if (issue.asset != null) + { + EditorGUIUtility.PingObject(issue.asset); + Selection.activeObject = issue.asset; + } + }) { text = "定位 SO 资产", style = { flexGrow = 1, marginRight = 4 } }; + btnRow.Add(pingBtn); + + var copyBtn = new Button(() => + { + EditorGUIUtility.systemCopyBuffer = issue.key; + Debug.Log($"[LocalizationAudit] 已复制 Key:{issue.key}"); + }) { text = "复制 Key", style = { flexGrow = 1 } }; + btnRow.Add(copyBtn); + + foreach (var lang in issue.missingLanguages) + { + var capturedLang = lang; + var openBtn = new Button(() => PingTableFile(capturedLang, issue.table)) + { + text = $"打开 {lang}/{issue.table}.json", + style = { marginTop = 4, marginLeft = 10, marginRight = 10 }, + }; + _detailRoot.Add(openBtn); + } + } + + private void RebuildNamingDetail(NamingIssue ni) + { + if (_detailRoot == null) return; + _detailRoot.Clear(); + + var title = new Label($"命名不规范:{ni.key}"); + title.style.fontSize = 14; + title.style.unityFontStyleAndWeight = FontStyle.Bold; + title.style.paddingLeft = 12; + title.style.paddingTop = 10; + title.style.paddingBottom = 6; + _detailRoot.Add(title); + + AddDetailRow(_detailRoot, "表名", ni.table); + AddDetailRow(_detailRoot, "字段", ni.fieldName); + AddDetailRow(_detailRoot, "资产路径", ni.soPath); + + _detailRoot.Add(new Label("规范要求:UPPER_SNAKE_CASE(如 QUEST_FIND_HERB_NAME)") + { style = { paddingLeft = 12, paddingTop = 8, fontSize = 11, opacity = 0.7f } }); + + _detailRoot.Add(new VisualElement { style = { height = 8 } }); + var pingBtn = new Button(() => + { + if (ni.asset != null) + { + EditorGUIUtility.PingObject(ni.asset); + Selection.activeObject = ni.asset; + } + }) { text = "定位 SO 资产", style = { marginLeft = 10, marginRight = 10 } }; + _detailRoot.Add(pingBtn); + + var copyBtn = new Button(() => + { + EditorGUIUtility.systemCopyBuffer = ni.key; + Debug.Log($"[LocalizationAudit] 已复制 Key:{ni.key}"); + }) { text = "复制 Key", style = { marginLeft = 10, marginRight = 10, marginTop = 4 } }; + _detailRoot.Add(copyBtn); + } + + private static void AddDetailRow(VisualElement parent, string label, string value) + { + var row = new VisualElement { style = { flexDirection = FlexDirection.Row, paddingLeft = 12, paddingTop = 2, paddingBottom = 2 } }; + row.Add(new Label(label + ":") { style = { width = 80, opacity = 0.6f, fontSize = 11 } }); + row.Add(new Label(value) { style = { flexGrow = 1, fontSize = 11 } }); + parent.Add(row); + } + + private static void PingTableFile(string language, string tableName) + { + string path = $"Assets/Resources/Localization/{language}/{tableName}.json"; + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset != null) + { + EditorGUIUtility.PingObject(asset); + Selection.activeObject = asset; + } + else + { + Debug.LogWarning($"[LocalizationAudit] 未找到表文件:{path}"); + } + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Modules/LocalizationAuditModule.cs.meta b/Assets/_Game/Scripts/Editor/Modules/LocalizationAuditModule.cs.meta new file mode 100644 index 0000000..8b23491 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/LocalizationAuditModule.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b311cc7d8950e35458e44e0523e23ef0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Modules/NpcModule.cs b/Assets/_Game/Scripts/Editor/Modules/NpcModule.cs index 04ce059..d2699c4 100644 --- a/Assets/_Game/Scripts/Editor/Modules/NpcModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/NpcModule.cs @@ -6,6 +6,7 @@ using UnityEngine.UIElements; using BaseGames.Dialogue; using BaseGames.Quest; using BaseGames.Editor.Shared; +using BaseGames.Localization; namespace BaseGames.Editor.Modules { @@ -70,8 +71,8 @@ namespace BaseGames.Editor.Modules }; } - filterRow.Add(QuestModule.MakeFilterChip("有好感度", v => { filterAffinity = v; RebuildFilter(); })); - filterRow.Add(QuestModule.MakeFilterChip("有头像", v => { filterPortrait = v; RebuildFilter(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("有好感度", v => { filterAffinity = v; RebuildFilter(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("有头像", v => { filterPortrait = v; RebuildFilter(); })); container.Add(_listPane); _listPane.Refresh(); @@ -114,7 +115,7 @@ namespace BaseGames.Editor.Modules string nameDisplay = string.IsNullOrEmpty(n.nameKey) ? "(未设置)" - : (BaseGames.Localization.LocalizationManager.GetEditorPreview(n.nameKey, "Dialogue") ?? n.nameKey); + : (BaseGames.Localization.LocalizationManager.GetEditorPreview(n.nameKey, LocalizationTable.Dialogue) ?? n.nameKey); SkillModule.AddChip(card, "名称", nameDisplay); if (!string.IsNullOrEmpty(n.nameKey)) SkillModule.AddChip(card, "名称 Key", n.nameKey); @@ -263,7 +264,7 @@ namespace BaseGames.Editor.Modules string GetLoc(string key) { if (locCache.TryGetValue(key, out var v)) return v; - v = BaseGames.Localization.LocalizationManager.GetEditorPreview(key, "Dialogue"); + v = BaseGames.Localization.LocalizationManager.GetEditorPreview(key, LocalizationTable.Dialogue); locCache[key] = v; return v; } diff --git a/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs b/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs index 6f51cbc..90b68fb 100644 --- a/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs @@ -8,6 +8,7 @@ using UnityEngine; using UnityEngine.UIElements; using BaseGames.Quest; using BaseGames.Editor.Shared; +using BaseGames.Localization; namespace BaseGames.Editor.Modules { @@ -32,7 +33,7 @@ namespace BaseGames.Editor.Modules private System.Action _playModeHandler; // 依赖关系图中 FindAll() 的静态缓存,同一编辑器会话内复用,避免重复扫描磁盘 - private static QuestSO[] s_allQuestCache; + private static List s_allQuestCache; private static double s_allQuestCacheTime; private const double k_AllQuestCacheTtl = 5.0; // 秒;超时后下次打开 foldout 时刷新 @@ -96,62 +97,26 @@ namespace BaseGames.Editor.Modules }; } - filterRow.Add(MakeFilterChip("主线", v => { filterCategory = v ? QuestCategory.Main : (QuestCategory?)null; RebuildFilter(); })); - filterRow.Add(MakeFilterChip("支线", v => { filterCategory = v ? QuestCategory.Side : (QuestCategory?)null; RebuildFilter(); })); - filterRow.Add(MakeFilterChip("日常", v => { filterCategory = v ? QuestCategory.Daily : (QuestCategory?)null; RebuildFilter(); })); - filterRow.Add(MakeFilterChip("隐藏", v => { filterCategory = v ? QuestCategory.Hidden : (QuestCategory?)null; RebuildFilter(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("主线", v => { filterCategory = v ? QuestCategory.Main : (QuestCategory?)null; RebuildFilter(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("支线", v => { filterCategory = v ? QuestCategory.Side : (QuestCategory?)null; RebuildFilter(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("日常", v => { filterCategory = v ? QuestCategory.Daily : (QuestCategory?)null; RebuildFilter(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("隐藏", v => { filterCategory = v ? QuestCategory.Hidden : (QuestCategory?)null; RebuildFilter(); })); // 分隔 var sep = new Label("|"); sep.style.opacity = 0.3f; sep.style.marginLeft = 2; sep.style.marginRight = 2; filterRow.Add(sep); - filterRow.Add(MakeFilterChip("有前置", v => { filterPrereq = v; RebuildFilter(); })); - filterRow.Add(MakeFilterChip("无目标", v => { filterNoObj = v; RebuildFilter(); })); - filterRow.Add(MakeFilterChip("可失败", v => { filterCanFail = v; RebuildFilter(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("有前置", v => { filterPrereq = v; RebuildFilter(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("无目标", v => { filterNoObj = v; RebuildFilter(); })); + filterRow.Add(DataHubEditorKit.MakeFilterChip("可失败", v => { filterCanFail = v; RebuildFilter(); })); container.Add(_listPane); _listPane.Refresh(); } internal static VisualElement MakeFilterChip(string label, System.Action onToggle) - { - bool active = false; - var chip = new Label(label); - chip.style.fontSize = 10; - chip.style.paddingLeft = 6; - chip.style.paddingRight = 6; - chip.style.paddingTop = 2; - chip.style.paddingBottom = 2; - chip.style.marginRight = 4; - chip.style.marginBottom = 2; - chip.style.borderTopLeftRadius = 8; - chip.style.borderTopRightRadius = 8; - chip.style.borderBottomLeftRadius = 8; - chip.style.borderBottomRightRadius = 8; - chip.style.borderTopWidth = 1; - chip.style.borderRightWidth = 1; - chip.style.borderBottomWidth = 1; - chip.style.borderLeftWidth = 1; - chip.style.borderTopColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f)); - chip.style.borderRightColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f)); - chip.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f)); - chip.style.borderLeftColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f)); - chip.style.opacity = 0.6f; - - void SetActive(bool on) - { - active = on; - chip.style.opacity = on ? 1f : 0.6f; - chip.style.backgroundColor = on - ? new StyleColor(new Color(0.3f, 0.6f, 1f, 0.25f)) - : StyleKeyword.None; - onToggle(on); - } - - chip.RegisterCallback(_ => SetActive(!active)); - return chip; - } + => DataHubEditorKit.MakeFilterChip(label, onToggle); public void BuildDetailPane(VisualElement container, UnityEngine.Object selected) { @@ -201,7 +166,7 @@ namespace BaseGames.Editor.Modules } else { - var resolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(s.displayNameKey, "Quest"); + var resolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(s.displayNameKey, LocalizationTable.Quest); nameDisplay = resolved != null ? resolved : s.displayNameKey + " ⚠ [缺少本地化]"; } SkillModule.AddChip(card, "名称", nameDisplay); @@ -343,7 +308,7 @@ namespace BaseGames.Editor.Modules // 目标描述(本地化预览,灰色小字,显示策划填写的实际内容) if (!string.IsNullOrEmpty(obj.displayTextKey)) { - var resolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(obj.displayTextKey, "Quest"); + var resolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(obj.displayTextKey, LocalizationTable.Quest); bool l10nMissing = resolved == null; string descText = l10nMissing ? obj.displayTextKey + " ⚠ [缺少本地化]" : resolved; var desc = new Label(descText); diff --git a/Assets/_Game/Scripts/Editor/Modules/WeaponModule.cs b/Assets/_Game/Scripts/Editor/Modules/WeaponModule.cs index 2e2a643..88247a4 100644 --- a/Assets/_Game/Scripts/Editor/Modules/WeaponModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/WeaponModule.cs @@ -55,16 +55,19 @@ namespace BaseGames.Editor.Modules if (_selected == null) return; - // Stats Card + // Stats Card(复用 SkillModule 共享构建方法) var statsCard = BuildStatsCard(_selected); container.Add(statsCard); - // 操作按钮行 - var toolbar = BuildActionBar(_selected); - container.Add(toolbar); + // 操作按钮行(复用 BuildStandardActionBar,统一按钮样式) + container.Add(SkillModule.BuildStandardActionBar( + _selected, Folder, Prefix, + created => _listPane.Refresh(created), + cloned => _listPane.Refresh(cloned), + () => _listPane.Refresh(null))); // 分隔线 - container.Add(MakeDivider()); + container.Add(SkillModule.MakeDivider()); // Inspector var insp = new InspectorElement(_selected); container.Add(insp); @@ -92,92 +95,12 @@ namespace BaseGames.Editor.Modules private static VisualElement BuildStatsCard(WeaponSO w) { - var card = new VisualElement(); - card.style.flexDirection = FlexDirection.Row; - card.style.flexWrap = Wrap.Wrap; - card.style.paddingLeft = 12; - card.style.paddingRight = 12; - card.style.paddingTop = 8; - card.style.paddingBottom = 8; - card.style.marginBottom = 4; - card.style.backgroundColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.08f)); - card.style.borderBottomWidth = 1; - card.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.2f)); - - AddStatChip(card, "类型", w.weaponType.ToString()); - AddStatChip(card, "地面段数", (w.groundComboSteps?.Length ?? 0).ToString()); - AddStatChip(card, "空中段数", (w.airComboSteps?.Length ?? 0).ToString()); - AddStatChip(card, "ID", string.IsNullOrEmpty(w.weaponId) ? "-" : w.weaponId); + var card = SkillModule.MakeCard(); + SkillModule.AddChip(card, "类型", w.weaponType.ToString()); + SkillModule.AddChip(card, "地面段数", (w.groundComboSteps?.Length ?? 0).ToString()); + SkillModule.AddChip(card, "空中段数", (w.airComboSteps?.Length ?? 0).ToString()); + SkillModule.AddChip(card, "ID", string.IsNullOrEmpty(w.weaponId) ? "-" : w.weaponId); return card; } - - private static void AddStatChip(VisualElement parent, string label, string value) - { - var chip = new VisualElement(); - chip.style.flexDirection = FlexDirection.Row; - chip.style.alignItems = Align.Center; - chip.style.marginRight = 14; - chip.style.marginBottom = 2; - - var lbl = new Label(label + ":"); - lbl.style.opacity = 0.6f; - lbl.style.fontSize = 11; - lbl.style.marginRight = 3; - chip.Add(lbl); - - var val = new Label(value); - val.style.fontSize = 11; - val.style.unityFontStyleAndWeight = UnityEngine.FontStyle.Bold; - chip.Add(val); - - parent.Add(chip); - } - - private VisualElement BuildActionBar(WeaponSO w) - { - var bar = new VisualElement(); - bar.style.flexDirection = FlexDirection.Row; - bar.style.paddingLeft = 12; - bar.style.paddingRight = 12; - bar.style.paddingTop = 6; - bar.style.paddingBottom = 6; - bar.style.flexWrap = Wrap.Wrap; - - var btnPing = new Button(() => { EditorGUIUtility.PingObject(w); Selection.activeObject = w; }) - { text = "在 Project 中定位", tooltip = "在 Project 窗口高亮此资产" }; - bar.Add(btnPing); - - var btnClone = new Button(() => - { - var clone = AssetOperations.Clone(w, Folder); - if (clone != null) _listPane.Refresh(clone); - }) { text = "克隆..." }; - bar.Add(btnClone); - - var btnDel = new Button(() => - { - if (AssetOperations.Delete(w)) _listPane.Refresh(null); - }) { text = "删除" }; - btnDel.style.borderLeftColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f)); - btnDel.style.borderRightColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f)); - btnDel.style.borderTopColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f)); - btnDel.style.borderBottomColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f)); - btnDel.style.borderLeftWidth = 1; - btnDel.style.borderRightWidth = 1; - btnDel.style.borderTopWidth = 1; - btnDel.style.borderBottomWidth = 1; - btnDel.style.marginLeft = 8; - bar.Add(btnDel); - - return bar; - } - - private static VisualElement MakeDivider() - { - var d = new VisualElement(); - d.style.height = 1; - d.style.backgroundColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.2f)); - return d; - } } } diff --git a/Assets/_Game/Scripts/Editor/Quest/QuestOverviewEditorWindow.cs.meta b/Assets/_Game/Scripts/Editor/Quest/QuestOverviewEditorWindow.cs.meta new file mode 100644 index 0000000..1faad5c --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Quest/QuestOverviewEditorWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 463effce1dd1fc648aea0ed635a9e7a6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Quest/QuestSOEditor.cs.meta b/Assets/_Game/Scripts/Editor/Quest/QuestSOEditor.cs.meta new file mode 100644 index 0000000..613f07a --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Quest/QuestSOEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 363d113e10945e240bf0260221ae2aff +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Shared/AssetCreationWizard.cs.meta b/Assets/_Game/Scripts/Editor/Shared/AssetCreationWizard.cs.meta new file mode 100644 index 0000000..ed99282 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Shared/AssetCreationWizard.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 472cbbc6ac2cacd46947af5235b8e47b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Shared/DataHubEditorKit.cs b/Assets/_Game/Scripts/Editor/Shared/DataHubEditorKit.cs new file mode 100644 index 0000000..dcdc63d --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Shared/DataHubEditorKit.cs @@ -0,0 +1,56 @@ +using System; +using UnityEngine; +using UnityEngine.UIElements; + +namespace BaseGames.Editor +{ + /// + /// DataHub 模块通用 UI 工具集。提供跨模块复用的 UIElements 控件工厂方法。 + /// + public static class DataHubEditorKit + { + /// + /// 创建可切换的过滤标签按钮(圆角 chip 样式)。 + /// 点击后切换激活/非激活状态,并触发 回调。 + /// + public static VisualElement MakeFilterChip(string label, Action onToggle) + { + bool active = false; + var chip = new Label(label); + chip.style.fontSize = 10; + chip.style.paddingLeft = 6; + chip.style.paddingRight = 6; + chip.style.paddingTop = 2; + chip.style.paddingBottom = 2; + chip.style.marginRight = 4; + chip.style.marginBottom = 2; + chip.style.borderTopLeftRadius = 8; + chip.style.borderTopRightRadius = 8; + chip.style.borderBottomLeftRadius = 8; + chip.style.borderBottomRightRadius = 8; + chip.style.borderTopWidth = 1; + chip.style.borderRightWidth = 1; + chip.style.borderBottomWidth = 1; + chip.style.borderLeftWidth = 1; + var borderColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f)); + chip.style.borderTopColor = borderColor; + chip.style.borderRightColor = borderColor; + chip.style.borderBottomColor = borderColor; + chip.style.borderLeftColor = borderColor; + chip.style.opacity = 0.6f; + + void SetActive(bool on) + { + active = on; + chip.style.opacity = on ? 1f : 0.6f; + chip.style.backgroundColor = on + ? new StyleColor(new Color(0.3f, 0.6f, 1f, 0.25f)) + : StyleKeyword.None; + onToggle(on); + } + + chip.RegisterCallback(_ => SetActive(!active)); + return chip; + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Shared/DataHubEditorKit.cs.meta b/Assets/_Game/Scripts/Editor/Shared/DataHubEditorKit.cs.meta new file mode 100644 index 0000000..84d61b9 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Shared/DataHubEditorKit.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 666eee8c08676294db8404d0eb3409f2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Shared/SoListPane.cs b/Assets/_Game/Scripts/Editor/Shared/SoListPane.cs index d9df4e6..7797744 100644 --- a/Assets/_Game/Scripts/Editor/Shared/SoListPane.cs +++ b/Assets/_Game/Scripts/Editor/Shared/SoListPane.cs @@ -247,7 +247,6 @@ namespace BaseGames.Editor if (_extraFilter != null && !_extraFilter(item)) continue; _filtered.Add(item); } - } _listView.RefreshItems(); _countLabel.text = _all.Count == _filtered.Count diff --git a/Assets/_Game/Scripts/Editor/UI.meta b/Assets/_Game/Scripts/Editor/UI.meta new file mode 100644 index 0000000..3dfb981 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/UI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f33c1c7220eea6d45a73820db7b94e35 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/UI/UIManagerEditor.cs b/Assets/_Game/Scripts/Editor/UI/UIManagerEditor.cs new file mode 100644 index 0000000..914e217 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/UI/UIManagerEditor.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using UnityEngine; +using UnityEditor; +using BaseGames.UI; + +namespace BaseGames.Editor.UI +{ + /// + /// UIManager 自定义 Inspector。 + /// + /// 功能: + /// 1. 绘制默认 Inspector(原有字段布局不变) + /// 2. 在面板注册表下方实时显示验证状态: + /// · 红色错误:PanelId 重复注册 + /// · 黄色警告:GameObject 引用为 null + /// · 绿色信息:全部验证通过 + /// 3. 提供一键测试按钮(仅 Play Mode 有效) + /// + [CustomEditor(typeof(UIManager))] + public class UIManagerEditor : UnityEditor.Editor + { + public override void OnInspectorGUI() + { + DrawDefaultInspector(); + + EditorGUILayout.Space(6); + EditorGUILayout.LabelField("── 注册表验证 ──────────────────────", EditorStyles.boldLabel); + + var panelsProp = serializedObject.FindProperty("_panels"); + if (panelsProp == null || !panelsProp.isArray || panelsProp.arraySize == 0) + { + EditorGUILayout.HelpBox("面板注册表为空,请在 Inspector 中添加面板绑定。", MessageType.Warning); + } + else + { + var seenIds = new HashSet(); + bool hasError = false; + bool hasWarning = false; + + for (int i = 0; i < panelsProp.arraySize; i++) + { + var elem = panelsProp.GetArrayElementAtIndex(i); + var idProp = elem.FindPropertyRelative("id"); + var rootProp = elem.FindPropertyRelative("root"); + + string idName = idProp != null + ? idProp.enumDisplayNames[idProp.enumValueIndex] + : $"[{i}]"; + + if (rootProp != null && rootProp.objectReferenceValue == null) + { + EditorGUILayout.HelpBox($"[{i}] PanelId.{idName} 的 GameObject 引用为 null!", MessageType.Warning); + hasWarning = true; + } + + if (idProp != null && !seenIds.Add(idProp.enumValueIndex)) + { + EditorGUILayout.HelpBox($"[{i}] PanelId.{idName} 重复注册!同一 ID 只允许绑定一个 GameObject。", MessageType.Error); + hasError = true; + } + } + + if (!hasError && !hasWarning) + EditorGUILayout.HelpBox($"注册表验证通过 ✓ 共 {panelsProp.arraySize} 个面板", MessageType.Info); + } + + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("── 运行期快速测试(需 Play Mode)─────", EditorStyles.boldLabel); + + using (new EditorGUI.DisabledScope(!Application.isPlaying)) + { + var manager = (UIManager)target; + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("打开 Pause")) manager.OpenPanel(PanelId.Pause); + if (GUILayout.Button("打开 Map")) manager.OpenPanel(PanelId.Map); + if (GUILayout.Button("打开 Shop")) manager.OpenPanel(PanelId.Shop); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("打开 CharmPanel")) manager.OpenPanel(PanelId.CharmPanel); + if (GUILayout.Button("打开 Settings")) manager.OpenPanel(PanelId.Settings); + if (GUILayout.Button("关闭栈顶面板")) manager.CloseTopPanel(); + EditorGUILayout.EndHorizontal(); + + // ── 面板栈实时可视化 ─────────────────────────────────────────── + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("── 面板栈(栊顶 = 第一行)───────────────────", EditorStyles.boldLabel); + + var snapshot = manager.EditorGetPanelSnapshot(); + if (snapshot == null || snapshot.Length == 0) + { + EditorGUILayout.HelpBox("面板栈为空", MessageType.None); + } + else + { + for (int i = 0; i < snapshot.Length; i++) + { + var go = snapshot[i]; + string label = go != null ? go.name : "(null)"; + if (i == 0) + { + // 栈顶面板加粗显示 + var style = new GUIStyle(EditorStyles.label) + { + fontStyle = FontStyle.Bold, + }; + EditorGUILayout.LabelField($"▶ [{i}] {label} (栈顶)", style); + } + else + { + EditorGUILayout.LabelField($" [{i}] {label}"); + } + } + } + } + } + + public override bool RequiresConstantRepaint() => Application.isPlaying; + } +} diff --git a/Assets/_Game/Scripts/Editor/UI/UIManagerEditor.cs.meta b/Assets/_Game/Scripts/Editor/UI/UIManagerEditor.cs.meta new file mode 100644 index 0000000..15b75f6 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/UI/UIManagerEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 95f2af7feffeab44799a4127da2c7f8f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Equipment/BaseGames.Equipment.asmdef b/Assets/_Game/Scripts/Equipment/BaseGames.Equipment.asmdef index a44ad23..9587d25 100644 --- a/Assets/_Game/Scripts/Equipment/BaseGames.Equipment.asmdef +++ b/Assets/_Game/Scripts/Equipment/BaseGames.Equipment.asmdef @@ -14,7 +14,8 @@ "BaseGames.Combat", "BaseGames.Feedback", "BaseGames.Skills", - "BaseGames.Core.Save" + "BaseGames.Core.Save", + "BaseGames.Localization" ], "autoReferenced": true, "overrideReferences": false, diff --git a/Assets/_Game/Scripts/Equipment/CharmSO.cs b/Assets/_Game/Scripts/Equipment/CharmSO.cs index 2a6cbf6..a60776d 100644 --- a/Assets/_Game/Scripts/Equipment/CharmSO.cs +++ b/Assets/_Game/Scripts/Equipment/CharmSO.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using UnityEngine; +using BaseGames.Localization; namespace BaseGames.Equipment { @@ -8,7 +9,7 @@ namespace BaseGames.Equipment /// 资产路径: Assets/ScriptableObjects/Equipment/Charms/Charm_{Name}.asset /// [CreateAssetMenu(menuName = "BaseGames/Equipment/Charm")] - public class CharmSO : ScriptableObject + public class CharmSO : ScriptableObject, ILocalizableAsset { [Header("Identity")] public string charmId; // 全局唯一 ID,如 "Charm_QuickSlash" @@ -31,5 +32,13 @@ namespace BaseGames.Equipment [Header("Lore")] public bool isUnique; public string unlockHint; + + public IEnumerable GetLocalizationKeys() + { + if (!string.IsNullOrEmpty(displayNameKey)) + yield return new LocalizationKeyRef(displayNameKey, "Items", nameof(displayNameKey)); + if (!string.IsNullOrEmpty(descriptionKey)) + yield return new LocalizationKeyRef(descriptionKey, "Items", nameof(descriptionKey)); + } } } diff --git a/Assets/_Game/Scripts/Equipment/EquipmentManager.cs b/Assets/_Game/Scripts/Equipment/EquipmentManager.cs index 4cf0841..ed6bc0e 100644 --- a/Assets/_Game/Scripts/Equipment/EquipmentManager.cs +++ b/Assets/_Game/Scripts/Equipment/EquipmentManager.cs @@ -15,7 +15,7 @@ namespace BaseGames.Equipment /// 挂在 Player 上,管理护符的装备/卸下及 Notch 容量。 /// 实现 ISaveable 以持久化装备状态。 /// - public class EquipmentManager : MonoBehaviour, ISaveable + public class EquipmentManager : MonoBehaviour, ISaveable, IEquipmentService { [Header("配置")] [SerializeField] private EquipmentConfigSO _config; @@ -40,12 +40,14 @@ namespace BaseGames.Equipment // ── 生命周期 ────────────────────────────────────────────────────────── private void OnEnable() { + ServiceLocator.Register(this); ServiceLocator.GetOrDefault()?.Register(this); _onAchievementNotchGranted?.Subscribe(() => IncreaseNotches(1)).AddTo(_subs); } private void OnDisable() { + ServiceLocator.Unregister(this); ServiceLocator.GetOrDefault()?.Unregister(this); _subs.Clear(); } diff --git a/Assets/_Game/Scripts/Equipment/IEquipmentService.cs b/Assets/_Game/Scripts/Equipment/IEquipmentService.cs new file mode 100644 index 0000000..491f23c --- /dev/null +++ b/Assets/_Game/Scripts/Equipment/IEquipmentService.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace BaseGames.Equipment +{ + /// + /// 装备管理服务接口。UI 层通过此接口读取护符状态并驱动装备操作, + /// 与 EquipmentManager 具体实现解耦,便于独立测试和跨场景复用。 + /// + public interface IEquipmentService + { + /// 已使用的 Notch 数量(缓存值,避免每帧 LINQ Sum)。 + int UsedNotches { get; } + + /// Notch 总容量。 + int TotalNotches { get; } + + /// 当前已装备护符列表(只读视图)。 + IReadOnlyList Equipped { get; } + + /// 已收集护符列表(只读视图)。 + IReadOnlyList Collected { get; } + + /// + /// 装备护符。返回 null 表示成功;返回错误描述字符串表示失败原因。 + /// + string TryEquipCharm(CharmSO charm); + + /// 卸下指定护符。 + void UnequipCharm(CharmSO charm); + } +} diff --git a/Assets/_Game/Scripts/Equipment/IEquipmentService.cs.meta b/Assets/_Game/Scripts/Equipment/IEquipmentService.cs.meta new file mode 100644 index 0000000..657b2f8 --- /dev/null +++ b/Assets/_Game/Scripts/Equipment/IEquipmentService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 475a394eced2eba46b59669695d20446 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Equipment/IToolSlotService.cs b/Assets/_Game/Scripts/Equipment/IToolSlotService.cs new file mode 100644 index 0000000..3ab5b0b --- /dev/null +++ b/Assets/_Game/Scripts/Equipment/IToolSlotService.cs @@ -0,0 +1,22 @@ +namespace BaseGames.Equipment +{ + /// + /// 工具槽查询接口(架构 09_ProgressionModule §7.5)。 + /// UI 层通过 ServiceLocator.GetOrDefault<IToolSlotService>() 消费此接口, + /// 避免直接引用 ToolSlotManager 具体类型,保持 UI 与 Equipment 程序集的解耦。 + /// + public interface IToolSlotService + { + /// 返回指定槽位当前装备的 ToolSO;槽位为空时返回 null。 + ToolSO GetTool(int slotIndex); + + /// 返回指定槽位剩余使用次数;-1 表示无限次;槽位为空时返回 0。 + int GetRemainingUses(int slotIndex); + + /// + /// 返回指定槽位的冷却进度比例,范围 [0, 1]。 + /// 0 = 不在冷却中(可使用);1 = 刚刚使用,冷却刚开始。 + /// + float GetCooldownRatio(int slotIndex); + } +} diff --git a/Assets/_Game/Scripts/Equipment/IToolSlotService.cs.meta b/Assets/_Game/Scripts/Equipment/IToolSlotService.cs.meta new file mode 100644 index 0000000..4fa4a37 --- /dev/null +++ b/Assets/_Game/Scripts/Equipment/IToolSlotService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 28930e99c6de2674bb4f9bedb4a80d60 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Equipment/ToolSO.cs b/Assets/_Game/Scripts/Equipment/ToolSO.cs index b3e53fc..bf2f5b9 100644 --- a/Assets/_Game/Scripts/Equipment/ToolSO.cs +++ b/Assets/_Game/Scripts/Equipment/ToolSO.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using UnityEngine; using BaseGames.Player.States; +using BaseGames.Localization; namespace BaseGames.Equipment { @@ -36,7 +38,7 @@ namespace BaseGames.Equipment /// 资产路径: Assets/ScriptableObjects/Equipment/Tools/Tool_{Name}.asset /// [CreateAssetMenu(menuName = "BaseGames/Equipment/Tool")] - public class ToolSO : ScriptableObject + public class ToolSO : ScriptableObject, ILocalizableAsset { public string toolId; public string displayNameKey; @@ -46,6 +48,12 @@ namespace BaseGames.Equipment public int maxUses = 1; [SerializeReference] - public IToolEffect effect; // 工具使用效果(多态) + public IToolEffect effect; + + public IEnumerable GetLocalizationKeys() + { + if (!string.IsNullOrEmpty(displayNameKey)) + yield return new LocalizationKeyRef(displayNameKey, "Items", nameof(displayNameKey)); + } } } diff --git a/Assets/_Game/Scripts/Equipment/ToolSlotManager.cs b/Assets/_Game/Scripts/Equipment/ToolSlotManager.cs index d3dcff5..584a87c 100644 --- a/Assets/_Game/Scripts/Equipment/ToolSlotManager.cs +++ b/Assets/_Game/Scripts/Equipment/ToolSlotManager.cs @@ -11,7 +11,7 @@ namespace BaseGames.Equipment /// 管理玩家的 2 个工具槽(装备、使用、冷却)。 /// 实现 ISaveable 以持久化槽位状态。 /// - public class ToolSlotManager : MonoBehaviour, ISaveable + public class ToolSlotManager : MonoBehaviour, ISaveable, IToolSlotService { private const int SlotCount = 2; @@ -27,8 +27,17 @@ namespace BaseGames.Equipment Debug.Assert(_toolCatalog != null, "[ToolSlotManager] _toolCatalog 未赋值,请在 Inspector 中指定 ToolCatalogSO。", this); } - private void OnEnable() => ServiceLocator.GetOrDefault()?.Register(this); - private void OnDisable() => ServiceLocator.GetOrDefault()?.Unregister(this); + private void OnEnable() + { + ServiceLocator.GetOrDefault()?.Register(this); + ServiceLocator.Register(this); + } + + private void OnDisable() + { + ServiceLocator.GetOrDefault()?.Unregister(this); + ServiceLocator.Unregister(this); + } // ── 装备 ───────────────────────────────────────────────────────────── public void EquipTool(int slotIndex, ToolSO tool) diff --git a/Assets/_Game/Scripts/Localization/BaseGames.Localization.asmdef b/Assets/_Game/Scripts/Localization/BaseGames.Localization.asmdef index 0d0e681..d0e065c 100644 --- a/Assets/_Game/Scripts/Localization/BaseGames.Localization.asmdef +++ b/Assets/_Game/Scripts/Localization/BaseGames.Localization.asmdef @@ -1,17 +1,19 @@ { - "excludePlatforms": [], - "allowUnsafeCode": false, - "precompiledReferences": [], - "name": "BaseGames.Localization", - "defineConstraints": [], - "noEngineReferences": false, - "versionDefines": [], - "rootNamespace": "BaseGames.Localization", - "references": [ - "BaseGames.Core.Events", - "BaseGames.Core.Save" - ], - "autoReferenced": true, - "overrideReferences": false, - "includePlatforms": [] -} + "name": "BaseGames.Localization", + "rootNamespace": "BaseGames.Localization", + "references": [ + "BaseGames.Core", + "BaseGames.Core.Events", + "BaseGames.Core.Save", + "Unity.TextMeshPro" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Assets/_Game/Scripts/Localization/ILocalizableAsset.cs b/Assets/_Game/Scripts/Localization/ILocalizableAsset.cs new file mode 100644 index 0000000..82cbd68 --- /dev/null +++ b/Assets/_Game/Scripts/Localization/ILocalizableAsset.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; + +namespace BaseGames.Localization +{ + /// + /// 标记一个 ScriptableObject 持有可本地化字段。 + /// 实现此接口后, + /// 将自动发现并检查该 SO 的所有 Key,无需在审计模块中硬编码扫描逻辑。 + /// + /// 新增 SO 类型时:实现此接口即可自动纳入本地化审计,不需要修改审计模块。 + /// + public interface ILocalizableAsset + { + /// + /// 返回该资产中所有本地化 Key 的引用列表。 + /// 实现时跳过空 key(string.IsNullOrEmpty 检查)。 + /// + IEnumerable GetLocalizationKeys(); + } + + /// + /// 对一个本地化 Key 引用的描述,供审计工具使用。 + /// + public readonly struct LocalizationKeyRef + { + /// 本地化 Key 字符串。 + public readonly string Key; + + /// 所属表名(使用 常量)。 + public readonly string Table; + + /// 该 Key 来自的字段名称,用于审计报告中精确定位。 + public readonly string FieldName; + + public LocalizationKeyRef(string key, string table, string fieldName) + { + Key = key; + Table = table; + FieldName = fieldName; + } + } +} diff --git a/Assets/_Game/Scripts/Localization/ILocalizableAsset.cs.meta b/Assets/_Game/Scripts/Localization/ILocalizableAsset.cs.meta new file mode 100644 index 0000000..c1b3cbb --- /dev/null +++ b/Assets/_Game/Scripts/Localization/ILocalizableAsset.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b4ea4cfc00373e14aadc6750c579aae7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Localization/ILocalizationService.cs b/Assets/_Game/Scripts/Localization/ILocalizationService.cs index 7c6d3c0..32225af 100644 --- a/Assets/_Game/Scripts/Localization/ILocalizationService.cs +++ b/Assets/_Game/Scripts/Localization/ILocalizationService.cs @@ -12,12 +12,49 @@ namespace BaseGames.Localization /// /// 获取本地化字符串。查找顺序:当前语言 → 回退语言(English)→ 直接返回 key。 + /// 表名建议使用 中的常量。 /// - string Get(string key, string table = "UI"); + string Get(string key, string table = LocalizationTable.UI); + + /// + /// 尝试获取本地化字符串。返回 false 表示 key 在所有语言(含回退)中均不存在。 + /// 与 不同:key 不存在时不会将 key 本身作为值返回。 + /// 适用于需要区分"key 存在但值为空"和"key 完全不存在"的场景。 + /// + bool TryGet(string key, out string value, string table = LocalizationTable.UI); + + /// + /// 获取带格式化参数的本地化字符串(string.Format 风格)。 + /// 例:GetFormat("REWARD_GOLD", LocalizationTable.UI, amount) → "获得 100 灵珠"。 + /// 格式化失败时静默返回原始模板字符串,不抛出异常。 + /// + string GetFormat(string key, string table, params object[] args); /// 切换游戏语言并通知所有订阅者刷新文本。 void SetLanguage(Language language); + /// + /// 同步预热指定语言的所有已知本地化表,避免首次访问时产生帧卡顿。 + /// 在主线程阻塞执行,建议改用 分帧加载。 + /// + void PreloadTables(Language language); + + /// + /// 异步分帧预热指定语言的所有本地化表(每帧加载一个表,不阻塞主线程)。 + /// 建议在 Loading Screen 的协程中调用。 + /// + /// 要预热的语言。 + /// 全部表加载完成后的回调(可为 null)。 + void PreloadTablesAsync(Language language, Action onComplete = null); + + /// + /// 获取带数量的复数形式本地化字符串。 + /// 规则:先查找 "{key}_one"(count==1)或 "{key}_other"(count≠1),找不到则回退到基础 key。 + /// 查找到的模板以 string.Format(template, count) 展开,{0} 代入 count。 + /// 示例:key="ITEM_COUNT",表中配置 "ITEM_COUNT_other"="获得 {0} 个物品" → "获得 5 个物品"。 + /// + string GetPlural(string key, int count, string table = LocalizationTable.UI); + /// 语言切换时触发。 event Action OnLanguageChanged; } diff --git a/Assets/_Game/Scripts/Localization/LanguageFontConfigSO.cs b/Assets/_Game/Scripts/Localization/LanguageFontConfigSO.cs new file mode 100644 index 0000000..34f2f95 --- /dev/null +++ b/Assets/_Game/Scripts/Localization/LanguageFontConfigSO.cs @@ -0,0 +1,59 @@ +using System; +using TMPro; +using UnityEngine; + +namespace BaseGames.Localization +{ + /// + /// 每种语言对应的 TMP 字体配置。 + /// 创建资产:Assets/Data/Localization/FontConfig.asset + /// + /// 用法:将此资产拖入 的 + /// Font Config 字段,切换语言时 LocalizedText 将自动替换字体和材质。 + /// + /// CJK 语言通常需要单独的字体资产,无需为每个文本节点逐一指定, + /// 只需在此 SO 中统一配置一次即可全局生效。 + /// + [CreateAssetMenu( + menuName = "BaseGames/Localization/Language Font Config", + fileName = "FontConfig")] + public class LanguageFontConfigSO : ScriptableObject + { + [Serializable] + public class FontEntry + { + [Tooltip("对应的语言。")] + public Language language; + + [Tooltip("该语言使用的 TMP 字体资产(留空表示沿用默认字体)。")] + public TMP_FontAsset fontAsset; + + [Tooltip("该语言字体的材质预设(留空表示使用字体默认材质)。")] + public Material fontMaterial; + } + + [Tooltip("每种语言的字体映射。未列出的语言保持默认字体不变。")] + public FontEntry[] entries = Array.Empty(); + + /// + /// 尝试获取指定语言的字体配置。 + /// 返回 false 表示该语言未配置,调用方应保持现有字体不变。 + /// + public bool TryGetFont(Language language, out TMP_FontAsset font, out Material material) + { + if (entries != null) + { + foreach (var entry in entries) + { + if (entry.language != language) continue; + font = entry.fontAsset; + material = entry.fontMaterial; + return true; + } + } + font = null; + material = null; + return false; + } + } +} diff --git a/Assets/_Game/Scripts/Localization/LanguageFontConfigSO.cs.meta b/Assets/_Game/Scripts/Localization/LanguageFontConfigSO.cs.meta new file mode 100644 index 0000000..cc89765 --- /dev/null +++ b/Assets/_Game/Scripts/Localization/LanguageFontConfigSO.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b31f31378d72a8e42b454d4af01e51ab +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Localization/LocalizationManager.cs b/Assets/_Game/Scripts/Localization/LocalizationManager.cs index e805698..39298ef 100644 --- a/Assets/_Game/Scripts/Localization/LocalizationManager.cs +++ b/Assets/_Game/Scripts/Localization/LocalizationManager.cs @@ -1,4 +1,4 @@ -// Assets/Scripts/Localization/LocalizationManager.cs +// Assets/Scripts/Localization/LocalizationManager.cs // 本地化管理器(运行时 JSON 文件驱动)。 // // 数据格式(放在 Resources/Localization/{Language}/{TableName}.json): @@ -15,9 +15,11 @@ // // 便捷静态方法(内部仍走 ServiceLocator,推荐在热路径之外使用): // LocalizationManager.Get("ui_start") -// LocalizationManager.Get("dlg_hero", "Dialogue") +// LocalizationManager.Get("dlg_hero", LocalizationTable.Dialogue) +// LocalizationManager.GetFormat("REWARD_GOLD", LocalizationTable.UI, 100) using System; +using System.Collections; using System.Collections.Generic; using UnityEngine; using BaseGames.Core; @@ -36,10 +38,17 @@ namespace BaseGames.Localization private Language _currentLanguage = Language.ChineseSimplified; private readonly Language _fallbackLanguage = Language.English; - // 双层缓存:languageKey("ChineseSimplified/UI") → (key → value) - private readonly Dictionary> _cache = new(); + // 双层缓存:(Language, tableName) 结构体键 → (key → value) + // 使用值类型 CacheKey 代替字符串插值,消除每次 Get 调用的 string 堆分配。 + private readonly Dictionary> _cache = new(); - // ILocalizationService 实例事件 + // LanguageEventChannelSO:语言切换时向 SO 驱动的 UI 组件广播。 + // 在 Persistent 场景预制体的 Inspector 中拖入 EVT_LanguageChanged.asset。 + [SerializeField] + [Tooltip("语言切换事件频道(EVT_LanguageChanged.asset)。切换语言时广播,订阅此频道的 UI 组件自动刷新文本。")] + private LanguageEventChannelSO _languageEventChannel; + + // ILocalizationService 实例事件(C# 订阅,供不方便引用 SO 的组件使用) private event Action _onLanguageChanged; event Action ILocalizationService.OnLanguageChanged { @@ -78,8 +87,22 @@ namespace BaseGames.Localization if (_currentLanguage == language) return; _currentLanguage = language; _onLanguageChanged?.Invoke(language); + _languageEventChannel?.Raise(language); + + // 同步到设置文件(ISettingsService 存储 locale code 字符串) + ServiceLocator.GetOrDefault()?.SetLanguage(LanguageToLocaleCode(language)); } + /// 将 Language 枚举转换为标准 locale code 字符串。 + public static string LanguageToLocaleCode(Language language) => language switch + { + Language.ChineseSimplified => "zh-CN", + Language.Japanese => "ja-JP", + Language.Korean => "ko-KR", + Language.English => "en-US", + _ => "en-US", + }; + /// /// 获取本地化字符串(显式接口实现)。 /// 查找顺序:当前语言 → 回退语言(English)→ 直接返回 key。 @@ -101,6 +124,118 @@ namespace BaseGames.Localization return key; } + /// + /// 尝试获取本地化字符串(显式接口实现)。 + /// 返回 false 时 value 为 null,key 不会作为 value 返回。 + /// + bool ILocalizationService.TryGet(string key, out string value, string table) + { + if (string.IsNullOrEmpty(key)) { value = null; return false; } + + if (TryGetFromTable(_currentLanguage, table, key, out value)) return true; + if (_currentLanguage != _fallbackLanguage && + TryGetFromTable(_fallbackLanguage, table, key, out value)) return true; + + value = null; + return false; + } + + /// + /// 获取带格式化参数的本地化字符串(显式接口实现)。 + /// 格式化失败时静默返回原始模板字符串。 + /// + string ILocalizationService.GetFormat(string key, string table, params object[] args) + { + string template = ((ILocalizationService)this).Get(key, table); + if (args == null || args.Length == 0) return template; + try { return string.Format(template, args); } + catch (Exception e) + { +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogWarning($"[LocalizationManager] GetFormat '{key}' 格式化失败: {e.Message}"); +#else + _ = e; +#endif + return template; + } + } + + /// + /// 获取带数量的复数形式本地化字符串(显式接口实现)。 + /// 先查找 "{key}_one"(count==1)或 "{key}_other"(count≠1),找不到则回退到基础 key。 + /// 模板以 string.Format(template, count) 展开。 + /// + string ILocalizationService.GetPlural(string key, int count, string table) + { + string pluralKey = count == 1 ? $"{key}_one" : $"{key}_other"; + if (TryGetFromTable(_currentLanguage, table, pluralKey, out string text) || + (_currentLanguage != _fallbackLanguage && + TryGetFromTable(_fallbackLanguage, table, pluralKey, out text))) + { + try { return string.Format(text, count); } + catch { return text; } + } + + // 回退到基础 key + string baseText = ((ILocalizationService)this).Get(key, table); + try { return string.Format(baseText, count); } + catch { return baseText; } + } + + /// + /// 同步预热指定语言的所有已知本地化表,不阻塞检测已缓存表(显式接口实现)。 + /// + void ILocalizationService.PreloadTables(Language language) + { + foreach (var table in LocalizationTable.All) + { + var ck = new CacheKey(language, table); + if (_cache.ContainsKey(ck)) continue; + + var dict = LoadTable(language, table); +#if UNITY_EDITOR || DEVELOPMENT_BUILD + if (dict == null) + Debug.LogWarning( + $"[LocalizationManager] PreloadTables:{language}/{table} 未找到," + + $"请确认 Resources/Localization/{language}/{table}.json 存在。"); +#endif + _cache[ck] = dict; + } + } + + /// + /// 异步分帧预热:每帧加载一个表,不阻塞主线程(显式接口实现)。 + /// 建议在 Loading Screen 的协程中调用。 + /// + void ILocalizationService.PreloadTablesAsync(Language language, Action onComplete) + => StartCoroutine(PreloadTablesRoutine(language, onComplete)); + + private IEnumerator PreloadTablesRoutine(Language language, Action onComplete) + { + foreach (var table in LocalizationTable.All) + { + var ck = new CacheKey(language, table); + if (_cache.ContainsKey(ck)) { yield return null; continue; } + + string path = $"Localization/{language}/{table}"; + var request = Resources.LoadAsync(path); + yield return request; + + var asset = request.asset as TextAsset; + var dict = asset == null ? null : ParseTableText(asset.text); + +#if UNITY_EDITOR || DEVELOPMENT_BUILD + if (dict == null) + Debug.LogWarning( + $"[LocalizationManager] PreloadTablesAsync:{language}/{table} 未找到," + + $"请确认 Resources/Localization/{language}/{table}.json 存在。"); +#endif + _cache[ck] = dict; + } + + onComplete?.Invoke(); + } + // ── ISaveable ───────────────────────────────────────────────────────── public void OnSave(SaveData data) { @@ -118,75 +253,91 @@ namespace BaseGames.Localization // ── 静态便捷方法 ───────────────────────────────────────────────────────── /// /// 静态快捷获取本地化字符串。委托给 ILocalizationService 实例;服务未注册时直接返回 key。 + /// 表名建议使用 中的常量。 /// - public static string Get(string key, string table = "UI") + public static string Get(string key, string table = LocalizationTable.UI) => ServiceLocator.GetOrDefault()?.Get(key, table) ?? key; + /// + /// 静态快捷尝试获取本地化字符串。服务未注册时返回 false。 + /// + public static bool TryGet(string key, out string value, string table = LocalizationTable.UI) + { + var svc = ServiceLocator.GetOrDefault(); + if (svc != null) return svc.TryGet(key, out value, table); + value = null; + return false; + } + + /// + /// 静态快捷获取带格式化参数的本地化字符串。 + /// 服务未注册时以 key 作为模板直接格式化后返回。 + /// + public static string GetFormat(string key, string table, params object[] args) + { + var svc = ServiceLocator.GetOrDefault(); + if (svc != null) return svc.GetFormat(key, table, args); + if (args == null || args.Length == 0) return key; + try { return string.Format(key, args); } + catch { return key; } + } + + /// + /// 静态快捷获取带数量的复数形式本地化字符串。 + /// 服务未注册时直接返回 key。 + /// + public static string GetPlural(string key, int count, string table = LocalizationTable.UI) + => ServiceLocator.GetOrDefault()?.GetPlural(key, count, table) ?? key; + // ── 编辑器预览(不依赖 ServiceLocator 实例)──────────────────────────── #if UNITY_EDITOR // 编辑器预览缓存:"{language}/{table}" → (key → value) // 生命周期与编辑器进程相同;域重载时自动清空(static 字段随域重载重置)。 - private static readonly System.Collections.Generic.Dictionary< - string, - System.Collections.Generic.Dictionary> s_editorPreviewCache = new(); + private static readonly Dictionary> s_editorPreviewCache = new(); /// /// 编辑器工具专用:不依赖运行时服务实例,直接从 Resources 读取本地化文本。 /// 结果缓存在静态字典中,同一编辑器会话内同一表只加载一次。 /// 找不到时返回 null(区别于运行时的 key 回退,便于调用方判断是否显示 key)。 /// - public static string GetEditorPreview(string key, string table = "UI", + public static string GetEditorPreview(string key, string table = LocalizationTable.UI, Language language = Language.ChineseSimplified) { if (string.IsNullOrEmpty(key)) return null; var dict = GetEditorTable(language, table) - ?? GetEditorTable(Language.English, table); // 中文缺失时英文回退 + ?? GetEditorTable(Language.English, table); if (dict == null) return null; dict.TryGetValue(key, out var value); - return value; // 找不到 key 时返回 null + return value; } - private static System.Collections.Generic.Dictionary GetEditorTable( - Language language, string table) + public static Dictionary GetEditorTable(Language language, string table) { string cacheKey = $"{language}/{table}"; if (s_editorPreviewCache.TryGetValue(cacheKey, out var cached)) - return cached; // 已缓存(可能是 null 占位,表示文件不存在) + return cached; string path = $"Localization/{language}/{table}"; var asset = Resources.Load(path); - if (asset == null) - { - s_editorPreviewCache[cacheKey] = null; // 记录"不存在",避免重复尝试 - return null; - } - - var parsed = JsonUtility.FromJson(asset.text); - if (parsed?.entries == null) - { - s_editorPreviewCache[cacheKey] = null; - return null; - } - - var dict = new System.Collections.Generic.Dictionary( - parsed.entries.Count, System.StringComparer.Ordinal); - foreach (var entry in parsed.entries) - if (!string.IsNullOrEmpty(entry.key)) - dict[entry.key] = entry.value ?? string.Empty; - + var dict = asset == null ? null : ParseTableText(asset.text); s_editorPreviewCache[cacheKey] = dict; return dict; } + + /// 编辑器工具:清除编辑器预览缓存(修改 JSON 文件后手动刷新时调用)。 + public static void ClearEditorPreviewCache() => s_editorPreviewCache.Clear(); #endif + + // ── 内部缓存查找 ────────────────────────────────────────────────────── private bool TryGetFromTable(Language language, string table, string key, out string value) { - var cacheKey = $"{language}/{table}"; - if (!_cache.TryGetValue(cacheKey, out var dict)) + var ck = new CacheKey(language, table); + if (!_cache.TryGetValue(ck, out var dict)) { dict = LoadTable(language, table); - _cache[cacheKey] = dict; // 即使加载失败也存入空字典,避免每帧重试 + _cache[ck] = dict; // 即使加载失败也存入空字典,避免每帧重试 } if (dict != null && dict.TryGetValue(key, out value)) return true; value = null; @@ -201,9 +352,16 @@ namespace BaseGames.Localization { string path = $"Localization/{language}/{table}"; var asset = Resources.Load(path); - if (asset == null) return null; + return asset == null ? null : ParseTableText(asset.text); + } - var parsed = JsonUtility.FromJson(asset.text); + /// + /// 将 JSON 文本解析为 key→value 字典(内部共享解析逻辑)。 + /// 返回 null 表示格式无效。 + /// + private static Dictionary ParseTableText(string jsonText) + { + var parsed = JsonUtility.FromJson(jsonText); if (parsed?.entries == null) return null; var dict = new Dictionary(parsed.entries.Count, StringComparer.Ordinal); @@ -214,6 +372,29 @@ namespace BaseGames.Localization return dict; } + // ── 缓存键(值类型,消除字符串插值 GC)────────────────────────────── + private readonly struct CacheKey : IEquatable + { + private readonly Language _language; + private readonly string _table; + + public CacheKey(Language language, string table) + { + _language = language; + _table = table; + } + + public bool Equals(CacheKey other) + => _language == other._language && + string.Equals(_table, other._table, StringComparison.Ordinal); + + public override bool Equals(object obj) + => obj is CacheKey other && Equals(other); + + public override int GetHashCode() + => HashCode.Combine((int)_language, _table); + } + // ── 序列化辅助类型 ──────────────────────────────────────────────────── [Serializable] @@ -230,4 +411,3 @@ namespace BaseGames.Localization } } } - diff --git a/Assets/_Game/Scripts/Localization/LocalizationTable.cs b/Assets/_Game/Scripts/Localization/LocalizationTable.cs new file mode 100644 index 0000000..1593d87 --- /dev/null +++ b/Assets/_Game/Scripts/Localization/LocalizationTable.cs @@ -0,0 +1,39 @@ +namespace BaseGames.Localization +{ + /// + /// 本地化表名常量。 + /// 所有调用 时 + /// 必须引用此类的常量,禁止直接硬编码表名字符串。 + /// + /// 新增表时:在此追加常量,并在 Resources/Localization/{Language}/ 下创建同名 JSON 文件。 + /// + public static class LocalizationTable + { + /// 通用 UI 文本(按钮、标题、菜单、HUD、提示等)。 + public const string UI = "UI"; + + /// NPC 对话行与对话选项文本。 + public const string Dialogue = "Dialogue"; + + /// 任务名称与描述文本。 + public const string Quest = "Quest"; + + /// 法术名称与描述文本。 + public const string Spells = "Spells"; + + /// 角色名称(NPC、玩家角色等)。 + public const string Character = "Character"; + + /// 物品名称与描述(护符、收集品等)。 + public const string Items = "Items"; + + /// 技能名称与描述。 + public const string Skills = "Skills"; + + /// 教程与上下文提示文本。 + public const string Tutorial = "Tutorial"; + + /// 所有已定义的表名数组,供预热和审计遍历使用。 + public static readonly string[] All = { UI, Dialogue, Quest, Spells, Character, Items, Skills, Tutorial }; + } +} diff --git a/Assets/_Game/Scripts/Localization/LocalizationTable.cs.meta b/Assets/_Game/Scripts/Localization/LocalizationTable.cs.meta new file mode 100644 index 0000000..3afd116 --- /dev/null +++ b/Assets/_Game/Scripts/Localization/LocalizationTable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 231300f4bcb6aa34991ea6b1b145204d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Localization/LocalizedText.cs b/Assets/_Game/Scripts/Localization/LocalizedText.cs new file mode 100644 index 0000000..7735696 --- /dev/null +++ b/Assets/_Game/Scripts/Localization/LocalizedText.cs @@ -0,0 +1,144 @@ +using UnityEngine; +using TMPro; +using BaseGames.Core; + +namespace BaseGames.Localization +{ + /// + /// UI 文本本地化自动绑定组件。 + /// 挂载在含 的 GameObject 上,语言切换时自动刷新文本内容。 + /// + /// 用法: + /// 1. 挂上此组件,填写 (默认 UI 表)。 + /// 2. 运行时 触发时自动刷新。 + /// 3. 格式化参数在运行时通过 传入后即时更新显示。 + /// 4. (可选)绑定 ,语言切换时自动替换 TMP 字体。 + /// + /// 编辑器预览:Inspector 内实时显示当前 key 对应的本地化文本(使用简体中文表)。 + /// + [RequireComponent(typeof(TMP_Text))] + [AddComponentMenu("BaseGames/Localization/Localized Text")] + public class LocalizedText : MonoBehaviour + { + [Tooltip("本地化 Key,如 \"BTN_START\"、\"HUD_HP\"。")] + [SerializeField] private string _key; + + [Tooltip("所属本地化表。使用 LocalizationTable 中的常量,默认 \"UI\"。")] + [SerializeField] private string _table = LocalizationTable.UI; + + [Tooltip("(可选)语言→字体映射表。填写后语言切换时自动替换 TMP 字体,用于 CJK 等需要独立字体的语言。")] + [SerializeField] private LanguageFontConfigSO _fontConfig; + + // 格式化参数(运行时由 SetFormatArgs 设置,空数组 = 无格式化) + private object[] _formatArgs; + + private TMP_Text _label; + private ILocalizationService _svc; + + // ── 生命周期 ────────────────────────────────────────────────────────── + + private void Awake() + { + _label = GetComponent(); + } + + private void OnEnable() + { + _svc = ServiceLocator.GetOrDefault(); + if (_svc != null) + _svc.OnLanguageChanged += OnLanguageChanged; +#if UNITY_EDITOR || DEVELOPMENT_BUILD + else + Debug.LogWarning( + $"[LocalizedText] '{name}' OnEnable:ILocalizationService 尚未注册," + + $"文本将不会随语言切换自动刷新。请确认 LocalizationManager 在此对象激活前已完成 Awake。", this); +#endif + Refresh(); + } + + private void OnDisable() + { + if (_svc != null) + _svc.OnLanguageChanged -= OnLanguageChanged; + _svc = null; + } + + // ── 公开 API ────────────────────────────────────────────────────────── + + /// + /// 动态更改本地化 Key 并立即刷新文本。 + /// 适用于同一 UI 控件在不同状态下显示不同字段的情况。 + /// + public void SetKey(string key, string table = null) + { + _key = key; + if (table != null) _table = table; + Refresh(); + } + + /// + /// 设置格式化参数并立即刷新文本。 + /// 本地化字符串中使用标准 {0}、{1}…占位符,例如: + /// "获得 {0} 灵珠" → SetFormatArgs(amount) + /// + public void SetFormatArgs(params object[] args) + { + _formatArgs = args; + Refresh(); + } + + /// 强制立即刷新文本(语言切换后由组件自动调用,通常无需手动调用)。 + public void Refresh() + { + if (_label == null || string.IsNullOrEmpty(_key)) return; + ApplyFont(); + _label.text = ResolveText(); + } + + // ── 内部 ────────────────────────────────────────────────────────────── + + private void OnLanguageChanged(Language _) => Refresh(); + + private string ResolveText() + { + // 直接使用缓存的 _svc 实例,避免每次调用 ServiceLocator 字典查找(热路径优化) + if (_svc != null) + { + return (_formatArgs != null && _formatArgs.Length > 0) + ? _svc.GetFormat(_key, _table, _formatArgs) + : _svc.Get(_key, _table); + } + // 服务未注册时使用静态方法兜底(保证不崩溃) + return (_formatArgs != null && _formatArgs.Length > 0) + ? LocalizationManager.GetFormat(_key, _table, _formatArgs) + : LocalizationManager.Get(_key, _table); + } + + private void ApplyFont() + { + if (_fontConfig == null || _label == null) return; + var lang = _svc?.CurrentLanguage ?? Language.ChineseSimplified; + if (!_fontConfig.TryGetFont(lang, out var font, out var mat)) return; + if (font != null) _label.font = font; + if (mat != null) _label.fontSharedMaterial = mat; + } + +#if UNITY_EDITOR + // 编辑器下 key / table 变化时立即预览(无需进入 Play Mode) + private void OnValidate() + { + if (!Application.isPlaying) + UpdateEditorPreview(); + } + + public void UpdateEditorPreview() + { + if (_label == null) _label = GetComponent(); + if (_label == null || string.IsNullOrEmpty(_key)) return; + string preview = LocalizationManager.GetEditorPreview(_key, _table); + // 未找到时显示 key 本身,方便策划确认是否拼写正确 + _label.text = preview ?? $"[{_key}]"; + } +#endif + } +} diff --git a/Assets/_Game/Scripts/Localization/LocalizedText.cs.meta b/Assets/_Game/Scripts/Localization/LocalizedText.cs.meta new file mode 100644 index 0000000..98a6573 --- /dev/null +++ b/Assets/_Game/Scripts/Localization/LocalizedText.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 964e3a3ab86805244bbde47d5f54950b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Quest/BaseGames.Quest.asmdef b/Assets/_Game/Scripts/Quest/BaseGames.Quest.asmdef index e116e70..bf4d01b 100644 --- a/Assets/_Game/Scripts/Quest/BaseGames.Quest.asmdef +++ b/Assets/_Game/Scripts/Quest/BaseGames.Quest.asmdef @@ -16,7 +16,8 @@ "BaseGames.Enemies", "BaseGames.Dialogue", "Unity.Addressables", - "Unity.ResourceManager" + "Unity.ResourceManager", + "BaseGames.Localization" ], "autoReferenced": true, "overrideReferences": false, diff --git a/Assets/_Game/Scripts/Quest/IQuestManager.cs b/Assets/_Game/Scripts/Quest/IQuestManager.cs index a628747..ac26942 100644 --- a/Assets/_Game/Scripts/Quest/IQuestManager.cs +++ b/Assets/_Game/Scripts/Quest/IQuestManager.cs @@ -143,6 +143,13 @@ namespace BaseGames.Quest /// 相比 不分配新列表,适合高频调用场景。 /// void FillFilterQuests(System.Func predicate, System.Collections.Generic.List result); + + /// + /// 根据 questId 查找对应的 QuestSO 资产。 + /// 返回 true 时 quest 非空;未注册或 ID 无效时返回 false,quest 为 null。 + /// UI 层通过此方法取得 QuestSO,无需在各 HUD 组件中重复注入完整的任务数据库。 + /// + bool TryGetQuest(string questId, out QuestSO quest); } /// diff --git a/Assets/_Game/Scripts/Quest/QuestEventChannelRegistry.cs.meta b/Assets/_Game/Scripts/Quest/QuestEventChannelRegistry.cs.meta new file mode 100644 index 0000000..2c875d2 --- /dev/null +++ b/Assets/_Game/Scripts/Quest/QuestEventChannelRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1b7e9d569a19aa44aaaf2fc8fd4324a8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Quest/QuestGiver.cs b/Assets/_Game/Scripts/Quest/QuestGiver.cs index 3e39753..5969bcd 100644 --- a/Assets/_Game/Scripts/Quest/QuestGiver.cs +++ b/Assets/_Game/Scripts/Quest/QuestGiver.cs @@ -32,7 +32,7 @@ namespace BaseGames.Quest [SerializeField] private DialogueSequenceSO _completedDialogue; [Header("交互选项")] - [Tooltip("勾选后,任务进行中(Active 且未完成)时交互提示变为"放弃任务",交互即触发 AbandonQuest。\n" + + [Tooltip("勾选后,任务进行中(Active 且未完成)时交互提示变为\"放弃任务\",交互即触发 AbandonQuest。\n" + "适合允许玩家主动放弃的支线任务;主线任务建议保持取消勾选。")] [SerializeField] private bool _allowAbandon; diff --git a/Assets/_Game/Scripts/Quest/QuestManager.cs b/Assets/_Game/Scripts/Quest/QuestManager.cs index a9865dc..cec6b35 100644 --- a/Assets/_Game/Scripts/Quest/QuestManager.cs +++ b/Assets/_Game/Scripts/Quest/QuestManager.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using UnityEngine; +using BaseGames.Core; using BaseGames.Core.Events; using BaseGames.Core.Save; using QuestStateEnum = BaseGames.Core.Events.QuestState; @@ -1079,6 +1081,13 @@ namespace BaseGames.Quest private QuestSO GetQuestSO(string id) => _questIndex != null && _questIndex.TryGetValue(id, out var q) ? q : null; + /// + public bool TryGetQuest(string questId, out QuestSO quest) + { + quest = GetQuestSO(questId); + return quest != null; + } + /// /// 优先从预缓存表查找 compositeKey(O(1),零字符串分配); /// 缓存未命中时 fallback 到 CompositeKey() 动态构建(运行时新增的目标)。 diff --git a/Assets/_Game/Scripts/Quest/QuestSO.cs b/Assets/_Game/Scripts/Quest/QuestSO.cs index 5266ff3..39a6b6c 100644 --- a/Assets/_Game/Scripts/Quest/QuestSO.cs +++ b/Assets/_Game/Scripts/Quest/QuestSO.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using UnityEngine; using BaseGames.Core; using BaseGames.Dialogue; +using BaseGames.Localization; namespace BaseGames.Quest { @@ -10,7 +12,7 @@ namespace BaseGames.Quest /// 资产路径: Assets/ScriptableObjects/Quest/Quest_{questId}.asset /// [CreateAssetMenu(menuName = "BaseGames/Quest/Quest")] - public class QuestSO : ScriptableObject + public class QuestSO : ScriptableObject, ILocalizableAsset { [Header("标识")] [Tooltip("任务唯一 ID,如 \"Quest_FindMushroom\"。运行时由 QuestManager 以此为键索引,必须全局唯一。")] @@ -270,6 +272,18 @@ namespace BaseGames.Quest return false; } #endif + + public IEnumerable GetLocalizationKeys() + { + if (!string.IsNullOrEmpty(displayNameKey)) + yield return new LocalizationKeyRef(displayNameKey, "Quest", nameof(displayNameKey)); + if (!string.IsNullOrEmpty(descriptionKey)) + yield return new LocalizationKeyRef(descriptionKey, "Quest", nameof(descriptionKey)); + if (objectives != null) + foreach (var obj in objectives) + if (obj != null && !string.IsNullOrEmpty(obj.displayTextKey)) + yield return new LocalizationKeyRef(obj.displayTextKey, "Quest", "objectives.displayTextKey"); + } } [Serializable] diff --git a/Assets/_Game/Scripts/Skills/BaseGames.Skills.asmdef b/Assets/_Game/Scripts/Skills/BaseGames.Skills.asmdef index 2c18757..c8cd915 100644 --- a/Assets/_Game/Scripts/Skills/BaseGames.Skills.asmdef +++ b/Assets/_Game/Scripts/Skills/BaseGames.Skills.asmdef @@ -13,7 +13,8 @@ "BaseGames.Input", "BaseGames.Combat", "BaseGames.Feedback", - "Kybernetik.Animancer" + "Kybernetik.Animancer", + "BaseGames.Localization" ], "autoReferenced": true, "overrideReferences": false, diff --git a/Assets/_Game/Scripts/Skills/FormSkillSO.cs b/Assets/_Game/Scripts/Skills/FormSkillSO.cs index c1b6e8b..1914267 100644 --- a/Assets/_Game/Scripts/Skills/FormSkillSO.cs +++ b/Assets/_Game/Scripts/Skills/FormSkillSO.cs @@ -1,6 +1,8 @@ +using System.Collections.Generic; using UnityEngine; using Animancer; using BaseGames.Combat; +using BaseGames.Localization; namespace BaseGames.Skills { @@ -35,7 +37,7 @@ namespace BaseGames.Skills /// 路径: Assets/Scripts/Skills/FormSkillSO.cs /// [CreateAssetMenu(menuName = "BaseGames/Skills/FormSkill")] - public class FormSkillSO : ScriptableObject + public class FormSkillSO : ScriptableObject, ILocalizableAsset { [Header("Identity")] public string skillId; @@ -81,5 +83,13 @@ namespace BaseGames.Skills /// 命名规范: Assets/Prefabs/Skills/SKL_{skillId}_HitBox.prefab /// public GameObject SkillHitBoxPrefab; + + public IEnumerable GetLocalizationKeys() + { + if (!string.IsNullOrEmpty(displayNameKey)) + yield return new LocalizationKeyRef(displayNameKey, "Skills", nameof(displayNameKey)); + if (!string.IsNullOrEmpty(descriptionKey)) + yield return new LocalizationKeyRef(descriptionKey, "Skills", nameof(descriptionKey)); + } } } diff --git a/Assets/_Game/Scripts/Spells/BaseGames.Spells.asmdef b/Assets/_Game/Scripts/Spells/BaseGames.Spells.asmdef index 7961102..245d7a2 100644 --- a/Assets/_Game/Scripts/Spells/BaseGames.Spells.asmdef +++ b/Assets/_Game/Scripts/Spells/BaseGames.Spells.asmdef @@ -14,7 +14,8 @@ "BaseGames.Combat", "BaseGames.Feedback", "BaseGames.Skills", - "Kybernetik.Animancer" + "Kybernetik.Animancer", + "BaseGames.Localization" ], "autoReferenced": true, "overrideReferences": false, diff --git a/Assets/_Game/Scripts/Spells/ISpellService.cs b/Assets/_Game/Scripts/Spells/ISpellService.cs new file mode 100644 index 0000000..78754da --- /dev/null +++ b/Assets/_Game/Scripts/Spells/ISpellService.cs @@ -0,0 +1,26 @@ +using System; + +namespace BaseGames.Spells +{ + /// + /// 法术管理服务接口。UI 层(SpellSlotWidget)通过此接口读取法术状态, + /// 与 SpellManager 具体实现解耦,支持测试场景下的 Mock 替换。 + /// + public interface ISpellService + { + /// 当前装备的法术;null 表示未装备。 + SpellSO EquippedSpell { get; } + + /// 冷却进度(0 = 就绪,1 = 刚施放)。 + float CooldownFraction { get; } + + /// 法术当前是否可施放(已装备且冷却完毕)。 + bool IsReady { get; } + + /// + /// 法术装备或卸下时触发。参数为新法术,null 表示已卸下。 + /// UI 订阅此事件可实现零延迟的图标刷新,无需轮询。 + /// + event Action OnSpellChanged; + } +} diff --git a/Assets/_Game/Scripts/Spells/ISpellService.cs.meta b/Assets/_Game/Scripts/Spells/ISpellService.cs.meta new file mode 100644 index 0000000..4177b8f --- /dev/null +++ b/Assets/_Game/Scripts/Spells/ISpellService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bad07198c62f8774da0868bceb1ccd34 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Spells/SpellManager.cs b/Assets/_Game/Scripts/Spells/SpellManager.cs index 586c129..1dc0024 100644 --- a/Assets/_Game/Scripts/Spells/SpellManager.cs +++ b/Assets/_Game/Scripts/Spells/SpellManager.cs @@ -1,7 +1,9 @@ +using System; using UnityEngine; using BaseGames.Player; using BaseGames.Input; using BaseGames.Feedback; +using BaseGames.Core; namespace BaseGames.Spells { @@ -12,7 +14,7 @@ namespace BaseGames.Spells /// /// 输入事件:订阅 InputReaderSO.SpellCastEvent(对应 InputActionAsset 中的 "Spell" Action)。 /// - public class SpellManager : MonoBehaviour + public class SpellManager : MonoBehaviour, ISpellService { [Header("依赖引用")] [SerializeField] private PlayerStats _stats; @@ -27,6 +29,10 @@ namespace BaseGames.Spells private float _cooldownRemaining; private IFeedbackPlayer _feedback; + // ── ISpellService 事件 ──────────────────────────────────────────────── + /// + public event Action OnSpellChanged; + // ── 生命周期 ────────────────────────────────────────────────────────── private void Awake() @@ -38,12 +44,14 @@ namespace BaseGames.Spells private void OnEnable() { + ServiceLocator.Register(this); if (_input != null) _input.SpellCastEvent += TryCastSpell; } private void OnDisable() { + ServiceLocator.Unregister(this); if (_input != null) _input.SpellCastEvent -= TryCastSpell; } @@ -61,6 +69,7 @@ namespace BaseGames.Spells { _equippedSpell = spell; _cooldownRemaining = 0f; + OnSpellChanged?.Invoke(spell); } /// 卸下当前装备的法术。 @@ -68,6 +77,7 @@ namespace BaseGames.Spells { _equippedSpell = null; _cooldownRemaining = 0f; + OnSpellChanged?.Invoke(null); } /// 返回当前冷却进度(0 = 就绪,1 = 刚施放)。供 UI 血条使用。 diff --git a/Assets/_Game/Scripts/Spells/SpellSO.cs b/Assets/_Game/Scripts/Spells/SpellSO.cs index 846c743..447eeba 100644 --- a/Assets/_Game/Scripts/Spells/SpellSO.cs +++ b/Assets/_Game/Scripts/Spells/SpellSO.cs @@ -1,7 +1,9 @@ +using System.Collections.Generic; using UnityEngine; using Animancer; using BaseGames.Combat; using BaseGames.Skills; // FeedbackPresetSO +using BaseGames.Localization; namespace BaseGames.Spells { @@ -27,13 +29,14 @@ namespace BaseGames.Spells /// 创建路径:Assets/Data/Spells/SPL_{spellId}.asset /// [CreateAssetMenu(menuName = "BaseGames/Spells/Spell")] - public class SpellSO : ScriptableObject + public class SpellSO : ScriptableObject, ILocalizableAsset { [Header("Identity")] [Tooltip("全局唯一 ID,建议使用 GameIds 域中的常量")] public string spellId; [Tooltip("本地化 Key(从 LocalizationManager.Get(displayNameKey, \"Spells\") 获取)")] public string displayNameKey; + [Tooltip("本地化 Key,格式如 \"SPL_Fireball_Desc\"。通过 LocalizationManager.Get(descriptionKey, LocalizationTable.Spells) 获取。")] [TextArea(1, 3)] public string descriptionKey; public Sprite icon; @@ -73,5 +76,13 @@ namespace BaseGames.Spells [Header("Feedback")] public FeedbackPresetSO castFeedback; + + public IEnumerable GetLocalizationKeys() + { + if (!string.IsNullOrEmpty(displayNameKey)) + yield return new LocalizationKeyRef(displayNameKey, "Spells", nameof(displayNameKey)); + if (!string.IsNullOrEmpty(descriptionKey)) + yield return new LocalizationKeyRef(descriptionKey, "Spells", nameof(descriptionKey)); + } } } diff --git a/Assets/_Game/Scripts/Support/Localization/LanguageManagerSO.cs b/Assets/_Game/Scripts/Support/Localization/LanguageManagerSO.cs deleted file mode 100644 index 2c30190..0000000 --- a/Assets/_Game/Scripts/Support/Localization/LanguageManagerSO.cs +++ /dev/null @@ -1,51 +0,0 @@ -using UnityEngine; - -namespace BaseGames.Localization -{ - /// - /// 语言设置持久化 SO(架构 16_SupportingModules §4.1)。 - /// 支持的语言列表由设计师填写;当前语言持久化到 PlayerPrefs。 - /// - [CreateAssetMenu(menuName = "BaseGames/Settings/LanguageManager", fileName = "SET_LanguageManager")] - public class LanguageManagerSO : ScriptableObject - { - private const string PrefsKey = "game_language"; - - [Tooltip("游戏支持的语言 locale code 列表(如 zh-CN、en-US)")] - public string[] supportedLocales = { "zh-CN", "en-US" }; - - [Tooltip("默认语言 locale code")] - public string defaultLocale = "zh-CN"; - - public string CurrentLocale { get; private set; } - - private void OnEnable() - { - CurrentLocale = PlayerPrefs.GetString(PrefsKey, defaultLocale); - } - - /// 设置当前语言并持久化到 PlayerPrefs,同时通知 Unity Localization(若已安装)。 - public void SetLocale(string localeCode) - { - CurrentLocale = localeCode; - PlayerPrefs.SetString(PrefsKey, localeCode); - PlayerPrefs.Save(); - ApplyLocale(localeCode); - } - - /// 初始化时应用已保存语言。 - public void ApplySaved() => ApplyLocale(CurrentLocale); - - private static void ApplyLocale(string localeCode) - { -#if UNITY_LOCALIZATION - var locales = UnityEngine.Localization.Settings.LocalizationSettings.AvailableLocales; - var locale = locales?.GetLocale(new UnityEngine.Localization.LocaleIdentifier(localeCode)); - if (locale != null) - UnityEngine.Localization.Settings.LocalizationSettings.SelectedLocale = locale; -#else - Debug.Log($"[LanguageManager] 语言设为 {localeCode}(Unity Localization 包未安装,仅记录 PlayerPrefs)。"); -#endif - } - } -} diff --git a/Assets/_Game/Scripts/Support/Localization/LocalizationManager.cs b/Assets/_Game/Scripts/Support/Localization/LocalizationManager.cs deleted file mode 100644 index 4c65e81..0000000 --- a/Assets/_Game/Scripts/Support/Localization/LocalizationManager.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using UnityEngine; - -namespace BaseGames.Localization -{ - /// - /// 本地化字符串访问工具类(架构 16_SupportingModules §4)。 - /// 当 Unity Localization 包(com.unity.localization)安装后使用 LocalizationSettings 实现完整功能; - /// 未安装时退回到 Key 直传(返回 entryKey 本身)。 - /// - public static class LocalizationManager - { -#if UNITY_LOCALIZATION - private const string DefaultTable = "UI"; - - /// 从指定表中获取本地化字符串。 - public static string Get(string entryKey, string tableName = DefaultTable) - { - try - { - var op = UnityEngine.Localization.Settings.LocalizationSettings - .StringDatabase.GetLocalizedStringAsync(tableName, entryKey); - return op.IsDone ? op.Result : entryKey; - } - catch (Exception e) - { - Debug.LogWarning($"[Localization] Key '{entryKey}' in table '{tableName}' 读取失败: {e.Message}"); - return entryKey; - } - } - - /// 带格式化参数的本地化字符串。 - public static string GetFormat(string entryKey, string tableName, params object[] args) - { - string template = Get(entryKey, tableName); - try { return string.Format(template, args); } - catch (Exception e) - { - Debug.LogWarning($"[Localization] GetFormat '{entryKey}' 格式化失败: {e.Message}"); - return template; - } - } -#else - /// Unity Localization 包未安装:直接返回 Key。 - public static string Get(string entryKey, string tableName = "UI") - => entryKey; - - public static string GetFormat(string entryKey, string tableName, params object[] args) - { - try { return string.Format(entryKey, args); } - catch (Exception e) - { - Debug.LogWarning($"[Localization] GetFormat '{entryKey}' 格式化失败: {e.Message}"); - return entryKey; - } - } -#endif - } -} diff --git a/Assets/_Game/Scripts/Tutorial/ContextualHintTrigger.cs b/Assets/_Game/Scripts/Tutorial/ContextualHintTrigger.cs index f17fe0e..c529a03 100644 --- a/Assets/_Game/Scripts/Tutorial/ContextualHintTrigger.cs +++ b/Assets/_Game/Scripts/Tutorial/ContextualHintTrigger.cs @@ -42,7 +42,7 @@ namespace BaseGames.Tutorial if (stats == null || !stats.HasAbility(_requiredAbility)) return; } - string text = LocalizationManager.Get(_hintLocKey, "UI"); + string text = LocalizationManager.Get(_hintLocKey, LocalizationTable.UI); tm.ShowHint(_hintId, text, _displayDuration); tm.CompleteHint(_hintId); // 写入 SaveData,防止场景重载后重复触发 diff --git a/Assets/_Game/Scripts/Tutorial/TutorialHintUI.cs b/Assets/_Game/Scripts/Tutorial/TutorialHintUI.cs index ed1d34e..7712214 100644 --- a/Assets/_Game/Scripts/Tutorial/TutorialHintUI.cs +++ b/Assets/_Game/Scripts/Tutorial/TutorialHintUI.cs @@ -7,48 +7,87 @@ namespace BaseGames.Tutorial /// /// 教程提示 UI 组件(架构 21_LiquidPuzzleModule §17)。 /// 负责显示/隐藏提示面板和文字;duration ≤ 0 时不自动消隐。 + /// 使用 CanvasGroup 实现淡入淡出,不依赖 Animator。 /// + [RequireComponent(typeof(CanvasGroup))] public class TutorialHintUI : MonoBehaviour { - [SerializeField] private GameObject _panel; - [SerializeField] private TMP_Text _label; -#pragma warning disable CS0414 - [SerializeField] private float _fadeSpeed = 4f; -#pragma warning restore CS0414 + [SerializeField] private TMP_Text _label; + [SerializeField] private float _fadeSpeed = 4f; - private Coroutine _autoHideCoroutine; + private CanvasGroup _cg; + private Coroutine _autoHideCoroutine; + private Coroutine _fadeCoroutine; + + private void Awake() + { + _cg = GetComponent(); + _cg.alpha = 0f; + gameObject.SetActive(false); + } // ── 公共 API ────────────────────────────────────────────────────── - /// 显示提示文字。duration ≤ 0 时不自动消隐。 + /// 淡入显示提示文字。duration ≤ 0 时不自动消隐。 public void Show(string text, float duration = 0f) { - if (_panel != null) _panel.SetActive(true); if (_label != null) _label.text = text; - if (_autoHideCoroutine != null) - StopCoroutine(_autoHideCoroutine); + // 停止所有进行中的动画/定时 + StopAutoHide(); + StopFade(); + + gameObject.SetActive(true); + _fadeCoroutine = StartCoroutine(FadeTo(1f)); if (duration > 0f) _autoHideCoroutine = StartCoroutine(AutoHideRoutine(duration)); } - /// 立即隐藏提示面板。 + /// 淡出并隐藏提示面板。 public void Hide() { - if (_autoHideCoroutine != null) - { - StopCoroutine(_autoHideCoroutine); - _autoHideCoroutine = null; - } - if (_panel != null) _panel.SetActive(false); + StopAutoHide(); + StopFade(); + _fadeCoroutine = StartCoroutine(FadeOutAndDeactivate()); } // ── 内部 ────────────────────────────────────────────────────────── + private void StopAutoHide() + { + if (_autoHideCoroutine == null) return; + StopCoroutine(_autoHideCoroutine); + _autoHideCoroutine = null; + } + + private void StopFade() + { + if (_fadeCoroutine == null) return; + StopCoroutine(_fadeCoroutine); + _fadeCoroutine = null; + } + private IEnumerator AutoHideRoutine(float duration) { - yield return new WaitForSeconds(duration); + yield return new WaitForSecondsRealtime(duration); Hide(); _autoHideCoroutine = null; } + + private IEnumerator FadeTo(float target) + { + while (!Mathf.Approximately(_cg.alpha, target)) + { + _cg.alpha = Mathf.MoveTowards(_cg.alpha, target, _fadeSpeed * Time.unscaledDeltaTime); + yield return null; + } + _cg.alpha = target; + } + + private IEnumerator FadeOutAndDeactivate() + { + yield return FadeTo(0f); + gameObject.SetActive(false); + _fadeCoroutine = null; + } } } diff --git a/Assets/_Game/Scripts/UI/BaseGames.UI.asmdef b/Assets/_Game/Scripts/UI/BaseGames.UI.asmdef index 9b79a72..3a11d75 100644 --- a/Assets/_Game/Scripts/UI/BaseGames.UI.asmdef +++ b/Assets/_Game/Scripts/UI/BaseGames.UI.asmdef @@ -16,7 +16,11 @@ "BaseGames.Localization", "Unity.TextMeshPro", "Unity.InputSystem", - "BaseGames.Equipment" + "BaseGames.Equipment", + "BaseGames.Combat.StatusEffects", + "BaseGames.Spells", + "BaseGames.Quest", + "BaseGames.Skills" ], "autoReferenced": true, "overrideReferences": false, diff --git a/Assets/_Game/Scripts/UI/CharmEquipPanel.cs b/Assets/_Game/Scripts/UI/CharmEquipPanel.cs new file mode 100644 index 0000000..54b3be6 --- /dev/null +++ b/Assets/_Game/Scripts/UI/CharmEquipPanel.cs @@ -0,0 +1,233 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEngine; +using UnityEngine.UI; +using TMPro; +using UnityEngine.EventSystems; +using BaseGames.Core; +using BaseGames.Core.Events; +using BaseGames.Equipment; +using BaseGames.Localization; + +namespace BaseGames.UI +{ + /// + /// 护符装备面板。 + /// + /// 布局: + /// 顶部 — 标题 + 凹槽容量进度条(已用/总数) + /// 左侧 — 已装备护符区域(动态格子列表) + /// 右侧 — 已收集护符目录(可滚动,点击即装备/卸下) + /// + /// 数据来源:ServiceLocator 获取 EquipmentManager,读取 Collected / Equipped 列表。 + /// 反应式更新:订阅 _onEquipmentChanged VoidEventChannelSO,变更时重建两侧列表。 + /// + /// Inspector 必填: + /// _notchText — "X / Y 格子" 文本 + /// _equippedContainer — 已装备列表父节点 + /// _catalogContainer — 收藏目录列表父节点 + /// _charmCardTemplate — 护符卡片预制(kept inactive) + /// _btnClose — 关闭按钮 + /// _onEquipmentChanged — EquipmentManager 广播的装备变更事件频道 + /// + public class CharmEquipPanel : MonoBehaviour, IFocusable + { + // ── Inspector 字段 ──────────────────────────────────────────────────── + + [Header("UI 根节点")] + [SerializeField] private TMP_Text _notchText; + [SerializeField] private Image _notchBarFill; + [SerializeField] private Transform _equippedContainer; + [SerializeField] private Transform _catalogContainer; + [SerializeField] private GameObject _charmCardTemplate; // kept inactive + [SerializeField] private Button _btnClose; + + [Header("Event Channels")] + [SerializeField] private VoidEventChannelSO _onEquipmentChanged; + + // ── 私有状态 ────────────────────────────────────────────────────────── + + private IEquipmentService _manager; + private readonly List _equippedCards = new(); + private readonly List _catalogCards = new(); + private readonly Queue _cardPool = new(); // O(1) 取用归还 + private readonly CompositeDisposable _subs = new(); + + // ── 生命周期 ────────────────────────────────────────────────────────── + + private void OnEnable() + { + _manager = ServiceLocator.GetOrDefault(); + + if (_btnClose != null) + { + _btnClose.onClick.RemoveAllListeners(); + _btnClose.onClick.AddListener(OnCloseBtnClicked); + } + + _onEquipmentChanged?.Subscribe(Rebuild).AddTo(_subs); + Rebuild(); + + // 手柄导航:面板打开时将焦点置于关闭按钮 + EventSystem.current?.SetSelectedGameObject(_btnClose?.gameObject); + } + + private void OnDisable() + { + _subs.Clear(); + RecycleCards(_equippedCards); + RecycleCards(_catalogCards); + } + + // ── 重建 UI ─────────────────────────────────────────────────────────── + + private void Rebuild() + { + if (_manager == null) return; + + RefreshNotchBar(); + RebuildEquippedList(); + RebuildCatalogList(); + } + + private void RefreshNotchBar() + { + int used = _manager.UsedNotches; + int total = _manager.TotalNotches; + + if (_notchText != null) + _notchText.text = $"{used} / {total}"; + + if (_notchBarFill != null) + _notchBarFill.fillAmount = total > 0 ? (float)used / total : 0f; + } + + private void RebuildEquippedList() + { + RecycleCards(_equippedCards); + if (_equippedContainer == null) return; + + foreach (var charm in _manager.Equipped) + { + var card = SpawnCard(_equippedContainer, charm, isEquipped: true); + if (card != null) _equippedCards.Add(card); + } + } + + private void RebuildCatalogList() + { + RecycleCards(_catalogCards); + if (_catalogContainer == null) return; + + foreach (var charm in _manager.Collected) + { + bool isEquipped = _manager.Equipped.Contains(charm); + var card = SpawnCard(_catalogContainer, charm, isEquipped); + if (card != null) _catalogCards.Add(card); + } + } + + // ── 卡片生成 ────────────────────────────────────────────────────────── + + private GameObject SpawnCard(Transform parent, CharmSO charm, bool isEquipped) + { + if (_charmCardTemplate == null || parent == null) return null; + + if (!TryDequeueCard(out GameObject go)) + go = Instantiate(_charmCardTemplate); + + go.transform.SetParent(parent, worldPositionStays: false); + go.SetActive(true); + + // Icon(第一个 Image) + 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) + { + string name = LocalizationManager.Get(charm.displayNameKey, LocalizationTable.Items); + 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 > 2) + { + string desc = LocalizationManager.Get(charm.descriptionKey, LocalizationTable.Items); + texts[2].text = string.IsNullOrEmpty(desc) || desc == charm.descriptionKey + ? string.Empty : desc; + } + + // 按钮(约定:第一个 Button = 装备/卸下切换) + var btn = go.GetComponentInChildren