#if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Linq; using Newtonsoft.Json.Linq; using TMPro; using UnityEditor; using UnityEngine; using UnityEngine.UI; namespace XGame.EditorTools { public class ValidationResult { public bool Ok = true; public readonly List Errors = new List(); public readonly List Warnings = new List(); } public static class JsonUIPrefabBuilder { // Doc/UIPrefabCreater 位于仓库根(Unity 工程目录 Client/ 的上一级), // 不能用相对路径(Unity 工作目录是工程目录),需基于 dataPath 解析为绝对路径。 private const string JsonRootRelative = "Doc/UIPrefabCreater"; private const string TextureRoot = "Assets/Game/Art/UI/Texture"; private static readonly int[] RequiredRefResolution = { 2048, 1024 }; // 解析 JSON 根目录绝对路径:优先 仓库根/Doc/UIPrefabCreater,回退 工程根/Doc/UIPrefabCreater private static string ResolveJsonRoot() { // Application.dataPath = /Client/Assets string projectDir = Directory.GetParent(Application.dataPath).FullName; // /Client string repoDir = Directory.GetParent(projectDir)?.FullName ?? projectDir; // string atRepo = Path.Combine(repoDir, JsonRootRelative); if (Directory.Exists(atRepo)) { return atRepo; } string atProject = Path.Combine(projectDir, JsonRootRelative); return atProject; } public static string ResolveDefaultJsonRoot() { return ResolveJsonRoot(); } // 已识别但本期未实现的组件:遇到只告警、不报错(让 LoginPanelControls.json 仍能通过) private static readonly HashSet KnownUnimplemented = new HashSet { "Toggle", "TMP_InputField", "RotationAnimation" }; [MenuItem("Tools/UI/Generate Prefab From JSON...")] public static void GenerateOne() { string path = EditorUtility.OpenFilePanel("选择 UI JSON", ResolveJsonRoot(), "json"); if (string.IsNullOrEmpty(path)) { return; } string output = BuildFromJsonFile(path); if (!string.IsNullOrEmpty(output)) { Debug.Log("[UIGen] 生成成功: " + output); } } [MenuItem("Tools/UI/Generate All Prefabs From JSON")] public static void GenerateAll() { IReadOnlyList outputs = BuildAll(ResolveJsonRoot()); Debug.Log("[UIGen] 批量生成完成,成功 " + outputs.Count + " 个。"); } public static void BuildGameLobbyFromCommandLine() { string jsonPath = Path.Combine(ResolveJsonRoot(), "GameLobbyControls.json"); string output = BuildFromJsonFile(jsonPath); if (string.IsNullOrEmpty(output)) { throw new Exception("[UIGen] GameLobbyControls.json build failed."); } } public static void BuildMatchWaitingFromCommandLine() { string jsonPath = Path.Combine(ResolveJsonRoot(), "MatchWaitingControls.json"); string output = BuildFromJsonFile(jsonPath); if (string.IsNullOrEmpty(output)) { throw new Exception("[UIGen] MatchWaitingControls.json build failed."); } } public static void BuildAllFromCommandLine() { string jsonRoot = GetCommandLineValue("-uiJsonRoot"); if (string.IsNullOrEmpty(jsonRoot)) { jsonRoot = ResolveJsonRoot(); } else if (!Path.IsPathRooted(jsonRoot)) { string projectDir = Directory.GetParent(Application.dataPath).FullName; string repoDir = Directory.GetParent(projectDir)?.FullName ?? projectDir; jsonRoot = Path.Combine(repoDir, jsonRoot); } IReadOnlyList outputs = BuildAll(jsonRoot); if (outputs.Count == 0) { throw new Exception("[UIGen] no prefab was built."); } } public static ValidationResult Validate(string jsonPath) { ValidationResult vr = new ValidationResult(); if (!File.Exists(jsonPath)) { Fail(vr, "文件不存在: " + jsonPath); return vr; } JObject root; try { root = JObject.Parse(File.ReadAllText(jsonPath)); } catch (Exception ex) { Fail(vr, "JSON 解析失败: " + ex.Message); return vr; } foreach (string field in new[] { "schemaVersion", "prefabName", "prefabPath", "canvas", "assets", "nodes" }) { if (root[field] == null) { Fail(vr, "缺少顶层字段: " + field); } } if (!vr.Ok) { return vr; } string prefabName = (string)root["prefabName"]; string prefabPath = (string)root["prefabPath"]; if (!prefabPath.Replace("\\", "/").StartsWith("Assets/")) { Fail(vr, "prefabPath 必须位于 Assets/ 下: " + prefabPath); } JToken refRes = root["canvas"]?["canvasScaler"]?["referenceResolution"]; if (refRes == null || (int)refRes[0] != RequiredRefResolution[0] || (int)refRes[1] != RequiredRefResolution[1]) { Fail(vr, "referenceResolution 必须为 [2048, 1024]"); } JArray nodes = root["nodes"] as JArray; if (nodes == null || nodes.Count == 0) { Fail(vr, "nodes 为空"); return vr; } HashSet names = new HashSet(); List roots = new List(); foreach (JToken node in nodes) { string name = (string)node["name"]; if (string.IsNullOrEmpty(name)) { Fail(vr, "存在无 name 的节点"); continue; } if (!names.Add(name)) { Fail(vr, "节点名重复: " + name); } if (node["rect"] == null) { Fail(vr, "节点缺少 rect: " + name); } if (node["parent"] == null || node["parent"].Type == JTokenType.Null) { roots.Add(name); } } foreach (JToken node in nodes) { JToken parent = node["parent"]; if (parent != null && parent.Type != JTokenType.Null && !names.Contains((string)parent)) { Fail(vr, "父节点不存在: " + (string)parent + " (来自 " + (string)node["name"] + ")"); } } if (roots.Count != 1) { Fail(vr, "根节点数量必须为 1,实际 " + roots.Count); } else if (roots[0] != prefabName) { Fail(vr, "prefabName 必须与根节点名一致: " + prefabName + " vs " + roots[0]); } foreach (string b in EnumStrings(root["bindings"])) { if (!names.Contains(b)) { Fail(vr, "bindings 节点不存在: " + b); } } foreach (JToken ev in (root["events"] as JArray) ?? new JArray()) { string n = (string)ev["node"]; if (!names.Contains(n)) { Fail(vr, "events 节点不存在: " + n); } } return vr; } public static string BuildFromJsonFile(string jsonPath) { ValidationResult vr = Validate(jsonPath); foreach (string w in vr.Warnings) { Debug.LogWarning("[UIGen] " + Path.GetFileName(jsonPath) + ": " + w); } if (!vr.Ok) { foreach (string e in vr.Errors) { Debug.LogError("[UIGen] " + Path.GetFileName(jsonPath) + ": " + e); } return null; } JObject root = JObject.Parse(File.ReadAllText(jsonPath)); string prefabName = (string)root["prefabName"]; string prefabPath = (string)root["prefabPath"]; JArray nodes = (JArray)root["nodes"]; GameObject rootGo = null; try { Dictionary map = new Dictionary(); List deferred = new List(); foreach (JToken node in nodes) { string name = (string)node["name"]; string parentName = node["parent"]?.Type == JTokenType.Null ? null : (string)node["parent"]; GameObject go = new GameObject(name, typeof(RectTransform)); map[name] = go; if (parentName == null) { rootGo = go; } else if (map.TryGetValue(parentName, out GameObject parentGo)) { go.transform.SetParent(parentGo.transform, false); } ApplyRect(go.GetComponent(), node["rect"] as JObject); bool active = node["active"] == null || (bool)node["active"]; foreach (JToken comp in (node["components"] as JArray) ?? new JArray()) { ApplyComponent(go, comp as JObject, map, deferred, Path.GetFileName(jsonPath)); } go.SetActive(active); } foreach (Action a in deferred) { a(); } string dir = Path.GetDirectoryName(prefabPath); if (!AssetDatabase.IsValidFolder(dir)) { Directory.CreateDirectory(dir); AssetDatabase.Refresh(); } GameObject saved = PrefabUtility.SaveAsPrefabAsset(rootGo, prefabPath); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Debug.Log("[UIGen] " + prefabName + " 生成完成 -> " + prefabPath); return saved == null ? null : prefabPath; } finally { if (rootGo != null) { UnityEngine.Object.DestroyImmediate(rootGo); } } } public static IReadOnlyList BuildAll(string jsonRoot) { List outputs = new List(); int failed = 0; if (!Directory.Exists(jsonRoot)) { Debug.LogError("[UIGen] JSON 目录不存在: " + jsonRoot); return outputs; } foreach (string file in Directory.GetFiles(jsonRoot, "*.json", SearchOption.AllDirectories)) { // 只处理含 prefabName/nodes 的 UI 描述文件,跳过其它 json string text; try { text = File.ReadAllText(file); } catch { continue; } if (!text.Contains("\"prefabName\"") || !text.Contains("\"nodes\"")) { Debug.Log("[UIGen] 跳过非 UI JSON: " + file); continue; } string output = BuildFromJsonFile(file); if (string.IsNullOrEmpty(output)) { failed++; } else { outputs.Add(output); } } Debug.Log("[UIGen] BuildAll 完成 Success:" + outputs.Count + " Failed:" + failed); return outputs; } public static IReadOnlyList BuildChanged(string jsonRoot, string reason, bool force = false) { List outputs = new List(); int skipped = 0; int failed = 0; if (!Directory.Exists(jsonRoot)) { Debug.LogError("[UIGen] JSON 目录不存在: " + jsonRoot); return outputs; } foreach (string file in Directory.GetFiles(jsonRoot, "*.json", SearchOption.AllDirectories)) { if (!TryReadUiJson(file, out JObject root)) { skipped++; continue; } string prefabPath = (string)root["prefabPath"]; if (!force && !IsJsonNewerThanPrefab(file, prefabPath)) { skipped++; continue; } string output = BuildFromJsonFile(file); if (string.IsNullOrEmpty(output)) { failed++; } else { outputs.Add(output); Debug.Log("[UIGen:" + reason + "] " + Path.GetFileName(file) + " -> " + output); } } if (outputs.Count > 0 || failed > 0) { Debug.Log("[UIGen:" + reason + "] BuildChanged 完成 Success:" + outputs.Count + " Failed:" + failed + " Skipped:" + skipped); } return outputs; } private static bool TryReadUiJson(string file, out JObject root) { root = null; try { string text = File.ReadAllText(file); if (!text.Contains("\"prefabName\"") || !text.Contains("\"nodes\"")) { return false; } root = JObject.Parse(text); return root["prefabName"] != null && root["prefabPath"] != null && root["nodes"] != null; } catch { return false; } } private static bool IsJsonNewerThanPrefab(string jsonPath, string prefabPath) { if (string.IsNullOrEmpty(prefabPath)) { return false; } string normalizedPrefabPath = prefabPath.Replace("\\", "/"); string fullPrefabPath = Path.IsPathRooted(normalizedPrefabPath) ? normalizedPrefabPath : Path.Combine(Directory.GetParent(Application.dataPath).FullName, normalizedPrefabPath); if (!File.Exists(fullPrefabPath)) { return true; } return File.GetLastWriteTimeUtc(jsonPath) > File.GetLastWriteTimeUtc(fullPrefabPath).AddMilliseconds(100); } // ---------- RectTransform ---------- private static void ApplyRect(RectTransform rt, JObject rect) { if (rect == null) { return; } rt.anchorMin = ToVec2(rect["anchorMin"], new Vector2(0, 0)); rt.anchorMax = ToVec2(rect["anchorMax"], new Vector2(1, 1)); rt.pivot = ToVec2(rect["pivot"], new Vector2(0.5f, 0.5f)); if (rect["offsetMin"] != null && rect["offsetMax"] != null) { rt.offsetMin = ToVec2(rect["offsetMin"], Vector2.zero); rt.offsetMax = ToVec2(rect["offsetMax"], Vector2.zero); } else { rt.sizeDelta = ToVec2(rect["sizeDelta"], Vector2.zero); rt.anchoredPosition = ToVec2(rect["anchoredPosition"], Vector2.zero); } } // 用 Unity 重载的 == 判空,避免 C# ?? 在 Unity 对象上的“假 null”陷阱 private static T GetOrAdd(GameObject go) where T : Component { T c = go.GetComponent(); if (c == null) { c = go.AddComponent(); } return c; } // ---------- 组件 ---------- private static void ApplyComponent(GameObject go, JObject comp, Dictionary map, List deferred, string fileName) { if (comp == null) { return; } string type = (string)comp["type"]; switch (type) { case "Canvas": { Canvas c = GetOrAdd(go); c.renderMode = RenderMode.ScreenSpaceOverlay; if (comp["sortingOrder"] != null) c.sortingOrder = (int)comp["sortingOrder"]; break; } case "CanvasScaler": { CanvasScaler cs = GetOrAdd(go); cs.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; cs.referenceResolution = ToVec2(comp["referenceResolution"], new Vector2(2048, 1024)); cs.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight; cs.matchWidthOrHeight = comp["matchWidthOrHeight"] != null ? (float)comp["matchWidthOrHeight"] : 0.5f; break; } case "GraphicRaycaster": GetOrAdd(go); break; case "CanvasGroup": { CanvasGroup cg = GetOrAdd(go); if (comp["alpha"] != null) cg.alpha = (float)comp["alpha"]; if (comp["interactable"] != null) cg.interactable = (bool)comp["interactable"]; if (comp["blocksRaycasts"] != null) cg.blocksRaycasts = (bool)comp["blocksRaycasts"]; break; } case "Image": { Image img = GetOrAdd(go); string spriteName = (string)comp["sprite"]; Sprite sprite = ResolveSprite(spriteName, fileName); img.sprite = sprite; img.type = string.Equals((string)comp["imageType"], "Sliced", StringComparison.OrdinalIgnoreCase) ? Image.Type.Sliced : Image.Type.Simple; if (comp["preserveAspect"] != null) img.preserveAspect = (bool)comp["preserveAspect"]; img.raycastTarget = comp["raycastTarget"] == null || (bool)comp["raycastTarget"]; img.color = ParseColor((string)comp["color"], Color.white); break; } case "TextMeshProUGUI": { TextMeshProUGUI t = GetOrAdd(go); t.text = (string)comp["text"] ?? string.Empty; if (comp["fontSize"] != null) t.fontSize = (float)comp["fontSize"]; t.fontStyle = string.Equals((string)comp["fontStyle"], "Bold", StringComparison.OrdinalIgnoreCase) ? FontStyles.Bold : FontStyles.Normal; t.alignment = ParseAlignment((string)comp["alignment"]); t.color = ParseColor((string)comp["color"], Color.white); t.raycastTarget = comp["raycastTarget"] != null && (bool)comp["raycastTarget"]; break; } case "Button": { Button btn = GetOrAdd