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