UI系统组件

This commit is contained in:
2026-06-06 09:00:11 +08:00
parent fe4fd60083
commit d794b83ebe
107 changed files with 25690 additions and 476 deletions

View File

@@ -1,6 +1,7 @@
using UnityEditor;
using UnityEngine;
using BaseGames.Dialogue;
using BaseGames.Editor.Localization;
namespace BaseGames.Editor.Dialogue
{
@@ -78,30 +79,10 @@ namespace BaseGames.Editor.Dialogue
}
/// <summary>
/// 在 Project 窗口中 Ping 指定表名对应的本地化 JSON 文件Resources/Localization/…/{tableName}.json
/// 遍历所有语言目录,以第一个找到的文件为准
/// 在 Project 窗口中 Ping 指定表名对应的本地化 JSON 文件
/// Assets/_Game/Data/Localization/…/{tableName}.json优先简体中文 → 英文)
/// </summary>
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);
// 文件名(不含扩展名)必须完全匹配 tableName
if (!path.EndsWith($"/{tableName}.json", System.StringComparison.OrdinalIgnoreCase)) continue;
var asset = AssetDatabase.LoadAssetAtPath<TextAsset>(path);
if (asset == null) continue;
EditorGUIUtility.PingObject(asset);
Selection.activeObject = asset;
return;
}
Debug.LogWarning($"[NpcSOEditor] 未找到本地化表文件Resources/Localization/…/{tableName}.json");
}
=> LocalizationFileIO.PingAny(tableName);
}
}

View File

@@ -0,0 +1,205 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;
using BaseGames.Localization;
namespace BaseGames.Editor.Localization
{
/// <summary>
/// 本地化 CSVExcel 往返)读写共享工具。
///
/// CSV 约定:
/// - 列顺序:<c>key, ChineseSimplified, English, Japanese, Korean</c>(表头用 <see cref="Language"/> 枚举名)。
/// - 以 <b>UTF-8 BOM</b> 写出,使 Excel 双击打开时中日韩文不乱码。
/// - 解析遵循 RFC 4180带引号字段可含逗号 / 换行 / 双引号转义),并自动剥离前导 BOM。
/// - 导入语义:合并(覆盖已有 key、追加新 key、不删除多余 key
/// </summary>
public static class LocalizationCsv
{
/// <summary>导出列顺序(与导入表头解析共用)。</summary>
public static readonly Language[] Columns = LocalizationFileIO.AllLanguages;
// 带 BOM 的 UTF-8Excel 友好)
private static readonly Encoding Utf8Bom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
// ── 高层:表 ⇄ CSV 文件 ─────────────────────────────────────────────────
/// <summary>
/// 将给定的"语言→字典"导出为该表的 CSV 文件(含 BOM。返回写出的资产路径。
/// </summary>
public static string ExportToFile(string table,
IReadOnlyDictionary<Language, Dictionary<string, string>> langDicts)
{
// 汇总所有语言出现过的 key排序保证稳定 diff
var allKeys = new SortedSet<string>(StringComparer.Ordinal);
foreach (var lang in Columns)
if (langDicts.TryGetValue(lang, out var d) && d != null)
foreach (var k in d.Keys) allKeys.Add(k);
string csvText = BuildCsvText(allKeys, langDicts);
EditorScaffoldUtils.EnsureFolder(LocalizationPaths.ExportRoot);
string assetPath = LocalizationPaths.CsvPath(table);
File.WriteAllText(ToFullPath(assetPath), csvText, Utf8Bom);
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
return assetPath;
}
/// <summary>
/// 读取该表的 CSV 文件并解析为"语言→字典"(仅含表头出现的语言列)。
/// CSV 文件不存在时返回 null。
/// </summary>
public static Dictionary<Language, Dictionary<string, string>> ParseFile(string table)
{
string assetPath = LocalizationPaths.CsvPath(table);
string fullPath = ToFullPath(assetPath);
if (!File.Exists(fullPath)) return null;
return ParseText(File.ReadAllText(fullPath, Encoding.UTF8));
}
/// <summary>
/// 将该表的 CSV 合并写回磁盘 JSON供批处理工具用
/// 对 CSV 表头出现的每个语言,读现有 JSON → 合并 CSV → <see cref="LocalizationFileIO.Write"/>。
/// 返回受影响的语言数CSV 不存在时返回 0。
/// </summary>
public static int ImportFileToJson(string table)
{
var parsed = ParseFile(table);
if (parsed == null) return 0;
foreach (var (lang, csvDict) in parsed)
{
var merged = LocalizationFileIO.Read(lang, table); // 现有 JSON 副本
foreach (var kv in csvDict) merged[kv.Key] = kv.Value;
LocalizationFileIO.Write(lang, table, merged);
}
return parsed.Count;
}
// ── 低层:文本 ⇄ 行 ─────────────────────────────────────────────────────
/// <summary>构造 CSV 文本(表头 + 数据行,按 allKeys 顺序)。</summary>
public static string BuildCsvText(IEnumerable<string> orderedKeys,
IReadOnlyDictionary<Language, Dictionary<string, string>> langDicts)
{
var sb = new StringBuilder();
sb.Append("key");
foreach (var lang in Columns) sb.Append(',').Append(lang);
sb.Append('\n');
foreach (var key in orderedKeys)
{
sb.Append(Escape(key));
foreach (var lang in Columns)
{
string val = "";
if (langDicts.TryGetValue(lang, out var d) && d != null)
d.TryGetValue(key, out val);
sb.Append(',').Append(Escape(val ?? ""));
}
sb.Append('\n');
}
return sb.ToString();
}
/// <summary>解析 CSV 文本为"语言→字典"(仅表头识别出的 Language 列)。</summary>
public static Dictionary<Language, Dictionary<string, string>> ParseText(string text)
{
var rows = ParseRows(text);
var result = new Dictionary<Language, Dictionary<string, string>>();
if (rows.Count < 2) return result;
// 表头:列号 → Language
var header = rows[0];
var langCols = new List<(int col, Language lang)>();
for (int col = 1; col < header.Count; col++)
if (Enum.TryParse<Language>(header[col].Trim(), out var lang))
{
langCols.Add((col, lang));
result[lang] = new Dictionary<string, string>(StringComparer.Ordinal);
}
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)
result[lang][key] = col < cells.Count ? cells[col] : "";
}
return result;
}
/// <summary>按 RFC 4180 规范转义单元格(含逗号 / 引号 / 换行时用双引号包裹)。</summary>
public static string Escape(string value)
{
if (value == null) return "";
bool needsQuote = value.IndexOf(',') >= 0 || value.IndexOf('"') >= 0
|| value.IndexOf('\n') >= 0 || value.IndexOf('\r') >= 0;
if (!needsQuote) return value;
return "\"" + value.Replace("\"", "\"\"") + "\"";
}
/// <summary>RFC 4180 CSV 解析器,支持带引号字段(含换行),并剥离前导 UTF-8 BOM。</summary>
public static List<List<string>> ParseRows(string text)
{
var rows = new List<List<string>>();
if (string.IsNullOrEmpty(text)) return rows;
// 剥离前导 BOMFile.ReadAllText 以 UTF8 读时若文件带 BOM 会保留为 U+FEFF
if (text[0] == '') text = text.Substring(1);
var row = new List<string>();
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<string>();
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<string>(); i++; }
else { cell.Append(c); i++; }
}
}
if (cell.Length > 0 || row.Count > 0)
{
row.Add(cell.ToString());
rows.Add(row);
}
return rows;
}
private static string ToFullPath(string assetPath)
{
string projectRoot = Path.GetDirectoryName(Application.dataPath)!;
return Path.Combine(projectRoot, assetPath.Replace('/', Path.DirectorySeparatorChar));
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bd6037390f5ea7b4b93dd91283193c9a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;
using BaseGames.Localization;
@@ -11,11 +9,12 @@ namespace BaseGames.Editor.Localization
/// <summary>
/// 本地化 CSV 导入/导出工具。
///
/// 每个表导出为独立 CSV列顺序key, ChineseSimplified, English, Japanese, Korean。
/// 每个表导出为独立 CSV(含 UTF-8 BOMExcel 中文不乱码)列顺序key, ChineseSimplified, English, Japanese, Korean。
/// 文件存放路径Assets/_Game/Localization/Export/{TableName}.csv
///
/// 导入:读取 CSV → 回写 Resources/Localization/{Language}/{TableName}.json
/// 导入:读取 CSV → 合并回写 Assets/_Game/Data/Localization/{Language}/{TableName}.json(经 LocalizationFileIO
///
/// 注日常单表编辑请用「BaseGames / Localization / 表格编辑器」,此窗口用于批量多表导入导出。
/// 菜单BaseGames / Localization / CSV 导入导出工具
/// </summary>
public class LocalizationCsvTool : EditorWindow
@@ -29,9 +28,6 @@ namespace BaseGames.Editor.Localization
// ── 状态 ─────────────────────────────────────────────────────────────
private static readonly Language[] s_allLanguages =
(Language[])Enum.GetValues(typeof(Language));
private static readonly string[] s_allTables =
{
LocalizationTable.UI,
@@ -45,6 +41,7 @@ namespace BaseGames.Editor.Localization
};
private const string ExportDir = "Assets/_Game/Localization/Export";
private const string DataDir = LocalizationPaths.DataRoot;
private readonly bool[] _exportSelected = new bool[s_allTables.Length];
private readonly bool[] _importSelected = new bool[s_allTables.Length];
@@ -61,7 +58,7 @@ namespace BaseGames.Editor.Localization
EditorGUILayout.Space(6);
GUILayout.Label("📤 导出 → CSV", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
$"将 Resources/Localization/ 中的 JSON 表导出为 CSV 文件。\n" +
$"将 {DataDir}/ 中的 JSON 表导出为 CSV 文件UTF-8 BOMExcel 可直接打开)。\n" +
$"目标目录:{ExportDir}/",
MessageType.Info);
@@ -84,7 +81,7 @@ namespace BaseGames.Editor.Localization
GUILayout.Label("📥 导入 ← CSV", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
$"读取 {ExportDir}/ 中的 CSV回写至 Resources/Localization/ JSON 文件。\n" +
$"读取 {ExportDir}/ 中的 CSV合并回写至 {DataDir}/ JSON 文件。\n" +
"已有 Key 覆盖,新增 Key 追加,不删除多余 Key。",
MessageType.Info);
@@ -114,7 +111,7 @@ namespace BaseGames.Editor.Localization
private void RunExport()
{
EnsureDirectory(ExportDir);
LocalizationManager.ClearEditorPreviewCache();
int exported = 0;
try
@@ -126,50 +123,23 @@ namespace BaseGames.Editor.Localization
EditorUtility.DisplayProgressBar("导出 CSV", $"导出 {tableName}…", (float)ti / s_allTables.Length);
// 收集所有语言字典(以第一个语言的 Key 集合为主键集
// 经统一门面读取各语言字典(正确路径),交给 LocalizationCsv 写出(含 BOM
var langDicts = new Dictionary<Language, Dictionary<string, string>>();
var allKeys = new SortedSet<string>(StringComparer.Ordinal);
foreach (var lang in s_allLanguages)
bool any = false;
foreach (var lang in LocalizationFileIO.AllLanguages)
{
LocalizationManager.ClearEditorPreviewCache();
var dict = LocalizationManager.GetEditorTable(lang, tableName);
if (dict != null)
{
langDicts[lang] = dict;
foreach (var k in dict.Keys) allKeys.Add(k);
}
var dict = LocalizationFileIO.Read(lang, tableName);
langDicts[lang] = dict;
if (dict.Count > 0) any = true;
}
if (allKeys.Count == 0)
if (!any)
{
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);
LocalizationCsv.ExportToFile(tableName, langDicts);
exported++;
}
}
@@ -195,61 +165,18 @@ namespace BaseGames.Editor.Localization
{
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++)
// 合并语义 + 正确格式/目录/Addressable 注册,全部由统一门面处理
int affected = LocalizationCsv.ImportFileToJson(tableName);
if (affected == 0)
{
if (Enum.TryParse<Language>(header[col].Trim(), out var lang))
langCols.Add((col, lang));
Debug.LogWarning($"[CsvTool] CSV 文件不存在或为空,跳过:{ExportDir}/{tableName}.csv");
continue;
}
// 为每个语言准备合并后的字典
var mergedDicts = new Dictionary<Language, Dictionary<string, string>>();
foreach (var (_, lang) in langCols)
{
LocalizationManager.ClearEditorPreviewCache();
var existing = LocalizationManager.GetEditorTable(lang, tableName)
?? new Dictionary<string, string>(StringComparer.Ordinal);
mergedDicts[lang] = new Dictionary<string, string>(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)
@@ -275,104 +202,6 @@ namespace BaseGames.Editor.Localization
// ── 工具函数 ─────────────────────────────────────────────────────────
/// <summary>将 value 按 RFC 4180 规范包裹(含逗号/双引号/换行时用双引号包裹,内部双引号转义为 "")。</summary>
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("\"", "\"\"") + "\"";
}
/// <summary>简单 CSV 解析器,支持 RFC 4180 带引号字段(含换行)。</summary>
private static List<List<string>> ParseCsv(string text)
{
var rows = new List<List<string>>();
var row = new List<string>();
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<string>();
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<string>(); i++; }
else
{ cell.Append(c); i++; }
}
}
if (cell.Length > 0 || row.Count > 0)
{
row.Add(cell.ToString());
rows.Add(row);
}
return rows;
}
/// <summary>将字符串字典序列化为最小 JSON 对象UTF-8适合直接写入 Resources JSON。</summary>
private static string DictToJson(Dictionary<string, string> 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;

View File

@@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;
using BaseGames.Localization;
namespace BaseGames.Editor.Localization
{
/// <summary>
/// 本地化 JSON 表的编辑器侧磁盘读写门面(唯一入口)。
///
/// 所有编辑器工具表格编辑器、CSV 工具、审计模块、各 Inspector读写本地化文件
/// 都必须经此类,统一:路径(<see cref="LocalizationPaths"/>)、格式
/// <see cref="LocalizationSerializer"/>、Addressable 注册、编辑器缓存刷新。
///
/// 这样运行时加载与编辑器写盘永不脱节——杜绝"编辑器看着正常、Play 加载不到"的隐藏 bug。
/// </summary>
public static class LocalizationFileIO
{
/// <summary>所有受支持语言(枚举顺序)。</summary>
public static readonly Language[] AllLanguages =
(Language[])Enum.GetValues(typeof(Language));
// ── 读 ───────────────────────────────────────────────────────────────
/// <summary>
/// 读取指定语言 + 表的 key→value 字典(返回可安全修改的副本,不存在时返回空字典)。
/// 内部走 <see cref="LocalizationManager.GetEditorTable"/> 复用其静态缓存。
/// </summary>
public static Dictionary<string, string> Read(Language language, string table)
{
var src = LocalizationManager.GetEditorTable(language, table);
return src == null
? new Dictionary<string, string>(StringComparer.Ordinal)
: new Dictionary<string, string>(src, StringComparer.Ordinal);
}
/// <summary>该表的 JSON 文件是否已存在于磁盘。</summary>
public static bool TableExists(Language language, string table)
=> AssetDatabase.LoadAssetAtPath<TextAsset>(LocalizationPaths.AssetPath(language, table)) != null;
/// <summary>扫描数据根目录,返回磁盘上实际存在子目录的语言。</summary>
public static List<Language> DiscoverLanguages()
{
var result = new List<Language>();
if (!AssetDatabase.IsValidFolder(LocalizationPaths.DataRoot)) return result;
foreach (var langFolder in AssetDatabase.GetSubFolders(LocalizationPaths.DataRoot))
{
string langName = Path.GetFileName(langFolder);
if (Enum.TryParse<Language>(langName, out var lang))
result.Add(lang);
}
return result;
}
// ── 写 ───────────────────────────────────────────────────────────────
/// <summary>
/// 将字典写回指定语言 + 表的 JSON 文件(正确的 <c>{entries:[…]}</c> 格式 + 正确目录)。
/// 新文件会自动注册到 Addressables地址 <see cref="LocalizationPaths.Address"/>
/// 写盘后清除编辑器预览缓存。
/// </summary>
/// <param name="language">目标语言。</param>
/// <param name="table">目标表名(<see cref="LocalizationTable"/> 常量)。</param>
/// <param name="dict">key→value 字典。</param>
/// <param name="registerAddressable">是否确保 Addressable 注册(默认 true。</param>
public static void Write(Language language, string table,
IReadOnlyDictionary<string, string> dict, bool registerAddressable = true)
{
string assetPath = LocalizationPaths.AssetPath(language, table);
EditorScaffoldUtils.EnsureFolder(LocalizationPaths.LanguageFolder(language));
// JSON 文件不写 BOMAddressables/TextAsset 解析期望纯 UTF-8
string json = LocalizationSerializer.Serialize(dict, sortKeys: true);
string fullPath = ToFullPath(assetPath);
File.WriteAllText(fullPath, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
if (registerAddressable)
EnsureAddressable(language, table, assetPath);
LocalizationManager.ClearEditorPreviewCache();
}
// ── Ping ───────────────────────────────────────────────────────────────
/// <summary>在 Project 窗口中定位指定语言 + 表的 JSON 文件。</summary>
public static TextAsset Ping(Language language, string table)
{
var asset = AssetDatabase.LoadAssetAtPath<TextAsset>(LocalizationPaths.AssetPath(language, table));
if (asset != null) EditorScaffoldUtils.PingAndSelect(asset);
return asset;
}
/// <summary>在 Project 窗口中定位指定表的任一存在语言文件(优先简体中文 → 英文 → 其余)。</summary>
public static TextAsset PingAny(string table)
{
foreach (var lang in OrderedForPing())
{
var asset = AssetDatabase.LoadAssetAtPath<TextAsset>(LocalizationPaths.AssetPath(lang, table));
if (asset != null) { EditorScaffoldUtils.PingAndSelect(asset); return asset; }
}
Debug.LogWarning($"[LocalizationFileIO] 未找到本地化表文件:{LocalizationPaths.DataRoot}/…/{table}.json");
return null;
}
// ── 内部 ───────────────────────────────────────────────────────────────
private static IEnumerable<Language> OrderedForPing()
{
yield return Language.ChineseSimplified;
yield return Language.English;
foreach (var lang in AllLanguages)
if (lang != Language.ChineseSimplified && lang != Language.English)
yield return lang;
}
/// <summary>
/// 确保该 JSON 文件已注册为对应 Addressable 地址。
/// 复用 <see cref="BaseGames.Editor.Addressables.CoreSceneRegistrar"/> 的 FindEntry-or-Create 幂等范式。
/// </summary>
private static void EnsureAddressable(Language language, string table, string assetPath)
{
var settings = AddressableAssetSettingsDefaultObject.Settings;
if (settings == null)
{
Debug.LogWarning(
$"[LocalizationFileIO] Addressable Settings 未初始化,未能注册 " +
$"{LocalizationPaths.Address(language, table)}。运行时可能加载不到该表。");
return;
}
string guid = AssetDatabase.AssetPathToGUID(assetPath);
if (string.IsNullOrEmpty(guid)) return;
string address = LocalizationPaths.Address(language, table);
var entry = settings.FindAssetEntry(guid)
?? settings.CreateOrMoveEntry(guid, settings.DefaultGroup, false, false);
// 已注册且地址正确则无需改动(避免无意义的 SetDirty
if (entry.address == address) return;
entry.address = address;
EditorUtility.SetDirty(settings);
AssetDatabase.SaveAssets();
}
private static string ToFullPath(string assetPath)
{
string projectRoot = Path.GetDirectoryName(Application.dataPath)!;
return Path.Combine(projectRoot, assetPath.Replace('/', Path.DirectorySeparatorChar));
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 96378ff704ef55a4db9c6d7ee55c12a0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -60,7 +60,7 @@ namespace BaseGames.Editor.Localization
{
EditorUtility.DisplayDialog("Key 选择器",
$"表「{table}」尚无可用 Key。\n" +
$"请先在 Resources/Localization/{{语言}}/{table}.json 中添加条目。",
$"请先用「BaseGames / Localization / 表格编辑器」或在 {LocalizationPaths.DataRoot}/{{语言}}/{table}.json 中添加条目。",
"确定");
return;
}

View File

@@ -0,0 +1,463 @@
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using BaseGames.Localization;
namespace BaseGames.Editor.Localization
{
/// <summary>
/// 本地化表格编辑器(策划录入中心)。
///
/// 以 (Key × 语言) 网格直接编辑一张本地化表,无需手改 JSON
/// - 表切换、搜索过滤、"仅显示缺失"。
/// - 新增 / 重命名 / 删除 Key跨所有语言同步
/// - 缺失单元格红底高亮。
/// - 保存:经 <see cref="LocalizationFileIO"/> 写回所有语言 JSON正确 entries 格式 + 自动注册 Addressables
/// - 工具栏内置 Excel/CSV 双向:导出 CSVUTF-8 BOM、导入 CSV合并后即时刷新网格
/// - 1000+ 行虚拟化渲染(仅绘制可见行)。
///
/// 菜单BaseGames / Localization / 表格编辑器
/// </summary>
public class LocalizationTableEditorWindow : EditorWindow
{
[MenuItem("BaseGames/Localization/表格编辑器")]
private static void Open()
{
var win = GetWindow<LocalizationTableEditorWindow>("本地化表格编辑器");
win.minSize = new Vector2(720, 480);
}
// ── 常量 ─────────────────────────────────────────────────────────────
private static readonly Language[] s_langs = LocalizationFileIO.AllLanguages;
private const float RowHeight = 22f;
private const float KeyColWidth = 240f;
private const float MinLangCol = 140f;
private const float Scrollbar = 16f;
// ── 状态 ─────────────────────────────────────────────────────────────
private string[] _tables;
private int _tableIndex;
private string _currentTable;
// 工作副本:语言 → (key → value)
private readonly Dictionary<Language, Dictionary<string, string>> _data = new();
private readonly List<string> _keys = new(); // 全部 key已排序
private readonly List<string> _filtered = new(); // 过滤后用于显示
private string _search = "";
private bool _onlyMissing;
private bool _dirty;
private Vector2 _scroll;
// 行内输入状态机(新增 / 重命名 Key
private enum InputMode { None, AddKey, RenameKey }
private InputMode _inputMode = InputMode.None;
private string _inputText = "";
private string _renameTarget;
private GUIStyle _keyBtnStyle;
// ── 生命周期 ──────────────────────────────────────────────────────────
private void OnEnable()
{
_tables = LocalizationTable.All;
_tableIndex = 0;
LoadTable(_tables[_tableIndex]);
}
// ── 数据加载 / 保存 ───────────────────────────────────────────────────
private void LoadTable(string table)
{
_currentTable = table;
_data.Clear();
LocalizationManager.ClearEditorPreviewCache();
var union = new SortedSet<string>(StringComparer.Ordinal);
foreach (var lang in s_langs)
{
var dict = LocalizationFileIO.Read(lang, table);
_data[lang] = dict;
foreach (var k in dict.Keys) union.Add(k);
}
_keys.Clear();
_keys.AddRange(union);
_dirty = false;
_inputMode = InputMode.None;
ApplyFilter();
}
private void Save()
{
foreach (var lang in s_langs)
LocalizationFileIO.Write(lang, _currentTable, _data[lang]);
AssetDatabase.SaveAssets();
_dirty = false;
ShowNotification(new GUIContent($"已保存 {_currentTable}{_keys.Count} 个 Key × {s_langs.Length} 语言)"));
}
/// <summary>切换表 / 关闭前:若有未保存改动,询问保存。返回 false 表示用户取消操作。</summary>
private bool ConfirmDiscardIfDirty(string actionDesc)
{
if (!_dirty) return true;
int choice = EditorUtility.DisplayDialogComplex(
"有未保存的改动",
$"表「{_currentTable}」有未保存的改动。{actionDesc}前要保存吗?",
"保存", "取消", "丢弃");
switch (choice)
{
case 0: Save(); return true; // 保存
case 2: return true; // 丢弃
default: return false; // 取消
}
}
// ── GUI ───────────────────────────────────────────────────────────────
private void OnGUI()
{
EnsureStyles();
DrawToolbar();
if (_inputMode != InputMode.None) DrawInputBar();
DrawHeader();
DrawGrid();
}
private void DrawToolbar()
{
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
// 表切换
EditorGUI.BeginChangeCheck();
int newIndex = EditorGUILayout.Popup(_tableIndex, _tables, EditorStyles.toolbarPopup, GUILayout.Width(160));
if (EditorGUI.EndChangeCheck() && newIndex != _tableIndex)
{
if (ConfirmDiscardIfDirty("切换表"))
{
_tableIndex = newIndex;
LoadTable(_tables[_tableIndex]);
}
}
// 搜索
EditorGUI.BeginChangeCheck();
_search = EditorGUILayout.TextField(_search, EditorStyles.toolbarSearchField, GUILayout.MinWidth(120));
if (EditorGUI.EndChangeCheck()) ApplyFilter();
bool prevOnlyMissing = _onlyMissing;
_onlyMissing = GUILayout.Toggle(_onlyMissing, "仅显示缺失", EditorStyles.toolbarButton, GUILayout.Width(80));
if (_onlyMissing != prevOnlyMissing) ApplyFilter();
GUILayout.FlexibleSpace();
if (GUILayout.Button(" 新增 Key", EditorStyles.toolbarButton, GUILayout.Width(80)))
BeginAddKey();
if (GUILayout.Button("导出 CSV", EditorStyles.toolbarButton, GUILayout.Width(64)))
ExportCsv();
if (GUILayout.Button("导入 CSV", EditorStyles.toolbarButton, GUILayout.Width(64)))
ImportCsv();
using (new EditorGUI.DisabledScope(!_dirty))
{
var saveContent = new GUIContent(_dirty ? "● 保存" : "保存");
if (GUILayout.Button(saveContent, EditorStyles.toolbarButton, GUILayout.Width(64)))
Save();
}
if (GUILayout.Button("刷新", EditorStyles.toolbarButton, GUILayout.Width(44)))
{
if (ConfirmDiscardIfDirty("刷新")) LoadTable(_currentTable);
}
EditorGUILayout.EndHorizontal();
}
private void DrawInputBar()
{
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
GUILayout.Label(_inputMode == InputMode.AddKey ? "新增 Key" : $"重命名「{_renameTarget}」为:",
GUILayout.Width(_inputMode == InputMode.AddKey ? 70 : 200));
GUI.SetNextControlName("LocInputField");
_inputText = EditorGUILayout.TextField(_inputText);
bool submit = false;
var e = Event.current;
if (e.type == EventType.KeyDown && (e.keyCode == KeyCode.Return || e.keyCode == KeyCode.KeypadEnter)
&& GUI.GetNameOfFocusedControl() == "LocInputField")
{ submit = true; e.Use(); }
if (e.type == EventType.KeyDown && e.keyCode == KeyCode.Escape) { CancelInput(); e.Use(); }
if (GUILayout.Button("确认", GUILayout.Width(48)) || submit) CommitInput();
if (GUILayout.Button("取消", GUILayout.Width(48))) CancelInput();
EditorGUILayout.EndHorizontal();
// 命名提示
if (!string.IsNullOrEmpty(_inputText) && !IsValidKey(_inputText))
EditorGUILayout.HelpBox("建议使用 UPPER_SNAKE_CASE大写字母开头大写字母/数字/下划线)。", MessageType.Warning);
}
private void DrawHeader()
{
float langW = LangColWidth();
var rect = GUILayoutUtility.GetRect(0, RowHeight, GUILayout.ExpandWidth(true));
EditorGUI.DrawRect(rect, new Color(0.18f, 0.18f, 0.18f, 1f));
var keyRect = new Rect(rect.x + 4, rect.y + 3, KeyColWidth - 6, RowHeight - 4);
EditorGUI.LabelField(keyRect, $"Key{_filtered.Count}/{_keys.Count}", EditorStyles.boldLabel);
for (int c = 0; c < s_langs.Length; c++)
{
var lr = new Rect(rect.x + KeyColWidth + c * langW + 4, rect.y + 3, langW - 6, RowHeight - 4);
int missing = CountMissing(s_langs[c]);
string label = missing > 0 ? $"{s_langs[c]} (缺{missing})" : s_langs[c].ToString();
EditorGUI.LabelField(lr, label, EditorStyles.boldLabel);
}
}
private void DrawGrid()
{
float langW = LangColWidth();
float viewH = position.height - GUILayoutUtility.GetLastRect().yMax;
float contentH = _filtered.Count * RowHeight;
_scroll = GUI.BeginScrollView(
new Rect(0, GUILayoutUtility.GetLastRect().yMax, position.width, viewH),
_scroll,
new Rect(0, 0, position.width - Scrollbar, contentH));
int first = Mathf.Max(0, (int)(_scroll.y / RowHeight) - 1);
int last = Mathf.Min(_filtered.Count - 1, (int)((_scroll.y + viewH) / RowHeight) + 1);
for (int i = first; i <= last && i < _filtered.Count; i++)
DrawRow(i, _filtered[i], langW);
GUI.EndScrollView();
}
private void DrawRow(int index, string key, float langW)
{
float y = index * RowHeight;
var rowRect = new Rect(0, y, position.width - Scrollbar, RowHeight);
if (index % 2 == 0)
EditorGUI.DrawRect(rowRect, new Color(1f, 1f, 1f, 0.025f));
// Key 列(点击弹出 重命名/删除/复制 菜单)
var keyRect = new Rect(2, y + 1, KeyColWidth - 4, RowHeight - 2);
if (GUI.Button(keyRect, key, _keyBtnStyle))
ShowKeyContextMenu(key);
// 各语言可编辑单元格
for (int c = 0; c < s_langs.Length; c++)
{
var lang = s_langs[c];
var cellRect = new Rect(KeyColWidth + c * langW + 1, y + 1, langW - 2, RowHeight - 2);
_data[lang].TryGetValue(key, out var val);
if (string.IsNullOrEmpty(val))
EditorGUI.DrawRect(cellRect, new Color(0.5f, 0.15f, 0.1f, 0.28f)); // 缺失高亮
EditorGUI.BeginChangeCheck();
string newVal = EditorGUI.DelayedTextField(cellRect, val ?? "");
if (EditorGUI.EndChangeCheck())
{
_data[lang][key] = newVal;
MarkDirty();
if (_onlyMissing) ApplyFilter(); // 填上后可能从"缺失"列表消失
}
}
}
// ── Key 增删改 ────────────────────────────────────────────────────────
private void ShowKeyContextMenu(string key)
{
var menu = new GenericMenu();
menu.AddItem(new GUIContent("重命名"), false, () => BeginRename(key));
menu.AddItem(new GUIContent("删除"), false, () => DeleteKey(key));
menu.AddItem(new GUIContent("复制 Key"), false, () => EditorGUIUtility.systemCopyBuffer = key);
menu.ShowAsContext();
}
private void BeginAddKey()
{
_inputMode = InputMode.AddKey;
_inputText = "";
_renameTarget = null;
EditorGUI.FocusTextInControl("LocInputField");
}
private void BeginRename(string key)
{
_inputMode = InputMode.RenameKey;
_inputText = key;
_renameTarget = key;
EditorGUI.FocusTextInControl("LocInputField");
}
private void CommitInput()
{
string newKey = _inputText?.Trim();
if (string.IsNullOrEmpty(newKey)) { CancelInput(); return; }
if (_inputMode == InputMode.AddKey)
{
if (_keys.Contains(newKey))
{
EditorUtility.DisplayDialog("新增 Key", $"Key「{newKey}」已存在。", "确定");
return;
}
foreach (var lang in s_langs) _data[lang][newKey] = "";
_keys.Add(newKey);
_keys.Sort(StringComparer.Ordinal);
MarkDirty();
}
else if (_inputMode == InputMode.RenameKey && _renameTarget != null)
{
if (newKey == _renameTarget) { CancelInput(); return; }
if (_keys.Contains(newKey))
{
EditorUtility.DisplayDialog("重命名", $"Key「{newKey}」已存在。", "确定");
return;
}
foreach (var lang in s_langs)
{
_data[lang].TryGetValue(_renameTarget, out var v);
_data[lang].Remove(_renameTarget);
_data[lang][newKey] = v ?? "";
}
_keys.Remove(_renameTarget);
_keys.Add(newKey);
_keys.Sort(StringComparer.Ordinal);
MarkDirty();
}
_inputMode = InputMode.None;
ApplyFilter();
}
private void CancelInput()
{
_inputMode = InputMode.None;
_inputText = "";
_renameTarget = null;
GUI.FocusControl(null);
}
private void DeleteKey(string key)
{
if (!EditorUtility.DisplayDialog("删除 Key", $"确定删除「{key}」(所有语言)?", "删除", "取消"))
return;
foreach (var lang in s_langs) _data[lang].Remove(key);
_keys.Remove(key);
MarkDirty();
ApplyFilter();
}
// ── CSV 双向 ─────────────────────────────────────────────────────────
private void ExportCsv()
{
string path = LocalizationCsv.ExportToFile(_currentTable, _data);
var asset = AssetDatabase.LoadAssetAtPath<TextAsset>(path);
if (asset != null) EditorGUIUtility.PingObject(asset);
ShowNotification(new GUIContent($"已导出 {path}"));
}
private void ImportCsv()
{
var parsed = LocalizationCsv.ParseFile(_currentTable);
if (parsed == null)
{
EditorUtility.DisplayDialog("导入 CSV",
$"未找到 CSV 文件:\n{LocalizationPaths.CsvPath(_currentTable)}\n\n请先「导出 CSV」或将文件放到该路径。",
"确定");
return;
}
// 合并进内存网格(覆盖已有 key、追加新 key、不删多余随后置脏待保存
int added = 0, updated = 0;
foreach (var (lang, csvDict) in parsed)
{
if (!_data.ContainsKey(lang)) _data[lang] = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var kv in csvDict)
{
bool isNew = !_keys.Contains(kv.Key);
if (isNew && !_data[lang].ContainsKey(kv.Key)) added++;
else updated++;
_data[lang][kv.Key] = kv.Value;
if (!_keys.Contains(kv.Key)) _keys.Add(kv.Key);
}
}
_keys.Sort(StringComparer.Ordinal);
MarkDirty();
ApplyFilter();
ShowNotification(new GUIContent($"已导入并刷新网格(新增 {added}、更新 {updated})。记得点「保存」写回。"));
}
// ── 过滤 / 工具 ───────────────────────────────────────────────────────
private void ApplyFilter()
{
_filtered.Clear();
foreach (var key in _keys)
{
if (_onlyMissing && !HasMissing(key)) continue;
if (!string.IsNullOrEmpty(_search) && !MatchesSearch(key)) continue;
_filtered.Add(key);
}
Repaint();
}
private bool MatchesSearch(string key)
{
if (key.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0) return true;
foreach (var lang in s_langs)
if (_data[lang].TryGetValue(key, out var v) && !string.IsNullOrEmpty(v)
&& v.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0)
return true;
return false;
}
private bool HasMissing(string key)
{
foreach (var lang in s_langs)
if (!_data[lang].TryGetValue(key, out var v) || string.IsNullOrEmpty(v)) return true;
return false;
}
private int CountMissing(Language lang)
{
int n = 0;
foreach (var key in _keys)
if (!_data[lang].TryGetValue(key, out var v) || string.IsNullOrEmpty(v)) n++;
return n;
}
private float LangColWidth()
{
float avail = position.width - KeyColWidth - Scrollbar;
return Mathf.Max(MinLangCol, avail / s_langs.Length);
}
private static bool IsValidKey(string key)
{
if (string.IsNullOrEmpty(key) || !char.IsUpper(key[0])) return false;
foreach (char c in key)
if (!(char.IsUpper(c) || char.IsDigit(c) || c == '_')) return false;
return true;
}
private void MarkDirty() => _dirty = true;
private void EnsureStyles()
{
_keyBtnStyle ??= new GUIStyle(EditorStyles.label)
{
alignment = TextAnchor.MiddleLeft,
padding = new RectOffset(4, 2, 0, 0),
fontSize = 11,
};
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d25d80dd8535edd4c95436a6f9d2f5a4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -64,7 +64,7 @@ namespace BaseGames.Editor.Localization
{
EditorGUILayout.HelpBox(
$"Key「{key}」在表「{table}」中未找到(简体中文表与英文表均未命中)。\n" +
$"请检查 Resources/Localization/{{Language}}/{table}.json 文件。",
$"请检查 {LocalizationPaths.DataRoot}/{{Language}}/{table}.json 文件。",
MessageType.Warning);
}
else
@@ -101,7 +101,7 @@ namespace BaseGames.Editor.Localization
Repaint();
}
if (GUILayout.Button($"跳转到表文件({table}", GUILayout.Width(160)))
PingLocalizationFile(table);
LocalizationFileIO.PingAny(table);
EditorGUILayout.EndHorizontal();
}
@@ -120,24 +120,5 @@ namespace BaseGames.Editor.Localization
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<TextAsset>(path);
if (asset == null) continue;
EditorGUIUtility.PingObject(asset);
Selection.activeObject = asset;
return;
}
Debug.LogWarning($"[LocalizedTextEditor] 未找到本地化表文件Resources/Localization/…/{tableName}.json");
}
}
}

View File

@@ -7,13 +7,14 @@ using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
using BaseGames.Localization;
using BaseGames.Editor.Localization;
namespace BaseGames.Editor.Modules
{
/// <summary>
/// DataHub 本地化审计模块。
/// 通过 <see cref="ILocalizableAsset"/> 接口扫描项目中所有 ScriptableObject 的本地化 Key
/// 与 Resources/Localization/ JSON 表比对,列出缺失条目和命名不规范条目。
/// 与 Assets/_Game/Data/Localization/ JSON 表比对,列出缺失条目和命名不规范条目。
///
/// 菜单入口DataHub → "本地化审计"
/// </summary>
@@ -168,15 +169,8 @@ namespace BaseGames.Editor.Modules
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<Language>(langName, out var lang))
_availableLanguages.Add(lang);
}
// 统一经 LocalizationFileIO 扫描真相源目录Assets/_Game/Data/Localization
_availableLanguages.AddRange(LocalizationFileIO.DiscoverLanguages());
}
// ── 通用 ILocalizableAsset 扫描 ───────────────────────────────────────
@@ -556,16 +550,14 @@ namespace BaseGames.Editor.Modules
private static void PingTableFile(string language, string tableName)
{
string path = $"Assets/Resources/Localization/{language}/{tableName}.json";
var asset = AssetDatabase.LoadAssetAtPath<TextAsset>(path);
if (asset != null)
if (Enum.TryParse<Language>(language, out var lang))
{
EditorGUIUtility.PingObject(asset);
Selection.activeObject = asset;
if (LocalizationFileIO.Ping(lang, tableName) == null)
Debug.LogWarning($"[LocalizationAudit] 未找到表文件:{LocalizationPaths.AssetPath(lang, tableName)}");
}
else
{
Debug.LogWarning($"[LocalizationAudit] 未找到表文件:{path}");
LocalizationFileIO.PingAny(tableName);
}
}
}

View File

@@ -313,9 +313,25 @@ namespace BaseGames.Editor
// ── 流式加载系统 ──────────────────────────────────────────────────
ScaffoldStreamingSystem(services, report);
// ── 多设备 UI 焦点守护EventSystem 上挂 UISelectionRestorer──────────
EnsureUISelectionRestorer(report);
MarkDirtyAndLog("Persistent 场景脚手架", root, report);
}
/// <summary>确保场景中的 EventSystem 挂有 <see cref="BaseGames.UI.UISelectionRestorer"/>(多设备焦点守护,幂等)。</summary>
private static void EnsureUISelectionRestorer(List<string> report)
{
var es = Object.FindObjectOfType<UnityEngine.EventSystems.EventSystem>();
if (es == null)
{
report.Add("未找到 EventSystemUISelectionRestorer 未挂载(键盘/手柄丢失焦点后无法自动恢复)。请确认 Persistent 场景含 EventSystem + InputSystemUIInputModule。");
return;
}
if (es.GetComponent<BaseGames.UI.UISelectionRestorer>() == null)
Undo.AddComponent<BaseGames.UI.UISelectionRestorer>(es.gameObject);
}
// ─────────────────────────────────────────────────────────────────────
// Scaffold Main Menu Scene
// ─────────────────────────────────────────────────────────────────────
@@ -357,6 +373,7 @@ namespace BaseGames.Editor
var subTmp = GetOrAddComponent<TextMeshProUGUI>(subtitleRt.gameObject);
subTmp.text = "A 2D Action Adventure"; subTmp.fontSize = 40f; subTmp.alignment = TextAlignmentOptions.Center;
subTmp.color = new Color(0.7f, 0.66f, 0.55f, 0.9f); subTmp.raycastTarget = false; subTmp.characterSpacing = 8f;
BindLocalizedText(subtitleRt.gameObject, "MENU_SUBTITLE");
// ── 主菜单控制器 ──────────────────────────────────────────────────
MainMenuController menuCtrl = GetOrAddComponent<MainMenuController>(canvasGo);
@@ -386,6 +403,11 @@ namespace BaseGames.Editor
var le = GetOrAddComponent<LayoutElement>(b);
le.preferredHeight = 64f; le.minHeight = 56f;
}
BindLocalizedButton(btnNewGameGo, "MENU_NEW_GAME");
BindLocalizedButton(btnContinueGo, "MENU_CONTINUE");
BindLocalizedButton(btnSettingsGo, "MENU_SETTINGS");
BindLocalizedButton(btnCreditsGo, "MENU_CREDITS");
BindLocalizedButton(btnQuitGo, "MENU_QUIT");
AssignReference(menuCtrl, "_btnNewGame", btnNewGameGo.GetComponent<Button>());
AssignReference(menuCtrl, "_btnContinue", btnContinueGo.GetComponent<Button>());
@@ -416,6 +438,7 @@ namespace BaseGames.Editor
var slotTitleTmp = GetOrAddComponent<TextMeshProUGUI>(slotTitleRt.gameObject);
slotTitleTmp.text = "Select Save"; slotTitleTmp.fontSize = 56f; slotTitleTmp.fontStyle = FontStyles.Bold;
slotTitleTmp.alignment = TextAlignmentOptions.Center; slotTitleTmp.color = GoldText; slotTitleTmp.raycastTarget = false;
BindLocalizedText(slotTitleRt.gameObject, "SAVESLOT_TITLE");
// 卡片容器(居中竖排)
var slotsContainerRt = GetOrCreateUIChild(saveSlotPanelGo.transform, "SlotsContainer");
@@ -450,6 +473,7 @@ namespace BaseGames.Editor
SetRect(slotBackRt, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0.5f, 0f),
new Vector2(0f, 70f), new Vector2(260f, 64f));
StyleAsTextButton(slotBackGo, 30f);
BindLocalizedButton(slotBackGo, "BTN_BACK");
AssignReference(menuCtrl, "_btnCloseSaveSlot", slotBackGo.GetComponent<Button>());
// ── ConfirmDialog覆盖 / 删除确认)─────────────────────
@@ -460,15 +484,17 @@ namespace BaseGames.Editor
NewGameModeController modeCtrl = BuildNewGameMode(saveSlotPanelGo.transform);
AssignReference(saveSlotCtrl, "_modeSelect", modeCtrl);
// ── SettingsPanel ─────────────────────────────────────────────────
// ── SettingsPanel(音量 / 画面 / 可访问性 / 语言)──────────────────
var settingsPanelRt = GetOrCreateUIChild(canvasGo.transform, "SettingsPanel");
StretchFull(settingsPanelRt);
BuildSettingsPanel(settingsPanelRt.gameObject, menuCtrl, report);
settingsPanelRt.gameObject.SetActive(false);
AssignReference(menuCtrl, "_settingsPanel", settingsPanelRt.gameObject);
// ── CreditsPanel ──────────────────────────────────────────────────
// ── CreditsPanel(制作团队)────────────────────────────────────────
var creditsPanelRt = GetOrCreateUIChild(canvasGo.transform, "CreditsPanel");
StretchFull(creditsPanelRt);
BuildCreditsPanel(creditsPanelRt.gameObject, menuCtrl, report);
creditsPanelRt.gameObject.SetActive(false);
AssignReference(menuCtrl, "_creditsPanel", creditsPanelRt.gameObject);
@@ -505,20 +531,38 @@ namespace BaseGames.Editor
bgImg.type = Image.Type.Simple; bgImg.preserveAspect = true; bgImg.enabled = false;
bgImg.transform.SetSiblingIndex(1);
// 全覆盖选择按钮(透明,金色高亮;位于信息层之下,靠 raycast 接收点击
// 选中/悬停高亮叠加(基色白不透明;显隐由按钮 ColorBlock 的 alpha 控制 → 可见的选中反馈
var hlImg = GetOrCreateImage(slotGo.transform, "Highlight", Color.white, false);
hlImg.transform.SetSiblingIndex(2);
// 全覆盖选择按钮(透明,靠 raycast 接收点击;着色目标为 Highlight 叠加图)
GameObject selectGo = GetOrCreateButtonChild(slotGo.transform, "SelectButton", "");
StretchFull((RectTransform)selectGo.transform);
var selImg = selectGo.GetComponent<Image>();
if (selImg != null) selImg.color = new Color(1f, 1f, 1f, 0f);
var selLabel = GetButtonLabel(selectGo);
if (selLabel != null) selLabel.gameObject.SetActive(false);
var selBtn = selectGo.GetComponent<Button>();
if (selBtn != null)
{
selBtn.targetGraphic = hlImg;
var cc = selBtn.colors;
cc.normalColor = new Color(1f, 0.85f, 0.45f, 0f); // 静态:透明
cc.highlightedColor = new Color(1f, 0.86f, 0.5f, 0.08f); // 悬停:极淡金
cc.selectedColor = new Color(1f, 0.86f, 0.5f, 0.13f); // 键盘/手柄选中:淡金叠加
cc.pressedColor = new Color(1f, 0.86f, 0.5f, 0.22f);
cc.disabledColor = new Color(1f, 1f, 1f, 0f);
cc.colorMultiplier = 1f; cc.fadeDuration = 0.1f;
selBtn.colors = cc;
}
// 空槽提示
var emptyRt = GetOrCreateUIChild(slotGo.transform, "EmptyIndicator");
StretchFull(emptyRt);
GameObject emptyGo = emptyRt.gameObject;
GetOrCreateText(emptyGo.transform, "EmptyText", "Empty Slot · New Game", 34f,
var emptyText = GetOrCreateText(emptyGo.transform, "EmptyText", "Empty Slot · New Game", 34f,
new Color(0.7f, 0.66f, 0.55f, 0.85f), TextAlignmentOptions.Center);
BindLocalizedText(emptyText.gameObject, "SAVESLOT_EMPTY");
// 有档信息区(左侧竖排:区域 / 时长 / 时间)+ 右侧(灵珠 / 生命 / 钢魂)
var dataRt = GetOrCreateUIChild(slotGo.transform, "DataIndicator");
@@ -540,7 +584,8 @@ namespace BaseGames.Editor
var badgeRt = GetOrCreateUIChild(dataGo.transform, "SteelSoulBadge");
SetRect(badgeRt, new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(0f, 4f), new Vector2(120f, 40f));
GetOrAddComponent<Image>(badgeRt.gameObject).color = new Color(0.5f, 0.55f, 0.6f, 0.5f);
GetOrCreateText(badgeRt.transform, "BadgeText", "STEEL", 22f, new Color(0.85f,0.9f,1f,1f), TextAlignmentOptions.Center);
var badgeText = GetOrCreateText(badgeRt.transform, "BadgeText", "STEEL", 22f, new Color(0.85f,0.9f,1f,1f), TextAlignmentOptions.Center);
BindLocalizedText(badgeText.gameObject, "BADGE_STEELSOUL");
GameObject badgeGo = badgeRt.gameObject;
// 删除按钮(右上角小 ×
@@ -597,6 +642,8 @@ namespace BaseGames.Editor
yesGo.GetComponent<Image>().color = new Color(0.45f, 0.12f, 0.12f, 0.85f);
GameObject noGo = GetOrCreateButtonChild(boxRt.transform, "Btn_Cancel", "Cancel");
SetRect((RectTransform)noGo.transform, new Vector2(0.5f,0f), new Vector2(0.5f,0f), new Vector2(0.5f,0f), new Vector2(150f,40f), new Vector2(220f,64f));
BindLocalizedButton(yesGo, "CONFIRM_YES");
BindLocalizedButton(noGo, "CONFIRM_NO");
AssignReference(confirmCtrl, "_root", confirmGo);
AssignReference(confirmCtrl, "_titleText", titleTmp);
@@ -638,6 +685,10 @@ namespace BaseGames.Editor
GameObject backGo = GetOrCreateButtonChild(boxRt.transform, "Btn_Back", "Back");
SetRect((RectTransform)backGo.transform, new Vector2(0.5f,0f), new Vector2(0.5f,0f), new Vector2(0.5f,0f), new Vector2(0f,46f), new Vector2(260f,60f));
StyleAsTextButton(backGo, 28f);
BindLocalizedText(modeTitle.gameObject, "MODE_SELECT_TITLE");
BindLocalizedButton(normalGo, "MODE_NORMAL");
BindLocalizedButton(steelGo, "MODE_STEELSOUL");
BindLocalizedButton(backGo, "BTN_BACK");
AssignReference(modeCtrl, "_root", modeGo);
AssignReference(modeCtrl, "_btnNormal", normalGo.GetComponent<Button>());
@@ -1073,18 +1124,273 @@ namespace BaseGames.Editor
return t != null ? t.GetComponent<TextMeshProUGUI>() : null;
}
/// <summary>将按钮改造为"纯文字"风格(透明底,仅金色高亮),用于主菜单主按钮列表。</summary>
/// <summary>
/// 将按钮改造为"纯文字"风格(透明底)。关键:把 Button 的 targetGraphic 指向文字 Label
/// 这样鼠标悬停Highlighted与键盘/手柄选中Selected会直接给文字着色 → 导航有可见反馈。
/// Label 基色设为白,由 ColorBlock 决定静态/高亮/选中的可见色。
/// </summary>
private static void StyleAsTextButton(GameObject buttonGo, float fontSize = 34f)
{
var img = buttonGo.GetComponent<Image>();
if (img != null) img.color = new Color(1f, 1f, 1f, 0f); // 透明底,仍可作 raycast target
if (img != null) { img.color = new Color(1f, 1f, 1f, 0f); img.raycastTarget = true; } // 透明底作 raycast target
var label = GetButtonLabel(buttonGo);
if (label != null)
if (label == null) return;
label.fontSize = fontSize;
label.fontStyle = FontStyles.Normal;
label.color = Color.white; // 基色白,实际可见色由 ColorBlock × 该色得到
var btn = buttonGo.GetComponent<Button>();
if (btn == null) return;
btn.targetGraphic = label; // 文字作为着色目标 → 选中/悬停可见
var c = btn.colors;
c.normalColor = new Color(0.60f, 0.56f, 0.42f, 1f); // 静态:暗金
c.highlightedColor = new Color(1f, 0.95f, 0.72f, 1f); // 鼠标悬停:亮金
c.selectedColor = new Color(1f, 0.95f, 0.72f, 1f); // 键盘/手柄选中:亮金
c.pressedColor = new Color(1f, 0.82f, 0.38f, 1f);
c.disabledColor = new Color(0.4f, 0.4f, 0.4f, 0.5f);
c.colorMultiplier = 1f; c.fadeDuration = 0.1f;
btn.colors = c;
}
/// <summary>
/// 给含 TMP_Text 的节点挂上 <see cref="BaseGames.Localization.LocalizedText"/> 并绑定本地化 keyUI 表)。
/// 自动绑定 FontConfig语言切换时换 CJK 字体),并立即刷新编辑器预览(显示当前语言文本)。
/// </summary>
private static void BindLocalizedText(GameObject textGo, string key)
{
if (textGo == null || textGo.GetComponent<TMP_Text>() == null) return;
var loc = GetOrAddComponent<BaseGames.Localization.LocalizedText>(textGo);
var so = new SerializedObject(loc);
so.FindProperty("_key").stringValue = key;
var fontCfg = FindFirstAssetByType<BaseGames.Localization.LanguageFontConfigSO>("FontConfig");
if (fontCfg != null) so.FindProperty("_fontConfig").objectReferenceValue = fontCfg;
so.ApplyModifiedPropertiesWithoutUndo();
loc.UpdateEditorPreview();
}
/// <summary>给 GetOrCreateButtonChild 生成的按钮的 "Label" 子节点绑定本地化 key。</summary>
private static void BindLocalizedButton(GameObject buttonGo, string key)
{
var label = GetButtonLabel(buttonGo);
if (label != null) BindLocalizedText(label.gameObject, key);
}
// ─────────────────────────────────────────────────────────────────────
// 设置控件辅助(复用 Unity DefaultControls / TMP_DefaultControls 标准控件层级)
// ─────────────────────────────────────────────────────────────────────
private static UnityEngine.UI.DefaultControls.Resources _uiRes;
private static UnityEngine.UI.DefaultControls.Resources UIRes()
{
if (_uiRes.standard == null)
_uiRes = new UnityEngine.UI.DefaultControls.Resources
{
standard = AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/UISprite.psd"),
background = AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/Background.psd"),
inputField = AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/InputFieldBackground.psd"),
knob = AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/Knob.psd"),
checkmark = AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/Checkmark.psd"),
dropdown = AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/DropdownArrow.psd"),
mask = AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/UIMask.psd"),
};
return _uiRes;
}
private static TMPro.TMP_DefaultControls.Resources _tmpRes;
private static TMPro.TMP_DefaultControls.Resources TMPRes()
{
if (_tmpRes.standard == null)
_tmpRes = new TMPro.TMP_DefaultControls.Resources
{
standard = AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/UISprite.psd"),
background = AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/Background.psd"),
inputField = AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/InputFieldBackground.psd"),
knob = AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/Knob.psd"),
checkmark = AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/Checkmark.psd"),
dropdown = AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/DropdownArrow.psd"),
mask = AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/UIMask.psd"),
};
return _tmpRes;
}
/// <summary>将控件 RectTransform 放到行的右半区(横向拉伸、固定高度、垂直居中)。</summary>
private static void PlaceRightHalf(RectTransform rt, float height, float rightInset = 12f)
{
rt.anchorMin = new Vector2(0.5f, 0.5f);
rt.anchorMax = new Vector2(1f, 0.5f);
rt.pivot = new Vector2(0.5f, 0.5f);
rt.offsetMin = new Vector2(8f, -height / 2f);
rt.offsetMax = new Vector2(-rightInset, height / 2f);
}
/// <summary>创建/获取一行设置项(左侧本地化标签 + 右半区控件,由调用方填充),返回行 RectTransform。</summary>
private static RectTransform CreateSettingRow(Transform content, string name, string labelKey)
{
var row = GetOrCreateUIChild(content, name);
var le = GetOrAddComponent<LayoutElement>(row.gameObject);
le.preferredHeight = 56f; le.minHeight = 50f;
var label = GetOrCreateText(row, "Label", labelKey, 26f, GoldText, TextAlignmentOptions.MidlineLeft);
SetRect((RectTransform)label.transform, new Vector2(0f, 0f), new Vector2(0.5f, 1f), new Vector2(0f, 0.5f), new Vector2(12f, 0f), Vector2.zero);
((RectTransform)label.transform).offsetMin = new Vector2(12f, 0f);
((RectTransform)label.transform).offsetMax = new Vector2(0f, 0f);
BindLocalizedText(label.gameObject, labelKey);
return row;
}
private static Slider GetOrCreateSliderInRow(RectTransform row, float min, float max, float val, float rightInset = 12f)
{
var existing = row.Find("Slider");
GameObject go;
if (existing != null && existing.GetComponent<Slider>() != null) go = existing.gameObject;
else
{
label.fontSize = fontSize;
label.fontStyle = FontStyles.Normal;
label.color = GoldText;
if (existing != null) Undo.DestroyObjectImmediate(existing.gameObject);
go = UnityEngine.UI.DefaultControls.CreateSlider(UIRes());
go.name = "Slider";
Undo.RegisterCreatedObjectUndo(go, "Create Slider");
go.transform.SetParent(row, false);
}
PlaceRightHalf((RectTransform)go.transform, 20f, rightInset);
var s = go.GetComponent<Slider>();
s.minValue = min; s.maxValue = max; s.value = val;
return s;
}
private static Toggle GetOrCreateToggleInRow(RectTransform row)
{
var existing = row.Find("Toggle");
GameObject go;
if (existing != null && existing.GetComponent<Toggle>() != null) go = existing.gameObject;
else
{
if (existing != null) Undo.DestroyObjectImmediate(existing.gameObject);
go = UnityEngine.UI.DefaultControls.CreateToggle(UIRes());
go.name = "Toggle";
Undo.RegisterCreatedObjectUndo(go, "Create Toggle");
go.transform.SetParent(row, false);
}
// 隐藏勾选框自带的 Label设置项标签由行左侧统一提供
var lbl = go.transform.Find("Label");
if (lbl != null) lbl.gameObject.SetActive(false);
PlaceRightHalf((RectTransform)go.transform, 28f);
return go.GetComponent<Toggle>();
}
private static TMPro.TMP_Dropdown GetOrCreateDropdownInRow(RectTransform row, string[] options)
{
var existing = row.Find("Dropdown");
GameObject go;
if (existing != null && existing.GetComponent<TMPro.TMP_Dropdown>() != null) go = existing.gameObject;
else
{
if (existing != null) Undo.DestroyObjectImmediate(existing.gameObject);
go = TMPro.TMP_DefaultControls.CreateDropdown(TMPRes());
go.name = "Dropdown";
Undo.RegisterCreatedObjectUndo(go, "Create Dropdown");
go.transform.SetParent(row, false);
}
PlaceRightHalf((RectTransform)go.transform, 36f);
var dd = go.GetComponent<TMPro.TMP_Dropdown>();
dd.options.Clear();
foreach (var o in options) dd.options.Add(new TMPro.TMP_Dropdown.OptionData(o));
dd.value = 0;
dd.RefreshShownValue();
return dd;
}
// ─────────────────────────────────────────────────────────────────────
// 设置面板 / 制作团队面板构建器
// ─────────────────────────────────────────────────────────────────────
/// <summary>构建设置面板内容(音量 / 画面 / 可访问性 / 语言)并绑定 SettingsPanelController 全部字段。</summary>
private static void BuildSettingsPanel(GameObject panelGo, MainMenuController menuCtrl, List<string> report)
{
var ctrl = GetOrAddComponent<BaseGames.UI.SettingsPanelController>(panelGo);
GetOrCreateImage(panelGo.transform, "Overlay", new Color(0.04f, 0.05f, 0.08f, 0.97f), true).transform.SetAsFirstSibling();
var titleTmp = GetOrCreateText(panelGo.transform, "PanelTitle", "Settings", 56f, GoldText, TextAlignmentOptions.Center);
titleTmp.fontStyle = FontStyles.Bold;
SetRect((RectTransform)titleTmp.transform, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -70f), new Vector2(900f, 80f));
BindLocalizedText(titleTmp.gameObject, "SETTINGS_TITLE");
var content = GetOrCreateUIChild(panelGo.transform, "Content");
SetRect(content, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, -10f), new Vector2(860f, 720f));
var vlg = GetOrAddComponent<VerticalLayoutGroup>(content.gameObject);
vlg.spacing = 8f; vlg.childAlignment = TextAnchor.UpperCenter;
vlg.childControlWidth = true; vlg.childControlHeight = true;
vlg.childForceExpandWidth = true; vlg.childForceExpandHeight = false;
// 音量
var rMaster = CreateSettingRow(content, "Row_Master", "SETTINGS_MASTER_VOLUME");
var rBgm = CreateSettingRow(content, "Row_BGM", "SETTINGS_BGM_VOLUME");
var rSfx = CreateSettingRow(content, "Row_SFX", "SETTINGS_SFX_VOLUME");
var rAmbient = CreateSettingRow(content, "Row_Ambient", "SETTINGS_AMBIENT_VOLUME");
var sMaster = GetOrCreateSliderInRow(rMaster, 0f, 1f, 1f);
var sBgm = GetOrCreateSliderInRow(rBgm, 0f, 1f, 0.8f);
var sSfx = GetOrCreateSliderInRow(rSfx, 0f, 1f, 1f);
var sAmbient = GetOrCreateSliderInRow(rAmbient, 0f, 1f, 0.8f);
// 画面
var rVsync = CreateSettingRow(content, "Row_VSync", "SETTINGS_VSYNC");
var tVsync = GetOrCreateToggleInRow(rVsync);
var rFps = CreateSettingRow(content, "Row_FPS", "SETTINGS_FPS");
var dFps = GetOrCreateDropdownInRow(rFps, new[] { "30", "60", "120", "∞" });
// 可访问性
var rUiScale = CreateSettingRow(content, "Row_UIScale", "SETTINGS_UI_SCALE");
var sUiScale = GetOrCreateSliderInRow(rUiScale, 0.8f, 1.5f, 1f, 76f);
var uiScaleVal = GetOrCreateText(rUiScale, "ValueText", "100%", 22f, new Color(0.8f,0.78f,0.7f,1f), TextAlignmentOptions.MidlineRight);
SetRect((RectTransform)uiScaleVal.transform, new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), new Vector2(-12f, 0f), new Vector2(64f, 36f));
var rColorblind = CreateSettingRow(content, "Row_Colorblind", "SETTINGS_COLORBLIND");
var dColorblind = GetOrCreateDropdownInRow(rColorblind, new[] { "关闭", "红色盲", "绿色盲", "蓝黄色盲" });
var rShake = CreateSettingRow(content, "Row_ScreenShake", "SETTINGS_SCREEN_SHAKE");
var tShake = GetOrCreateToggleInRow(rShake);
// 语言
var rLang = CreateSettingRow(content, "Row_Language", "SETTINGS_LANGUAGE");
var dLang = GetOrCreateDropdownInRow(rLang, new[] { "中文", "English", "日本語", "한국어" });
// 绑定 SettingsPanelController 字段
AssignReference(ctrl, "_masterVolume", sMaster);
AssignReference(ctrl, "_bgmVolume", sBgm);
AssignReference(ctrl, "_sfxVolume", sSfx);
AssignReference(ctrl, "_ambientVolume", sAmbient);
AssignReference(ctrl, "_vSyncToggle", tVsync);
AssignReference(ctrl, "_fpsDropdown", dFps);
AssignReference(ctrl, "_uiScaleSlider", sUiScale);
AssignReference(ctrl, "_uiScaleValueText", uiScaleVal);
AssignReference(ctrl, "_colorblindDropdown", dColorblind);
AssignReference(ctrl, "_screenShakeToggle", tShake);
AssignReference(ctrl, "_languageDropdown", dLang);
// 返回按钮
GameObject backGo = GetOrCreateButtonChild(panelGo.transform, "BackButton", "Back");
SetRect((RectTransform)backGo.transform, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 60f), new Vector2(260f, 64f));
StyleAsTextButton(backGo, 30f);
BindLocalizedButton(backGo, "BTN_BACK");
AssignReference(menuCtrl, "_btnCloseSettings", backGo.GetComponent<Button>());
}
/// <summary>构建制作团队面板(标题 + 滚动正文 + 返回)。</summary>
private static void BuildCreditsPanel(GameObject panelGo, MainMenuController menuCtrl, List<string> report)
{
GetOrCreateImage(panelGo.transform, "Overlay", new Color(0.04f, 0.05f, 0.08f, 0.97f), true).transform.SetAsFirstSibling();
var titleTmp = GetOrCreateText(panelGo.transform, "PanelTitle", "Credits", 56f, GoldText, TextAlignmentOptions.Center);
titleTmp.fontStyle = FontStyles.Bold;
SetRect((RectTransform)titleTmp.transform, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -70f), new Vector2(900f, 80f));
BindLocalizedText(titleTmp.gameObject, "CREDITS_TITLE");
var body = GetOrCreateText(panelGo.transform, "Body", "Credits", 30f, new Color(0.82f, 0.8f, 0.74f, 1f), TextAlignmentOptions.Top);
SetRect((RectTransform)body.transform, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, 0f), new Vector2(900f, 600f));
BindLocalizedText(body.gameObject, "CREDITS_BODY");
GameObject backGo = GetOrCreateButtonChild(panelGo.transform, "BackButton", "Back");
SetRect((RectTransform)backGo.transform, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 60f), new Vector2(260f, 64f));
StyleAsTextButton(backGo, 30f);
BindLocalizedButton(backGo, "BTN_BACK");
AssignReference(menuCtrl, "_btnCloseCredits", backGo.GetComponent<Button>());
}
private static void AssignReference(Object target, string propertyName, Object value)

View File

@@ -0,0 +1,759 @@
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.UI;
using BaseGames.UI.Settings;
using BaseGames.UI.MainMenu;
using BaseGames.UI.Theme;
using BaseGames.Localization;
using BaseGames.Editor.Localization;
namespace BaseGames.Editor.UI
{
/// <summary>
/// UI 通用控件库脚手架。
///
/// 一键生成 / 更新 themed 控件预制件UIButton / UISelectableRow / UISlider / UIDropdown /
/// UISimplePanel / UITabGroup并提供"向选中节点放置控件"菜单,使策划可拖拽即用。
///
/// 预制件输出目录Assets/_Game/Prefabs/UI/Controls/(命名前缀 UI_符合 AssetFolderSpec
/// 构建方式复用 Unity/TMP 的 DefaultControls 标准层级,再挂上本项目的封装组件与 UIThemeRole 标记。
///
/// 菜单BaseGames/UI/控件库/...
/// </summary>
public static class UIControlLibraryScaffold
{
private const string ControlsDir = "Assets/_Game/Prefabs/UI/Controls";
private const string ThemeDir = "Assets/_Game/Data/UI/Themes";
private const string ThemeName = "UI_Theme_Default";
// 生成期间的默认主题GenerateAll 开头确保存在;各 Build 方法读取)
private static UIThemeSO s_theme;
// 预制件文件名
private const string PfButton = "UI_Control_Button";
private const string PfRow = "UI_Control_SelectableRow";
private const string PfSlider = "UI_Control_Slider";
private const string PfDropdown = "UI_Control_Dropdown";
private const string PfPanel = "UI_Control_Panel";
private const string PfTabBar = "UI_Control_TabBar";
// ── 生成 ─────────────────────────────────────────────────────────────
[MenuItem("BaseGames/UI/控件库/生成或更新控件预制件")]
public static void GenerateAll()
{
EnsureFolder(ControlsDir);
var report = new List<string>();
s_theme = EnsureDefaultTheme(report);
BuildButton(report);
BuildSelectableRow(report);
BuildSlider(report);
BuildDropdown(report);
BuildPanel(report);
BuildTabBar(report);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
var sb = new System.Text.StringBuilder("[UIControlLibrary] 控件预制件已生成/更新:\n");
foreach (var r in report) sb.AppendLine(" • " + r);
sb.AppendLine($"目录:{ControlsDir}/(占位配色,可挂 UIThemeApplier + UIThemeSO 统一主题)");
Debug.Log(sb.ToString());
}
// ── 放置 ─────────────────────────────────────────────────────────────
[MenuItem("BaseGames/UI/控件库/向选中节点放置 ▸ Button")]
private static void PlaceButton() => Place(PfButton);
[MenuItem("BaseGames/UI/控件库/向选中节点放置 ▸ SelectableRow")]
private static void PlaceRow() => Place(PfRow);
[MenuItem("BaseGames/UI/控件库/向选中节点放置 ▸ Slider")]
private static void PlaceSlider() => Place(PfSlider);
[MenuItem("BaseGames/UI/控件库/向选中节点放置 ▸ Dropdown")]
private static void PlaceDropdown() => Place(PfDropdown);
[MenuItem("BaseGames/UI/控件库/向选中节点放置 ▸ Panel")]
private static void PlacePanel() => Place(PfPanel);
[MenuItem("BaseGames/UI/控件库/向选中节点放置 ▸ TabBar")]
private static void PlaceTabBar() => Place(PfTabBar);
private static void Place(string prefabName)
{
string path = $"{ControlsDir}/{prefabName}.prefab";
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (prefab == null)
{
EditorUtility.DisplayDialog("控件库",
$"未找到预制件:{path}\n请先执行「生成或更新控件预制件」。", "确定");
return;
}
// 父节点:当前选中的 Transform否则活动场景中的第一个 Canvas
Transform parent = Selection.activeTransform;
if (parent == null)
{
var canvas = Object.FindObjectOfType<Canvas>();
parent = canvas != null ? canvas.transform : null;
}
var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab, parent);
if (instance == null) return;
Undo.RegisterCreatedObjectUndo(instance, $"Place {prefabName}");
Selection.activeGameObject = instance;
EditorGUIUtility.PingObject(instance);
}
// ── 默认主题 ─────────────────────────────────────────────────────────
private static UIThemeSO EnsureDefaultTheme(List<string> report)
{
EnsureFolder(ThemeDir);
string path = $"{ThemeDir}/{ThemeName}.asset";
var theme = AssetDatabase.LoadAssetAtPath<UIThemeSO>(path);
if (theme == null)
{
theme = ScriptableObject.CreateInstance<UIThemeSO>(); // 字段含默认配色
AssetDatabase.CreateAsset(theme, path);
report.Add($"{path}(默认主题,可调色板/字体)");
}
return theme;
}
// ── 各控件构建 ───────────────────────────────────────────────────────
private static void BuildButton(List<string> report)
{
var go = TMP_DefaultControls.CreateButton(TmpResources());
go.name = PfButton;
Size(go, 200, 48);
var uiBtn = GetOrAdd<UIButton>(go);
if (s_theme != null) AssignRef(uiBtn, "_theme", s_theme);
SetEnum(GetOrAdd<UIThemeRole>(go), "_kind", (int)UIThemeRoleKind.Button);
var label = go.GetComponentInChildren<TMP_Text>();
if (label != null)
{
label.text = "Button";
SetEnum(GetOrAdd<UIThemeRole>(label.gameObject), "_kind", (int)UIThemeRoleKind.Text_Primary);
}
SaveAsPrefab(go, PfButton, report);
}
private static void BuildSelectableRow(List<string> report)
{
var go = NewUI(PfRow, 320, 48);
var bg = go.AddComponent<Image>();
bg.sprite = Standard(); bg.type = Image.Type.Sliced;
bg.color = new Color(1f, 1f, 1f, 0.06f);
var btn = go.AddComponent<Button>();
btn.targetGraphic = bg;
var row = go.AddComponent<UISelectableRow>();
// 选中高亮(铺底,置于内容之下,默认隐藏)
var highlight = NewUIChild(go.transform, "SelectedHighlight", out var hlRt);
Stretch(hlRt);
var hlImg = highlight.AddComponent<Image>();
hlImg.color = new Color(0.20f, 0.65f, 1f, 0.35f);
SetEnum(highlight.AddComponent<UIThemeRole>(), "_kind", (int)UIThemeRoleKind.Graphic_Accent);
highlight.SetActive(false);
// 图标
var iconGo = NewUIChild(go.transform, "Icon", out var iconRt);
iconRt.anchorMin = new Vector2(0, 0.5f); iconRt.anchorMax = new Vector2(0, 0.5f);
iconRt.pivot = new Vector2(0, 0.5f); iconRt.anchoredPosition = new Vector2(10, 0);
iconRt.sizeDelta = new Vector2(32, 32);
var icon = iconGo.AddComponent<Image>(); icon.enabled = false;
// 标签
var labelGo = NewUIChild(go.transform, "Label", out var labelRt);
labelRt.anchorMin = new Vector2(0, 0); labelRt.anchorMax = new Vector2(1, 1);
labelRt.offsetMin = new Vector2(52, 0); labelRt.offsetMax = new Vector2(-12, 0);
var label = labelGo.AddComponent<TextMeshProUGUI>();
label.text = "Row"; label.alignment = TextAlignmentOptions.MidlineLeft; label.fontSize = 20;
SetEnum(labelGo.AddComponent<UIThemeRole>(), "_kind", (int)UIThemeRoleKind.Text_Primary);
AssignRef(row, "_button", btn);
AssignRef(row, "_label", label);
AssignRef(row, "_icon", icon);
AssignRef(row, "_selectedHighlight", highlight);
SaveAsPrefab(go, PfRow, report);
}
private static void BuildSlider(List<string> report)
{
var go = DefaultControls.CreateSlider(UiResources());
go.name = PfSlider;
Size(go, 240, 24);
var slider = go.GetComponent<Slider>();
var ui = GetOrAdd<UISlider>(go);
// 数值标签(右侧)
var labelGo = NewUIChild(go.transform, "ValueLabel", out var labelRt);
labelRt.anchorMin = new Vector2(1, 0.5f); labelRt.anchorMax = new Vector2(1, 0.5f);
labelRt.pivot = new Vector2(0, 0.5f); labelRt.anchoredPosition = new Vector2(8, 0);
labelRt.sizeDelta = new Vector2(48, 24);
var label = labelGo.AddComponent<TextMeshProUGUI>();
label.text = "0"; label.alignment = TextAlignmentOptions.MidlineLeft; label.fontSize = 18;
SetEnum(labelGo.AddComponent<UIThemeRole>(), "_kind", (int)UIThemeRoleKind.Text_Secondary);
AssignRef(ui, "_slider", slider);
AssignRef(ui, "_valueLabel", label);
SaveAsPrefab(go, PfSlider, report);
}
private static void BuildDropdown(List<string> report)
{
var go = TMP_DefaultControls.CreateDropdown(TmpResources());
go.name = PfDropdown;
Size(go, 200, 40);
var dd = go.GetComponent<TMP_Dropdown>();
var ui = GetOrAdd<UIDropdown>(go);
AssignRef(ui, "_dropdown", dd);
SaveAsPrefab(go, PfDropdown, report);
}
private static void BuildPanel(List<string> report)
{
var go = NewUI(PfPanel, 480, 320);
var bg = go.AddComponent<Image>();
bg.sprite = Background(); bg.type = Image.Type.Sliced;
bg.color = new Color(0.06f, 0.07f, 0.10f, 0.96f);
SetEnum(go.AddComponent<UIThemeRole>(), "_kind", (int)UIThemeRoleKind.Graphic_Background);
go.AddComponent<CanvasGroup>();
var applier = go.AddComponent<UIThemeApplier>();
if (s_theme != null) AssignRef(applier, "_theme", s_theme);
var panel = go.AddComponent<UISimplePanel>();
AssignRef(panel, "_canvasGroup", go.GetComponent<CanvasGroup>());
// 标题
var titleGo = NewUIChild(go.transform, "Title", out var titleRt);
titleRt.anchorMin = new Vector2(0, 1); titleRt.anchorMax = new Vector2(1, 1);
titleRt.pivot = new Vector2(0.5f, 1); titleRt.anchoredPosition = new Vector2(0, -16);
titleRt.sizeDelta = new Vector2(-32, 40);
var title = titleGo.AddComponent<TextMeshProUGUI>();
title.text = "Panel"; title.alignment = TextAlignmentOptions.Top; title.fontSize = 28;
SetEnum(titleGo.AddComponent<UIThemeRole>(), "_kind", (int)UIThemeRoleKind.Text_Header);
SaveAsPrefab(go, PfPanel, report);
}
private static void BuildTabBar(List<string> report)
{
var go = NewUI(PfTabBar, 480, 360);
var tabGroup = go.AddComponent<UITabGroup>();
// 头部按钮行
var headerGo = NewUIChild(go.transform, "Header", out var headerRt);
headerRt.anchorMin = new Vector2(0, 1); headerRt.anchorMax = new Vector2(1, 1);
headerRt.pivot = new Vector2(0.5f, 1); headerRt.anchoredPosition = Vector2.zero;
headerRt.sizeDelta = new Vector2(0, 44);
var hLayout = headerGo.AddComponent<HorizontalLayoutGroup>();
hLayout.spacing = 4; hLayout.childForceExpandWidth = true; hLayout.childForceExpandHeight = true;
// 内容容器
var contentGo = NewUIChild(go.transform, "Content", out var contentRt);
contentRt.anchorMin = Vector2.zero; contentRt.anchorMax = Vector2.one;
contentRt.offsetMin = new Vector2(0, 0); contentRt.offsetMax = new Vector2(0, -48);
var tabs = new (GameObject content, Button btn, GameObject hl)[2];
for (int i = 0; i < 2; i++)
{
var (btn, hl) = MakeTabHeader(headerGo.transform, $"Tab{i}Header", $"Tab {i + 1}");
var tabContent = NewUIChild(contentGo.transform, $"Tab{i}Content", out var tcRt);
Stretch(tcRt);
var tcImg = tabContent.AddComponent<Image>();
tcImg.color = new Color(1f, 1f, 1f, 0.03f);
var lblGo = NewUIChild(tabContent.transform, "Label", out var lblRt);
Stretch(lblRt);
var lbl = lblGo.AddComponent<TextMeshProUGUI>();
lbl.text = $"Tab {i + 1} 内容"; lbl.alignment = TextAlignmentOptions.Center; lbl.fontSize = 22;
if (i != 0) tabContent.SetActive(false);
tabs[i] = (tabContent, btn, hl);
}
WireTabGroup(tabGroup, tabs);
SaveAsPrefab(go, PfTabBar, report);
}
private static (Button btn, GameObject highlight) MakeTabHeader(Transform parent, string name, string text)
{
var go = NewUIChild(parent, name, out _);
var bg = go.AddComponent<Image>();
bg.sprite = Standard(); bg.type = Image.Type.Sliced;
bg.color = new Color(1f, 1f, 1f, 0.08f);
var btn = go.AddComponent<Button>(); btn.targetGraphic = bg;
var uiBtn = GetOrAdd<UIButton>(go);
if (s_theme != null) AssignRef(uiBtn, "_theme", s_theme);
var hl = NewUIChild(go.transform, "Highlight", out var hlRt);
Stretch(hlRt);
var hlImg = hl.AddComponent<Image>();
hlImg.color = new Color(0.20f, 0.65f, 1f, 0.30f);
hl.SetActive(false);
var lblGo = NewUIChild(go.transform, "Label", out var lblRt);
Stretch(lblRt);
var lbl = lblGo.AddComponent<TextMeshProUGUI>();
lbl.text = text; lbl.alignment = TextAlignmentOptions.Center; lbl.fontSize = 20;
SetEnum(lblGo.AddComponent<UIThemeRole>(), "_kind", (int)UIThemeRoleKind.Text_Primary);
return (btn, hl);
}
private static void WireTabGroup(UITabGroup tabGroup, (GameObject content, Button btn, GameObject hl)[] tabs)
{
var so = new SerializedObject(tabGroup);
var prop = so.FindProperty("_tabs");
prop.arraySize = tabs.Length;
for (int i = 0; i < tabs.Length; i++)
{
var el = prop.GetArrayElementAtIndex(i);
el.FindPropertyRelative("content").objectReferenceValue = tabs[i].content;
el.FindPropertyRelative("headerButton").objectReferenceValue = tabs[i].btn;
el.FindPropertyRelative("headerHighlight").objectReferenceValue = tabs[i].hl;
}
so.ApplyModifiedPropertiesWithoutUndo();
}
// ══ 设置面板(数据驱动)════════════════════════════════════════════════
private const string SchemaDir = "Assets/_Game/Data/UI";
private const string SchemaName = "UI_SettingsSchema";
private const string PfSettingHeader = "UI_Setting_Header";
private const string PfSettingSlider = "UI_Setting_SliderRow";
private const string PfSettingToggle = "UI_Setting_ToggleRow";
private const string PfSettingDrop = "UI_Setting_DropdownRow";
private const string PfSettingsPanel = "UI_SettingsPanel";
[MenuItem("BaseGames/UI/控件库/生成设置面板(行预制件 + 默认表 + 面板)")]
public static void GenerateSettings()
{
EnsureFolder(ControlsDir);
EnsureFolder(SchemaDir);
var report = new List<string>();
s_theme = EnsureDefaultTheme(report);
var header = BuildSettingHeader(report);
var slider = BuildSettingSliderRow(report);
var toggle = BuildSettingToggleRow(report);
var dropdown = BuildSettingDropdownRow(report);
var schema = EnsureDefaultSchema(report);
BuildSettingsPanel(report, header, slider, toggle, dropdown, schema);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
var sb = new System.Text.StringBuilder("[UIControlLibrary] 数据驱动设置面板已生成:\n");
foreach (var r in report) sb.AppendLine(" • " + r);
sb.AppendLine("标签 KeySET_*请用「BaseGames/Localization/表格编辑器」补译文;改 UI_SettingsSchema 即可增删/重排设置项。");
Debug.Log(sb.ToString());
}
private static GameObject BuildSettingHeader(List<string> report)
{
var go = NewUI(PfSettingHeader, 480, 32);
var lblGo = NewUIChild(go.transform, "Label", out var rt);
Stretch(rt);
var lbl = lblGo.AddComponent<TextMeshProUGUI>();
lbl.text = "Section"; lbl.fontSize = 22; lbl.alignment = TextAlignmentOptions.MidlineLeft;
lblGo.AddComponent<BaseGames.Localization.LocalizedText>();
SetEnum(lblGo.AddComponent<UIThemeRole>(), "_kind", (int)UIThemeRoleKind.Text_Header);
return SaveAsPrefab(go, PfSettingHeader, report);
}
private static GameObject BuildSettingSliderRow(List<string> report)
{
var go = SettingRowRoot(PfSettingSlider, out _);
var sliderGo = DefaultControls.CreateSlider(UiResources());
sliderGo.name = "Slider"; sliderGo.transform.SetParent(go.transform, false);
var sle = sliderGo.AddComponent<LayoutElement>(); sle.flexibleWidth = 1; sle.preferredHeight = 20;
var slider = sliderGo.GetComponent<Slider>();
var uiSlider = sliderGo.AddComponent<UISlider>();
var valGo = NewUIChild(go.transform, "Value", out _);
var val = valGo.AddComponent<TextMeshProUGUI>();
val.text = "0"; val.fontSize = 18; val.alignment = TextAlignmentOptions.MidlineRight;
var vle = valGo.AddComponent<LayoutElement>(); vle.preferredWidth = 56;
SetEnum(valGo.AddComponent<UIThemeRole>(), "_kind", (int)UIThemeRoleKind.Text_Secondary);
AssignRef(uiSlider, "_slider", slider);
AssignRef(uiSlider, "_valueLabel", val);
return SaveAsPrefab(go, PfSettingSlider, report);
}
private static GameObject BuildSettingToggleRow(List<string> report)
{
var go = SettingRowRoot(PfSettingToggle, out _);
var toggleGo = DefaultControls.CreateToggle(UiResources());
toggleGo.name = "Toggle"; toggleGo.transform.SetParent(go.transform, false);
var tle = toggleGo.AddComponent<LayoutElement>(); tle.preferredWidth = 30; tle.preferredHeight = 30;
return SaveAsPrefab(go, PfSettingToggle, report);
}
private static GameObject BuildSettingDropdownRow(List<string> report)
{
var go = SettingRowRoot(PfSettingDrop, out _);
var ddGo = TMP_DefaultControls.CreateDropdown(TmpResources());
ddGo.name = "Dropdown"; ddGo.transform.SetParent(go.transform, false);
var dle = ddGo.AddComponent<LayoutElement>(); dle.flexibleWidth = 1; dle.preferredHeight = 32;
var dd = ddGo.GetComponent<TMP_Dropdown>();
var ui = ddGo.AddComponent<UIDropdown>();
AssignRef(ui, "_dropdown", dd);
return SaveAsPrefab(go, PfSettingDrop, report);
}
/// <summary>构建带左侧本地化标签的设置行根HorizontalLayout。返回根out 标签 TMP。</summary>
private static GameObject SettingRowRoot(string name, out TMP_Text label)
{
var go = NewUI(name, 480, 44);
var h = go.AddComponent<HorizontalLayoutGroup>();
h.spacing = 12; h.childAlignment = TextAnchor.MiddleLeft;
h.childForceExpandWidth = false; h.childForceExpandHeight = false;
h.childControlWidth = true; h.childControlHeight = true;
h.padding = new RectOffset(8, 8, 4, 4);
var lblGo = NewUIChild(go.transform, "Label", out _);
label = lblGo.AddComponent<TextMeshProUGUI>();
label.text = "Label"; label.fontSize = 20; label.alignment = TextAlignmentOptions.MidlineLeft;
var le = lblGo.AddComponent<LayoutElement>(); le.preferredWidth = 200; le.flexibleWidth = 0;
lblGo.AddComponent<BaseGames.Localization.LocalizedText>();
SetEnum(lblGo.AddComponent<UIThemeRole>(), "_kind", (int)UIThemeRoleKind.Text_Primary);
return go;
}
private static SettingsSchemaSO EnsureDefaultSchema(List<string> report)
{
string path = $"{SchemaDir}/{SchemaName}.asset";
var schema = AssetDatabase.LoadAssetAtPath<SettingsSchemaSO>(path);
bool created = schema == null;
if (created) { schema = ScriptableObject.CreateInstance<SettingsSchemaSO>(); AssetDatabase.CreateAsset(schema, path); }
// 仅在新建时填充默认项,避免覆盖策划已有编辑
if (created)
{
var items = new (bool h, string key, SettingKey s)[]
{
(true, "SET_SECTION_AUDIO", default),
(false, "SET_MASTER_VOLUME", SettingKey.MasterVolume),
(false, "SET_BGM_VOLUME", SettingKey.BGMVolume),
(false, "SET_SFX_VOLUME", SettingKey.SFXVolume),
(false, "SET_AMBIENT_VOLUME", SettingKey.AmbientVolume),
(true, "SET_SECTION_DISPLAY", default),
(false, "SET_VSYNC", SettingKey.VSync),
(false, "SET_TARGET_FPS", SettingKey.TargetFPS),
(true, "SET_SECTION_ACCESS", default),
(false, "SET_UI_SCALE", SettingKey.UIScale),
(false, "SET_COLORBLIND", SettingKey.ColorblindMode),
(false, "SET_SCREEN_SHAKE", SettingKey.ScreenShake),
(true, "SET_SECTION_LANGUAGE", default),
(false, "SET_LANGUAGE", SettingKey.Language),
};
var so = new SerializedObject(schema);
var prop = so.FindProperty("_items");
prop.arraySize = items.Length;
for (int i = 0; i < items.Length; i++)
{
var el = prop.GetArrayElementAtIndex(i);
el.FindPropertyRelative("isHeader").boolValue = items[i].h;
el.FindPropertyRelative("labelKey").stringValue = items[i].key;
el.FindPropertyRelative("key").enumValueIndex = (int)items[i].s;
}
so.ApplyModifiedPropertiesWithoutUndo();
report.Add($"{path}(默认 {items.Length} 项,可增删/重排)");
}
return schema;
}
private static void BuildSettingsPanel(List<string> report, GameObject header, GameObject slider,
GameObject toggle, GameObject dropdown, SettingsSchemaSO schema)
{
var go = NewUI(PfSettingsPanel, 540, 640);
var bg = go.AddComponent<Image>();
bg.sprite = Background(); bg.type = Image.Type.Sliced;
bg.color = new Color(0.06f, 0.07f, 0.10f, 0.96f);
SetEnum(go.AddComponent<UIThemeRole>(), "_kind", (int)UIThemeRoleKind.Graphic_Background);
var applier = go.AddComponent<UIThemeApplier>();
if (s_theme != null) AssignRef(applier, "_theme", s_theme);
var panel = go.AddComponent<DataDrivenSettingsPanel>();
// 行容器(竖向布局)
var content = NewUIChild(go.transform, "Content", out var crt);
crt.anchorMin = Vector2.zero; crt.anchorMax = Vector2.one;
crt.offsetMin = new Vector2(16, 16); crt.offsetMax = new Vector2(-16, -16);
var v = content.AddComponent<VerticalLayoutGroup>();
v.spacing = 6; v.childForceExpandWidth = true; v.childForceExpandHeight = false;
v.childControlWidth = true; v.childControlHeight = true;
v.childAlignment = TextAnchor.UpperCenter;
AssignRef(panel, "_schema", schema);
AssignRef(panel, "_container", content.transform);
AssignRef(panel, "_headerPrefab", header);
AssignRef(panel, "_sliderRowPrefab", slider);
AssignRef(panel, "_toggleRowPrefab", toggle);
AssignRef(panel, "_dropdownRowPrefab", dropdown);
SaveAsPrefab(go, PfSettingsPanel, report);
}
// ══ 主菜单(数据驱动)════════════════════════════════════════════════
private const string PfMenuButton = "UI_MainMenu_Button";
private const string MenuConfigName = "UI_MainMenuConfig";
[MenuItem("BaseGames/UI/控件库/生成主菜单(按钮预制件 + 默认表)")]
public static void GenerateMainMenu()
{
EnsureFolder(ControlsDir);
EnsureFolder(SchemaDir);
var report = new List<string>();
s_theme = EnsureDefaultTheme(report);
BuildMainMenuButton(report);
EnsureDefaultMenuConfig(report);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
var sb = new System.Text.StringBuilder("[UIControlLibrary] 数据驱动主菜单已生成:\n");
foreach (var r in report) sb.AppendLine(" • " + r);
sb.AppendLine("标签 MENU_* 请补译文;改 UI_MainMenuConfig 即可增删/重排菜单项。");
sb.AppendLine("用法:在 Scene_MainMenu 挂 DataDrivenMainMenuController指定 config/container/buttonPrefab + 子面板引用。");
Debug.Log(sb.ToString());
}
private static GameObject BuildMainMenuButton(List<string> report)
{
var go = NewUI(PfMenuButton, 300, 56);
var bg = go.AddComponent<Image>();
bg.sprite = Standard(); bg.type = Image.Type.Sliced; bg.color = new Color(1f, 1f, 1f, 0.06f);
var btn = go.AddComponent<Button>(); btn.targetGraphic = bg;
var uiBtn = GetOrAdd<UIButton>(go);
if (s_theme != null) AssignRef(uiBtn, "_theme", s_theme);
var view = go.AddComponent<MainMenuButtonView>();
var iconGo = NewUIChild(go.transform, "Icon", out var irt);
irt.anchorMin = new Vector2(0, 0.5f); irt.anchorMax = new Vector2(0, 0.5f); irt.pivot = new Vector2(0, 0.5f);
irt.anchoredPosition = new Vector2(14, 0); irt.sizeDelta = new Vector2(32, 32);
var icon = iconGo.AddComponent<Image>(); icon.enabled = false;
var lblGo = NewUIChild(go.transform, "Label", out var lrt);
lrt.anchorMin = Vector2.zero; lrt.anchorMax = Vector2.one;
lrt.offsetMin = new Vector2(56, 0); lrt.offsetMax = new Vector2(-12, 0);
var lbl = lblGo.AddComponent<TextMeshProUGUI>();
lbl.text = "Menu Item"; lbl.alignment = TextAlignmentOptions.MidlineLeft; lbl.fontSize = 24;
var loc = lblGo.AddComponent<BaseGames.Localization.LocalizedText>();
SetEnum(lblGo.AddComponent<UIThemeRole>(), "_kind", (int)UIThemeRoleKind.Text_Primary);
AssignRef(view, "_button", btn);
AssignRef(view, "_label", loc);
AssignRef(view, "_icon", icon);
return SaveAsPrefab(go, PfMenuButton, report);
}
private static MainMenuConfigSO EnsureDefaultMenuConfig(List<string> report)
{
string path = $"{SchemaDir}/{MenuConfigName}.asset";
var cfg = AssetDatabase.LoadAssetAtPath<MainMenuConfigSO>(path);
bool created = cfg == null;
if (created) { cfg = ScriptableObject.CreateInstance<MainMenuConfigSO>(); AssetDatabase.CreateAsset(cfg, path); }
if (created)
{
var items = new (string key, MainMenuAction a, bool req)[]
{
("MENU_NEW_GAME", MainMenuAction.NewGame, false),
("MENU_CONTINUE", MainMenuAction.Continue, true),
("MENU_SETTINGS", MainMenuAction.OpenSettings, false),
("MENU_CREDITS", MainMenuAction.OpenCredits, false),
("MENU_QUIT", MainMenuAction.Quit, false),
};
var so = new SerializedObject(cfg);
var prop = so.FindProperty("_items");
prop.arraySize = items.Length;
for (int i = 0; i < items.Length; i++)
{
var el = prop.GetArrayElementAtIndex(i);
el.FindPropertyRelative("labelKey").stringValue = items[i].key;
el.FindPropertyRelative("action").enumValueIndex = (int)items[i].a;
el.FindPropertyRelative("requiresSave").boolValue = items[i].req;
el.FindPropertyRelative("sceneKey").stringValue = "";
}
so.ApplyModifiedPropertiesWithoutUndo();
report.Add($"{path}(默认 {items.Length} 项菜单)");
}
return cfg;
}
// ══ 本地化补全(数据驱动面板的 SET_*/MENU_* 标签)══════════════════════
[MenuItem("BaseGames/UI/控件库/补充设置与菜单本地化(中/英)")]
public static void SeedDataDrivenUILocalization()
{
var zh = new Dictionary<string, string>
{
{ "SET_SECTION_AUDIO", "音频" },
{ "SET_MASTER_VOLUME", "主音量" },
{ "SET_BGM_VOLUME", "音乐" },
{ "SET_SFX_VOLUME", "音效" },
{ "SET_AMBIENT_VOLUME", "环境音" },
{ "SET_SECTION_DISPLAY", "画面" },
{ "SET_VSYNC", "垂直同步" },
{ "SET_TARGET_FPS", "目标帧率" },
{ "SET_SECTION_ACCESS", "辅助功能" },
{ "SET_UI_SCALE", "界面缩放" },
{ "SET_COLORBLIND", "色盲模式" },
{ "SET_SCREEN_SHAKE", "屏幕震动" },
{ "SET_SECTION_LANGUAGE", "语言" },
{ "SET_LANGUAGE", "语言" },
{ "SET_FPS_UNLIMITED", "无限" },
{ "SET_COLORBLIND_0", "关闭" },
{ "SET_COLORBLIND_1", "红色弱Protanopia" },
{ "SET_COLORBLIND_2", "绿色弱Deuteranopia" },
{ "SET_COLORBLIND_3", "蓝色弱Tritanopia" },
{ "MENU_NEW_GAME", "新游戏" },
{ "MENU_CONTINUE", "继续" },
{ "MENU_SETTINGS", "设置" },
{ "MENU_CREDITS", "制作团队" },
{ "MENU_QUIT", "退出" },
};
var en = new Dictionary<string, string>
{
{ "SET_SECTION_AUDIO", "Audio" },
{ "SET_MASTER_VOLUME", "Master Volume" },
{ "SET_BGM_VOLUME", "Music" },
{ "SET_SFX_VOLUME", "Sound Effects" },
{ "SET_AMBIENT_VOLUME", "Ambience" },
{ "SET_SECTION_DISPLAY", "Display" },
{ "SET_VSYNC", "V-Sync" },
{ "SET_TARGET_FPS", "Target FPS" },
{ "SET_SECTION_ACCESS", "Accessibility" },
{ "SET_UI_SCALE", "UI Scale" },
{ "SET_COLORBLIND", "Colorblind Mode" },
{ "SET_SCREEN_SHAKE", "Screen Shake" },
{ "SET_SECTION_LANGUAGE", "Language" },
{ "SET_LANGUAGE", "Language" },
{ "SET_FPS_UNLIMITED", "Unlimited" },
{ "SET_COLORBLIND_0", "Off" },
{ "SET_COLORBLIND_1", "Protanopia" },
{ "SET_COLORBLIND_2", "Deuteranopia" },
{ "SET_COLORBLIND_3", "Tritanopia" },
{ "MENU_NEW_GAME", "New Game" },
{ "MENU_CONTINUE", "Continue" },
{ "MENU_SETTINGS", "Settings" },
{ "MENU_CREDITS", "Credits" },
{ "MENU_QUIT", "Quit" },
};
int added = MergeWriteUI(Language.ChineseSimplified, zh)
+ MergeWriteUI(Language.English, en);
Debug.Log($"[UIControlLibrary] 已补充设置/菜单本地化(新增 {added} 条,已存在的不覆盖)。" +
"日/韩缺省走英文回退可用「BaseGames/Localization/表格编辑器」补译。");
}
/// <summary>把缺失的 key 合并写入指定语言的 UI 表(已存在的保留,不覆盖)。返回新增数。</summary>
private static int MergeWriteUI(Language lang, Dictionary<string, string> kv)
{
var dict = LocalizationFileIO.Read(lang, LocalizationTable.UI);
int added = 0;
foreach (var p in kv)
if (!dict.ContainsKey(p.Key)) { dict[p.Key] = p.Value; added++; }
if (added > 0) LocalizationFileIO.Write(lang, LocalizationTable.UI, dict);
return added;
}
// ── 通用助手 ─────────────────────────────────────────────────────────
private static GameObject NewUI(string name, float w, float h)
{
var go = new GameObject(name, typeof(RectTransform));
((RectTransform)go.transform).sizeDelta = new Vector2(w, h);
return go;
}
private static GameObject NewUIChild(Transform parent, string name, out RectTransform rt)
{
var go = new GameObject(name, typeof(RectTransform));
rt = (RectTransform)go.transform;
rt.SetParent(parent, false);
return go;
}
private static void Size(GameObject go, float w, float h)
{
if (go.transform is RectTransform rt) rt.sizeDelta = new Vector2(w, h);
}
private static void Stretch(RectTransform rt)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
}
private static T GetOrAdd<T>(GameObject go) where T : Component
=> go.GetComponent<T>() ?? go.AddComponent<T>();
private static void SetEnum(Component c, string prop, int value)
{
var so = new SerializedObject(c);
var p = so.FindProperty(prop);
if (p != null) { p.enumValueIndex = value; so.ApplyModifiedPropertiesWithoutUndo(); }
}
private static void AssignRef(Object target, string prop, Object value)
{
var so = new SerializedObject(target);
var p = so.FindProperty(prop);
if (p == null) { Debug.LogWarning($"[UIControlLibrary] 未找到属性 {target.GetType().Name}.{prop}"); return; }
p.objectReferenceValue = value;
so.ApplyModifiedPropertiesWithoutUndo();
}
private static GameObject SaveAsPrefab(GameObject go, string name, List<string> report)
{
string path = $"{ControlsDir}/{name}.prefab";
var asset = PrefabUtility.SaveAsPrefabAsset(go, path);
Object.DestroyImmediate(go);
report.Add(path);
return asset;
}
private static void EnsureFolder(string dir)
{
string[] parts = dir.Split('/');
string cur = parts[0]; // "Assets"
for (int i = 1; i < parts.Length; i++)
{
string next = $"{cur}/{parts[i]}";
if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(cur, parts[i]);
cur = next;
}
}
// ── 内建 UI 资源(默认皮肤 sprite──────────────────────────────────────
private static Sprite Standard() => AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/UISprite.psd");
private static Sprite Background() => AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/Background.psd");
private static Sprite Knob() => AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/Knob.psd");
private static Sprite Checkmark() => AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/Checkmark.psd");
private static Sprite DropArrow() => AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/DropdownArrow.psd");
private static Sprite Mask() => AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/UIMask.psd");
private static Sprite InputBg() => AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/InputFieldBackground.psd");
private static DefaultControls.Resources UiResources() => new DefaultControls.Resources
{
standard = Standard(), background = Background(), inputField = InputBg(),
knob = Knob(), checkmark = Checkmark(), dropdown = DropArrow(), mask = Mask(),
};
private static TMP_DefaultControls.Resources TmpResources() => new TMP_DefaultControls.Resources
{
standard = Standard(), background = Background(), inputField = InputBg(),
knob = Knob(), checkmark = Checkmark(), dropdown = DropArrow(), mask = Mask(),
};
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2c325d7b7aebe0b4c855627035ebcf02
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: