Ship the WeChat hot-update catalogs and the camp, TideWhale T2, UI atlas, and animation-lab work.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-10-24 18:04:41 +08:00
co-authored by Cursor
parent 6ebc9401ce
commit 45e62132c8
1432 changed files with 2971856 additions and 2798 deletions
@@ -11,6 +11,26 @@ using Object=UnityEngine.Object;
/// <summary>Isolated static or sampled-pose views; preserves all open scene contents.</summary>
public static class AuditCharacterVisual
{
public static string CaptureCampGuideIdle()
{
return Capture(
"Assets/Bundles/Characters/Human/CampGuide/CampGuide_Rigged.prefab",
"Tools/output/campguide_review/idle",
1.55f,
"Assets/Bundles/Characters/Human/CampGuide/Clips/Idle.anim",
0.45f);
}
public static string CaptureCampGuideWalk()
{
return Capture(
"Assets/Bundles/Characters/Human/CampGuide/CampGuide_Rigged.prefab",
"Tools/output/campguide_review/walk",
1.55f,
"Assets/Bundles/Characters/Human/CampGuide/Clips/Walk.anim",
0.3f);
}
public static string Capture(string prefabPath,string outputDirectory,float height=.55f,string clipPath="",float phase=0)
{
string dir=Path.GetFullPath(Path.Combine(Application.dataPath,"../..",outputDirectory));Directory.CreateDirectory(dir);
@@ -0,0 +1,89 @@
using System;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
using UnityEngine.U2D;
/// <summary>检查真实 bundle 中是否重复包含未压缩的图集原图。</summary>
public static class AuditUiAtlasBundle
{
public static string RebuildProductionWarm()
{
string result = AOT.Editor.YooAssetTools.BuildAndStageArt.BuildAndStage();
if (!result.StartsWith("ok", StringComparison.Ordinal)) throw new Exception(result);
return result + " | " + Production();
}
public static string VerifyProductionGuard()
{
string oldDirectory = Path.GetFullPath(Path.Combine(Application.dataPath, "../Bundles/WebGL/Art/20261022.142050"));
string latestDirectory = Path.GetFullPath(Path.Combine(Application.dataPath, "../../Tools/cdn_root/wechat/1.0.0/Art"));
var oldChecks = AIGame.EditorTools.UiAtlasBuildValidation.Inspect(oldDirectory);
var newChecks = AIGame.EditorTools.UiAtlasBuildValidation.Inspect(latestDirectory);
if (!oldChecks.Any(c => c.SourceTextureCount > 0)) throw new Exception("Guard did not detect the known bad production bundle");
if (newChecks.Any(c => c.SourceTextureCount > 0)) throw new Exception("New production bundles still contain source textures");
string output = Path.GetFullPath(Path.Combine(Application.dataPath, "../../Tools/output/ui_atlas/size"));
Directory.CreateDirectory(output);
File.WriteAllText(Path.Combine(output, "production_guard.json"), JsonConvert.SerializeObject(new {
passed = true, knownBadDetected = true, oldChecks, newChecks
}, Formatting.Indented));
return "PASS: known bad Art detected; all " + newChecks.Length + " current Art atlas bundles have zero source textures.";
}
public static string RebuildProductionWithoutStaleCache()
{
UnityEditor.Build.Pipeline.Utilities.BuildCache.PurgeCache(false);
string result = AOT.Editor.YooAssetTools.BuildAndStageArt.BuildAndStage();
if (!result.StartsWith("ok", StringComparison.Ordinal)) throw new Exception(result);
return result + " | " + Production();
}
public static string Production()
{
string root = Path.Combine(Application.dataPath, "StreamingAssets/yoo/Art");
return Inspect(Directory.GetFiles(root, "assets_bundles_ui_atlas_ui_homeatlas_*.bundle").Single(), "production_latest");
}
public static string Original()
{
string path = Path.Combine(Application.dataPath,
"StreamingAssets/yoo/Art/assets_bundles_ui_atlas_ui_homeatlas_a6442d30c6c5d555575f90ae44067db5.bundle");
return Inspect(path, "production_before");
}
public static string Verification()
{
string output = Path.GetFullPath(Path.Combine(Application.dataPath, "../../Tools/output/ui_atlas"));
string root = JObject.Parse(File.ReadAllText(Path.Combine(output, "build.json"))).Value<string>("packageRoot");
return Inspect(Directory.GetFiles(root, "assets_bundles_ui_atlas_ui_homeatlas_*.bundle").OrderByDescending(File.GetLastWriteTimeUtc).First(),
"verification_after", true);
}
public static string Inspect(string path, string reportName, bool requireStripped = false)
{
var bundle = AssetBundle.LoadFromFile(path);
if (bundle == null) throw new Exception("Cannot read bundle: " + path);
try
{
string[] assets = bundle.GetAllAssetNames();
var sources = bundle.LoadAllAssets<Texture2D>();
var atlases = bundle.LoadAllAssets<SpriteAtlas>();
int spriteCount = atlases.Sum(a => a.spriteCount);
var report = new {
path, bytes = new FileInfo(path).Length, spriteCount, assets,
sourceTextures = sources.Select(t => new { t.name, t.width, t.height, format = t.format.ToString() }).ToArray()
};
string output = Path.GetFullPath(Path.Combine(Application.dataPath, "../../Tools/output/ui_atlas/size"));
Directory.CreateDirectory(output);
File.WriteAllText(Path.Combine(output, reportName + ".json"), JsonConvert.SerializeObject(report, Formatting.Indented));
if (requireStripped && sources.Length != 0) throw new Exception("Bundle still contains original Texture2D data: " + path);
return $"bytes={report.bytes}, sourceTextures={sources.Length}, sprites={spriteCount}";
}
finally
{
bundle.Unload(true);
}
}
}
+289
View File
@@ -0,0 +1,289 @@
#if UNITY_EDITOR
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
/// <summary>Adds locomotion to the camp guide prefab used by the home screen.</summary>
public static class BuildCampGuide
{
const string PrefabPath = "Assets/Bundles/Characters/Human/CampGuide/CampGuide_Rigged.prefab";
public static string Attach()
{
var root = PrefabUtility.LoadPrefabContents(PrefabPath);
try
{
if (root.GetComponent<HotUpdate.Gameplay.ExplorerLocomotion>() == null)
root.AddComponent<HotUpdate.Gameplay.ExplorerLocomotion>();
SetLayer(root.transform);
PrefabUtility.SaveAsPrefabAsset(root, PrefabPath);
}
finally
{
PrefabUtility.UnloadPrefabContents(root);
}
return PrefabPath;
}
public static string Bones()
{
var asset = AssetDatabase.LoadAssetAtPath<GameObject>(
"Assets/Bundles/Characters/Human/CampGuide/Rigged/CampGuide_Rigged.fbx");
var model = UnityEngine.Object.Instantiate(asset);
try
{
var lines = new System.Text.StringBuilder();
foreach (var t in model.GetComponentsInChildren<Transform>())
{
if (!t.name.StartsWith("bone_")) continue;
var p = t.position;
lines.AppendLine($"{t.name} parent={t.parent.name} child={t.childCount} x={p.x:F3} y={p.y:F3} z={p.z:F3}");
}
return lines.ToString();
}
finally { UnityEngine.Object.DestroyImmediate(model); }
}
public static string Author()
{
var asset = AssetDatabase.LoadAssetAtPath<GameObject>(
"Assets/Bundles/Characters/Human/CampGuide/Rigged/CampGuide_Rigged.fbx");
var model = Object.Instantiate(asset);
try
{
var animator = model.GetComponent<Animator>();
if (animator != null) animator.enabled = false;
var bones = model.GetComponentsInChildren<Transform>()
.Where(t => t.name.StartsWith("bone_")).ToArray();
Transform Bone(string name) => bones.First(t => t.name == name);
var hips = Bone("bone_0");
var spine = Bone("bone_1");
var chest = Bone("bone_2");
var leftArm = Bone("bone_6");
var leftElbow = Bone("bone_7");
var leftHand = Bone("bone_9");
var rightArm = Bone("bone_10");
var rightElbow = Bone("bone_11");
var rightHand = Bone("bone_13");
var leftThigh = Bone("bone_14");
var leftKnee = Bone("bone_15");
var rightThigh = Bone("bone_18");
var rightKnee = Bone("bone_19");
float ground = Lowest(model);
HangArm(leftArm, leftHand, 1f);
HangArm(rightArm, rightHand, -1f);
leftElbow.localRotation *= Quaternion.Euler(12f, 0f, 0f);
rightElbow.localRotation *= Quaternion.Euler(12f, 0f, 0f);
var relaxed = Read(bones);
var relaxedArmL = leftArm.rotation;
var relaxedArmR = rightArm.rotation;
var relaxedThighL = leftThigh.rotation;
var relaxedThighR = rightThigh.rotation;
var relaxedKneeL = leftKnee.localRotation;
var relaxedKneeR = rightKnee.localRotation;
WriteClip(model, bones, "Idle", 1.8f, 24, (t, poseBones) =>
{
Write(poseBones, relaxed);
float breath = Mathf.Sin(t * Mathf.PI * 2f);
spine.localRotation *= Quaternion.Euler(1.6f * breath, 0f, 0f);
chest.localRotation *= Quaternion.Euler(1.1f * breath, 0.6f * breath, 0f);
hips.position += Vector3.up * (0.006f * breath);
Plant(model, hips, ground);
});
WriteClip(model, bones, "Walk", 1.05f, 18, (t, poseBones) =>
{
Write(poseBones, relaxed);
float swing = Mathf.Sin(t * Mathf.PI * 2f);
leftThigh.rotation = Quaternion.AngleAxis(swing * 24f, Vector3.right) * relaxedThighL;
rightThigh.rotation = Quaternion.AngleAxis(-swing * 24f, Vector3.right) * relaxedThighR;
float leftBend = 10f + 16f * Mathf.InverseLerp(-1f, 1f, -swing);
float rightBend = 10f + 16f * Mathf.InverseLerp(-1f, 1f, swing);
leftKnee.localRotation = relaxedKneeL * Quaternion.Euler(leftBend, 0f, 0f);
rightKnee.localRotation = relaxedKneeR * Quaternion.Euler(rightBend, 0f, 0f);
leftArm.rotation = Quaternion.AngleAxis(-swing * 16f, Vector3.right) * relaxedArmL;
rightArm.rotation = Quaternion.AngleAxis(swing * 16f, Vector3.right) * relaxedArmR;
spine.localRotation *= Quaternion.Euler(2.5f, 0f, -swing * 3f);
hips.position += Vector3.up * (Mathf.Abs(swing) * 0.012f);
Plant(model, hips, ground);
});
AssetDatabase.SaveAssets();
return "Authored grounded Idle and Walk on CampGuide clips";
}
finally { Object.DestroyImmediate(model); }
}
static void HangArm(Transform upper, Transform hand, float side)
{
Vector3 from = hand.position - upper.position;
Vector3 to = Vector3.down * 0.92f + Vector3.right * side * 0.45f + Vector3.forward * 0.12f;
upper.rotation = Quaternion.FromToRotation(from.normalized, to.normalized) * upper.rotation;
}
static void Plant(GameObject model, Transform hips, float ground)
{
hips.position += Vector3.up * (ground - Lowest(model));
}
static float Lowest(GameObject model)
{
float min = float.MaxValue;
foreach (var skin in model.GetComponentsInChildren<SkinnedMeshRenderer>())
{
var mesh = skin.sharedMesh;
var matrices = skin.bones.Select((b, i) => b.localToWorldMatrix * mesh.bindposes[i]).ToArray();
var weights = mesh.boneWeights;
var vertices = mesh.vertices;
for (int i = 0; i < vertices.Length; i++)
{
var w = weights[i];
Vector3 p = matrices[w.boneIndex0].MultiplyPoint3x4(vertices[i]) * w.weight0
+ matrices[w.boneIndex1].MultiplyPoint3x4(vertices[i]) * w.weight1
+ matrices[w.boneIndex2].MultiplyPoint3x4(vertices[i]) * w.weight2
+ matrices[w.boneIndex3].MultiplyPoint3x4(vertices[i]) * w.weight3;
if (p.y < min) min = p.y;
}
}
return min;
}
sealed class Pose
{
public Vector3[] p, s;
public Quaternion[] q;
}
static Pose Read(Transform[] bones) => new Pose
{
p = bones.Select(t => t.localPosition).ToArray(),
s = bones.Select(t => t.localScale).ToArray(),
q = bones.Select(t => t.localRotation).ToArray()
};
static void Write(Transform[] bones, Pose pose)
{
for (int i = 0; i < bones.Length; i++)
{
bones[i].localPosition = pose.p[i];
bones[i].localRotation = pose.q[i];
bones[i].localScale = pose.s[i];
}
}
static void WriteClip(GameObject model, Transform[] bones, string name, float seconds, int frames, System.Action<float, Transform[]> pose)
{
var samples = new Pose[frames];
for (int f = 0; f < frames; f++)
{
float t = f / (float)(frames - 1);
pose(t, bones);
samples[f] = Read(bones);
}
samples[frames - 1] = samples[0];
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(
"Assets/Bundles/Characters/Human/CampGuide/Clips/" + name + ".anim");
if (clip == null) throw new FileNotFoundException(name);
clip.ClearCurves();
clip.frameRate = 30f;
for (int b = 0; b < bones.Length; b++)
{
string path = AnimationUtility.CalculateTransformPath(bones[b], model.transform);
for (int c = 0; c < 10; c++)
{
int component = c;
var keys = Enumerable.Range(0, frames).Select(f => new Keyframe(
seconds * f / (frames - 1),
component < 3 ? samples[f].p[b][component]
: component < 7 ? samples[f].q[b][component - 3]
: samples[f].s[b][component - 7])).ToArray();
string prop = component < 3 ? "m_LocalPosition." + "xyz"[component]
: component < 7 ? "m_LocalRotation." + "xyzw"[component - 3]
: "m_LocalScale." + "xyz"[component - 7];
AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve(path, typeof(Transform), prop), new AnimationCurve(keys));
}
}
var settings = AnimationUtility.GetAnimationClipSettings(clip);
settings.startTime = 0f;
settings.stopTime = seconds;
settings.loopTime = true;
settings.loopBlend = false;
AnimationUtility.SetAnimationClipSettings(clip, settings);
EditorUtility.SetDirty(clip);
}
public static string Preview()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
var shader = Shader.Find("AIGame/HomeExplorerCutout");
var stage = new GameObject("CampGuidePreview");
var rt = new RenderTexture(512, 512, 16, RenderTextureFormat.ARGB32);
var tex = new Texture2D(512, 512, TextureFormat.RGBA32, false);
try
{
var actor = (GameObject)PrefabUtility.InstantiatePrefab(prefab, stage.transform);
actor.transform.localPosition = new Vector3(0.4f, 0f, 0f);
actor.transform.localRotation = Quaternion.Euler(0f, 70f, 0f);
SetLayer(actor.transform);
var lightGo = new GameObject("Light");
lightGo.transform.SetParent(stage.transform, false);
var light = lightGo.AddComponent<Light>();
light.type = LightType.Directional;
light.intensity = 1.15f;
light.cullingMask = 1 << 31;
light.transform.rotation = Quaternion.Euler(38f, -28f, 0f);
var camGo = new GameObject("Camera");
camGo.transform.SetParent(stage.transform, false);
var camera = camGo.AddComponent<Camera>();
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = new Color(1f, 0f, 1f, 1f);
camera.cullingMask = 1 << 31;
camera.orthographic = true;
camera.orthographicSize = 1.45f;
camera.nearClipPlane = 0.05f;
camera.farClipPlane = 30f;
camera.transform.localPosition = new Vector3(0.15f, 1.7f, -3.6f);
camera.transform.LookAt(stage.transform.TransformPoint(0.4f, 0.7f, 0f));
camera.targetTexture = rt;
var anim = actor.GetComponentInChildren<Animator>();
if (anim != null)
{
anim.Update(0f);
anim.Play("Walk", 0, 0.3f);
anim.Update(0f);
}
camera.Render();
camera.targetTexture = null;
var mat = new Material(shader);
mat.SetColor("_Key", new Color(1f, 0f, 1f, 1f));
var cut = RenderTexture.GetTemporary(512, 512, 0, RenderTextureFormat.ARGB32);
Graphics.Blit(rt, cut, mat);
RenderTexture.active = cut;
tex.ReadPixels(new Rect(0, 0, 512, 512), 0, 0);
tex.Apply();
RenderTexture.active = null;
RenderTexture.ReleaseTemporary(cut);
string path = Path.GetFullPath(Path.Combine(Application.dataPath, "../../Tools/output/campguide_review/cutout.png"));
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllBytes(path, tex.EncodeToPNG());
Object.DestroyImmediate(mat);
return path;
}
finally
{
RenderTexture.active = null;
Object.DestroyImmediate(tex);
rt.Release();
Object.DestroyImmediate(rt);
Object.DestroyImmediate(stage);
}
}
static void SetLayer(Transform root)
{
root.gameObject.layer = 31;
foreach (Transform child in root) SetLayer(child);
}
}
#endif
@@ -0,0 +1,307 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AIGame.EditorTools;
using AIGame.Review;
using Newtonsoft.Json;
using Unity.Collections;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.Rendering;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public static class BuildCharacterAnimationLab
{
public const string Root = "Assets/Review/CharacterAnimationLab";
public const string ScenePath = Root + "/CharacterAnimationLab.unity";
const string Generated = Root + "/Generated";
static string Repo => Directory.GetParent(Application.dataPath).Parent.FullName;
static string DataPath => Path.Combine(Repo, "Tools/output/campguide_studio/rig_mixamo41/unity_review/review_data.json");
public class Bone { public string name; public int parent; public float[] rest; public float[][] bindpose; }
public class Variant { public string name; public float[][] vertices, normals, uv, auditEdges; public int[] triangles; public float[][][] weights; public int originalVertices, trianglesCount; }
public class Motion { public string name; public int fps; public float[][][] samples; }
public class Gait { public Dictionary<string,Motion> motions; }
public class Data { public Bone[] bones; public Variant[] variants; public Motion[] clips; public float translationScale,height; public float[][] translationOffsets,translationDeltaRotations,bindRotationCorrections; public int[] rotationParents; }
static Vector3 V(float[] x, int offset = 0) => new Vector3(x[offset], x[offset + 1], x[offset + 2]);
static Quaternion Q(float[] x) => new Quaternion(x[3], x[4], x[5], x[6]);
static void SetTRS(Transform t, float[] x) { t.localPosition = V(x); t.localRotation = Q(x); t.localScale = V(x, 7); }
static Matrix4x4 Matrix(float[][] rows)
{
var m = new Matrix4x4(); for (int r = 0; r < 4; r++) for (int c = 0; c < 4; c++) m[r, c] = rows[r][c]; return m;
}
static void Folder(string path)
{
if (AssetDatabase.IsValidFolder(path)) return;
var parent = path.Substring(0, path.LastIndexOf('/')); Folder(parent);
AssetDatabase.CreateFolder(parent, path.Substring(path.LastIndexOf('/') + 1));
}
static Material Material(string name, Color color, string destination = Generated)
{
var path = destination + "/" + name + ".mat";
var material = AssetDatabase.LoadAssetAtPath<Material>(path);
if (!material) { material = new Material(Shader.Find("Universal Render Pipeline/Lit")); AssetDatabase.CreateAsset(material, path); }
material.SetColor("_BaseColor", color); material.SetFloat("_Smoothness", .22f); material.SetFloat("_Metallic", 0);
EditorUtility.SetDirty(material); return material;
}
static Mesh MakeMesh(Variant item, Bone[] bones, string destination = Generated)
{
string path = destination + "/" + item.name + ".asset";
var mesh = AssetDatabase.LoadAssetAtPath<Mesh>(path);
if (!mesh) { mesh = new Mesh { name = item.name }; AssetDatabase.CreateAsset(mesh, path); }
mesh.Clear(); mesh.indexFormat = IndexFormat.UInt32;
mesh.vertices = item.vertices.Select(x => V(x)).ToArray();
mesh.normals = item.normals.Select(x => V(x)).ToArray();
mesh.uv = item.uv.Select(x => new Vector2(x[0], x[1])).ToArray();
mesh.triangles = item.triangles; mesh.bindposes = bones.Select(b => Matrix(b.bindpose)).ToArray();
var counts = new NativeArray<byte>(item.weights.Select(x => (byte)x.Length).ToArray(), Allocator.Temp);
var flattened = new NativeArray<BoneWeight1>(item.weights.SelectMany(x => x).Select(x => new BoneWeight1 { boneIndex = (int)x[0], weight = x[1] }).ToArray(), Allocator.Temp);
try { mesh.SetBoneWeights(counts, flattened); } finally { counts.Dispose(); flattened.Dispose(); }
mesh.RecalculateBounds(); mesh.RecalculateTangents(); EditorUtility.SetDirty(mesh); return mesh;
}
static AnimationClip MakeClip(Motion motion, Bone[] bones, string destination = Generated)
{
string path = destination + "/" + motion.name + ".anim";
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(path);
if (!clip) { clip = new AnimationClip(); AssetDatabase.CreateAsset(clip, path); }
clip.ClearCurves(); clip.name = motion.name; clip.frameRate = motion.fps; clip.legacy = false;
string[] paths = new string[bones.Length];
string[] properties = { "m_LocalPosition.x", "m_LocalPosition.y", "m_LocalPosition.z", "m_LocalRotation.x", "m_LocalRotation.y", "m_LocalRotation.z", "m_LocalRotation.w", "m_LocalScale.x", "m_LocalScale.y", "m_LocalScale.z" };
for (int b = 0; b < bones.Length; b++)
{
paths[b] = bones[b].parent < 0 ? bones[b].name : paths[bones[b].parent] + "/" + bones[b].name;
for (int channel = 0; channel < properties.Length; channel++)
{
var keys = new Keyframe[motion.samples.Length];
for (int f = 0; f < keys.Length; f++) keys[f] = new Keyframe(f / (float)motion.fps, motion.samples[f][b][channel]);
var curve = new AnimationCurve(keys);
for (int f = 0; f < keys.Length; f++) { AnimationUtility.SetKeyLeftTangentMode(curve, f, AnimationUtility.TangentMode.Linear); AnimationUtility.SetKeyRightTangentMode(curve, f, AnimationUtility.TangentMode.Linear); }
AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve(paths[b], typeof(Transform), properties[channel]), curve);
}
}
clip.EnsureQuaternionContinuity();
var settings = AnimationUtility.GetAnimationClipSettings(clip); settings.loopTime = true; AnimationUtility.SetAnimationClipSettings(clip, settings);
EditorUtility.SetDirty(clip); return clip;
}
public static string Build()
{
if (EditorApplication.isPlaying) throw new InvalidOperationException("Build in Edit Mode.");
var data = JsonConvert.DeserializeObject<Data>(File.ReadAllText(DataPath));
if (data.bones.Length != 41 || data.variants.Length != 3) throw new InvalidDataException("Expected 3 variants and 41 bones.");
Folder(Generated);
var meshes = data.variants.Select(v => MakeMesh(v, data.bones)).ToArray();
var clips = data.clips.Select(c => MakeClip(c, data.bones)).ToArray();
var clay = Material("Clay", new Color(.68f, .73f, .79f));
string texturedPath = Generated + "/CharacterTextured.mat";
var textured = AssetDatabase.LoadAssetAtPath<Material>(texturedPath);
if (!textured)
{
textured = new Material(AssetDatabase.LoadAssetAtPath<Material>("Assets/Bundles/Characters/Human/CampGuideStudio/CampGuideStudio.mat"));
textured.name = "CharacterTextured"; AssetDatabase.CreateAsset(textured, texturedPath);
}
string uiPrefab = JsonUIPrefabBuilder.BuildFromJsonFile(Path.Combine(Repo, "Doc/UIPrefabCreater/UI_CharacterAnimationLab.json"));
if (string.IsNullOrEmpty(uiPrefab)) throw new InvalidOperationException("Comparison UI JSON failed.");
var previous = SceneManager.GetActiveScene();
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive);
try
{
SceneManager.SetActiveScene(scene);
var root = new GameObject("CharacterAnimationLab");
var viewer = root.AddComponent<CharacterAnimationLab>(); viewer.clips = clips; viewer.texturedMaterial = textured; viewer.clayMaterial = clay;
viewer.travel = data.clips.Select(c => V(c.samples[c.samples.Length - 1][0]) - V(c.samples[0][0])).ToArray();
viewer.actors = new CharacterAnimationLab.Actor[3];
string[] labels = { "Mixamo 网站", "TokenRig 原始", "手部约束后" };
Color[] colors = { new Color(.49f,.78f,.85f), new Color(.94f,.72f,.43f), new Color(.62f,.82f,.51f) };
for (int i = 0; i < 3; i++)
{
var slot = new GameObject("Stage_" + data.variants[i].name); slot.transform.SetParent(root.transform, false); slot.transform.localPosition = new Vector3((i - 1) * .99f, 0, 0);
var rig = new GameObject("Rig"); rig.transform.SetParent(slot.transform, false); rig.transform.localScale = Vector3.one * (1.55f / 1.1348386f);
var bones = new Transform[data.bones.Length];
for (int b = 0; b < bones.Length; b++)
{
var go = new GameObject(data.bones[b].name); bones[b] = go.transform;
go.transform.SetParent(data.bones[b].parent < 0 ? rig.transform : bones[data.bones[b].parent], false); SetTRS(go.transform, data.bones[b].rest);
}
var meshGo = new GameObject("CharacterMesh"); meshGo.transform.SetParent(rig.transform, false);
var skin = meshGo.AddComponent<SkinnedMeshRenderer>(); skin.sharedMesh = meshes[i]; skin.bones = bones; skin.rootBone = bones[0]; skin.sharedMaterial = textured;
skin.quality = SkinQuality.Auto; skin.updateWhenOffscreen = true; skin.localBounds = new Bounds(new Vector3(0,.75f,0), new Vector3(3,3,3));
viewer.actors[i] = new CharacterAnimationLab.Actor { label = labels[i], rig = rig, hips = bones[0], rightHand = bones.First(b=>b.name=="mixamorig:RightHand"), renderer = skin };
var pedestal = GameObject.CreatePrimitive(PrimitiveType.Cylinder); pedestal.name = "Pedestal"; pedestal.transform.SetParent(slot.transform, false);
pedestal.transform.localPosition = new Vector3(0,-.02f,0); pedestal.transform.localScale = new Vector3(.98f,.01f,.98f);
pedestal.GetComponent<Renderer>().sharedMaterial = Material("Pedestal" + i, colors[i]); UnityEngine.Object.DestroyImmediate(pedestal.GetComponent<Collider>());
}
var floor = GameObject.CreatePrimitive(PrimitiveType.Cube); floor.name = "ReviewFloor"; floor.transform.SetParent(root.transform, false);
floor.transform.localPosition = new Vector3(0,-.09f,.15f); floor.transform.localScale = new Vector3(5.4f,.1f,3.2f);
floor.GetComponent<Renderer>().sharedMaterial = Material("Floor", new Color(.83f,.88f,.92f)); UnityEngine.Object.DestroyImmediate(floor.GetComponent<Collider>());
var background = new GameObject("BackgroundCamera").AddComponent<Camera>(); background.transform.SetParent(root.transform,false); background.depth=-10; background.cullingMask=0;
background.clearFlags=CameraClearFlags.SolidColor; background.backgroundColor=new Color(.75f,.83f,.89f);
var camera = new GameObject("ReviewCamera").AddComponent<Camera>(); camera.transform.SetParent(root.transform,false); camera.tag="MainCamera";
camera.orthographic=true; camera.rect=new Rect(0,.34f,1,.535f); camera.clearFlags=CameraClearFlags.SolidColor; camera.backgroundColor=background.backgroundColor; camera.allowHDR=false;
camera.nearClipPlane=.05f; camera.farClipPlane=30; camera.gameObject.AddComponent<AudioListener>(); viewer.reviewCamera=camera;
foreach (var setup in new[] { ("KeyLight",new Vector3(45,-35,0),1.1f), ("FillLight",new Vector3(35,145,0),.45f) })
{
var light=new GameObject(setup.Item1).AddComponent<Light>(); light.transform.SetParent(root.transform,false); light.type=LightType.Directional;
light.transform.rotation=Quaternion.Euler(setup.Item2); light.intensity=setup.Item3; light.shadows=setup.Item1=="KeyLight"?LightShadows.Soft:LightShadows.None;
}
RenderSettings.ambientMode=AmbientMode.Flat; RenderSettings.ambientLight=new Color(.65f,.69f,.73f); RenderSettings.fog=false;
var ui=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(uiPrefab),root.transform); viewer.ui=ui.transform;
ui.GetComponentInChildren<AnimationLabSeekBar>().viewer=viewer;
var events = new GameObject("EventSystem", typeof(EventSystem),typeof(StandaloneInputModule)); events.transform.SetParent(root.transform,false);
viewer.motion=0; viewer.time=10f/30; viewer.inPlace=true; viewer.Sample();
EditorSceneManager.MarkSceneDirty(scene); EditorSceneManager.SaveScene(scene,ScenePath);
PrefabUtility.SaveAsPrefabAsset(root,Generated+"/CharacterAnimationLab.prefab"); AssetDatabase.SaveAssets();
return JsonConvert.SerializeObject(new { scene=ScenePath, variants=3,bones=41,triangles=3000,clips=clips.Select(c=>new {c.name,c.length}), ui=uiPrefab });
}
finally
{
if (previous.IsValid() && previous.isLoaded) SceneManager.SetActiveScene(previous);
EditorSceneManager.CloseScene(scene,true);
}
}
public static string BuildLmGirl()
{
if (EditorApplication.isPlaying) throw new InvalidOperationException("Edit Mode required.");
const string targetRoot=Root+"/LmGirl";
const string generated=targetRoot+"/Generated";
if (SceneManager.GetSceneByPath(targetRoot+"/LmGirlAnimationLab.unity").isLoaded)
throw new InvalidOperationException("Open a different scene before rebuilding LmGirl; Unity cannot overwrite another loaded scene.");
Folder(generated);
var data=JsonConvert.DeserializeObject<Data>(File.ReadAllText(Path.Combine(Repo,"Tools/output/lmgirl_tpose_studio/rig_mixamo41/unity_review_data.json")));
var meshes=data.variants.ToDictionary(v=>v.name,v=>MakeMesh(v,data.bones,generated));
var website=JsonConvert.DeserializeObject<Data>(File.ReadAllText(Path.Combine(Repo,"Tools/output/lmgirl_tpose_studio/rig_mixamo41/website/unity_review_data.json")));
meshes["mixamo"]=MakeMesh(website.variants[0],website.bones,generated);
var gait=JsonConvert.DeserializeObject<Gait>(File.ReadAllText(Path.Combine(Repo,"Tools/output/lmgirl_tpose_studio/rig_mixamo41/gait_correction.json")));
var walk41=MakeClip(gait.motions["tokenrig"],data.bones,generated);
var walk33=MakeClip(gait.motions["mixamo"],website.bones,generated);
foreach (string suffix in new[]{"base","mix"})
{
string path=generated+"/LmGirlTpose_"+suffix+".png";
File.Copy(Path.Combine(Repo,"Tools/output/lmgirl_tpose_studio/xpbr/LmGirlTpose_"+suffix+".png"),path,true);
AssetDatabase.ImportAsset(path,ImportAssetOptions.ForceSynchronousImport);
var importer=(TextureImporter)AssetImporter.GetAtPath(path);
importer.sRGBTexture=suffix=="base";importer.maxTextureSize=1024;
importer.alphaIsTransparency=false;importer.SaveAndReimport();
}
string matPath=generated+"/LmGirlTextured.mat";
var material=AssetDatabase.LoadAssetAtPath<Material>(matPath);
if (!material)
{
material=new Material(AssetDatabase.LoadAssetAtPath<Material>(Generated+"/CharacterTextured.mat"));
AssetDatabase.CreateAsset(material,matPath);
}
material.SetTexture("_MainTex",AssetDatabase.LoadAssetAtPath<Texture2D>(generated+"/LmGirlTpose_base.png"));
material.SetTexture("_MixTex",AssetDatabase.LoadAssetAtPath<Texture2D>(generated+"/LmGirlTpose_mix.png"));
EditorUtility.SetDirty(material);
var uiPath=JsonUIPrefabBuilder.BuildFromJsonFile(Path.Combine(Repo,"Doc/UIPrefabCreater/UI_LmGirlAnimationLab.json"));
if (string.IsNullOrEmpty(uiPath)) throw new InvalidOperationException("LmGirl UI conversion failed.");
var previous=SceneManager.GetActiveScene();
var scene=EditorSceneManager.NewScene(NewSceneSetup.EmptyScene,NewSceneMode.Additive);
try
{
SceneManager.SetActiveScene(scene);
var root=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(Generated+"/CharacterAnimationLab.prefab"));
PrefabUtility.UnpackPrefabInstance(root,PrefabUnpackMode.Completely,InteractionMode.AutomatedAction);
root.name="LmGirlAnimationLab";
var viewer=root.GetComponent<CharacterAnimationLab>();
string[] variants={"mixamo","raw","post","local"};
string[] labels={"Mixamo 网站 · 33 骨","TokenRig 原始 · 41 骨","体素后处理 · 41 骨","局部约束 · 41 骨"};
var slots=viewer.actors.Select(a=>a.rig.transform.parent).ToList();
foreach(var actor in viewer.actors)UnityEngine.Object.DestroyImmediate(actor.rig);
slots.Add(UnityEngine.Object.Instantiate(slots[2],root.transform));
viewer.actors=new CharacterAnimationLab.Actor[4];
Color[] colors={new Color(.49f,.78f,.85f),new Color(.94f,.72f,.43f),new Color(.68f,.62f,.85f),new Color(.62f,.82f,.51f)};
Quaternion Rotation(float[] q)=>new Quaternion(q[0],q[1],q[2],q[3]);
for (int i=0;i<variants.Length;i++)
{
var actorData=i==0?website:data;
var slot=slots[i];slot.localPosition=new Vector3((i-1.5f)*.83f,0,0);
slot.name="Stage_LmGirl_"+variants[i];
slot.GetComponentInChildren<Renderer>().sharedMaterial=Material("LmGirlPedestal"+i,colors[i],generated);
var rig=new GameObject("Rig");rig.transform.SetParent(slot,false);rig.transform.localScale=Vector3.one*(1.55f/actorData.height);
var bones=new Transform[actorData.bones.Length];
for (int b=0;b<bones.Length;b++)
{
var go=new GameObject(actorData.bones[b].name);bones[b]=go.transform;
go.transform.SetParent(actorData.bones[b].parent<0?rig.transform:bones[actorData.bones[b].parent],false);
SetTRS(go.transform,actorData.bones[b].rest);
}
var meshGo=new GameObject("CharacterMesh");meshGo.transform.SetParent(rig.transform,false);
var skin=meshGo.AddComponent<SkinnedMeshRenderer>();skin.sharedMesh=meshes[variants[i]];skin.bones=bones;skin.rootBone=bones[0];
skin.sharedMaterial=material;skin.quality=SkinQuality.Auto;skin.updateWhenOffscreen=true;
skin.localBounds=new Bounds(new Vector3(0,.75f,0),new Vector3(3,3,3));
viewer.actors[i]=new CharacterAnimationLab.Actor{label=labels[i],rig=rig,hips=bones[0],
rightHand=bones.First(b=>b.name=="mixamorig:RightHand"),renderer=skin,
walkingClip=i==0?walk33:walk41,
translationScale=actorData.translationScale,translationOffsets=actorData.translationOffsets.Select(x=>V(x)).ToArray(),
translationDeltaRotations=actorData.translationDeltaRotations?.Select(Rotation).ToArray()??Array.Empty<Quaternion>(),
bindRotationCorrections=actorData.bindRotationCorrections?.Select(Rotation).ToArray()??Array.Empty<Quaternion>(),
rotationParents=actorData.rotationParents??Array.Empty<int>()};
}
UnityEngine.Object.DestroyImmediate(viewer.ui.gameObject);
var ui=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(uiPath),root.transform);
viewer.ui=ui.transform;ui.GetComponentInChildren<AnimationLabSeekBar>().viewer=viewer;
viewer.texturedMaterial=material;viewer.motion=0;viewer.time=10f/30;viewer.focus=-1;viewer.detail=0;viewer.yaw=0;viewer.textured=true;viewer.playing=true;
viewer.Sample();
RenderSettings.ambientMode=AmbientMode.Flat;RenderSettings.ambientLight=new Color(.65f,.69f,.73f);RenderSettings.fog=false;
if (!EditorSceneManager.SaveScene(scene,targetRoot+"/LmGirlAnimationLab.unity"))
throw new IOException("LmGirl scene was not saved.");
PrefabUtility.SaveAsPrefabAsset(root,generated+"/LmGirlAnimationLab.prefab");
// Keep the rejected generic-cleaning experiments inspectable as
// persistent prefabs without promoting them to the default lineup.
for(int b=0;b<data.bones.Length;b++)SetTRS(viewer.actors[1].renderer.bones[b],data.bones[b].rest);
for(int b=0;b<website.bones.Length;b++)SetTRS(viewer.actors[0].renderer.bones[b],website.bones[b].rest);
PrefabUtility.SaveAsPrefabAsset(viewer.actors[0].rig,generated+"/LmGirl_mixamo.prefab");
foreach (string variant in new[]{"raw","post","clean","clean_eps","local"})
{
var actor=viewer.actors[1];actor.renderer.sharedMesh=meshes[variant];
PrefabUtility.SaveAsPrefabAsset(actor.rig,generated+"/LmGirl_"+variant+".prefab");
}
AssetDatabase.SaveAssets();
return JsonConvert.SerializeObject(new{scene=targetRoot+"/LmGirlAnimationLab.unity",visibleVariants=4,websiteBones=website.bones.Length,tokenRigBones=data.bones.Length,triangles=4999,sourceClips=viewer.clips.Select(c=>AssetDatabase.GetAssetPath(c)),walkingClips=new[]{AssetDatabase.GetAssetPath(walk33),AssetDatabase.GetAssetPath(walk41)},translationScale=data.translationScale});
}
finally
{
if (previous.IsValid()&&previous.isLoaded)SceneManager.SetActiveScene(previous);
EditorSceneManager.CloseScene(scene,true);
}
}
public static string OpenLmGirl()
{
if (EditorSceneManager.GetSceneManagerSetup().Any(s=>SceneManager.GetSceneByPath(s.path).isDirty))throw new InvalidOperationException("Existing scene has unsaved changes.");
EditorSceneManager.OpenScene(Root+"/LmGirl/LmGirlAnimationLab.unity",OpenSceneMode.Single);
Selection.activeGameObject=UnityEngine.Object.FindObjectOfType<CharacterAnimationLab>().gameObject;
return SceneManager.GetActiveScene().path;
}
public static string RefineLayout()
{
if (EditorApplication.isPlaying) throw new InvalidOperationException("Edit Mode required.");
var viewer = UnityEngine.Object.FindObjectOfType<CharacterAnimationLab>();
if (!viewer || viewer.gameObject.scene.path != ScenePath) throw new InvalidOperationException("Open the comparison scene first.");
for (int i=0;i<viewer.actors.Length;i++)
{
var actor=viewer.actors[i];
actor.rig.transform.parent.localPosition=new Vector3((i-1)*.99f,0,0);
actor.rightHand=actor.renderer.bones.First(b=>b.name=="mixamorig:RightHand");
}
viewer.Sample();
EditorUtility.SetDirty(viewer);
EditorSceneManager.MarkSceneDirty(viewer.gameObject.scene);
EditorSceneManager.SaveScene(viewer.gameObject.scene);
PrefabUtility.SaveAsPrefabAsset(viewer.gameObject,Generated+"/CharacterAnimationLab.prefab");
AssetDatabase.SaveAssets();
return "Saved adaptive comparison framing, stage spacing and hand focus references.";
}
public static string Open()
{
if (EditorSceneManager.GetSceneManagerSetup().Any(s=>SceneManager.GetSceneByPath(s.path).isDirty))
throw new InvalidOperationException("Existing scene has unsaved changes; open comparison additively instead.");
EditorSceneManager.OpenScene(ScenePath,OpenSceneMode.Single);
Selection.activeGameObject=UnityEngine.Object.FindObjectOfType<CharacterAnimationLab>().gameObject;
return ScenePath;
}
}
#endif
+421
View File
@@ -0,0 +1,421 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.SceneManagement;
using Object = UnityEngine.Object;
/// <summary>Authors HomeCamp through Pipeline; never constructs the scene at runtime.</summary>
public static class BuildHomeCamp
{
const string Root = "Assets/Bundles/Scenes/Town/HomeCamp";
const string Town = "Assets/Bundles/Scenes/Town/TownSquare";
const string PrefabPath = Root + "/HomeCamp.prefab";
const string ScenePath = Root + "/HomeCamp.unity";
const int Layer = 29;
static readonly string[] Parts = { "ground", "mainhouse", "cottage", "fountain" };
static string Repo => Directory.GetParent(Application.dataPath).Parent.FullName;
static string Review => Path.Combine(Repo, "Tools/output/homecamp_review");
[Serializable] class Receipt { public string status = ""; public int triangles = 0; public int targetTriangles = 0; }
public static string Inspect()
{
return string.Join("\n", Enumerable.Range(0, SceneManager.sceneCount).Select(i =>
{ var s = SceneManager.GetSceneAt(i); return s.path + " dirty=" + s.isDirty; }));
}
static void Folder(string path)
{
if (AssetDatabase.IsValidFolder(path)) return;
var parent = path.Substring(0, path.LastIndexOf('/'));
Folder(parent); AssetDatabase.CreateFolder(parent, Path.GetFileName(path));
}
static Bounds BoundsOf(GameObject go)
{
var renderers = go.GetComponentsInChildren<Renderer>(true);
if (renderers.Length == 0) throw new InvalidOperationException("No renderer: " + go.name);
var b = renderers[0].bounds;
foreach (var r in renderers.Skip(1)) b.Encapsulate(r.bounds);
return b;
}
static void SetLayer(GameObject go)
{
foreach (var t in go.GetComponentsInChildren<Transform>(true)) t.gameObject.layer = Layer;
}
static void Source(string source, string destination)
{
if (!File.Exists(source)) throw new FileNotFoundException(source);
Folder(Path.GetDirectoryName(destination).Replace('\\', '/'));
File.Copy(source, destination, true);
AssetDatabase.ImportAsset(destination, ImportAssetOptions.ForceSynchronousImport);
}
static void TextureSettings(string path, bool srgb, bool backdrop = false)
{
var importer = (TextureImporter)AssetImporter.GetAtPath(path);
importer.textureType = TextureImporterType.Default;
importer.sRGBTexture = srgb; importer.alphaIsTransparency = false;
importer.mipmapEnabled = !backdrop; importer.maxTextureSize = 2048;
importer.textureCompression = TextureImporterCompression.Uncompressed;
importer.wrapMode = TextureWrapMode.Clamp;
importer.SaveAndReimport();
}
static Material SaveMaterial(string path, string shaderName)
{
var shader = Shader.Find(shaderName);
if (!shader) throw new InvalidOperationException("Missing shader " + shaderName);
var mat = AssetDatabase.LoadAssetAtPath<Material>(path);
if (!mat) { mat = new Material(shader); AssetDatabase.CreateAsset(mat, path); }
mat.shader = shader;
return mat;
}
public static string BuildAssets()
{
if (EditorApplication.isPlaying) throw new InvalidOperationException("Stop Play before authoring.");
AssetDatabase.ImportAsset("Assets/Bundles/Shaders/XShader/HomeCampXPbr.shader", ImportAssetOptions.ForceSynchronousImport);
// Validate all four receipts before importing any model.
foreach (var part in Parts)
{
var path = Path.Combine(Repo, "Tools/output/homecamp_studio", part, "production.json");
var receipt = JsonUtility.FromJson<Receipt>(File.ReadAllText(path));
if (receipt.status != "ready_for_unity" || receipt.triangles * 10 > receipt.targetTriangles * 13)
throw new InvalidOperationException("Unfinished model " + part);
}
foreach (var part in Parts)
{
var dir = Root + "/" + part;
var source = Path.Combine(Repo, "Tools/output/homecamp_studio", part, "xpbr");
foreach (var suffix in new[] { ".fbx", "_base.png", "_mix.png" })
Source(Path.Combine(source, part + suffix), dir + "/" + part + suffix);
var fbx = dir + "/" + part + ".fbx";
var importer = (ModelImporter)AssetImporter.GetAtPath(fbx);
importer.globalScale = 1; importer.useFileScale = false; importer.bakeAxisConversion = true;
importer.materialImportMode = ModelImporterMaterialImportMode.None;
importer.meshCompression = ModelImporterMeshCompression.Off;
importer.isReadable = part == "ground"; importer.addCollider = false;
importer.SaveAndReimport();
TextureSettings(dir + "/" + part + "_base.png", true);
TextureSettings(dir + "/" + part + "_mix.png", false);
var mat = SaveMaterial(dir + "/" + part + ".mat", "Test/HomeCampXPbr");
mat.SetTexture("_MainTex", AssetDatabase.LoadAssetAtPath<Texture2D>(dir + "/" + part + "_base.png"));
mat.SetTexture("_MixTex", AssetDatabase.LoadAssetAtPath<Texture2D>(dir + "/" + part + "_mix.png"));
mat.SetColor("_BaseColor", Color.white); mat.SetFloat("_Metallic", 1); mat.SetFloat("_Roughness", 1); mat.SetFloat("_AO", 1);
EditorUtility.SetDirty(mat); AssetDatabase.SaveAssetIfDirty(mat);
var scene = EditorSceneManager.NewPreviewScene();
try
{
var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(fbx), scene);
go.name = part;
go.transform.localPosition = Vector3.zero; go.transform.localRotation = Quaternion.identity; go.transform.localScale = Vector3.one;
foreach (var r in go.GetComponentsInChildren<Renderer>()) r.sharedMaterial = mat;
if (part == "ground")
foreach (var f in go.GetComponentsInChildren<MeshFilter>())
{ var c = f.gameObject.AddComponent<MeshCollider>(); c.sharedMesh = f.sharedMesh; c.convex = false; }
PrefabUtility.SaveAsPrefabAsset(go, dir + "/" + part + ".prefab");
}
finally { EditorSceneManager.ClosePreviewScene(scene); }
}
Source(Path.Combine(Repo, "Doc/Design/Scene/HomeCamp/backdrop.png"), Root + "/backdrop.png");
TextureSettings(Root + "/backdrop.png", true, true);
var background = SaveMaterial(Root + "/backdrop.mat", "Universal Render Pipeline/Unlit");
background.SetTexture("_BaseMap", AssetDatabase.LoadAssetAtPath<Texture2D>(Root + "/backdrop.png"));
background.SetColor("_BaseColor", Color.white);
EditorUtility.SetDirty(background); AssetDatabase.SaveAssetIfDirty(background);
foreach (var name in new[] { "tree_card", "flower_card" })
{
Source(Path.Combine(Repo, "Doc/Design/Scene/HomeCamp", name + ".png"), Root + "/" + name + ".png");
TextureSettings(Root + "/" + name + ".png", true);
var ti = (TextureImporter)AssetImporter.GetAtPath(Root + "/" + name + ".png");
ti.alphaIsTransparency = true; ti.SaveAndReimport();
var card = SaveMaterial(Root + "/" + name + ".mat", "Universal Render Pipeline/Unlit");
card.SetTexture("_BaseMap", AssetDatabase.LoadAssetAtPath<Texture2D>(Root + "/" + name + ".png"));
card.SetColor("_BaseColor", Color.white); card.SetFloat("_AlphaClip", 1); card.SetFloat("_Cutoff", .55f);
card.EnableKeyword("_ALPHATEST_ON"); card.SetFloat("_Cull", 0); card.renderQueue = 2450;
card.SetOverrideTag("RenderType", "TransparentCutout");
EditorUtility.SetDirty(card); AssetDatabase.SaveAssetIfDirty(card);
}
BuildGroundSurface();
return "Imported four Studio assets + XPbr, authored four component Prefabs and unlit background material.";
}
static void BuildGroundSurface()
{
Source(Path.Combine(Repo, "Doc/Design/Scene/HomeCamp/ground_surface.png"), Root + "/ground_surface.png");
TextureSettings(Root + "/ground_surface.png", true);
var mat = SaveMaterial(Root + "/ground_surface.mat", "Universal Render Pipeline/Unlit");
mat.SetTexture("_BaseMap", AssetDatabase.LoadAssetAtPath<Texture2D>(Root + "/ground_surface.png"));
mat.SetColor("_BaseColor", Color.white);
EditorUtility.SetDirty(mat); AssetDatabase.SaveAssetIfDirty(mat);
var mesh = AssetDatabase.LoadAssetAtPath<Mesh>(Root + "/GroundSurface.asset");
if (!mesh) { mesh = new Mesh(); AssetDatabase.CreateAsset(mesh, Root + "/GroundSurface.asset"); }
mesh.Clear(); mesh.name = "HomeCamp continuous ground surface";
mesh.vertices = new[] {new Vector3(-18,0,-21), new Vector3(-18,0,21), new Vector3(18,0,21), new Vector3(18,0,-21)};
mesh.uv = new[] {new Vector2(0,0),new Vector2(0,1),new Vector2(1,1),new Vector2(1,0)};
mesh.triangles = new[] {0,1,2,0,2,3}; mesh.RecalculateNormals(); mesh.RecalculateTangents(); mesh.RecalculateBounds();
EditorUtility.SetDirty(mesh); AssetDatabase.SaveAssetIfDirty(mesh);
var scene = EditorSceneManager.NewPreviewScene();
try
{
var go = new GameObject("GroundSurface"); SceneManager.MoveGameObjectToScene(go, scene);
go.AddComponent<MeshFilter>().sharedMesh = mesh; go.AddComponent<MeshRenderer>().sharedMaterial = mat;
go.AddComponent<MeshCollider>().sharedMesh = mesh;
PrefabUtility.SaveAsPrefabAsset(go, Root + "/GroundSurface.prefab");
}
finally { EditorSceneManager.ClosePreviewScene(scene); }
}
static GameObject Place(Transform parent, string path, string name, Vector3 pos, float size, bool width = false, bool faceCenter = false)
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (!prefab) throw new FileNotFoundException(path);
var go = (GameObject)PrefabUtility.InstantiatePrefab(prefab, parent);
go.name = name; go.transform.localPosition = Vector3.zero; go.transform.localRotation = Quaternion.identity;
go.transform.localScale = Vector3.one;
var b = BoundsOf(go);
var current = width ? Mathf.Max(b.size.x, b.size.z) : b.size.y;
go.transform.localScale = Vector3.one * (size / current);
if (faceCenter)
{ var away = new Vector3(pos.x, 0, pos.z - .8f); go.transform.localRotation = Quaternion.LookRotation(away, Vector3.up); }
go.transform.position = pos;
SetLayer(go);
return go;
}
static string PartPath(string part) => Root + "/" + part + "/" + part + ".prefab";
static string ReusePath(string part)
{
if (part == "tree" || part == "bush")
{
var name = part == "bush" ? "shrub" : "tree";
return "Assets/Bundles/Scenes/Prototype/Battlefield/A/Components/" + name + "/" + name + ".prefab";
}
return Town + "/" + part + "/" + part + ".prefab";
}
static float GroundY(GameObject ground, float x, float z)
{
var ray = new Ray(new Vector3(x, 60, z), Vector3.down);
var hits = ground.GetComponentsInChildren<MeshCollider>().Select(c =>
{ RaycastHit hit; return c.Raycast(ray, out hit, 120) ? hit.point.y : float.NegativeInfinity; }).ToArray();
var y = hits.Max();
if (float.IsNegativeInfinity(y)) throw new InvalidOperationException("Ground ray missed at " + x + "," + z);
return y;
}
static GameObject FoliageCard(Transform parent, string name, string image, Vector3 bottom, float height)
{
var go = GameObject.CreatePrimitive(PrimitiveType.Quad); go.name = name;
go.transform.SetParent(parent, false); Object.DestroyImmediate(go.GetComponent<Collider>());
var texture = AssetDatabase.LoadAssetAtPath<Texture2D>(Root + "/" + image + ".png");
var rotation = Quaternion.LookRotation(new Vector3(0, 1.8f, 3) - new Vector3(0, 15, -27), Vector3.up);
go.transform.rotation = rotation;
go.transform.position = bottom + (rotation * Vector3.up) * height * .5f;
go.transform.localScale = new Vector3(height * texture.width / texture.height, height, 1);
var renderer = go.GetComponent<Renderer>(); renderer.sharedMaterial = AssetDatabase.LoadAssetAtPath<Material>(Root + "/" + image + ".mat");
renderer.shadowCastingMode = ShadowCastingMode.Off; renderer.receiveShadows = false;
SetLayer(go); return go;
}
static Camera MakeCamera(Scene scene, Transform parent)
{
var go = new GameObject("HomeCampCamera"); SceneManager.MoveGameObjectToScene(go, scene);
if (parent) go.transform.SetParent(parent, false);
var camera = go.AddComponent<Camera>();
go.transform.position = new Vector3(0, 15, -27);
go.transform.LookAt(new Vector3(0, 1.8f, 3));
camera.fieldOfView = 48; camera.nearClipPlane = .1f; camera.farClipPlane = 180;
camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = new Color(.46f, .72f, .88f);
camera.cullingMask = 1 << Layer; camera.scene = scene;
camera.allowHDR = false; camera.allowMSAA = true;
return camera;
}
static void Light(Scene scene, Transform parent)
{
var go = new GameObject("HomeCampSun"); SceneManager.MoveGameObjectToScene(go, scene);
if (parent) go.transform.SetParent(parent, false);
var light = go.AddComponent<Light>(); light.type = LightType.Directional;
light.color = new Color(1, .95f, .84f); light.intensity = 1.05f; light.shadows = LightShadows.Soft;
light.cullingMask = 1 << Layer; go.transform.rotation = Quaternion.Euler(43, -32, 0);
}
static void Background(Transform parent, Camera camera)
{
var go = GameObject.CreatePrimitive(PrimitiveType.Quad); go.name = "DistantLakeWaterfallCastle";
go.transform.SetParent(parent, false); Object.DestroyImmediate(go.GetComponent<Collider>());
var renderer = go.GetComponent<MeshRenderer>();
renderer.sharedMaterial = AssetDatabase.LoadAssetAtPath<Material>(Root + "/backdrop.mat");
renderer.shadowCastingMode = ShadowCastingMode.Off; renderer.receiveShadows = false;
const float distance = 75;
var halfHeight = distance * Mathf.Tan(camera.fieldOfView * Mathf.Deg2Rad * .5f);
var width = halfHeight * 2 * 1080f / 1920;
var height = width / 1.5f;
go.transform.position = camera.transform.position + camera.transform.forward * distance + camera.transform.up * (halfHeight - height * .5f);
go.transform.rotation = camera.transform.rotation;
go.transform.localScale = new Vector3(width * 1.04f, height * 1.04f, 1);
SetLayer(go);
}
public static string Build()
{
if (EditorApplication.isPlaying) throw new InvalidOperationException("Author in Edit Mode.");
var previous = SceneManager.GetActiveScene();
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive);
try
{
SceneManager.SetActiveScene(scene);
var root = new GameObject("HomeCamp");
var ground = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(Root + "/GroundSurface.prefab"), root.transform);
ground.name = "Ground"; SetLayer(ground);
// Final source ground must be horizontal; reject a vertical generated plate.
var gb = BoundsOf(ground);
if (gb.size.y > Mathf.Min(gb.size.x, gb.size.z) * .3f)
throw new InvalidOperationException("Ground requires offline orientation correction before assembly: " + gb.size);
Physics.SyncTransforms();
var placements = new List<string>();
void Put(string part, string name, float x, float z, float size, bool width = false, bool facing = false, bool reused = false)
{
var y = GroundY(ground, x, z);
if (reused && (part == "tree" || part == "bush"))
{
FoliageCard(root.transform, name, part == "tree" ? "tree_card" : "flower_card", new Vector3(x,y,z), size);
placements.Add(name + " card groundY=" + y.ToString("F3")); return;
}
var path = reused ? ReusePath(part) : PartPath(part);
var go = Place(root.transform, path, name, new Vector3(x, y, z), size, width, facing);
var bottom = BoundsOf(go).min.y;
go.transform.position += Vector3.up * (y - bottom);
placements.Add(name + " groundY=" + y.ToString("F3"));
}
Put("mainhouse", "MainHouse", 0, 7.2f, 6.5f, false, true);
Put("mainhouse", "HouseLeft", -7.5f, 4, 4, false, true);
Put("mainhouse", "HouseRight", 7.5f, 4, 4, false, true);
Put("fountain", "Fountain", 0, .8f, 4.4f, true);
Put("tree", "TreeBackLeft", -9, 8, 7, false, false, true);
Put("tree", "TreeBackRight", 9, 9, 6.5f, false, false, true);
Put("tree", "TreeRearLeft", -5.6f, 14, 4.8f, false, false, true);
Put("tree", "TreeRearRight", 5.6f, 14, 4.8f, false, false, true);
Put("tree", "TreeFrontLeft", -10, -4, 5, false, false, true);
Put("lamp", "LampLeft", -5.2f, -.5f, 2.6f, false, false, true);
Put("lamp", "LampRight", 5.2f, 1, 2.6f, false, false, true);
Put("lamp", "LampForeground", 5.5f, -6, 2.7f, false, false, true);
Put("bench", "BenchFrontLeft", -4.4f, -4, 2.2f, true, true, true);
for (int i = 0; i < 12; i++)
{
var angle = (i * 30f) * Mathf.Deg2Rad;
Put("bush", "FountainFlowers" + i, Mathf.Sin(angle) * 2.7f, .8f + Mathf.Cos(angle) * 2.7f, .55f, false, false, true);
}
foreach (var p in new[] { new Vector2(-7,-5), new Vector2(-5,-7), new Vector2(6,-6), new Vector2(8,-3), new Vector2(-9,2), new Vector2(9,2) })
Put("bush", "BorderFlowers", p.x, p.y, .9f, false, false, true);
var camera = MakeCamera(scene, root.transform);
Light(scene, root.transform); Background(root.transform, camera); SetLayer(root);
RenderSettings.ambientMode = AmbientMode.Trilight;
RenderSettings.ambientSkyColor = new Color(.68f, .75f, .82f);
RenderSettings.ambientEquatorColor = new Color(.55f, .57f, .52f);
RenderSettings.ambientGroundColor = new Color(.35f, .34f, .29f);
PrefabUtility.SaveAsPrefabAsset(root, PrefabPath);
EditorSceneManager.SaveScene(scene, ScenePath);
Directory.CreateDirectory(Review);
File.WriteAllText(Path.Combine(Review, "assembly.txt"), string.Join("\n", placements));
CaptureCamera(camera, 1080, 1920, "homecamp_portrait.png");
CaptureCamera(camera, 1080, 2400, "homecamp_tall.png");
return "Saved " + PrefabPath + " and " + ScenePath + "; " + placements.Count + " placements; portrait/tall rendered.";
}
finally
{
if (previous.IsValid() && previous.isLoaded) SceneManager.SetActiveScene(previous);
EditorSceneManager.CloseScene(scene, true);
}
}
static void CaptureCamera(Camera camera, int width, int height, string name)
{
Directory.CreateDirectory(Review);
var rt = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.ARGB32);
var old = RenderTexture.active; var oldTarget = camera.targetTexture; var oldAspect = camera.aspect;
var oldAsync = ShaderUtil.allowAsyncCompilation;
var texture = new Texture2D(width, height, TextureFormat.RGB24, false);
try
{
ShaderUtil.allowAsyncCompilation = false;
camera.aspect = (float)width / height; camera.targetTexture = rt; camera.Render(); camera.Render();
RenderTexture.active = rt; texture.ReadPixels(new Rect(0, 0, width, height), 0, 0); texture.Apply();
File.WriteAllBytes(Path.Combine(Review, name), texture.EncodeToPNG());
}
finally { ShaderUtil.allowAsyncCompilation = oldAsync; camera.targetTexture = oldTarget; camera.aspect = oldAspect; RenderTexture.active = old; RenderTexture.ReleaseTemporary(rt); Object.DestroyImmediate(texture); }
}
public static string CaptureParts() => CaptureSet(Parts);
public static string CaptureReuse() => CaptureSet(new[] { "tree", "lamp", "bench", "bush" });
static string CaptureSet(IEnumerable<string> parts)
{
foreach (var part in parts)
{
var scene = EditorSceneManager.NewPreviewScene();
try
{
var path = Parts.Contains(part) ? PartPath(part) : ReusePath(part);
var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(path), scene);
SetLayer(go); Light(scene, null);
var b = BoundsOf(go); var extent = Mathf.Max(b.size.x, Mathf.Max(b.size.y, b.size.z));
var camera = MakeCamera(scene, null); camera.orthographic = true; camera.orthographicSize = extent * .7f;
camera.nearClipPlane = Mathf.Max(.00001f, extent * .01f); camera.farClipPlane = Mathf.Max(10, extent * 10);
foreach (var direction in new[] { "front", "side", "back", "perspective" })
{
var offset = direction == "front" ? new Vector3(0,.15f,-3) : direction == "side" ? new Vector3(3,.15f,0) : direction == "back" ? new Vector3(0,.15f,3) : new Vector3(2,1.8f,-3);
camera.transform.position = b.center + offset * extent;
camera.transform.LookAt(b.center);
CaptureCamera(camera, 640, 640, part + "_" + direction + ".png");
}
}
finally { EditorSceneManager.ClosePreviewScene(scene); }
}
return "Rendered four views per component.";
}
public static string Verify()
{
var report = new List<string>();
foreach (var part in Parts)
{
var p = AssetDatabase.LoadAssetAtPath<GameObject>(PartPath(part));
if (!p) throw new InvalidOperationException("Missing component " + part);
var triangles = p.GetComponentsInChildren<MeshFilter>().Sum(f => (int)f.sharedMesh.GetIndexCount(0) / 3);
var receipt = JsonUtility.FromJson<Receipt>(File.ReadAllText(Path.Combine(Repo, "Tools/output/homecamp_studio", part, "production.json")));
if (triangles > receipt.targetTriangles * 1.3f) throw new InvalidOperationException("Triangle budget " + part);
foreach (var r in p.GetComponentsInChildren<Renderer>())
if (!r.sharedMaterial || r.sharedMaterial.shader.name != "Test/HomeCampXPbr") throw new InvalidOperationException("XPbr " + part);
report.Add(part + "=" + triangles);
}
var root = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
if (!root || !File.Exists(ScenePath)) throw new InvalidOperationException("Missing assembled scene/prefab");
if (root.GetComponentsInChildren<MeshCollider>().Length == 0) throw new InvalidOperationException("Missing ground collider");
var preview = EditorSceneManager.NewPreviewScene();
var triangleCount = 0;
try
{
var instance = (GameObject)PrefabUtility.InstantiatePrefab(root, preview);
var ground = instance.transform.Find("Ground").gameObject;
Physics.SyncTransforms();
for (int x=-8; x<=8; x+=4) for (int z=-8; z<=8; z+=4) GroundY(ground,x,z);
triangleCount = instance.GetComponentsInChildren<MeshFilter>().Sum(f => (int)f.sharedMesh.GetIndexCount(0)/3);
}
finally { EditorSceneManager.ClosePreviewScene(preview); }
var backdrop = root.transform.Find("DistantLakeWaterfallCastle");
if (!backdrop || backdrop.GetComponent<Collider>()) throw new InvalidOperationException("Backdrop collider");
var bg = backdrop.GetComponent<Renderer>();
if (bg.sharedMaterial.shader.name != "Universal Render Pipeline/Unlit" || bg.shadowCastingMode != ShadowCastingMode.Off)
throw new InvalidOperationException("Backdrop must be unlit without shadows");
if (EditorBuildSettings.scenes.Any(s => s.path == ScenePath)) throw new InvalidOperationException("Review scene unexpectedly added to build");
Directory.CreateDirectory(Review);
File.WriteAllText(Path.Combine(Review, "verification.txt"), "PASS " + string.Join("; ", report) + "; assembledTriangles=" + triangleCount + "; ground collision 25/25; unlit quad backdrop; independent scene; cottage/Studio ground retained as review candidates and not placed");
return File.ReadAllText(Path.Combine(Review, "verification.txt"));
}
public static string OpenForReview()
{
var scene = SceneManager.GetSceneByPath(ScenePath);
if (!scene.IsValid() || !scene.isLoaded) scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Additive);
SceneManager.SetActiveScene(scene);
var root = scene.GetRootGameObjects().Single(); Selection.activeGameObject = root;
var camera = root.GetComponentInChildren<Camera>();
if (SceneView.lastActiveSceneView != null) SceneView.lastActiveSceneView.AlignViewToObject(camera.transform);
return "Opened HomeCamp additively; existing scenes preserved.";
}
}
#endif
+15 -4
View File
@@ -66,14 +66,16 @@ public static class BuildT2Visuals
}
/// <summary>Builds {Name}_T2.prefab for every partner whose accessory static exists; returns one line per partner.</summary>
public static string BuildPrefabs()
public static string BuildPrefabs() => BuildSelected(-1);
public static string BuildTideWhale() => BuildSelected(9);
static string BuildSelected(int pet)
{
if (EditorApplication.isPlaying) throw new Exception("Build T2 visuals in Edit Mode");
var lines = new List<string>();
var scene = EditorSceneManager.NewPreviewScene();
try
{
foreach (var s in Specs)
foreach (var s in Specs.Where(s => pet < 0 || s.Pet == pet))
{
var accessory = AssetDatabase.LoadAssetAtPath<GameObject>(AccessoryPath(s));
if (accessory == null) { lines.Add(s.Name + ": accessory missing, skipped"); continue; }
@@ -109,13 +111,15 @@ public static class BuildT2Visuals
}
/// <summary>Nests each {Name}_T2 prefab as Heroes/HeroN/VisualT2 (inactive, same scale as Visual); runtime shows it for tier 2.</summary>
public static string ApplyArena()
public static string ApplyArena() => ApplySelected(-1);
public static string ApplyTideWhale() => ApplySelected(9);
static string ApplySelected(int pet)
{
if (EditorApplication.isPlaying) throw new Exception("Apply T2 visuals in Edit Mode");
var root = PrefabUtility.LoadPrefabContents(Arena); var done = new List<int>();
try
{
foreach (var s in Specs)
foreach (var s in Specs.Where(s => pet < 0 || s.Pet == pet))
{
var t2 = AssetDatabase.LoadAssetAtPath<GameObject>(T2Path(s)); if (t2 == null) continue;
var hero = root.transform.Find("Heroes/Hero" + s.Pet) ?? throw new Exception("Arena missing Hero" + s.Pet);
@@ -131,6 +135,13 @@ public static class BuildT2Visuals
return "Arena VisualT2 nested for heroes " + string.Join(",", done);
}
public static string VerifyComplete()
{
foreach (var s in Specs)
if (AssetDatabase.LoadAssetAtPath<GameObject>(T2Path(s)) == null) throw new Exception("Missing T2 " + s.Name);
return Verify();
}
public static string Verify()
{
var arena = AssetDatabase.LoadAssetAtPath<GameObject>(Arena); var lines = new List<string>();
+9 -5
View File
@@ -361,7 +361,11 @@ public static class BuildTownSquare
group.AssetTags = "art";
BundleCollectorSettingData.ModifyGroup(package, group);
const string collectPath = "Assets/Bundles/Scenes";
const string broadPath = "Assets/Bundles/Scenes";
const string collectPath = "Assets/Bundles/Scenes/Town/TownSquare/TownSquare.prefab";
var broad = group.Collectors.Find(c => c.CollectPath == broadPath);
if (broad != null)
BundleCollectorSettingData.RemoveCollector(group, broad);
var collector = group.Collectors.Find(c => c.CollectPath == collectPath);
if (collector == null)
{
@@ -371,8 +375,8 @@ public static class BuildTownSquare
CollectorGUID = AssetDatabase.AssetPathToGUID(collectPath),
CollectorType = ECollectorType.MainAssetCollector,
AddressRuleName = nameof(AddressByFileName),
PackRuleName = nameof(PackDirectory),
FilterRuleName = nameof(CollectAll),
PackRuleName = nameof(PackSeparately),
FilterRuleName = nameof(CollectPrefab),
AssetTags = "art",
};
BundleCollectorSettingData.CreateCollector(group, collector);
@@ -381,8 +385,8 @@ public static class BuildTownSquare
{
collector.CollectorGUID = AssetDatabase.AssetPathToGUID(collectPath);
collector.AddressRuleName = nameof(AddressByFileName);
collector.PackRuleName = nameof(PackDirectory);
collector.FilterRuleName = nameof(CollectAll);
collector.PackRuleName = nameof(PackSeparately);
collector.FilterRuleName = nameof(CollectPrefab);
BundleCollectorSettingData.ModifyCollector(group, collector);
}
@@ -0,0 +1,76 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
public static class InspectBoyAnimator
{
static string PathOf(UnityEngine.Object o)=>o?AssetDatabase.GetAssetPath(o):null;
static string Hierarchy(Transform t)=>!t?null:!t.parent?t.name:Hierarchy(t.parent)+"/"+t.name;
static object Clip(AnimationClip c)=>new{c.name,path=PathOf(c),c.legacy,c.humanMotion,c.length,curves=AnimationUtility.GetCurveBindings(c).Length,
paths=AnimationUtility.GetCurveBindings(c).Select(x=>x.path).Distinct().Take(5).ToArray()};
static object AnimatorInfo(Animator a)
{
var controller=a.runtimeAnimatorController;
var ac=controller as AnimatorController;
return new{path=Hierarchy(a.transform),asset=PathOf(a.gameObject),prefabSource=PathOf(PrefabUtility.GetCorrespondingObjectFromSource(a.gameObject)),
a.enabled,a.applyRootMotion,a.updateMode,a.cullingMode,a.isHuman,bindingWarning=new SerializedObject(a).FindProperty("m_WarningMessage")?.stringValue,
avatar=a.avatar?new{name=a.avatar.name,path=PathOf(a.avatar),a.avatar.isValid,a.avatar.isHuman}:null,
controller=controller?new{name=controller.name,path=PathOf(controller),clips=controller.animationClips.Select(Clip).ToArray()}:null,
states=ac?ac.layers.Select(l=>new{l.name,defaultState=l.stateMachine.defaultState?l.stateMachine.defaultState.name:null,
states=l.stateMachine.states.Select(s=>new{s.state.name,motion=PathOf(s.state.motion),motionName=s.state.motion?s.state.motion.name:null}).ToArray()}).ToArray():null,
components=a.GetComponents<Component>().Select(c=>c?c.GetType().FullName:"Missing script").ToArray()};
}
public static string Inspect()
{
var selected=Selection.activeGameObject;
var selectedAncestors=selected?selected.GetComponentsInParent<Animator>(true).Concat(selected.GetComponentsInChildren<Animator>(true)).Distinct().Select(AnimatorInfo).ToArray():null;
string[] paths={"Assets/Bundles/Characters/Human/test/Boy_mixamo/CampGuideStudio.fbx",
"Assets/Bundles/Characters/Human/Common/CampGuideStudio@Walking.fbx",
"Assets/Bundles/Characters/Human/Common/CampGuideStudio@Breathing Idle.fbx",
"Assets/Bundles/Characters/Human/Common/CampGuideStudio@Happy Idle.fbx"};
var importers=paths.Select(p=>
{
var importer=AssetImporter.GetAtPath(p) as ModelImporter;
var model=AssetDatabase.LoadAssetAtPath<GameObject>(p);
return new{path=p,exists=importer!=null,type=importer?importer.animationType.ToString():null,
avatarSetup=importer?importer.avatarSetup.ToString():null,sourceAvatar=importer?PathOf(importer.sourceAvatar):null,
avatars=AssetDatabase.LoadAllAssetsAtPath(p).OfType<Avatar>().Select(a=>new{a.name,a.isHuman,a.isValid}).ToArray(),
clips=AssetDatabase.LoadAllAssetsAtPath(p).OfType<AnimationClip>().Select(Clip).ToArray()};
}).ToArray();
var result=new{selected=selected?Hierarchy(selected.transform):null,selectedAnimators=selectedAncestors,
sceneAnimators=UnityEngine.Object.FindObjectsOfType<Animator>(true).Select(AnimatorInfo).ToArray(),importers};
var output=JsonConvert.SerializeObject(result,Formatting.Indented);
var directory=System.IO.Path.Combine(Directory.GetParent(Application.dataPath).Parent.FullName,"Tools/output/campguide_studio/animator_warning");
Directory.CreateDirectory(directory);File.WriteAllText(System.IO.Path.Combine(directory,"inspection.json"),output);
return output;
}
public static string Direction()
{
object V(Vector3 v)=>new{x=v.x,y=v.y,z=v.z};
object Rig(Transform t)
{
var bones=t.GetComponentsInChildren<Transform>(true);
Transform Bone(string n)=>bones.FirstOrDefault(b=>b.name.EndsWith(n));
var foot=Bone("LeftFoot");var toe=Bone("LeftToeBase");
return new{name=Hierarchy(t),localEuler=V(t.localEulerAngles),worldEuler=V(t.eulerAngles),scale=V(t.lossyScale),
worldToeDirection=foot&&toe?V((toe.position-foot.position).normalized):null,
localToeDirection=foot&&toe?V(t.InverseTransformDirection(toe.position-foot.position).normalized):null,
parents=t.GetComponentsInParent<Transform>().Select(p=>new{p.name,euler=V(p.localEulerAngles)}).ToArray()};
}
var viewer=UnityEngine.Object.FindObjectOfType<AIGame.Review.CharacterAnimationLab>();
var clips=AssetDatabase.LoadAllAssetsAtPath("Assets/Bundles/Characters/Human/Common/CampGuideStudio@Walking.fbx").OfType<AnimationClip>().Where(c=>!c.name.StartsWith("__preview__")).Select(c=>
{
var settings=AnimationUtility.GetAnimationClipSettings(c);
return new{c.name,c.humanMotion,settings.orientationOffsetY,settings.keepOriginalOrientation,settings.keepOriginalPositionXZ};
}).ToArray();
return JsonConvert.SerializeObject(new{EditorApplication.isPlaying,
animators=UnityEngine.Object.FindObjectsOfType<Animator>(true).Select(a=>new{rig=Rig(a.transform),a.isHuman,a.applyRootMotion}).ToArray(),
viewer=viewer?new{viewer.yaw,actors=viewer.actors.Select(a=>Rig(a.rig.transform)).ToArray()}:null,clips},Formatting.Indented);
}
}
#endif
+37
View File
@@ -0,0 +1,37 @@
using System;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
/// <summary>Read-only inspection for reproducible audio review; never edits production assets.</summary>
public static class ReviewAudioMix
{
public static string Recovery()
{
var scenes=Enumerable.Range(0,UnityEngine.SceneManagement.SceneManager.sceneCount).Select(i=>UnityEngine.SceneManagement.SceneManager.GetSceneAt(i)).ToArray();
if(EditorApplication.isPlaying||scenes.Length!=1||scenes[0].path!="Assets/Scenes/Boot.unity"||scenes[0].isDirty)throw new Exception("Original clean Boot edit state not restored");
var result=new{status="pass",utc=DateTime.UtcNow.ToString("o"),playing=EditorApplication.isPlaying,scenes=scenes.Select(s=>new{s.path,s.isDirty}),
sfx=HotUpdate.Gameplay.PrototypeClientPrefs.Sfx,music=HotUpdate.Gameplay.PrototypeClientPrefs.Music,
preferenceBaseline="Original SFX value was held in the interrupted task; exact pre-capture preference could not be reverified after restart",
audio=AudioSettings.GetConfiguration(),temporaryReviewObjects=UnityEngine.Object.FindObjectsOfType<GameObject>().Count(g=>g.name.Contains("MixReview"))};
string folder=Path.GetFullPath(Path.Combine(Application.dataPath,"../../Doc/Design/Sound/MixReview"));Directory.CreateDirectory(folder);
string json=JsonConvert.SerializeObject(result,Formatting.Indented);File.WriteAllText(Path.Combine(folder,"unity_recovery.json"),json);return json;
}
public static string Snapshot()
{
var skill=AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Combat/BattleSkillPresentation.prefab");
var enemy=AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Effects/EnemyCombat/EnemyCombatPresentation.prefab");
var ui=AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Sound/Prefab/UiSfx.prefab");
var music=AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Sound/Prefab/MainMenuMusic.prefab");
Func<GameObject,object[]> sources=g=>g.GetComponentsInChildren<AudioSource>(true).Select(s=>(object)new {s.name,s.volume,s.pitch,s.spatialBlend,s.loop,clip=AssetDatabase.GetAssetPath(s.clip)}).ToArray();
var so=new SerializedObject(enemy.GetComponent<HotUpdate.Gameplay.EnemyCombatPresentation>());
var result=new{status="pass",utc=DateTime.UtcNow.ToString("o"),skill=sources(skill),enemy=sources(enemy),ui=sources(ui),music=sources(music),
enemyMixBudget=so.FindProperty("_mixBudget").floatValue,enemyVoiceVolume=so.FindProperty("_voiceVolume").floatValue,
audio=AudioSettings.GetConfiguration(),listenerVolume=AudioListener.volume,
subjectivelyApproved=false,accountWrites=false};
string folder=Path.GetFullPath(Path.Combine(Application.dataPath,"../../Doc/Design/Sound/MixReview"));Directory.CreateDirectory(folder);
string json=JsonConvert.SerializeObject(result,Formatting.Indented);File.WriteAllText(Path.Combine(folder,"unity_snapshot.json"),json);return json;
}
}
@@ -18,6 +18,11 @@ public static class SetupAnimalAnimator
return Run("Animal", "RockBall", 0.55f);
}
public static string SetupCampGuide()
{
return Run("Human", "CampGuide", 1.55f);
}
// First validate one gait on the matching rig before accepting more actions.
public static string SetupRockBallWalk()
{
@@ -0,0 +1,347 @@
#if UNITY_EDITOR
using System.IO;
using System.Linq;
using HotUpdate.Gameplay;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
/// <summary>
/// Studio mesh + Mixamo TokenRig + Kimodo idle/walk.
/// unity command run_script --file AgentScripts/SetupCampGuideStudio.cs --entry SetupCampGuideStudio.Run
/// </summary>
public static class SetupCampGuideStudio
{
const string Root = "Assets/Bundles/Characters/Human/CampGuideStudio";
const string Actor = "CampGuideStudio";
const float Height = 1.55f;
public static string ClipInfo()
{
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>($"{Root}/Clips/Walk.anim");
var bindings = AnimationUtility.GetCurveBindings(clip);
var leg = bindings.FirstOrDefault(b => b.path.Contains("LeftUpLeg") && b.propertyName.EndsWith("m_LocalRotation.x"));
var curve = string.IsNullOrEmpty(leg.path) ? null : AnimationUtility.GetEditorCurve(clip, leg);
var rig = AssetDatabase.LoadAssetAtPath<GameObject>($"{Root}/{Actor}_Rigged.prefab");
var animator = rig.GetComponentInChildren<Animator>(true);
var bone = rig.GetComponentsInChildren<Transform>(true).FirstOrDefault(t => t.name.Contains("LeftUpLeg"));
string bonePath = bone == null || animator == null
? "missing"
: AnimationUtility.CalculateTransformPath(bone, animator.transform);
var ranges = new System.Text.StringBuilder();
foreach (var binding in bindings.Where(b => b.path.EndsWith("LeftUpLeg")))
{
var keys = AnimationUtility.GetEditorCurve(clip, binding).keys;
float lo = keys.Min(k => k.value), hi = keys.Max(k => k.value);
ranges.Append($"{binding.propertyName}:{lo:0.000}..{hi:0.000} ");
}
return $"curves={bindings.Length} len={clip.length:0.00} bonePath={bonePath} {ranges}";
}
public static string Dump()
{
AssetDatabase.Refresh();
var rig = AssetDatabase.LoadAssetAtPath<GameObject>($"{Root}/Rigged/{Actor}_Rigged.fbx");
if (rig == null) return "missing";
return string.Join(" | ", rig.GetComponentsInChildren<Transform>(true).Select(t => t.name).Take(40));
}
public static string Run()
{
AssetDatabase.Refresh();
var fbx = $"{Root}/Rigged/{Actor}_Rigged.fbx";
if (!File.Exists(fbx)) throw new FileNotFoundException("missing rig", fbx);
Configure(fbx, true, null);
Avatar avatar = null;
foreach (var obj in AssetDatabase.LoadAllAssetsAtPath(fbx))
if (obj is Avatar found) { avatar = found; break; }
if (avatar == null) throw new System.InvalidOperationException("Mixamo avatar missing");
var rig = AssetDatabase.LoadAssetAtPath<GameObject>(fbx);
var bones = rig.GetComponentsInChildren<Transform>(true).Count(t => t.name.StartsWith("mixamorig:"));
if (bones < 30 || bones > 65) throw new System.InvalidOperationException("mixamorig count " + bones);
foreach (var clip in new[] { "Idle", "Walk" })
{
var anim = $"{Root}/Anims/{Actor}_{clip}.fbx";
Configure(anim, false, avatar);
var other = AssetDatabase.LoadAssetAtPath<GameObject>(anim);
var actual = other.GetComponentsInChildren<Transform>(true).Count(t => t.name.StartsWith("mixamorig:"));
if (actual != bones) throw new System.InvalidOperationException(clip + " skeleton " + actual + " != " + bones);
}
var mat = EnsureMaterial();
var controller = EnsureController();
var prefab = EnsurePrefab(fbx, controller, mat);
AssetDatabase.SaveAssets();
return $"bones={bones} prefab={prefab}";
}
static void Configure(string path, bool character, Avatar source)
{
var importer = (ModelImporter)AssetImporter.GetAtPath(path);
importer.animationType = ModelImporterAnimationType.Generic;
importer.avatarSetup = character ? ModelImporterAvatarSetup.CreateFromThisModel : ModelImporterAvatarSetup.CopyFromOther;
if (!character && source != null) importer.sourceAvatar = source;
importer.importAnimation = !character;
importer.materialImportMode = ModelImporterMaterialImportMode.None;
importer.globalScale = 1f;
importer.useFileScale = false;
importer.bakeAxisConversion = true;
importer.SaveAndReimport();
}
static Material EnsureMaterial()
{
var basePath = $"{Root}/{Actor}_base.png";
var mixPath = $"{Root}/{Actor}_mix.png";
foreach (var path in new[] { basePath, mixPath })
{
var tex = (TextureImporter)AssetImporter.GetAtPath(path);
tex.textureType = TextureImporterType.Default;
tex.sRGBTexture = !path.EndsWith("_mix.png");
tex.mipmapEnabled = true;
tex.SaveAndReimport();
}
var shader = Shader.Find("Test/XPbr");
if (shader == null) throw new System.InvalidOperationException("Test/XPbr missing");
var matPath = $"{Root}/{Actor}.mat";
var mat = AssetDatabase.LoadAssetAtPath<Material>(matPath) ?? new Material(shader);
mat.shader = shader;
var albedo = AssetDatabase.LoadAssetAtPath<Texture2D>(basePath);
var mix = AssetDatabase.LoadAssetAtPath<Texture2D>(mixPath);
if (mat.HasProperty("_MainTex")) mat.SetTexture("_MainTex", albedo);
if (mat.HasProperty("_MixTex")) mat.SetTexture("_MixTex", mix);
if (mat.HasProperty("_BaseMap")) mat.SetTexture("_BaseMap", albedo);
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", Color.white);
if (mat.HasProperty("_Metallic")) mat.SetFloat("_Metallic", 1f);
if (mat.HasProperty("_Roughness")) mat.SetFloat("_Roughness", 1f);
if (mat.HasProperty("_AO")) mat.SetFloat("_AO", 1f);
if (mat.HasProperty("_AlphaTest")) mat.SetFloat("_AlphaTest", 0f);
mat.DisableKeyword("_ALPHATEST_ON");
if (AssetDatabase.LoadAssetAtPath<Material>(matPath) == null) AssetDatabase.CreateAsset(mat, matPath);
else EditorUtility.SetDirty(mat);
return AssetDatabase.LoadAssetAtPath<Material>(matPath);
}
static AnimatorController EnsureController()
{
var path = $"{Root}/{Actor}_Animator.controller";
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(path)
?? AnimatorController.CreateAnimatorControllerAtPath(path);
var machine = controller.layers[0].stateMachine;
foreach (var state in machine.states.ToArray()) machine.RemoveState(state.state);
foreach (var parameter in controller.parameters.ToArray()) controller.RemoveParameter(parameter);
controller.AddParameter("Motion", AnimatorControllerParameterType.Int);
var clipDir = $"{Root}/Clips";
Directory.CreateDirectory(Path.Combine(Directory.GetParent(Application.dataPath).FullName, "Assets/Bundles/Characters/Human/CampGuideStudio/Clips"));
var idleClip = CopyClip("Idle", clipDir);
var walkClip = CopyClip("Walk", clipDir);
var idle = machine.AddState("Idle");
idle.motion = idleClip;
machine.defaultState = idle;
var walk = machine.AddState("Walk");
walk.motion = walkClip;
var toWalk = idle.AddTransition(walk);
toWalk.hasExitTime = false;
toWalk.duration = 0.1f;
toWalk.AddCondition(AnimatorConditionMode.Equals, 1, "Motion");
var toIdle = walk.AddTransition(idle);
toIdle.hasExitTime = false;
toIdle.duration = 0.1f;
toIdle.AddCondition(AnimatorConditionMode.Equals, 0, "Motion");
EditorUtility.SetDirty(controller);
return controller;
}
static AnimationClip CopyClip(string name, string clipDir)
{
var src = AssetDatabase.LoadAllAssetsAtPath($"{Root}/Anims/{Actor}_{name}.fbx")
.OfType<AnimationClip>().First(c => !c.name.StartsWith("__preview__"));
var copy = Object.Instantiate(src);
copy.name = name;
var settings = AnimationUtility.GetAnimationClipSettings(copy);
settings.loopTime = true;
AnimationUtility.SetAnimationClipSettings(copy, settings);
var dest = $"{clipDir}/{name}.anim";
var existing = AssetDatabase.LoadAssetAtPath<AnimationClip>(dest);
if (existing == null) AssetDatabase.CreateAsset(copy, dest);
else { EditorUtility.CopySerialized(copy, existing); Object.DestroyImmediate(copy); }
return AssetDatabase.LoadAssetAtPath<AnimationClip>(dest);
}
static string EnsurePrefab(string fbx, RuntimeAnimatorController controller, Material material)
{
var modelRoot = AssetDatabase.LoadAssetAtPath<GameObject>(fbx);
var instance = new GameObject(Actor + "_Rigged");
var model = (GameObject)PrefabUtility.InstantiatePrefab(modelRoot, instance.transform);
model.name = "Model";
var bounds = BindBounds(model);
float scale = Height / bounds.size.y;
model.transform.localScale = Vector3.one * scale;
model.transform.localPosition = -new Vector3(bounds.center.x, bounds.min.y, bounds.center.z) * scale;
foreach (var renderer in model.GetComponentsInChildren<Renderer>(true)) renderer.sharedMaterial = material;
var animator = model.GetComponent<Animator>() ?? model.AddComponent<Animator>();
animator.runtimeAnimatorController = controller;
animator.applyRootMotion = false;
animator.cullingMode = AnimatorCullingMode.AlwaysAnimate;
if (instance.GetComponent<ExplorerLocomotion>() == null) instance.AddComponent<ExplorerLocomotion>();
SetLayer(instance.transform);
var path = $"{Root}/{Actor}_Rigged.prefab";
PrefabUtility.SaveAsPrefabAsset(instance, path);
Object.DestroyImmediate(instance);
return path;
}
static Bounds BindBounds(GameObject model)
{
Bounds bounds = default;
bool has = false;
foreach (var skin in model.GetComponentsInChildren<SkinnedMeshRenderer>())
{
var mesh = new Mesh();
skin.BakeMesh(mesh);
foreach (var vertex in mesh.vertices)
{
var point = model.transform.InverseTransformPoint(skin.transform.TransformPoint(vertex));
if (!has) { bounds = new Bounds(point, Vector3.zero); has = true; }
else bounds.Encapsulate(point);
}
Object.DestroyImmediate(mesh);
}
if (!has) throw new System.InvalidOperationException("no skinned mesh");
return bounds;
}
public static string Fit()
{
var path = $"{Root}/{Actor}_Rigged.prefab";
var root = PrefabUtility.LoadPrefabContents(path);
try
{
var model = root.transform.Find("Model");
var anim = model.GetComponent<Animator>();
anim.Play("Idle", 0, 0f);
anim.Update(0f);
var before = Measure(model.gameObject);
float factor = Height / Mathf.Max(0.01f, before.max - before.min);
model.localScale *= factor;
anim.Update(0f);
var after = Measure(model.gameObject);
model.localPosition -= new Vector3(0f, after.min, 0f);
PrefabUtility.SaveAsPrefabAsset(root, path);
return $"before={before.min:0.000}..{before.max:0.000} scale={model.localScale.y:0.000}";
}
finally
{
PrefabUtility.UnloadPrefabContents(root);
}
}
static (float min, float max) Measure(GameObject model)
{
float min = float.MaxValue, max = float.MinValue;
foreach (var skin in model.GetComponentsInChildren<SkinnedMeshRenderer>())
{
var mesh = new Mesh();
skin.BakeMesh(mesh);
foreach (var vertex in mesh.vertices)
{
float y = skin.transform.TransformPoint(vertex).y;
if (y < min) min = y;
if (y > max) max = y;
}
Object.DestroyImmediate(mesh);
}
return (min, max);
}
public static string Review()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>($"{Root}/{Actor}_Rigged.prefab");
var stage = new GameObject("CampGuideStudioReview");
var rt = new RenderTexture(480, 720, 16, RenderTextureFormat.ARGB32);
var tex = new Texture2D(480, 720, TextureFormat.RGB24, false);
var dir = Path.GetFullPath(Path.Combine(Application.dataPath, "../../Tools/output/campguide_studio/review"));
Directory.CreateDirectory(dir);
try
{
var actor = (GameObject)PrefabUtility.InstantiatePrefab(prefab, stage.transform);
var lightGo = new GameObject("Light");
lightGo.transform.SetParent(stage.transform, false);
var light = lightGo.AddComponent<Light>();
light.type = LightType.Directional;
light.intensity = 1.2f;
light.transform.rotation = Quaternion.Euler(40f, -30f, 0f);
var camGo = new GameObject("Camera");
camGo.transform.SetParent(stage.transform, false);
var camera = camGo.AddComponent<Camera>();
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = new Color(0.85f, 0.85f, 0.85f, 1f);
camera.orthographic = true;
camera.orthographicSize = 0.95f;
camera.nearClipPlane = 0.05f;
camera.farClipPlane = 20f;
camera.targetTexture = rt;
var anim = actor.GetComponentInChildren<Animator>();
anim.applyRootMotion = false;
var model = actor.transform.Find("Model").gameObject;
var skin = actor.GetComponentInChildren<SkinnedMeshRenderer>();
var notes = new System.Text.StringBuilder();
foreach (var shot in new[] { "Idle", "Walk" })
{
var sampled = AssetDatabase.LoadAssetAtPath<AnimationClip>($"{Root}/Clips/{shot}.anim");
sampled.SampleAnimation(model, shot == "Walk" ? 0.5f : 0.4f);
var mesh = new Mesh();
skin.BakeMesh(mesh);
var posed = new GameObject("Posed");
posed.transform.SetParent(skin.transform, false);
posed.AddComponent<MeshFilter>().sharedMesh = mesh;
var posedRenderer = posed.AddComponent<MeshRenderer>();
posedRenderer.sharedMaterial = skin.sharedMaterial;
skin.enabled = false;
float minY = float.MaxValue, maxY = float.MinValue;
foreach (var v in mesh.vertices)
{
var p = skin.transform.TransformPoint(v);
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
notes.Append($"{shot} y={minY:0.000}..{maxY:0.000} scale={actor.transform.Find("Model").localScale.y:0.000} bounds={skin.bounds.size.y:0.000} ");
foreach (var view in new[] { "front", "side" })
{
camera.transform.position = view == "front" ? new Vector3(0f, 0.8f, -2.4f) : new Vector3(2.4f, 0.8f, 0f);
camera.transform.LookAt(new Vector3(0f, 0.75f, 0f));
camera.Render();
RenderTexture.active = rt;
tex.ReadPixels(new Rect(0, 0, 480, 720), 0, 0);
tex.Apply();
RenderTexture.active = null;
File.WriteAllBytes(Path.Combine(dir, shot.ToLowerInvariant() + "_" + view + ".png"), tex.EncodeToPNG());
}
skin.enabled = true;
Object.DestroyImmediate(posed);
Object.DestroyImmediate(mesh);
}
camera.targetTexture = null;
return notes + dir;
}
finally
{
RenderTexture.active = null;
cameraCleanup(rt, tex, stage);
}
}
static void cameraCleanup(RenderTexture rt, Texture2D tex, GameObject stage)
{
Object.DestroyImmediate(tex);
rt.Release();
Object.DestroyImmediate(rt);
Object.DestroyImmediate(stage);
}
static void SetLayer(Transform transform)
{
transform.gameObject.layer = 31;
foreach (Transform child in transform) SetLayer(child);
}
}
#endif
+7 -2
View File
@@ -19,6 +19,11 @@ public static class SetupCharacter
return Run("Human", "CaoCao");
}
public static string SetupCampGuide()
{
return Run("Human", "CampGuide", 1.55f);
}
public static string SetupLmGirl()
{
return Run("Human", "LmGirl", 1.65f);
@@ -214,7 +219,7 @@ public static class SetupCharacter
CollectorGUID = AssetDatabase.AssetPathToGUID(collectPath),
CollectorType = ECollectorType.MainAssetCollector,
AddressRuleName = nameof(AddressByFileName),
PackRuleName = nameof(PackDirectory),
PackRuleName = nameof(PackSeparately),
FilterRuleName = nameof(CollectAll),
AssetTags = "art",
};
@@ -224,7 +229,7 @@ public static class SetupCharacter
{
collector.CollectorGUID = AssetDatabase.AssetPathToGUID(collectPath);
collector.AddressRuleName = nameof(AddressByFileName);
collector.PackRuleName = nameof(PackDirectory);
collector.PackRuleName = nameof(PackSeparately);
collector.FilterRuleName = nameof(CollectAll);
BundleCollectorSettingData.ModifyCollector(group, collector);
}
@@ -0,0 +1,122 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AIGame.Review;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
public static class VerifyCharacterAnimationLab
{
static string Repo => Directory.GetParent(Application.dataPath).Parent.FullName;
static string Output => Path.Combine(Repo,"Tools/output/campguide_studio/rig_mixamo41/unity_review");
const string Root="Assets/Review/CharacterAnimationLab";
public static string Verify()
{
var data=JObject.Parse(File.ReadAllText(Path.Combine(Output,"review_data.json")));
var expected=JArray.Parse(File.ReadAllText(Path.Combine(Output,"../comparison.json"))).Concat(JArray.Parse(File.ReadAllText(Path.Combine(Output,"../comparison_local.json")))).ToArray();
var prefab=PrefabUtility.LoadPrefabContents(Root+"/Generated/CharacterAnimationLab.prefab");
var previous=QualitySettings.skinWeights;var baked=new Mesh();
try
{
QualitySettings.skinWeights=SkinWeights.Unlimited;
var viewer=prefab.GetComponent<CharacterAnimationLab>();
viewer.inPlace=false;viewer.yaw=0;viewer.playing=false;
// BakeMesh includes the presentation scale in this hierarchy. Audit
// at the original 1.135m source scale used for the Blender metrics.
foreach(var actor in viewer.actors)actor.rig.transform.localScale=Vector3.one;
var results=new List<object>();
string[] prefixes={"Mixamo","TokenRig41","TokenRigLocal"};
string[] labels={"Walking","Breathing Idle","Happy Idle"};
for(int c=0;c<3;c++)
{
viewer.motion=c;
var max=new float[3,4];var over=new int[3];
int frames=((JArray)data["clips"][c]["samples"]).Count;
for(int frame=0;frame<frames;frame++)
{
viewer.time=frame/30f;viewer.Sample();
for(int actor=0;actor<3;actor++)
{
viewer.actors[actor].renderer.BakeMesh(baked);
var vertices=baked.vertices;
foreach(var e in data["variants"][actor]["auditEdges"])
{
float ratio=Vector3.Distance(vertices[(int)e[0]],vertices[(int)e[1]])/(float)e[2];int mask=(int)e[3];
for(int region=0;region<4;region++)if((mask&(1<<region))!=0)max[actor,region]=Mathf.Max(max[actor,region],ratio);
if(ratio>2)over[actor]++;
}
}
}
for(int actor=0;actor<3;actor++)
{
var reference=expected.First(x=>(string)x["label"]==prefixes[actor]+" "+labels[c]);
string[] regions={"all","hands","neck","head"};
float worstError=0;
for(int region=0;region<4;region++)worstError=Mathf.Max(worstError,Mathf.Abs(max[actor,region]-(float)reference["animation"]["summary"][regions[region]]["max_edge_stretch"]));
if(worstError>.003f)throw new InvalidOperationException(prefixes[actor]+" "+labels[c]+" native skinning mismatch "+worstError+" actual="+max[actor,0]);
results.Add(new {variant=prefixes[actor],motion=labels[c],frames,maxStretch=max[actor,0],handMax=max[actor,1],neckMax=max[actor,2],maxDifferenceFromBlender=worstError});
}
}
int buttons=viewer.ui.GetComponentsInChildren<Button>(true).Length;
if(buttons!=21 || viewer.actors.Any(a=>a.renderer.bones.Length!=41))throw new InvalidOperationException("Missing controls or bones: "+buttons);
File.WriteAllText(Path.Combine(Output,"unity_verification.json"),JsonConvert.SerializeObject(new {passed=true,buttons,bones=41,triangles=3000,samples=results},Formatting.Indented));
return JsonConvert.SerializeObject(results);
}
finally{QualitySettings.skinWeights=previous;UnityEngine.Object.DestroyImmediate(baked);PrefabUtility.UnloadPrefabContents(prefab);}
}
static CharacterAnimationLab Viewer()=>UnityEngine.Object.FindObjectOfType<CharacterAnimationLab>();
public static string State()
{
var v=Viewer();if(!v)return "No comparison viewer loaded";
return JsonConvert.SerializeObject(new {v.motion,v.time,v.playing,v.focus,v.detail,v.inPlace,v.speed,clip=v.clips[v.motion].name,bones=v.actors.Select(a=>a.renderer.bones.Length),positions=v.actors.Select(a=>a.hips.localPosition.ToString("F5"))});
}
public static string Pose(int motion,int frame,int focus,int detail,int view,bool textured)
{
var v=Viewer();v.motion=motion;v.playing=false;v.time=frame/30f;v.focus=focus;v.detail=detail;v.yaw=view;v.textured=textured;v.Sample();return State();
}
public static string Reset()
{
var v=Viewer();v.motion=0;v.time=0;v.focus=-1;v.detail=0;v.yaw=0;v.speed=1;v.textured=true;v.inPlace=true;v.playing=true;v.Sample();return State();
}
public static string Controls()
{
var v=Viewer();if(!Application.isPlaying)throw new InvalidOperationException("Play Mode required.");
var buttons=v.ui.GetComponentsInChildren<Button>(true).ToDictionary(b=>b.name);
void Click(string n)=>buttons["Btn_"+n].onClick.Invoke();
void Check(bool ok,string message){if(!ok)throw new InvalidOperationException(message);}
Reset();Click("Play");Check(!v.playing,"Pause listener");
float paused=v.time;v.Sample();Check(Mathf.Abs(v.time-paused)<.00001f,"Paused clock changed");
Click("Restart");Click("Next");Check(Mathf.Abs(v.time-1f/30)<.00001f,"Next frame");
Click("Previous");Check(v.time==0,"Previous frame");
Click("Breathe");Check(v.motion==1&&v.FrameCount==299,"Breathing clip");
Click("Speed025");Check(v.speed==.25f,"Quarter speed");
var seek=v.ui.GetComponentInChildren<AnimationLabSeekBar>();
var rect=(RectTransform)seek.transform;var corners=new Vector3[4];rect.GetWorldCorners(corners);
var screen=RectTransformUtility.WorldToScreenPoint(null,Vector3.Lerp(corners[0],corners[2],.5f));
seek.OnPointerDown(new UnityEngine.EventSystems.PointerEventData(UnityEngine.EventSystems.EventSystem.current){position=screen});
Check(Mathf.Abs(v.time-v.Duration*.5f)<.001f&&!v.playing,"Progress pointer seek");
Click("Side");Check(v.actors.All(a=>Quaternion.Angle(a.rig.transform.localRotation,Quaternion.Euler(0,90,0))<.001f),"Synchronized view angle");
Click("Focus2");Click("Detail");Click("Detail");Check(v.focus==1&&v.detail==2,"Hand close-up");
Click("Material");Check(v.actors.All(a=>a.renderer.sharedMaterial==v.clayMaterial),"Shared clay material");
Click("Walk");v.Seek(1);Click("Travel");var moving=v.actors[0].hips.localPosition;Click("Travel");
Check(Vector3.Distance(moving-v.actors[0].hips.localPosition,v.travel[0])<.00001f,"In-place travel removal");
Click("All");Click("Front");Click("Happy");Check(v.motion==2&&v.focus<0&&v.detail==0,"All-view and Happy Idle");
var first=v.actors[0].renderer.bones;
float maxPoseError=0;
foreach(var actor in v.actors.Skip(1))for(int i=0;i<first.Length;i++)
{
maxPoseError=Mathf.Max(maxPoseError,Vector3.Distance(first[i].localPosition,actor.renderer.bones[i].localPosition));
Check(Quaternion.Angle(first[i].localRotation,actor.renderer.bones[i].localRotation)<.01f,"Bone rotations out of sync");
}
Check(maxPoseError<.00001f,"Bone positions out of sync");
var result=new {passed=true,checks=new[]{"pause freezes clock","previous/next frame","clip selection","speed","pointer seek","synchronized angle","hand focus","material switch","in-place travel","all-view","41-bone synchronization"},maxPoseError};
File.WriteAllText(Path.Combine(Output,"unity_controls.json"),JsonConvert.SerializeObject(result,Formatting.Indented));
Reset();return JsonConvert.SerializeObject(result);
}
}
#endif
+259
View File
@@ -0,0 +1,259 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using AIGame.EditorTools;
using AOT.Boot;
using AOT.UI;
using HotUpdate.Gameplay;
using HotUpdate.UI;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using Object = UnityEngine.Object;
public static class VerifyHomeMobileUi
{
const string MaterialPath = "Assets/Bundles/UI/Material/HomeExplorerCutout.mat";
const string SceneKey = "AIGame.HomeMobile.SceneSetup";
static string Repo => Path.GetFullPath(Path.Combine(Application.dataPath, "../.."));
static string Output => Path.Combine(Repo, "Tools/output/home_mobile");
public static string Status() => JsonConvert.SerializeObject(new {
playing = EditorApplication.isPlaying, manager = UiManager.Instance != null,
hud = UiManager.Instance != null && UiManager.Instance.Get("UI_MainHUD") != null,
bootstraps = Object.FindObjectsOfType<GameplayBootstrap>().Select(b => new { b.name, b.enabled, active = b.gameObject.activeInHierarchy }).ToArray(),
explorers = Object.FindObjectsOfType<HomeExplorerPresenter>().Select(e => new { e.name, e.Ready,
image = e.GetComponent<RawImage>()?.material?.shader?.name }).ToArray(),
backgrounds = Object.FindObjectsOfType<Image>().Where(i => i.name == "Img_Camp").Select(i => new {
sprite = i.sprite != null ? i.sprite.name : null, texture = i.sprite != null && i.sprite.texture != null ? i.sprite.texture.name : null,
textureSize = i.sprite != null && i.sprite.texture != null ? i.sprite.texture.width : 0, i.color
}).ToArray(),
roots = UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects().Select(g => g.name).ToArray()
});
public static string BuildPrefab()
{
Check(!EditorApplication.isPlaying, "Stop Play Mode before authoring");
if (!AssetDatabase.IsValidFolder("Assets/Bundles/UI/Material")) AssetDatabase.CreateFolder("Assets/Bundles/UI", "Material");
var shader = AssetDatabase.LoadAssetAtPath<Shader>("Assets/HotUpdate/Shaders/HomeExplorerCutout.shader");
Check(shader != null && shader.isSupported, "Preview shader unavailable");
var material = AssetDatabase.LoadAssetAtPath<Material>(MaterialPath);
if (material == null) { material = new Material(shader); AssetDatabase.CreateAsset(material, MaterialPath); }
material.shader = shader;
EditorUtility.SetDirty(material); AssetDatabase.SaveAssets();
string json = Path.Combine(Repo, "Doc/UIPrefabCreater/UI_MainHUD.json");
var validation = JsonUIPrefabBuilder.Validate(json);
Check(validation.Ok, string.Join("\n", validation.Errors));
string path = JsonUIPrefabBuilder.BuildFromJsonFile(json);
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
Check(prefab != null, "HUD build failed");
Check(prefab.transform.Find("ExplorerStage").GetComponent<RawImage>().material == material, "HUD has no explicit preview material");
Check(AssetDatabase.GetDependencies(path, true).Contains(AssetDatabase.GetAssetPath(shader)), "Shader is not a real prefab dependency");
Check(prefab.GetComponentsInChildren<UiSafeArea>(true).Length == 4, "Missing safe-area components");
Check(prefab.transform.Find("Img_Camp").GetComponent<Image>().sprite != null, "Camp background sprite is missing");
VerifyCutout(material);
return "PASS: JSON HUD, explicit material/shader dependency, four authored safe-area layouts and GPU color-key discard";
}
static void VerifyCutout(Material material)
{
var texture = new Texture2D(2, 2, TextureFormat.RGBA32, false, true);
var target = new RenderTexture(8, 8, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear);
var pixels = new Texture2D(8, 8, TextureFormat.RGBA32, false, true);
var previous = RenderTexture.active;
try
{
texture.SetPixels(Enumerable.Repeat(material.GetColor("_Key"), 4).ToArray()); texture.Apply();
target.Create(); RenderTexture.active = target; GL.Clear(true, true, Color.cyan);
Graphics.Blit(texture, target, material);
RenderTexture.active = target; pixels.ReadPixels(new Rect(0, 0, 8, 8), 0, 0); pixels.Apply();
Color color = pixels.GetPixel(4, 4);
Check(color.r < .05f && color.b > .95f && color.g > .95f, "Color-key background was not discarded");
Graphics.Blit(Texture2D.whiteTexture, target, material);
RenderTexture.active = target; pixels.ReadPixels(new Rect(0, 0, 8, 8), 0, 0); pixels.Apply();
Check(pixels.GetPixel(4, 4).r > .95f, "Foreground was discarded");
}
finally { RenderTexture.active = previous; target.Release(); Object.DestroyImmediate(target); Object.DestroyImmediate(texture); Object.DestroyImmediate(pixels); }
}
public static string PrepareScene()
{
Check(!EditorApplication.isPlaying, "Stop Play Mode first");
for (int i = 0; i < UnityEngine.SceneManagement.SceneManager.sceneCount; i++)
Check(!UnityEngine.SceneManagement.SceneManager.GetSceneAt(i).isDirty, "Existing scene has unsaved changes");
Directory.CreateDirectory(Output);
var setup = EditorSceneManager.GetSceneManagerSetup();
string json = JsonConvert.SerializeObject(setup);
SessionState.SetString(SceneKey, json); File.WriteAllText(Path.Combine(Output, "scene_setup.json"), json);
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
SetResolution(1080, 1920, true);
return "Prepared isolated scene; original scene setup and Game View size retained";
}
public static string StartPreview()
{
Check(EditorApplication.isPlaying, "Enter Play Mode first");
var camera = GameObject.Find("HomeMobileTestCamera")?.GetComponent<Camera>();
if (camera == null) camera = new GameObject("HomeMobileTestCamera", typeof(Camera)).GetComponent<Camera>();
camera.clearFlags = CameraClearFlags.SolidColor; camera.cullingMask = 0;
camera.backgroundColor = new Color(.2f, .3f, .4f);
if (camera.GetComponent<AudioListener>() == null) camera.gameObject.AddComponent<AudioListener>();
if (EventSystem.current == null) new GameObject("EventSystem", typeof(EventSystem), typeof(StandaloneInputModule));
var bootstrap = Object.FindObjectOfType<GameplayBootstrap>();
if (bootstrap == null)
{
bootstrap = new GameObject("GameplayBootstrap").AddComponent<GameplayBootstrap>();
bootstrap.Bind(new BootContext { HttpBase = "http://127.0.0.1:1", UiTheme = "candy" }, null);
}
return "Isolated UI started; allow normal editor frames before checking";
}
public static string CheckReady()
{
var hud = UiManager.Instance?.Get("UI_MainHUD");
Check(hud != null && hud.GetComponentInChildren<HomeExplorerPresenter>().Ready, "UI preview is still loading");
Check(Object.FindObjectsOfType<HotUpdate.TownSquareSpawner>().Length == 0 && Object.FindObjectsOfType<HotUpdate.CharacterSpawner>().Length == 0,
"Startup still creates 3D world spawners");
Check(GameObject.Find("TownSquare_Runtime") == null && GameObject.Find("CaoCao_Runtime") == null, "Startup world was loaded");
var raw = hud.GetComponentInChildren<RawImage>();
Check(raw.enabled && raw.texture is RenderTexture && raw.material.shader.name == "AIGame/HomeExplorerCutout", "Live preview material missing");
var presenter = hud.GetComponentInChildren<HomeExplorerPresenter>();
Check(!presenter.Talking, "Explorer speaks before a tap");
return "PASS: no startup 3D world; actual serialized preview material loaded; no automatic speech";
}
public static string SetPlain() => SetScenario("plain", 1080, 1920, Vector4.zero);
public static string SetPhone() => SetScenario("iphone_notch_capsule", 1080, 2338, new Vector4(0, 34f / 844, 0, 91f / 844));
public static string SetAndroid() => SetScenario("android_capsule", 1080, 2400, new Vector4(0, 0, 0, 64f / 800));
static string SetScenario(string name, int width, int height, Vector4 insets)
{
SetResolution(width, height, false); UiSafeArea.SetPreviewInsets(insets);
SessionState.SetString(SceneKey + ".Scenario", name);
SessionState.SetInt(SceneKey + ".Width", width); SessionState.SetInt(SceneKey + ".Height", height);
SessionState.SetFloat(SceneKey + ".Top", insets.w);
return name + " selected; allow normal editor frames before capture";
}
public static string CompletePreview()
{
var hud = UiManager.Instance.Get("UI_MainHUD");
var presenter = hud.GetComponentInChildren<HomeExplorerPresenter>();
hud.transform.Find("Btn_Explorer").GetComponent<Button>().onClick.Invoke();
Check(presenter.Talking, "Explorer speech no longer works");
foreach (string name in new[] { "plain", "iphone_notch_capsule", "android_capsule" })
Check(File.Exists(Path.Combine(Output, name + ".png")), "Missing screenshot: " + name);
Save("runtime", new { passed = true, noStartupWorld = true, materialDependency = true,
scenarios = new[] { "plain", "iphone_notch_capsule", "android_capsule" }, explorerMovesAndTalks = true });
return "PASS: actual GameplayBootstrap has no startup world; explorer preview and speech; three screen/safe-area scenarios and header hit targets";
}
public static string CapturePreview()
{
var hud = UiManager.Instance.Get("UI_MainHUD");
string name = SessionState.GetString(SceneKey + ".Scenario", "");
int width = SessionState.GetInt(SceneKey + ".Width", 0), height = SessionState.GetInt(SceneKey + ".Height", 0);
Check(Screen.width == width && Screen.height == height, "Game View has not resized yet");
Canvas.ForceUpdateCanvases();
float upper = Screen.height * (1 - SessionState.GetFloat(SceneKey + ".Top", 0));
foreach (string node in new[] { "Layout_Top", "Panel_Top" })
{
var rect = (RectTransform)hud.transform.Find(node);
var corners = new Vector3[4]; rect.GetWorldCorners(corners);
Check(corners.Max(c => c.y) <= upper + 1, "Top layout overlaps native area: " + node);
}
var background = (RectTransform)hud.transform.Find("Img_Camp");
Check(background.anchorMin == Vector2.zero && background.anchorMax == Vector2.one &&
background.offsetMin == Vector2.zero && background.offsetMax == Vector2.zero, "Background no longer fills screen");
var top = hud.transform.Find("Layout_Top");
foreach (var button in top.GetComponentsInChildren<Button>())
{
var rect = (RectTransform)button.transform;
var data = new PointerEventData(EventSystem.current) { position = rect.TransformPoint(rect.rect.center) };
var hits = new List<RaycastResult>(); EventSystem.current.RaycastAll(data, hits);
Check(hits.Count > 0 && ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject) == button.gameObject,
"Header hit target blocked: " + button.name);
}
// Render an authored Canvas with a camera; avoid synchronous screen-backbuffer capture.
var camera = GameObject.Find("HomeMobileTestCamera").GetComponent<Camera>();
var canvas = hud.GetComponent<Canvas>(); var mode = canvas.renderMode; var worldCamera = canvas.worldCamera;
int mask = camera.cullingMask; var previous = RenderTexture.active;
var target = new RenderTexture(width, height, 24); var pixels = new Texture2D(width, height, TextureFormat.RGB24, false);
try
{
camera.targetTexture = target; camera.cullingMask = 1 << LayerMask.NameToLayer("UI");
canvas.renderMode = RenderMode.ScreenSpaceCamera; canvas.worldCamera = camera; canvas.planeDistance = 1;
Canvas.ForceUpdateCanvases(); camera.Render();
RenderTexture.active = target; pixels.ReadPixels(new Rect(0, 0, width, height), 0, 0); pixels.Apply();
Directory.CreateDirectory(Output); File.WriteAllBytes(Path.Combine(Output, name + ".png"), pixels.EncodeToPNG());
}
finally
{
RenderTexture.active = previous; camera.targetTexture = null; camera.cullingMask = mask;
canvas.renderMode = mode; canvas.worldCamera = worldCamera; Canvas.ForceUpdateCanvases();
target.Release(); Object.Destroy(target); Object.Destroy(pixels);
}
return "PASS " + name + ": header bounds/hit targets and full-screen background; image=" + Path.Combine(Output, name + ".png");
}
static void SetResolution(int width, int height, bool remember)
{
var editor = typeof(Editor).Assembly; var viewType = editor.GetType("UnityEditor.GameView");
var window = EditorWindow.GetWindow(viewType);
var selected = viewType.GetProperty("selectedSizeIndex", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (remember) SessionState.SetInt(SceneKey + ".Size", (int)selected.GetValue(window));
var sizesType = editor.GetType("UnityEditor.GameViewSizes");
var sizes = typeof(ScriptableSingleton<>).MakeGenericType(sizesType).GetProperty("instance").GetValue(null);
var group = sizesType.GetMethod("GetGroup").Invoke(sizes, new[] { sizesType.GetProperty("currentGroupType").GetValue(sizes) });
int count = (int)group.GetType().GetMethod("GetTotalCount").Invoke(group, null); int index = count;
for (int i = 0; i < count; i++)
{
var size = group.GetType().GetMethod("GetGameViewSize").Invoke(group, new object[] { i });
if ((int)size.GetType().GetProperty("width").GetValue(size) == width && (int)size.GetType().GetProperty("height").GetValue(size) == height) { index = i; break; }
}
if (index == count)
{
var size = Activator.CreateInstance(editor.GetType("UnityEditor.GameViewSize"), new object[] {
Enum.ToObject(editor.GetType("UnityEditor.GameViewSizeType"), 1), width, height, "Home mobile " + width + "x" + height });
group.GetType().GetMethod("AddCustomSize").Invoke(group, new[] { size });
}
selected.SetValue(window, index); window.Repaint();
}
public static string RestoreScene()
{
Check(!EditorApplication.isPlaying, "Stop Play Mode first");
string json = SessionState.GetString(SceneKey, "");
if (string.IsNullOrEmpty(json) && File.Exists(Path.Combine(Output, "scene_setup.json")))
json = File.ReadAllText(Path.Combine(Output, "scene_setup.json"));
Check(!string.IsNullOrEmpty(json), "Missing saved scene setup");
EditorSceneManager.RestoreSceneManagerSetup(JsonConvert.DeserializeObject<SceneSetup[]>(json));
var type = typeof(Editor).Assembly.GetType("UnityEditor.GameView"); var window = EditorWindow.GetWindow(type);
type.GetProperty("selectedSizeIndex", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.SetValue(window, SessionState.GetInt(SceneKey + ".Size", 0)); window.Repaint();
UiSafeArea.SetPreviewInsets(null); SessionState.EraseString(SceneKey);
return "Restored original scenes and Game View";
}
public static string StageResources()
{
Check(!EditorApplication.isPlaying, "Stop Play Mode before building resources");
string art = AOT.Editor.YooAssetTools.BuildAndStageArt.BuildAndStage();
Check(art.StartsWith("ok", StringComparison.Ordinal), art);
string raw = AOT.Editor.YooAssetTools.BuildAndStageHotUpdate.BuildAndStage();
Check(raw.StartsWith("ok", StringComparison.Ordinal), raw);
HybridCLR.Editor.Commands.LinkGeneratorCommand.GenerateLinkXml(EditorUserBuildSettings.activeBuildTarget);
Save("staged", new { passed = true, art, raw, hybridClrLinkUpdated = true, remotePublished = false });
return art + " | " + raw;
}
static void Save(string name, object value)
{
Directory.CreateDirectory(Output); File.WriteAllText(Path.Combine(Output, name + ".json"), JsonConvert.SerializeObject(value, Formatting.Indented));
}
static void Check(bool condition, string message) { if (!condition) throw new Exception(message); }
}
@@ -0,0 +1,109 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using HybridCLR.Editor;
using HybridCLR.Editor.Commands;
using HybridCLR.Editor.Settings;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.Build.Reporting;
using UnityEngine;
public static class VerifyHybridClrGeneration
{
public static async Task<string> VerifyCallbacks()
{
BuildReport report = BuildReport.GetLatestReport();
if (report == null || report.summary.platform != BuildTarget.WebGL)
throw new Exception("A WebGL build report is required");
string bee = Environment.GetEnvironmentVariable("BEE_BUILD_THREADS");
string binaryen = Environment.GetEnvironmentVariable("BINARYEN_CORES");
var callbacks = new AOT.Editor.HybridCLR.WebGlBuildConcurrency();
try
{
Environment.SetEnvironmentVariable("BEE_BUILD_THREADS", null);
Environment.SetEnvironmentVariable("BINARYEN_CORES", null);
callbacks.OnPreprocessBuild(report);
RequireEnvironment("4", "4");
callbacks.OnPostprocessBuild(report);
RequireEnvironment(null, null);
Environment.SetEnvironmentVariable("BEE_BUILD_THREADS", "6");
Environment.SetEnvironmentVariable("BINARYEN_CORES", "2");
callbacks.OnPreprocessBuild(report);
callbacks.OnPostprocessBuild(report);
RequireEnvironment("6", "2");
Environment.SetEnvironmentVariable("BEE_BUILD_THREADS", null);
Environment.SetEnvironmentVariable("BINARYEN_CORES", null);
callbacks.OnPreprocessBuild(report);
// Simulate an aborted build that never invokes its postprocess callback.
await Task.Delay(250);
RequireEnvironment(null, null);
return "PASS: WebGL worker defaults, successful cleanup, explicit overrides, failed-build idle cleanup";
}
finally
{
callbacks.OnPostprocessBuild(report);
Environment.SetEnvironmentVariable("BEE_BUILD_THREADS", bee);
Environment.SetEnvironmentVariable("BINARYEN_CORES", binaryen);
}
}
static void RequireEnvironment(string bee, string binaryen)
{
if (Environment.GetEnvironmentVariable("BEE_BUILD_THREADS") != bee ||
Environment.GetEnvironmentVariable("BINARYEN_CORES") != binaryen)
throw new Exception("Unexpected build worker environment");
}
public static string GenerateAll()
{
string previousBee = Environment.GetEnvironmentVariable("BEE_BUILD_THREADS");
string previousBinaryen = Environment.GetEnvironmentVariable("BINARYEN_CORES");
DateTime started = DateTime.UtcNow;
try
{
Debug.Log("[VerifyHybridClrGeneration] Verify standard Generate All and project build callbacks");
PrebuildCommand.GenerateAll();
string directory = SettingsUtil.GetAssembliesPostIl2CppStripDir(EditorUserBuildSettings.activeBuildTarget);
string[] dlls = Directory.GetFiles(directory, "*.dll");
if (dlls.Length == 0) throw new Exception("No stripped AOT DLLs generated");
string[] generated = {
Path.Combine(SettingsUtil.GeneratedCppDir, "MethodBridge.cpp"),
Path.Combine(SettingsUtil.GeneratedCppDir, "UnityVersion.h"),
Path.Combine(Application.dataPath, HybridCLRSettings.Instance.outputLinkFile),
Path.Combine(Application.dataPath, HybridCLRSettings.Instance.outputAOTGenericReferenceFile)
};
foreach (string file in generated)
if (!File.Exists(file) || new FileInfo(file).Length == 0 || File.GetLastWriteTimeUtc(file) < started)
throw new Exception("Missing or stale generated file: " + file);
if (Environment.GetEnvironmentVariable("BEE_BUILD_THREADS") != previousBee ||
Environment.GetEnvironmentVariable("BINARYEN_CORES") != previousBinaryen)
throw new Exception("Build worker environment was not restored");
string output = Path.GetFullPath(Path.Combine(Application.dataPath, "../../Tools/output/hybridclr_genall"));
Directory.CreateDirectory(output);
string result = JsonConvert.SerializeObject(new {
passed = true,
target = EditorUserBuildSettings.activeBuildTarget.ToString(),
startedUtc = started,
completedUtc = DateTime.UtcNow,
beeBuildThreads = 4,
binaryenCores = 4,
environmentRestored = true,
generatedFiles = generated.Select(Path.GetFullPath).ToArray(),
aotDirectory = Path.GetFullPath(directory),
aotDllCount = dlls.Length,
aotDlls = dlls.Select(Path.GetFileName).ToArray()
}, Formatting.Indented);
File.WriteAllText(Path.Combine(output, "generate_all.json"), result);
return result;
}
finally
{
Environment.SetEnvironmentVariable("BEE_BUILD_THREADS", previousBee);
Environment.SetEnvironmentVariable("BINARYEN_CORES", previousBinaryen);
}
}
}
@@ -0,0 +1,119 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AIGame.Review;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public static class VerifyLmGirlAnimationLab
{
const string Root="Assets/Review/CharacterAnimationLab";
static string Output=>Path.Combine(Directory.GetParent(Application.dataPath).Parent.FullName,"Tools/output/lmgirl_tpose_studio/rig_mixamo41");
static void Check(bool ok,string message){if(!ok)throw new InvalidOperationException(message);}
static CharacterAnimationLab Viewer()=>UnityEngine.Object.FindObjectOfType<CharacterAnimationLab>();
public static string Verify()
{
var data=JObject.Parse(File.ReadAllText(Path.Combine(Output,"unity_review_data.json")));
var expected=JObject.Parse(File.ReadAllText(Path.Combine(Output,"walking_comparison.json")));
var root=PrefabUtility.LoadPrefabContents(Root+"/LmGirl/Generated/LmGirlAnimationLab.prefab");
var baked=new Mesh();var quality=QualitySettings.skinWeights;
try
{
QualitySettings.skinWeights=SkinWeights.Unlimited;
var v=root.GetComponent<CharacterAnimationLab>();v.playing=false;v.inPlace=false;v.motion=0;
// Historical bind/weight baseline, deliberately excluding the later
// foot-path correction. Active corrected playback has a gait audit.
foreach(var actor in v.actors)actor.walkingClip=null;
foreach(var actor in v.actors)actor.rig.transform.localScale=Vector3.one;
Check(v.clips[0]==AssetDatabase.LoadAssetAtPath<AnimationClip>(Root+"/Generated/Walking.anim"),"Walking clip must be the same asset.");
Check(v.actors.Length==4&&v.actors[0].renderer.bones.Length==33,"Website 33-bone actor required.");
Check(v.actors.Skip(1).All(a=>a.renderer.bones.Length==41&&a.translationOffsets.Length==41),"TokenRig 41 bones/offsets required.");
var results=new List<object>();
foreach(var variant in data["variants"])
{
string name=(string)variant["name"];
var mesh=AssetDatabase.LoadAssetAtPath<Mesh>(Root+"/LmGirl/Generated/"+name+".asset");
Check(mesh.triangles.Length/3==4999,"Topology changed.");
v.actors[1].renderer.sharedMesh=mesh;
float[] maxima=new float[3];int[] masks={1,2,4};
for(int frame=0;frame<32;frame++)
{
v.time=frame/30f;v.Sample();v.actors[1].renderer.BakeMesh(baked);var points=baked.vertices;
foreach(var edge in variant["auditEdges"])
{
float ratio=Vector3.Distance(points[(int)edge[0]],points[(int)edge[1]])/(float)edge[2];
for(int r=0;r<3;r++)if(((int)edge[3]&masks[r])!=0)maxima[r]=Mathf.Max(maxima[r],ratio);
}
}
string[] regions={"all","hands","neck"};float error=0;
for(int r=0;r<3;r++)error=Mathf.Max(error,Mathf.Abs(maxima[r]-(float)expected[name]["maxStretch"][regions[r]]));
Check(error<.003f,name+" native comparison mismatch: "+error);
results.Add(new{variant=name,frames=32,maxStretch=maxima[0],hands=maxima[1],neck=maxima[2],maxDifference=error});
}
var website=JObject.Parse(File.ReadAllText(Path.Combine(Output,"website/unity_review_data.json")));
var websiteExpected=JObject.Parse(File.ReadAllText(Path.Combine(Output,"website/walking_comparison.json")));
Check(v.actors[0].renderer.sharedMesh.GetBonesPerVertex().ToArray().Max()==5,"Website influences were truncated.");
float[] siteMax=new float[3];int[] siteMasks={1,2,4};
for(int frame=0;frame<32;frame++)
{
v.time=frame/30f;v.Sample();v.actors[0].renderer.BakeMesh(baked);var points=baked.vertices;
foreach(var edge in website["variants"][0]["auditEdges"])
{
float ratio=Vector3.Distance(points[(int)edge[0]],points[(int)edge[1]])/(float)edge[2];
for(int r=0;r<3;r++)if(((int)edge[3]&siteMasks[r])!=0)siteMax[r]=Mathf.Max(siteMax[r],ratio);
}
}
string[] siteRegions={"all","hands","neck"};float siteError=0;
for(int r=0;r<3;r++)siteError=Mathf.Max(siteError,Mathf.Abs(siteMax[r]-(float)websiteExpected["maxStretch"][siteRegions[r]]));
Check(siteError<.003f,"Website rest-axis mapping mismatch: "+siteError);
results.Add(new{variant="mixamo",frames=32,maxStretch=siteMax[0],hands=siteMax[1],neck=siteMax[2],maxDifference=siteError});
var report=new{passed=true,samples=192,bones=41,websiteBones=33,websiteMaxInfluences=5,triangles=4999,sameWalkingClip=true,results};
File.WriteAllText(Path.Combine(Output,"unity_verification.json"),JsonConvert.SerializeObject(report,Formatting.Indented));
return JsonConvert.SerializeObject(report);
}
finally{QualitySettings.skinWeights=quality;UnityEngine.Object.DestroyImmediate(baked);PrefabUtility.UnloadPrefabContents(root);}
}
public static string Controls()
{
var v=Viewer();Check(Application.isPlaying,"Play Mode required.");
var buttons=v.ui.GetComponentsInChildren<Button>(true).ToDictionary(b=>b.name);
void Click(string name)=>buttons["Btn_"+name].onClick.Invoke();
Check(buttons.Count==22,"22 serialized buttons required.");
v.motion=0;v.time=0;v.playing=true;v.inPlace=true;v.Sample();Click("Play");Check(!v.playing,"Pause");
Click("Next");Check(Mathf.Abs(v.time-1f/30)<.00001f,"Next frame");Click("Previous");Check(v.time==0,"Previous frame");
Click("Breathe");Check(v.motion==1,"Breathing");Click("Happy");Check(v.motion==2,"Happy");Click("Walk");
foreach(var speed in new[]{"Speed025","Speed05","Speed1","Speed2"})Click(speed);
Check(v.speed==2,"Speed");Click("Speed1");
var seek=v.ui.GetComponentInChildren<AnimationLabSeekBar>();var corners=new Vector3[4];((RectTransform)seek.transform).GetWorldCorners(corners);
seek.OnPointerDown(new PointerEventData(EventSystem.current){position=RectTransformUtility.WorldToScreenPoint(null,Vector3.Lerp(corners[0],corners[2],.5f))});
Check(Mathf.Abs(v.time-v.Duration*.5f)<.001f&&!v.playing,"Pointer seek");
v.Seek(1);var stationary=v.actors[0].hips.localPosition;Click("Travel");
Check(Vector3.Distance(v.actors[0].hips.localPosition-stationary,v.travel[0]*v.actors[0].translationScale)<.00001f,"Proportional travel");Click("Travel");
Click("Focus4");Click("Detail");Click("Detail");Check(v.focus==3&&v.detail==2&&v.actors[3].rightHand,"Fourth actor hand close-up");
Check(v.actors.Count(a=>a.renderer.enabled)==1,"Focused view must isolate the selected actor.");
Click("Material");Check(v.actors.All(a=>a.renderer.sharedMaterial==v.clayMaterial),"Clay");Click("Material");
Click("Side");Click("Back");Click("Front");Click("All");
Check(v.actors.All(a=>a.renderer.enabled),"All-view must restore four actors.");
var first=v.actors[1].renderer.bones;
foreach(var actor in v.actors.Skip(2))for(int i=0;i<41;i++)
{
Check(Vector3.Distance(first[i].localPosition,actor.renderer.bones[i].localPosition)<.00001f,"Pose translations differ.");
Check(Mathf.Abs(Quaternion.Dot(first[i].localRotation,actor.renderer.bones[i].localRotation))>.99999f,"Pose rotations differ.");
}
var result=new{passed=true,buttons=22,checks=new[]{"pause","frame step","three clips","four speeds","pointer seek","scaled in-place travel","fourth actor focus/detail","materials","views","TokenRig 41-bone pose synchronization"}};
File.WriteAllText(Path.Combine(Output,"unity_controls.json"),JsonConvert.SerializeObject(result,Formatting.Indented));
v.time=0;v.playing=true;v.Sample();return JsonConvert.SerializeObject(result);
}
public static string Pose(int frame,int focus,int detail,int yaw,bool textured)
{
var v=Viewer();v.motion=0;v.time=frame/30f;v.playing=false;v.inPlace=true;v.focus=focus;v.detail=detail;v.yaw=yaw;v.textured=textured;v.Sample();
return JsonConvert.SerializeObject(new{v.time,v.focus,v.detail,v.yaw,v.textured});
}
}
#endif
+112
View File
@@ -0,0 +1,112 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using AIGame.Review;
public static class VerifyLmGirlGait
{
const string Root="Assets/Review/CharacterAnimationLab/LmGirl/Generated";
static string Output=>Path.Combine(Directory.GetParent(Application.dataPath).Parent.FullName,"Tools/output/lmgirl_tpose_studio/rig_mixamo41");
static Vector3 V(JToken t)=>new Vector3((float)t[0],(float)t[1],(float)t[2]);
static void Check(bool valid,string message){if(!valid)throw new InvalidOperationException(message);}
public static string Verify()
{
var expected=JObject.Parse(File.ReadAllText(Path.Combine(Output,"gait_correction.json")));
var data=JObject.Parse(File.ReadAllText(Path.Combine(Output,"unity_review_data.json")));
var website=JObject.Parse(File.ReadAllText(Path.Combine(Output,"website/unity_review_data.json")));
var root=PrefabUtility.LoadPrefabContents(Root+"/LmGirlAnimationLab.prefab");
var baked=new Mesh();var previousQuality=QualitySettings.skinWeights;
try
{
QualitySettings.skinWeights=SkinWeights.Unlimited;
var v=root.GetComponent<CharacterAnimationLab>();v.playing=false;v.inPlace=false;v.motion=0;v.focus=-1;
Check(v.actors.All(a=>a.walkingClip&&Mathf.Abs(a.walkingClip.length-v.Duration)<.00001f),"Missing mapped walk or wrong timing.");
Check(v.actors.Skip(1).All(a=>a.walkingClip==v.actors[1].walkingClip),"All TokenRig variants must share one corrected motion.");
foreach(var a in v.actors)a.rig.transform.localScale=Vector3.one;
var bones=v.actors.Select(a=>a.renderer.bones.ToDictionary(b=>b.name.Split(':').Last())).ToArray();
float maxPositionError=0,maxLengthDrift=0;var minGaps=Enumerable.Repeat(float.MaxValue,4).ToArray();
var lengths=new float[4,4];
for(int ai=0;ai<4;ai++)
{
var bind=ai==0?website:data;
var rest=bind["bones"].ToDictionary(b=>(string)b["name"],b=>Matrix(b["bindpose"]).inverse.GetColumn(3));
int segment=0;
foreach(string side in new[]{"Left","Right"})foreach(var pair in new[]{new[]{"UpLeg","Leg"},new[]{"Leg","Foot"}})
lengths[ai,segment++]=Vector3.Distance(rest["mixamorig:"+side+pair[0]],rest["mixamorig:"+side+pair[1]]);
}
for(int frame=0;frame<32;frame++)
{
v.time=frame/30f;v.Sample();
for(int ai=0;ai<4;ai++)
{
var a=v.actors[ai];var track=expected["reports"][ai==0?"mixamo":"tokenrig"]["tracks"][frame];
foreach(var p in (JObject)track["positions"])
{
var actual=a.rig.transform.InverseTransformPoint(bones[ai][p.Key].position);
maxPositionError=Mathf.Max(maxPositionError,Vector3.Distance(actual,V(p.Value)));
}
}
}
Check(maxPositionError<.00005f,"Native mapped pose disagrees with independent IK reference: "+maxPositionError);
// 120Hz scan covers interpolation between original 30Hz keyframes.
for(int frame=0;frame<=124;frame++)
{
v.time=frame/120f;v.Sample();
for(int ai=0;ai<4;ai++)
{
var b=bones[ai];var lateral=(b["LeftUpLeg"].position-b["RightUpLeg"].position).normalized;
float gap=Vector3.Dot(b["LeftFoot"].position-b["RightFoot"].position,lateral);minGaps[ai]=Mathf.Min(minGaps[ai],gap);
int segment=0;
foreach(string side in new[]{"Left","Right"})foreach(var pair in new[]{new[]{"UpLeg","Leg"},new[]{"Leg","Foot"}})
maxLengthDrift=Mathf.Max(maxLengthDrift,Mathf.Abs(Vector3.Distance(b[side+pair[0]].position,b[side+pair[1]].position)-lengths[ai,segment++]));
}
}
Check(minGaps.All(g=>g>.08f),"Feet cross or become too close: "+string.Join(",",minGaps));
Check(maxLengthDrift<.0001f,"Leg segment lengths changed: "+maxLengthDrift);
var deformation=new List<object>();
foreach(var variant in data["variants"].Concat(website["variants"]))
{
string name=(string)variant["name"];int ai=name=="mixamo"?0:1;var actor=v.actors[ai];
actor.renderer.sharedMesh=AssetDatabase.LoadAssetAtPath<Mesh>(Root+"/"+name+".asset");
float[] maxima=new float[3];int[] bits={1,2,4};
for(int frame=0;frame<32;frame++)
{
v.time=frame/30f;v.Sample();actor.renderer.BakeMesh(baked);var vertices=baked.vertices;
foreach(var edge in variant["auditEdges"])
{
float ratio=Vector3.Distance(vertices[(int)edge[0]],vertices[(int)edge[1]])/(float)edge[2];
for(int i=0;i<3;i++)if(((int)edge[3]&bits[i])!=0)maxima[i]=Mathf.Max(maxima[i],ratio);
}
}
string[] regions={"all","hands","neck"};float error=0;
for(int i=0;i<3;i++)error=Mathf.Max(error,Mathf.Abs(maxima[i]-(float)expected["deformation"][name]["maxStretch"][regions[i]]));
Check(error<.003f,"Corrected skin mismatch "+name+": "+error);
deformation.Add(new{variant=name,maxStretch=maxima[0],hands=maxima[1],neck=maxima[2],maxDifference=error});
}
var result=new{passed=true,originalFrames=32,interpolationHz=120,interpolationSamples=125*4,
minimumFootSignedGaps=minGaps,maxPositionError,maxLengthDrift,deformationSamples=192,deformation};
File.WriteAllText(Path.Combine(Output,"unity_gait_verification.json"),JsonConvert.SerializeObject(result,Formatting.Indented));
return JsonConvert.SerializeObject(result);
}
finally{QualitySettings.skinWeights=previousQuality;UnityEngine.Object.DestroyImmediate(baked);PrefabUtility.UnloadPrefabContents(root);}
}
static Matrix4x4 Matrix(JToken rows)
{
var m=new Matrix4x4();for(int r=0;r<4;r++)for(int c=0;c<4;c++)m[r,c]=(float)rows[r][c];return m;
}
public static string Pose(bool corrected,int frame,int focus)
{
var v=UnityEngine.Object.FindObjectOfType<CharacterAnimationLab>();
for(int i=0;i<v.actors.Length;i++)v.actors[i].walkingClip=corrected?
AssetDatabase.LoadAssetAtPath<AnimationClip>(Root+"/Walking_LmGirl"+(i==0?"Mixamo33":"41")+".anim"):null;
v.motion=0;v.time=frame/30f;v.playing=false;v.inPlace=true;v.focus=focus;v.detail=0;v.yaw=0;v.textured=false;v.Sample();
return corrected?"Corrected foot-path mapping":"Previous angle-only mapping (temporary Play Mode preview)";
}
}
#endif
@@ -0,0 +1,89 @@
using System;
using System.Linq;
using System.Reflection;
using System.IO;
using AOT.Boot;
using HotUpdate.Net;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
public static class VerifyMiniGameNetwork
{
static string Output => Path.GetFullPath(Path.Combine(Application.dataPath, "../../Tools/output/minigame_network"));
public static string ExportNow()
{
Check(!EditorApplication.isPlaying && !BuildPipeline.isBuildingPlayer);
Directory.CreateDirectory(Output);
// Remove an earlier background queue if the unfocused editor never pumped delayCall.
foreach (var callback in EditorApplication.delayCall?.GetInvocationList() ?? Array.Empty<Delegate>())
if (callback.Method.DeclaringType.Assembly.GetName().Name.StartsWith("PipelineRunScript_VerifyMiniGameNetwork", StringComparison.Ordinal))
EditorApplication.delayCall -= (EditorApplication.CallbackFunction)callback;
SaveExport("building", null);
try
{
string result = AOT.Editor.WeChat.ExportWeChatBasePackage.Export();
SaveExport(result.StartsWith("ok", StringComparison.Ordinal) ? "completed" : "failed", result);
return result;
}
catch (Exception error) { SaveExport("failed", error.ToString()); throw; }
}
static void SaveExport(string status, string result) => File.WriteAllText(Path.Combine(Output, "export.json"),
JsonConvert.SerializeObject(new { status, result, utc = DateTime.UtcNow }, Formatting.Indented));
public static string StageHotUpdate()
{
string result = AOT.Editor.YooAssetTools.BuildAndStageHotUpdate.BuildAndStage();
if (!result.StartsWith("ok", StringComparison.Ordinal)) throw new Exception(result);
HybridCLR.Editor.Commands.LinkGeneratorCommand.GenerateLinkXml(EditorUserBuildSettings.activeBuildTarget);
return result;
}
public static string CheckUiReady()
{
var gameplay = UnityEngine.Object.FindObjectOfType<HotUpdate.Gameplay.GameplayBootstrap>();
Check(gameplay != null);
var ctx = (BootContext)typeof(HotUpdate.Gameplay.GameplayBootstrap)
.GetField("_ctx", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(gameplay);
Check(ctx != null && ctx.MainUiReady && string.IsNullOrEmpty(ctx.MainUiError));
Check(HotUpdate.UI.UiManager.Instance.Get("UI_MainHUD") != null);
return "PASS: authored home prefab loaded and signalled readiness independently of network login";
}
public static string Endpoints()
{
const string http = BootSettings.ProductionHttpBase;
const string ws = BootSettings.ProductionWsTemplate;
Check(AuthApi.DefaultHttpBase == "https://BuddyDefense.xworld.ren:15888");
Check(GameNet.ResolveRemoteEndpoint("https://game.xworld.ren", "https", http) == http);
Check(GameNet.ResolveRemoteEndpoint("wss://game.xworld.ren/ws?pid={pid}&token={token}", "wss", ws) == ws);
Check(GameNet.ResolveRemoteEndpoint("http://127.0.0.1:8080", "https", http) == http);
Check(GameNet.ResolveRemoteEndpoint("https://localhost:8080", "https", http) == http);
Check(GameNet.ResolveRemoteEndpoint("https://[::1]:8080", "https", http) == http);
Check(GameNet.ResolveRemoteEndpoint("https://dev.localhost", "https", http) == http);
Check(GameNet.ResolveRemoteEndpoint("ws://127.0.0.1/ws", "wss", ws) == ws);
Check(GameNet.ResolveRemoteEndpoint(ws, "wss", "invalid") == ws);
Check(GameNet.ResolveRemoteEndpoint("https://custom.example/api", "https", http) == "https://custom.example/api");
Check(GameNet.ResolveRemoteEndpoint(null, "https", http) == http);
var session = new SessionData { Pid = 9, Token = "a+b &c" };
string resolved = session.BuildWsUrl();
Check(resolved.StartsWith("wss://BuddyDefense.xworld.ren:15888/ws?pid=9&token="));
Check(resolved.EndsWith("a%2Bb%20%26c"));
return "PASS: local/insecure endpoints fall back; explicit secure remote endpoints preserved";
}
public static string SceneStatus() => JsonConvert.SerializeObject(new {
playing = EditorApplication.isPlaying,
boot = UnityEngine.Object.FindObjectsOfType<BootDriver>().Select(b => new { b.name, b.enabled }).ToArray(),
canvases = UnityEngine.Object.FindObjectsOfType<Canvas>().Select(c => new { c.name, c.enabled,
c.renderMode, c.sortingOrder, active = c.gameObject.activeInHierarchy,
camera = c.worldCamera != null ? c.worldCamera.name : null }).ToArray(),
cameras = UnityEngine.Object.FindObjectsOfType<Camera>().Select(c => new { c.name, c.enabled,
c.depth, c.cullingMask, target = c.targetTexture != null ? c.targetTexture.name : null }).ToArray()
});
static void Check(bool ok) { if (!ok) throw new Exception("Endpoint regression"); }
}
@@ -0,0 +1,119 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using HotUpdate.UI;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
using Object = UnityEngine.Object;
public static class VerifyPreviewDeployment
{
static string Output => Path.GetFullPath(Path.Combine(Application.dataPath, "../../Tools/output/server_release"));
const string MaterialPath = "Assets/Bundles/UI/Material/HomeExplorerCutout.mat";
public static string PackedMaterial()
{
string art = Path.GetFullPath(Path.Combine(Application.dataPath, "../../Tools/cdn_root/wechat/1.0.0/Art"));
var shaders = AssetBundle.LoadFromFile(Directory.GetFiles(art, "unityshaders_*.bundle").Single());
var bundle = AssetBundle.LoadFromFile(Directory.GetFiles(art, "share_assets_bundles_ui_material_*.bundle").Single());
Check(bundle != null, "Material bundle unavailable");
try
{
var material = bundle.LoadAllAssets<Material>().Single(m => m.name == "HomeExplorerCutout");
var saved = new SerializedObject(material).FindProperty("m_SavedProperties.m_Colors");
var key = Color.clear;
for (int i = 0; i < saved.arraySize; i++)
{
var pair = saved.GetArrayElementAtIndex(i);
if (pair.FindPropertyRelative("first").stringValue == "_Key") key = pair.FindPropertyRelative("second").colorValue;
}
Directory.CreateDirectory(Output);
string json = JsonConvert.SerializeObject(new { shippedKey = new[] { key.r, key.g, key.b },
sourceKey = AssetDatabase.LoadAssetAtPath<Material>(MaterialPath).GetColor("_Key").ToString(),
hardcodedMagentaMismatch = Vector4.Distance(key, Color.magenta) > .5f });
File.WriteAllText(Path.Combine(Output, "packed_preview.json"), json);
return json;
}
finally { bundle.Unload(true); if (shaders != null) shaders.Unload(true); }
}
public static string StartLegacyPreview()
{
Check(EditorApplication.isPlaying, "Play isolated scene first");
var holder = new GameObject("LegacyPreviewHolder"); holder.SetActive(false);
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/UI_MainHUD.prefab");
var hud = Object.Instantiate(prefab, holder.transform, false); hud.name = "LegacyPreviewHUD";
var raw = hud.transform.Find("ExplorerStage").GetComponent<RawImage>();
var material = new Material(AssetDatabase.LoadAssetAtPath<Material>(MaterialPath));
material.SetColor("_Key", new Color(.07f,.02f,.09f,1)); raw.material = material;
holder.SetActive(true);
var camera = new GameObject("LegacyPreviewCamera", typeof(Camera)).GetComponent<Camera>();
camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = Color.gray; camera.cullingMask = 0;
return "Started authored HUD with shipped legacy material key";
}
public static string RuntimeStatus() => JsonConvert.SerializeObject(new {
roots = Object.FindObjectsOfType<HomeExplorerPresenter>(true).Select(p => new { p.name,p.enabled,
active=p.gameObject.activeInHierarchy,p.Ready,material=p.GetComponent<RawImage>()?.material?.name,
shader=p.GetComponent<RawImage>()?.material?.shader?.name,
supported=p.GetComponent<RawImage>()?.material?.shader?.isSupported }).ToArray(),
cameras=Object.FindObjectsOfType<Camera>().Select(c=>c.name).ToArray()
});
public static string CheckAndCapture()
{
var hud = GameObject.Find("LegacyPreviewHUD"); var raw = hud.transform.Find("ExplorerStage").GetComponent<RawImage>();
var presenter = raw.GetComponent<HomeExplorerPresenter>();
Check(presenter != null && presenter.Ready, "Preview not ready");
var previewCamera = (Camera)typeof(HomeExplorerPresenter).GetField("_camera", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(presenter);
Check(Vector4.Distance(previewCamera.backgroundColor, raw.material.GetColor("_Key")) < .001f, "Camera clear differs from shipped material");
VerifyDiscard(raw.material);
var canvas = hud.GetComponent<Canvas>(); var oldMode = canvas.renderMode; var oldCamera = canvas.worldCamera;
var camera = GameObject.Find("LegacyPreviewCamera").GetComponent<Camera>();
var target = new RenderTexture(540, 960, 24); var pixels = new Texture2D(540, 960, TextureFormat.RGB24, false);
var previous = RenderTexture.active;
try
{
camera.targetTexture = target; camera.cullingMask = 1 << LayerMask.NameToLayer("UI");
canvas.renderMode = RenderMode.ScreenSpaceCamera; canvas.worldCamera = camera; canvas.planeDistance = 1;
Canvas.ForceUpdateCanvases(); camera.Render(); RenderTexture.active = target;
pixels.ReadPixels(new Rect(0,0,540,960),0,0); pixels.Apply(); Directory.CreateDirectory(Output);
File.WriteAllBytes(Path.Combine(Output,"legacy_preview.png"),pixels.EncodeToPNG());
}
finally
{
RenderTexture.active = previous; camera.targetTexture = null; canvas.renderMode = oldMode; canvas.worldCamera = oldCamera;
target.Release(); Object.Destroy(target); Object.Destroy(pixels);
}
return "PASS: legacy shipped material and camera match; GPU removes the key, foreground retained; screenshot captured";
}
static void VerifyDiscard(Material material)
{
var input = new Texture2D(2,2,TextureFormat.RGBA32,false,true);
var output = new RenderTexture(8,8,0,RenderTextureFormat.ARGB32,RenderTextureReadWrite.Linear);
var read = new Texture2D(8,8,TextureFormat.RGBA32,false,true); var previous = RenderTexture.active;
try
{
foreach (var key in new[] { new Color(.07f,.02f,.09f,1), Color.magenta })
{
var test = new Material(material); test.SetColor("_Key",key);
try
{
input.SetPixels(Enumerable.Repeat(key,4).ToArray()); input.Apply(); output.Create();
RenderTexture.active = output; GL.Clear(true,true,Color.cyan); Graphics.Blit(input,output,test);
RenderTexture.active = output; read.ReadPixels(new Rect(0,0,8,8),0,0);read.Apply();
var color = read.GetPixel(4,4); Check(color.r < .05f && color.g > .95f && color.b > .95f,"GPU did not discard background");
Graphics.Blit(Texture2D.whiteTexture,output,test); RenderTexture.active = output;
read.ReadPixels(new Rect(0,0,8,8),0,0);read.Apply(); Check(read.GetPixel(4,4).r > .95f,"GPU discarded foreground");
}
finally { Object.Destroy(test); }
}
}
finally { RenderTexture.active = previous; output.Release(); Object.Destroy(input); Object.Destroy(output); Object.Destroy(read); }
}
static void Check(bool pass,string message) { if (!pass) throw new Exception(message); }
}
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using HotUpdate.Gameplay;
using Newtonsoft.Json;
using TMPro;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using Object = UnityEngine.Object;
public static class VerifyPrototypePanelBindings
{
static readonly Type PanelType = typeof(PrototypeFlow).GetNestedType("Panel", BindingFlags.NonPublic);
static object Bind(GameObject go) => Activator.CreateInstance(PanelType, new object[] { go });
static void Check(bool condition, string message) { if (!condition) throw new Exception(message); }
public static string All()
{
var scene = EditorSceneManager.NewPreviewScene();
var root = new GameObject("PanelBindingRegression", typeof(RectTransform), typeof(Canvas));
SceneManager.MoveGameObjectToScene(root, scene);
root.GetComponent<Canvas>().renderMode = RenderMode.ScreenSpaceOverlay;
var failures = new List<string>();
int passed = 0, generated = 0;
var paths = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/Bundles/UI/Prefab" })
.Select(AssetDatabase.GUIDToAssetPath)
.Where(p => System.IO.Path.GetFileName(p).StartsWith("UI_Prototype", StringComparison.Ordinal)).ToArray();
try
{
foreach (string path in paths)
{
var go = Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>(path), root.transform, false);
try
{
var texts = go.GetComponentsInChildren<TextMeshProUGUI>(true);
Check(texts.Length >= 2, path + ": expected multiple labels");
foreach (var text in texts) text.ForceMeshUpdate(true, true);
// Use TMP's own factory: two labels can render with the same material,
// creating identically named internal nodes, as on the mini game.
var font = texts[0].font;
var material = texts[0].fontSharedMaterial;
var reference = new MaterialReference(1, font, null, material, 0);
TMP_SubMeshUI.AddSubTextObject(texts[0], reference);
TMP_SubMeshUI.AddSubTextObject(texts[1], reference);
generated += go.GetComponentsInChildren<TMP_SubMeshUI>(true).Length;
object panel = Bind(go);
var nodes = (Dictionary<string, Transform>)PanelType.GetField("_nodes", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(panel);
var labels = (Dictionary<string, TextMeshProUGUI>)PanelType.GetField("_texts", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(panel);
Check(nodes.Values.All(n => n.GetComponent<TMP_SubMeshUI>() == null), path + ": internal renderer leaked into bindings");
foreach (var node in go.GetComponentsInChildren<Transform>(true).Where(n => n.GetComponent<TMP_SubMeshUI>() == null))
Check((Transform)PanelType.GetMethod("Node").Invoke(panel, new object[] { node.name }) == node, path + ": missing node " + node.name);
foreach (var text in texts.Where(t => t.name.StartsWith("Txt_", StringComparison.Ordinal)))
{
Check(labels[text.name] == text, path + ": text lookup failed " + text.name);
if (text.GetComponentInParent<TMP_InputField>() != null) continue;
string value = text.text + " ";
PanelType.GetMethod("Text").Invoke(panel, new object[] { text.name.Substring(4), value });
Check(text.text == value, path + ": text binding failed " + text.name);
}
foreach (var button in go.GetComponentsInChildren<Button>(true))
Check((Button)PanelType.GetMethod("Button").Invoke(panel, new object[] { button.name.Substring(4) }) == button, path + ": button binding failed " + button.name);
go.SetActive(false); go.SetActive(true);
Bind(go); // Cached prefab can be reopened after TMP children already exist.
// Keep genuine authoring mistakes visible instead of silently choosing a node.
var duplicate = new GameObject(texts[0].name, typeof(RectTransform));
duplicate.transform.SetParent(go.transform, false);
bool rejected = false;
try { Bind(go); }
catch (TargetInvocationException error) { rejected = error.InnerException is ArgumentException; }
Check(rejected, path + ": duplicate authored node silently accepted");
passed++;
}
catch (Exception error) { failures.Add(path + ": " + (error.InnerException ?? error).Message); }
finally { Object.DestroyImmediate(go); }
}
}
finally { EditorSceneManager.ClosePreviewScene(scene); }
Check(paths.Length > 0, "No prototype prefabs found");
Check(failures.Count == 0, string.Join("\n", failures));
return JsonConvert.SerializeObject(new { passed, total = paths.Length, generatedTmpChildren = generated, failures });
}
}
+114
View File
@@ -0,0 +1,114 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using Object=UnityEngine.Object;
/// <summary>Isolated T2 asset/pose checks and authoritative battle fixture. No network/account writes.</summary>
public static class VerifyTideWhaleT2
{
const string Animal="Assets/Bundles/Characters/Animal/";
const string T2=Animal+"TideWhale/TideWhale_T2.prefab";
const string Arena="Assets/Bundles/Scenes/Prototype/PrototypeArena.prefab";
static string Repo=>Path.GetFullPath(Path.Combine(Application.dataPath,"../.."));
static string Folder=>Path.Combine(Repo,"Doc/Design/Character/TideWhale/T2");
static void Check(bool value,string message){if(!value)throw new Exception(message);}
static void Report(string name,object value)=>File.WriteAllText(Path.Combine(Folder,name+".json"),JsonConvert.SerializeObject(value,Formatting.Indented));
public static string Assets()
{
Check(!EditorApplication.isPlaying,"Check assets in Edit Mode");
var t1=AssetDatabase.LoadAssetAtPath<GameObject>(Animal+"TideWhaleStudio/TideWhaleStudio_Companion.prefab");
var t2=AssetDatabase.LoadAssetAtPath<GameObject>(T2);Check(t2!=null,"Missing T2");
Check(PrefabUtility.GetCorrespondingObjectFromSource(t2)==t1,"Not a variant of the formal Studio T1");
var a=t1.GetComponentInChildren<Animator>(true);var b=t2.GetComponentInChildren<Animator>(true);
Check(a.avatar==b.avatar&&a.runtimeAnimatorController==b.runtimeAnimatorController,"Original rig/controller changed");
var acc=t2.GetComponentsInChildren<Transform>(true).Single(t=>t.name=="T2Accessory");Check(acc.parent.name.StartsWith("bone_"),"Accessory not on bone");
var mesh=acc.GetComponentInChildren<MeshFilter>(true).sharedMesh;int triangles=mesh.triangles.Length/3;
Check(triangles<=520&&mesh.uv.Length==mesh.vertexCount,"Triangle/UV budget invalid");
foreach(var r in acc.GetComponentsInChildren<Renderer>(true))Check(r.sharedMaterial!=null&&r.sharedMaterial.shader.name=="Test/XPbr","Missing accessory XPbr");
foreach(string suffix in new[]{"base","mix"}){var tex=AssetDatabase.LoadAssetAtPath<Texture2D>(Animal+"TideWhale/TideWhaleT2/TideWhaleT2_"+suffix+".png");Check(tex!=null&&tex.width==512&&tex.height==512,"Missing 512 texture");}
var arena=AssetDatabase.LoadAssetAtPath<GameObject>(Arena);var hero=arena.transform.Find("Heroes/Hero9");var visual=hero.Find("VisualT2");
Check(visual!=null&&!visual.gameObject.activeSelf&&PrefabUtility.GetCorrespondingObjectFromSource(visual.gameObject)==t2,"Arena has no inactive nested T2");
Check(visual.GetComponentsInChildren<Transform>(true).All(t=>t.gameObject.layer==30),"Battle layer changed");
Check(Vector3.Distance(visual.localScale,hero.Find("Visual").localScale)<1e-5f,"T1/T2 scales differ");
var scene=EditorSceneManager.NewPreviewScene();var instance=(GameObject)PrefabUtility.InstantiatePrefab(t2,scene);
try
{
var animator=instance.GetComponentInChildren<Animator>();animator.enabled=false;
var crown=instance.GetComponentsInChildren<Transform>().Single(t=>t.name=="T2Accessory");var position=crown.localPosition;var rotation=crown.localRotation;var scale=crown.localScale;
foreach(string motion in new[]{"Idle","Walk","Attack"})
{
var clip=AssetDatabase.LoadAssetAtPath<AnimationClip>(Animal+"TideWhale/Clips/"+motion+".anim");Check(clip!=null,"Missing original "+motion);
for(int i=0;i<=120;i++)
{
clip.SampleAnimation(animator.gameObject,clip.length*i/120);
Check(Vector3.Distance(position,crown.localPosition)<1e-5f&&Quaternion.Angle(rotation,crown.localRotation)<.001f&&Vector3.Distance(scale,crown.localScale)<1e-5f,"Accessory not rigid on head in "+motion);
var p=crown.position;Check(!float.IsNaN(p.x+p.y+p.z)&&!float.IsInfinity(p.x+p.y+p.z),"Invalid pose");
}
}
}
finally{Object.DestroyImmediate(instance);EditorSceneManager.ClosePreviewScene(scene);}
Report("unity_assets",new{status="pass",triangles,uvVertices=mesh.uv.Length,textureSize=512,bone=acc.parent.name,originalAvatar=true,originalController=true,originalThreeClips=true,poseSamples=363,arenaHero=9,inactiveByDefault=true,layer=30});
return "PASS TideWhale T2: "+triangles+" tris, 512 textures, "+acc.parent.name+", unchanged T1 rig/controller, 363 rigid attachment samples, Hero9 nested";
}
public static async Task<string> Battle()
{
Check(EditorApplication.isPlaying,"Enter Play Mode");
var data=JObject.Parse(File.ReadAllText(Path.Combine(Repo,"Tools/output/combat_runtime/traces/stage_20_t2_f1.json")));
var snapshot=JsonUtility.FromJson<BattleSnapshot>(data["save"].ToString());var config=JsonUtility.FromJson<PrototypeConfig>(data["config"].ToString());
var game=new PrototypeGame(PrototypeSave.BattleSnapshot(snapshot.level,snapshot.stages,snapshot.formation));var battle=new PrototypeBattle("tidewhale-t2-readonly",game,config,20);
battle.LoadTrace(JsonUtility.FromJson<CombatTraceManifest>(data["manifest"].ToString()),data["events"].ToObject<CombatEvent[]>());
var arena=Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>(Arena));var ui=Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/UI_PrototypeBattle.prefab"));ui.name="UI_PrototypeBattle";ui.SetActive(false);
var host=new GameObject("TideWhaleT2ReadOnlyReview");host.SetActive(false);var flow=host.AddComponent<PrototypeFlow>();
Action<string,object> set=(name,value)=>typeof(PrototypeFlow).GetField(name,BindingFlags.Instance|BindingFlags.NonPublic).SetValue(flow,value);
Action refresh=()=>typeof(PrototypeFlow).GetMethod("RefreshBattle",BindingFlags.Instance|BindingFlags.NonPublic).Invoke(flow,null);
try
{
set("_profile",new Func<long>(()=>-1));set("_boundProfile",-1L);set("_game",game);set("_config",config);set("_battle",battle);set("_arena",arena);set("_settled",true);set("_speed",1);set("_page",Activator.CreateInstance(typeof(PrototypeFlow).GetNestedType("Panel",BindingFlags.NonPublic),new object[]{ui}));
var heroes=Enumerable.Range(0,10).Select(i=>arena.transform.Find("Heroes/Hero"+i)).ToArray();var enemies=Enumerable.Range(0,9).Select(i=>arena.transform.Find("Enemies/Enemy"+i)).ToArray();var types=enemies.Select(e=>Enumerable.Range(0,5).Select(i=>e.Find("Type"+i)).ToArray()).ToArray();var t1=heroes.Select(h=>h.Find("Visual")).ToArray();var t2=heroes.Select(h=>h.Find("VisualT2")).ToArray();
set("_heroViews",heroes);set("_enemyViews",enemies);set("_enemyTypes",types);set("_heroT1Visuals",t1);set("_heroT2Visuals",t2);set("_heroAnimators",t1.Select(v=>v.GetComponentInChildren<Animator>(true)).ToArray());set("_heroT2Animators",t2.Select(v=>v.GetComponentInChildren<Animator>(true)).ToArray());set("_heroPulse",new float[10]);set("_heroSkillPulse",new float[10]);set("_enemyAnimators",types.Select(ts=>ts.Select(t=>t.GetComponentInChildren<Animator>(true)).ToArray()).ToArray());set("_enemyModels",new PrototypeBattle.Enemy[9]);set("_enemyStrikes",new int[9]);
foreach(var name in new[]{"_projectiles","_skillViews","_shieldViews"})set(name,Enumerable.Range(0,10).Select(i=>arena.transform.Find((name=="_projectiles"?"Projectiles/Shot":name=="_skillViews"?"SkillEffects/Skill":"SkillEffects/Shield")+i)).ToArray());
var fx=arena.GetComponentInChildren<BattleSkillPresentation>();set("_skillFeedback",fx);set("_enemyFeedback",arena.GetComponentInChildren<EnemyCombatPresentation>());
// Freeze the fixture before sampling the short opening cast. Wall-clock waits
// can miss that pose while the Editor warms shaders on its first frame.
set("_paused",true);host.SetActive(true);battle.Tick();battle.Tick();
typeof(PrototypeFlow).GetMethod("DrainBattleFeedback",BindingFlags.Instance|BindingFlags.NonPublic).Invoke(flow,new object[]{battle.Time});refresh();
var whale=battle.Heroes.Single(h=>h.Pet==9);var animator=t2[9].GetComponentInChildren<Animator>(true);var crest=heroes[9].Find("T2Crest");
Check(whale.Tier==2&&t2[9].gameObject.activeSelf&&!t1[9].gameObject.activeSelf&&!crest.gameObject.activeSelf,"T2 visual/crest toggle failed");
whale.Tier=1;refresh();Check(t1[9].gameObject.activeSelf&&!t2[9].gameObject.activeSelf&&!crest.gameObject.activeSelf,"T1 fallback failed");whale.Tier=2;refresh();
Check(fx.EffectPlays[14]>0,"Opening water shield did not play");
// Shield is an instant cast (release==start), so the authored protocol
// intentionally keeps Idle. Sample the first nonzero-windup whale attack.
var action=data["events"].First(e=>(string)e["kind"]=="cast_start"&&(int?)e["source"]==9&&(int)e["release"]>(int)e["tick"]);
int targetTick=(int)action["tick"]+1;
Check(targetTick<2400,"Attack sample exceeds protocol tick budget");
for(int i=battle.TraceTick;i<targetTick&&!battle.Finished;i++)
{
battle.Tick();
// Seeking is read-model only; stale presentation cues are not replayed.
if(battle.TraceTick<targetTick-2){while(battle.TryDequeueCue(out var ignored)){}while(battle.TryDequeueSkillCue(out var old)){};}
}
Check(battle.TraceTick==targetTick,"Trace ended before whale attack sample");
typeof(PrototypeFlow).GetMethod("DrainBattleFeedback",BindingFlags.Instance|BindingFlags.NonPublic).Invoke(flow,new object[]{battle.Time});refresh();
bool attack=animator.GetCurrentAnimatorStateInfo(0).IsName("Attack");
Check(attack,"Whale nonzero-windup attack missing at time="+battle.Time+", start="+whale.CastStart+", release="+whale.CastRelease);
set("_paused",true);float paused=battle.Time;await Task.Delay(200);Check(battle.Time==paused,"Pause failed");
set("_speed",2);set("_paused",false);await Task.Delay(350);Check(battle.Time>paused+.35f,"Double speed did not advance battle clock");
set("_paused",true);await Task.Delay(40);
var point=(Vector3)typeof(PrototypeFlow).GetMethod("CombatPoint",BindingFlags.Instance|BindingFlags.NonPublic).Invoke(flow,new object[]{9});Check(Vector3.Distance(point,heroes[9].Find("SkillMuzzle").position)<.001f,"Scaled skill anchor mismatch");
var camera=arena.GetComponentInChildren<Camera>();var rt=new RenderTexture(900,1200,24);var texture=new Texture2D(900,1200,TextureFormat.RGB24,false);var previous=RenderTexture.active;
try{camera.targetTexture=rt;camera.Render();RenderTexture.active=rt;texture.ReadPixels(new Rect(0,0,900,1200),0,0);texture.Apply();File.WriteAllBytes(Path.Combine(Folder,"battle.png"),texture.EncodeToPNG());}
finally{camera.targetTexture=null;RenderTexture.active=previous;rt.Release();Object.Destroy(rt);Object.Destroy(texture);}
Report("unity_battle",new{status="pass",authoritative=true,stage=20,trace="stage_20_t2_f1.json",formation=snapshot.formation,t1T2Toggle=true,placeholderHidden=true,attack,waterShield=fx.EffectPlays[14],pause=true,doubleSpeed=true,scaledMuzzle=true,networkAccountWrites=false});
return "PASS TideWhale T2 in authoritative stage 20: T1/T2 toggle, hidden placeholder, Attack/water shield, pause/2x, scaled muzzle; no account writes";
}
finally{Object.Destroy(host);Object.Destroy(ui);Object.Destroy(arena);}
}
}
+408
View File
@@ -0,0 +1,408 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AIGame.EditorTools;
using HotUpdate.UI;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEditor.U2D;
using UnityEngine;
using UnityEngine.U2D;
using UnityEngine.UI;
using YooAsset;
using YooAsset.Editor;
using Object = UnityEngine.Object;
/// <summary>通过 Pipeline 检查图集生成、自动增删移动、GUID、边界及实际 YooAsset 构建映射。</summary>
public static class VerifyUiAtlases
{
static string Output => Path.GetFullPath(Path.Combine(Application.dataPath, "../../Tools/output/ui_atlas"));
static int checks;
static void Check(bool value, string message) { if (!value) throw new Exception(message); checks++; }
static void Save(string name, object result)
{
Directory.CreateDirectory(Output);
File.WriteAllText(Path.Combine(Output, name + ".json"), JsonConvert.SerializeObject(result, Formatting.Indented));
}
public static string Editor()
{
checks = 0;
SetupUiBundleCollector.Setup();
var groups = UiSpriteAtlasGenerator.CollectTextures();
var guids = groups.Keys.ToDictionary(p => p, AssetDatabase.AssetPathToGUID);
string unchanged = UiSpriteAtlasGenerator.GenerateAll();
Check(unchanged.Contains("updated=0"), "Regeneration is not idempotent: " + unchanged);
Check(EditorSettings.spritePackerMode == SpritePackerMode.AlwaysOnAtlas, "SpriteAtlas V1 packing disabled");
Check(UiAtlasPaths.GetAtlasPath("A/B") != UiAtlasPaths.GetAtlasPath("A_B"), "Nested directory name collision");
string[] platforms = { "Standalone", "iPhone", "Android", "WebGL" };
TextureImporterFormat[] formats = { TextureImporterFormat.DXT5, TextureImporterFormat.ASTC_6x6,
TextureImporterFormat.ASTC_6x6, TextureImporterFormat.ASTC_8x8 };
var atlases = groups.Keys.Select(AssetDatabase.LoadAssetAtPath<SpriteAtlas>).ToArray();
SpriteAtlasUtility.PackAtlases(atlases, EditorUserBuildSettings.activeBuildTarget);
var sourceBorders = new Dictionary<string, Vector4>();
foreach (var group in groups)
{
var atlas = AssetDatabase.LoadAssetAtPath<SpriteAtlas>(group.Key);
Check(AssetDatabase.AssetPathToGUID(group.Key) == guids[group.Key], "Atlas GUID changed: " + group.Key);
Check(atlas.GetPackables().SequenceEqual(group.Value), "Packables differ: " + group.Key);
Check(!atlas.GetPackingSettings().enableRotation && !atlas.GetPackingSettings().enableTightPacking, "UI packing changed");
Check(!atlas.GetTextureSettings().generateMipMaps && atlas.IsIncludeInBuild(), "Atlas must participate in SBP packing without mipmaps");
for (int i = 0; i < platforms.Length; i++)
{
var settings = atlas.GetPlatformSettings(platforms[i]);
Check(settings.overridden && settings.format == formats[i] && settings.maxTextureSize == 2048
&& !settings.crunchedCompression, "Wrong platform: " + platforms[i] + " " + group.Key);
}
foreach (var texture in group.Value)
{
string path = AssetDatabase.GetAssetPath(texture);
var importer = (TextureImporter)AssetImporter.GetAtPath(path);
Check(importer.textureCompression == TextureImporterCompression.Uncompressed && !importer.crunchedCompression,
"UI source texture must remain uncompressed: " + path);
Check(importer.GetDefaultPlatformTextureSettings().format == TextureImporterFormat.RGBA32,
"UI source default format must be RGBA32: " + path);
foreach (string platform in platforms)
Check(!importer.GetPlatformTextureSettings(platform).overridden,
"UI source still has a platform override: " + platform + " " + path);
sourceBorders[path] = importer.spriteBorder;
foreach (var sprite in AssetDatabase.LoadAllAssetsAtPath(path).OfType<Sprite>())
{
var packed = atlas.GetSprite(sprite.name);
Check(packed != null, "Sprite absent from packed atlas: " + path);
Check(packed.border == sprite.border, "Packed sprite border changed: " + path);
Object.DestroyImmediate(packed);
}
if (UiSpriteNineSliceRules.IsStandardControl(path))
Check(importer.spriteBorder.sqrMagnitude > 0, "Nine-slice border absent: " + path);
}
}
var collect = BundleCollectorSettingData.Setting.BeginCollect("Art", false, false, true);
var inputs = collect.CollectAssets.ToDictionary(a => a.AssetInfo.AssetPath);
var map = new TaskGetBuildMap().CreateBuildMap(false, new ScriptableBuildParameters
{
PackageName = "Art", EnableSharePackRule = true, UseAssetDependencyDB = false
});
foreach (var group in groups)
{
Check(inputs[group.Key].CollectorType == ECollectorType.MainAssetCollector, "Atlas is not addressable");
var bundle = map.GetBundleInfo(inputs[group.Key].BundleName);
Check(bundle.GetAllPackAssetPaths().Contains(group.Key), "Atlas omitted from build map");
foreach (var texture in group.Value)
{
string path = AssetDatabase.GetAssetPath(texture);
Check(inputs[path].BundleName == inputs[group.Key].BundleName, "Sprite and atlas collected into different bundles: " + path);
Check(bundle.GetAllPackAssetPaths().Contains(path), "Sprite omitted from its atlas bundle: " + path);
Check(((TextureImporter)AssetImporter.GetAtPath(path)).spriteBorder == sourceBorders[path], "Original border modified");
}
}
Save("editor", new { passed = true, checks, unchanged, target = EditorUserBuildSettings.activeBuildTarget.ToString(),
atlases = groups.Select(g => new { path = g.Key, guid = guids[g.Key], textures = g.Value.Count }).ToArray() });
return "PASS: " + checks + " checks, " + unchanged;
}
public static string ApplyUncompressedSources()
{
Check(!EditorApplication.isPlayingOrWillChangePlaymode, "Apply source import settings in Edit Mode");
var paths = AssetDatabase.FindAssets("t:Texture2D", new[] { UiAtlasPaths.TextureRoot })
.Select(AssetDatabase.GUIDToAssetPath).OrderBy(p => p, StringComparer.Ordinal).ToArray();
var guids = paths.ToDictionary(p => p, AssetDatabase.AssetPathToGUID);
var borders = paths.ToDictionary(p => p, p => ((TextureImporter)AssetImporter.GetAtPath(p)).spriteBorder);
int updated = 0;
AssetDatabase.StartAssetEditing();
try
{
foreach (string path in paths)
{
var importer = (TextureImporter)AssetImporter.GetAtPath(path);
if (!TexturePlatformPostprocessor.ConfigureUiAtlasSource(importer)) continue;
importer.SaveAndReimport();
updated++;
}
}
finally { AssetDatabase.StopAssetEditing(); }
foreach (string path in paths)
{
var importer = (TextureImporter)AssetImporter.GetAtPath(path);
Check(AssetDatabase.AssetPathToGUID(path) == guids[path] && importer.spriteBorder == borders[path],
"Source GUID or border changed: " + path);
Check(!TexturePlatformPostprocessor.ConfigureUiAtlasSource(importer), "Source settings not idempotent: " + path);
Check(AssetDatabase.LoadAssetAtPath<Texture2D>(path).format == TextureFormat.RGBA32,
"Imported source texture is compressed: " + path);
}
Save("source_uncompressed", new { passed = true, textures = paths.Length, updated, format = "RGBA32",
compression = "Uncompressed", platformOverrides = false, guidsAndBordersPreserved = true });
return $"PASS: {paths.Length} uncompressed source textures, updated={updated}; GUIDs and borders preserved.";
}
static async Task Until(Func<bool> condition)
{
DateTime end = DateTime.UtcNow.AddSeconds(20);
while (!condition())
{
if (DateTime.UtcNow > end) throw new Exception("Automatic atlas update timed out");
await Task.Delay(100);
}
}
static bool HasPackables(string path, int count)
{
var atlas = AssetDatabase.LoadAssetAtPath<SpriteAtlas>(path);
return atlas != null && atlas.GetPackables().Length == count;
}
static void WriteTexture(string path)
{
var texture = new Texture2D(24, 24);
try
{
var pixels = Enumerable.Repeat(new Color32(100, 160, 220, 200), 24 * 24).ToArray();
texture.SetPixels32(pixels); texture.Apply();
File.WriteAllBytes(path, texture.EncodeToPNG());
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport);
}
finally { Object.DestroyImmediate(texture); }
}
public static async Task<string> AutomaticChanges()
{
checks = 0;
string folder = "__AtlasVerify_" + Guid.NewGuid().ToString("N");
string root = UiAtlasPaths.TextureRoot + "/" + folder;
string atlasPath = UiAtlasPaths.GetAtlasPath(folder);
string childAtlas = UiAtlasPaths.GetAtlasPath(folder + "/Child");
AssetDatabase.CreateFolder(UiAtlasPaths.TextureRoot, folder);
try
{
WriteTexture(root + "/a.png");
await Until(() => HasPackables(atlasPath, 1));
string guid = AssetDatabase.AssetPathToGUID(atlasPath);
var importer = (TextureImporter)AssetImporter.GetAtPath(root + "/a.png");
Check(importer.textureType == TextureImporterType.Sprite && !importer.mipmapEnabled, "New UI texture not configured");
Check(importer.textureCompression == TextureImporterCompression.Uncompressed
&& !importer.GetPlatformTextureSettings("WebGL").overridden, "New UI source is compressed");
WriteTexture(root + "/b.png");
await Until(() => HasPackables(atlasPath, 2));
Check(AssetDatabase.AssetPathToGUID(atlasPath) == guid, "Adding texture recreated atlas");
AssetDatabase.CreateFolder(root, "Child");
Check(string.IsNullOrEmpty(AssetDatabase.MoveAsset(root + "/b.png", root + "/Child/b.png")), "Move failed");
await Until(() => HasPackables(atlasPath, 1) && HasPackables(childAtlas, 1));
Check(AssetDatabase.AssetPathToGUID(atlasPath) == guid, "Moving texture recreated source atlas");
AssetDatabase.DeleteAsset(root + "/a.png");
await Until(() => AssetDatabase.LoadAssetAtPath<SpriteAtlas>(atlasPath) == null);
Check(HasPackables(childAtlas, 1), "Removing parent atlas affected child atlas");
}
finally
{
AssetDatabase.DeleteAsset(root);
UiSpriteAtlasGenerator.GenerateAll();
}
Check(AssetDatabase.LoadAssetAtPath<SpriteAtlas>(atlasPath) == null
&& AssetDatabase.LoadAssetAtPath<SpriteAtlas>(childAtlas) == null, "Temporary atlases not removed");
Save("automatic", new { passed = true, checks, added = true, moved = true, deleted = true, cleaned = true });
return "PASS: automatic add/move/delete and GUID retention; temporary resources deleted.";
}
public static string BuildIsolated()
{
SetupUiBundleCollector.Setup();
const string packageName = "UiAtlasVerification";
var settings = BundleCollectorSettingData.Setting;
Check(settings.Packages.All(p => p.PackageName != packageName), "Verification package already exists");
var production = settings.GetPackage("Art");
var ui = production.Groups.Single(g => g.GroupName == "UI");
var group = new BundleCollectorGroup { GroupName = "UI", ActiveRuleName = nameof(EnableGroup) };
// Clone only UI collectors; use one existing dialog to exercise Prefab Sprite late binding.
foreach (var source in ui.Collectors)
{
bool prefab = source.CollectPath == UiPrefabLoader.PrefabFolder;
string path = prefab ? UiPrefabLoader.PrefabFolder + "/UI_ConfirmDialog.prefab" : source.CollectPath;
group.Collectors.Add(new BundleCollector { CollectPath = path, CollectorGUID = AssetDatabase.AssetPathToGUID(path),
CollectorType = source.CollectorType, AddressRuleName = source.AddressRuleName,
PackRuleName = source.PackRuleName, FilterRuleName = source.FilterRuleName, AssetTags = source.AssetTags });
}
var package = new BundleCollectorPackage { PackageName = packageName, EnableAddressable = true,
AutoCollectShaders = true, Groups = new List<BundleCollectorGroup> { group } };
settings.Packages.Add(package);
try
{
var parameters = new ScriptableBuildParameters {
BuildOutputRoot = Path.Combine(Output, "Build"), BundledFileRoot = Path.Combine(Output, "Builtin"),
BuildPipeline = EBuildPipeline.ScriptableBuildPipeline.ToString(), BuildBundleType = (int)EBundleType.AssetBundle,
BuildTarget = EditorUserBuildSettings.activeBuildTarget, PackageName = packageName,
PackageVersion = "verify_" + DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"),
EnableSharePackRule = true, VerifyBuildingResult = true, FileNameStyle = EFileNameStyle.BundleName_HashName,
BundledCopyOption = EBundledCopyOption.OnlyCopyAll, CompressOption = ECompressOption.LZ4,
BuiltinShadersBundleName = DefaultBundlePackRule.CreateShadersPackRuleResult().GetBundleName(packageName, settings.UniqueBundleName)
};
var result = new ScriptableBuildPipeline().Run(parameters, true);
Check(result.Success, "Isolated UI bundle build failed: " + result.ErrorInfo);
Save("build", new { passed = true, packageName, packageRoot = parameters.GetBundledRootDirectory(),
packageVersion = parameters.PackageVersion, target = EditorUserBuildSettings.activeBuildTarget.ToString() });
return result.OutputPackageDirectory;
}
finally { settings.Packages.Remove(package); }
}
public static string PrepareRuntimeScene()
{
Check(!EditorApplication.isPlayingOrWillChangePlaymode, "Stop Play Mode before preparing verification");
var setup = EditorSceneManager.GetSceneManagerSetup();
for (int i = 0; i < UnityEngine.SceneManagement.SceneManager.sceneCount; i++)
Check(!UnityEngine.SceneManagement.SceneManager.GetSceneAt(i).isDirty, "Existing scene has unsaved changes");
string json = JsonConvert.SerializeObject(setup.Select(s => new { s.path, s.isLoaded, s.isActive }).ToArray());
SessionState.SetString("AIGame.UiAtlasVerify.SceneSetup", json);
Directory.CreateDirectory(Output);
File.WriteAllText(Path.Combine(Output, "scene_setup.json"), json);
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
return "Empty test scene ready; original scene setup retained in SessionState.";
}
public static string RestoreRuntimeScene()
{
Check(!EditorApplication.isPlayingOrWillChangePlaymode, "Stop Play Mode before restoring scenes");
string json = SessionState.GetString("AIGame.UiAtlasVerify.SceneSetup", "");
string backupPath = Path.Combine(Output, "scene_setup.json");
if (string.IsNullOrEmpty(json) && File.Exists(backupPath)) json = File.ReadAllText(backupPath);
Check(!string.IsNullOrEmpty(json), "No original scene setup retained");
var restored = JArray.Parse(json).Select(s => new SceneSetup {
path = s.Value<string>("path"), isLoaded = s.Value<bool>("isLoaded"), isActive = s.Value<bool>("isActive")
}).ToArray();
if (restored.Length == 0) EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
else EditorSceneManager.RestoreSceneManagerSetup(restored);
SessionState.EraseString("AIGame.UiAtlasVerify.SceneSetup");
if (File.Exists(backupPath)) File.Delete(backupPath);
return "Original scene setup restored.";
}
static async Task Coroutine(MonoBehaviour host, IEnumerator routine)
{
bool completed = false;
host.StartCoroutine(Finish(routine, () => completed = true));
await Until(() => completed);
}
static IEnumerator Finish(IEnumerator routine, Action done)
{
yield return routine;
done();
}
static void CaptureDialog(GameObject dialog)
{
var cameraObject = new GameObject("UiAtlasCaptureCamera", typeof(Camera));
var camera = cameraObject.GetComponent<Camera>();
var target = new RenderTexture(540, 960, 24);
var pixels = new Texture2D(540, 960, TextureFormat.RGB24, false);
var previous = RenderTexture.active;
try
{
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = new Color(0.08f, 0.12f, 0.2f);
camera.orthographic = true;
camera.cullingMask = 1 << LayerMask.NameToLayer("UI");
camera.transform.position = new Vector3(0, 0, -50);
camera.targetTexture = target;
foreach (var canvas in dialog.GetComponentsInChildren<Canvas>(true))
{
canvas.renderMode = RenderMode.ScreenSpaceCamera;
canvas.worldCamera = camera;
canvas.planeDistance = 10;
}
dialog.SetActive(true);
Canvas.ForceUpdateCanvases();
camera.Render();
RenderTexture.active = target;
pixels.ReadPixels(new Rect(0, 0, 540, 960), 0, 0);
pixels.Apply();
File.WriteAllBytes(Path.Combine(Output, "dialog.png"), pixels.EncodeToPNG());
}
finally
{
RenderTexture.active = previous;
camera.targetTexture = null;
Object.Destroy(cameraObject); Object.Destroy(target); Object.Destroy(pixels);
}
}
public static async Task<string> Runtime()
{
Check(Application.isPlaying, "Run in the isolated Play Mode scene");
Check(!YooAssets.IsInitialized, "Refusing to interfere with existing resource packages");
checks = 0;
var build = JObject.Parse(File.ReadAllText(Path.Combine(Output, "build.json")));
string packageRoot = build.Value<string>("packageRoot");
YooAssets.Initialize();
var package = YooAssets.CreatePackage("UiAtlasVerification");
var root = new GameObject("UiAtlasVerification");
var loader = root.AddComponent<UiAtlasLoader>();
loader.Init(package, false); // 关掉 AssetDatabase fallback,必须从真实资源包异步加载。
AssetHandle prefabHandle = null;
GameObject instance = null;
try
{
#if UNITY_WEBGL
var webFileSystem = new FileSystemParameters("YooAsset.WebServerFileSystem", new Uri(packageRoot + "/").AbsoluteUri);
webFileSystem.AddParameter(EFileSystemParameter.DisableUnityWebCache, true);
var init = package.InitializePackageAsync(new WebPlayModeOptions {
WebServerFileSystemParameters = webFileSystem
});
#else
var init = package.InitializePackageAsync(new OfflinePlayModeOptions {
BuiltinFileSystemParameters = FileSystemParameters.CreateDefaultBuiltinFileSystemParameters(packageRoot)
});
#endif
await Until(() => init.IsDone);
Check(init.Status == EOperationStatus.Succeeded, "Initialize failed: " + init.Error);
var manifest = package.LoadPackageManifestAsync(new LoadPackageManifestOptions(build.Value<string>("packageVersion"), 60));
await Until(() => manifest.IsDone);
Check(manifest.Status == EOperationStatus.Succeeded, "Manifest failed: " + manifest.Error);
prefabHandle = package.LoadAssetAsync<GameObject>("UI_ConfirmDialog");
await Until(() => prefabHandle.IsDone);
Check(prefabHandle.Status == EOperationStatus.Succeeded, "Dialog failed: " + prefabHandle.Error);
instance = Object.Instantiate(prefabHandle.GetAssetObject<GameObject>());
await Coroutine(loader, loader.WaitForPending());
var images = instance.GetComponentsInChildren<Image>(true).Where(i => i.sprite != null).ToArray();
Check(images.Length > 0, "Dialog has no sprite references");
foreach (var image in images)
Check(image.sprite.packed && image.sprite.texture != null, "Prefab sprite has no packed texture: " + image.name);
CaptureDialog(instance);
Sprite a = null, b = null;
bool firstDone = false, secondDone = false;
loader.StartCoroutine(Finish(loader.LoadSpriteAsync("Common", "btn_primary_normal", s => a = s), () => firstDone = true));
loader.StartCoroutine(Finish(loader.LoadSpriteAsync("Common", "btn_primary_normal", s => b = s), () => secondDone = true));
await Until(() => firstDone && secondDone);
Check(a != null && a.texture != null && ReferenceEquals(a, b), "Concurrent sprite requests did not share a valid packed texture");
var imageObject = new GameObject("DynamicImage", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
imageObject.transform.SetParent(root.transform);
var target = imageObject.GetComponent<Image>();
bool oldDone = false, latestDone = false;
Sprite latest = null;
loader.StartCoroutine(Finish(loader.SetSpriteAsync(target, "Home", "home_ico_coin"), () => oldDone = true));
loader.StartCoroutine(Finish(loader.SetSpriteAsync(target, "Home", "home_ico_diamond", s => latest = s), () => latestDone = true));
await Until(() => oldDone && latestDone);
Check(target.sprite != null && target.sprite.packed && target.sprite.texture != null && target.sprite == latest,
"Latest dynamic image request has no valid packed texture");
var homeTexture = new { target.sprite.texture.width, target.sprite.texture.height,
format = target.sprite.texture.format.ToString() };
Sprite cachedClone = a;
Object.Destroy(instance); instance = null;
Object.Destroy(root);
await Until(() => root == null && cachedClone == null);
Check(cachedClone == null, "Cached sprite clones not released on destruction");
Save("runtime", new { passed = true, checks, realBundles = true, editorFallback = false,
prefabLateBinding = true, concurrentCache = true, dynamicSprite = true, disposed = true, homeTexture });
return "PASS: " + checks + " checks; real YooAsset bundles, automatic Prefab binding, async sprites and cleanup.";
}
finally
{
if (instance != null) Object.Destroy(instance);
if (root != null) Object.Destroy(root);
await Task.Delay(100);
prefabHandle?.Release();
YooAssets.Destroy();
}
}
}
@@ -0,0 +1,72 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using AOT.Editor.WeChat;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
using WeChatWASM;
public static class VerifyWeChatExport
{
public static string RefreshAndVerify()
{
string root = Path.GetFullPath(Path.Combine(Application.dataPath, "../Build/WeChat/Base"));
string output = Path.GetFullPath(Path.Combine(Application.dataPath, "../../Tools/output/wechat_export"));
Directory.CreateDirectory(output);
foreach (string name in new[] { "project.config.json", "minigame/project.config.json", "minigame/game.js", "base-package-report.txt" })
{
string source = Path.Combine(root, name);
string backup = Path.Combine(output, "before_" + name.Replace('/', '_'));
if (File.Exists(source) && !File.Exists(backup)) File.Copy(source, backup);
}
var resourceState = Snapshot(Path.Combine(root, "webgl"));
string[] privatePaths = { Path.Combine(root, "project.private.config.json"), Path.Combine(root, "minigame/project.private.config.json") };
string[] privateBefore = privatePaths.Select(p => File.Exists(p) ? File.ReadAllText(p) : null).ToArray();
JObject rootBefore = JObject.Parse(File.ReadAllText(Path.Combine(root, "project.config.json")));
JObject miniBefore = JObject.Parse(File.ReadAllText(Path.Combine(root, "minigame/project.config.json")));
string expectedAppid = (string)miniBefore["appid"];
if (string.IsNullOrWhiteSpace(expectedAppid)) expectedAppid = (string)rootBefore["appid"];
string result = ExportWeChatBasePackage.RefreshExportedConfig();
Require(result.StartsWith("ok", StringComparison.Ordinal), result);
JObject config = JObject.Parse(File.ReadAllText(Path.Combine(root, "project.config.json")));
Require((string)config["miniprogramRoot"] == "minigame/" && (string)config["compileType"] == "game", "Invalid project root");
Require(File.Exists(Path.Combine(root, (string)config["miniprogramRoot"], "game.json")), "game.json cannot be resolved");
Require((string)config["appid"] == expectedAppid, "AppID was changed");
Require(config.SelectToken("setting.babelSetting.ignore").Values<string>().Contains("webgl.wasm.framework.unityweb.js"), "SDK Babel exclusion missing");
Require(config.SelectToken("packOptions.ignore").Any(t => (string)t["value"] == ".symbols.unityweb"), "Symbol exclusion missing");
string gamePath = Path.Combine(root, "minigame/game.js");
string game = File.ReadAllText(gamePath);
string cdn = WXConvertCore.config.ProjectConf.CDN;
Require(game.Contains("DATA_CDN: " + JsonConvert.SerializeObject(cdn)), "SDK/export CDN mismatch");
Match md5 = Regex.Match(game, @"DATA_FILE_MD5:\s*'([0-9a-f]+)'");
string dataName = md5.Groups[1].Value + ".webgl.data.unityweb.bin.txt";
string dataPath = Path.Combine(root, "webgl", dataName);
Require(md5.Success && File.Exists(dataPath), "Missing startup data file");
for (int i = 0; i < privatePaths.Length; ++i)
Require((File.Exists(privatePaths[i]) ? File.ReadAllText(privatePaths[i]) : null) == privateBefore[i], "Private config changed");
Require(resourceState.SequenceEqual(Snapshot(Path.Combine(root, "webgl"))), "WebGL resources changed");
string configText = config.ToString();
Require(ExportWeChatBasePackage.RefreshExportedConfig().StartsWith("ok"), "Second refresh failed");
Require(JObject.Parse(File.ReadAllText(Path.Combine(root, "project.config.json"))).ToString() == configText &&
File.ReadAllText(gamePath) == game, "Config refresh was not idempotent");
string report = JsonConvert.SerializeObject(new { passed = true, noBuild = true, cdn,
startupData = dataPath, startupUrl = cdn + dataName, bytes = new FileInfo(dataPath).Length,
appidPreserved = true, privateConfigsPreserved = true, resourceFilesPreserved = true,
devtoolsSourceResolved = true, sdkExclusionsPreserved = true, idempotent = true }, Formatting.Indented);
File.WriteAllText(Path.Combine(output, "configuration.json"), report);
return report;
}
static string[] Snapshot(string path) => Directory.GetFiles(path, "*", SearchOption.AllDirectories)
.OrderBy(p => p, StringComparer.Ordinal).Select(p => {
var file = new FileInfo(p);
return p + ":" + file.Length + ":" + file.LastWriteTimeUtc.Ticks;
}).ToArray();
static void Require(bool condition, string message)
{
if (!condition) throw new Exception(message);
}
}
@@ -0,0 +1,121 @@
using System;
using System.Collections;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using AOT.Editor.WeChat;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
public static class VerifyYooAssetStripping
{
static readonly string[] TypeNames = {
"YooAsset.WebNetworkFileSystem", "YooAsset.BuiltinFileSystem", "YooAsset.SandboxFileSystem"
};
static string Project => Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
static string Output => Path.GetFullPath(Path.Combine(Project, "../Tools/output/yooasset_stripping"));
public static string Before()
{
return Save("before", new {
full = Inspect(Path.Combine(Project, "Library/ScriptAssemblies/YooAsset.dll")),
stripped = Inspect(Path.Combine(Project, "Library/Bee/artifacts/WebGL/ManagedStripped/YooAsset.dll"))
});
}
public static string BuildAndVerify()
{
if (EditorUserBuildSettings.activeBuildTarget != BuildTarget.WebGL)
throw new Exception("Select WebGL before running this verification");
string gamePath = Path.Combine(Project, "Build/WeChat/Base/minigame/game.js");
string oldDataPath = StartupDataPath(gamePath);
string oldDataHash = Hash(oldDataPath);
string[] business = SnapshotBusinessBundles();
DateTime started = DateTime.UtcNow;
string result = ExportWeChatBasePackage.Export();
if (!result.StartsWith("ok minigame=", StringComparison.Ordinal)) throw new Exception(result);
string strippedPath = Path.Combine(Project, "Library/Bee/artifacts/WebGL/ManagedStripped/YooAsset.dll");
var stripped = Inspect(strippedPath);
if (File.GetLastWriteTimeUtc(strippedPath) < started)
throw new Exception("Stripped YooAsset DLL is stale");
if (stripped.Any(t => !t.defaultConstructor || !t.constructorBody))
throw new Exception("A reflected file system constructor was stripped");
string cpp = Path.Combine(Project, "Library/Bee/artifacts/WebGL/il2cppOutput/cpp");
bool nativeConstructor = Directory.GetFiles(cpp, "YooAsset*.cpp")
.Any(p => File.ReadAllText(p).Contains("WebNetworkFileSystem__ctor"));
if (!nativeConstructor) throw new Exception("Native WebNetworkFileSystem constructor was not emitted");
if (!business.SequenceEqual(SnapshotBusinessBundles()))
throw new Exception("Business bundles changed unexpectedly");
string newDataPath = StartupDataPath(gamePath);
return Save("after", new {
passed = true, export = result, startedUtc = started, completedUtc = DateTime.UtcNow,
stripped, nativeConstructor,
oldStartupData = oldDataPath, startupData = newDataPath,
oldStartupHash = oldDataHash, startupHash = Hash(newDataPath),
startupDataUnchanged = oldDataHash == Hash(newDataPath),
businessBundlesUnchanged = true,
wechatRuntimeRetested = false
});
}
public sealed class TypeCheck
{
public string type;
public bool typePresent;
public bool defaultConstructor;
public bool constructorBody;
}
static TypeCheck[] Inspect(string path)
{
// Burst embeds a second Cecil copy; select Unity's loaded Mono.Cecil explicitly.
var cecil = AppDomain.CurrentDomain.GetAssemblies().First(a => a.GetName().Name == "Mono.Cecil");
var definition = cecil.GetType("Mono.Cecil.AssemblyDefinition");
object assembly = definition.GetMethod("ReadAssembly", new[] { typeof(string) }).Invoke(null, new object[] { path });
using ((IDisposable)assembly)
{
object module = Property(assembly, "MainModule");
return TypeNames.Select(name => {
object type = module.GetType().GetMethod("GetType", new[] { typeof(string) }).Invoke(module, new object[] { name });
var check = new TypeCheck { type = name, typePresent = type != null };
if (type != null)
foreach (object method in (IEnumerable)Property(type, "Methods"))
if ((string)Property(method, "Name") == ".ctor" && !(bool)Property(method, "HasParameters"))
{
check.defaultConstructor = true;
check.constructorBody = (bool)Property(method, "HasBody");
}
return check;
}).ToArray();
}
}
static object Property(object instance, string name) => instance.GetType().GetProperty(name).GetValue(instance);
static string StartupDataPath(string gamePath)
{
var match = Regex.Match(File.ReadAllText(gamePath), @"DATA_FILE_MD5:\s*'([0-9a-f]+)'");
if (!match.Success) throw new Exception("Cannot read DATA_FILE_MD5");
return Path.Combine(Project, "Build/WeChat/Base/webgl", match.Groups[1].Value + ".webgl.data.unityweb.bin.txt");
}
static string Hash(string path)
{
using (var sha = SHA256.Create())
using (var stream = File.OpenRead(path))
return BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant();
}
static string[] SnapshotBusinessBundles() => Directory.GetFiles(Path.Combine(Application.dataPath, "StreamingAssets/yoo"), "*.bundle", SearchOption.AllDirectories)
.OrderBy(p => p, StringComparer.Ordinal).Select(p => p + ":" + Hash(p)).ToArray();
static string Save(string name, object report)
{
Directory.CreateDirectory(Output);
string json = JsonConvert.SerializeObject(report, Formatting.Indented);
File.WriteAllText(Path.Combine(Output, name + ".json"), json);
return json;
}
}