Files
AIGame/AIClient/AgentScripts/BuildCharacterReview.cs
T

536 lines
26 KiB
C#

#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;
}
/// <summary>
/// Builds a persistent editor scene for reviewing character / unit art before gameplay swaps.
/// </summary>
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<CharacterReviewSubject>();
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<Match>().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<GameObject>(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<GameObject>(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>();
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>();
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<AudioListener>();
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<TextMesh>();
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<Renderer>();
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<Collider>());
}
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<Collider>());
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<TextMesh>();
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<Collider>());
var renderer = marker.GetComponent<Renderer>();
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<TextMesh>();
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<Animator>(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<CharacterReviewAnimCycle>();
if (cycle == null) cycle = subjectRoot.AddComponent<CharacterReviewAnimCycle>();
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;i<clips.Length;i++) names.GetArrayElementAtIndex(i).stringValue=clips[i];
serialized.ApplyModifiedPropertiesWithoutUndo();
cycle.enabled = true;
}
static void WriteReport(CharacterReviewManifest data, string scenePath, string status)
{
string reportPath = string.IsNullOrEmpty(data.reportPath)
? "Tools/output/character_review/" + data.sceneId + "/review_report.json"
: Norm(data.reportPath);
string absolute = Path.GetFullPath(Path.Combine(RepoRoot, reportPath));
Directory.CreateDirectory(Path.GetDirectoryName(absolute));
var sb = new StringBuilder();
sb.AppendLine("{");
sb.AppendLine(" \"schemaVersion\": 1,");
sb.AppendLine(" \"sceneId\": \"" + data.sceneId + "\",");
sb.AppendLine(" \"outputScene\": \"" + scenePath + "\",");
sb.AppendLine(" \"status\": \"" + status + "\",");
sb.AppendLine(" \"generatedAtUtc\": \"" + DateTime.UtcNow.ToString("o") + "\",");
sb.AppendLine(" \"subjectCount\": " + data.subjects.Length + ",");
sb.AppendLine(" \"doc\": \"Doc/Design/Character/CharacterUnitArtProc.md\"");
sb.AppendLine("}");
File.WriteAllText(absolute, sb.ToString());
}
static Bounds ActualBounds(Renderer renderer)
{
var skin=renderer as SkinnedMeshRenderer;
if(skin==null) return renderer.bounds;
// Renderer.bounds is an animation culling volume, not posed geometry.
// Evaluate skin matrices directly; BakeMesh at scaled ancestors can
// include scale a second time in this Unity version.
var mesh=skin.sharedMesh;
var poses=mesh.bindposes;
var matrices=skin.bones.Select((b,i)=>b.localToWorldMatrix*poses[i]).ToArray();
var weights=mesh.boneWeights;
var vertices=mesh.vertices;
Bounds bounds=default;
for(int i=0;i<vertices.Length;i++)
{
var w=weights[i]; var v=vertices[i];
var p=matrices[w.boneIndex0].MultiplyPoint3x4(v)*w.weight0
+matrices[w.boneIndex1].MultiplyPoint3x4(v)*w.weight1
+matrices[w.boneIndex2].MultiplyPoint3x4(v)*w.weight2
+matrices[w.boneIndex3].MultiplyPoint3x4(v)*w.weight3;
if(i==0) bounds=new Bounds(p,Vector3.zero); else bounds.Encapsulate(p);
}
return bounds;
}
public static string OpenReview()
{
var data = ReadManifest();
string scenePath = Norm(data.outputScene);
if (!File.Exists(AbsoluteAsset(scenePath)))
throw new FileNotFoundException("Review scene missing; run Build first", AbsoluteAsset(scenePath));
var scene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
var root = GameObject.Find(data.sceneId);
if (root != null)
{
var subjects = root.transform.Find("Subjects");
if (subjects != null)
{
for (int i = 0; i < subjects.childCount; i++)
EnsureAnimCycle(subjects.GetChild(i).gameObject);
}
Selection.activeGameObject = root;
EditorGUIUtility.PingObject(root);
EditorSceneManager.MarkSceneDirty(scene);
EditorSceneManager.SaveScene(scene);
}
var view = SceneView.lastActiveSceneView;
if (view != null)
{
view.LookAt(data.cameraTarget.Value, Quaternion.Euler(18f, 20f, 0f), 8f);
view.orthographic = false;
view.Repaint();
}
return "Opened " + scene.path + " (anim cycle on Motion units)";
}
public static string ValidateReview()
{
var data = ReadManifest();
string scenePath = Norm(data.outputScene);
EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
var root = GameObject.Find(data.sceneId);
if (root == null) throw new Exception("Missing root " + data.sceneId);
if (GameObject.Find("ReviewCamera") == null) throw new Exception("Missing ReviewCamera");
// 轮播会改姿态,校验前强制 Idle
foreach (var cycle in root.GetComponentsInChildren<CharacterReviewAnimCycle>(true))
cycle.enabled = false;
foreach (var animator in root.GetComponentsInChildren<Animator>(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<Renderer>(true)
.Where(r => r.name != "ForwardMinusZ" && r.name != "ForwardPlusZ" && r.name != "NameLabel" && r.GetComponentInParent<TextMesh>() == 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<MeshFilter>(true))
if (filter.sharedMesh != null) tris += filter.sharedMesh.triangles.Length / 3;
foreach (var skin in node.GetComponentsInChildren<SkinnedMeshRenderer>(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<Animator>(true);
if (animator == null || animator.runtimeAnimatorController == null)
throw new Exception(subject.id + " missing Animator/controller");
var ctrl = animator.runtimeAnimatorController;
var clipNames = new HashSet<string>(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