#if UNITY_EDITOR using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; [Serializable] public sealed class CharacterReviewVec3 { public float x, y, z; public Vector3 Value => new Vector3(x, y, z); } [Serializable] public sealed class CharacterReviewSubject { public string id, kind, prefab, notes; public string localForward = "+Z"; public float expectedHeight = 1.8f, heightTolerance = 0.15f; public int maxTriangles = 3500; public bool requireXpbr = true; public bool requireAnimator = false; public string[] requiredClips = { "Idle", "Walk", "Attack" }; } [Serializable] public sealed class CharacterReviewManifest { public int schemaVersion; public string sceneId, outputScene, reportPath, label; public float spacing = 3f, groundSize = 24f, fieldOfView = 40f; public CharacterReviewVec3 cameraPosition, cameraTarget; public CharacterReviewSubject[] subjects; } /// /// Builds a persistent editor scene for reviewing character / unit art before gameplay swaps. /// public static class BuildCharacterReview { const string ManifestPath = "Doc/Design/Character/character_unit_review_manifest.json"; const string CapturePath = "Assets/Screenshots/character_unit_review.png"; static string RepoRoot => Directory.GetParent(Application.dataPath).Parent.FullName; static string ProjectRoot => Directory.GetParent(Application.dataPath).FullName; static string AbsoluteAsset(string assetPath) => Path.GetFullPath(Path.Combine(ProjectRoot, Norm(assetPath))); static CharacterReviewManifest ReadManifest() { string path = Path.GetFullPath(Path.Combine(RepoRoot, ManifestPath)); if (!File.Exists(path)) throw new FileNotFoundException("Missing character review manifest", path); string text = File.ReadAllText(path); var data = new CharacterReviewManifest { schemaVersion = ExtractInt(text, "schemaVersion"), sceneId = ExtractString(text, "sceneId"), outputScene = ExtractString(text, "outputScene"), reportPath = ExtractString(text, "reportPath"), label = ExtractString(text, "label"), spacing = ExtractFloat(text, "spacing", 3f), groundSize = ExtractFloat(text, "groundSize", 24f), fieldOfView = ExtractFloat(text, "fieldOfView", 40f), cameraPosition = ReadVec(text, "cameraPosition"), cameraTarget = ReadVec(text, "cameraTarget"), subjects = ParseSubjects(SliceArray(text, "subjects")) }; if (string.IsNullOrEmpty(data.sceneId) || string.IsNullOrEmpty(data.outputScene)) throw new Exception("Character review manifest needs sceneId and outputScene."); if (data.cameraPosition == null || data.cameraTarget == null) throw new Exception("Character review manifest needs cameraPosition and cameraTarget."); if (data.subjects == null || data.subjects.Length == 0) throw new Exception("Character review manifest needs at least one subject."); return data; } static string ExtractString(string json, string key) { var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*\"([^\"]*)\""); return match.Success ? match.Groups[1].Value : ""; } static int ExtractInt(string json, string key) { var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*(-?\\d+)"); return match.Success ? int.Parse(match.Groups[1].Value) : 0; } static float ExtractFloat(string json, string key, float fallback) { var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*(-?[0-9.]+)"); return match.Success ? float.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture) : fallback; } static bool ExtractBool(string json, string key, bool fallback) { var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*(true|false)"); return match.Success ? match.Groups[1].Value == "true" : fallback; } static CharacterReviewVec3 ReadVec(string json, string key) { var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*\\{\\s*\"x\"\\s*:\\s*(-?[0-9.]+)\\s*,\\s*\"y\"\\s*:\\s*(-?[0-9.]+)\\s*,\\s*\"z\"\\s*:\\s*(-?[0-9.]+)"); if (!match.Success) return null; return new CharacterReviewVec3 { x = float.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture), y = float.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture), z = float.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture) }; } static string SliceArray(string text, string key) { int i = text.IndexOf("\"" + key + "\"", StringComparison.Ordinal); if (i < 0) return "[]"; int start = text.IndexOf('[', i); int depth = 0; for (int c = start; c < text.Length; c++) { if (text[c] == '[') depth++; else if (text[c] == ']') { depth--; if (depth == 0) return text.Substring(start, c - start + 1); } } return "[]"; } static CharacterReviewSubject[] ParseSubjects(string arrayJson) { var items = new List(); int depth = 0, start = -1; for (int i = 0; i < arrayJson.Length; i++) { if (arrayJson[i] == '{') { if (depth == 0) start = i; depth++; } else if (arrayJson[i] == '}') { depth--; if (depth == 0 && start >= 0) { string slice = arrayJson.Substring(start, i - start + 1); items.Add(new CharacterReviewSubject { id = ExtractString(slice, "id"), kind = ExtractString(slice, "kind"), prefab = ExtractString(slice, "prefab"), notes = ExtractString(slice, "notes"), localForward = slice.Contains("\"localForward\"") ? ExtractString(slice, "localForward") : "+Z", expectedHeight = ExtractFloat(slice, "expectedHeight", 1.8f), heightTolerance = ExtractFloat(slice, "heightTolerance", 0.15f), maxTriangles = ExtractInt(slice, "maxTriangles"), requireXpbr = ExtractBool(slice, "requireXpbr", true), requireAnimator = ExtractBool(slice, "requireAnimator", false), requiredClips = slice.Contains("\"requiredClips\"") ? Regex.Matches(SliceArray(slice,"requiredClips"), "\"([^\"]+)\"").Cast().Select(m=>m.Groups[1].Value).ToArray() : new[] { "Idle", "Walk", "Attack" } }); if (items[items.Count - 1].maxTriangles <= 0) items[items.Count - 1].maxTriangles = 3500; start = -1; } } } return items.ToArray(); } static string Norm(string path) => path.Replace('\\', '/'); static void EnsureFolder(string assetPath) { string absolute = AbsoluteAsset(assetPath); string directory = Path.GetDirectoryName(absolute); if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); string unityDir = Path.GetDirectoryName(Norm(assetPath))?.Replace('\\', '/'); if (string.IsNullOrEmpty(unityDir) || !unityDir.StartsWith("Assets/", StringComparison.Ordinal)) return; string[] parts = unityDir.Split('/'); string current = parts[0]; for (int i = 1; i < parts.Length; i++) { string next = current + "/" + parts[i]; if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(current, parts[i]); current = next; } } public static string ValidateOnly() { var data = ReadManifest(); foreach (var subject in data.subjects) { if (string.IsNullOrEmpty(subject.id) || string.IsNullOrEmpty(subject.prefab)) throw new Exception("Subject missing id/prefab"); if (subject.localForward != "+Z" && subject.localForward != "-Z") throw new Exception("Subject localForward must be +Z or legacy -Z: " + subject.id); if (AssetDatabase.LoadAssetAtPath(Norm(subject.prefab)) == null) throw new Exception("Missing prefab " + subject.prefab); } return "PASS character review manifest subjects=" + data.subjects.Length; } public static string Build() { if (EditorApplication.isPlaying) throw new Exception("Build character review in Edit Mode."); var data = ReadManifest(); string scenePath = Norm(data.outputScene); EnsureFolder(scenePath); var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); var root = new GameObject(data.sceneId); SceneManager.MoveGameObjectToScene(root, scene); BuildGround(root.transform, data.groundSize); BuildHeightMarkers(root.transform); var subjects = new GameObject("Subjects"); subjects.transform.SetParent(root.transform, false); for (int i = 0; i < data.subjects.Length; i++) { var subject = data.subjects[i]; var prefab = AssetDatabase.LoadAssetAtPath(Norm(subject.prefab)); if (prefab == null) throw new Exception("Missing prefab " + subject.prefab); var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab, subjects.transform); instance.name = subject.id; instance.transform.localPosition = new Vector3(i * data.spacing, 0f, 0f); instance.transform.localRotation = Quaternion.identity; AddForwardMarker(instance.transform, subject.localForward); AddNameLabel(instance.transform, subject.id + " / " + subject.kind); EnsureAnimCycle(instance); } var lighting = new GameObject("Lighting"); lighting.transform.SetParent(root.transform, false); var sun = new GameObject("Sun"); sun.transform.SetParent(lighting.transform, false); sun.transform.rotation = Quaternion.Euler(48f, -30f, 0f); var light = sun.AddComponent(); light.type = LightType.Directional; light.color = new Color(1f, 0.96f, 0.88f); light.intensity = 1.2f; light.shadows = LightShadows.Soft; var cameraGo = new GameObject("ReviewCamera"); cameraGo.tag = "MainCamera"; var camera = cameraGo.AddComponent(); camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = new Color(0.55f, 0.72f, 0.82f); camera.fieldOfView = data.fieldOfView; camera.nearClipPlane = 0.05f; camera.farClipPlane = 80f; cameraGo.AddComponent(); cameraGo.transform.position = data.cameraPosition.Value; cameraGo.transform.rotation = Quaternion.LookRotation(data.cameraTarget.Value - cameraGo.transform.position, Vector3.up); var title = new GameObject("ReviewLabel"); var text = title.AddComponent(); text.text = string.IsNullOrEmpty(data.label) ? data.sceneId : data.label; text.anchor = TextAnchor.LowerCenter; text.alignment = TextAlignment.Center; text.characterSize = 0.12f; text.fontSize = 48; text.color = new Color(0.15f, 0.2f, 0.25f); title.transform.position = data.cameraTarget.Value + new Vector3(0f, 2.6f, 0f); RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Trilight; RenderSettings.ambientSkyColor = new Color(0.55f, 0.65f, 0.75f); RenderSettings.ambientEquatorColor = new Color(0.4f, 0.42f, 0.4f); RenderSettings.ambientGroundColor = new Color(0.25f, 0.24f, 0.22f); EditorSceneManager.SaveScene(scene, scenePath); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); WriteReport(data, scenePath, "built"); return "Built character review scene " + scenePath + " with " + data.subjects.Length + " subjects."; } static void BuildGround(Transform parent, float size) { var ground = GameObject.CreatePrimitive(PrimitiveType.Plane); ground.name = "Ground"; ground.transform.SetParent(parent, false); ground.transform.localScale = Vector3.one * (size / 10f); var renderer = ground.GetComponent(); var mat = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard")); mat.color = new Color(0.78f, 0.82f, 0.74f); renderer.sharedMaterial = mat; UnityEngine.Object.DestroyImmediate(ground.GetComponent()); } static void BuildHeightMarkers(Transform parent) { var markers = new GameObject("HeightMarkers"); markers.transform.SetParent(parent, false); markers.transform.localPosition = new Vector3(-2.2f, 0f, 0f); foreach (float h in new[] { 0.5f, 1.0f, 1.8f }) { var stick = GameObject.CreatePrimitive(PrimitiveType.Cylinder); stick.name = "Stick_" + h.ToString("0.0", CultureInfo.InvariantCulture); stick.transform.SetParent(markers.transform, false); stick.transform.localPosition = new Vector3(0f, h * 0.5f, 0f); stick.transform.localScale = new Vector3(0.06f, h * 0.5f, 0.06f); UnityEngine.Object.DestroyImmediate(stick.GetComponent()); var label = new GameObject("Label_" + stick.name); label.transform.SetParent(stick.transform, false); label.transform.localPosition = new Vector3(0.8f, 0.5f, 0f); var text = label.AddComponent(); text.text = h.ToString("0.0", CultureInfo.InvariantCulture) + "m"; text.characterSize = 0.08f; text.fontSize = 36; text.anchor = TextAnchor.MiddleLeft; text.color = new Color(0.2f, 0.25f, 0.3f); } } static void AddForwardMarker(Transform subject, string localForward = "+Z") { if (localForward != "+Z" && localForward != "-Z") throw new ArgumentException("Unsupported localForward"); var marker = GameObject.CreatePrimitive(PrimitiveType.Cube); bool legacy = localForward == "-Z"; marker.name = legacy ? "ForwardMinusZ" : "ForwardPlusZ"; marker.transform.SetParent(subject, false); marker.transform.localPosition = new Vector3(0f, 0.05f, legacy ? -0.55f : 0.55f); marker.transform.localScale = new Vector3(0.12f, 0.04f, 0.45f); UnityEngine.Object.DestroyImmediate(marker.GetComponent()); var renderer = marker.GetComponent(); var mat = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard")); mat.color = new Color(0.2f, 0.55f, 0.95f); renderer.sharedMaterial = mat; } public static string VerifyForwardConvention() { var subjects=ParseSubjects("[{\"id\":\"new\"},{\"id\":\"legacy\",\"localForward\":\"-Z\"}]"); if(subjects.Length!=2 || subjects[0].localForward!="+Z" || subjects[1].localForward!="-Z") throw new Exception("Manifest forward defaults or explicit legacy override failed"); var manifest=ReadManifest(); if(manifest.subjects.Any(s=>s.localForward!="+Z" && s.localForward!="-Z")) throw new Exception("Invalid declared forward in current manifest"); return "PASS +Z defaults and legacy -Z overrides; manifest subjects="+manifest.subjects.Length; } static void AddNameLabel(Transform subject, string label) { var go = new GameObject("NameLabel"); go.transform.SetParent(subject, false); go.transform.localPosition = new Vector3(0f, 2.1f, 0f); var text = go.AddComponent(); text.text = label; text.anchor = TextAnchor.LowerCenter; text.alignment = TextAlignment.Center; text.characterSize = 0.06f; text.fontSize = 40; text.color = new Color(0.12f, 0.16f, 0.2f); } static void EnsureAnimCycle(GameObject subjectRoot) { var animator = subjectRoot.GetComponentInChildren(true); if (animator == null || animator.runtimeAnimatorController == null) return; bool hasMotion = false; foreach (var p in animator.parameters) { if (p.name == "Motion" && p.type == AnimatorControllerParameterType.Int) { hasMotion = true; break; } } if (!hasMotion) return; var cycle = subjectRoot.GetComponent(); if (cycle == null) cycle = subjectRoot.AddComponent(); var clips = animator.runtimeAnimatorController.animationClips.Select(c=>c.name).Distinct().ToArray(); var serialized = new SerializedObject(cycle); var names = serialized.FindProperty("motionNames"); names.arraySize = clips.Length; for(int i=0;ib.localToWorldMatrix*poses[i]).ToArray(); var weights=mesh.boneWeights; var vertices=mesh.vertices; Bounds bounds=default; for(int i=0;i(true)) cycle.enabled = false; foreach (var animator in root.GetComponentsInChildren(true)) { if (animator.runtimeAnimatorController == null) continue; foreach (var p in animator.parameters) { if (p.name == "Motion" && p.type == AnimatorControllerParameterType.Int) { animator.SetInteger("Motion", 0); animator.Update(0f); break; } } } var report = new StringBuilder("PASS "); foreach (var subject in data.subjects) { var node = root.transform.Find("Subjects/" + subject.id); if (node == null) throw new Exception("Missing subject node " + subject.id); var renderers = node.GetComponentsInChildren(true) .Where(r => r.name != "ForwardMinusZ" && r.name != "ForwardPlusZ" && r.name != "NameLabel" && r.GetComponentInParent() == null && !r.name.StartsWith("Label_", StringComparison.Ordinal)) .ToArray(); if (renderers.Length == 0) throw new Exception(subject.id + " has no mesh renderer"); var bounds = ActualBounds(renderers[0]); for (int i = 1; i < renderers.Length; i++) bounds.Encapsulate(ActualBounds(renderers[i])); if (bounds.min.y < -0.05f || bounds.min.y > 0.08f) throw new Exception(subject.id + " bottom y=" + bounds.min.y.ToString("F3") + " (want near 0)"); float height = bounds.size.y; if (Mathf.Abs(height - subject.expectedHeight) > subject.heightTolerance) throw new Exception(subject.id + " height=" + height.ToString("F2") + " expected " + subject.expectedHeight); int tris = 0; foreach (var filter in node.GetComponentsInChildren(true)) if (filter.sharedMesh != null) tris += filter.sharedMesh.triangles.Length / 3; foreach (var skin in node.GetComponentsInChildren(true)) if (skin.sharedMesh != null) tris += skin.sharedMesh.triangles.Length / 3; if (tris > subject.maxTriangles) throw new Exception(subject.id + " tris=" + tris + " > " + subject.maxTriangles); if (subject.requireXpbr) { foreach (var renderer in renderers) { if (renderer.sharedMaterial == null || renderer.sharedMaterial.shader == null) throw new Exception(subject.id + " missing material"); if (renderer.sharedMaterial.shader.name != "Test/XPbr") throw new Exception(subject.id + " shader=" + renderer.sharedMaterial.shader.name); } } if (node.Find("ForwardMinusZ") == null) throw new Exception(subject.id + " missing -Z marker"); if (subject.requireAnimator) { var animator = node.GetComponentInChildren(true); if (animator == null || animator.runtimeAnimatorController == null) throw new Exception(subject.id + " missing Animator/controller"); var ctrl = animator.runtimeAnimatorController; var clipNames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var clip in ctrl.animationClips) clipNames.Add(clip.name); foreach (var need in subject.requiredClips) { bool hit = clipNames.Any(n => n.IndexOf(need, StringComparison.OrdinalIgnoreCase) >= 0); if (!hit) throw new Exception(subject.id + " missing clip " + need + " in " + string.Join(",", clipNames)); } } report.Append(subject.id).Append("(h=").Append(height.ToString("F2")).Append(",tris=").Append(tris).Append(") "); } WriteReport(data, scenePath, "validated"); return report.ToString(); } public static string CaptureReview() { return OpenReview() + "; then run: unity command capture_scene_view --width 1600 --height 900 --save_path " + CapturePath; } public static string BuildOpenValidate() { string built = Build(); OpenReview(); string validated = ValidateReview(); return built + " | " + validated; } } #endif