Add prototype gameplay loop with character/battlefield assets and server game APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-10-20 18:50:00 +08:00
co-authored by Cursor
parent 79369e4473
commit 6c64210331
833 changed files with 998079 additions and 1042 deletions
+111
View File
@@ -0,0 +1,111 @@
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
/// <summary>
/// Nests the authored A-scheme battlefield under the live battle prefab.
/// Gameplay actors stay on PrototypeArena; the primitive ground is turned off.
/// </summary>
public static class AttachGreenfield
{
const string Arena = "Assets/Bundles/Scenes/Prototype/PrototypeArena.prefab";
const string Battlefield = "Assets/Bundles/Scenes/Prototype/Battlefield/Battlefield_Greenfield.prefab";
public static string Build()
{
if (EditorApplication.isPlaying) throw new System.Exception("Attach the battlefield in Edit Mode.");
var battlefield = AssetDatabase.LoadAssetAtPath<GameObject>(Battlefield);
if (battlefield == null) throw new System.Exception("Missing " + Battlefield);
var root = PrefabUtility.LoadPrefabContents(Arena);
try
{
var previous = root.transform.Find("Greenfield");
if (previous != null) Object.DestroyImmediate(previous.gameObject);
var instance = (GameObject)PrefabUtility.InstantiatePrefab(battlefield, root.transform);
instance.name = "Greenfield";
instance.transform.SetSiblingIndex(0);
foreach (var transform in instance.GetComponentsInChildren<Transform>(true)) transform.gameObject.layer = 30;
foreach (Transform child in root.transform)
{
if (child == instance.transform) continue;
if (child.name == "Ground" || child.name == "Lane" || child.name == "LeftBorder" || child.name == "RightBorder" || child.name == "BattleLight")
child.gameObject.SetActive(false);
}
PrefabUtility.SaveAsPrefabAsset(root, Arena);
}
finally { PrefabUtility.UnloadPrefabContents(root); }
AssetDatabase.SaveAssets();
return Verify();
}
public static string Verify()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(Arena);
if (prefab == null) throw new System.Exception("Missing arena prefab.");
var scene = EditorSceneManager.NewPreviewScene();
var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab, scene);
try
{
var report = new StringBuilder();
var greenfield = instance.transform.Find("Greenfield");
if (greenfield == null || !greenfield.gameObject.activeSelf) throw new System.Exception("Greenfield is missing.");
Require(greenfield, "Ground/GroundMesh");
Require(greenfield, "Routes/Lane0");
Require(greenfield, "Routes/Lane1");
Require(greenfield, "Routes/Lane2");
Require(greenfield, "Lighting/Sun");
if (greenfield.Find("Decorations/bridge") != null || greenfield.Find("Decorations/bridgeLeft") != null || greenfield.Find("Decorations/bridgeRight") != null)
throw new System.Exception("Bridge is still placed in the battle arena.");
var badLayer = greenfield.GetComponentsInChildren<Renderer>(true).FirstOrDefault(r => r.gameObject.layer != 30);
if (badLayer != null) throw new System.Exception("Greenfield renderer not on layer 30: " + badLayer.name);
foreach (string name in new[] { "Heroes", "Enemies", "Projectiles", "DeploymentMarkers", "SkillEffects", "BattleCamera", "DefenceLine" })
{
var node = instance.transform.Find(name);
if (node == null || !node.gameObject.activeSelf) throw new System.Exception(name + " must stay active.");
}
foreach (Transform child in instance.transform)
{
if (child.name == "Ground" || child.name == "Lane" || child.name == "LeftBorder" || child.name == "RightBorder" || child.name == "BattleLight")
{
if (child.gameObject.activeSelf) throw new System.Exception(child.name + " primitive is still active.");
}
}
var mesh = greenfield.Find("Ground/GroundMesh").GetComponentInChildren<MeshRenderer>();
if (mesh == null || mesh.sharedMaterial == null || mesh.sharedMaterial.shader.name != "Test/XPbr")
throw new System.Exception("Greenfield ground shader is not Test/XPbr.");
report.Append("PASS nested Greenfield; primitives hidden; gameplay nodes kept.");
return report.ToString();
}
finally
{
Object.DestroyImmediate(instance);
EditorSceneManager.ClosePreviewScene(scene);
}
}
static void Require(Transform root, string path)
{
var node = root.Find(path);
if (node == null) throw new System.Exception("Missing " + path);
}
public static string FramePrefabStage()
{
var stage = PrefabStageUtility.OpenPrefab(Arena);
var view = SceneView.lastActiveSceneView;
if (view == null) throw new System.Exception("No Scene View.");
view.LookAt(new Vector3(0, 0, 3.5f), Quaternion.Euler(38, -35, 0), 22f);
view.orthographic = false;
view.Repaint();
return stage.assetPath;
}
public static string ClosePrefabStage()
{
StageUtility.GoToMainStage();
return "Closed prefab stage";
}
}
+70
View File
@@ -0,0 +1,70 @@
#if UNITY_EDITOR
using System;
using System.IO;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using Object = UnityEngine.Object;
public static class AuditAnimalWalk
{
const string Root = "Assets/Bundles/Characters/Animal/RockBall";
static GameObject Spawn(string path) => Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>(path));
public static string Capture()
{
string dir = Path.GetFullPath(Path.Combine(Application.dataPath, "../../Tools/output/rockball_walk_audit"));
Directory.CreateDirectory(dir);
var scene = EditorSceneManager.NewPreviewScene();
try
{
var unit = Spawn(Root + "/RockBall_Rigged.prefab");
SceneManager.MoveGameObjectToScene(unit, scene);
var anim = unit.GetComponentInChildren<Animator>();
anim.enabled = false;
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(Root + "/Clips/Walk.anim");
var light = new GameObject("AuditLight").AddComponent<Light>();
SceneManager.MoveGameObjectToScene(light.gameObject, scene);
light.type = LightType.Directional;
light.intensity = 1.5f;
light.transform.rotation = Quaternion.Euler(45, -30, 0);
var cam = new GameObject("AuditCamera").AddComponent<Camera>();
SceneManager.MoveGameObjectToScene(cam.gameObject, scene);
cam.scene = scene;
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = new Color(.45f,.53f,.59f);
cam.transform.position = new Vector3(1.1f,.75f,-1.6f);
cam.transform.LookAt(new Vector3(0,.28f,0));
cam.orthographic = true;
cam.orthographicSize = .43f;
cam.nearClipPlane = .01f;
var rt = new RenderTexture(512,512,24);
cam.targetTexture = rt;
var tex = new Texture2D(512,512,TextureFormat.RGB24,false);
var previous = RenderTexture.active;
try
{
for (int i = -1; i < 4; i++)
{
if (i >= 0) clip.SampleAnimation(anim.gameObject, clip.length * i / 4f);
cam.Render();
RenderTexture.active = rt;
tex.ReadPixels(new Rect(0,0,512,512),0,0);
tex.Apply();
File.WriteAllBytes(Path.Combine(dir, i < 0 ? "bind.png" : "walk_" + i + ".png"), tex.EncodeToPNG());
}
}
finally
{
RenderTexture.active = previous;
cam.targetTexture = null;
rt.Release();
Object.DestroyImmediate(rt);
Object.DestroyImmediate(tex);
}
}
finally { EditorSceneManager.ClosePreviewScene(scene); }
return dir;
}
}
#endif
@@ -0,0 +1,64 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using Object=UnityEngine.Object;
/// <summary>Isolated static or sampled-pose views; preserves all open scene contents.</summary>
public static class AuditCharacterVisual
{
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);
var scene=EditorSceneManager.NewPreviewScene();
try
{
var prefab=AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
if(prefab==null)throw new Exception("Missing visual prefab: "+prefabPath);
var unit=(GameObject)PrefabUtility.InstantiatePrefab(prefab,scene);
var animator=unit.GetComponentInChildren<Animator>();if(animator!=null)animator.enabled=false;
if(animator==null)
{
var filters=unit.GetComponentsInChildren<MeshFilter>();
var points=filters.SelectMany(m=>m.sharedMesh.vertices.Select(v=>m.transform.TransformPoint(v))).ToArray();
if(points.Length==0)throw new Exception("No static geometry");
float minY=points.Min(v=>v.y),actualHeight=points.Max(v=>v.y)-minY;
int triangles=filters.Sum(m=>m.sharedMesh.triangles.Length/3);
if(Mathf.Abs(minY)>.002f || Mathf.Abs(actualHeight-height)>.002f)throw new Exception("Static height/bottom invalid");
if(unit.GetComponentsInChildren<Renderer>().Any(r=>r.sharedMaterial==null || r.sharedMaterial.shader.name!="Test/XPbr"))throw new Exception("Static XPbr material missing");
File.WriteAllText(Path.Combine(dir,"static_verification.txt"),$"height={actualHeight:F6} bottom={minY:F6} triangles={triangles} XPbr=True; facing verified in front/side/back views");
}
if(!string.IsNullOrEmpty(clipPath))
{
var clip=AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
if(clip==null || animator==null)throw new Exception("Missing clip/animator");
clip.SampleAnimation(animator.gameObject,clip.length*phase);
}
var light=new GameObject("AuditLight").AddComponent<Light>();SceneManager.MoveGameObjectToScene(light.gameObject,scene);
light.type=LightType.Directional;light.intensity=1.4f;light.transform.rotation=Quaternion.Euler(40,-30,0);
var camera=new GameObject("AuditCamera").AddComponent<Camera>();SceneManager.MoveGameObjectToScene(camera.gameObject,scene);camera.scene=scene;
camera.clearFlags=CameraClearFlags.SolidColor;camera.backgroundColor=new Color(.56f,.64f,.7f);
camera.orthographic=true;camera.orthographicSize=height*.68f;camera.nearClipPlane=.01f;camera.farClipPlane=10;
var rt=new RenderTexture(640,640,24);var tex=new Texture2D(640,640,TextureFormat.RGB24,false);var previous=RenderTexture.active;
try
{
var directions=new[]{new Vector3(0,.22f,-1),new Vector3(1,.22f,0),new Vector3(0,.22f,1),new Vector3(.7f,.3f,-1)};
string[] names={"front","side","back","perspective"};
for(int i=0;i<directions.Length;i++)
{
var center=Vector3.up*height*.5f;camera.transform.position=center+directions[i]*height*3;
camera.transform.LookAt(center);camera.targetTexture=rt;camera.Render();RenderTexture.active=rt;
tex.ReadPixels(new Rect(0,0,640,640),0,0);tex.Apply();File.WriteAllBytes(Path.Combine(dir,names[i]+".png"),tex.EncodeToPNG());
}
}
finally {RenderTexture.active=previous;camera.targetTexture=null;rt.Release();Object.DestroyImmediate(rt);Object.DestroyImmediate(tex);}
}
finally {EditorSceneManager.ClosePreviewScene(scene);}
return dir;
}
}
#endif
@@ -0,0 +1,166 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEngine;
/// <summary>
/// Imports the authored battlefield component FBX/XPbr files and creates one
/// reusable prefab per component. Ground is built separately so its collider
/// and read/write mesh settings cannot be confused with decoration assets.
/// </summary>
public static class BuildBattlefieldAssets
{
const string Root = "Assets/Bundles/Scenes/Prototype/Battlefield/A/Components";
const string GroundRoot = "Assets/Bundles/Scenes/Prototype/Battlefield/A/Ground";
const string GroundPrefab = "Assets/Bundles/Scenes/Prototype/Battlefield/GreenfieldGround.prefab";
const string XpbrShader = "Test/XPbr";
static readonly string[] Parts = { "tree", "rock", "bridge", "sign", "shrub" };
public static string BuildComponents()
{
AssetDatabase.Refresh();
var reports = new List<string>();
foreach (var type in Parts) reports.Add(BuildPart(type));
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
return string.Join(" | ", reports);
}
/// <summary>
/// Builds the independently authored ground asset once Hunyuan has produced
/// Ground/ground.fbx and its XPbr textures. The ground keeps a readable mesh
/// so the authored prefab can carry a non-convex MeshCollider.
/// </summary>
public static string BuildGround()
{
AssetDatabase.Refresh();
string fbxPath = GroundRoot + "/ground.fbx";
if (!File.Exists(fbxPath)) return "ground: missing fbx";
string basePath = GroundRoot + "/ground_base.png";
string mixPath = GroundRoot + "/ground_mix.png";
ConfigureTexture(basePath, true, true);
ConfigureTexture(mixPath, false, false);
var importer = AssetImporter.GetAtPath(fbxPath) as ModelImporter;
if (importer != null)
{
importer.globalScale = 1f;
importer.useFileScale = false;
importer.bakeAxisConversion = true;
importer.materialImportMode = ModelImporterMaterialImportMode.ImportViaMaterialDescription;
importer.meshCompression = ModelImporterMeshCompression.Medium;
importer.isReadable = true;
importer.addCollider = false;
importer.SaveAndReimport();
}
var mat = GetOrCreateMaterial(GroundRoot + "/ground.mat", "ground", basePath, mixPath);
var model = AssetDatabase.LoadAssetAtPath<GameObject>(fbxPath);
if (model == null) return "ground: fbx not loaded";
var root = new GameObject("GreenfieldGround");
try
{
var instance = (GameObject)PrefabUtility.InstantiatePrefab(model);
instance.name = "GroundMesh";
instance.transform.SetParent(root.transform, false);
PrefabUtility.UnpackPrefabInstance(instance, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction);
foreach (var renderer in instance.GetComponentsInChildren<Renderer>(true)) renderer.sharedMaterial = mat;
foreach (var filter in instance.GetComponentsInChildren<MeshFilter>(true))
{
if (filter.sharedMesh == null) continue;
var collider = filter.gameObject.GetComponent<MeshCollider>();
if (collider == null) collider = filter.gameObject.AddComponent<MeshCollider>();
collider.convex = false;
collider.sharedMesh = filter.sharedMesh;
}
PrefabUtility.SaveAsPrefabAsset(root, GroundPrefab);
}
finally { UnityEngine.Object.DestroyImmediate(root); }
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
return "ground: " + GroundPrefab;
}
public static string BuildAll()
{
var components = BuildComponents();
var ground = BuildGround();
return components + " | " + ground;
}
static string BuildPart(string type)
{
string dir = Root + "/" + type;
string fbxPath = dir + "/" + type + ".fbx";
string matPath = dir + "/" + type + ".mat";
string basePath = dir + "/" + type + "_base.png";
string mixPath = dir + "/" + type + "_mix.png";
string prefabPath = dir + "/" + type + ".prefab";
if (!File.Exists(fbxPath)) return type + ": missing fbx";
ConfigureTexture(basePath, true, true);
ConfigureTexture(mixPath, false, false);
var importer = AssetImporter.GetAtPath(fbxPath) as ModelImporter;
if (importer != null)
{
importer.globalScale = 1f;
importer.useFileScale = false;
importer.bakeAxisConversion = true;
importer.materialImportMode = ModelImporterMaterialImportMode.ImportViaMaterialDescription;
importer.meshCompression = ModelImporterMeshCompression.Medium;
importer.isReadable = false;
importer.addCollider = false;
importer.SaveAndReimport();
}
var mat = GetOrCreateMaterial(matPath, type, basePath, mixPath);
var model = AssetDatabase.LoadAssetAtPath<GameObject>(fbxPath);
if (model == null) return type + ": fbx not loaded";
var instance = (GameObject)PrefabUtility.InstantiatePrefab(model);
instance.name = type;
foreach (var renderer in instance.GetComponentsInChildren<Renderer>()) renderer.sharedMaterial = mat;
instance.transform.localPosition = Vector3.zero;
instance.transform.localRotation = Quaternion.identity;
instance.transform.localScale = Vector3.one;
PrefabUtility.SaveAsPrefabAsset(instance, prefabPath);
UnityEngine.Object.DestroyImmediate(instance);
return type + ": " + prefabPath;
}
static Material GetOrCreateMaterial(string matPath, string name, string basePath, string mixPath)
{
var baseMap = AssetDatabase.LoadAssetAtPath<Texture2D>(basePath);
var mixMap = AssetDatabase.LoadAssetAtPath<Texture2D>(mixPath);
var shader = Shader.Find(XpbrShader) ?? Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
var mat = AssetDatabase.LoadAssetAtPath<Material>(matPath);
if (mat == null)
{
mat = new Material(shader) { name = name };
AssetDatabase.CreateAsset(mat, matPath);
}
else mat.shader = shader;
if (mat.HasProperty("_MainTex")) mat.SetTexture("_MainTex", baseMap);
if (mat.HasProperty("_MixTex")) mat.SetTexture("_MixTex", mixMap);
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);
EditorUtility.SetDirty(mat);
return mat;
}
static void ConfigureTexture(string path, bool srgb, bool alpha)
{
if (!File.Exists(path)) return;
var importer = AssetImporter.GetAtPath(path) as TextureImporter;
if (importer == null) return;
importer.textureType = TextureImporterType.Default;
importer.sRGBTexture = srgb;
importer.mipmapEnabled = true;
importer.alphaIsTransparency = alpha;
importer.SaveAndReimport();
}
}
#endif
@@ -0,0 +1,354 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
[Serializable] public sealed class BattlefieldVec3 { public float x, y, z; public Vector3 Value => new Vector3(x, y, z); }
[Serializable] public sealed class BattlefieldGroundSpec { public string type, gamePrefab, requirements; public int targetTriangles; }
[Serializable] public sealed class BattlefieldPlacement { public string name; public BattlefieldVec3 position, rotation, scale; }
[Serializable] public sealed class BattlefieldComponentSpec { public string type, prefab, label; public int targetTriangles; public bool placeInScene; public BattlefieldVec3 position, rotation, scale; public BattlefieldPlacement[] placements; }
[Serializable] public sealed class BattlefieldAssemblySpec { public string outputPrefab, componentFolder, markerFolder, materialsFolder; }
[Serializable] public sealed class BattlefieldManifest
{
public int schemaVersion, componentTypeLimit;
public string sceneId, selectedDesign;
public BattlefieldGroundSpec ground;
public BattlefieldComponentSpec[] components;
public BattlefieldAssemblySpec assembly;
}
/// <summary>Pipeline authoring entry for the selected battlefield design. It never creates gameplay scenery at runtime.</summary>
public static class BuildBattlefieldScene
{
const string ManifestPath = "Doc/Design/Scene/Battlefield/battlefield_scene_manifest.json";
static string RepoRoot => Directory.GetParent(Application.dataPath).Parent.FullName;
static string ProjectPath(string path) => path.StartsWith("Assets/", StringComparison.Ordinal) ? path : path;
static BattlefieldManifest ReadManifest()
{
string path = Path.GetFullPath(Path.Combine(RepoRoot, ManifestPath));
if (!File.Exists(path)) throw new FileNotFoundException("Missing battlefield manifest", path);
string text = File.ReadAllText(path);
// JsonUtility drops nested objects in this manifest, so each object is parsed on its own.
var data = new BattlefieldManifest
{
schemaVersion = ExtractInt(text, "schemaVersion"),
sceneId = ExtractString(text, "sceneId"),
selectedDesign = ExtractString(text, "selectedDesign"),
componentTypeLimit = ExtractInt(text, "componentTypeLimit"),
ground = JsonUtility.FromJson<BattlefieldGroundSpec>(SliceObject(text, "ground")),
assembly = JsonUtility.FromJson<BattlefieldAssemblySpec>(SliceObject(text, "assembly")),
components = ParseComponents(SliceArray(text, "components"))
};
if (data.ground == null) throw new Exception("Invalid battlefield manifest");
return data;
}
static string ExtractString(string json, string key)
{
var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*\"([^\"]*)\"");
return match.Success ? match.Groups[1].Value : "";
}
static int ExtractInt(string json, string key)
{
var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*(-?\\d+)");
return match.Success ? int.Parse(match.Groups[1].Value) : 0;
}
static BattlefieldVec3 ReadVec(string json, string key)
{
var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*\\{\\s*\"x\"\\s*:\\s*(-?[0-9.]+)\\s*,\\s*\"y\"\\s*:\\s*(-?[0-9.]+)\\s*,\\s*\"z\"\\s*:\\s*(-?[0-9.]+)");
if (!match.Success) return null;
return new BattlefieldVec3
{
x = float.Parse(match.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture),
y = float.Parse(match.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture),
z = float.Parse(match.Groups[3].Value, System.Globalization.CultureInfo.InvariantCulture)
};
}
static BattlefieldComponentSpec[] ParseComponents(string arrayJson)
{
var items = new List<BattlefieldComponentSpec>();
int depth = 0, start = -1;
for (int i = 0; i < arrayJson.Length; i++)
{
if (arrayJson[i] == '{') { if (depth == 0) start = i; depth++; }
else if (arrayJson[i] == '}')
{
depth--;
if (depth == 0 && start >= 0)
{
string slice = arrayJson.Substring(start, i - start + 1);
var item = JsonUtility.FromJson<BattlefieldComponentSpec>(slice);
if (item != null)
{
item.position = ReadVec(slice, "position") ?? item.position;
item.rotation = ReadVec(slice, "rotation") ?? item.rotation;
item.scale = ReadVec(slice, "scale") ?? item.scale;
item.placements = ReadPlacements(slice);
var place = Regex.Match(slice, "\"placeInScene\"\\s*:\\s*(true|false)");
item.placeInScene = !place.Success || place.Groups[1].Value == "true";
items.Add(item);
}
start = -1;
}
}
}
return items.ToArray();
}
static BattlefieldPlacement[] ReadPlacements(string slice)
{
if (slice.IndexOf("\"placements\"", StringComparison.Ordinal) < 0) return null;
string arrayJson = SliceArray(slice, "placements");
var items = new List<BattlefieldPlacement>();
int depth = 0, start = -1;
for (int i = 0; i < arrayJson.Length; i++)
{
if (arrayJson[i] == '{') { if (depth == 0) start = i; depth++; }
else if (arrayJson[i] == '}')
{
depth--;
if (depth == 0 && start >= 0)
{
string place = arrayJson.Substring(start, i - start + 1);
items.Add(new BattlefieldPlacement
{
name = Regex.Match(place, "\"name\"\\s*:\\s*\"([^\"]*)\"").Groups[1].Value,
position = ReadVec(place, "position"),
rotation = ReadVec(place, "rotation"),
scale = ReadVec(place, "scale")
});
start = -1;
}
}
}
return items.Count == 0 ? null : items.ToArray();
}
static void Validate(BattlefieldManifest data)
{
if (string.IsNullOrEmpty(data.selectedDesign) || (data.selectedDesign != "A" && data.selectedDesign != "B" && data.selectedDesign != "C"))
throw new Exception("Choose battlefield design A/B/C before assembling models.");
if (data.ground == null || data.ground.type != "ground" || string.IsNullOrEmpty(data.ground.gamePrefab))
throw new Exception("Ground must be a separate authored prefab.");
if (data.components == null) data.components = Array.Empty<BattlefieldComponentSpec>();
var types = new HashSet<string>();
foreach (var item in data.components)
{
if (item == null || string.IsNullOrEmpty(item.type) || !types.Add(item.type)) throw new Exception("Component types must be unique.");
if (types.Count > 15 || types.Count > data.componentTypeLimit) throw new Exception("Battlefield component type limit exceeded.");
if (item.type == "ground") throw new Exception("Ground cannot be listed as a decoration component.");
if (string.IsNullOrEmpty(item.prefab)) throw new Exception("Missing prefab for component " + item.type);
if (AssetDatabase.LoadAssetAtPath<GameObject>(item.prefab) == null) throw new Exception("Missing component prefab " + item.prefab);
}
var groundPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(data.ground.gamePrefab);
if (groundPrefab == null) throw new Exception("Missing ground prefab " + data.ground.gamePrefab);
var groundMeshes = groundPrefab.GetComponentsInChildren<MeshFilter>(true);
if (groundMeshes.Length == 0 || groundMeshes.Any(m => m.sharedMesh == null)) throw new Exception("Ground prefab has no readable mesh " + data.ground.gamePrefab);
var groundColliders = groundPrefab.GetComponentsInChildren<MeshCollider>(true);
if (groundColliders.Length == 0 || groundColliders.Any(c => c.sharedMesh == null || c.convex)) throw new Exception("Ground prefab requires a non-convex MeshCollider " + data.ground.gamePrefab);
if (data.assembly == null || string.IsNullOrEmpty(data.assembly.outputPrefab)) throw new Exception("Missing assembly.outputPrefab");
}
static Transform Group(Transform parent, string name)
{
var go = new GameObject(name); go.layer = 30; go.transform.SetParent(parent, false); return go.transform;
}
static void SetLayer(GameObject go, int layer)
{
foreach (var transform in go.GetComponentsInChildren<Transform>(true)) transform.gameObject.layer = layer;
}
static GameObject AddPrefab(string assetPath, Transform parent, string name, BattlefieldVec3 position, BattlefieldVec3 rotation, BattlefieldVec3 scale)
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(assetPath);
var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
instance.name = name; instance.transform.SetParent(parent, false);
SetLayer(instance, 30);
if (position != null) instance.transform.localPosition = position.Value;
if (rotation != null) instance.transform.localEulerAngles = rotation.Value;
if (scale != null) instance.transform.localScale = scale.Value;
return instance;
}
/// <summary>
/// The baked ground is a tall slab facing Z. Lay it on XZ, cover the three lanes, and sit the top surface on y=0.
/// </summary>
static void LayGroundFlat(GameObject groundMesh)
{
groundMesh.transform.localRotation = Quaternion.Euler(-90f, 0f, 0f);
groundMesh.transform.localScale = Vector3.one;
groundMesh.transform.localPosition = Vector3.zero;
var bounds = RendererBounds(groundMesh);
const float targetWidth = 22f;
const float targetDepth = 26f;
float scale = Mathf.Max(targetWidth / Mathf.Max(0.01f, bounds.size.x), targetDepth / Mathf.Max(0.01f, bounds.size.z));
groundMesh.transform.localScale = Vector3.one * scale;
bounds = RendererBounds(groundMesh);
var footprintCenter = new Vector3(0f, 0f, 3.5f);
groundMesh.transform.position += new Vector3(footprintCenter.x - bounds.center.x, -bounds.max.y, footprintCenter.z - bounds.center.z);
}
static Bounds RendererBounds(GameObject go)
{
var renderers = go.GetComponentsInChildren<Renderer>(true);
if (renderers.Length == 0) throw new Exception("Ground prefab has no renderer.");
var bounds = renderers[0].bounds;
for (int i = 1; i < renderers.Length; i++) bounds.Encapsulate(renderers[i].bounds);
return bounds;
}
static string SliceObject(string text, string key)
{
int i = text.IndexOf("\"" + key + "\"", StringComparison.Ordinal);
if (i < 0) return "";
int start = text.IndexOf('{', i);
int depth = 0;
for (int c = start; c < text.Length; c++)
{
if (text[c] == '{') depth++;
else if (text[c] == '}') { depth--; if (depth == 0) return text.Substring(start, c - start + 1); }
}
return "";
}
static string SliceArray(string text, string key)
{
int i = text.IndexOf("\"" + key + "\"", StringComparison.Ordinal);
int start = text.IndexOf('[', i);
int depth = 0;
for (int c = start; c < text.Length; c++)
{
if (text[c] == '[') depth++;
else if (text[c] == ']') { depth--; if (depth == 0) return text.Substring(start, c - start + 1); }
}
return "[]";
}
public static string ValidateOnly()
{
var data = ReadManifest(); Validate(data);
return "PASS battlefield manifest design=" + data.selectedDesign + " components=" + (data.components?.Length ?? 0);
}
public static string Build()
{
if (EditorApplication.isPlaying) throw new Exception("Build battlefield in Edit Mode.");
var data = ReadManifest(); Validate(data);
string output = data.assembly.outputPrefab;
string folder = Path.GetDirectoryName(output).Replace('\\', '/');
if (!AssetDatabase.IsValidFolder(folder)) throw new Exception("Create the authored battlefield folder first: " + folder);
var root = new GameObject(data.sceneId);
root.layer = 30;
try
{
var ground = Group(root.transform, "Ground");
LayGroundFlat(AddPrefab(data.ground.gamePrefab, ground, "GroundMesh", null, null, null));
var routes = Group(root.transform, "Routes");
var gameplay = Group(root.transform, "Gameplay"); var spawns = Group(gameplay, "SpawnPoints"); var goals = Group(gameplay, "GoalPoints");
for (int lane = 0; lane < 3; lane++) { var route = Group(routes, "Lane" + lane); route.localPosition = new Vector3(lane == 0 ? 0 : lane == 1 ? -3 : 3, 0, 0); var spawn = Group(spawns, "Lane" + lane); spawn.localPosition = route.localPosition + new Vector3(0, 0, 11); var goal = Group(goals, "Lane" + lane); goal.localPosition = route.localPosition + new Vector3(0, 0, -4); }
var decorations = Group(root.transform, "Decorations");
foreach (var item in data.components)
{
if (!item.placeInScene) continue;
if (item.placements != null && item.placements.Length > 0)
{
for (int i = 0; i < item.placements.Length; i++)
{
var place = item.placements[i];
if (place.position == null || place.rotation == null || place.scale == null)
throw new Exception("Component " + item.type + " placement " + i + " is missing a layout transform.");
AddPrefab(item.prefab, decorations, string.IsNullOrEmpty(place.name) ? item.type + i : place.name, place.position, place.rotation, place.scale);
}
}
else
{
if (item.position == null || item.rotation == null || item.scale == null)
throw new Exception("Component " + item.type + " is missing a layout transform.");
AddPrefab(item.prefab, decorations, item.type, item.position, item.rotation, item.scale);
}
}
var lighting = Group(root.transform, "Lighting");
var sunObject = new GameObject("Sun");
sunObject.transform.SetParent(lighting, false);
sunObject.transform.localRotation = Quaternion.Euler(48f, -30f, 0f);
var sun = sunObject.AddComponent<Light>();
sun.type = LightType.Directional;
sun.color = new Color(1f, 0.96f, 0.88f);
sun.intensity = 1.1f;
sun.shadows = LightShadows.Soft;
SetLayer(root, 30);
PrefabUtility.SaveAsPrefabAsset(root, output);
}
finally { UnityEngine.Object.DestroyImmediate(root); }
AssetDatabase.SaveAssets(); AssetDatabase.Refresh();
return "Built battlefield prefab " + output + " from design " + data.selectedDesign + " with " + data.components.Length + " component types.";
}
public static string VerifyBuilt()
{
const string output = "Assets/Bundles/Scenes/Prototype/Battlefield/Battlefield_Greenfield.prefab";
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(output);
if (prefab == null) throw new Exception("Missing " + output);
var scene = EditorSceneManager.NewPreviewScene();
try
{
var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab, scene);
string[] names = { "Ground/GroundMesh", "Routes/Lane0", "Routes/Lane1", "Routes/Lane2", "Gameplay/SpawnPoints/Lane0", "Gameplay/SpawnPoints/Lane1", "Gameplay/SpawnPoints/Lane2", "Gameplay/GoalPoints/Lane0", "Gameplay/GoalPoints/Lane1", "Gameplay/GoalPoints/Lane2", "Decorations/tree", "Decorations/rock", "Decorations/sign", "Decorations/shrub", "Lighting/Sun" };
foreach (var name in names)
if (instance.transform.Find(name) == null) throw new Exception("Missing node " + name);
var ground = instance.transform.Find("Ground/GroundMesh");
var collider = ground.GetComponentInChildren<MeshCollider>(true);
if (collider == null || collider.sharedMesh == null || collider.convex || !collider.sharedMesh.isReadable)
throw new Exception("Ground collider must be a readable non-convex MeshCollider.");
var groundBounds = RendererBounds(ground.gameObject);
if (groundBounds.max.y > 0.05f || groundBounds.max.y < -0.05f) throw new Exception("Ground top is not on y=0: " + groundBounds.max.y);
if (groundBounds.min.x > -3f || groundBounds.max.x < 3f || groundBounds.min.z > -4f || groundBounds.max.z < 11f)
throw new Exception("Ground footprint does not cover the three lanes: " + groundBounds);
var report = new StringBuilder();
var decorations = instance.transform.Find("Decorations");
foreach (Transform child in decorations)
if (child.name.IndexOf("bridge", StringComparison.OrdinalIgnoreCase) >= 0) throw new Exception("Bridge is still placed: " + child.name);
var badLayer = instance.GetComponentsInChildren<Renderer>(true).FirstOrDefault(r => r.gameObject.layer != 30);
if (badLayer != null) throw new Exception("Renderer not on battle layer 30: " + badLayer.name + " layer=" + badLayer.gameObject.layer);
foreach (var decoName in new[] { "tree", "rock", "sign", "shrub" })
{
var deco = instance.transform.Find("Decorations/" + decoName);
var renderer = deco.GetComponentInChildren<Renderer>(true);
if (renderer == null || renderer.sharedMaterial == null || renderer.sharedMaterial.shader == null)
throw new Exception(decoName + " has no material.");
if (renderer.sharedMaterial.shader.name != "Test/XPbr") throw new Exception(decoName + " shader is " + renderer.sharedMaterial.shader.name);
report.Append(decoName).Append('@').Append(deco.localPosition.ToString("F1")).Append(' ');
}
return "PASS ground=" + groundBounds.size + " topY=" + groundBounds.max.y.ToString("F3") + " " + report;
}
finally { EditorSceneManager.ClosePreviewScene(scene); }
}
public static string FramePrefabStage()
{
const string output = "Assets/Bundles/Scenes/Prototype/Battlefield/Battlefield_Greenfield.prefab";
var stage = UnityEditor.SceneManagement.PrefabStageUtility.OpenPrefab(output);
var sv = SceneView.lastActiveSceneView;
if (sv == null) throw new Exception("No Scene View is open.");
sv.orthographic = false;
sv.LookAt(new Vector3(0f, 0f, 3.5f), Quaternion.Euler(38f, -35f, 0f), 22f);
sv.Repaint();
return "Framed " + stage.assetPath;
}
public static string ClosePrefabStage()
{
StageUtility.GoToMainStage();
return "Closed prefab stage";
}
}
+80
View File
@@ -0,0 +1,80 @@
using System;
using System.IO;
using AIGame.EditorTools;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
public static class BuildCampAssets
{
const string World="Assets/Bundles/Scenes/Prototype";
const string Pictures="Assets/Bundles/UI/Texture/Prototype";
static Material Mat(string name) => AssetDatabase.LoadAssetAtPath<Material>(World+"/Materials/Proto"+name+".mat");
static Transform Part(Transform parent,string name,PrimitiveType type,Vector3 position,Vector3 scale,string material)
{
var go=GameObject.CreatePrimitive(type);go.name=name;go.layer=30;go.transform.SetParent(parent,false);
go.transform.localPosition=position;go.transform.localScale=scale;go.GetComponent<Renderer>().sharedMaterial=Mat(material);
UnityEngine.Object.DestroyImmediate(go.GetComponent<Collider>());return go.transform;
}
static void Prop(int id,Transform root)
{
if(id==0)
{
Part(root,"Foot",PrimitiveType.Cylinder,new Vector3(0,.06f,0),new Vector3(.7f,.06f,.7f),"Cream");
Part(root,"Pole",PrimitiveType.Cylinder,new Vector3(0,1,0),new Vector3(.09f,1,.09f),"Dark");
Part(root,"Finial",PrimitiveType.Sphere,new Vector3(0,2.03f,0),Vector3.one*.18f,"Yellow");
Part(root,"Flag",PrimitiveType.Cube,new Vector3(.43f,1.62f,0),new Vector3(.86f,.58f,.06f),"Orange");
Part(root,"Stripe",PrimitiveType.Cube,new Vector3(.43f,1.62f,-.04f),new Vector3(.7f,.12f,.025f),"Cream");
}
else if(id==1)
{
Part(root,"Pot",PrimitiveType.Cylinder,new Vector3(0,.27f,0),new Vector3(.8f,.27f,.8f),"Orange");
Part(root,"Rim",PrimitiveType.Cylinder,new Vector3(0,.53f,0),new Vector3(.9f,.055f,.9f),"Cream");
Part(root,"Stem",PrimitiveType.Cylinder,new Vector3(0,.9f,0),new Vector3(.09f,.4f,.09f),"Leaf");
for(int i=0;i<5;i++) {float a=i*Mathf.PI*2/5;Part(root,"Petal"+i,PrimitiveType.Sphere,new Vector3(Mathf.Sin(a)*.34f,1.37f+Mathf.Cos(a)*.34f,0),new Vector3(.45f,.45f,.2f),"Water");}
Part(root,"Heart",PrimitiveType.Sphere,new Vector3(0,1.37f,-.13f),Vector3.one*.36f,"Yellow");
Part(root,"Leaf",PrimitiveType.Sphere,new Vector3(.22f,.87f,0),new Vector3(.55f,.15f,.25f),"Leaf").localRotation=Quaternion.Euler(0,0,28);
}
else
{
Part(root,"Base",PrimitiveType.Cylinder,new Vector3(0,.08f,0),new Vector3(.65f,.08f,.65f),"Rock");
Part(root,"Post",PrimitiveType.Cylinder,new Vector3(0,.85f,0),new Vector3(.11f,.8f,.11f),"Dark");
Part(root,"Housing",PrimitiveType.Cube,new Vector3(0,1.72f,0),new Vector3(.6f,.7f,.45f),"Dark");
Part(root,"Glass",PrimitiveType.Cube,new Vector3(0,1.72f,-.05f),new Vector3(.46f,.55f,.44f),"Yellow");
Part(root,"Cap",PrimitiveType.Cube,new Vector3(0,2.12f,0),new Vector3(.78f,.12f,.6f),"Rock");
Part(root,"Top",PrimitiveType.Sphere,new Vector3(0,2.25f,0),Vector3.one*.18f,"Orange");
}
}
static void BuildProp(int id)
{
var scene=EditorSceneManager.NewPreviewScene();var previous=RenderTexture.active;
var rt=new RenderTexture(256,256,24,RenderTextureFormat.ARGB32);
try
{
var root=new GameObject("CampDecoration"+id);SceneManager.MoveGameObjectToScene(root,scene);root.layer=30;Prop(id,root.transform);
PrefabUtility.SaveAsPrefabAsset(root,World+"/CampDecoration"+id+".prefab");
var cameraGo=new GameObject("Camera");SceneManager.MoveGameObjectToScene(cameraGo,scene);var camera=cameraGo.AddComponent<Camera>();camera.scene=scene;
camera.clearFlags=CameraClearFlags.SolidColor;camera.backgroundColor=Color.clear;camera.cullingMask=1<<30;camera.orthographic=true;camera.orthographicSize=1.35f;camera.allowHDR=false;
camera.transform.position=new Vector3(1.3f,1.7f,-4);camera.transform.LookAt(new Vector3(id==0?.22f:0,1.05f,0));camera.targetTexture=rt;
var lightGo=new GameObject("Light");SceneManager.MoveGameObjectToScene(lightGo,scene);var light=lightGo.AddComponent<Light>();light.type=LightType.Directional;light.intensity=1.15f;light.cullingMask=1<<30;lightGo.transform.rotation=Quaternion.Euler(35,-35,0);
camera.Render();RenderTexture.active=rt;var texture=new Texture2D(256,256,TextureFormat.RGBA32,false);
try {texture.ReadPixels(new Rect(0,0,256,256),0,0);texture.Apply();File.WriteAllBytes(Pictures+"/proto_decor_"+id+".png",texture.EncodeToPNG());}
finally {UnityEngine.Object.DestroyImmediate(texture);}
}
finally {RenderTexture.active=previous;EditorSceneManager.ClosePreviewScene(scene);rt.Release();UnityEngine.Object.DestroyImmediate(rt);}
string path=Pictures+"/proto_decor_"+id+".png";AssetDatabase.ImportAsset(path);
var importer=(TextureImporter)AssetImporter.GetAtPath(path);importer.textureType=TextureImporterType.Sprite;importer.spriteImportMode=SpriteImportMode.Single;importer.mipmapEnabled=false;importer.alphaIsTransparency=true;importer.textureCompression=TextureImporterCompression.Uncompressed;
var settings=new TextureImporterSettings();importer.ReadTextureSettings(settings);settings.spriteMeshType=SpriteMeshType.FullRect;importer.SetTextureSettings(settings);importer.SaveAndReimport();
}
public static string Build()
{
if(EditorApplication.isPlaying) throw new Exception("Build camp in Edit Mode");
for(int i=0;i<3;i++) BuildProp(i);
string repo=Directory.GetParent(Application.dataPath).Parent.FullName;
foreach(string screen in new[]{"Camp","Build"})
if(string.IsNullOrEmpty(JsonUIPrefabBuilder.BuildFromJsonFile(Path.Combine(repo,"Doc/UIPrefabCreater/UI_Prototype"+screen+".json")))) throw new Exception("Camp UI build failed");
SetupUiBundleCollector.Setup();AssetDatabase.SaveAssets();
return "Created three authored decorations and transparent portraits; generated two camp UI prefabs from JSON.";
}
}
@@ -0,0 +1,518 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
[Serializable] public sealed class CharacterReviewVec3 { public float x, y, z; public Vector3 Value => new Vector3(x, y, z); }
[Serializable]
public sealed class CharacterReviewSubject
{
public string id, kind, prefab, notes;
public float expectedHeight = 1.8f, heightTolerance = 0.15f;
public int maxTriangles = 3500;
public bool requireXpbr = true;
public bool requireAnimator = false;
public string[] requiredClips = { "Idle", "Walk", "Attack" };
}
[Serializable]
public sealed class CharacterReviewManifest
{
public int schemaVersion;
public string sceneId, outputScene, reportPath, label;
public float spacing = 3f, groundSize = 24f, fieldOfView = 40f;
public CharacterReviewVec3 cameraPosition, cameraTarget;
public CharacterReviewSubject[] subjects;
}
/// <summary>
/// Builds a persistent editor scene for reviewing character / unit art before gameplay swaps.
/// </summary>
public static class BuildCharacterReview
{
const string ManifestPath = "Doc/Design/Character/character_unit_review_manifest.json";
const string CapturePath = "Assets/Screenshots/character_unit_review.png";
static string RepoRoot => Directory.GetParent(Application.dataPath).Parent.FullName;
static string ProjectRoot => Directory.GetParent(Application.dataPath).FullName;
static string AbsoluteAsset(string assetPath) => Path.GetFullPath(Path.Combine(ProjectRoot, Norm(assetPath)));
static CharacterReviewManifest ReadManifest()
{
string path = Path.GetFullPath(Path.Combine(RepoRoot, ManifestPath));
if (!File.Exists(path)) throw new FileNotFoundException("Missing character review manifest", path);
string text = File.ReadAllText(path);
var data = new CharacterReviewManifest
{
schemaVersion = ExtractInt(text, "schemaVersion"),
sceneId = ExtractString(text, "sceneId"),
outputScene = ExtractString(text, "outputScene"),
reportPath = ExtractString(text, "reportPath"),
label = ExtractString(text, "label"),
spacing = ExtractFloat(text, "spacing", 3f),
groundSize = ExtractFloat(text, "groundSize", 24f),
fieldOfView = ExtractFloat(text, "fieldOfView", 40f),
cameraPosition = ReadVec(text, "cameraPosition"),
cameraTarget = ReadVec(text, "cameraTarget"),
subjects = ParseSubjects(SliceArray(text, "subjects"))
};
if (string.IsNullOrEmpty(data.sceneId) || string.IsNullOrEmpty(data.outputScene))
throw new Exception("Character review manifest needs sceneId and outputScene.");
if (data.cameraPosition == null || data.cameraTarget == null)
throw new Exception("Character review manifest needs cameraPosition and cameraTarget.");
if (data.subjects == null || data.subjects.Length == 0)
throw new Exception("Character review manifest needs at least one subject.");
return data;
}
static string ExtractString(string json, string key)
{
var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*\"([^\"]*)\"");
return match.Success ? match.Groups[1].Value : "";
}
static int ExtractInt(string json, string key)
{
var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*(-?\\d+)");
return match.Success ? int.Parse(match.Groups[1].Value) : 0;
}
static float ExtractFloat(string json, string key, float fallback)
{
var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*(-?[0-9.]+)");
return match.Success ? float.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture) : fallback;
}
static bool ExtractBool(string json, string key, bool fallback)
{
var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*(true|false)");
return match.Success ? match.Groups[1].Value == "true" : fallback;
}
static CharacterReviewVec3 ReadVec(string json, string key)
{
var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*\\{\\s*\"x\"\\s*:\\s*(-?[0-9.]+)\\s*,\\s*\"y\"\\s*:\\s*(-?[0-9.]+)\\s*,\\s*\"z\"\\s*:\\s*(-?[0-9.]+)");
if (!match.Success) return null;
return new CharacterReviewVec3
{
x = float.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture),
y = float.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture),
z = float.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture)
};
}
static string SliceArray(string text, string key)
{
int i = text.IndexOf("\"" + key + "\"", StringComparison.Ordinal);
if (i < 0) return "[]";
int start = text.IndexOf('[', i);
int depth = 0;
for (int c = start; c < text.Length; c++)
{
if (text[c] == '[') depth++;
else if (text[c] == ']') { depth--; if (depth == 0) return text.Substring(start, c - start + 1); }
}
return "[]";
}
static CharacterReviewSubject[] ParseSubjects(string arrayJson)
{
var items = new List<CharacterReviewSubject>();
int depth = 0, start = -1;
for (int i = 0; i < arrayJson.Length; i++)
{
if (arrayJson[i] == '{') { if (depth == 0) start = i; depth++; }
else if (arrayJson[i] == '}')
{
depth--;
if (depth == 0 && start >= 0)
{
string slice = arrayJson.Substring(start, i - start + 1);
items.Add(new CharacterReviewSubject
{
id = ExtractString(slice, "id"),
kind = ExtractString(slice, "kind"),
prefab = ExtractString(slice, "prefab"),
notes = ExtractString(slice, "notes"),
expectedHeight = ExtractFloat(slice, "expectedHeight", 1.8f),
heightTolerance = ExtractFloat(slice, "heightTolerance", 0.15f),
maxTriangles = ExtractInt(slice, "maxTriangles"),
requireXpbr = ExtractBool(slice, "requireXpbr", true),
requireAnimator = ExtractBool(slice, "requireAnimator", false),
requiredClips = slice.Contains("\"requiredClips\"")
? Regex.Matches(SliceArray(slice,"requiredClips"), "\"([^\"]+)\"").Cast<Match>().Select(m=>m.Groups[1].Value).ToArray()
: new[] { "Idle", "Walk", "Attack" }
});
if (items[items.Count - 1].maxTriangles <= 0) items[items.Count - 1].maxTriangles = 3500;
start = -1;
}
}
}
return items.ToArray();
}
static string Norm(string path) => path.Replace('\\', '/');
static void EnsureFolder(string assetPath)
{
string absolute = AbsoluteAsset(assetPath);
string directory = Path.GetDirectoryName(absolute);
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
string unityDir = Path.GetDirectoryName(Norm(assetPath))?.Replace('\\', '/');
if (string.IsNullOrEmpty(unityDir) || !unityDir.StartsWith("Assets/", StringComparison.Ordinal)) return;
string[] parts = unityDir.Split('/');
string current = parts[0];
for (int i = 1; i < parts.Length; i++)
{
string next = current + "/" + parts[i];
if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(current, parts[i]);
current = next;
}
}
public static string ValidateOnly()
{
var data = ReadManifest();
foreach (var subject in data.subjects)
{
if (string.IsNullOrEmpty(subject.id) || string.IsNullOrEmpty(subject.prefab))
throw new Exception("Subject missing id/prefab");
if (AssetDatabase.LoadAssetAtPath<GameObject>(Norm(subject.prefab)) == null)
throw new Exception("Missing prefab " + subject.prefab);
}
return "PASS character review manifest subjects=" + data.subjects.Length;
}
public static string Build()
{
if (EditorApplication.isPlaying) throw new Exception("Build character review in Edit Mode.");
var data = ReadManifest();
string scenePath = Norm(data.outputScene);
EnsureFolder(scenePath);
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var root = new GameObject(data.sceneId);
SceneManager.MoveGameObjectToScene(root, scene);
BuildGround(root.transform, data.groundSize);
BuildHeightMarkers(root.transform);
var subjects = new GameObject("Subjects");
subjects.transform.SetParent(root.transform, false);
for (int i = 0; i < data.subjects.Length; i++)
{
var subject = data.subjects[i];
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(Norm(subject.prefab));
if (prefab == null) throw new Exception("Missing prefab " + subject.prefab);
var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab, subjects.transform);
instance.name = subject.id;
instance.transform.localPosition = new Vector3(i * data.spacing, 0f, 0f);
instance.transform.localRotation = Quaternion.identity;
AddForwardMarker(instance.transform);
AddNameLabel(instance.transform, subject.id + " / " + subject.kind);
EnsureAnimCycle(instance);
}
var lighting = new GameObject("Lighting");
lighting.transform.SetParent(root.transform, false);
var sun = new GameObject("Sun");
sun.transform.SetParent(lighting.transform, false);
sun.transform.rotation = Quaternion.Euler(48f, -30f, 0f);
var light = sun.AddComponent<Light>();
light.type = LightType.Directional;
light.color = new Color(1f, 0.96f, 0.88f);
light.intensity = 1.2f;
light.shadows = LightShadows.Soft;
var cameraGo = new GameObject("ReviewCamera");
cameraGo.tag = "MainCamera";
var camera = cameraGo.AddComponent<Camera>();
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = new Color(0.55f, 0.72f, 0.82f);
camera.fieldOfView = data.fieldOfView;
camera.nearClipPlane = 0.05f;
camera.farClipPlane = 80f;
cameraGo.AddComponent<AudioListener>();
cameraGo.transform.position = data.cameraPosition.Value;
cameraGo.transform.rotation = Quaternion.LookRotation(data.cameraTarget.Value - cameraGo.transform.position, Vector3.up);
var title = new GameObject("ReviewLabel");
var text = title.AddComponent<TextMesh>();
text.text = string.IsNullOrEmpty(data.label) ? data.sceneId : data.label;
text.anchor = TextAnchor.LowerCenter;
text.alignment = TextAlignment.Center;
text.characterSize = 0.12f;
text.fontSize = 48;
text.color = new Color(0.15f, 0.2f, 0.25f);
title.transform.position = data.cameraTarget.Value + new Vector3(0f, 2.6f, 0f);
RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Trilight;
RenderSettings.ambientSkyColor = new Color(0.55f, 0.65f, 0.75f);
RenderSettings.ambientEquatorColor = new Color(0.4f, 0.42f, 0.4f);
RenderSettings.ambientGroundColor = new Color(0.25f, 0.24f, 0.22f);
EditorSceneManager.SaveScene(scene, scenePath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
WriteReport(data, scenePath, "built");
return "Built character review scene " + scenePath + " with " + data.subjects.Length + " subjects.";
}
static void BuildGround(Transform parent, float size)
{
var ground = GameObject.CreatePrimitive(PrimitiveType.Plane);
ground.name = "Ground";
ground.transform.SetParent(parent, false);
ground.transform.localScale = Vector3.one * (size / 10f);
var renderer = ground.GetComponent<Renderer>();
var mat = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard"));
mat.color = new Color(0.78f, 0.82f, 0.74f);
renderer.sharedMaterial = mat;
UnityEngine.Object.DestroyImmediate(ground.GetComponent<Collider>());
}
static void BuildHeightMarkers(Transform parent)
{
var markers = new GameObject("HeightMarkers");
markers.transform.SetParent(parent, false);
markers.transform.localPosition = new Vector3(-2.2f, 0f, 0f);
foreach (float h in new[] { 0.5f, 1.0f, 1.8f })
{
var stick = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
stick.name = "Stick_" + h.ToString("0.0", CultureInfo.InvariantCulture);
stick.transform.SetParent(markers.transform, false);
stick.transform.localPosition = new Vector3(0f, h * 0.5f, 0f);
stick.transform.localScale = new Vector3(0.06f, h * 0.5f, 0.06f);
UnityEngine.Object.DestroyImmediate(stick.GetComponent<Collider>());
var label = new GameObject("Label_" + stick.name);
label.transform.SetParent(stick.transform, false);
label.transform.localPosition = new Vector3(0.8f, 0.5f, 0f);
var text = label.AddComponent<TextMesh>();
text.text = h.ToString("0.0", CultureInfo.InvariantCulture) + "m";
text.characterSize = 0.08f;
text.fontSize = 36;
text.anchor = TextAnchor.MiddleLeft;
text.color = new Color(0.2f, 0.25f, 0.3f);
}
}
static void AddForwardMarker(Transform subject)
{
var marker = GameObject.CreatePrimitive(PrimitiveType.Cube);
marker.name = "ForwardMinusZ";
marker.transform.SetParent(subject, false);
marker.transform.localPosition = new Vector3(0f, 0.05f, -0.55f);
marker.transform.localScale = new Vector3(0.12f, 0.04f, 0.45f);
UnityEngine.Object.DestroyImmediate(marker.GetComponent<Collider>());
var renderer = marker.GetComponent<Renderer>();
var mat = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard"));
mat.color = new Color(0.2f, 0.55f, 0.95f);
renderer.sharedMaterial = mat;
}
static void AddNameLabel(Transform subject, string label)
{
var go = new GameObject("NameLabel");
go.transform.SetParent(subject, false);
go.transform.localPosition = new Vector3(0f, 2.1f, 0f);
var text = go.AddComponent<TextMesh>();
text.text = label;
text.anchor = TextAnchor.LowerCenter;
text.alignment = TextAlignment.Center;
text.characterSize = 0.06f;
text.fontSize = 40;
text.color = new Color(0.12f, 0.16f, 0.2f);
}
static void EnsureAnimCycle(GameObject subjectRoot)
{
var animator = subjectRoot.GetComponentInChildren<Animator>(true);
if (animator == null || animator.runtimeAnimatorController == null) return;
bool hasMotion = false;
foreach (var p in animator.parameters)
{
if (p.name == "Motion" && p.type == AnimatorControllerParameterType.Int)
{
hasMotion = true;
break;
}
}
if (!hasMotion) return;
var cycle = subjectRoot.GetComponent<CharacterReviewAnimCycle>();
if (cycle == null) cycle = subjectRoot.AddComponent<CharacterReviewAnimCycle>();
var clips = animator.runtimeAnimatorController.animationClips.Select(c=>c.name).Distinct().ToArray();
var serialized = new SerializedObject(cycle);
var names = serialized.FindProperty("motionNames");
names.arraySize = clips.Length;
for(int i=0;i<clips.Length;i++) names.GetArrayElementAtIndex(i).stringValue=clips[i];
serialized.ApplyModifiedPropertiesWithoutUndo();
cycle.enabled = true;
}
static void WriteReport(CharacterReviewManifest data, string scenePath, string status)
{
string reportPath = string.IsNullOrEmpty(data.reportPath)
? "Tools/output/character_review/" + data.sceneId + "/review_report.json"
: Norm(data.reportPath);
string absolute = Path.GetFullPath(Path.Combine(RepoRoot, reportPath));
Directory.CreateDirectory(Path.GetDirectoryName(absolute));
var sb = new StringBuilder();
sb.AppendLine("{");
sb.AppendLine(" \"schemaVersion\": 1,");
sb.AppendLine(" \"sceneId\": \"" + data.sceneId + "\",");
sb.AppendLine(" \"outputScene\": \"" + scenePath + "\",");
sb.AppendLine(" \"status\": \"" + status + "\",");
sb.AppendLine(" \"generatedAtUtc\": \"" + DateTime.UtcNow.ToString("o") + "\",");
sb.AppendLine(" \"subjectCount\": " + data.subjects.Length + ",");
sb.AppendLine(" \"doc\": \"Doc/Design/Character/CharacterUnitArtProc.md\"");
sb.AppendLine("}");
File.WriteAllText(absolute, sb.ToString());
}
static Bounds ActualBounds(Renderer renderer)
{
var skin=renderer as SkinnedMeshRenderer;
if(skin==null) return renderer.bounds;
// Renderer.bounds is an animation culling volume, not posed geometry.
// Evaluate skin matrices directly; BakeMesh at scaled ancestors can
// include scale a second time in this Unity version.
var mesh=skin.sharedMesh;
var poses=mesh.bindposes;
var matrices=skin.bones.Select((b,i)=>b.localToWorldMatrix*poses[i]).ToArray();
var weights=mesh.boneWeights;
var vertices=mesh.vertices;
Bounds bounds=default;
for(int i=0;i<vertices.Length;i++)
{
var w=weights[i]; var v=vertices[i];
var p=matrices[w.boneIndex0].MultiplyPoint3x4(v)*w.weight0
+matrices[w.boneIndex1].MultiplyPoint3x4(v)*w.weight1
+matrices[w.boneIndex2].MultiplyPoint3x4(v)*w.weight2
+matrices[w.boneIndex3].MultiplyPoint3x4(v)*w.weight3;
if(i==0) bounds=new Bounds(p,Vector3.zero); else bounds.Encapsulate(p);
}
return bounds;
}
public static string OpenReview()
{
var data = ReadManifest();
string scenePath = Norm(data.outputScene);
if (!File.Exists(AbsoluteAsset(scenePath)))
throw new FileNotFoundException("Review scene missing; run Build first", AbsoluteAsset(scenePath));
var scene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
var root = GameObject.Find(data.sceneId);
if (root != null)
{
var subjects = root.transform.Find("Subjects");
if (subjects != null)
{
for (int i = 0; i < subjects.childCount; i++)
EnsureAnimCycle(subjects.GetChild(i).gameObject);
}
Selection.activeGameObject = root;
EditorGUIUtility.PingObject(root);
EditorSceneManager.MarkSceneDirty(scene);
EditorSceneManager.SaveScene(scene);
}
var view = SceneView.lastActiveSceneView;
if (view != null)
{
view.LookAt(data.cameraTarget.Value, Quaternion.Euler(18f, 20f, 0f), 8f);
view.orthographic = false;
view.Repaint();
}
return "Opened " + scene.path + " (anim cycle on Motion units)";
}
public static string ValidateReview()
{
var data = ReadManifest();
string scenePath = Norm(data.outputScene);
EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
var root = GameObject.Find(data.sceneId);
if (root == null) throw new Exception("Missing root " + data.sceneId);
if (GameObject.Find("ReviewCamera") == null) throw new Exception("Missing ReviewCamera");
// 轮播会改姿态,校验前强制 Idle
foreach (var cycle in root.GetComponentsInChildren<CharacterReviewAnimCycle>(true))
cycle.enabled = false;
foreach (var animator in root.GetComponentsInChildren<Animator>(true))
{
if (animator.runtimeAnimatorController == null) continue;
foreach (var p in animator.parameters)
{
if (p.name == "Motion" && p.type == AnimatorControllerParameterType.Int)
{
animator.SetInteger("Motion", 0);
animator.Update(0f);
break;
}
}
}
var report = new StringBuilder("PASS ");
foreach (var subject in data.subjects)
{
var node = root.transform.Find("Subjects/" + subject.id);
if (node == null) throw new Exception("Missing subject node " + subject.id);
var renderers = node.GetComponentsInChildren<Renderer>(true)
.Where(r => r.name != "ForwardMinusZ" && r.name != "NameLabel" && r.GetComponentInParent<TextMesh>() == null && !r.name.StartsWith("Label_", StringComparison.Ordinal))
.ToArray();
if (renderers.Length == 0) throw new Exception(subject.id + " has no mesh renderer");
var bounds = ActualBounds(renderers[0]);
for (int i = 1; i < renderers.Length; i++) bounds.Encapsulate(ActualBounds(renderers[i]));
if (bounds.min.y < -0.05f || bounds.min.y > 0.08f)
throw new Exception(subject.id + " bottom y=" + bounds.min.y.ToString("F3") + " (want near 0)");
float height = bounds.size.y;
if (Mathf.Abs(height - subject.expectedHeight) > subject.heightTolerance)
throw new Exception(subject.id + " height=" + height.ToString("F2") + " expected " + subject.expectedHeight);
int tris = 0;
foreach (var filter in node.GetComponentsInChildren<MeshFilter>(true))
if (filter.sharedMesh != null) tris += filter.sharedMesh.triangles.Length / 3;
foreach (var skin in node.GetComponentsInChildren<SkinnedMeshRenderer>(true))
if (skin.sharedMesh != null) tris += skin.sharedMesh.triangles.Length / 3;
if (tris > subject.maxTriangles)
throw new Exception(subject.id + " tris=" + tris + " > " + subject.maxTriangles);
if (subject.requireXpbr)
{
foreach (var renderer in renderers)
{
if (renderer.sharedMaterial == null || renderer.sharedMaterial.shader == null)
throw new Exception(subject.id + " missing material");
if (renderer.sharedMaterial.shader.name != "Test/XPbr")
throw new Exception(subject.id + " shader=" + renderer.sharedMaterial.shader.name);
}
}
if (node.Find("ForwardMinusZ") == null) throw new Exception(subject.id + " missing -Z marker");
if (subject.requireAnimator)
{
var animator = node.GetComponentInChildren<Animator>(true);
if (animator == null || animator.runtimeAnimatorController == null)
throw new Exception(subject.id + " missing Animator/controller");
var ctrl = animator.runtimeAnimatorController;
var clipNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var clip in ctrl.animationClips) clipNames.Add(clip.name);
foreach (var need in subject.requiredClips)
{
bool hit = clipNames.Any(n => n.IndexOf(need, StringComparison.OrdinalIgnoreCase) >= 0);
if (!hit) throw new Exception(subject.id + " missing clip " + need + " in " + string.Join(",", clipNames));
}
}
report.Append(subject.id).Append("(h=").Append(height.ToString("F2")).Append(",tris=").Append(tris).Append(") ");
}
WriteReport(data, scenePath, "validated");
return report.ToString();
}
public static string CaptureReview()
{
return OpenReview() + "; then run: unity command capture_scene_view --width 1600 --height 900 --save_path " + CapturePath;
}
public static string BuildOpenValidate()
{
string built = Build();
OpenReview();
string validated = ValidateReview();
return built + " | " + validated;
}
}
#endif
+17
View File
@@ -0,0 +1,17 @@
using System;
using System.IO;
using AIGame.EditorTools;
using UnityEditor;
using UnityEngine;
public static class BuildChest
{
public static string Build()
{
if (EditorApplication.isPlaying) throw new Exception("Build chest in Edit Mode");
string repo = Directory.GetParent(Application.dataPath).Parent.FullName;
if (string.IsNullOrEmpty(JsonUIPrefabBuilder.BuildFromJsonFile(Path.Combine(repo, "Doc/UIPrefabCreater/UI_PrototypeChest.json")))) throw new Exception("Chest prefab failed");
SetupUiBundleCollector.Setup(); AssetDatabase.SaveAssets();
return "Built Candy chest page: daily chest, owned chest list, fixed/choice contents and open buttons from JSON.";
}
}
+18
View File
@@ -0,0 +1,18 @@
using System;
using System.IO;
using AIGame.EditorTools;
using UnityEditor;
using UnityEngine;
public static class BuildDaily
{
public static string Build()
{
if(EditorApplication.isPlaying) throw new Exception("Build daily UI in Edit Mode");
string repo=Directory.GetParent(Application.dataPath).Parent.FullName;
foreach(string name in new[]{"UI_PrototypeTasks","UI_PrototypeSignin","UI_MainHUD"})
if(string.IsNullOrEmpty(JsonUIPrefabBuilder.BuildFromJsonFile(Path.Combine(repo,"Doc/UIPrefabCreater/"+name+".json")))) throw new Exception("Build failed "+name);
SetupUiBundleCollector.Setup();AssetDatabase.SaveAssets();
return "Built task, seven-day sign-in and home reward-dot prefabs through JSON; reused existing sprites.";
}
}
+135
View File
@@ -0,0 +1,135 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using Object=UnityEngine.Object;
public static class BuildEnemyReview
{
[Serializable] public sealed class Recipe {public string id,assetRoot,staticPrefab,enemyPrefab,visualPrefab,reviewScene,usage;public float attackImpact,height,reviewOriginX;}
static Recipe Read(string path)
{
var r=JsonUtility.FromJson<Recipe>(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../..",path))));
if(string.IsNullOrEmpty(r.visualPrefab))r.visualPrefab=r.enemyPrefab;
if(r.reviewOriginX==0)r.reviewOriginX=20;
return r;
}
static bool Companion(Recipe r)=>r.usage=="companion_visual";
static string Prefix(Recipe r)=>Companion(r) ? "CompanionReview_"+r.id : "EnemyReview";
static void Folder(string path)
{
string current="Assets";
foreach(string part in path.Split('/').Skip(1)) {string next=current+"/"+part;if(!AssetDatabase.IsValidFolder(next))AssetDatabase.CreateFolder(current,part);current=next;}
}
public static string Publish(string recipePath="Config/Art/EnemyCharacters/RockBall.json")
{
var r=Read(recipePath);Folder(Path.GetDirectoryName(r.visualPrefab).Replace('\\','/'));
var scene=EditorSceneManager.NewPreviewScene();
try
{
var source=AssetDatabase.LoadAssetAtPath<GameObject>(r.assetRoot+"/"+r.id+"_Rigged.prefab");
var go=(GameObject)PrefabUtility.InstantiatePrefab(source,scene);go.name=r.id+(Companion(r) ? "_Companion" : "_Enemy");
var animator=go.GetComponentInChildren<Animator>();
if(animator.runtimeAnimatorController.animationClips.Select(c=>c.name).Distinct().Count()!=3)throw new Exception("Three actions required before publishing character visual");
PrefabUtility.SaveAsPrefabAsset(go,r.visualPrefab);
}
finally {EditorSceneManager.ClosePreviewScene(scene);}
AssetDatabase.SaveAssets();return r.visualPrefab;
}
public static string Build(string recipePath="Config/Art/EnemyCharacters/RockBall.json")
{
if(EditorApplication.isPlaying)throw new Exception("Build in Edit Mode");
var r=Read(recipePath);Publish(recipePath);
var existing=SceneManager.GetSceneByPath(r.reviewScene);
if(existing.IsValid() && existing.isLoaded)
{
if(existing.isDirty)throw new Exception("Existing character review has unsaved edits; preserve before rebuilding");
EditorSceneManager.CloseScene(existing,true);
}
Folder(Path.GetDirectoryName(r.reviewScene).Replace('\\','/'));
// Additive + a separate render layer preserves any user edits in other scenes.
var scene=EditorSceneManager.NewScene(NewSceneSetup.EmptyScene,NewSceneMode.Additive);
SceneManager.SetActiveScene(scene);
var root=new GameObject((Companion(r) ? "CompanionProductionReview_" : "EnemyProductionReview_")+r.id);
const int layer=29;
const string floorPath="Assets/Bundles/Scenes/Review/EnemyReviewFloor.mat";
var mat=AssetDatabase.LoadAssetAtPath<Material>(floorPath);
if(mat==null){mat=new Material(Shader.Find("Universal Render Pipeline/Lit"));mat.color=new Color(.48f,.56f,.50f);AssetDatabase.CreateAsset(mat,floorPath);}
float spacing=Mathf.Max(1,r.height*1.4f);
var floor=GameObject.CreatePrimitive(PrimitiveType.Plane);floor.name="Ground_Y0";floor.transform.SetParent(root.transform,false);
floor.transform.position=new Vector3(r.reviewOriginX,0,0);floor.transform.localScale=Vector3.one*(.8f*spacing);floor.GetComponent<Renderer>().sharedMaterial=mat;
string[] names={"Static","Idle","Walk","Attack","Cycle"};
for(int i=0;i<names.Length;i++)
{
var asset=AssetDatabase.LoadAssetAtPath<GameObject>(i==0 ? r.staticPrefab : r.visualPrefab);
var go=(GameObject)PrefabUtility.InstantiatePrefab(asset,root.transform);go.name=Prefix(r)+"_"+names[i];
go.transform.position=new Vector3(r.reviewOriginX+(i-2)*spacing,0,0);
var label=new GameObject("NameLabel");label.transform.SetParent(go.transform,false);label.transform.position=go.transform.position+Vector3.up*(r.height+.21f);
label.transform.localScale=Vector3.one/go.transform.lossyScale.x;
var text=label.AddComponent<TextMesh>();text.text=names[i];text.anchor=TextAnchor.MiddleCenter;text.characterSize=.01f;text.fontSize=42;text.color=new Color(.12f,.18f,.2f);
var marker=GameObject.CreatePrimitive(PrimitiveType.Cube);marker.name="ForwardMinusZ";marker.transform.SetParent(go.transform,false);
marker.transform.position=go.transform.position+new Vector3(0,.012f,-.4f);marker.transform.localScale=new Vector3(.035f,.015f,.22f)/go.transform.lossyScale.x;
Object.DestroyImmediate(marker.GetComponent<Collider>());
if(i>0)
{
var cycle=go.AddComponent<CharacterReviewAnimCycle>();cycle.enabled=false;
var so=new SerializedObject(cycle);var modes=so.FindProperty("motionNames");modes.arraySize=3;
for(int m=0;m<3;m++)modes.GetArrayElementAtIndex(m).stringValue=new[]{"Idle","Walk","Attack"}[m];
so.FindProperty("fixedMotion").intValue=i==4 ? -1 : i-1;
so.ApplyModifiedPropertiesWithoutUndo();cycle.enabled=true;
}
}
var sun=new GameObject(Prefix(r)+"Sun").AddComponent<Light>();sun.transform.SetParent(root.transform,false);
sun.type=LightType.Directional;sun.intensity=1.0f;sun.shadows=LightShadows.Soft;sun.cullingMask=1<<layer;sun.transform.rotation=Quaternion.Euler(48,-30,0);
var camera=new GameObject(Prefix(r)+"Camera").AddComponent<Camera>();camera.transform.SetParent(root.transform,false);
camera.clearFlags=CameraClearFlags.SolidColor;camera.backgroundColor=new Color(.66f,.76f,.81f);camera.depth=50+r.reviewOriginX-20;camera.cullingMask=1<<layer;
camera.transform.position=new Vector3(r.reviewOriginX+1.1f*spacing,r.height*2,-4.5f*spacing);camera.transform.LookAt(new Vector3(r.reviewOriginX,r.height*.7f,0));
camera.orthographic=true;camera.orthographicSize=spacing;camera.nearClipPlane=.05f;camera.farClipPlane=100;
foreach(var t in root.GetComponentsInChildren<Transform>(true))t.gameObject.layer=layer;
RenderSettings.ambientMode=UnityEngine.Rendering.AmbientMode.Trilight;
RenderSettings.ambientSkyColor=new Color(.65f,.72f,.8f);
RenderSettings.ambientEquatorColor=new Color(.45f,.5f,.48f);
RenderSettings.ambientGroundColor=new Color(.3f,.28f,.25f);
EditorSceneManager.SaveScene(scene,r.reviewScene);
var view=SceneView.lastActiveSceneView;if(view!=null){view.LookAt(new Vector3(r.reviewOriginX,.35f,0),Quaternion.Euler(12,-12,0),3.6f);view.Repaint();}
return "Built additive review "+r.reviewScene+"; other scenes preserved";
}
public static string Capture(string recipePath="Config/Art/EnemyCharacters/RockBall.json")
{
var r=Read(recipePath);var camera=GameObject.Find(Prefix(r)+"Camera").GetComponent<Camera>();
var units=new[]{"Idle","Walk","Attack","Cycle"}.Select(n=>GameObject.Find(Prefix(r)+"_"+n)).ToArray();
var cycles=units.Select(g=>g.GetComponent<CharacterReviewAnimCycle>()).ToArray();
var labels=units.Select(g=>g.transform.Find("NameLabel").GetComponent<TextMesh>()).ToArray();
var labelTexts=labels.Select(t=>t.text).ToArray();
var animators=units.Select(g=>g.GetComponentInChildren<Animator>()).ToArray();
var transforms=units.SelectMany(g=>g.GetComponentsInChildren<Transform>()).ToArray();
var p=transforms.Select(t=>t.localPosition).ToArray();var q=transforms.Select(t=>t.localRotation).ToArray();var s=transforms.Select(t=>t.localScale).ToArray();
var rt=new RenderTexture(1900,760,24);var tex=new Texture2D(1900,760,TextureFormat.RGB24,false);var active=RenderTexture.active;
try
{
for(int i=0;i<units.Length;i++)
{
cycles[i].enabled=false;animators[i].enabled=false;
string name=i==0 ? "Idle" : i==1 ? "Walk" : "Attack";
labels[i].text=i==3 ? "Auto cycle" : name+(i==2 ? " (impact)" : " (loop)");
var clip=AssetDatabase.LoadAssetAtPath<AnimationClip>(r.assetRoot+"/Clips/"+name+".anim");
clip.SampleAnimation(animators[i].gameObject,clip.length*(i==0 ? .5f : i==1 ? .25f : r.attackImpact));
}
camera.targetTexture=rt;camera.Render();RenderTexture.active=rt;tex.ReadPixels(new Rect(0,0,1900,760),0,0);tex.Apply();
string path="Assets/Screenshots/"+(Companion(r) ? "companion_" : "enemy_")+r.id.ToLowerInvariant()+"_review.png";
File.WriteAllBytes(Path.Combine(Application.dataPath,"../",path),tex.EncodeToPNG());return path;
}
finally
{
RenderTexture.active=active;camera.targetTexture=null;rt.Release();Object.DestroyImmediate(rt);Object.DestroyImmediate(tex);
for(int i=0;i<transforms.Length;i++){transforms[i].localPosition=p[i];transforms[i].localRotation=q[i];transforms[i].localScale=s[i];}
for(int i=0;i<units.Length;i++){labels[i].text=labelTexts[i];animators[i].enabled=true;cycles[i].enabled=true;}
AssetDatabase.Refresh();
}
}
}
#endif
+273
View File
@@ -0,0 +1,273 @@
using System;
using System.IO;
using System.Collections.Generic;
using System.Linq;
using AIGame.EditorTools;
using HotUpdate.UI;
using Newtonsoft.Json.Linq;
using TMPro;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using System.Reflection;
using System.Threading.Tasks;
/// <summary>Pipeline-only import, JSON-to-Prefab build, isolated rendering and binding checks.</summary>
public static class BuildHomeUi
{
static string Repo => Directory.GetParent(Application.dataPath).Parent.FullName;
const string Folder = "Assets/Bundles/UI/Texture/Home";
static readonly string[] Icons = { "coin", "diamond", "energy", "avatar", "tasks", "gift", "mail", "trophy", "bag", "home", "battle", "pets", "chest", "calendar", "settings", "pinball" };
static readonly string[] Names = { "UI_MainHUD" };
static string Design => Path.Combine(Repo, "Doc/Design/UI/Home");
public static string Build()
{
if (EditorApplication.isPlaying) throw new Exception("Build in Edit Mode.");
Directory.CreateDirectory(Folder);
File.Copy(Path.Combine(Design, "home_camp_source.png"), Folder + "/home_camp.png", true);
var atlas = new Texture2D(2, 2, TextureFormat.RGBA32, false);
try
{
if (!atlas.LoadImage(File.ReadAllBytes(Path.Combine(Design, "home_icons_source.png")))) throw new Exception("Invalid icon sheet.");
for (int i = 0; i < Icons.Length; i++)
{
int col = i % 4, row = 3 - i / 4;
int x = Mathf.RoundToInt(col * atlas.width / 4f), y = Mathf.RoundToInt(row * atlas.height / 4f);
int w = Mathf.RoundToInt((col + 1) * atlas.width / 4f) - x;
int h = Mathf.RoundToInt((row + 1) * atlas.height / 4f) - y;
var icon = new Texture2D(w, h, TextureFormat.RGBA32, false);
try { icon.SetPixels(CleanSlice(atlas.GetPixels(x, y, w, h), w, h)); icon.Apply(); File.WriteAllBytes(Folder + "/home_ico_" + Icons[i] + ".png", icon.EncodeToPNG()); }
finally { UnityEngine.Object.DestroyImmediate(icon); }
}
}
finally { UnityEngine.Object.DestroyImmediate(atlas); }
AssetDatabase.Refresh();
foreach (var path in Directory.GetFiles(Folder, "*.png"))
{
var importer = (TextureImporter)AssetImporter.GetAtPath(path);
importer.textureType = TextureImporterType.Sprite;
importer.spriteImportMode = SpriteImportMode.Single;
importer.spritePixelsPerUnit = 100;
var settings = new TextureImporterSettings(); importer.ReadTextureSettings(settings);
settings.spriteMeshType = SpriteMeshType.FullRect; importer.SetTextureSettings(settings);
importer.alphaIsTransparency = true; importer.mipmapEnabled = false;
importer.textureCompression = TextureImporterCompression.Uncompressed;
importer.filterMode = FilterMode.Bilinear; importer.wrapMode = TextureWrapMode.Clamp;
importer.maxTextureSize = 2048; importer.SaveAndReimport();
}
foreach (var name in Names)
{
var path = Path.Combine(Repo, "Doc/UIPrefabCreater", name + ".json");
var validation = JsonUIPrefabBuilder.Validate(path);
if (!validation.Ok) throw new Exception(string.Join("\n", validation.Errors));
if (string.IsNullOrEmpty(JsonUIPrefabBuilder.BuildFromJsonFile(path))) throw new Exception("Build failed: " + name);
}
return "Imported 17 Home sprites; built Candy home HUD prefab from JSON.";
}
// Atlas extraction: discard isolated fragments spilling in from adjacent cells.
// This preserves original icon pixels and alpha; it does not repaint the source artwork.
static Color[] CleanSlice(Color[] pixels, int width, int height)
{
var visited = new bool[pixels.Length];
var components = new List<List<int>>();
for (int start = 0; start < pixels.Length; start++)
{
if (visited[start] || pixels[start].a < 0.01f) continue;
var queue = new Queue<int>(); var component = new List<int>(); queue.Enqueue(start); visited[start] = true;
while (queue.Count > 0)
{
int p = queue.Dequeue(); component.Add(p);
int px = p % width, py = p / width;
for (int dy = -1; dy <= 1; dy++) for (int dx = -1; dx <= 1; dx++)
{
int nx = px + dx, ny = py + dy;
if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
int next = ny * width + nx;
if (visited[next] || pixels[next].a < 0.01f) continue;
visited[next] = true; queue.Enqueue(next);
}
}
components.Add(component);
}
int largest = components.Count > 0 ? components.Max(c => c.Count) : 0;
foreach (var c in components.Where(c => c.Count < largest * 0.03f))
foreach (int p in c) pixels[p] = Color.clear;
return pixels;
}
public static string Verify()
{
int checks = 0;
Action<bool, string> check = (ok, message) => { if (!ok) throw new Exception(message); checks++; };
foreach (var name in Names)
{
var doc = JObject.Parse(File.ReadAllText(Path.Combine(Repo, "Doc/UIPrefabCreater", name + ".json")));
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/" + name + ".prefab");
check(prefab != null, "Missing prefab " + name);
var scene = EditorSceneManager.NewPreviewScene();
var go = UnityEngine.Object.Instantiate(prefab); SceneManager.MoveGameObjectToScene(go, scene);
try
{
foreach (var path in ((JObject)doc["assets"]["sprites"]).Properties())
check(AssetDatabase.LoadAssetAtPath<Sprite>((string)path.Value) != null, "Missing sprite " + path.Name);
var images = go.GetComponentsInChildren<Image>(true);
check(images.All(i => i.sprite != null), "Unresolved prefab image " + name);
check(go.GetComponentsInChildren<CanvasScaler>().Single().referenceResolution == new Vector2(1080, 1920), "Wrong resolution");
foreach (var b in doc["bindings"])
check(go.GetComponentsInChildren<Transform>(true).Any(t => t.name == (string)b), "Missing binding " + b);
check(!go.GetComponentsInChildren<Transform>(true).Any(t => t.name == "UI_ThemeSwitcher"), "Unexpected theme toolbar");
var view = go.AddComponent<MainHudView>(); view.Bind();
string called = null; int invocations = 0, reconnect = 0;
view.OnHomeActionClicked += k => { called = k; invocations++; };
view.OnReconnectClicked += () => reconnect++;
view.Bind(); // Bind is safe to repeat.
foreach (var e in doc["events"])
{
var button = go.GetComponentsInChildren<Button>(true).Single(b => b.name == (string)e["node"]);
check(button.GetComponent<RectTransform>().sizeDelta.x >= 88 && button.GetComponent<RectTransform>().sizeDelta.y >= 82, "Small touch target " + button.name);
button.onClick.Invoke();
var eventName = (string)e["name"];
if (eventName != "MainHUD.Reconnect")
check(called == eventName.Substring("MainHUD.".Length), "Wrong click callback: " + eventName);
}
check(invocations == 18 && reconnect == 1, "Duplicate or missing event callbacks");
view.SetStatus("已连接"); view.SetPid(10248); view.ShowReconnect(true, "网络已断开,请重试");
check(go.transform.Find("Panel_Reconnect").gameObject.activeSelf, "Reconnect not visible");
check(go.GetComponentsInChildren<TextMeshProUGUI>(true).Single(t => t.name == "Txt_Reconnect").text == "网络已断开,请重试", "Reconnect message missing");
check(go.GetComponentsInChildren<TextMeshProUGUI>(true).Single(t => t.name == "Txt_Status").text == "已连接", "Status binding broken");
view.ShowReconnect(false); check(!go.transform.Find("Panel_Reconnect").gameObject.activeSelf, "Reconnect not hidden");
}
finally { UnityEngine.Object.DestroyImmediate(go); EditorSceneManager.ClosePreviewScene(scene); }
}
foreach (var path in Directory.GetFiles(Folder, "*.png"))
{
var importer = (TextureImporter)AssetImporter.GetAtPath(path);
check(importer.textureType == TextureImporterType.Sprite && !importer.mipmapEnabled && importer.textureCompression == TextureImporterCompression.Uncompressed, "Wrong sprite settings " + path);
}
return checks + " sprite / schema / bindings / touch-target / click / reconnect checks passed.";
}
public static string Preview()
{
foreach (var name in Names) Render(name, 1080, 1920, false);
Render("UI_MainHUD", 1080, 2400, false);
Render("UI_MainHUD", 1080, 1920, true);
return "Rendered Candy, tall-screen and reconnect previews: " + Design;
}
public static string PrepareLive()
{
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);
SessionState.SetInt("AIGame.Home.PreviousGameViewSize", (int)selected.GetValue(window));
var sizesType = editor.GetType("UnityEditor.GameViewSizes");
var sizes = typeof(ScriptableSingleton<>).MakeGenericType(sizesType).GetProperty("instance").GetValue(null);
var getGroup = sizesType.GetMethod("GetGroup");
var groupType = sizesType.GetProperty("currentGroupType").GetValue(sizes);
var group = getGroup.Invoke(sizes, new[] { groupType });
int count = (int)group.GetType().GetMethod("GetTotalCount").Invoke(group, null);
int found = -1;
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) == 1080 && (int)size.GetType().GetProperty("height").GetValue(size) == 1920) { found = i; break; }
}
if (found < 0)
{
var sizeType = editor.GetType("UnityEditor.GameViewSize"); var kind = editor.GetType("UnityEditor.GameViewSizeType");
var size = Activator.CreateInstance(sizeType, new object[] { Enum.ToObject(kind, 1), 1080, 1920, "AIGame Home 1080x1920" });
group.GetType().GetMethod("AddCustomSize").Invoke(group, new[] { size }); found = count;
}
selected.SetValue(window, found); window.Repaint();
return "Game View set to portrait 1080x1920 for live UI validation.";
}
public static string RestoreGameView()
{
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("AIGame.Home.PreviousGameViewSize", 0));
window.Repaint(); return "Restored previous Game View size.";
}
static async Task Until(Func<bool> condition)
{
var start = DateTime.UtcNow;
while (!condition()) { if ((DateTime.UtcNow - start).TotalSeconds > 20) throw new Exception("Live HUD did not become ready."); await Task.Delay(100); }
}
static void Click(Button button)
{
Canvas.ForceUpdateCanvases();
var rect = button.GetComponent<RectTransform>();
var data = new PointerEventData(EventSystem.current) { position = RectTransformUtility.WorldToScreenPoint(null, rect.TransformPoint(rect.rect.center)), button = PointerEventData.InputButton.Left };
var hits = new List<RaycastResult>(); EventSystem.current.RaycastAll(data, hits);
if (hits.Count == 0 || ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject) != button.gameObject) throw new Exception("Button raycast blocked: " + button.name + "; hits=" + string.Join(",", hits.Take(4).Select(h => h.gameObject.name)));
ExecuteEvents.Execute(button.gameObject, data, ExecuteEvents.pointerClickHandler);
}
public static async Task<string> VerifyLive()
{
if (!EditorApplication.isPlaying) throw new Exception("Enter Play Mode first.");
await Until(() => UiManager.Instance != null && UiManager.Instance.Get(ConfirmDialogView.PrefabName) != null);
var hud = UiManager.Instance.Get(MainHudView.PrefabName);
var dialog = UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>();
if (EventSystem.current == null) throw new Exception("No EventSystem.");
var flow = UnityEngine.Object.FindObjectOfType<HotUpdate.Gameplay.PrototypeFlow>();
await Until(() => flow?.Game != null && !flow.IsBusy);
dialog.Hide();
flow.Handle("Home");
await Task.Delay(100);
var entries = new[] { ("Btn_Adventure", "Stages"), ("Btn_Tasks", "Tasks"), ("Btn_Mail", "Mail"), ("Btn_Rewards", "Chest"), ("Btn_NavPets", "Pets"), ("Btn_NavShop", "Shop"), ("Btn_NavBattle", "Stages"), ("Btn_NavPinball", "Pinball"), ("Btn_Settings", "") };
foreach (var entry in entries)
{
Click(hud.GetComponentsInChildren<Button>().Single(b => b.name == entry.Item1));
if (entry.Item2.Length > 0) { await Until(() => flow.Page == "UI_Prototype" + entry.Item2); flow.Handle("Home"); await Until(() => flow.Page == "Home"); }
else { await Until(() => dialog.gameObject.activeSelf); await Task.Delay(100); Click(dialog.GetComponentsInChildren<Button>().Single(b => b.name == "Btn_Ok")); if (dialog.gameObject.activeSelf) throw new Exception("Dialog did not close."); }
await Task.Delay(150);
}
Click(hud.GetComponentsInChildren<Button>().Single(b => b.name == "Btn_NavHome"));
await Until(() => flow.Page == "UI_PrototypeCamp"); flow.Handle("Home");
return "9 home feature entries and camp passed live EventSystem raycasts, authored pages and remaining support dialogs; screen=" + Screen.width + "x" + Screen.height + "; theme=" + UiManager.Instance.Theme;
}
static void Render(string name, int width, int height, bool reconnect)
{
var scene = EditorSceneManager.NewPreviewScene();
var previous = RenderTexture.active;
var rt = new RenderTexture(width, height, 24);
try
{
var cameraGo = new GameObject("HomePreviewCamera", typeof(Camera)); SceneManager.MoveGameObjectToScene(cameraGo, scene);
var camera = cameraGo.GetComponent<Camera>(); camera.scene = scene;
camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = new Color(0.92f, 0.95f, 0.97f);
camera.orthographic = true; camera.orthographicSize = height / 2f; camera.transform.position = new Vector3(0, 0, -100);
camera.targetTexture = rt;
var go = UnityEngine.Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/" + name + ".prefab"));
SceneManager.MoveGameObjectToScene(go, scene);
var canvas = go.GetComponent<Canvas>(); canvas.renderMode = RenderMode.ScreenSpaceCamera; canvas.worldCamera = camera; canvas.planeDistance = 10;
var scaler = go.GetComponent<CanvasScaler>(); scaler.enabled = false; canvas.scaleFactor = 1;
var view = go.AddComponent<MainHudView>(); view.Bind(); view.SetStatus("欢迎回到小镇");
if (reconnect) view.ShowReconnect(true, "网络连接已断开,正在为你恢复连接…");
Canvas.ForceUpdateCanvases();
foreach (var text in go.GetComponentsInChildren<TextMeshProUGUI>(true)) text.ForceMeshUpdate(true);
Canvas.ForceUpdateCanvases(); camera.Render();
RenderTexture.active = rt;
var screenshot = new Texture2D(width, height, TextureFormat.RGB24, false);
try
{
screenshot.ReadPixels(new Rect(0, 0, width, height), 0, 0); screenshot.Apply();
File.WriteAllBytes(Path.Combine(Design, name + (reconnect ? "_reconnect" : height == 2400 ? "_tall" : "") + ".png"), screenshot.EncodeToPNG());
}
finally { UnityEngine.Object.DestroyImmediate(screenshot); }
}
finally { RenderTexture.active = previous; EditorSceneManager.ClosePreviewScene(scene); rt.Release(); UnityEngine.Object.DestroyImmediate(rt); }
}
}
+18
View File
@@ -0,0 +1,18 @@
using System;
using System.IO;
using AIGame.EditorTools;
using UnityEditor;
using UnityEngine;
public static class BuildInventory
{
public static string Build()
{
if(EditorApplication.isPlaying)throw new Exception("Build inventory in Edit Mode");
string repo=Directory.GetParent(Application.dataPath).Parent.FullName;
foreach(string name in new[]{"Bag","Shop"})
if(string.IsNullOrEmpty(JsonUIPrefabBuilder.BuildFromJsonFile(Path.Combine(repo,"Doc/UIPrefabCreater/UI_Prototype"+name+".json"))))throw new Exception("Build inventory failed");
SetupUiBundleCollector.Setup();AssetDatabase.SaveAssets();
return "Built Candy bag and supply shop from JSON, reusing existing portraits/icons and nine-slices.";
}
}
+63
View File
@@ -0,0 +1,63 @@
using System;
using System.IO;
using System.Linq;
using AIGame.EditorTools;
using UnityEditor;
using UnityEngine;
public static class BuildLanes
{
const string Arena = "Assets/Bundles/Scenes/Prototype/PrototypeArena.prefab";
const string Materials = "Assets/Bundles/Scenes/Prototype/Materials/";
static Transform Group(string name, Transform parent)
{
var existing = parent.Find(name); if (existing != null) return existing;
var go = new GameObject(name); go.layer = 30; go.transform.SetParent(parent, false); return go.transform;
}
static Transform Shape(string name, Transform parent, PrimitiveType primitive, Vector3 position, Vector3 scale, string material)
{
var shape = parent.Find(name);
if (shape == null)
{
var go = GameObject.CreatePrimitive(primitive); go.name = name; go.layer = 30; go.transform.SetParent(parent, false); shape = go.transform;
UnityEngine.Object.DestroyImmediate(go.GetComponent<Collider>());
}
shape.localPosition = position; shape.localScale = scale;
var mat = AssetDatabase.LoadAssetAtPath<Material>(Materials + material + ".mat");
if (mat == null) throw new Exception("Missing material " + material);
shape.GetComponent<Renderer>().sharedMaterial = mat; return shape;
}
public static string Build()
{
if (EditorApplication.isPlaying) throw new Exception("Build lanes in Edit Mode");
string repo = Directory.GetParent(Application.dataPath).Parent.FullName;
foreach (string screen in new[] { "Formation", "Pets", "Battle", "Stages" })
if (string.IsNullOrEmpty(JsonUIPrefabBuilder.BuildFromJsonFile(Path.Combine(repo, "Doc/UIPrefabCreater/UI_Prototype" + screen + ".json")))) throw new Exception("Failed " + screen);
var root = PrefabUtility.LoadPrefabContents(Arena);
try
{
var markers = Group("DeploymentMarkers", root.transform); var effects = Group("SkillEffects", root.transform);
for (int slot = 0; slot < 6; slot++)
Shape("Slot" + slot, markers, PrimitiveType.Cylinder, new Vector3(slot / 2 == 0 ? 0 : slot / 2 == 1 ? -3 : 3, .015f, slot % 2 == 0 ? 0 : -2), new Vector3(1.7f, .025f, 1.7f), "ProtoCream");
string[] colors = { "ProtoCyan", "ProtoOrange", "ProtoLeaf", "ProtoYellow", "ProtoWater", "ProtoRock", "ProtoEmber", "ProtoGrass", "ProtoViolet", "ProtoCyan" };
for (int i = 0; i < 10; i++)
{
Shape("Skill" + i, effects, PrimitiveType.Cylinder, Vector3.zero, new Vector3(1.5f, .04f, 1.5f), colors[i]).gameObject.SetActive(false);
Shape("Shield" + i, effects, PrimitiveType.Cylinder, Vector3.zero, new Vector3(1.9f, .06f, 1.9f), "ProtoCyan").gameObject.SetActive(false);
var crest = Shape("T2Crest", root.transform.Find("Heroes/Hero" + i), PrimitiveType.Cube, new Vector3(0, 2.0f, 0), Vector3.one * .24f, "ProtoYellow");
crest.localRotation = Quaternion.Euler(0, 45, 45); crest.gameObject.SetActive(false);
}
for (int i = 0; i < 9; i++)
{
var mark = root.transform.Find("Enemies/Enemy" + i);
var slow = Shape("SlowMark", mark, PrimitiveType.Cylinder, new Vector3(0, .015f, 0), new Vector3(1.1f, .03f, 1.1f), "ProtoWater"); slow.gameObject.SetActive(false);
}
foreach (Transform child in root.transform.Cast<Transform>().Where(t => t.name == "Lane" || t.name.StartsWith("LaneRoad")))
child.GetComponent<Renderer>().sharedMaterial = AssetDatabase.LoadAssetAtPath<Material>(Materials + "ProtoPath.mat");
PrefabUtility.SaveAsPrefabAsset(root, Arena);
}
finally { PrefabUtility.UnloadPrefabContents(root); }
SetupUiBundleCollector.Setup(); AssetDatabase.SaveAssets();
return "Built four Candy pages, authored drag handles/ghost, three-lane markers, ten skill/shield slots and T2 crests.";
}
}
+17
View File
@@ -0,0 +1,17 @@
using System;
using System.IO;
using AIGame.EditorTools;
using UnityEditor;
using UnityEngine;
public static class BuildMail
{
public static string Build()
{
if (EditorApplication.isPlaying) throw new Exception("Build mail in Edit Mode");
string repo = Directory.GetParent(Application.dataPath).Parent.FullName;
if (string.IsNullOrEmpty(JsonUIPrefabBuilder.BuildFromJsonFile(Path.Combine(repo, "Doc/UIPrefabCreater/UI_PrototypeMail.json")))) throw new Exception("Mail prefab failed");
SetupUiBundleCollector.Setup(); AssetDatabase.SaveAssets();
return "Built Candy mail list, paged rows, scrollable details and claim buttons from JSON.";
}
}
@@ -0,0 +1,76 @@
using System;
using System.IO;
using HotUpdate.Gameplay;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using YooAsset.Editor;
public static class BuildMainMenuMusic
{
public const string PrefabPath = "Assets/Bundles/Sound/Prefab/MainMenuMusic.prefab";
const string ClipPath = "Assets/Bundles/Sound/BGM/MeadowBreeze_MainMenu_Loop.ogg";
public static string Build()
{
if (EditorApplication.isPlaying) throw new Exception("Build music in Edit Mode");
var clip = AssetDatabase.LoadAssetAtPath<AudioClip>(ClipPath);
if (clip == null) throw new Exception("Selected BGM missing");
if (!AssetDatabase.IsValidFolder("Assets/Bundles/Sound/Prefab"))
AssetDatabase.CreateFolder("Assets/Bundles/Sound", "Prefab");
var original = SceneManager.GetActiveScene();
var temporary = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive);
try
{
SceneManager.SetActiveScene(temporary);
var root = new GameObject(MainMenuMusic.PrefabName);
var source = root.AddComponent<AudioSource>();
source.clip = clip;
source.playOnAwake = false;
source.loop = true;
source.spatialBlend = 0;
source.volume = 1;
var player = root.AddComponent<MainMenuMusic>();
var serialized = new SerializedObject(player);
serialized.FindProperty("_source").objectReferenceValue = source;
serialized.ApplyModifiedPropertiesWithoutUndo();
PrefabUtility.SaveAsPrefabAsset(root, PrefabPath);
}
finally
{
SceneManager.SetActiveScene(original);
EditorSceneManager.CloseScene(temporary, true);
}
var package = BundleCollectorSettingData.Setting.Packages.Find(p => p.PackageName == "Art");
var group = package?.Groups.Find(g => g.GroupName == "Sound");
if (group == null) throw new Exception("Existing Art/Sound group missing");
const string folder = "Assets/Bundles/Sound/Prefab";
if (!group.Collectors.Exists(c => c.CollectPath == folder))
BundleCollectorSettingData.CreateCollector(group, new BundleCollector {
CollectPath = folder, CollectorGUID = AssetDatabase.AssetPathToGUID(folder),
CollectorType = ECollectorType.MainAssetCollector,
AddressRuleName = nameof(AddressByFileName), PackRuleName = nameof(PackSeparately),
FilterRuleName = nameof(CollectAll), AssetTags = "art"
});
package.CheckConfigError();
BundleCollectorSettingData.SaveFile();
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
var audio = prefab.GetComponent<AudioSource>();
if (prefab.GetComponent<MainMenuMusic>()?.Source != audio || audio.clip != clip ||
!audio.loop || audio.playOnAwake || audio.spatialBlend != 0 || audio.volume != 1)
throw new Exception("Music prefab configuration invalid");
string report = JsonConvert.SerializeObject(new {
selected = ClipPath, prefab = PrefabPath, address = MainMenuMusic.PrefabName,
package = "Art", group = "Sound", loop = audio.loop, volume = audio.volume,
seconds = clip.length, clip.channels, clip.frequency,
independentMusicToggle = true, runtimePlaybackWired = true,
bundleBuilt = false, verifiedUtc = DateTime.UtcNow.ToString("o")
}, Formatting.Indented);
string repo = Directory.GetParent(Application.dataPath).Parent.FullName;
File.WriteAllText(Path.Combine(repo, "Doc/Design/Sound/MainMenuSoft/playback_assets.json"), report);
return report;
}
}
+147
View File
@@ -0,0 +1,147 @@
#if UNITY_EDITOR
using System.IO;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;
/// <summary>
/// 把 LmGirl / LmCat 摆进同一观察场景。正面为局部 -Z,相机在 -Z 侧。
/// unity command run_script --file AgentScripts/BuildModelView.cs --entry BuildModelView.Run
/// </summary>
public static class BuildModelView
{
const string SceneDir = "Assets/Bundles/Scenes/Test/ModelView";
const string ScenePath = SceneDir + "/ModelView.unity";
const string GroundMatPath = SceneDir + "/Ground.mat";
const string GirlPath = "Assets/Bundles/Characters/Human/LmGirl/LmGirl.prefab";
const string CatPath = "Assets/Bundles/Characters/Animal/LmCat/LmCat.prefab";
public static string Run()
{
var girl = AssetDatabase.LoadAssetAtPath<GameObject>(GirlPath);
var cat = AssetDatabase.LoadAssetAtPath<GameObject>(CatPath);
if (girl == null)
return "missing " + GirlPath;
if (cat == null)
return "missing " + CatPath;
Directory.CreateDirectory(SceneDir);
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var lightGo = new GameObject("Directional Light");
var light = lightGo.AddComponent<Light>();
light.type = LightType.Directional;
light.color = new Color(1f, 0.96f, 0.9f);
light.intensity = 1.2f;
light.shadows = LightShadows.Soft;
lightGo.transform.rotation = Quaternion.Euler(48f, -25f, 0f);
var groundMat = AssetDatabase.LoadAssetAtPath<Material>(GroundMatPath);
if (groundMat == null)
{
var shader = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
groundMat = new Material(shader);
groundMat.color = new Color(0.82f, 0.84f, 0.86f);
AssetDatabase.CreateAsset(groundMat, GroundMatPath);
}
var ground = GameObject.CreatePrimitive(PrimitiveType.Plane);
ground.name = "Ground";
ground.transform.localScale = new Vector3(14f, 1f, 14f);
ground.GetComponent<MeshRenderer>().sharedMaterial = groundMat;
var houses = PlaceHouses();
var girlGo = (GameObject)PrefabUtility.InstantiatePrefab(girl);
girlGo.name = "LmGirl";
girlGo.transform.SetPositionAndRotation(new Vector3(-1.6f, 0f, -36f), Quaternion.identity);
var catGo = (GameObject)PrefabUtility.InstantiatePrefab(cat);
catGo.name = "LmCat";
catGo.transform.SetPositionAndRotation(new Vector3(1.6f, 0f, -36f), Quaternion.identity);
var bounds = RendererBounds(girlGo);
bounds.Encapsulate(RendererBounds(catGo));
foreach (var house in houses)
bounds.Encapsulate(RendererBounds(house));
var camGo = new GameObject("Main Camera");
camGo.tag = "MainCamera";
var cam = camGo.AddComponent<Camera>();
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = new Color(0.93f, 0.94f, 0.95f);
cam.fieldOfView = 32f;
cam.nearClipPlane = 0.1f;
cam.farClipPlane = 250f;
camGo.AddComponent<AudioListener>();
var center = bounds.center;
var halfFov = cam.fieldOfView * 0.5f * Mathf.Deg2Rad;
var distY = bounds.size.y / (2f * Mathf.Tan(halfFov) * 0.62f);
var aspect = 16f / 9f;
var halfFovX = Mathf.Atan(Mathf.Tan(halfFov) * aspect);
var distX = bounds.size.x / (2f * Mathf.Tan(halfFovX) * 0.72f);
var dist = Mathf.Max(distY, distX);
var camPos = center + new Vector3(0f, bounds.size.z * 0.36f, -dist * 1.08f);
camGo.transform.position = camPos;
camGo.transform.LookAt(center);
RenderSettings.ambientMode = AmbientMode.Trilight;
RenderSettings.ambientSkyColor = new Color(0.55f, 0.58f, 0.62f);
RenderSettings.ambientEquatorColor = new Color(0.42f, 0.42f, 0.42f);
RenderSettings.ambientGroundColor = new Color(0.28f, 0.28f, 0.28f);
EditorSceneManager.SaveScene(scene, ScenePath);
return $"scene={ScenePath} houses={houses.Count} girlH={Height(girlGo):F2} catH={Height(catGo):F2} cam={camPos}";
}
static System.Collections.Generic.List<GameObject> PlaceHouses()
{
var names = new[]
{
"cream_tile", "cream_red", "white_balcony", "brown_wood",
"cream_porch", "stone_balcony", "thatched", "white_blue",
"wood_fence", "shop_awning", "picket", "gate",
};
var placed = new System.Collections.Generic.List<GameObject>();
const float gap = 22f;
for (var i = 0; i < names.Length; i++)
{
var path = $"Assets/Bundles/Scenes/Test/LmHouses/{names[i]}/{names[i]}.prefab";
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (prefab == null)
continue;
var col = i % 4;
var row = i / 4;
var go = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
go.name = names[i];
go.transform.SetPositionAndRotation(
new Vector3((col - 1.5f) * gap, 0f, (row - 1f) * gap),
Quaternion.identity);
placed.Add(go);
}
return placed;
}
static Bounds RendererBounds(GameObject root)
{
Bounds bounds = default;
var has = false;
foreach (var r in root.GetComponentsInChildren<Renderer>())
{
if (!has)
{
bounds = r.bounds;
has = true;
}
else bounds.Encapsulate(r.bounds);
}
return has ? bounds : new Bounds(root.transform.position, Vector3.one);
}
static float Height(GameObject root)
{
return RendererBounds(root).size.y;
}
}
#endif
+21
View File
@@ -0,0 +1,21 @@
using System;
using System.IO;
using AIGame.EditorTools;
using UnityEditor;
using UnityEngine;
public static class BuildProgression
{
public static string Build()
{
if (EditorApplication.isPlaying) throw new Exception("Build UI in Edit Mode");
string repo = Directory.GetParent(Application.dataPath).Parent.FullName;
foreach (var screen in new[] { "Stages", "Pinball", "Capture", "Pets", "Formation", "Battle", "Camp", "Build", "Tasks", "Signin", "Bag", "Shop", "Mail", "Chest", "Settings" })
{
string output = JsonUIPrefabBuilder.BuildFromJsonFile(Path.Combine(repo, "Doc/UIPrefabCreater/UI_Prototype" + screen + ".json"));
if (string.IsNullOrEmpty(output)) throw new Exception("Failed UI_Prototype" + screen);
}
SetupUiBundleCollector.Setup();
return "Generated sixteen gameplay UI prefabs from JSON; reused authored models, sprites and nine-slices.";
}
}
+288
View File
@@ -0,0 +1,288 @@
using System;
using System.IO;
using System.Linq;
using AIGame.EditorTools;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using YooAsset.Editor;
/// <summary>Pipeline authoring for G0 assets. No scene or prefab YAML is written by external tools.</summary>
public static class BuildPrototype
{
const string World = "Assets/Bundles/Scenes/Prototype";
const string Pictures = "Assets/Bundles/UI/Texture/Prototype";
static string Repo => Directory.GetParent(Application.dataPath).Parent.FullName;
public static readonly string[] Screens = { "Stages", "Pinball", "Capture", "Pets", "Formation", "Battle" };
const int PetCount = 10;
static Material _cream, _rock, _orange, _leaf, _dark, _white, _cyan, _grass, _path, _monster, _yellow, _water, _ember, _violet;
static Material Material(string name, string color)
{
string path = World + "/Materials/" + name + ".mat";
var material = AssetDatabase.LoadAssetAtPath<Material>(path);
if (material == null)
{
material = new Material(Shader.Find("Universal Render Pipeline/Lit")); AssetDatabase.CreateAsset(material, path);
}
ColorUtility.TryParseHtmlString(color, out var c); material.SetColor("_BaseColor", c); material.SetFloat("_Smoothness", .22f);
EditorUtility.SetDirty(material); AssetDatabase.SaveAssetIfDirty(material); return material;
}
static Transform Group(string name, Transform parent)
{
var go = new GameObject(name); go.layer = 30; go.transform.SetParent(parent, false); return go.transform;
}
static Transform Shape(string name, Transform parent, PrimitiveType type, Vector3 position, Vector3 scale, Material material)
{
var go = GameObject.CreatePrimitive(type); go.name = name; go.layer = 30; go.transform.SetParent(parent, false);
go.transform.localPosition = position; go.transform.localScale = scale; go.GetComponent<Renderer>().sharedMaterial = material;
UnityEngine.Object.DestroyImmediate(go.GetComponent<Collider>()); return go.transform;
}
static void Face(Transform root, float y, float z, float spacing)
{
foreach (float x in new[] { -spacing, spacing })
{
Shape("EyeWhite", root, PrimitiveType.Sphere, new Vector3(x, y, z), Vector3.one * .21f, _white);
Shape("Pupil", root, PrimitiveType.Sphere, new Vector3(x, y, z - .1f), Vector3.one * .1f, _dark);
}
}
static Transform Pet(int id, Transform parent, string name)
{
var root = Group(name, parent);
if (id == 0)
{
Shape("Body", root, PrimitiveType.Sphere, new Vector3(0, .7f, 0), new Vector3(1.3f, 1.3f, 1), _rock);
foreach (int side in new[] { -1, 1 })
{
Shape("Arm", root, PrimitiveType.Cube, new Vector3(side * .77f, .65f, 0), new Vector3(.4f, .65f, .55f), _rock);
Shape("Foot", root, PrimitiveType.Sphere, new Vector3(side * .34f, .15f, -.1f), new Vector3(.5f, .3f, .6f), _cream);
}
Shape("Crystal", root, PrimitiveType.Cube, new Vector3(0, 1.4f, 0), new Vector3(.32f, .4f, .32f), _cyan).localRotation = Quaternion.Euler(0, 45, 12);
Face(root, .9f, -.48f, .24f);
}
else if (id == 1)
{
Shape("Body", root, PrimitiveType.Sphere, new Vector3(0, .47f, 0), new Vector3(.8f, .9f, .7f), _orange);
Shape("Head", root, PrimitiveType.Sphere, new Vector3(0, 1.08f, -.06f), new Vector3(1.04f, .86f, .82f), _orange);
Shape("Muzzle", root, PrimitiveType.Sphere, new Vector3(0, .92f, -.39f), new Vector3(.6f, .34f, .24f), _cream);
foreach (int side in new[] { -1, 1 })
{
Shape("Ear", root, PrimitiveType.Cube, new Vector3(side * .32f, 1.52f, -.03f), new Vector3(.28f, .48f, .26f), _orange).localRotation = Quaternion.Euler(0, 0, side * -18);
Shape("Foot", root, PrimitiveType.Sphere, new Vector3(side * .24f, .12f, -.14f), new Vector3(.32f, .24f, .45f), _cream);
}
Shape("Tail", root, PrimitiveType.Capsule, new Vector3(.5f, .5f, .25f), new Vector3(.2f, .5f, .2f), _orange).localRotation = Quaternion.Euler(15, 0, -45);
Shape("Scarf", root, PrimitiveType.Cube, new Vector3(0, .73f, -.32f), new Vector3(.65f, .14f, .2f), _cyan);
Face(root, 1.15f, -.43f, .23f);
}
else if (id == 2)
{
Shape("Pot", root, PrimitiveType.Cylinder, new Vector3(0, .25f, 0), new Vector3(.85f, .25f, .85f), _cream);
Shape("Stem", root, PrimitiveType.Cylinder, new Vector3(0, .66f, 0), new Vector3(.16f, .4f, .16f), _leaf);
for (int i = 0; i < 5; i++)
{
float a = i * Mathf.PI * 2 / 5;
Shape("Petal", root, PrimitiveType.Sphere, new Vector3(Mathf.Cos(a) * .43f, 1.14f + Mathf.Sin(a) * .43f, 0), new Vector3(.58f, .58f, .25f), _leaf);
}
Shape("Face", root, PrimitiveType.Sphere, new Vector3(0, 1.14f, -.12f), new Vector3(.7f, .7f, .35f), _cream);
Face(root, 1.22f, -.29f, .16f);
}
else if (id == 3)
{
Shape("Body", root, PrimitiveType.Sphere, new Vector3(0, .65f, 0), new Vector3(.9f, 1.05f, .8f), _yellow);
Shape("Head", root, PrimitiveType.Sphere, new Vector3(0, 1.23f, -.06f), new Vector3(1, .88f, .85f), _yellow);
Shape("Beak", root, PrimitiveType.Cube, new Vector3(0, 1.1f, -.54f), new Vector3(.23f, .16f, .3f), _orange);
foreach (int side in new[] { -1, 1 })
{
Shape("Wing", root, PrimitiveType.Sphere, new Vector3(side * .53f, .75f, .03f), new Vector3(.24f, .72f, .5f), _cyan).localRotation = Quaternion.Euler(0, 0, side * -25);
Shape("Foot", root, PrimitiveType.Sphere, new Vector3(side * .22f, .1f, -.12f), new Vector3(.28f, .2f, .46f), _orange);
}
Shape("LightningCrest", root, PrimitiveType.Cube, new Vector3(0, 1.77f, -.02f), new Vector3(.16f, .4f, .18f), _cyan).localRotation = Quaternion.Euler(0, 0, 22);
Face(root, 1.3f, -.45f, .23f);
}
else if (id == 4)
{
Shape("Shell", root, PrimitiveType.Sphere, new Vector3(0, .52f, .12f), new Vector3(1.35f, .92f, 1.5f), _water);
Shape("ShellTop", root, PrimitiveType.Sphere, new Vector3(0, .72f, .16f), new Vector3(1.12f, .64f, 1.15f), _cyan);
Shape("Head", root, PrimitiveType.Sphere, new Vector3(0, .58f, -.75f), new Vector3(.74f, .72f, .68f), _water);
foreach (int side in new[] { -1, 1 }) foreach (int end in new[] { -1, 1 })
Shape("Foot", root, PrimitiveType.Sphere, new Vector3(side * .53f, .14f, end * .5f), new Vector3(.4f, .28f, .5f), _cream);
Shape("Tail", root, PrimitiveType.Capsule, new Vector3(0, .35f, .96f), new Vector3(.18f, .28f, .18f), _water).localRotation = Quaternion.Euler(90, 0, 0);
Face(root, .68f, -1.04f, .18f);
}
else if (id == 5)
{
Shape("Body", root, PrimitiveType.Sphere, new Vector3(0, .78f, 0), new Vector3(1.5f, 1.1f, 1.1f), _rock);
Shape("Horn", root, PrimitiveType.Cube, new Vector3(0, 1.42f, -.42f), new Vector3(.3f, .5f, .26f), _cream).localRotation = Quaternion.Euler(18, 0, 0);
foreach (int side in new[] { -1, 1 })
{
Shape("Leg", root, PrimitiveType.Cube, new Vector3(side * .52f, .22f, .1f), new Vector3(.38f, .48f, .44f), _rock);
Shape("Plate", root, PrimitiveType.Cube, new Vector3(side * .38f, 1.16f, .1f), new Vector3(.44f, .2f, .62f), _cyan);
}
Shape("Tail", root, PrimitiveType.Capsule, new Vector3(0, .62f, .82f), new Vector3(.2f, .34f, .2f), _rock).localRotation = Quaternion.Euler(80, 0, 0);
Face(root, .92f, -.58f, .26f);
}
else if (id == 6)
{
Shape("Body", root, PrimitiveType.Sphere, new Vector3(0, .58f, .05f), new Vector3(.95f, .95f, .85f), _ember);
Shape("Head", root, PrimitiveType.Sphere, new Vector3(0, 1.26f, -.12f), new Vector3(1.05f, .92f, .9f), _orange);
for (int i = 0; i < 7; i++)
{
float a = -1.05f + i * .35f;
Shape("Mane", root, PrimitiveType.Sphere, new Vector3(Mathf.Sin(a) * .72f, 1.26f + Mathf.Cos(a) * .72f, .12f), new Vector3(.34f, .34f, .3f), _ember);
}
foreach (int side in new[] { -1, 1 })
{
Shape("Ear", root, PrimitiveType.Cube, new Vector3(side * .4f, 1.78f, -.1f), new Vector3(.22f, .3f, .2f), _orange).localRotation = Quaternion.Euler(0, 0, side * -14);
Shape("Paw", root, PrimitiveType.Sphere, new Vector3(side * .3f, .12f, -.2f), new Vector3(.34f, .24f, .44f), _cream);
}
Shape("Tail", root, PrimitiveType.Capsule, new Vector3(0, .52f, .78f), new Vector3(.2f, .5f, .2f), _ember).localRotation = Quaternion.Euler(60, 0, 0);
Face(root, 1.32f, -.52f, .24f);
}
else if (id == 7)
{
Shape("Stem", root, PrimitiveType.Cylinder, new Vector3(0, .72f, 0), new Vector3(.22f, .62f, .22f), _leaf);
Shape("Bud", root, PrimitiveType.Sphere, new Vector3(0, 1.5f, 0), new Vector3(.86f, .8f, .78f), _leaf);
for (int i = 0; i < 4; i++)
{
float a = i * Mathf.PI / 2 + .4f;
Shape("Vine", root, PrimitiveType.Capsule, new Vector3(Mathf.Cos(a) * .72f, 1.1f, Mathf.Sin(a) * .72f), new Vector3(.12f, .5f, .12f), _leaf).localRotation = Quaternion.Euler(28, -a * 57.3f, 0);
Shape("Leaf", root, PrimitiveType.Sphere, new Vector3(Mathf.Cos(a) * .95f, .78f, Mathf.Sin(a) * .95f), new Vector3(.5f, .16f, .36f), _grass);
}
Shape("Face", root, PrimitiveType.Sphere, new Vector3(0, 1.52f, -.32f), new Vector3(.56f, .56f, .24f), _cream);
Face(root, 1.58f, -.48f, .14f);
}
else if (id == 8)
{
Shape("Body", root, PrimitiveType.Sphere, new Vector3(0, .6f, .05f), new Vector3(1.35f, .86f, .72f), _violet);
Shape("Head", root, PrimitiveType.Sphere, new Vector3(0, 1.06f, -.68f), new Vector3(.82f, .74f, .78f), _violet);
foreach (int side in new[] { -1, 1 })
{
Shape("Ear", root, PrimitiveType.Cube, new Vector3(side * .3f, 1.5f, -.66f), new Vector3(.2f, .34f, .18f), _violet).localRotation = Quaternion.Euler(0, 0, side * -10);
foreach (int end in new[] { -1, 1 })
Shape("Leg", root, PrimitiveType.Capsule, new Vector3(side * .46f, .24f, end * .42f), new Vector3(.16f, .3f, .16f), _violet);
Shape("Bolt", root, PrimitiveType.Cube, new Vector3(side * .62f, 1.02f, -.1f), new Vector3(.16f, .36f, .14f), _cyan).localRotation = Quaternion.Euler(0, 0, side * 26);
}
Shape("Tail", root, PrimitiveType.Capsule, new Vector3(0, .82f, .78f), new Vector3(.14f, .56f, .14f), _violet).localRotation = Quaternion.Euler(58, 0, 0);
Face(root, 1.14f, -1.02f, .2f);
}
else if (id == 9)
{
Shape("Body", root, PrimitiveType.Sphere, new Vector3(0, .7f, .1f), new Vector3(1.15f, 1.1f, 1.85f), _water);
Shape("Belly", root, PrimitiveType.Sphere, new Vector3(0, .48f, -.05f), new Vector3(.86f, .62f, 1.4f), _cream);
Shape("Fin", root, PrimitiveType.Cube, new Vector3(0, 1.42f, .2f), new Vector3(.16f, .5f, .62f), _cyan).localRotation = Quaternion.Euler(-18, 0, 0);
foreach (int side in new[] { -1, 1 })
{
Shape("Flipper", root, PrimitiveType.Cube, new Vector3(side * .82f, .5f, -.1f), new Vector3(.44f, .14f, .5f), _cyan).localRotation = Quaternion.Euler(0, 0, side * -22);
Shape("Spout", root, PrimitiveType.Cylinder, new Vector3(side * .2f, 1.28f, -.7f), new Vector3(.1f, .24f, .1f), _cyan);
}
Face(root, .9f, -1.68f, .22f);
}
return root;
}
static void SetupMaterials()
{
Directory.CreateDirectory(World + "/Materials"); Directory.CreateDirectory(Pictures); AssetDatabase.Refresh();
_cream = Material("ProtoCream", "#F6EBCF"); _rock = Material("ProtoRock", "#879BC0");
_orange = Material("ProtoOrange", "#F5A144"); _leaf = Material("ProtoLeaf", "#7BC95A");
_dark = Material("ProtoDark", "#283A44"); _white = Material("ProtoWhite", "#FFFFFF");
_cyan = Material("ProtoCyan", "#42BED7"); _grass = Material("ProtoGrass", "#92C989");
_path = Material("ProtoPath", "#DCCDA9"); _monster = Material("ProtoMonster", "#BA77CB");
_yellow = Material("ProtoYellow", "#F6D553"); _water = Material("ProtoWater", "#69C8DF");
_ember = Material("ProtoEmber", "#E86A3C"); _violet = Material("ProtoViolet", "#9B7BD4");
}
static void MakeArena()
{
var scene = EditorSceneManager.NewPreviewScene(); var root = new GameObject("PrototypeArena");
SceneManager.MoveGameObjectToScene(root, scene); root.layer = 30;
try
{
Shape("Ground", root.transform, PrimitiveType.Cube, new Vector3(0, -.3f, 4), new Vector3(10, .4f, 20), _grass);
foreach (float x in new[] { -3f, 0f, 3f })
Shape("Lane", root.transform, PrimitiveType.Cube, new Vector3(x, -.065f, 3.5f), new Vector3(2.3f, .06f, 17), x == 0 ? _path : _rock);
Shape("DefenceLine", root.transform, PrimitiveType.Cube, new Vector3(0, .05f, -4), new Vector3(9.6f, .15f, .2f), _cyan);
for (int i = 0; i < 7; i++)
{
Shape("LeftBorder", root.transform, PrimitiveType.Sphere, new Vector3(-4.65f, .1f, -3 + i * 2.7f), new Vector3(.7f, .6f, .85f), _leaf);
Shape("RightBorder", root.transform, PrimitiveType.Sphere, new Vector3(4.65f, .1f, -3 + i * 2.7f), new Vector3(.7f, .6f, .85f), _leaf);
}
var heroes = Group("Heroes", root.transform);
for (int i = 0; i < PetCount; i++)
{
var pet = Pet(i, heroes, "Hero" + i); pet.localRotation = Quaternion.Euler(0, 180, 0); pet.gameObject.SetActive(false);
}
var enemies = Group("Enemies", root.transform);
for (int i = 0; i < 9; i++)
{
var enemy = Group("Enemy" + i, enemies);
Shape("Body", enemy, PrimitiveType.Sphere, new Vector3(0, .48f, 0), new Vector3(.85f, .95f, .85f), _monster);
Shape("Horn", enemy, PrimitiveType.Cube, new Vector3(0, 1.02f, 0), new Vector3(.2f, .35f, .2f), _cream).localRotation = Quaternion.Euler(0, 0, 20);
Face(enemy, .65f, -.38f, .17f); enemy.gameObject.SetActive(false);
}
var projectiles = Group("Projectiles", root.transform);
for (int i = 0; i < PetCount; i++)
Shape("Shot" + i, projectiles, PrimitiveType.Sphere, Vector3.zero, Vector3.one * .26f, i == 2 ? _leaf : _orange).gameObject.SetActive(false);
var camera = Group("BattleCamera", root.transform).gameObject.AddComponent<Camera>();
camera.depth = 20; camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = new Color(.85f, .94f, .81f);
camera.orthographic = true; camera.orthographicSize = 9.5f; camera.nearClipPlane = .1f; camera.farClipPlane = 100;
camera.cullingMask = 1 << 30; camera.transform.position = new Vector3(0, 16, -11); camera.transform.LookAt(new Vector3(0, 0, 4));
var light = Group("BattleLight", root.transform).gameObject.AddComponent<Light>();
light.type = LightType.Directional; light.intensity = 1; light.cullingMask = 1 << 30; light.transform.rotation = Quaternion.Euler(45, -30, 0);
PrefabUtility.SaveAsPrefabAsset(root, World + "/PrototypeArena.prefab");
}
finally { UnityEngine.Object.DestroyImmediate(root); EditorSceneManager.ClosePreviewScene(scene); }
}
static void Portrait(int id)
{
var scene = EditorSceneManager.NewPreviewScene(); var previous = RenderTexture.active;
var rt = new RenderTexture(256, 256, 24, RenderTextureFormat.ARGB32);
try
{
var root = new GameObject("PortraitRoot"); SceneManager.MoveGameObjectToScene(root, scene);
Pet(id, root.transform, "PortraitPet");
var camera = Group("PortraitCamera", root.transform).gameObject.AddComponent<Camera>(); camera.scene = scene;
camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = Color.clear; camera.cullingMask = 1 << 30;
camera.orthographic = true; camera.orthographicSize = id == 3 ? 1.22f : id >= 9 ? 1.34f : 1.08f; camera.allowHDR = false;
camera.transform.position = new Vector3(1.6f, 1.55f, -4); camera.transform.LookAt(new Vector3(0, id == 3 ? .94f : id >= 5 ? .86f : .82f, 0)); camera.targetTexture = rt;
var light = Group("PortraitLight", root.transform).gameObject.AddComponent<Light>(); light.type = LightType.Directional;
light.intensity = 1.15f; light.cullingMask = 1 << 30; light.transform.rotation = Quaternion.Euler(35, -35, 0);
camera.Render(); RenderTexture.active = rt;
var texture = new Texture2D(256, 256, TextureFormat.RGBA32, false);
try { texture.ReadPixels(new Rect(0, 0, 256, 256), 0, 0); texture.Apply(); File.WriteAllBytes(Pictures + "/proto_pet_" + id + ".png", texture.EncodeToPNG()); }
finally { UnityEngine.Object.DestroyImmediate(texture); }
}
finally { RenderTexture.active = previous; EditorSceneManager.ClosePreviewScene(scene); rt.Release(); UnityEngine.Object.DestroyImmediate(rt); }
string path = Pictures + "/proto_pet_" + id + ".png";
AssetDatabase.ImportAsset(path);
var importer = (TextureImporter)AssetImporter.GetAtPath(path); importer.textureType = TextureImporterType.Sprite;
importer.spriteImportMode = SpriteImportMode.Single; importer.mipmapEnabled = false; importer.alphaIsTransparency = true;
importer.textureCompression = TextureImporterCompression.Uncompressed; importer.filterMode = FilterMode.Bilinear;
var settings = new TextureImporterSettings(); importer.ReadTextureSettings(settings); settings.spriteMeshType = SpriteMeshType.FullRect; importer.SetTextureSettings(settings); importer.SaveAndReimport();
}
public static string Build()
{
if (EditorApplication.isPlaying) throw new Exception("Build G0 assets in Edit Mode.");
SetupMaterials(); MakeArena(); for (int i = 0; i < PetCount; i++) Portrait(i);
UiSpriteNineSliceRules.ApplyExisting();
foreach (var screen in Screens)
{
var output = JsonUIPrefabBuilder.BuildFromJsonFile(Path.Combine(Repo, "Doc/UIPrefabCreater/UI_Prototype" + screen + ".json"));
if (string.IsNullOrEmpty(output)) throw new Exception("Failed to build " + screen);
}
var home = JsonUIPrefabBuilder.BuildFromJsonFile(Path.Combine(Repo, "Doc/UIPrefabCreater/UI_MainHUD.json"));
if (string.IsNullOrEmpty(home)) throw new Exception("Failed to build home.");
SetupUiBundleCollector.Setup();
var package = BundleCollectorSettingData.Setting.GetPackage("Art");
var group = package.Groups.FirstOrDefault(g => g.GroupName == "GameplayPrototype") ?? BundleCollectorSettingData.CreateGroup(package, "GameplayPrototype");
group.GroupDesc = "G0 teaching arena"; group.ActiveRuleName = nameof(EnableGroup); BundleCollectorSettingData.ModifyGroup(package, group);
if (!group.Collectors.Any(c => c.CollectPath == World))
BundleCollectorSettingData.CreateCollector(group, new BundleCollector { CollectPath = World, CollectorGUID = AssetDatabase.AssetPathToGUID(World), CollectorType = ECollectorType.MainAssetCollector, AddressRuleName = nameof(AddressByFileName), PackRuleName = nameof(PackSeparately), FilterRuleName = nameof(CollectPrefab) });
package.CheckConfigError(); BundleCollectorSettingData.SaveFile();
return "Built authored arena with ten pets, ten portraits and six JSON UI prefabs; updated Art collectors.";
}
public static string RefreshNewPortraits()
{
if (EditorApplication.isPlaying) throw new Exception("Render portraits in Edit Mode");
SetupMaterials(); for (int i = 3; i < PetCount; i++) Portrait(i); return "Rendered portraits for pets 3-9.";
}
}
+240
View File
@@ -0,0 +1,240 @@
#if UNITY_EDITOR
using System;
using System.IO;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
[Serializable]
public sealed class SceneReviewManifest
{
public int schemaVersion;
public string sceneId;
public string sourcePrefab;
public string outputScene;
public string reportPath;
public string label;
public SceneReviewVec3 cameraPosition;
public SceneReviewVec3 cameraTarget;
public float fieldOfView = 45f;
}
[Serializable]
public sealed class SceneReviewVec3
{
public float x;
public float y;
public float z;
public Vector3 Value => new Vector3(x, y, z);
}
[Serializable]
public sealed class SceneReviewReport
{
public int schemaVersion = 1;
public string sceneId;
public string sourcePrefab;
public string outputScene;
public string generatedAtUtc;
public string[] reviewSteps;
}
/// <summary>
/// Creates a persistent editor scene from an already-authored assembly prefab.
/// This is an authoring/review step; no scene objects are created by runtime code.
/// </summary>
public static class BuildSceneReview
{
const string ManifestPath = "Doc/Design/Scene/scene_review_manifest.json";
static string RepoRoot => Directory.GetParent(Application.dataPath).Parent.FullName;
static SceneReviewManifest ReadManifest()
{
var path = Path.GetFullPath(Path.Combine(RepoRoot, ManifestPath));
if (!File.Exists(path)) throw new FileNotFoundException("Missing scene review manifest", path);
var manifest = JsonUtility.FromJson<SceneReviewManifest>(File.ReadAllText(path));
if (manifest == null || string.IsNullOrEmpty(manifest.sceneId))
throw new InvalidOperationException("Scene review manifest is invalid.");
if (string.IsNullOrEmpty(manifest.sourcePrefab) || string.IsNullOrEmpty(manifest.outputScene))
throw new InvalidOperationException("Scene review manifest requires sourcePrefab and outputScene.");
if (manifest.cameraPosition == null || manifest.cameraTarget == null)
throw new InvalidOperationException("Scene review manifest requires cameraPosition and cameraTarget.");
return manifest;
}
static string NormalizeAssetPath(string path)
{
return path.Replace('\\', '/');
}
static void EnsureFolderForAsset(string assetPath)
{
var absolute = Path.GetFullPath(Path.Combine(RepoRoot, NormalizeAssetPath(assetPath)));
var directory = Path.GetDirectoryName(absolute);
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
}
static GameObject AddReviewCamera(SceneReviewManifest manifest)
{
var go = new GameObject("ReviewCamera");
go.tag = "MainCamera";
var camera = go.AddComponent<Camera>();
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = new Color(0.12f, 0.16f, 0.22f);
camera.fieldOfView = manifest.fieldOfView > 1f ? manifest.fieldOfView : 45f;
camera.nearClipPlane = 0.1f;
camera.farClipPlane = 200f;
go.AddComponent<AudioListener>();
go.transform.position = manifest.cameraPosition.Value;
go.transform.rotation = Quaternion.LookRotation(manifest.cameraTarget.Value - go.transform.position, Vector3.up);
return go;
}
static GameObject AddReviewLight()
{
var go = new GameObject("ReviewKeyLight");
var light = go.AddComponent<Light>();
light.type = LightType.Directional;
light.color = new Color(1f, 0.96f, 0.88f);
light.intensity = 1.15f;
light.shadows = LightShadows.Soft;
go.transform.rotation = Quaternion.Euler(48f, -30f, 0f);
return go;
}
static GameObject AddReviewLabel(SceneReviewManifest manifest)
{
var go = new GameObject("ReviewLabel");
var text = go.AddComponent<TextMesh>();
text.text = string.IsNullOrEmpty(manifest.label) ? manifest.sceneId + " Review" : manifest.label;
text.anchor = TextAnchor.UpperLeft;
text.alignment = TextAlignment.Left;
text.characterSize = 0.22f;
text.fontSize = 42;
text.color = Color.white;
go.transform.position = manifest.cameraTarget.Value + new Vector3(-8f, 5f, 4f);
go.transform.rotation = Quaternion.LookRotation(manifest.cameraPosition.Value - go.transform.position, Vector3.up);
return go;
}
public static string ValidateOnly()
{
var manifest = ReadManifest();
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(NormalizeAssetPath(manifest.sourcePrefab));
if (prefab == null) throw new InvalidOperationException("Missing review source prefab " + manifest.sourcePrefab);
if (string.IsNullOrEmpty(manifest.outputScene) || !manifest.outputScene.EndsWith(".unity", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("outputScene must be a .unity asset path.");
return "PASS review manifest scene=" + manifest.sceneId + " prefab=" + manifest.sourcePrefab;
}
public static string Build()
{
if (EditorApplication.isPlaying) throw new InvalidOperationException("Build the review scene in Edit Mode.");
var manifest = ReadManifest();
var prefabPath = NormalizeAssetPath(manifest.sourcePrefab);
var scenePath = NormalizeAssetPath(manifest.outputScene);
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
if (prefab == null) throw new InvalidOperationException("Missing review source prefab " + prefabPath);
EnsureFolderForAsset(scenePath);
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
instance.name = manifest.sceneId + "_ReviewRoot";
SceneManager.MoveGameObjectToScene(instance, scene);
AddReviewCamera(manifest);
AddReviewLight();
AddReviewLabel(manifest);
RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Trilight;
RenderSettings.ambientSkyColor = new Color(0.30f, 0.38f, 0.50f);
RenderSettings.ambientEquatorColor = new Color(0.22f, 0.25f, 0.28f);
RenderSettings.ambientGroundColor = new Color(0.12f, 0.12f, 0.12f);
EditorSceneManager.SaveScene(scene, scenePath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
WriteReport(manifest, scenePath);
return "Built review scene " + scenePath + " from " + prefabPath;
}
static void WriteReport(SceneReviewManifest manifest, string scenePath)
{
var reportPath = string.IsNullOrEmpty(manifest.reportPath)
? "Tools/output/scene_review/" + manifest.sceneId + "/review_report.json"
: NormalizeAssetPath(manifest.reportPath);
var absolute = Path.GetFullPath(Path.Combine(RepoRoot, reportPath));
var directory = Path.GetDirectoryName(absolute);
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
var report = new SceneReviewReport
{
sceneId = manifest.sceneId,
sourcePrefab = manifest.sourcePrefab,
outputScene = scenePath,
generatedAtUtc = DateTime.UtcNow.ToString("o"),
reviewSteps = new[]
{
"Open the generated .unity scene in Unity.",
"Check the assembled prefab hierarchy and component placement.",
"Check the Game view from ReviewCamera and the Scene view for collisions.",
"Run ValidateReview after any regeneration."
}
};
File.WriteAllText(absolute, JsonUtility.ToJson(report, true));
}
public static string OpenReview()
{
var manifest = ReadManifest();
var scenePath = NormalizeAssetPath(manifest.outputScene);
if (!File.Exists(Path.GetFullPath(Path.Combine(RepoRoot, scenePath))))
throw new FileNotFoundException("Review scene has not been built", scenePath);
var scene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
var root = GameObject.Find(manifest.sceneId + "_ReviewRoot");
if (root != null)
{
Selection.activeGameObject = root;
EditorGUIUtility.PingObject(root);
}
var sceneView = SceneView.lastActiveSceneView;
if (sceneView != null)
{
sceneView.LookAt(manifest.cameraTarget.Value, Quaternion.Euler(32f, 0f, 0f), 24f);
sceneView.Repaint();
}
return "Opened review scene " + scene.path;
}
public static string BuildAndOpenBattlefieldReview()
{
BuildBattlefieldAssets.BuildAll();
BuildBattlefieldScene.Build();
BuildBattlefieldScene.VerifyBuilt();
var result = Build();
OpenReview();
return result + " and opened it for review.";
}
/// <summary>Build and open a review scene from the currently selected scene manifest.</summary>
public static string BuildAndOpenReview()
{
BuildBattlefieldScene.Build();
var result = Build();
OpenReview();
return result + " and opened it for review.";
}
public static string ValidateReview()
{
var manifest = ReadManifest();
var scenePath = NormalizeAssetPath(manifest.outputScene);
var scene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
var root = GameObject.Find(manifest.sceneId + "_ReviewRoot");
if (root == null) throw new InvalidOperationException("Missing review root " + manifest.sceneId + "_ReviewRoot");
if (GameObject.Find("ReviewCamera") == null) throw new InvalidOperationException("Missing ReviewCamera");
if (GameObject.Find("ReviewKeyLight") == null) throw new InvalidOperationException("Missing ReviewKeyLight");
return "PASS review scene=" + scene.path + " root=" + root.name;
}
}
#endif
+19
View File
@@ -0,0 +1,19 @@
using System;
using System.IO;
using AIGame.EditorTools;
using UnityEditor;
using UnityEngine;
public static class BuildSettings
{
public static string Build()
{
if (EditorApplication.isPlaying) throw new Exception("Build settings in Edit Mode");
string repo = Directory.GetParent(Application.dataPath).Parent.FullName;
if (string.IsNullOrEmpty(JsonUIPrefabBuilder.BuildFromJsonFile(Path.Combine(repo, "Doc/UIPrefabCreater/UI_PrototypeSettings.json"))))
throw new Exception("Settings prefab failed");
SetupUiBundleCollector.Setup();
AssetDatabase.SaveAssets();
return "Built Candy settings page: audio, quality, vibrate and account info from JSON.";
}
}
@@ -0,0 +1,77 @@
#if UNITY_EDITOR
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
public static class DiagnoseRockBallAnim
{
public static string Run()
{
var sb = new StringBuilder();
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Characters/Animal/RockBall/RockBall_Rigged.prefab");
if (prefab == null) return "missing prefab";
var inst = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
var anim = inst.GetComponentInChildren<Animator>(true);
sb.AppendLine("animator=" + (anim != null) + " ctrl=" + (anim != null && anim.runtimeAnimatorController != null));
if (anim != null)
{
sb.AppendLine("avatar=" + (anim.avatar != null ? anim.avatar.name + " isValid=" + anim.avatar.isValid + " isHuman=" + anim.avatar.isHuman : "null"));
sb.AppendLine("applyRootMotion=" + anim.applyRootMotion + " culling=" + anim.cullingMode);
foreach (var p in anim.parameters) sb.AppendLine("param " + p.name + " " + p.type);
var ctrl = anim.runtimeAnimatorController as AnimatorController;
if (ctrl != null)
{
foreach (var s in ctrl.layers[0].stateMachine.states)
{
var clip = s.state.motion as AnimationClip;
sb.AppendLine("state " + s.state.name + " clip=" + (clip != null ? clip.name + " len=" + clip.length + " empty=" + clip.empty + " curves=" + AnimationUtility.GetCurveBindings(clip).Length + " objBindings=" + AnimationUtility.GetObjectReferenceCurveBindings(clip).Length : "null"));
if (clip != null)
{
var binds = AnimationUtility.GetCurveBindings(clip);
foreach (var b in binds.Take(12))
sb.AppendLine(" bind " + b.path + " / " + b.propertyName);
// sample a few key values
if (binds.Length > 0)
{
var c = AnimationUtility.GetEditorCurve(clip, binds[0]);
if (c != null && c.keys.Length > 0)
sb.AppendLine(" firstCurve keys=" + c.keys.Length + " t0=" + c.keys[0].value + " tmid=" + c.Evaluate(clip.length * 0.5f));
}
}
}
}
// bone names under model
var bones = inst.GetComponentsInChildren<Transform>(true).Select(t => t.name).Where(n => n.StartsWith("bone") || n.Contains("Armature") || n.Contains("Root")).Take(30);
sb.AppendLine("bones: " + string.Join(", ", bones));
var skins = inst.GetComponentsInChildren<SkinnedMeshRenderer>(true);
foreach (var sk in skins)
sb.AppendLine("skin " + sk.name + " bones=" + (sk.bones != null ? sk.bones.Length : 0) + " root=" + (sk.rootBone != null ? sk.rootBone.name : "null") + " mesh=" + (sk.sharedMesh != null ? sk.sharedMesh.name + " v=" + sk.sharedMesh.vertexCount : "null"));
}
Object.DestroyImmediate(inst);
// also inspect anim fbx clips directly
foreach (var clipName in new[] { "Idle", "Walk", "Attack" })
{
var path = "Assets/Bundles/Characters/Animal/RockBall/Anims/RockBall_" + clipName + ".fbx";
var clips = AssetDatabase.LoadAllAssetsAtPath(path).OfType<AnimationClip>().Where(c => !c.name.StartsWith("__preview__")).ToArray();
sb.AppendLine("fbx " + clipName + " clips=" + clips.Length);
foreach (var clip in clips)
{
var binds = AnimationUtility.GetCurveBindings(clip);
sb.AppendLine(" " + clip.name + " len=" + clip.length + " binds=" + binds.Length + " empty=" + clip.empty);
foreach (var b in binds.Take(8)) sb.AppendLine(" " + b.path + " / " + b.propertyName);
}
var named = "Assets/Bundles/Characters/Animal/RockBall/Clips/" + clipName + ".anim";
var namedClip = AssetDatabase.LoadAssetAtPath<AnimationClip>(named);
if (namedClip != null)
{
var binds = AnimationUtility.GetCurveBindings(namedClip);
sb.AppendLine(" named.anim binds=" + binds.Length + " len=" + namedClip.length);
}
}
return sb.ToString();
}
}
#endif
@@ -0,0 +1,83 @@
#if UNITY_EDITOR
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEngine;
public static class DiagnoseRockBallOrient
{
static string Fmt(Vector3 v) => $"({v.x:F3},{v.y:F3},{v.z:F3})";
static string FmtE(Quaternion q) => $"({q.eulerAngles.x:F1},{q.eulerAngles.y:F1},{q.eulerAngles.z:F1})";
static Bounds MeshBounds(GameObject go)
{
var rs = go.GetComponentsInChildren<Renderer>()
.Where(r => r.name != "ForwardMinusZ" && r.GetComponent<TextMesh>() == null)
.ToArray();
var b = rs[0].bounds;
for (int i = 1; i < rs.Length; i++) b.Encapsulate(rs[i].bounds);
return b;
}
public static string Run()
{
var sb = new StringBuilder();
var staticP = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Characters/Animal/RockBall/RockBall.prefab");
var rigP = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Characters/Animal/RockBall/RockBall_Rigged.prefab");
var a = (GameObject)PrefabUtility.InstantiatePrefab(staticP);
a.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);
var ba = MeshBounds(a);
sb.AppendLine($"STATIC bounds min={Fmt(ba.min)} size={Fmt(ba.size)}");
Object.DestroyImmediate(a);
var b = (GameObject)PrefabUtility.InstantiatePrefab(rigP);
b.transform.SetPositionAndRotation(new Vector3(3, 0, 0), Quaternion.identity);
var anim = b.GetComponentInChildren<Animator>();
var bone0 = b.GetComponentsInChildren<Transform>().FirstOrDefault(t => t.name == "bone_0");
var arm = b.GetComponentsInChildren<Transform>().FirstOrDefault(t => t.name == "Armature");
anim.Rebind();
anim.Update(0f);
var br = MeshBounds(b);
sb.AppendLine($"RIGGED rebind bounds min={Fmt(br.min)} size={Fmt(br.size)}");
if (arm != null) sb.AppendLine($" Armature up={Fmt(arm.up)} fwd={Fmt(arm.forward)} euler={FmtE(arm.rotation)}");
if (bone0 != null) sb.AppendLine($" bone0 up={Fmt(bone0.up)} fwd={Fmt(bone0.forward)} euler={FmtE(bone0.rotation)}");
foreach (var motion in new[] { 0, 1, 2 })
{
anim.Rebind();
anim.SetInteger("Motion", motion);
anim.Update(0f);
for (int i = 0; i < 15; i++) anim.Update(0.05f);
var bm = MeshBounds(b);
string name = motion == 0 ? "Idle" : motion == 1 ? "Walk" : "Attack";
sb.AppendLine($"{name}: bounds min={Fmt(bm.min)} size={Fmt(bm.size)} extentY/X={(bm.size.y / Mathf.Max(0.001f, bm.size.x)):F2}");
if (bone0 != null)
sb.AppendLine($" bone0 up={Fmt(bone0.up)} euler={FmtE(bone0.rotation)}");
}
// clip: does Armature/bone_0 leave bind orientation?
foreach (var name in new[] { "Idle", "Walk", "Attack" })
{
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>($"Assets/Bundles/Characters/Animal/RockBall/Clips/{name}.anim");
if (clip == null) continue;
sb.AppendLine($"clip {name}:");
foreach (var path in new[] { "Armature", "Armature/bone_0" })
{
var qx = AnimationUtility.GetEditorCurve(clip, new EditorCurveBinding { path = path, propertyName = "m_LocalRotation.x", type = typeof(Transform) });
var qy = AnimationUtility.GetEditorCurve(clip, new EditorCurveBinding { path = path, propertyName = "m_LocalRotation.y", type = typeof(Transform) });
var qz = AnimationUtility.GetEditorCurve(clip, new EditorCurveBinding { path = path, propertyName = "m_LocalRotation.z", type = typeof(Transform) });
var qw = AnimationUtility.GetEditorCurve(clip, new EditorCurveBinding { path = path, propertyName = "m_LocalRotation.w", type = typeof(Transform) });
if (qx == null) continue;
var q0 = new Quaternion(qx.Evaluate(0), qy.Evaluate(0), qz.Evaluate(0), qw.Evaluate(0));
var q1 = new Quaternion(qx.Evaluate(clip.length * 0.5f), qy.Evaluate(clip.length * 0.5f), qz.Evaluate(clip.length * 0.5f), qw.Evaluate(clip.length * 0.5f));
sb.AppendLine($" {path} t0 euler={FmtE(q0)} tMid euler={FmtE(q1)} angleDelta={Quaternion.Angle(q0, q1):F1}");
}
}
Object.DestroyImmediate(b);
return sb.ToString();
}
}
#endif
+174
View File
@@ -0,0 +1,174 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
/// <summary>Bake an upright, grounded, periodic in-place gait onto this exact bind.</summary>
public static class FinalizeAnimalWalk
{
const string Root = "Assets/Bundles/Characters/Animal/RockBall";
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 Vector3[] Vertices(SkinnedMeshRenderer skin)
{
var m=skin.sharedMesh;
var bind=m.bindposes;
var matrices=skin.bones.Select((b,i)=>b.localToWorldMatrix*bind[i]).ToArray();
var weights=m.boneWeights;
return m.vertices.Select((v,i)=> {
var w=weights[i];
return matrices[w.boneIndex0].MultiplyPoint3x4(v)*w.weight0
+matrices[w.boneIndex1].MultiplyPoint3x4(v)*w.weight1
+matrices[w.boneIndex2].MultiplyPoint3x4(v)*w.weight2
+matrices[w.boneIndex3].MultiplyPoint3x4(v)*w.weight3;
}).ToArray();
}
static float Bottom(GameObject model) => model.GetComponentsInChildren<SkinnedMeshRenderer>().SelectMany(Vertices).Min(v=>v.y);
static float Difference(Pose a,Pose b)
{
float sum=0;
for(int i=0;i<a.q.Length;i++) sum+=Mathf.Pow(Quaternion.Angle(a.q[i],b.q[i]),2)+10000*(a.p[i]-b.p[i]).sqrMagnitude;
return Mathf.Sqrt(sum/a.q.Length);
}
public static string Run(float strideMeters = .06f, float liftMeters = .018f, float cycleSeconds = 1.1f)
=> RunFor(Root,"RockBall",.55f,"bone_0","bone_8","bone_10",strideMeters,liftMeters,cycleSeconds,"Tools/output/rockball_walk_audit");
public static string RunFor(string assetRoot,string actor,float height,string rootBone,string rightFoot,string leftFoot,
float strideMeters,float liftMeters,float cycleSeconds,string reportDirectory,bool calibrateBind=false,float sourceWeight=1f)
{
var asset=AssetDatabase.LoadAssetAtPath<GameObject>(assetRoot+"/Rigged/"+actor+"_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!=model.transform && (t.name=="Armature" || t.name.StartsWith("bone_"))).ToArray();
var root=bones.First(t=>t.name==rootBone);
var feet=new[] { bones.First(t=>t.name==rightFoot),bones.First(t=>t.name==leftFoot) };
var bindPose=Read(bones);
var bindRotation=root.rotation;
var bindPosition=root.position;
float bindBottom=Bottom(model);
float bindHeight=model.GetComponentsInChildren<SkinnedMeshRenderer>().SelectMany(Vertices).Max(v=>v.y)-bindBottom;
float metersToBind=bindHeight/height;
var src=AssetDatabase.LoadAllAssetsAtPath(assetRoot+"/Anims/"+actor+"_Walk.fbx").OfType<AnimationClip>().First(c=>!c.name.StartsWith("__preview__"));
int count=Mathf.RoundToInt(src.length*30)+1;
var poses=new Pose[count];
Quaternion reference=Quaternion.identity;
Pose referencePose=null;
float referenceTilt=0;
for(int f=0;f<count;f++)
{
Write(bones,bindPose);
src.SampleAnimation(model,f/30f);
if(f==0) { reference=root.rotation; referenceTilt=Vector3.Angle(root.up,Vector3.up); referencePose=Read(bones); }
// Root policy is authored into the clip, never counter-rotated at runtime.
// Remove the generated initial pitch/roll offset, retain gait deltas.
if(calibrateBind)
{
var raw=Read(bones);
for(int b=0;b<bones.Length;b++)
{
bones[b].localPosition=bindPose.p[b]+(raw.p[b]-referencePose.p[b])*sourceWeight;
bones[b].localRotation=Quaternion.Slerp(bindPose.q[b],bindPose.q[b]*Quaternion.Inverse(referencePose.q[b])*raw.q[b],sourceWeight);
bones[b].localScale=bindPose.s[b];
}
}
else root.rotation=bindRotation*Quaternion.Inverse(reference)*root.rotation;
root.position=new Vector3(bindPosition.x,root.position.y,bindPosition.z);
root.position+=Vector3.up*(bindBottom-Bottom(model));
poses[f]=Read(bones);
}
// Select repeated gait phases, not an arbitrary first/last truncation.
int first=0,last=count-1;
float error=float.MaxValue;
for(int a=0;a<count/4;a++)
for(int b=Mathf.Max(a+21,count*2/3);b<count;b++)
{
float d=Difference(poses[a],poses[b]);
if(d<error) { error=d; first=a; last=b; }
}
if(error>15) throw new InvalidOperationException($"No usable repeated gait phase (RMS={error:F2}). Generate a better walk.");
int frames=last-first+1;
var loop=new Pose[frames];
for(int f=0;f<frames;f++)
{
float t=(float)f/(frames-1);
float blend=t*t*t*(t*(6*t-15)+10); // smooth residual closure, zero derivative at ends
var a=poses[first+f]; var begin=poses[first]; var end=poses[last];
var p=new Pose { p=new Vector3[bones.Length],s=new Vector3[bones.Length],q=new Quaternion[bones.Length] };
for(int i=0;i<bones.Length;i++)
{
p.p[i]=a.p[i]+blend*(begin.p[i]-end.p[i]);
p.s[i]=a.s[i]+blend*(begin.s[i]-end.s[i]);
p.q[i]=a.q[i]*Quaternion.Slerp(Quaternion.identity,Quaternion.Inverse(end.q[i])*begin.q[i],blend);
}
Write(bones,p);
// Author an explicit alternating short step in the OFFLINE clip bake.
// Bone mapping and dimensions belong to this character's recipe.
for(int foot=0;foot<feet.Length;foot++)
{
float phase=2*Mathf.PI*t+foot*Mathf.PI;
float swing=Mathf.Max(0,Mathf.Cos(phase));
feet[foot].position+=Vector3.back*(Mathf.Sin(phase)*strideMeters*.5f*metersToBind)
+Vector3.up*(swing*swing*liftMeters*metersToBind);
}
root.position+=Vector3.up*(bindBottom-Bottom(model));
loop[f]=Read(bones);
}
// Force identical quaternion representatives on the seam.
for(int f=1;f<frames;f++) for(int b=0;b<bones.Length;b++)
if(Quaternion.Dot(loop[f-1].q[b],loop[f].q[b])<0) {
var q=loop[f].q[b]; loop[f].q[b]=new Quaternion(-q.x,-q.y,-q.z,-q.w);
}
loop[frames-1]=loop[0];
var clip=AssetDatabase.LoadAssetAtPath<AnimationClip>(assetRoot+"/Clips/Walk.anim");
Undo.RecordObject(clip,"Bake verified in-place Walk");
clip.ClearCurves();
clip.frameRate=30;
for(int b=0;b<bones.Length;b++)
{
string path=AnimationUtility.CalculateTransformPath(bones[b],model.transform);
for(int component=0;component<10;component++)
{
int c=component;
var keys=Enumerable.Range(0,frames).Select(f=>new Keyframe(cycleSeconds*f/(frames-1),
c<3 ? loop[f].p[b][c] : c<7 ? loop[f].q[b][c-3] : loop[f].s[b][c-7])).ToArray();
for(int k=0;k<frames;k++)
{
int prev=k==0 ? frames-2 : k-1, next=k==frames-1 ? 1 : k+1;
float tangent=(keys[next].value-keys[prev].value)*(frames-1)/(2*cycleSeconds);
keys[k].inTangent=tangent; keys[k].outTangent=tangent;
}
string prop=c<3 ? "m_LocalPosition."+"xyz"[c] : c<7 ? "m_LocalRotation."+"xyzw"[c-3] : "m_LocalScale."+"xyz"[c-7];
AnimationUtility.SetEditorCurve(clip,EditorCurveBinding.FloatCurve(path,typeof(Transform),prop),new AnimationCurve(keys));
}
}
var settings=AnimationUtility.GetAnimationClipSettings(clip);
settings.startTime=0; settings.stopTime=cycleSeconds; settings.loopTime=true; settings.loopBlend=false;
AnimationUtility.SetAnimationClipSettings(clip,settings);
EditorUtility.SetDirty(clip);
AssetDatabase.SaveAssets();
string report=$"frames={first}..{last} length={clip.length:F4} phaseRms={error:F3} sourceInitialRootTilt={referenceTilt:F3}; allBoneBindCalibration={calibrateBind} sourceWeight={sourceWeight:F3}; root bind calibration + in-place XZ + per-frame grounded skin + periodic closure; authored short-foot stride={strideMeters:F3}m lift={liftMeters:F3}m cycle={cycleSeconds:F3}s";
string outPath=Path.GetFullPath(Path.Combine(Application.dataPath,"../../",reportDirectory,"walk_processing.txt"));
Directory.CreateDirectory(Path.GetDirectoryName(outPath));
File.WriteAllText(outPath,report);
return report;
}
finally { Object.DestroyImmediate(model); }
}
}
#endif
@@ -0,0 +1,160 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEngine;
using Object=UnityEngine.Object;
/// <summary>Offline source-motion editing; saves assets, never adds runtime correction.</summary>
public static class FinalizeEnemyMotions
{
[Serializable] public sealed class Recipe
{
public string id,assetRoot,rootBone,chestBone,rightArm,leftArm,outputDir,profile,attackStyle,attackEvent,usage;
public float height,idleSeconds,idleBreath,attackSeconds,attackWindup,attackImpact,attackRecover;
public float attackLeanDegrees,attackArmDegrees,attackLunge,attackReach,idleSourceWeight,attackSourceWeight;
}
sealed class Pose
{
public Vector3[] p,s;
public Quaternion[] q;
public static Pose Read(Transform[] b)=>new Pose { p=b.Select(t=>t.localPosition).ToArray(),s=b.Select(t=>t.localScale).ToArray(),q=b.Select(t=>t.localRotation).ToArray() };
public void Write(Transform[] b) { for(int i=0;i<b.Length;i++) { b[i].localPosition=p[i];b[i].localRotation=q[i];b[i].localScale=s[i]; } }
}
static string Repo=>Path.GetFullPath(Path.Combine(Application.dataPath,"../.."));
static float Smooth(float t)=>Mathf.SmoothStep(0,1,Mathf.Clamp01(t));
static float Strike(Recipe r,float t)
{
if(t<r.attackWindup) return -.3f*Smooth(t/r.attackWindup);
if(t<r.attackImpact) return Mathf.Lerp(-.3f,1,Smooth((t-r.attackWindup)/(r.attackImpact-r.attackWindup)));
if(t<r.attackRecover) return 1-Smooth((t-r.attackImpact)/(r.attackRecover-r.attackImpact));
return 0;
}
static Vector3[] Vertices(SkinnedMeshRenderer skin)
{
var mesh=skin.sharedMesh;var bind=mesh.bindposes;var weights=mesh.boneWeights;
var matrices=skin.bones.Select((b,i)=>b.localToWorldMatrix*bind[i]).ToArray();
return mesh.vertices.Select((v,i)=> { var w=weights[i];return matrices[w.boneIndex0].MultiplyPoint3x4(v)*w.weight0
+matrices[w.boneIndex1].MultiplyPoint3x4(v)*w.weight1+matrices[w.boneIndex2].MultiplyPoint3x4(v)*w.weight2
+matrices[w.boneIndex3].MultiplyPoint3x4(v)*w.weight3; }).ToArray();
}
static float Bottom(GameObject model)=>model.GetComponentsInChildren<SkinnedMeshRenderer>().SelectMany(Vertices).Min(v=>v.y);
static void Save(AnimationClip clip,Transform model,Transform[] bones,Pose[] poses,float seconds,bool loop)
{
Undo.RecordObject(clip,"Author character motion");
clip.ClearCurves();clip.frameRate=30;
int count=poses.Length;
for(int f=1;f<count;f++) for(int b=0;b<bones.Length;b++)
if(Quaternion.Dot(poses[f-1].q[b],poses[f].q[b])<0) {var q=poses[f].q[b];poses[f].q[b]=new Quaternion(-q.x,-q.y,-q.z,-q.w);}
poses[count-1]=poses[0];
for(int b=0;b<bones.Length;b++)
{
string path=AnimationUtility.CalculateTransformPath(bones[b],model);
for(int c=0;c<10;c++)
{
var keys=new Keyframe[count];
for(int f=0;f<count;f++) keys[f]=new Keyframe(seconds*f/(count-1),c<3 ? poses[f].p[b][c] : c<7 ? poses[f].q[b][c-3] : poses[f].s[b][c-7]);
for(int f=0;f<count;f++)
{
int prev=f==0 ? (loop ? count-2 : 0) : f-1;
int next=f==count-1 ? (loop ? 1 : count-1) : f+1;
float tangent=(keys[next].value-keys[prev].value)*(count-1)/(2*seconds);
if(!loop && (f==0 || f==count-1)) tangent=0;
keys[f].inTangent=tangent;keys[f].outTangent=tangent;
}
string prop=c<3 ? "m_LocalPosition."+"xyz"[c] : c<7 ? "m_LocalRotation."+"xyzw"[c-3] : "m_LocalScale."+"xyz"[c-7];
AnimationUtility.SetEditorCurve(clip,EditorCurveBinding.FloatCurve(path,typeof(Transform),prop),new AnimationCurve(keys));
}
}
var settings=AnimationUtility.GetAnimationClipSettings(clip);
settings.startTime=0;settings.stopTime=seconds;settings.loopTime=loop;settings.loopBlend=false;
AnimationUtility.SetAnimationClipSettings(clip,settings);
AnimationUtility.SetAnimationEvents(clip,new AnimationEvent[0]);
EditorUtility.SetDirty(clip);
}
public static string Run(string recipePath="Config/Art/EnemyCharacters/RockBall.json")
{
var r=JsonUtility.FromJson<Recipe>(File.ReadAllText(Path.Combine(Repo,recipePath)));
if(string.IsNullOrEmpty(r.attackStyle))r.attackStyle="body_slam";
if(string.IsNullOrEmpty(r.attackEvent))r.attackEvent=r.usage=="companion_visual" ? "OnCompanionAttackImpact" : "OnEnemyAttackImpact";
if(r.attackStyle!="body_slam" && r.attackStyle!="ranged_cast")throw new ArgumentException("Unsupported attack style");
if(r.height<=0 || !(0<r.attackWindup && r.attackWindup<r.attackImpact && r.attackImpact<r.attackRecover && r.attackRecover<1))
throw new ArgumentException("Invalid dimensions / attack phase order");
var sb=new StringBuilder();
foreach(string name in new[] {"Idle","Attack"})
{
var model=Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>(r.assetRoot+"/Rigged/"+r.id+"_Rigged.fbx"));
try
{
var anim=model.GetComponent<Animator>();if(anim!=null) anim.enabled=false;
var bones=model.GetComponentsInChildren<Transform>().Where(t=>t!=model.transform && (t.name=="Armature" || t.name.StartsWith("bone_"))).ToArray();
var root=bones.First(t=>t.name==r.rootBone);
var chest=bones.FirstOrDefault(t=>t.name==r.chestBone);
var arms=new[] {bones.FirstOrDefault(t=>t.name==r.rightArm),bones.FirstOrDefault(t=>t.name==r.leftArm)};
if(r.profile=="biped_short" && (chest==null || arms.Any(t=>t==null))) throw new Exception("Missing profile bones");
var bind=Pose.Read(bones);var bindRootPosition=root.position;float bindBottom=Bottom(model);
var skins=model.GetComponentsInChildren<SkinnedMeshRenderer>();
float unitScale=(skins.SelectMany(Vertices).Max(v=>v.y)-bindBottom)/r.height;
var source=AssetDatabase.LoadAllAssetsAtPath(r.assetRoot+"/Anims/"+r.id+"_"+name+".fbx").OfType<AnimationClip>().First(c=>!c.name.StartsWith("__preview__"));
source.SampleAnimation(model,0);var reference=Pose.Read(bones);
bool idle=name=="Idle";float seconds=idle ? r.idleSeconds : r.attackSeconds;
int count=Mathf.RoundToInt(seconds*30)+1;var poses=new Pose[count];
for(int f=0;f<count;f++)
{
float t=(float)f/(count-1);float idleWeight=r.idleSourceWeight>0 ? r.idleSourceWeight : .2f;
float attackWeight=r.attackSourceWeight>0 ? r.attackSourceWeight : .35f;
float weight=Mathf.Pow(Mathf.Sin(Mathf.PI*t),2)*(idle ? idleWeight : attackWeight);
bind.Write(bones);source.SampleAnimation(model,t*source.length);var raw=Pose.Read(bones);
for(int b=0;b<bones.Length;b++)
{
bones[b].localPosition=bind.p[b]+(raw.p[b]-reference.p[b])*weight;
bones[b].localRotation=Quaternion.Slerp(bind.q[b],bind.q[b]*Quaternion.Inverse(reference.q[b])*raw.q[b],weight);
bones[b].localScale=bind.s[b];
}
root.position=new Vector3(bindRootPosition.x,root.position.y,bindRootPosition.z);
if(r.profile=="biped_short")
{
if(idle)
{
float breath=Mathf.Pow(Mathf.Sin(Mathf.PI*t),2);
chest.localScale=Vector3.Scale(chest.localScale,new Vector3(1+.007f*breath,1+r.idleBreath/r.height*breath,1+.007f*breath));
}
else
{
float strike=Strike(r,t);
root.rotation=Quaternion.AngleAxis(-r.attackLeanDegrees*strike,Vector3.right)*root.rotation;
root.position+=Vector3.back*(r.attackLunge*strike*unitScale);
foreach(var arm in arms)
{
float armSign=r.attackStyle=="ranged_cast" ? 1 : -1;
arm.rotation=Quaternion.AngleAxis(armSign*r.attackArmDegrees*strike,Vector3.right)*arm.rotation;
// A ranged cast presents both paws in front, without moving the actor.
// Small offline bone translation supplies reach for very short chibi arms.
if(r.attackStyle=="ranged_cast")arm.position+=Vector3.back*(r.attackReach*strike*unitScale);
}
}
}
root.position+=Vector3.up*(bindBottom-Bottom(model));
poses[f]=Pose.Read(bones);
}
poses[0]=bind;poses[count-1]=bind;
var clip=AssetDatabase.LoadAssetAtPath<AnimationClip>(r.assetRoot+"/Clips/"+name+".anim");
Save(clip,model.transform,bones,poses,seconds,idle);
if(!idle)
{
AnimationUtility.SetAnimationEvents(clip,new[] {new AnimationEvent {
time=r.attackImpact*seconds,functionName=r.attackEvent,messageOptions=SendMessageOptions.DontRequireReceiver
}});
}
sb.AppendLine($"{name}: {seconds:F3}s loop={idle} start/end=bind generated source + offline {r.profile}/{r.attackStyle} authoring");
}
finally {Object.DestroyImmediate(model);}
}
AssetDatabase.SaveAssets();
string report=Path.Combine(Repo,r.outputDir,"motion_processing.txt");Directory.CreateDirectory(Path.GetDirectoryName(report));File.WriteAllText(report,sb.ToString());
return sb.ToString();
}
}
#endif
+120
View File
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AIGame.EditorTools;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
/// <summary>Pipeline-only migration and verification of the Common nine-slice controls.</summary>
public static class FixUiNineSlice
{
static string Repo => Directory.GetParent(Application.dataPath).Parent.FullName;
static readonly string[] Names = { "UI_MainHUD", "UI_ConfirmDialog" };
public static string Apply()
{
if (EditorApplication.isPlayingOrWillChangePlaymode) throw new Exception("Use Edit Mode.");
int changed = UiSpriteNineSliceRules.ApplyExisting();
foreach (string name in Names)
{
string path = Path.Combine(Repo, "Doc/UIPrefabCreater", name + ".json");
if (string.IsNullOrEmpty(JsonUIPrefabBuilder.BuildFromJsonFile(path)))
throw new Exception("Prefab build failed: " + name);
}
return "Updated " + changed + " standard control imports; rebuilt 2 JSON UI prefabs. " + Verify();
}
public static string Verify()
{
int checks = 0, controls = 0, slicedImages = 0, states = 0;
Action<bool, string> check = (ok, message) => { if (!ok) throw new Exception(message); checks++; };
var scene = EditorSceneManager.NewPreviewScene();
try
{
foreach (string guid in AssetDatabase.FindAssets("t:Texture2D", new[] { "Assets/Bundles/UI/Texture/Common" }))
{
string path = AssetDatabase.GUIDToAssetPath(guid);
if (!UiSpriteNineSliceRules.IsStandardControl(path)) continue;
controls++;
var importer = (TextureImporter)AssetImporter.GetAtPath(path);
importer.GetSourceTextureWidthAndHeight(out int width, out int height);
var sprite = AssetDatabase.LoadAssetAtPath<Sprite>(path);
check(sprite != null && sprite.border == UiSpriteNineSliceRules.GetBorder(path, width, height), "Border mismatch: " + path);
var settings = new TextureImporterSettings(); importer.ReadTextureSettings(settings);
check(settings.spriteMeshType == SpriteMeshType.FullRect, "Not Full Rect: " + path);
check(sprite.border.x > 0 && sprite.border.y > 0 && sprite.border.z > 0 && sprite.border.w > 0, "Empty border: " + path);
check(sprite.border.x + sprite.border.z < sprite.rect.width && sprite.border.y + sprite.border.w < sprite.rect.height, "No stretchable center: " + path);
var probeGo = new GameObject("NineSliceProbe", typeof(RectTransform), typeof(NineSliceProbe));
SceneManager.MoveGameObjectToScene(probeGo, scene);
try
{
var image = probeGo.GetComponent<NineSliceProbe>();
image.sprite = sprite; image.type = Image.Type.Sliced;
foreach (Vector2 size in new[] { sprite.rect.size, sprite.rect.size * 2f, sprite.rect.size * new Vector2(3f, 1.25f) })
{
image.rectTransform.sizeDelta = size;
using (var vh = new VertexHelper())
{
image.Populate(vh);
var vertices = new List<UIVertex>(); vh.GetUIVertexStream(vertices);
var x = vertices.Select(v => v.position.x).Distinct().OrderBy(v => v).ToArray();
var y = vertices.Select(v => v.position.y).Distinct().OrderBy(v => v).ToArray();
check(x.Length == 4 && y.Length == 4, "Not a nine-slice mesh: " + path);
check(Mathf.Abs(x[1] - x[0] - sprite.border.x) < .01f && Mathf.Abs(x[3] - x[2] - sprite.border.z) < .01f
&& Mathf.Abs(y[1] - y[0] - sprite.border.y) < .01f && Mathf.Abs(y[3] - y[2] - sprite.border.w) < .01f,
"Stretched corner borders: " + path);
}
}
}
finally { UnityEngine.Object.DestroyImmediate(probeGo); }
}
check(controls == 26, "Expected 26 standard control sprites, found " + controls);
foreach (string name in Names)
{
string json = Path.Combine(Repo, "Doc/UIPrefabCreater", name + ".json");
check(JsonUIPrefabBuilder.Validate(json).Ok, "Schema validation failed: " + name);
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/" + name + ".prefab");
foreach (var image in prefab.GetComponentsInChildren<Image>(true))
{
if (image.sprite == null || !UiSpriteNineSliceRules.IsStandardControl(AssetDatabase.GetAssetPath(image.sprite))) continue;
slicedImages++;
check(image.type == Image.Type.Sliced && image.hasBorder, "Prefab nine-slice missing: " + name + "/" + image.name);
}
foreach (var button in prefab.GetComponentsInChildren<Button>(true))
foreach (var sprite in new[] { button.spriteState.highlightedSprite, button.spriteState.pressedSprite, button.spriteState.disabledSprite })
{
if (sprite == null || !UiSpriteNineSliceRules.IsStandardControl(AssetDatabase.GetAssetPath(sprite))) continue;
states++;
check(sprite.border.sqrMagnitude > 0, "Button state border missing: " + button.name);
}
}
// A wrong JSON Image type must fail validation instead of producing a distorted prefab.
string invalidPath = Path.Combine(Repo, "Tools/output/ui_nine_slice_invalid.json");
try
{
var doc = JObject.Parse(File.ReadAllText(Path.Combine(Repo, "Doc/UIPrefabCreater/UI_ConfirmDialog.json")));
var comp = doc["nodes"].SelectMany(n => n["components"]).First(c => (string)c["sprite"] == "ui_panel_popup");
comp["imageType"] = "Simple";
File.WriteAllText(invalidPath, doc.ToString());
var validation = JsonUIPrefabBuilder.Validate(invalidPath);
check(!validation.Ok && validation.Errors.Any(e => e.Contains("imageType: Sliced")), "Wrong Image type was accepted.");
}
finally { if (File.Exists(invalidPath)) File.Delete(invalidPath); }
check(UiSpriteNineSliceRules.ApplyExisting() == 0, "Migration is not idempotent.");
}
finally { EditorSceneManager.ClosePreviewScene(scene); }
var result = new JObject { ["controls"] = controls, ["slicedPrefabImages"] = slicedImages, ["buttonStates"] = states, ["checks"] = checks, ["passed"] = true };
File.WriteAllText(Path.Combine(Repo, "Tools/output/ui_nine_slice_verification.json"), result.ToString());
return checks + " checks passed: " + controls + " sprites, " + slicedImages + " sliced prefab images, " + states + " button states; three stretch sizes preserve corner borders.";
}
}
public sealed class NineSliceProbe : Image
{
public void Populate(VertexHelper vh) { base.OnPopulateMesh(vh); }
}
@@ -0,0 +1,97 @@
using System;
using System.IO;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using YooAsset.Editor;
// Run through Unity Pipeline after import_asset. Never writes .meta by hand.
public static class ImportMainMenuMusic
{
const string ClipPath = "Assets/Bundles/Sound/BGM/CandyCamp_MainMenu_Loop.ogg";
public static string ConfigureAndVerify()
{
return ConfigureFor(ClipPath, "MainMenu");
}
public static string ConfigureSoftAndVerify()
{
return ConfigureFor("Assets/Bundles/Sound/BGM/MeadowBreeze_MainMenu_Loop.ogg", "MainMenuSoft");
}
static string ConfigureFor(string clipPath, string designFolder)
{
if (EditorApplication.isPlaying)
throw new InvalidOperationException("Import music in Edit Mode.");
string repo = Directory.GetParent(Application.dataPath).Parent.FullName;
string reportDirectory = Path.Combine(repo, "Doc/Design/Sound", designFolder);
var preparation = JObject.Parse(File.ReadAllText(Path.Combine(reportDirectory, "generation.json")));
float expectedLength = preparation["loop"]["seconds"].Value<float>();
var importer = AssetImporter.GetAtPath(clipPath) as AudioImporter;
if (importer == null) throw new Exception("AudioImporter missing: " + clipPath);
var sample = importer.defaultSampleSettings;
sample.loadType = AudioClipLoadType.CompressedInMemory;
sample.compressionFormat = AudioCompressionFormat.Vorbis;
sample.quality = 0.7f;
sample.sampleRateSetting = AudioSampleRateSetting.PreserveSampleRate;
sample.preloadAudioData = false;
importer.defaultSampleSettings = sample;
importer.forceToMono = false;
importer.loadInBackground = true;
importer.SaveAndReimport();
// Register only the new BGM directory in the existing Art package.
var package = BundleCollectorSettingData.Setting.Packages.Find(p => p.PackageName == "Art");
if (package == null) throw new Exception("Existing Art package missing.");
var group = package.Groups.Find(g => g.GroupName == "Sound");
if (group == null) group = BundleCollectorSettingData.CreateGroup(package, "Sound");
group.ActiveRuleName = nameof(EnableGroup);
group.AssetTags = "art";
BundleCollectorSettingData.ModifyGroup(package, group);
const string folder = "Assets/Bundles/Sound/BGM";
var collector = group.Collectors.Find(c => c.CollectPath == folder);
if (collector == null)
{
collector = new BundleCollector {
CollectPath = folder,
CollectorGUID = AssetDatabase.AssetPathToGUID(folder),
CollectorType = ECollectorType.MainAssetCollector,
AddressRuleName = nameof(AddressByFileName),
PackRuleName = nameof(PackSeparately),
FilterRuleName = nameof(CollectAll),
AssetTags = "art"
};
BundleCollectorSettingData.CreateCollector(group, collector);
}
package.CheckConfigError();
BundleCollectorSettingData.SaveFile();
var clip = AssetDatabase.LoadAssetAtPath<AudioClip>(clipPath);
if (clip == null || clip.channels != 2 || clip.frequency != 48000 ||
Math.Abs(clip.length - expectedLength) > 0.05f)
throw new Exception("Imported AudioClip dimensions do not match prepared audio.");
importer = (AudioImporter)AssetImporter.GetAtPath(clipPath);
if (importer.defaultSampleSettings.loadType != AudioClipLoadType.CompressedInMemory)
throw new Exception("Expected compressed-in-memory BGM import.");
var report = new {
asset = clipPath,
guid = AssetDatabase.AssetPathToGUID(clipPath),
clip.length, clip.samples, clip.channels, clip.frequency,
loadType = importer.defaultSampleSettings.loadType.ToString(),
compression = importer.defaultSampleSettings.compressionFormat.ToString(),
quality = importer.defaultSampleSettings.quality,
importer.loadInBackground,
preloadAudioData = importer.defaultSampleSettings.preloadAudioData,
package = "Art", group = "Sound", address = Path.GetFileNameWithoutExtension(clipPath),
packRule = collector.PackRuleName,
runtimePlaybackWired = false,
bundleBuilt = false,
verifiedUtc = DateTime.UtcNow.ToString("o")
};
string json = JsonConvert.SerializeObject(report, Formatting.Indented);
File.WriteAllText(Path.Combine(reportDirectory, "unity_validation.json"), json);
return json;
}
}
@@ -0,0 +1,61 @@
#if UNITY_EDITOR
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEngine;
public static class MeasureClipMotion
{
public static string Run()
{
var sb = new StringBuilder();
foreach (var name in new[] { "Idle", "Walk", "Attack" })
{
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>($"Assets/Bundles/Characters/Animal/RockBall/Clips/{name}.anim");
if (clip == null) { sb.AppendLine(name + " missing"); continue; }
var binds = AnimationUtility.GetCurveBindings(clip);
float maxRange = 0f;
string maxBind = "";
int moving = 0;
foreach (var b in binds)
{
var c = AnimationUtility.GetEditorCurve(clip, b);
if (c == null || c.keys.Length == 0) continue;
float mn = float.MaxValue, mx = float.MinValue;
foreach (var k in c.keys) { if (k.value < mn) mn = k.value; if (k.value > mx) mx = k.value; }
// also sample densely
for (float t = 0; t <= clip.length; t += clip.length / 20f)
{
float v = c.Evaluate(t);
if (v < mn) mn = v; if (v > mx) mx = v;
}
float range = mx - mn;
if (range > 1e-4f) moving++;
if (range > maxRange) { maxRange = range; maxBind = b.path + "/" + b.propertyName; }
}
sb.AppendLine($"{name}: len={clip.length:F3} binds={binds.Length} movingCurves={moving} maxRange={maxRange:F5} at {maxBind}");
}
// Compare bone rest vs sampled pose at mid Walk on instantiated prefab
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Characters/Animal/RockBall/RockBall_Rigged.prefab");
var go = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
var anim = go.GetComponentInChildren<Animator>();
anim.SetInteger("Motion", 1);
anim.Update(0f);
// record bone rotations at t=0 and after scrubbing
var bones = go.GetComponentsInChildren<Transform>().Where(t => t.name.StartsWith("bone_")).ToArray();
var r0 = bones.Select(b => b.localRotation).ToArray();
for (int i = 0; i < 30; i++) anim.Update(0.05f);
float maxAng = 0f;
string which = "";
for (int i = 0; i < bones.Length; i++)
{
float ang = Quaternion.Angle(r0[i], bones[i].localRotation);
if (ang > maxAng) { maxAng = ang; which = bones[i].name; }
}
sb.AppendLine($"prefab Walk scrub 1.5s maxBoneDeltaDeg={maxAng:F2} bone={which}");
Object.DestroyImmediate(go);
return sb.ToString();
}
}
#endif
+82
View File
@@ -0,0 +1,82 @@
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
/// <summary>
/// 把 CaoCao Prefab 摆到当前场景广场中心,Y 取地表碰撞高度。不保存进 TownSquare.prefab。
/// </summary>
public static class PlaceCaoCao
{
const string PrefabPath = "Assets/Bundles/Characters/Human/CaoCao/CaoCao.prefab";
public static string Run()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
if (prefab == null)
return "missing " + PrefabPath;
var existing = GameObject.Find("CaoCao_Preview");
if (existing != null)
Object.DestroyImmediate(existing);
var go = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
go.name = "CaoCao_Preview";
Physics.SyncTransforms();
var y = SampleGroundY(0f, 0f, out var sitInfo);
go.transform.position = new Vector3(0f, y, 0f);
go.transform.rotation = Quaternion.identity;
var bounds = default(Bounds);
var has = false;
foreach (var r in go.GetComponentsInChildren<Renderer>())
{
if (!has) { bounds = r.bounds; has = true; }
else bounds.Encapsulate(r.bounds);
}
return $"pos={go.transform.position} h={(has ? bounds.size.y : 0f):F3} groundY={y:F3} {sitInfo}";
}
static float SampleGroundY(float x, float z, out string info)
{
var plaza = GameObject.Find("TownSquare");
var cols = plaza != null
? plaza.GetComponentsInChildren<MeshCollider>()
: Object.FindObjectsByType<MeshCollider>(FindObjectsSortMode.None);
var y = 0f;
var hitAny = false;
var best = float.MaxValue;
var meshName = "";
var boundsTop = 0f;
foreach (var col in cols)
{
if (col == null || !col.enabled) continue;
meshName = col.sharedMesh != null ? col.sharedMesh.name : "null";
boundsTop = col.bounds.max.y;
var origins = new[]
{
new Vector3(x, 80f, z),
new Vector3(col.bounds.center.x, col.bounds.max.y + 8f, col.bounds.center.z),
new Vector3(x + 0.35f, 80f, z + 0.35f),
};
foreach (var origin in origins)
{
var ray = new Ray(origin, Vector3.down);
if (!col.Raycast(ray, out var hit, 160f)) continue;
if (hit.distance >= best) continue;
best = hit.distance;
y = hit.point.y;
hitAny = true;
}
}
if (!hitAny && boundsTop > 0.01f)
{
y = boundsTop;
info = $"fallbackBoundsTop={y:F3} mesh={meshName} cols={cols.Length}";
return y;
}
info = $"plaza={(plaza != null ? plaza.name : "null")} cols={cols.Length} mesh={meshName} meshHit={hitAny}";
return hitAny ? y : 0f;
}
}
#endif
@@ -0,0 +1,113 @@
using System.Collections.Generic;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
/// <summary>Temporary edit-mode view of PrototypeArena through BattleCamera. Does not save.</summary>
public static class PreviewBattleFraming
{
const string Arena = "Assets/Bundles/Scenes/Prototype/PrototypeArena.prefab";
static readonly List<GameObject> Hidden = new List<GameObject>();
static Scene Preview;
public static string Show()
{
Close();
for (int i = 0; i < SceneManager.sceneCount; i++)
{
var scene = SceneManager.GetSceneAt(i);
foreach (var root in scene.GetRootGameObjects())
{
if (!root.activeSelf) continue;
root.SetActive(false);
Hidden.Add(root);
}
}
Preview = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive);
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(Arena);
var arena = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
SceneManager.MoveGameObjectToScene(arena, Preview);
EditorSceneManager.SetActiveScene(Preview);
for (int lane = 0; lane < 3; lane++)
{
float x = lane == 0 ? 0 : lane == 1 ? -3f : 3f;
var hero = arena.transform.Find("Heroes/Hero" + lane);
hero.gameObject.SetActive(true);
hero.localPosition = new Vector3(x, 0, 0);
var enemy = arena.transform.Find("Enemies/Enemy" + lane);
enemy.gameObject.SetActive(true);
enemy.localPosition = new Vector3(x, 0, 8);
}
var camera = arena.transform.Find("BattleCamera").GetComponent<Camera>();
var view = SceneView.lastActiveSceneView;
if (view == null) throw new System.Exception("No Scene View.");
view.orthographic = camera.orthographic;
view.LookAt(camera.transform.position + camera.transform.forward * 12f, camera.transform.rotation, camera.orthographicSize);
view.camera.orthographic = camera.orthographic;
view.camera.orthographicSize = camera.orthographicSize;
view.Repaint();
var ground = arena.transform.Find("Greenfield/Ground/GroundMesh").GetComponentInChildren<Renderer>().bounds;
return "cameraPos=" + camera.transform.position + " ortho=" + camera.orthographicSize + " ground=" + ground.size + " center=" + ground.center;
}
public static string Measure()
{
GameObject arena = null;
for (int i = 0; i < SceneManager.sceneCount; i++)
{
foreach (var root in SceneManager.GetSceneAt(i).GetRootGameObjects())
if (root.name.Contains("PrototypeArena")) arena = root;
}
if (arena == null) throw new System.Exception("Battle preview is not open.");
var renderers = arena.GetComponentsInChildren<Renderer>(true);
int visible = 0;
var materials = new HashSet<Material>();
foreach (var renderer in renderers)
{
if (!renderer.enabled || !renderer.gameObject.activeInHierarchy) continue;
visible++;
foreach (var material in renderer.sharedMaterials) if (material != null) materials.Add(material);
}
return "visibleRenderers=" + visible + " materials=" + materials.Count;
}
public static string Status()
{
var report = new StringBuilder();
for (int i = 0; i < SceneManager.sceneCount; i++)
{
var scene = SceneManager.GetSceneAt(i);
report.Append(scene.name).Append(" path=").Append(scene.path).Append(" [");
foreach (var root in scene.GetRootGameObjects()) report.Append(root.name).Append(root.activeSelf ? "" : "(off)").Append(",");
report.Append("] ");
}
return report.ToString();
}
public static string Close()
{
var closed = new StringBuilder();
for (int i = SceneManager.sceneCount - 1; i >= 0; i--)
{
var scene = SceneManager.GetSceneAt(i);
if (!string.IsNullOrEmpty(scene.path)) continue;
EditorSceneManager.CloseScene(scene, true);
closed.Append(scene.name).Append(";");
}
int restored = 0;
for (int i = 0; i < SceneManager.sceneCount; i++)
{
foreach (var root in SceneManager.GetSceneAt(i).GetRootGameObjects())
{
if (root.activeSelf) continue;
root.SetActive(true);
restored++;
}
}
Preview = default;
Hidden.Clear();
return "closed=" + closed + " restored=" + restored;
}
}
@@ -0,0 +1,68 @@
using System;
using System.Linq;
using UnityEditor;
using UnityEngine;
/// <summary>Delete explicitly retired UI assets through Unity, after checking retained dependencies.</summary>
public static class RemoveAlternateUi
{
static readonly string[] Targets = {
"Assets/Bundles/UI/Prefab/UI_MainHUD_Studio.prefab",
"Assets/Bundles/UI/Prefab/UI_ConfirmDialog_Studio.prefab",
"Assets/Bundles/UI/Prefab/UI_MainHUD_Mint.prefab",
"Assets/Bundles/UI/Prefab/UI_ConfirmDialog_Mint.prefab",
"Assets/Bundles/UI/Prefab/UI_ThemeSwitcher.prefab",
"Assets/Bundles/UI/Texture/Studio",
"Assets/Bundles/UI/Texture/Mint",
"Assets/HotUpdate/Scripts/UI/ThemeSwitcherView.cs"
};
static bool Retired(string path) => Targets.Any(t => path == t || path.StartsWith(t + "/", StringComparison.Ordinal));
public static string Audit()
{
var owners = AssetDatabase.GetAllAssetPaths().Where(p => p.StartsWith("Assets/", StringComparison.Ordinal) && !Retired(p) &&
new[] { ".prefab", ".unity", ".asset", ".mat", ".controller" }.Any(e => p.EndsWith(e, StringComparison.Ordinal))).ToArray();
var conflicts = owners.SelectMany(p => AssetDatabase.GetDependencies(p, true).Where(Retired).Select(d => p + " -> " + d)).ToArray();
if (conflicts.Length > 0) throw new Exception("Retained assets reference retired UI:\n" + string.Join("\n", conflicts));
return "No retained serialized asset depends on retired UI; checked " + owners.Length + " assets.";
}
public static string Remove()
{
if (EditorApplication.isPlaying) throw new Exception("Exit Play Mode before deleting assets.");
Audit();
int count = 0;
foreach (var target in Targets)
{
if (AssetDatabase.LoadMainAssetAtPath(target) == null && !AssetDatabase.IsValidFolder(target)) continue;
if (!AssetDatabase.DeleteAsset(target)) throw new Exception("Unity could not delete " + target);
count++;
}
AssetDatabase.Refresh();
return "Deleted " + count + " retired UI assets/folders via AssetDatabase, including their Unity metadata.";
}
public static string FinalizeRemoval()
{
foreach (var target in Targets)
if (AssetDatabase.LoadMainAssetAtPath(target) != null || AssetDatabase.IsValidFolder(target)) throw new Exception("Retired asset still exists: " + target);
var result = AIGame.EditorTools.SetupUiBundleCollector.Setup();
var package = YooAsset.Editor.BundleCollectorSettingData.Setting.GetPackage("Art");
var group = package.Groups.Single(g => g.GroupName == "UI");
if (group.GroupDesc != "Candy UI") throw new Exception("Collector still advertises retired themes.");
foreach (var name in new[] { "UI_MainHUD", "UI_ConfirmDialog" })
{
var path = "Assets/Bundles/UI/Prefab/" + name + ".prefab";
if (AssetDatabase.LoadAssetAtPath<GameObject>(path) == null) throw new Exception("Candy prefab missing: " + name);
if (AssetDatabase.GetDependencies(path, true).Any(Retired)) throw new Exception("Candy still depends on retired themes.");
}
if (HotUpdate.UI.UiThemeCatalog.Normalize("studio") != "candy" || HotUpdate.UI.UiThemeCatalog.Normalize("mint") != "candy")
throw new Exception("Legacy theme fallback broken.");
foreach (var driver in Resources.FindObjectsOfTypeAll<AOT.Boot.BootDriver>().Where(d => d.gameObject.scene.IsValid()))
{
var settings = new SerializedObject(driver).FindProperty("settings.UiTheme");
if (settings != null && settings.stringValue != "candy") throw new Exception("Boot still selects a retired theme.");
}
return "8 retired targets absent; Candy HUD/dialog and dependencies intact; collector updated; old theme config falls back to Candy. " + result;
}
}
@@ -0,0 +1,85 @@
#if UNITY_EDITOR
using System;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
/// <summary>Persistent two-character review, isolated from the general review scene.</summary>
public static class ReviewRockBallWalk
{
const string Folder="Assets/Bundles/Scenes/Review";
public static string Build()
{
if(EditorApplication.isPlaying) throw new InvalidOperationException("Use Edit Mode");
for(int i=0;i<SceneManager.sceneCount;i++)
if(SceneManager.GetSceneAt(i).isDirty) throw new InvalidOperationException("Save pending scene edits before building Walk review");
var scene=EditorSceneManager.NewScene(NewSceneSetup.EmptyScene,NewSceneMode.Single);
var floorMaterial=AssetDatabase.LoadAssetAtPath<Material>(Folder+"/RockBallWalkFloor.mat");
if(floorMaterial==null)
{
floorMaterial=new Material(Shader.Find("Universal Render Pipeline/Lit"));
floorMaterial.color=new Color(.48f,.56f,.50f);
AssetDatabase.CreateAsset(floorMaterial,Folder+"/RockBallWalkFloor.mat");
}
var floor=GameObject.CreatePrimitive(PrimitiveType.Plane);
floor.name="Ground_Y0";
floor.transform.localScale=Vector3.one*.6f;
floor.GetComponent<Renderer>().sharedMaterial=floorMaterial;
for(int i=0;i<2;i++)
{
string root="Assets/Bundles/Characters/Animal/RockBall/";
string asset=i==0 ? "RockBall.prefab" : "RockBall_Rigged.prefab";
var go=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(root+asset));
go.name=i==0 ? "ApprovedStatic" : "WalkVerified";
go.transform.position=new Vector3(i==0 ? -.5f : .5f,0,0);
var label=new GameObject("NameLabel");
label.transform.SetParent(go.transform,false);
label.transform.position=go.transform.position+Vector3.up*.77f;
var text=label.AddComponent<TextMesh>();
text.text=i==0 ? "Approved static" : "12-bone rig";
text.anchor=TextAnchor.MiddleCenter;
label.transform.localScale=Vector3.one/go.transform.lossyScale.x;
text.characterSize=.012f; text.fontSize=42;
text.color=new Color(.12f,.18f,.2f);
var marker=GameObject.CreatePrimitive(PrimitiveType.Cube);
marker.name="ForwardMinusZ";
marker.transform.SetParent(go.transform,false);
// World dimensions: the approved static prefab root carries scale.
marker.transform.position=go.transform.position+new Vector3(0,.012f,-.4f);
marker.transform.localScale=new Vector3(.04f,.015f,.23f)/go.transform.lossyScale.x;
if(i==1)
{
var cycle=go.AddComponent<CharacterReviewAnimCycle>();
var so=new SerializedObject(cycle);
var names=so.FindProperty("motionNames");
names.arraySize=1; names.GetArrayElementAtIndex(0).stringValue="Walk";
so.ApplyModifiedPropertiesWithoutUndo();
cycle.enabled=false; cycle.enabled=true;
}
}
var sun=new GameObject("Sun").AddComponent<Light>();
sun.type=LightType.Directional; sun.intensity=1.3f; sun.shadows=LightShadows.Soft;
sun.transform.rotation=Quaternion.Euler(48,-30,0);
var camera=new GameObject("ReviewCamera").AddComponent<Camera>();
camera.tag="MainCamera";
camera.clearFlags=CameraClearFlags.SolidColor;
camera.backgroundColor=new Color(.66f,.76f,.81f);
camera.transform.position=new Vector3(1.15f,1.05f,-3.1f);
camera.transform.LookAt(new Vector3(0,.35f,0));
camera.orthographic=true; camera.orthographicSize=.85f;
camera.nearClipPlane=.05f; camera.farClipPlane=30;
camera.gameObject.AddComponent<AudioListener>();
RenderSettings.ambientMode=UnityEngine.Rendering.AmbientMode.Trilight;
RenderSettings.ambientSkyColor=new Color(.65f,.72f,.8f);
RenderSettings.ambientEquatorColor=new Color(.45f,.5f,.48f);
RenderSettings.ambientGroundColor=new Color(.3f,.28f,.25f);
AssetDatabase.SaveAssets();
string path=Folder+"/RockBallWalkReview.unity";
EditorSceneManager.SaveScene(scene,path);
var view=SceneView.lastActiveSceneView;
if(view!=null) { view.LookAt(new Vector3(0,.3f,0),Quaternion.Euler(12,-12,0),1.8f); view.Repaint(); }
return path;
}
}
#endif
@@ -0,0 +1,290 @@
#if UNITY_EDITOR
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
/// <summary>
/// 非人角色:同源TokenRig + UniMate三动作 → Generic资产;敌方入口还执行离线收尾与验证。
/// unity command run_script --file AgentScripts/SetupAnimalAnimator.cs --entry SetupAnimalAnimator.SetupRockBall
/// </summary>
public static class SetupAnimalAnimator
{
const string ArtPackageHint = "Assets/Bundles/Characters";
public static string SetupRockBall()
{
return Run("Animal", "RockBall", 0.55f);
}
// First validate one gait on the matching rig before accepting more actions.
public static string SetupRockBallWalk()
{
return Run("Animal", "RockBall", 0.55f, true);
}
/// <summary>
/// kind/actor/heightMeters — 从 Bundles/.../{actor}/Rigged/{actor}_Rigged.fbx 与 Anims/ 建 Prefab。
/// </summary>
public static string Run(string kind, string actor, float heightMeters = 0.55f, bool walkOnly = false)
{
if (string.IsNullOrWhiteSpace(actor))
throw new System.ArgumentException("actor required");
var root = string.IsNullOrWhiteSpace(kind)
? $"Assets/Bundles/Characters/{actor}"
: $"Assets/Bundles/Characters/{kind}/{actor}";
var riggedDir = $"{root}/Rigged";
var animDir = $"{root}/Anims";
var fbxPath = $"{riggedDir}/{actor}_Rigged.fbx";
if (!File.Exists(fbxPath))
throw new FileNotFoundException("missing rigged fbx", fbxPath);
AssetDatabase.Refresh();
ConfigureModelImporter(fbxPath, isCharacter: true, sourceAvatar: null);
var character = AssetDatabase.LoadAssetAtPath<GameObject>(fbxPath);
var avatar = character != null ? character.GetComponent<Animator>()?.avatar : null;
if (avatar == null)
{
// Avatar is on the imported model sub-asset.
foreach (var obj in AssetDatabase.LoadAllAssetsAtPath(fbxPath))
if (obj is Avatar av) { avatar = av; break; }
}
foreach (var clipName in walkOnly ? new[] { "Walk" } : new[] { "Idle", "Walk", "Attack" })
{
var animPath = $"{animDir}/{actor}_{clipName}.fbx";
if (File.Exists(animPath))
{
ConfigureModelImporter(animPath, isCharacter: false, sourceAvatar: avatar);
ValidateSkeleton(character, AssetDatabase.LoadAssetAtPath<GameObject>(animPath), animPath);
}
}
AssetDatabase.Refresh();
var controllerPath = $"{root}/{actor}_Animator.controller";
var controller = EnsureController(controllerPath, animDir, actor, walkOnly);
var prefabPath = EnsurePrefab(root, actor, fbxPath, controller, heightMeters);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
return $"rigged={fbxPath} controller={controllerPath} prefab={prefabPath} collect={ArtPackageHint}";
}
static void ValidateSkeleton(GameObject rig, GameObject animation, string path)
{
var expected = rig.GetComponentsInChildren<Transform>(true)
.Where(t => t.name.StartsWith("bone_"))
.Select(t => AnimationUtility.CalculateTransformPath(t, rig.transform)).OrderBy(p => p).ToArray();
var actual = animation.GetComponentsInChildren<Transform>(true)
.Where(t => t.name.StartsWith("bone_"))
.Select(t => AnimationUtility.CalculateTransformPath(t, animation.transform)).OrderBy(p => p).ToArray();
if (expected.Length == 0 || !expected.SequenceEqual(actual))
throw new System.InvalidOperationException($"Skeleton mismatch: rig={expected.Length} animation={actual.Length}: {path}. Use animation generated from this exact rig.");
}
static void ConfigureModelImporter(string path, bool isCharacter, Avatar sourceAvatar)
{
var importer = AssetImporter.GetAtPath(path) as ModelImporter;
if (importer == null) return;
importer.animationType = ModelImporterAnimationType.Generic;
if (isCharacter)
{
importer.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel;
importer.importAnimation = false;
importer.materialImportMode = ModelImporterMaterialImportMode.ImportViaMaterialDescription;
}
else
{
importer.avatarSetup = ModelImporterAvatarSetup.CopyFromOther;
if (sourceAvatar != null) importer.sourceAvatar = sourceAvatar;
importer.importAnimation = true;
importer.materialImportMode = ModelImporterMaterialImportMode.None;
}
importer.globalScale = 1f;
importer.useFileScale = false;
importer.bakeAxisConversion = true;
importer.SaveAndReimport();
}
static AnimatorController EnsureController(string controllerPath, string animDir, string actor, bool walkOnly)
{
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
if (controller == null)
{
controller = AnimatorController.CreateAnimatorControllerAtPath(controllerPath);
}
// Clear default layer states and rebuild Idle/Walk/Attack.
var sm = controller.layers[0].stateMachine;
foreach (var s in sm.states.ToArray())
sm.RemoveState(s.state);
foreach (var t in sm.anyStateTransitions.ToArray())
sm.RemoveAnyStateTransition(t);
foreach (var p in controller.parameters.ToArray())
controller.RemoveParameter(p);
controller.AddParameter("Motion", AnimatorControllerParameterType.Int);
if (!walkOnly) controller.AddParameter("Attack", AnimatorControllerParameterType.Trigger);
if (walkOnly)
{
var parameters = controller.parameters;
parameters[0].defaultInt = 1;
controller.parameters = parameters;
}
var clipDir = $"{Path.GetDirectoryName(controllerPath).Replace('\\', '/')}/Clips";
Directory.CreateDirectory(Path.GetFullPath(Path.Combine(Directory.GetParent(Application.dataPath).FullName, clipDir)));
var idleClip = walkOnly ? null : ExtractNamedClip($"{animDir}/{actor}_Idle.fbx", "Idle", clipDir);
var walkClip = ExtractNamedClip($"{animDir}/{actor}_Walk.fbx", "Walk", clipDir);
var attackClip = walkOnly ? null : ExtractNamedClip($"{animDir}/{actor}_Attack.fbx", "Attack", clipDir);
if (walkOnly)
{
if (walkClip == null) throw new FileNotFoundException("Walk clip missing under " + animDir);
var walkState = sm.AddState("Walk");
walkState.motion = walkClip;
sm.defaultState = walkState;
EditorUtility.SetDirty(controller);
return controller;
}
if (idleClip == null) throw new FileNotFoundException("Idle clip missing under " + animDir);
var idle = sm.AddState("Idle");
idle.motion = idleClip;
sm.defaultState = idle;
AnimatorState walk = null;
if (walkClip != null)
{
walk = sm.AddState("Walk");
walk.motion = walkClip;
var toWalk = idle.AddTransition(walk);
toWalk.hasExitTime = false;
toWalk.duration = 0.1f;
toWalk.AddCondition(AnimatorConditionMode.Equals, 1, "Motion");
var backIdle = walk.AddTransition(idle);
backIdle.hasExitTime = false;
backIdle.duration = 0.1f;
backIdle.AddCondition(AnimatorConditionMode.Equals, 0, "Motion");
}
if (attackClip != null)
{
var attack = sm.AddState("Attack");
attack.motion = attackClip;
foreach(var from in walk == null ? new[] { idle } : new[] { idle, walk })
{
var toAtk = from.AddTransition(attack);
toAtk.hasExitTime = false;
toAtk.duration = 0.05f;
toAtk.AddCondition(AnimatorConditionMode.If, 0, "Attack");
}
var atkBack = attack.AddTransition(idle);
atkBack.hasExitTime = true;
atkBack.exitTime = 0.98f;
atkBack.duration = 0.05f;
atkBack.AddCondition(AnimatorConditionMode.Equals, 0, "Motion");
if(walk != null)
{
var resume = attack.AddTransition(walk);
resume.hasExitTime = true;
resume.exitTime = 0.98f;
resume.duration = 0.05f;
resume.AddCondition(AnimatorConditionMode.Equals, 1, "Motion");
}
}
EditorUtility.SetDirty(controller);
return controller;
}
static AnimationClip ExtractNamedClip(string modelPath, string logicalName, string clipDir)
{
if (!File.Exists(modelPath)) return null;
var assets = AssetDatabase.LoadAllAssetsAtPath(modelPath);
var src = assets.OfType<AnimationClip>().FirstOrDefault(c => !c.name.StartsWith("__preview__"));
if (src == null) return null;
var destPath = $"{clipDir}/{logicalName}.anim";
var copy = Object.Instantiate(src);
copy.name = logicalName;
var settings = AnimationUtility.GetAnimationClipSettings(copy);
settings.loopTime = logicalName != "Attack";
AnimationUtility.SetAnimationClipSettings(copy, settings);
var existing = AssetDatabase.LoadAssetAtPath<AnimationClip>(destPath);
if (existing == null) AssetDatabase.CreateAsset(copy, destPath);
else
{
EditorUtility.CopySerialized(copy, existing);
Object.DestroyImmediate(copy);
EditorUtility.SetDirty(existing);
}
return AssetDatabase.LoadAssetAtPath<AnimationClip>(destPath);
}
static string EnsurePrefab(
string root,
string actor,
string fbxPath,
RuntimeAnimatorController controller,
float heightMeters)
{
var prefabPath = $"{root}/{actor}_Rigged.prefab";
var modelRoot = AssetDatabase.LoadAssetAtPath<GameObject>(fbxPath);
if (modelRoot == null) throw new FileNotFoundException("fbx not loaded", fbxPath);
// 外壳根节点供验收场景摆位(会写 localPosition);缩放/贴地放在子节点 Model。
var instance = new GameObject(actor + "_Rigged");
var model = (GameObject)PrefabUtility.InstantiatePrefab(modelRoot, instance.transform);
model.name = "Model";
model.transform.localPosition = Vector3.zero;
model.transform.localRotation = Quaternion.identity;
model.transform.localScale = Vector3.one;
// SkinnedMeshRenderer.bounds is a conservative animation/culling box,
// not actual rest geometry. Bake at unit model scale before sizing.
var bounds = BindBounds(model);
if (bounds.size.y > 0.01f)
{
float scale = heightMeters / bounds.size.y;
model.transform.localScale = Vector3.one * scale;
model.transform.localPosition = -new Vector3(bounds.center.x, bounds.min.y, bounds.center.z) * scale;
}
// 挂回已验收静态模的 XPbr(TokenRig 导出材质通常较差)
var mat = AssetDatabase.LoadAssetAtPath<Material>($"{root}/{actor}.mat");
if (mat != null)
{
foreach (var renderer in model.GetComponentsInChildren<Renderer>(true))
renderer.sharedMaterial = mat;
}
var animator = model.GetComponent<Animator>();
if (animator == null) animator = model.AddComponent<Animator>();
animator.runtimeAnimatorController = controller;
animator.applyRootMotion = false;
animator.cullingMode = AnimatorCullingMode.AlwaysAnimate;
PrefabUtility.SaveAsPrefabAsset(instance, prefabPath);
Object.DestroyImmediate(instance);
return prefabPath;
}
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 v in mesh.vertices)
{
var p = model.transform.InverseTransformPoint(skin.transform.TransformPoint(v));
if (!has) { bounds = new Bounds(p, Vector3.zero); has = true; }
else bounds.Encapsulate(p);
}
Object.DestroyImmediate(mesh);
}
if (!has) throw new System.InvalidOperationException("No skinned bind geometry in " + model.name);
return bounds;
}
}
#endif
+237
View File
@@ -0,0 +1,237 @@
#if UNITY_EDITOR
using System.IO;
using UnityEditor;
using UnityEngine;
using YooAsset.Editor;
/// <summary>
/// 新角色 Prefab:Y-up、底面原点、局部 -Z 正面,不做 +90X。
/// unity command run_script --file AgentScripts/SetupCharacter.cs --entry SetupCharacter.Run --args "[\"Human\",\"CaoCao\"]"
/// </summary>
public static class SetupCharacter
{
const string XPbrShaderName = "Test/XPbr";
const string ArtPackage = "Art";
const float TargetHeight = 1.8f;
public static string SetupCaoCao()
{
return Run("Human", "CaoCao");
}
public static string SetupLmGirl()
{
return Run("Human", "LmGirl", 1.65f);
}
public static string SetupLmCat()
{
return Run("Animal", "LmCat", 0.42f);
}
public static string SetupRockBall()
{
return Run("Animal", "RockBall", 0.55f);
}
public static string Run(string kind, string actor, float heightMeters = TargetHeight)
{
if (string.IsNullOrWhiteSpace(actor))
throw new System.ArgumentException("actor required");
if (heightMeters < 0.05f)
throw new System.ArgumentException("heightMeters too small");
var root = string.IsNullOrWhiteSpace(kind)
? $"Assets/Bundles/Characters/{actor}"
: $"Assets/Bundles/Characters/{kind}/{actor}";
AssetDatabase.Refresh();
var tex = EnsureTextureImports(root, actor);
var prefab = EnsureMeshPrefab(root, actor, heightMeters);
var art = EnsureArtCollector();
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
return $"{root} tex={tex} prefab={prefab} art={art}";
}
static string EnsureTextureImports(string root, string actor)
{
var fbxPath = $"{root}/{actor}.fbx";
var matPath = $"{root}/{actor}.mat";
var paths = new[]
{
$"{root}/{actor}_base.png",
$"{root}/{actor}_mix.png",
};
var ok = 0;
foreach (var path in paths)
{
if (!File.Exists(path)) continue;
var importer = AssetImporter.GetAtPath(path) as TextureImporter;
if (importer == null) continue;
var isMix = path.EndsWith("_mix.png");
importer.textureType = TextureImporterType.Default;
importer.sRGBTexture = !isMix;
importer.mipmapEnabled = true;
importer.alphaIsTransparency = !isMix;
importer.SaveAndReimport();
ok++;
}
if (!File.Exists(fbxPath))
return $"imported={ok} fbx=missing";
var model = AssetImporter.GetAtPath(fbxPath) as ModelImporter;
if (model != null)
{
model.globalScale = 1f;
model.useFileScale = false;
model.bakeAxisConversion = true;
model.materialImportMode = File.Exists(matPath)
? ModelImporterMaterialImportMode.ImportViaMaterialDescription
: ModelImporterMaterialImportMode.None;
model.meshCompression = ModelImporterMeshCompression.Medium;
model.isReadable = false;
model.SaveAndReimport();
}
return $"imported={ok} fbx=yes";
}
static string EnsureMeshPrefab(string root, string actor, float heightMeters)
{
var fbxPath = $"{root}/{actor}.fbx";
var basePath = $"{root}/{actor}_base.png";
var mixPath = $"{root}/{actor}_mix.png";
var matPath = $"{root}/{actor}.mat";
var prefabPath = $"{root}/{actor}.prefab";
if (!File.Exists(fbxPath))
return "missing fbx";
var baseMap = AssetDatabase.LoadAssetAtPath<Texture2D>(basePath);
var mixMap = AssetDatabase.LoadAssetAtPath<Texture2D>(mixPath);
if (baseMap == null)
return "missing base png";
var xpbr = Shader.Find(XPbrShaderName);
var urpLit = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
var useXpbr = xpbr != null && mixMap != null;
var shader = useXpbr ? xpbr : urpLit;
var mat = AssetDatabase.LoadAssetAtPath<Material>(matPath);
if (mat == null)
{
mat = new Material(shader);
AssetDatabase.CreateAsset(mat, matPath);
}
else
{
mat.shader = shader;
}
if (useXpbr)
{
if (mat.HasProperty("_MainTex")) mat.SetTexture("_MainTex", baseMap);
if (mat.HasProperty("_MixTex")) mat.SetTexture("_MixTex", mixMap);
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");
}
else if (mat.HasProperty("_BaseMap"))
{
mat.SetTexture("_BaseMap", baseMap);
}
EditorUtility.SetDirty(mat);
var modelRoot = AssetDatabase.LoadAssetAtPath<GameObject>(fbxPath);
if (modelRoot == null)
return "fbx not loaded";
var instance = (GameObject)PrefabUtility.InstantiatePrefab(modelRoot);
instance.name = actor;
foreach (var renderer in instance.GetComponentsInChildren<Renderer>())
renderer.sharedMaterial = mat;
instance.transform.localPosition = Vector3.zero;
instance.transform.localScale = Vector3.one;
instance.transform.localRotation = Quaternion.identity;
var bounds = MeshBounds(instance);
if (bounds.size.y > 0.01f)
instance.transform.localScale = Vector3.one * (heightMeters / bounds.size.y);
PrefabUtility.SaveAsPrefabAsset(instance, prefabPath);
Object.DestroyImmediate(instance);
return prefabPath + (useXpbr ? " (XPbr)" : " (URP Lit)");
}
static Bounds MeshBounds(GameObject root)
{
Bounds bounds = default;
var has = false;
foreach (var r in root.GetComponentsInChildren<Renderer>())
{
if (!has)
{
bounds = r.bounds;
has = true;
}
else bounds.Encapsulate(r.bounds);
}
return has ? bounds : new Bounds(root.transform.position, Vector3.one * 0.01f);
}
static string EnsureArtCollector()
{
var setting = BundleCollectorSettingData.Setting;
setting.ShowPackageView = true;
var package = setting.Packages.Find(p => p.PackageName == ArtPackage);
if (package == null)
package = BundleCollectorSettingData.CreatePackage(ArtPackage);
package.PackageDesc = "角色/场景美术";
package.EnableAddressable = true;
package.SupportExtensionless = true;
package.AutoCollectShaders = true;
package.IgnoreRuleName = nameof(NormalIgnoreRule);
BundleCollectorSettingData.ModifyPackage(package);
var group = package.Groups.Find(g => g.GroupName == "Characters");
if (group == null)
group = BundleCollectorSettingData.CreateGroup(package, "Characters");
group.ActiveRuleName = nameof(EnableGroup);
group.AssetTags = "art";
BundleCollectorSettingData.ModifyGroup(package, group);
const string collectPath = "Assets/Bundles/Characters";
var collector = group.Collectors.Find(c => c.CollectPath == collectPath);
if (collector == null)
{
collector = new BundleCollector
{
CollectPath = collectPath,
CollectorGUID = AssetDatabase.AssetPathToGUID(collectPath),
CollectorType = ECollectorType.MainAssetCollector,
AddressRuleName = nameof(AddressByFileName),
PackRuleName = nameof(PackDirectory),
FilterRuleName = nameof(CollectAll),
AssetTags = "art",
};
BundleCollectorSettingData.CreateCollector(group, collector);
}
else
{
collector.CollectorGUID = AssetDatabase.AssetPathToGUID(collectPath);
collector.AddressRuleName = nameof(AddressByFileName);
collector.PackRuleName = nameof(PackDirectory);
collector.FilterRuleName = nameof(CollectAll);
BundleCollectorSettingData.ModifyCollector(group, collector);
}
package.CheckConfigError();
BundleCollectorSettingData.SaveFile();
return $"art collect={collectPath}";
}
}
#endif
+140
View File
@@ -0,0 +1,140 @@
#if UNITY_EDITOR
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEngine;
/// <summary>
/// LmHouses 十二栋:XPbr Prefab,按高度缩放到场景可用尺寸。原点已在底面,不做 +90X。
/// unity command run_script --file AgentScripts/SetupLmHouses.cs --entry SetupLmHouses.Run
/// </summary>
public static class SetupLmHouses
{
const string Root = "Assets/Bundles/Scenes/Test/LmHouses";
const string XPbrShaderName = "Test/XPbr";
static readonly (string name, float height)[] Houses =
{
("cream_tile", 7.0f),
("cream_red", 7.2f),
("white_balcony", 7.0f),
("brown_wood", 6.8f),
("cream_porch", 7.2f),
("stone_balcony", 7.0f),
("thatched", 4.4f),
("white_blue", 7.0f),
("wood_fence", 6.6f),
("shop_awning", 7.0f),
("picket", 6.8f),
("gate", 7.0f),
};
public static string Run()
{
AssetDatabase.Refresh();
var reports = new List<string>();
foreach (var (name, height) in Houses)
reports.Add(EnsurePrefab(name, height));
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
return string.Join(" | ", reports);
}
static string EnsurePrefab(string name, float height)
{
var dir = $"{Root}/{name}";
var fbxPath = $"{dir}/{name}.fbx";
var basePath = $"{dir}/{name}_base.png";
var mixPath = $"{dir}/{name}_mix.png";
var matPath = $"{dir}/{name}.mat";
var prefabPath = $"{dir}/{name}.prefab";
if (!File.Exists(fbxPath))
return name + ":missing";
ConfigureTexture(basePath, srgb: true);
ConfigureTexture(mixPath, srgb: false);
var model = AssetImporter.GetAtPath(fbxPath) as ModelImporter;
if (model != null)
{
model.globalScale = 1f;
model.useFileScale = false;
model.bakeAxisConversion = true;
model.materialImportMode = File.Exists(matPath)
? ModelImporterMaterialImportMode.ImportViaMaterialDescription
: ModelImporterMaterialImportMode.None;
model.meshCompression = ModelImporterMeshCompression.Medium;
model.isReadable = false;
model.SaveAndReimport();
}
var baseMap = AssetDatabase.LoadAssetAtPath<Texture2D>(basePath);
var mixMap = AssetDatabase.LoadAssetAtPath<Texture2D>(mixPath);
var xpbr = Shader.Find(XPbrShaderName);
var urpLit = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
var useXpbr = xpbr != null && baseMap != null && mixMap != null;
var shader = useXpbr ? xpbr : urpLit;
var mat = AssetDatabase.LoadAssetAtPath<Material>(matPath);
if (mat == null)
{
mat = new Material(shader);
AssetDatabase.CreateAsset(mat, matPath);
}
else
{
mat.shader = shader;
}
if (useXpbr)
{
if (mat.HasProperty("_MainTex")) mat.SetTexture("_MainTex", baseMap);
if (mat.HasProperty("_MixTex")) mat.SetTexture("_MixTex", mixMap);
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);
}
else if (baseMap != null && mat.HasProperty("_BaseMap"))
{
mat.SetTexture("_BaseMap", baseMap);
}
EditorUtility.SetDirty(mat);
var modelRoot = AssetDatabase.LoadAssetAtPath<GameObject>(fbxPath);
if (modelRoot == null)
return name + ":fbx-not-loaded";
var instance = (GameObject)PrefabUtility.InstantiatePrefab(modelRoot);
instance.name = name;
foreach (var renderer in instance.GetComponentsInChildren<Renderer>())
renderer.sharedMaterial = mat;
instance.transform.localPosition = Vector3.zero;
instance.transform.localRotation = Quaternion.identity;
instance.transform.localScale = Vector3.one;
var bounds = default(Bounds);
var has = false;
foreach (var renderer in instance.GetComponentsInChildren<Renderer>())
{
if (!has) { bounds = renderer.bounds; has = true; }
else bounds.Encapsulate(renderer.bounds);
}
if (has && bounds.size.y > 0.01f)
instance.transform.localScale = Vector3.one * (height / bounds.size.y);
PrefabUtility.SaveAsPrefabAsset(instance, prefabPath);
Object.DestroyImmediate(instance);
return name + "=" + height.ToString("0.0");
}
static void ConfigureTexture(string path, bool srgb)
{
if (!File.Exists(path)) return;
var importer = AssetImporter.GetAtPath(path) as TextureImporter;
if (importer == null) return;
importer.textureType = TextureImporterType.Default;
importer.sRGBTexture = srgb;
importer.mipmapEnabled = true;
importer.alphaIsTransparency = srgb;
importer.SaveAndReimport();
}
}
#endif
+136
View File
@@ -0,0 +1,136 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
public static class VerifyAnimalWalk
{
const string Root = "Assets/Bundles/Characters/Animal/RockBall";
public static Vector3[] WorldVertices(GameObject go)
{
return go.GetComponentsInChildren<SkinnedMeshRenderer>().SelectMany(s =>
{
var mesh = s.sharedMesh;
var poses = mesh.bindposes;
var matrices = s.bones.Select((b,i) => b.localToWorldMatrix * poses[i]).ToArray();
var weights = mesh.boneWeights;
return mesh.vertices.Select((v,i) =>
{
var w = weights[i];
return matrices[w.boneIndex0].MultiplyPoint3x4(v) * w.weight0
+ matrices[w.boneIndex1].MultiplyPoint3x4(v) * w.weight1
+ matrices[w.boneIndex2].MultiplyPoint3x4(v) * w.weight2
+ matrices[w.boneIndex3].MultiplyPoint3x4(v) * w.weight3;
}).ToArray();
}).ToArray();
}
public static Bounds GeometryBounds(GameObject go)
{
var points = WorldVertices(go);
var result = new Bounds(points[0], Vector3.zero);
foreach (var p in points) result.Encapsulate(p);
return result;
}
public static string Inspect() => Check(false);
public static string Run() => Check(true);
public static string Live()
{
if(!EditorApplication.isPlaying) throw new Exception("Live check requires Play Mode");
var unit=GameObject.Find("WalkVerified");
if(unit==null) throw new Exception("Open RockBallWalkReview first");
var animator=unit.GetComponentInChildren<Animator>();
var state=animator.GetCurrentAnimatorStateInfo(0);
var bounds=GeometryBounds(unit);
if(!state.IsName("Walk") || animator.applyRootMotion || bounds.min.y < -.01f || bounds.min.y > .01f)
throw new Exception($"Unexpected live gait: {state.shortNameHash} bottom={bounds.min.y}");
return $"PASS Play Mode Walk time={state.normalizedTime:F3} bottom={bounds.min.y:F5} height={bounds.size.y:F5}";
}
static string Check(bool strict)
{
var sb = new StringBuilder();
var go = Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>(Root + "/RockBall_Rigged.prefab"));
try
{
var anim = go.GetComponentInChildren<Animator>();
anim.enabled = false;
var model = anim.gameObject;
var bones = model.GetComponentsInChildren<Transform>().Where(t => t.name.StartsWith("bone_")).ToArray();
var bind = GeometryBounds(go);
sb.AppendLine($"BIND height={bind.size.y:F5} minY={bind.min.y:F5} size={bind.size}");
var restPositions = WorldVertices(go);
var staticGo = Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>(Root + "/RockBall.prefab"));
float shapeError;
try
{
var points = staticGo.GetComponentsInChildren<MeshFilter>().SelectMany(f => f.sharedMesh.vertices.Select(v => f.transform.TransformPoint(v))).ToArray();
shapeError = restPositions.Max(v => points.Min(p => Vector3.Distance(v,p)));
}
finally { Object.DestroyImmediate(staticGo); }
sb.AppendLine($"BIND vs approved static maxVertexError={shapeError:F6}m bones={bones.Length}");
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(Root + "/Clips/Walk.anim");
var root = bones.First(t => t.name == "bone_0");
float minY = float.MaxValue, maxBottom = float.MinValue, tilt = 0, minHeight = float.MaxValue;
float motion = 0, seam = 0;
Vector3[] start = null;
var rotations = new Quaternion[bones.Length];
var rightFoot = bones.First(t => t.name == "bone_9");
var leftFoot = bones.First(t => t.name == "bone_11");
float minFootPhase = float.MaxValue, maxFootPhase = float.MinValue;
var rightSamples = new Vector3[61];
var leftSamples = new Vector3[61];
for (int f = 0; f <= 60; f++)
{
clip.SampleAnimation(model, clip.length * f / 60f);
var bounds = GeometryBounds(go);
minY = Mathf.Min(minY, bounds.min.y);
maxBottom = Mathf.Max(maxBottom, bounds.min.y);
minHeight = Mathf.Min(minHeight, bounds.size.y);
tilt = Mathf.Max(tilt, Vector3.Angle(root.up, Vector3.up));
rightSamples[f] = rightFoot.position-root.position;
leftSamples[f] = leftFoot.position-root.position;
float phase = rightSamples[f].z-leftSamples[f].z;
minFootPhase = Mathf.Min(minFootPhase,phase);
maxFootPhase = Mathf.Max(maxFootPhase,phase);
if (f == 0)
{
start = WorldVertices(go);
for (int b = 0; b < bones.Length; b++) rotations[b] = bones[b].localRotation;
}
for (int b = 0; b < bones.Length; b++) motion = Mathf.Max(motion, Quaternion.Angle(rotations[b], bones[b].localRotation));
if (f == 60)
{
var end = WorldVertices(go);
seam = end.Select((v,i) => Vector3.Distance(v,start[i])).Max();
}
if (f % 15 == 0) sb.AppendLine($"Walk {f}/60 minY={bounds.min.y:F5} height={bounds.size.y:F5} rootTilt={Vector3.Angle(root.up,Vector3.up):F2}");
}
sb.AppendLine($"RESULT len={clip.length:F4} loop={AnimationUtility.GetAnimationClipSettings(clip).loopTime} minY={minY:F5} maxBottom={maxBottom:F5} minHeight={minHeight:F5} maxRootTilt={tilt:F2} maxJointMotion={motion:F2} seamVertexMax={seam:F6}");
float rightStride = rightSamples.Max(p=>p.z)-rightSamples.Min(p=>p.z);
float leftStride = leftSamples.Max(p=>p.z)-leftSamples.Min(p=>p.z);
sb.AppendLine($"FEET rightStride={rightStride:F5}m leftStride={leftStride:F5}m relativePhaseRange={maxFootPhase-minFootPhase:F5}m");
// Verify the real controller through several loops as well as clip sampling.
anim.enabled = true;
anim.Rebind();
anim.SetInteger("Motion",1);
anim.Play("Walk",0,0);
anim.Update(0);
for (int i = 0; i < 180; i++) anim.Update(1f/30f);
var state = anim.GetCurrentAnimatorStateInfo(0);
sb.AppendLine($"ANIMATOR stateWalk={state.IsName("Walk")} normalizedTime={state.normalizedTime:F2}");
if (strict && (shapeError > .002f || Mathf.Abs(bind.size.y-.55f) > .002f || Mathf.Abs(bind.min.y) > .002f
|| minY < -.01f || maxBottom > .025f || minHeight < .46f || tilt > 15f || motion < 5f || seam > .002f
|| rightStride < .005f || leftStride < .005f || maxFootPhase-minFootPhase < .01f || !state.IsName("Walk")))
throw new Exception(sb.ToString());
var report = Path.GetFullPath(Path.Combine(Application.dataPath,"../../Tools/output/rockball_walk_audit/verification.txt"));
Directory.CreateDirectory(Path.GetDirectoryName(report));
File.WriteAllText(report,sb.ToString());
return (strict ? "PASS\n" : "INSPECT\n") + sb;
}
finally { Object.DestroyImmediate(go); }
}
}
#endif
+131
View File
@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using HotUpdate.Net;
using HotUpdate.UI;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
/// <summary>Play Mode check that PrototypeArena loads Greenfield without bridges.</summary>
public static class VerifyBattlefield
{
const string Endpoint = "http://127.0.0.1:18081";
static PrototypeFlow Flow => UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
static void Check(bool value, string message) { if (!value) throw new Exception(message); }
static T Get<T>(string name) => (T)typeof(PrototypeFlow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).GetValue(Flow);
static void Set(string name, object value) => typeof(PrototypeFlow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).SetValue(Flow, value);
static async Task Until(Func<bool> condition, int seconds = 25)
{
var end = DateTime.UtcNow.AddSeconds(seconds);
while (!condition())
{
Check(DateTime.UtcNow < end, "Battlefield timed out: " + Flow?.Page);
await Task.Delay(100);
}
}
static Button Button(string name) => UnityEngine.Object.FindObjectsOfType<Button>().Single(b => b.name == "Btn_" + name && b.gameObject.activeInHierarchy);
static void Click(string name)
{
var b = Button(name);
Check(b.interactable, "Disabled " + name);
Canvas.ForceUpdateCanvases();
var rect = b.GetComponent<RectTransform>();
var pointer = new PointerEventData(EventSystem.current) { position = RectTransformUtility.WorldToScreenPoint(null, rect.TransformPoint(rect.rect.center)), button = PointerEventData.InputButton.Left };
var hits = new List<RaycastResult>();
EventSystem.current.RaycastAll(pointer, hits);
Check(hits.Count > 0 && ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject) == b.gameObject, "Blocked " + name);
ExecuteEvents.Execute(b.gameObject, pointer, ExecuteEvents.pointerClickHandler);
}
static async Task Capture(string name)
{
await Task.Delay(200);
var type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("Unity.Pipeline.Editor.Commands.Capture.CaptureCommands")).First(t => t != null);
type.GetMethod("CaptureGameView", BindingFlags.Public | BindingFlags.Static).Invoke(null, new object[] { 1080, 1920, null, "Screenshots/battlefield_" + name + ".png", false, 0, "screen" });
}
public static string Assets()
{
var arena = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Scenes/Prototype/PrototypeArena.prefab");
Check(arena != null, "Missing PrototypeArena");
Check(arena.transform.Find("Greenfield") != null, "PrototypeArena missing Greenfield");
Check(arena.transform.Find("Greenfield/Ground/GroundMesh") != null, "Missing Greenfield ground");
Check(arena.transform.Find("Greenfield/Decorations/tree") != null, "Missing tree");
Check(arena.transform.Find("Greenfield/Decorations/rock") != null, "Missing rock");
Check(arena.transform.Find("Greenfield/Decorations/sign") != null, "Missing sign");
Check(arena.transform.Find("Greenfield/Decorations/shrub") != null, "Missing shrub");
Check(arena.transform.Find("Greenfield/Decorations/bridge") == null, "bridge still nested");
Check(arena.transform.Find("Greenfield/Decorations/bridgeLeft") == null, "bridgeLeft still nested");
Check(arena.transform.Find("Greenfield/Decorations/bridgeRight") == null, "bridgeRight still nested");
Check(arena.transform.Find("Heroes") != null && arena.transform.Find("BattleCamera") != null, "Gameplay nodes missing");
var ground = arena.transform.Find("Ground");
Check(ground == null || !ground.gameObject.activeSelf, "Primitive ground still active");
var badLayer = arena.transform.Find("Greenfield").GetComponentsInChildren<Renderer>(true).FirstOrDefault(r => r.gameObject.layer != 30);
Check(badLayer == null, "Greenfield renderer not on layer 30: " + badLayer?.name);
return "PASS authored PrototypeArena nests Greenfield without bridges.";
}
public static async Task<string> Play()
{
Check(EditorApplication.isPlaying, "Enter Play Mode first");
await Until(() => Flow?.Game != null && !Flow.IsBusy);
SessionState.SetString("AIGame.Battlefield.original", JsonUtility.ToJson(Flow.Game.State));
SessionState.SetString("AIGame.Battlefield.originalEndpoint", Get<string>("_httpBase"));
AuthResponse account = null; string error = null; bool done = false;
string user = "battlefield_" + Guid.NewGuid().ToString("N");
Directory.CreateDirectory(Path.Combine(Application.dataPath, "../../Tools/output/battlefield"));
File.WriteAllText(Path.Combine(Application.dataPath, "../../Tools/output/battlefield/account.txt"), user);
Flow.StartCoroutine(AuthApi.Register(Endpoint, user, "isolated-battlefield-password", (r, e) => { account = r; error = e; done = true; }));
await Until(() => done);
Check(account != null, "Isolated register failed " + error);
var session = new SessionData { Pid = account.pid, Token = account.token, Username = user };
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_session", new Func<SessionData>(() => session));
Set("_profile", new Func<long>(() => session.Pid));
Set("_httpBase", Endpoint);
Set("_boundProfile", 0L);
PlayerPrefs.DeleteKey("AIGame.G1.pending." + Uri.EscapeDataString(Endpoint) + "." + session.Pid);
Flow.Handle("Home");
await Until(() => Flow.Game != null && !Flow.IsBusy);
Check(Flow.Game.State.formation != null && Flow.Game.State.formation.Any(p => p >= 0), "Fresh account has empty formation");
Click("Adventure");
await Until(() => Flow.Page == "UI_PrototypeStages");
Click("Stage1");
await Until(() => Flow.Page == "UI_PrototypeFormation");
Click("StartBattle");
await Until(() => Flow.Page == "UI_PrototypeBattle" && Flow.Battle != null);
var arena = Get<GameObject>("_arena");
Check(arena != null, "Battle arena was not instantiated");
Check(arena.transform.Find("Greenfield/Ground/GroundMesh") != null, "Runtime Greenfield ground missing");
Check(arena.transform.Find("Greenfield/Decorations/tree") != null, "Runtime tree missing");
Check(arena.transform.Find("Greenfield/Decorations/bridge") == null, "Runtime bridge present");
Check(arena.transform.Find("Greenfield/Decorations/bridgeLeft") == null, "Runtime bridgeLeft present");
Check(arena.transform.Find("Greenfield/Decorations/bridgeRight") == null, "Runtime bridgeRight present");
var ground = arena.transform.Find("Ground");
Check(ground == null || !ground.gameObject.activeSelf, "Runtime primitive ground still active");
Click("Speed");
await Until(() => Flow.Battle.Time >= 8);
await Capture("play_battle");
return "PASS play battle loads Greenfield without bridges; screenshot battlefield_play_battle.png";
}
public static async Task<string> Cleanup()
{
var net = UnityEngine.Object.FindObjectOfType<GameNet>();
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_session", new Func<SessionData>(() => net.Session));
Set("_profile", new Func<long>(() => net.Session?.Pid ?? 0));
Set("_httpBase", SessionState.GetString("AIGame.Battlefield.originalEndpoint", "http://127.0.0.1:8080"));
Set("_boundProfile", 0L);
Flow.Handle("Home");
await Until(() => Flow.Game != null && !Flow.IsBusy);
var original = JsonUtility.FromJson<PrototypeSave>(SessionState.GetString("AIGame.Battlefield.original", ""));
Check(Flow.Game.State.coins == original.coins && Flow.Game.State.candy == original.candy && Flow.Game.State.shots == original.shots && Flow.Game.State.level == original.level, "Real account was changed by battlefield test");
return "PASS cleanup: real account restored; battlefield check used isolated :18081 only.";
}
}
+126
View File
@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using HotUpdate.Net;
using HotUpdate.UI;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
public static class VerifyCamp
{
static PrototypeFlow Flow => UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
static void Check(bool b,string message) {if(!b) throw new Exception(message);}
static T Get<T>(string field)=>(T)typeof(PrototypeFlow).GetField(field,BindingFlags.NonPublic|BindingFlags.Instance).GetValue(Flow);
static void Set(string field,object value)=>typeof(PrototypeFlow).GetField(field,BindingFlags.NonPublic|BindingFlags.Instance).SetValue(Flow,value);
static async Task Until(Func<bool> condition,int seconds=20)
{var end=DateTime.UtcNow.AddSeconds(seconds);while(!condition()){Check(DateTime.UtcNow<end,"Camp timed out at "+Flow?.Page);await Task.Delay(100);}}
static Button Button(string name)=>UnityEngine.Object.FindObjectsOfType<Button>().Single(b=>b.name=="Btn_"+name&&b.gameObject.activeInHierarchy);
static void Click(string name)
{
var b=Button(name);Check(b.interactable,"Disabled "+name);Canvas.ForceUpdateCanvases();var rect=b.GetComponent<RectTransform>();
var pointer=new PointerEventData(EventSystem.current){position=RectTransformUtility.WorldToScreenPoint(null,rect.TransformPoint(rect.rect.center)),button=PointerEventData.InputButton.Left};
var hits=new List<RaycastResult>();EventSystem.current.RaycastAll(pointer,hits);
Check(hits.Count>0&&ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject)==b.gameObject,"Camp click blocked "+name);
ExecuteEvents.Execute(b.gameObject,pointer,ExecuteEvents.pointerClickHandler);
}
static async Task Capture(string name)
{
await Task.Delay(150);
var type=AppDomain.CurrentDomain.GetAssemblies().Select(a=>a.GetType("Unity.Pipeline.Editor.Commands.Capture.CaptureCommands")).First(t=>t!=null);
type.GetMethod("CaptureGameView",BindingFlags.Static|BindingFlags.Public).Invoke(null,new object[]{1080,1920,null,"Screenshots/camp_"+name+".png",false,0,"screen"});
}
static async Task SyncHome()
{Flow.Handle("Home");Set("_needsSync",true);Flow.Handle("Home");await Until(()=>!Flow.IsBusy);await Task.Delay(150);}
public static string Assets()
{
foreach(string screen in new[]{"Camp","Build"})
{
var prefab=AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/UI_Prototype"+screen+".prefab");Check(prefab!=null,"Missing camp prefab");
Check(prefab.GetComponentsInChildren<TextMeshProUGUI>(true).All(t=>t.font!=null),"Missing camp font");
foreach(var i in prefab.GetComponentsInChildren<Image>(true).Where(i=>i.type==Image.Type.Sliced)) Check(i.sprite!=null&&i.sprite.border.x>0&&i.sprite.border.y>0,"Invalid camp slice");
}
for(int i=0;i<3;i++)
{
Check(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Scenes/Prototype/CampDecoration"+i+".prefab")!=null,"Missing decoration");
Check(AssetDatabase.LoadAssetAtPath<Sprite>("Assets/Bundles/UI/Texture/Prototype/proto_decor_"+i+".png")!=null,"Missing decoration portrait");
}
return "PASS two camp JSON prefabs, fonts/nine-slices and three authored decorations/portraits.";
}
public static async Task<string> Start()
{
await Until(()=>Flow?.Game!=null&&!Flow.IsBusy);
SessionState.SetString("AIGame.G1.original",JsonUtility.ToJson(Flow.Game.State));SessionState.SetString("AIGame.G1.originalEndpoint",Get<string>("_httpBase"));
AuthResponse account=null;string error=null;bool done=false;string user="g1live_camp_"+Guid.NewGuid().ToString("N");
Flow.StartCoroutine(AuthApi.Register("http://127.0.0.1:18081",user,"isolated-g1-test-password",(r,e)=>{account=r;error=e;done=true;}));
await Until(()=>done);Check(account!=null,"Camp register failed "+error);
File.WriteAllText(Path.Combine(Application.dataPath,"../../Tools/output/g1-live/account.txt"),user);
var session=new SessionData{Pid=account.pid,Token=account.token,Username=user};
Set("_session",new Func<SessionData>(()=>session));Set("_profile",new Func<long>(()=>session.Pid));Set("_httpBase","http://127.0.0.1:18081");Set("_boundProfile",0L);
Flow.Handle("Home");await Until(()=>!Flow.IsBusy);await Task.Delay(150);
Click("NavHome");await Until(()=>Flow.Page=="UI_PrototypeCamp");
Check(!Button("Construction").interactable&&!Button("Interact").interactable&&!Button("Training").interactable&&!Button("BallTree").interactable,"Fresh facilities unlocked too soon");await Capture("locked");
Click("Tent");await Task.Delay(100);Click("Ok");Click("Back");await Task.Delay(150);
Click("NavPinball");await Until(()=>Flow.Page=="UI_PrototypePinball");Click("Fire");await Until(()=>!Flow.IsBusy&&Flow.Game.State.shots==1);
Click("Back");await Task.Delay(150);Click("NavHome");await Until(()=>Flow.Page=="UI_PrototypeCamp");
Check(Button("Training").interactable&&!Button("BallTree").interactable,"Teaching training gate wrong");
Click("Training");await Until(()=>Flow.Page=="UI_PrototypePets");Check(Flow.Game.State.level==1,"Training doorway charged on open");
Click("Back");return "PASS real camp HUD entry, locked facilities, tent profile and first-shot training doorway without extra cost.";
}
public static async Task<string> Rest()
{
await SyncHome();Click("NavHome");await Until(()=>Flow.Page=="UI_PrototypeCamp");
Check(Button("BallTree").interactable&&Button("Interact").interactable&&!Button("Construction").interactable,"1-3 fixture unlock wrong");
int coins=Flow.Game.State.coins,balls=Flow.Game.State.balls,candy=Flow.Game.State.candy,tickets=Flow.Game.State.tickets;
Click("RestPet3");Click("Interact");await Until(()=>!Flow.IsBusy&&Flow.Game.State.campInteractions==1);
Check(Flow.Game.State.restPet==3&&Flow.Game.State.coins==coins&&Flow.Game.State.balls==balls&&Flow.Game.State.candy==candy&&Flow.Game.State.tickets==tickets,"Rest interaction awarded resources");
await Task.Delay(800);await Capture("rest");Click("BallTree");await Until(()=>Flow.Page=="UI_PrototypePinball");
Check(Flow.Game.State.balls==balls,"Ball tree opening duplicated production");Click("Back");return "PASS 1-3 rest gate, persisted electric partner greeting and no rewards; ball tree shares same inventory.";
}
public static async Task<string> BuildFlow()
{
await SyncHome();Click("NavHome");await Until(()=>Flow.Page=="UI_PrototypeCamp");Check(Button("Construction").interactable,"1-5 build still locked");await Capture("unlocked");
Click("Construction");await Until(()=>Flow.Page=="UI_PrototypeBuild");Check(Flow.Game.State.coins==1000,"Camp fixture wallet wrong");await Capture("catalogue");
Click("Decoration0");Click("CampSlot0");await Capture("preview");Check(Flow.Game.State.coins==1000&&Flow.Game.State.decorations[0]==0,"Preview purchased early");
Click("CancelPreview");Check(Flow.Game.State.coins==1000&&Flow.Game.State.campLayout.All(i=>i==-1)&&!Button("Place").interactable,"Cancel preview mutated authoritative state");
Click("CampSlot0");Click("Place");await Until(()=>!Flow.IsBusy&&Flow.Game.State.campLayout[0]==0);Check(Flow.Game.State.coins==880&&Flow.Game.State.decorations[0]==1,"Purchase mismatch");await Capture("placed");
Click("Decoration0");Click("CampSlot1");Click("Place");await Until(()=>!Flow.IsBusy&&Flow.Game.State.campLayout[1]==0);Check(Flow.Game.State.coins==880&&Flow.Game.State.campLayout[0]==-1,"Move charged or duplicated");
Click("CampSlot1");Click("Store");await Until(()=>!Flow.IsBusy&&Flow.Game.State.campLayout[1]==-1);Check(Flow.Game.State.decorations[0]==1&&Flow.Game.State.coins==880,"Store destroyed ownership or refunded");await Capture("warehouse");
Click("Decoration0");Click("CampSlot1");Click("Place");await Until(()=>!Flow.IsBusy&&Flow.Game.State.campLayout[1]==0);
Click("Decoration1");Click("CampSlot1");Check(!Button("Place").interactable,"Occupied preview allows overwrite");Click("CampSlot2");
var api=Get<PrototypeApi>("_api");var command=api.Command("camp.place",Flow.Game.State.revision,slot:2,item:1);PrototypeReply response=null;string error=null;
Flow.StartCoroutine(api.Send(command,(r,e)=>{response=r;error=e;}));await Until(()=>response!=null||error!=null);Check(response?.ok==true,"Purchase before lost ack failed");
var session=Get<Func<SessionData>>("_session")();string key="AIGame.G1.pending."+Uri.EscapeDataString("http://127.0.0.1:18081")+"."+session.Pid;
PlayerPrefs.SetString(key,JsonUtility.ToJson(command));PlayerPrefs.Save();Set("_needsSync",true);Flow.Handle("Home");await Until(()=>!Flow.IsBusy&&Get<PrototypeReply>("_reply").replayed);
Check(Flow.Game.State.coins==700&&Flow.Game.State.campLayout.SequenceEqual(new[]{-1,0,1})&&!api.HasPending,"Replayed purchase charged twice or lost layout");await Capture("recovery");Click("Ok");
Set("_boundProfile",0L);Flow.Handle("Home");await Until(()=>!Flow.IsBusy);await Task.Delay(150);Click("NavHome");await Until(()=>Flow.Page=="UI_PrototypeCamp");Click("Construction");await Until(()=>Flow.Page=="UI_PrototypeBuild");
Check(Flow.Game.State.coins==700&&Flow.Game.State.campLayout.SequenceEqual(new[]{-1,0,1})&&Flow.Game.State.restPet==3&&Flow.Game.State.campInteractions==1,"Camp session reattach lost state");await Capture("restored");
return "PASS live build: preview/cancel free, buy 120 once, move/store/warehouse free, occupied refusal, lost-ack purchase 180 once and layout/interaction restored after reattach.";
}
public static async Task<string> ReviewPreview()
{
await SyncHome();Click("NavHome");await Until(()=>Flow.Page=="UI_PrototypeCamp");Click("Construction");await Until(()=>Flow.Page=="UI_PrototypeBuild");
int coins=Flow.Game.State.coins;Click("Decoration2");Click("CampSlot0");Check(Button("Place").interactable,"Affordable preview disabled");await Capture("preview");
Click("CancelPreview");Check(Flow.Game.State.coins==coins&&!Button("Place").interactable,"Final preview cancellation changed wallet");
return "PASS final slot label spacing, affordable preview and cancellation without purchase.";
}
public static async Task<string> Insufficient()
{
await SyncHome();Click("NavHome");await Until(()=>Flow.Page=="UI_PrototypeCamp");Click("Construction");await Until(()=>Flow.Page=="UI_PrototypeBuild");
Click("Decoration2");Click("CampSlot0");Check(Flow.Game.State.coins==239&&!Button("Place").interactable,"Insufficient preview allowed purchase");
Check(UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().Single(t=>t.name=="Txt_Preview").text.Contains("当前不足"),"Missing insufficient funds reason");await Capture("insufficient");
Click("CancelPreview");return "PASS insufficient wallet displays reason, disables confirmation and allows free cancel.";
}
public static async Task<string> Navigation()
{
await Until(()=>!Flow.IsBusy);Flow.Handle("Home");await Task.Delay(150);Click("NavHome");await Until(()=>Flow.Page=="UI_PrototypeCamp");
Check(Button("Construction").interactable,"Final HUD camp routing failed");Click("Back");await Until(()=>Flow.Page=="Home");
return "PASS original MainHUD.Home event routes to camp; Back returns to illustrated home.";
}
}
+193
View File
@@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using HotUpdate.Net;
using HotUpdate.UI;
using Newtonsoft.Json.Linq;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public static class VerifyChest
{
static PrototypeFlow Flow => UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
static void Check(bool value, string message) { if (!value) throw new Exception(message); }
static T Get<T>(string name) => (T)typeof(PrototypeFlow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).GetValue(Flow);
static void Set(string name, object value) => typeof(PrototypeFlow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).SetValue(Flow, value);
static async Task Until(Func<bool> condition, int seconds = 20)
{ var end = DateTime.UtcNow.AddSeconds(seconds); while (!condition()) { Check(DateTime.UtcNow < end, "Chest timed out: " + Flow?.Page); await Task.Delay(100); } await Task.Delay(120); }
static Button Button(string name) => UnityEngine.Object.FindObjectsOfType<Button>().Single(b => b.name == "Btn_" + name && b.gameObject.activeInHierarchy);
static void Click(string name)
{
var b = Button(name); Check(b.interactable, "Disabled " + name); Canvas.ForceUpdateCanvases();
var rect = b.GetComponent<RectTransform>();
var pointer = new PointerEventData(EventSystem.current) { position = RectTransformUtility.WorldToScreenPoint(null, rect.TransformPoint(rect.rect.center)), button = PointerEventData.InputButton.Left };
var hits = new List<RaycastResult>(); EventSystem.current.RaycastAll(pointer, hits);
Check(hits.Count > 0 && ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject) == b.gameObject, "Blocked " + name);
ExecuteEvents.Execute(b.gameObject, pointer, ExecuteEvents.pointerClickHandler);
}
static async Task Capture(string name)
{
await Task.Delay(180);
var type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("Unity.Pipeline.Editor.Commands.Capture.CaptureCommands")).First(t => t != null);
type.GetMethod("CaptureGameView", BindingFlags.Public | BindingFlags.Static).Invoke(null, new object[] { 1080, 1920, null, "Screenshots/chest_" + name + ".png", false, 0, "screen" });
}
static void Ok() { var dialog = UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>(); dialog.Hide(); }
static readonly string Isolated = "http://127.0.0.1:18081";
static async Task Refresh()
{
Ok();
// Re-attach the isolated session saved by Start(); the flow may fall back to the dev account.
Set("_httpBase", Isolated); Set("_boundProfile", 0L);
await Login();
await Until(() => !Flow.IsBusy && Get<string>("_httpBase") == Isolated && Flow.Game.State.schema == 8);
await Task.Delay(120); Click("Rewards"); await Until(() => Flow.Page == "UI_PrototypeChest");
await Task.Delay(300); Canvas.ForceUpdateCanvases();
}
static async Task Login()
{
string user = SessionState.GetString("AIGame.Chest.user", "");
AuthResponse account = null; string error = null; bool done = false;
Flow.StartCoroutine(AuthApi.Login(Isolated, user, "isolated-chest-password", (r, e) => { account = r; error = e; done = true; }));
await Until(() => done);
Check(account != null, "Isolated session unavailable " + error);
var session = new SessionData { Pid = account.pid, Token = account.token, Username = user };
Set("_session", new Func<SessionData>(() => session)); Set("_profile", new Func<long>(() => session.Pid));
}
public static string Assets()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/UI_PrototypeChest.prefab");
Check(prefab != null, "Missing chest prefab");
Check(prefab.GetComponentsInChildren<TextMeshProUGUI>(true).All(t => t.font != null), "Missing chest font");
foreach (var image in prefab.GetComponentsInChildren<Image>(true).Where(i => i.type == Image.Type.Sliced))
Check(image.sprite != null && image.sprite.border.x > 0 && image.sprite.border.y > 0, "Invalid chest nine slice");
var names = prefab.GetComponentsInChildren<Transform>(true).Select(t => t.name).ToHashSet();
foreach (var name in new[] { "Btn_ClaimDailyChest", "Btn_OpenChest0", "Btn_OpenChest1", "Btn_OpenChest2", "Btn_ChestChoice0", "Btn_ChestChoice4", "Btn_GoStages", "Txt_ChestSummary", "Txt_ChestChoiceInfo", "ChestChoiceGroup" })
Check(names.Contains(name), "Missing chest node " + name);
Check(names.Contains("Dot_Rewards") || AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/UI_MainHUD.prefab").GetComponentsInChildren<Transform>(true).Any(t => t.name == "Dot_Rewards"), "Home chest dot missing");
var config = JsonUtility.FromJson<PrototypeConfig>(File.ReadAllText(Path.Combine(Application.dataPath, "../../Config/Gameplay/greenfield.json")));
Check(PrototypeConfig.Valid(config) && config.rewards?.chests?.Length == 3, "Chest config invalid");
Check(config.rewards.chests[1].IsChoice && config.rewards.chests[1].choices.Length == 5, "Choice chest contents invalid");
var oldJson = JObject.Parse(JsonUtility.ToJson(config)); oldJson.Remove("rewards");
Check(PrototypeConfig.Valid(JsonUtility.FromJson<PrototypeConfig>(oldJson.ToString())), "Pre-chest frozen config rejected");
var method = typeof(PrototypeApi).GetMethod("ValidChestReceipt", BindingFlags.Static | BindingFlags.NonPublic);
var request = new PrototypeCommand { action = "chest.open", requestId = "test", chest = 1, choice = 2 };
var reply = new PrototypeReply { action = request.action, ok = true, result = new PrototypeResult { id = "test", chest = 1, choice = 2, remaining = 0, kind = "choice", chestReward = new ChestReward { shardsPet = 2, shards = 10 } } };
Check((bool)method.Invoke(null, new object[] { reply, request }), "Valid chest receipt rejected");
var wrongChoice = Newtonsoft.Json.Linq.JObject.Parse(JsonUtility.ToJson(reply)); wrongChoice["result"]["choice"] = 3;
Check(!(bool)method.Invoke(null, new object[] { JsonUtility.FromJson<PrototypeReply>(wrongChoice.ToString()), request }), "Wrong choice receipt acknowledged");
var partial = Newtonsoft.Json.Linq.JObject.Parse(JsonUtility.ToJson(reply)); ((JObject)partial["result"]).Remove("chestReward");
Check(!(bool)method.Invoke(null, new object[] { JsonUtility.FromJson<PrototypeReply>(partial.ToString()), request }), "Missing chest reward acknowledged");
var dailyRequest = new PrototypeCommand { action = "chest.claimDaily", requestId = "daily", chest = 0 };
var dailyReply = new PrototypeReply { action = dailyRequest.action, ok = true, result = new PrototypeResult { id = "daily", chest = 0, count = 1 } };
Check((bool)method.Invoke(null, new object[] { dailyReply, dailyRequest }), "Valid daily chest receipt rejected");
dailyReply.result.count = 2;
Check(!(bool)method.Invoke(null, new object[] { dailyReply, dailyRequest }), "Over-counted daily receipt acknowledged");
// The server serializes every result field, so a daily receipt carries chest=0 and an
// all-zero chestReward. Zero must still deserialize as no chest contents.
dailyReply.result.count = 1;
var serialized = JObject.Parse(JsonUtility.ToJson(dailyReply));
serialized["result"]["remaining"] = 0; serialized["result"]["kind"] = null; serialized["result"]["chestReward"] = new JObject { ["coins"] = 0, ["candy"] = 0, ["balls"] = 0, ["tickets"] = 0, ["shardsPet"] = -1, ["shards"] = 0 };
Check((bool)method.Invoke(null, new object[] { JsonUtility.FromJson<PrototypeReply>(serialized.ToString()), dailyRequest }), "Serialized daily receipt acknowledged with zero chest=0");
return "PASS chest config, authored page, TMP fonts, nine-slices, home dot and complete versus malformed chest receipts.";
}
public static string State()
{
var game = new PrototypeSave { schema = 8, chests = new[] { 0, 0, 0 }, chestDailyClaimed = 0 };
game.stages = new[] { 1, 1, 1, 1, 1 }; game.shards = new int[5]; game.selectedStage = 1; game.clearedStages = new[] { 1, 1, 1, 1, 1 };
game.dailyDay = 1; game.signinDays = 1; game.dailyProgress = new int[3]; game.dailyClaimed = new int[3]; game.signinClaimed = new int[7];
game.shopBought = new int[3]; game.mails = new PrototypeMail[0]; game.campLayout = new[] { -1, -1, -1 }; game.decorations = new int[3]; game.restPet = 0;
Check(PrototypeGame.Valid(game), "Fresh schema8 save rejected");
game.chests = new[] { 1, 2, 3 };
Check(PrototypeGame.Valid(game), "Chest inventory rejected");
game.chestDailyClaimed = 1; Check(PrototypeGame.Valid(game), "Claimed daily flag rejected");
game.chestDailyClaimed = 2; Check(!PrototypeGame.Valid(game), "Invalid daily flag accepted");
game.chestDailyClaimed = 0; game.chests = new[] { 0, 0 }; Check(!PrototypeGame.Valid(game), "Short chest inventory accepted");
game.chests = new[] { 0, 0, -1 }; Check(!PrototypeGame.Valid(game), "Negative chest count accepted");
return "PASS client schema8 chest validation accepts owned boxes and rejects malformed inventory.";
}
public static async Task<string> Start()
{
await Until(() => Flow?.Game != null && !Flow.IsBusy);
SessionState.SetString("AIGame.Chest.original", JsonUtility.ToJson(Flow.Game.State));
SessionState.SetString("AIGame.Chest.endpoint", Get<string>("_httpBase"));
AuthResponse account = null; string error = null; bool done = false; string user = "g1live_chest_" + Guid.NewGuid().ToString("N");
Flow.StartCoroutine(AuthApi.Register("http://127.0.0.1:18081", user, "isolated-chest-password", (r, e) => { account = r; error = e; done = true; }));
await Until(() => done); Check(account != null, "Register failed " + error);
File.WriteAllText(Path.Combine(Application.dataPath, "../../Tools/output/g1-live/chest-account.txt"), user);
var session = new SessionData { Pid = account.pid, Token = account.token, Username = user };
SessionState.SetString("AIGame.Chest.user", user);
Set("_session", new Func<SessionData>(() => session)); Set("_profile", new Func<long>(() => session.Pid)); Set("_httpBase", Isolated); Set("_boundProfile", 0L);
Flow.Handle("Home"); await Until(() => !Flow.IsBusy); await Task.Delay(150);
Check(Flow.Game.State.schema == 8 && Flow.Game.State.chests.All(n => n == 0) && Flow.Game.State.chestDailyClaimed == 0, "New chest account started with boxes");
Check(GameObject.Find("Dot_Rewards") != null, "Daily chest red dot missing");
Click("Rewards"); await Until(() => Flow.Page == "UI_PrototypeChest");
await Task.Delay(300); Canvas.ForceUpdateCanvases(); await Capture("empty");
Check(!Button("OpenChest0").interactable && !Button("OpenChest1").interactable && !Button("OpenChest2").interactable, "Empty chest can be opened");
Check(!UnityEngine.Object.FindObjectsOfType<Button>().Any(b => b.name.StartsWith("Btn_ChestChoice", StringComparison.Ordinal) && b.gameObject.activeInHierarchy), "Choice list shown without a choice chest");
Click("ClaimDailyChest"); await Until(() => !Flow.IsBusy && Flow.Game.State.chestDailyClaimed == 1);
Check(Flow.Game.State.chests[0] == 1 && Flow.Game.State.coins == 0 && Flow.Game.State.candy == 0, "Daily chest awarded contents directly"); await Capture("daily"); Ok();
Check(!Button("ClaimDailyChest").interactable, "Daily chest claimable twice");
Click("OpenChest0"); await Until(() => !Flow.IsBusy && Flow.Game.State.chests[0] == 0);
Check(Flow.Game.State.coins == 60 && Flow.Game.State.candy == 30 && Flow.Game.State.balls == 22, "Fixed chest reward not exact"); await Capture("opened"); Ok();
Check(!Button("OpenChest0").interactable, "Opened chest still openable");
return "PASS actual chest entry, daily box without contents, exact fixed-box reward once, empty states and disabled open buttons.";
}
public static async Task<string> Choice()
{
await Refresh();
Check(Flow.Game.State.chests[1] == 1, "Choice chest fixture missing; run Tools/seed_chest_test.py choice");
Check(Button("OpenChest1").interactable && Button("ChestChoice0").gameObject.activeSelf && Button("ChestChoice4").gameObject.activeSelf, "Choice options not shown");
int shards = Flow.Game.State.shards[2];
Click("ChestChoice2"); await Task.Delay(250); Canvas.ForceUpdateCanvases();
Check(UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().Single(t => t.name == "Txt_ChestChoiceInfo").text.Contains("芽芽"), "Choice selection not reflected");
Click("OpenChest1"); await Until(() => !Flow.IsBusy && Flow.Game.State.chests[1] == 0);
Check(Flow.Game.State.shards[2] == shards + 10 && Flow.Game.State.coins == 60, "Choice chest granted the wrong shards"); await Capture("choice"); Ok();
return "PASS choice chest requires a selection and grants exactly the selected partner shards.";
}
public static async Task<string> LostAcknowledgement()
{
await Refresh();
if (Flow.Game.State.chestDailyClaimed == 0) { Click("ClaimDailyChest"); await Until(() => !Flow.IsBusy && Flow.Game.State.chestDailyClaimed == 1); Ok(); await Refresh(); }
Check(Flow.Game.State.chests[0] == 1, "Lost-ack fixture missing; run Tools/seed_chest_test.py lost");
var api = Get<PrototypeApi>("_api"); var session = Get<Func<SessionData>>("_session")();
string key = "AIGame.G1.pending." + Uri.EscapeDataString(Isolated) + "." + session.Pid;
var command = api.Command("chest.open", Flow.Game.State.revision, chest: 0);
PrototypeReply response = null; string error = null;
Flow.StartCoroutine(api.Send(command, (r, e) => { response = r; error = e; })); await Until(() => response != null || error != null);
Check(response?.ok == true, "First chest open failed " + error);
// api.Send returns the authoritative reply without mutating the flow view; snapshot that.
int balls = response.state.balls, coins = response.state.coins, shards = response.state.shards.Sum();
Check(coins == Flow.Game.State.coins + 60 && response.state.chests[0] == 0, "First chest open did not award exactly one fixed box");
PlayerPrefs.SetString(key, JsonUtility.ToJson(command)); PlayerPrefs.Save(); Set("_needsSync", true); Flow.Handle("Rewards");
await Until(() => !Flow.IsBusy && Get<PrototypeReply>("_reply").replayed);
{ var text = "Chest replay duplicated the reward or retained the request balls " + balls + "->" + Flow.Game.State.balls + " coins " + coins + "->" + Flow.Game.State.coins + " shards " + shards + "->" + Flow.Game.State.shards.Sum() + " pending=" + api.HasPending + " replayed=" + Get<PrototypeReply>("_reply").replayed; Check(Flow.Game.State.balls == balls && Flow.Game.State.coins == coins && Flow.Game.State.shards.Sum() == shards && !api.HasPending, text); }
var dialog = UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().CaptureState();
Check(dialog.Visible && dialog.Title == "宝箱已打开", "Recovered chest receipt not shown"); await Capture("recovery"); Ok();
return "PASS lost chest acknowledgement restores the original receipt, awards once and clears the durable queue.";
}
public static async Task<string> Cleanup()
{
Ok();
var testSession = Get<Func<SessionData>>("_session")();
string key = "AIGame.G1.pending." + Uri.EscapeDataString(Isolated) + "." + testSession.Pid; Check(!PlayerPrefs.HasKey(key), "Test request still pending");
var net = UnityEngine.Object.FindObjectOfType<GameNet>();
Set("_session", new Func<SessionData>(() => net.Session)); Set("_profile", new Func<long>(() => net.Session?.Pid ?? 0)); Set("_httpBase", SessionState.GetString("AIGame.Chest.endpoint", "http://127.0.0.1:8080")); Set("_boundProfile", 0L);
Flow.Handle("Home"); await Until(() => Flow.Game != null && !Flow.IsBusy);
var original = JsonUtility.FromJson<PrototypeSave>(SessionState.GetString("AIGame.Chest.original", ""));
Check(Flow.Game.State.coins == original.coins && Flow.Game.State.candy == original.candy && Flow.Game.State.balls == original.balls && Flow.Game.State.shots == original.shots && Flow.Game.State.level == original.level, "Original account resources changed during chest test");
return "PASS original account restored with unchanged wallet; no chest operation sent to the real account.";
}
}
+120
View File
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using HotUpdate.Net;
using HotUpdate.UI;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
public static class VerifyCollection
{
static PrototypeFlow Flow => UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
static void Check(bool b,string message) { if(!b) throw new Exception(message); }
static T Get<T>(string field) => (T)typeof(PrototypeFlow).GetField(field,BindingFlags.NonPublic|BindingFlags.Instance).GetValue(Flow);
static void Set(string field,object value) => typeof(PrototypeFlow).GetField(field,BindingFlags.NonPublic|BindingFlags.Instance).SetValue(Flow,value);
static async Task Until(Func<bool> condition,int seconds=20)
{
var deadline=DateTime.UtcNow.AddSeconds(seconds);
while(!condition()) {Check(DateTime.UtcNow<deadline,"Collection condition timed out at "+Flow?.Page);await Task.Delay(100);}
}
static void Click(string name)
{
var b=UnityEngine.Object.FindObjectsOfType<Button>().Single(x=>x.name=="Btn_"+name&&x.gameObject.activeInHierarchy);
Check(b.interactable,"Disabled button "+name);Canvas.ForceUpdateCanvases();var rect=b.GetComponent<RectTransform>();
var pointer=new PointerEventData(EventSystem.current){position=RectTransformUtility.WorldToScreenPoint(null,rect.TransformPoint(rect.rect.center)),button=PointerEventData.InputButton.Left};
var hits=new List<RaycastResult>();EventSystem.current.RaycastAll(pointer,hits);
Check(hits.Count>0&&ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject)==b.gameObject,"Raycast blocked "+name);
ExecuteEvents.Execute(b.gameObject,pointer,ExecuteEvents.pointerClickHandler);
}
static void Capture(string name)
{
var type=AppDomain.CurrentDomain.GetAssemblies().Select(a=>a.GetType("Unity.Pipeline.Editor.Commands.Capture.CaptureCommands")).First(t=>t!=null);
type.GetMethod("CaptureGameView",BindingFlags.Static|BindingFlags.Public).Invoke(null,new object[]{1080,1920,null,"Screenshots/collection_"+name+".png",false,0,"screen"});
}
public static string Assets()
{
var arena=AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Scenes/Prototype/PrototypeArena.prefab");
for(int i=0;i<10;i++)
{
Check(arena.transform.Find("Heroes/Hero"+i)!=null&&arena.transform.Find("Projectiles/Shot"+i)!=null,"Missing authored hero/projectile "+i);
Check(AssetDatabase.LoadAssetAtPath<Sprite>("Assets/Bundles/UI/Texture/Prototype/proto_pet_"+i+".png")!=null,"Missing portrait "+i);
}
var capture=AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/UI_PrototypeCapture.prefab");
Check(capture.GetComponentsInChildren<TextMeshProUGUI>(true).Count(t=>t.name.StartsWith("Txt_CaptureOdds"))==10,"Missing ten odds rows");
return "PASS ten authored partner models, projectiles, portraits and capture odds rows.";
}
public static async Task<string> PityAndBattle()
{
Check(Flow!=null,"Enter Play and attach isolated profile first");
if(Flow.Page!="Home") Click("Home");
Set("_needsSync",true);Flow.Handle("Home");await Until(()=>!Flow.IsBusy);
Check(Flow.Game.State.duplicateStreak==4&&Flow.Game.State.stages[4]==0,"Run isolated seed_collection_test.py first");
Flow.Handle("Pinball");await Until(()=>Flow.Page=="UI_PrototypePinball");Click("Capture");await Until(()=>Flow.Page=="UI_PrototypeCapture");
var reply=Get<PrototypeReply>("_reply");Check(reply.capture.pityReady&&reply.capture.entries[4].probability==1,"Water guarantee odds not shown");
Capture("pity");int tickets=Flow.Game.State.tickets;
Click("Capture");await Until(()=>!Flow.IsBusy&&Flow.Game.State.stages[4]==1);
Check(Flow.Game.State.tickets==tickets-1&&Get<PrototypeReply>("_reply").result.pity,"Pity draw not committed");
Capture("water_result");Click("Ok");await Until(()=>Flow.Page=="UI_PrototypePets");Capture("pets");
Click("Back");await Task.Delay(150);Flow.Handle("Pinball");await Until(()=>Flow.Page=="UI_PrototypePinball");Click("Capture");await Until(()=>Flow.Page=="UI_PrototypeCapture");
Check(Get<PrototypeReply>("_reply").capture.completed,"Complete pool did not pause pity");Capture("complete");
int shards=Flow.Game.State.shards.Sum(),count=Flow.Game.State.captureCount;tickets=Flow.Game.State.tickets;
Click("Capture");await Until(()=>!Flow.IsBusy&&Flow.Game.State.captureCount==count+1);
Check(Flow.Game.State.shards.Sum()==shards+20&&Flow.Game.State.tickets==tickets-1&&Flow.Game.State.duplicateStreak==0,"Complete-pool duplicate cost/shards incorrect");
Capture("duplicate_result");Click("Ok");await Until(()=>Flow.Page=="UI_PrototypePets");
Click("Pet3");Click("ToFormation");await Until(()=>Flow.Page=="UI_PrototypeFormation");Click("Slot1");await Until(()=>!Flow.IsBusy&&Flow.Game.State.formation[1]==3);
Click("Pet4");Click("Slot0");await Until(()=>!Flow.IsBusy&&Flow.Game.State.formation[0]==4);Capture("formation");
Click("ToStages");await Until(()=>Flow.Page=="UI_PrototypeStages");Click("Stage2");await Until(()=>Flow.Page=="UI_PrototypeFormation"&&!Flow.IsBusy);
SessionState.SetInt("Collection.coins",Flow.Game.State.coins);SessionState.SetInt("Collection.candy",Flow.Game.State.candy);SessionState.SetInt("Collection.balls",Flow.Game.State.balls);
Click("StartBattle");await Until(()=>Flow.Page=="UI_PrototypeBattle"&&Flow.Battle?.StageId==2);Click("Speed");await Until(()=>Flow.Battle.Time>12);
Check(Flow.Battle.Heroes.Any(h=>h.Pet==3)&&Flow.Battle.Heroes.Any(h=>h.Pet==4),"New pets missing in battle");Capture("battle");
return "PASS live collection: disclosed 100% missing-water pity, one ticket, five-owned catalogue, paused pity and random duplicate +20 shards, new water/electric team deployed and fighting Stage2.";
}
public static string FinishBattle()
{
if(Flow.Battle==null||!Flow.Battle.Finished||Flow.IsBusy||Flow.Game.State.clearedStages[1]!=1) return "RUNNING time="+Flow?.Battle?.Time;
Check(Flow.Battle.Won&&Flow.Game.State.coins==SessionState.GetInt("Collection.coins",0)+90&&Flow.Game.State.candy==SessionState.GetInt("Collection.candy",0)+50&&Flow.Game.State.balls==SessionState.GetInt("Collection.balls",0)+3,"New-team authoritative settlement mismatch");
Capture("result");return "PASS authored water/electric partners won Stage2; configured 90/50/3 reward committed once.";
}
public static string SettledBattle()
{
var receipt=Get<PrototypeReply>("_reply").lastBattleResult;
Check(receipt!=null&&receipt.stageId==2&&receipt.won&&receipt.firstClear&&Flow.Game.State.clearedStages[1]==1&&string.IsNullOrEmpty(Flow.Game.State.pendingBattle),"New-team victory receipt missing");
Check(Flow.Game.State.coins==SessionState.GetInt("Collection.coins",0)+90&&Flow.Game.State.candy==SessionState.GetInt("Collection.candy",0)+50&&Flow.Game.State.balls==SessionState.GetInt("Collection.balls",0)+3,"Settled new-team wallet mismatch");
return "PASS persisted water/electric Stage2 first-clear receipt and exact 90/50/3 wallet change after capture recovery.";
}
public static async Task<string> FreshTutorial()
{
AuthResponse account=null;string error=null;bool done=false;
string user="g1live_preview_"+Guid.NewGuid().ToString("N");
Flow.StartCoroutine(AuthApi.Register("http://127.0.0.1:18081",user,"isolated-g1-test-password",(r,e)=>{account=r;error=e;done=true;}));
await Until(()=>done);Check(account!=null,"Preview register failed "+error);
var session=new SessionData{Pid=account.pid,Token=account.token,Username=user};
Set("_session",new Func<SessionData>(()=>session));Set("_profile",new Func<long>(()=>session.Pid));Set("_httpBase","http://127.0.0.1:18081");Set("_boundProfile",0L);
Flow.Handle("Home");await Until(()=>!Flow.IsBusy);await Task.Delay(150);
Flow.Handle("Pinball");await Until(()=>Flow.Page=="UI_PrototypePinball");Click("Fire");await Until(()=>!Flow.IsBusy&&Flow.Game.State.tickets==1);
Click("Capture");await Until(()=>Flow.Page=="UI_PrototypeCapture");
var pity=UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().Single(t=>t.name=="Txt_Pity"&&t.gameObject.activeInHierarchy);
Check(!pity.text.Contains("最近获得")&&Get<PrototypeReply>("_reply").capture.entries[2].probability==1,"Fresh tutorial invents capture history");
Capture("tutorial");return "PASS fresh tutorial: first-shot ticket, 100% grass probability and no fabricated latest capture.";
}
public static async Task<string> LostAcknowledgement()
{
Click("Home");await Task.Delay(150);await Until(()=>!Flow.IsBusy);
var api=Get<PrototypeApi>("_api");var command=api.Command("capture",Flow.Game.State.revision);
int tickets=Flow.Game.State.tickets,shards=Flow.Game.State.shards.Sum(),count=Flow.Game.State.captureCount;
PrototypeReply response=null;string error=null;
Flow.StartCoroutine(api.Send(command,(r,e)=>{response=r;error=e;}));await Until(()=>response!=null||error!=null);Check(response?.ok==true,"Capture failed before simulated crash");
var session=Get<Func<SessionData>>("_session")();string key="AIGame.G1.pending."+Uri.EscapeDataString("http://127.0.0.1:18081")+"."+session.Pid;
PlayerPrefs.SetString(key,JsonUtility.ToJson(command));PlayerPrefs.Save();Set("_needsSync",true);Flow.Handle("Home");
await Until(()=>!Flow.IsBusy&&Get<PrototypeReply>("_reply").replayed);
Check(Flow.Game.State.tickets==tickets-1&&Flow.Game.State.captureCount==count+1&&Flow.Game.State.shards.Sum()==shards+20&&!api.HasPending,"Replayed capture redrew or double-debited");
var dialog=UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().CaptureState();Check(dialog.Visible&&dialog.Title=="获得进化碎片","Recovered draw result not surfaced");Capture("recovery");Click("Ok");
await Until(()=>Flow.Page=="UI_PrototypePets");return "PASS lost capture acknowledgement: same recorded result, one ticket and +20 shards, receipt shown after replay and journal cleared.";
}
}
+98
View File
@@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using HotUpdate.Net;
using HotUpdate.UI;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
public static class VerifyDaily
{
static PrototypeFlow Flow=>UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
static void Check(bool b,string message){if(!b)throw new Exception(message);}
static T Get<T>(string name)=>(T)typeof(PrototypeFlow).GetField(name,BindingFlags.NonPublic|BindingFlags.Instance).GetValue(Flow);
static void Set(string name,object value)=>typeof(PrototypeFlow).GetField(name,BindingFlags.NonPublic|BindingFlags.Instance).SetValue(Flow,value);
static async Task Until(Func<bool> condition,int seconds=20){var end=DateTime.UtcNow.AddSeconds(seconds);while(!condition()){Check(DateTime.UtcNow<end,"Daily timed out: "+Flow?.Page);await Task.Delay(100);}}
static Button Button(string name)=>UnityEngine.Object.FindObjectsOfType<Button>().Single(b=>b.name=="Btn_"+name&&b.gameObject.activeInHierarchy);
static void Click(string name)
{
var b=Button(name);Check(b.interactable,"Disabled "+name);Canvas.ForceUpdateCanvases();var rect=b.GetComponent<RectTransform>();
var pointer=new PointerEventData(EventSystem.current){position=RectTransformUtility.WorldToScreenPoint(null,rect.TransformPoint(rect.rect.center)),button=PointerEventData.InputButton.Left};
var hits=new List<RaycastResult>();EventSystem.current.RaycastAll(pointer,hits);Check(hits.Count>0&&ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject)==b.gameObject,"Click blocked "+name);ExecuteEvents.Execute(b.gameObject,pointer,ExecuteEvents.pointerClickHandler);
}
static bool Dot(string name)=>Get<MainHudView>("_hud").GetComponentsInChildren<Transform>(true).Single(t=>t.name==name).gameObject.activeSelf;
static async Task Capture(string name)
{
await Task.Delay(150);var type=AppDomain.CurrentDomain.GetAssemblies().Select(a=>a.GetType("Unity.Pipeline.Editor.Commands.Capture.CaptureCommands")).First(t=>t!=null);
type.GetMethod("CaptureGameView",BindingFlags.Static|BindingFlags.Public).Invoke(null,new object[]{1080,1920,null,"Screenshots/daily_"+name+".png",false,0,"screen"});
}
static async Task Shot(){int count=Flow.Game.State.shots;Click("Fire");await Until(()=>!Flow.IsBusy&&Flow.Game.State.shots==count+1);}
static async Task SyncHome(){Flow.Handle("Home");Set("_needsSync",true);Flow.Handle("Home");await Until(()=>!Flow.IsBusy);await Task.Delay(150);}
public static async Task<string> Start()
{
await Until(()=>Flow?.Game!=null&&!Flow.IsBusy);SessionState.SetString("AIGame.G1.original",JsonUtility.ToJson(Flow.Game.State));SessionState.SetString("AIGame.G1.originalEndpoint",Get<string>("_httpBase"));
AuthResponse account=null;string error=null;bool done=false;string user="g1live_daily_"+Guid.NewGuid().ToString("N");
Flow.StartCoroutine(AuthApi.Register("http://127.0.0.1:18081",user,"isolated-g1-test-password",(r,e)=>{account=r;error=e;done=true;}));await Until(()=>done);Check(account!=null,"Register failed "+error);
File.WriteAllText(Path.Combine(Application.dataPath,"../../Tools/output/g1-live/account.txt"),user);var session=new SessionData{Pid=account.pid,Token=account.token,Username=user};
Set("_session",new Func<SessionData>(()=>session));Set("_profile",new Func<long>(()=>session.Pid));Set("_httpBase","http://127.0.0.1:18081");Set("_boundProfile",0L);Flow.Handle("Home");await Until(()=>!Flow.IsBusy);await Task.Delay(150);
Check(Flow.Game.State.schema>=5&&Flow.Game.State.signinDays==1&&Dot("Dot_Daily")&&!Dot("Dot_Tasks"),"Fresh daily/sign-in state or red dots wrong");await Capture("home");
Click("Daily");await Until(()=>Flow.Page=="UI_PrototypeSignin");Check(Button("Signin0").interactable&&!Button("Signin1").interactable,"Sign-in locks wrong");await Capture("signin1");
int balls=Flow.Game.State.balls;Click("Signin0");await Until(()=>!Flow.IsBusy&&Flow.Game.State.signinClaimed[0]==1);Check(Flow.Game.State.balls==balls+10&&!Dot("Dot_Daily"),"Day1 reward or red dot wrong");await Capture("signin_reward");Click("Ok");
Set("_needsSync",true);Flow.Handle("Home");await Until(()=>!Flow.IsBusy);Check(Flow.Game.State.signinDays==1&&!Button("Signin0").interactable,"Same-day reconnect advances sign-in");
Click("ToTasks");await Until(()=>Flow.Page=="UI_PrototypeTasks");Check(!Button("ClaimDaily0").interactable&&!Button("ClaimGoal").interactable,"Incomplete task claimable");await Capture("tasks_empty");
Click("GoDaily0");await Until(()=>Flow.Page=="UI_PrototypePinball");await Shot();await Shot();await Shot();Check(Flow.Game.State.dailyProgress[0]==3&&Dot("Dot_Tasks"),"Real shot events not counted");
Click("Back");await Task.Delay(150);Click("Tasks");await Until(()=>Flow.Page=="UI_PrototypeTasks");await Capture("shot_ready");balls=Flow.Game.State.balls;
Click("ClaimDaily0");await Until(()=>!Flow.IsBusy&&Flow.Game.State.dailyClaimed[0]==1);Check(Flow.Game.State.balls==balls+5&&!Dot("Dot_Tasks"),"Shot claim repeated or dot retained");Click("Ok");
SessionState.SetInt("Daily.coins",Flow.Game.State.coins);SessionState.SetInt("Daily.candy",Flow.Game.State.candy);SessionState.SetInt("Daily.balls",Flow.Game.State.balls);
Click("GoDaily1");await Until(()=>Flow.Page=="UI_PrototypeStages");Click("Stage1");await Until(()=>Flow.Page=="UI_PrototypeFormation");Click("StartBattle");await Until(()=>Flow.Page=="UI_PrototypeBattle");Click("Speed");await Until(()=>Flow.Battle.Time>=12);
return "PASS live first login +10 once, locked future days, same-day reconnect stable, three real shots, daily +5 once and reward dots; tutorial battle running.";
}
public static async Task<string> Finish()
{
if(Flow.Battle==null||!Flow.Battle.Finished||Flow.IsBusy)return "RUNNING time="+Flow?.Battle?.Time;
Check(Flow.Battle.Won&&Flow.Game.State.dailyProgress[1]==1&&Flow.Game.State.balls==SessionState.GetInt("Daily.balls",0)+3&&Flow.Game.State.coins==SessionState.GetInt("Daily.coins",0)+80&&Flow.Game.State.candy==SessionState.GetInt("Daily.candy",0)+40,"Battle/task settlement mismatch");
Click("Home");await Task.Delay(150);Click("Tasks");await Until(()=>Flow.Page=="UI_PrototypeTasks");await Capture("battle_ready");
int balls=Flow.Game.State.balls;Click("ClaimGoal");await Until(()=>!Flow.IsBusy&&Flow.Game.State.goalClaimed);Check(Flow.Game.State.balls==balls+5,"Teaching goal wrong");Click("Ok");
Click("ClaimDaily1");await Until(()=>!Flow.IsBusy&&Flow.Game.State.dailyClaimed[1]==1);Check(Flow.Game.State.balls==balls+10,"Daily battle claim wrong");Click("Ok");
return "PASS live normally finished battle counted, exact first-clear reward, teaching +5 and daily battle +5 separately once.";
}
public static async Task<string> RestAndClaim()
{
await SyncHome();Click("NavHome");await Until(()=>Flow.Page=="UI_PrototypeCamp");int balls=Flow.Game.State.balls;Click("RestPet0");Click("Interact");await Until(()=>!Flow.IsBusy&&Flow.Game.State.dailyProgress[2]==1);
Check(Flow.Game.State.balls==balls,"Interaction directly gave resource");Click("Back");await Task.Delay(150);Click("Tasks");await Until(()=>Flow.Page=="UI_PrototypeTasks");await Capture("rest_ready");
Click("ClaimDaily2");await Until(()=>!Flow.IsBusy&&Flow.Game.State.dailyClaimed[2]==1);Check(Flow.Game.State.balls==balls+5&&!Dot("Dot_Tasks"),"Interaction task award or red dot wrong");Click("Ok");await Capture("tasks_claimed");
return "PASS actual rest interaction has no direct reward; third daily task gives +5 once; all three claimed and task red dot cleared.";
}
public static async Task<string> EarnedSignin()
{
await SyncHome();Check(Flow.Game.State.signinDays==3&&Dot("Dot_Daily"),"Earned sign-in fixture missing");Click("Daily");await Until(()=>Flow.Page=="UI_PrototypeSignin");
Check(Button("Signin1").interactable&&Button("Signin2").interactable&&!Button("Signin3").interactable,"Earned sign-in lock/preservation wrong");await Capture("signin3");
var api=Get<PrototypeApi>("_api");var command=api.Command("signin.claim",Flow.Game.State.revision,item:1);int balls=Flow.Game.State.balls;PrototypeReply response=null;string error=null;
Flow.StartCoroutine(api.Send(command,(r,e)=>{response=r;error=e;}));await Until(()=>response!=null||error!=null);Check(response?.ok==true,"Sign-in before simulated lost ack failed");
var session=Get<Func<SessionData>>("_session")();string key="AIGame.G1.pending."+Uri.EscapeDataString("http://127.0.0.1:18081")+"."+session.Pid;
PlayerPrefs.SetString(key,JsonUtility.ToJson(command));PlayerPrefs.Save();Set("_needsSync",true);Flow.Handle("Home");await Until(()=>!Flow.IsBusy&&Get<PrototypeReply>("_reply").replayed);
Check(Flow.Game.State.balls==balls+5&&Flow.Game.State.signinClaimed[1]==1&&!api.HasPending,"Sign-in retry granted twice");
var dialog=UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().CaptureState();Check(dialog.Visible&&dialog.Title=="奖励已领取","Original sign-in receipt not shown");await Capture("recovery");Click("Ok");
Click("Signin2");await Until(()=>!Flow.IsBusy&&Flow.Game.State.signinClaimed[2]==1);Check(Flow.Game.State.balls==balls+10&&!Dot("Dot_Daily"),"Earned reward preservation or red dot wrong");Click("Ok");
Set("_boundProfile",0L);Flow.Handle("Home");await Until(()=>!Flow.IsBusy);Check(Flow.Game.State.signinDays==3&&Flow.Game.State.signinClaimed.Take(3).All(n=>n==1),"Sign-in state lost on reattach");
return "PASS earned unclaimed login rewards remain, future day locked, lost-ack +5 granted once with receipt and durable journal cleared; profile reattach retains claims.";
}
public static async Task<string> Completed()
{
await SyncHome();Click("Daily");await Until(()=>Flow.Page=="UI_PrototypeSignin");Check(Flow.Game.State.signinDays==7&&!Dot("Dot_Daily")&&Enumerable.Range(0,7).All(i=>!Button("Signin"+i).interactable),"Completed seven-day course restarted");await Capture("signin_complete");
return "PASS completed seven-day view has all rewards claimed, no red dot and no repeated course.";
}
public static async Task<string> FinalViews()
{
await SyncHome();Click("Tasks");await Until(()=>Flow.Page=="UI_PrototypeTasks");Check(Enumerable.Range(0,3).All(i=>!Button("ClaimDaily"+i).interactable)&&!Dot("Dot_Tasks"),"Final daily claim state wrong");await Capture("tasks_claimed");
Click("ToSignin");await Until(()=>Flow.Page=="UI_PrototypeSignin");Check(Enumerable.Range(0,7).All(i=>!Button("Signin"+i).interactable)&&!Dot("Dot_Daily"),"Final sign-in claim state wrong");await Capture("signin_complete");
Click("Back");await Until(()=>Flow.Page=="Home");await Capture("home_claimed");return "PASS final task/sign-in copy, disabled claimed rewards and cleared home dots.";
}
}
+142
View File
@@ -0,0 +1,142 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using Object=UnityEngine.Object;
public static class VerifyEnemyMotions
{
[Serializable] public sealed class Recipe
{
public string id,assetRoot,staticPrefab,enemyPrefab,rootBone,rightFootTip,leftFootTip,outputDir,usage,attackEvent;
public float height,walkStride,maxRootTilt,groundTolerance,attackImpact,attackSeconds,attackLunge;
public int maxTriangles;
}
static string Repo=>Path.GetFullPath(Path.Combine(Application.dataPath,"../.."));
static Recipe Read(string path)
{
var r=JsonUtility.FromJson<Recipe>(File.ReadAllText(Path.Combine(Repo,path)));
if(string.IsNullOrEmpty(r.attackEvent))r.attackEvent=r.usage=="companion_visual" ? "OnCompanionAttackImpact" : "OnEnemyAttackImpact";
return r;
}
static void Require(bool condition,string message) {if(!condition) throw new Exception(message);}
static Vector3[] Vertices(GameObject go)=>go.GetComponentsInChildren<SkinnedMeshRenderer>().SelectMany(s=> {
var m=s.sharedMesh;var weights=m.boneWeights;var bind=m.bindposes;
var matrices=s.bones.Select((b,i)=>b.localToWorldMatrix*bind[i]).ToArray();
return m.vertices.Select((v,i)=> {var w=weights[i];return matrices[w.boneIndex0].MultiplyPoint3x4(v)*w.weight0
+matrices[w.boneIndex1].MultiplyPoint3x4(v)*w.weight1+matrices[w.boneIndex2].MultiplyPoint3x4(v)*w.weight2
+matrices[w.boneIndex3].MultiplyPoint3x4(v)*w.weight3;}).ToArray();
}).ToArray();
static Bounds BoundsOf(Vector3[] p) {var b=new Bounds(p[0],Vector3.zero);foreach(var v in p)b.Encapsulate(v);return b;}
static float Difference(Vector3[] a,Vector3[] b)=>a.Select((v,i)=>Vector3.Distance(v,b[i])).Max();
public static string Run(string recipePath="Config/Art/EnemyCharacters/RockBall.json")
=>RunCore(recipePath,false);
public static string Walk(string recipePath)
=>RunCore(recipePath,true);
static string RunCore(string recipePath,bool walkOnly)
{
var r=Read(recipePath);var sb=new StringBuilder();
var prefab=AssetDatabase.LoadAssetAtPath<GameObject>(r.assetRoot+"/"+r.id+"_Rigged.prefab");
Require(prefab!=null,"Missing rigged prefab");
var scene=EditorSceneManager.NewPreviewScene();
try
{
var go=(GameObject)PrefabUtility.InstantiatePrefab(prefab,scene);
var anim=go.GetComponentInChildren<Animator>();anim.enabled=false;
var model=anim.gameObject;var bones=go.GetComponentsInChildren<Transform>();
var root=bones.First(b=>b.name==r.rootBone);
var footR=bones.First(b=>b.name==r.rightFootTip);var footL=bones.First(b=>b.name==r.leftFootTip);
var bind=Vertices(go);var bb=BoundsOf(bind);
int triangles=go.GetComponentsInChildren<SkinnedMeshRenderer>().Sum(s=>s.sharedMesh.triangles.Length/3);
Require(Mathf.Abs(bb.size.y-r.height)<.002f && Mathf.Abs(bb.min.y)<.002f,"Bind height/bottom invalid");
Require(triangles<=r.maxTriangles,"Triangle budget exceeded");
Require(go.GetComponentsInChildren<SkinnedMeshRenderer>().All(s=>s.sharedMaterial.shader.name=="Test/XPbr"),"Expected approved XPbr material");
var approved=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(r.staticPrefab),scene);
float shape;
try {var points=approved.GetComponentsInChildren<MeshFilter>().SelectMany(m=>m.sharedMesh.vertices.Select(v=>m.transform.TransformPoint(v))).ToArray();shape=bind.Max(v=>points.Min(p=>Vector3.Distance(v,p)));}
finally {Object.DestroyImmediate(approved);}
Require(shape<.002f,"Approved mesh changed");
sb.AppendLine($"BIND height={bb.size.y:F6} bottom={bb.min.y:F6} tris={triangles} staticVertexError={shape:F6}");
var clips=new Dictionary<string,AnimationClip>();
foreach(string name in walkOnly ? new[] {"Walk"} : new[] {"Idle","Walk","Attack"})
{
var clip=AssetDatabase.LoadAssetAtPath<AnimationClip>(r.assetRoot+"/Clips/"+name+".anim");
Require(clip!=null && clip.length>.2f,"Missing/empty "+name);clips[name]=clip;
foreach(var b in AnimationUtility.GetCurveBindings(clip)) Require(string.IsNullOrEmpty(b.path)||model.transform.Find(b.path)!=null,"Unbound curve "+name+" "+b.path);
Require(AnimationUtility.GetAnimationClipSettings(clip).loopTime==(name!="Attack"),"Wrong loop flag "+name);
Vector3[] start=null,end=null;float minY=float.MaxValue,maxY=float.MinValue,maxTilt=0,movement=0,minHeight=float.MaxValue;
var right=new List<float>();var left=new List<float>();var roots=new List<Vector3>();
for(int f=0;f<=120;f++)
{
clip.SampleAnimation(model,clip.length*f/120f);
var p=Vertices(go);var b=BoundsOf(p);
if(f==0)start=p;if(f==120)end=p;
movement=Mathf.Max(movement,Difference(start,p));minY=Mathf.Min(minY,b.min.y);maxY=Mathf.Max(maxY,b.min.y);
minHeight=Mathf.Min(minHeight,b.size.y);maxTilt=Mathf.Max(maxTilt,Vector3.Angle(root.up,Vector3.up));
right.Add((footR.position-root.position).z);left.Add((footL.position-root.position).z);roots.Add(root.position);
}
float seam=Difference(start,end);float strideR=right.Max()-right.Min(),strideL=left.Max()-left.Min();
float drift=roots.Max(p=>Vector2.Distance(new Vector2(p.x,p.z),new Vector2(roots[0].x,roots[0].z)));
sb.AppendLine($"{name} len={clip.length:F3} loop={name!="Attack"} bottom=[{minY:F6},{maxY:F6}] minHeight={minHeight:F5} tilt={maxTilt:F3} movement={movement:F5} seam={seam:F6} rootXZ={drift:F5} feet={strideR:F5}/{strideL:F5}");
Require(minY>=-r.groundTolerance && maxY<=r.groundTolerance,"Ground contact failed\n"+sb);
Require(maxTilt<=r.maxRootTilt && minHeight>r.height*.8f,"Orientation failed\n"+sb);
Require(seam<.001f,"Pose seam failed\n"+sb);
if(name=="Idle")Require(movement>.0002f && movement<.03f && drift<.001f,"Idle motion invalid\n"+sb);
if(name=="Walk")Require(strideR>r.walkStride*.5f && strideL>r.walkStride*.5f && drift<.001f,"Gait invalid\n"+sb);
if(name=="Attack")
{
Require(movement>.02f && drift<r.attackLunge*1.5f+.001f,"Attack unreadable/drifting\n"+sb);
var events=AnimationUtility.GetAnimationEvents(clip);
Require(events.Length==1 && events[0].functionName==r.attackEvent && Mathf.Abs(events[0].time-r.attackImpact*r.attackSeconds)<.001f,"Impact event invalid");
}
}
if(walkOnly)
{
string walkReport=Path.Combine(Repo,r.outputDir,"walk_verification.txt");Directory.CreateDirectory(Path.GetDirectoryName(walkReport));File.WriteAllText(walkReport,sb.ToString());
return "PASS Walk\n"+sb;
}
clips["Idle"].SampleAnimation(model,0);var idlePose=Vertices(go);
clips["Attack"].SampleAnimation(model,clips["Attack"].length);
Require(Difference(idlePose,Vertices(go))<.001f,"Attack must recover to Idle reference");
anim.enabled=true;anim.Rebind();anim.Update(0);
Action<float> step=seconds=> {for(int f=0;f<Mathf.CeilToInt(seconds*60);f++){anim.Update(1f/60);Require(BoundsOf(Vertices(go)).min.y>-.01f,"Transition penetrates ground");}};
Action<string> state=name=>Require(anim.GetCurrentAnimatorStateInfo(0).IsName(name),"Expected "+name);
state("Idle");anim.SetInteger("Motion",1);step(.25f);state("Walk");
anim.SetTrigger("Attack");step(.2f);state("Attack");step(r.attackSeconds+.3f);state("Walk");step(r.attackSeconds*2);state("Walk");
anim.SetInteger("Motion",0);step(.25f);state("Idle");anim.SetTrigger("Attack");step(.2f);state("Attack");step(r.attackSeconds+.3f);state("Idle");
sb.AppendLine("CONTROLLER PASS Idle<->Walk, Idle/Walk->Attack, one trigger = one attack, recover to requested locomotion");
string report=Path.Combine(Repo,r.outputDir,"verification.txt");Directory.CreateDirectory(Path.GetDirectoryName(report));File.WriteAllText(report,sb.ToString());
return "PASS\n"+sb;
}
finally {EditorSceneManager.ClosePreviewScene(scene);}
}
public static async Task<string> Live(string recipePath="Config/Art/EnemyCharacters/RockBall.json")
{
Require(EditorApplication.isPlaying,"Use Play Mode");var r=Read(recipePath);
string prefix=r.usage=="companion_visual" ? "CompanionReview_"+r.id : "EnemyReview";
var unit=GameObject.Find(prefix+"_Cycle");Require(unit!=null,"Open this character's production review");
var anim=unit.GetComponentInChildren<Animator>();var seen=new HashSet<string>();float maxTilt=0;
var root=unit.GetComponentsInChildren<Transform>().First(t=>t.name==r.rootBone);
var position=unit.transform.position;
var deadline=DateTime.UtcNow.AddSeconds(10);
while(DateTime.UtcNow<deadline)
{
var state=anim.GetCurrentAnimatorStateInfo(0);
foreach(string name in new[]{"Idle","Walk","Attack"})if(state.IsName(name))seen.Add(name);
// Production meshes stay non-readable. Real skin vertices were
// checked in Edit Mode; live QA observes animation/transform state.
maxTilt=Mathf.Max(maxTilt,Vector3.Angle(root.up,Vector3.up));
Require(Vector3.Distance(position,unit.transform.position)<.001f,"Unexpected root motion");
await Task.Delay(80);
}
Require(seen.Count==3 && maxTilt<=r.maxRootTilt,"Live playback incomplete: "+string.Join(",",seen)+" tilt="+maxTilt);
return "PASS Play Mode observed "+string.Join(",",seen)+$" maxRootTilt={maxTilt:F3} actorRootPinned=True";
}
}
#endif
+248
View File
@@ -0,0 +1,248 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using HotUpdate.Net;
using HotUpdate.UI;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public static class VerifyG1
{
const string Endpoint = "http://127.0.0.1:18081";
static PrototypeFlow Flow => UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
static void Check(bool condition, string message) { if (!condition) throw new Exception(message); }
static void Set(string field, object value) => typeof(PrototypeFlow).GetField(field, BindingFlags.NonPublic | BindingFlags.Instance).SetValue(Flow, value);
static T Get<T>(string field) => (T)typeof(PrototypeFlow).GetField(field, BindingFlags.NonPublic | BindingFlags.Instance).GetValue(Flow);
static async Task Until(Func<bool> condition, int seconds = 20)
{
var deadline = DateTime.UtcNow.AddSeconds(seconds);
while (!condition()) { Check(DateTime.UtcNow < deadline, "G1 condition timed out: " + Flow?.Page); await Task.Delay(100); }
}
static void Click(string name)
{
var b = UnityEngine.Object.FindObjectsOfType<Button>().Single(x => x.name == "Btn_" + name && x.gameObject.activeInHierarchy);
Check(b.interactable, "Disabled button " + name); Canvas.ForceUpdateCanvases();
var rect = b.GetComponent<RectTransform>();
var pointer = new PointerEventData(EventSystem.current) { position = RectTransformUtility.WorldToScreenPoint(null, rect.TransformPoint(rect.rect.center)), button = PointerEventData.InputButton.Left };
var hits = new List<RaycastResult>(); EventSystem.current.RaycastAll(pointer, hits);
Check(hits.Count > 0 && ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject) == b.gameObject, "Raycast blocked " + name + ": " + string.Join(",",hits.Take(3).Select(h=>h.gameObject.name)));
ExecuteEvents.Execute(b.gameObject, pointer, ExecuteEvents.pointerClickHandler);
}
static void Capture(string name)
{
var type=AppDomain.CurrentDomain.GetAssemblies().Select(a=>a.GetType("Unity.Pipeline.Editor.Commands.Capture.CaptureCommands")).First(t=>t!=null);
type.GetMethod("CaptureGameView",BindingFlags.Static|BindingFlags.Public).Invoke(null,new object[]{1080,1920,null,"Screenshots/progression_"+name+".png",false,0,"screen"});
}
static async Task Shot()
{
int shots = Flow.Game.State.shots;
Click("Fire");
await Until(() => !Flow.IsBusy && Flow.Game.State.shots == shots + 1 && string.IsNullOrEmpty(Flow.Game.State.pendingShot), 15);
}
public static async Task<string> LiveStart()
{
Check(EditorApplication.isPlaying, "Enter Play Mode first");
await Until(() => Flow?.Game != null && !Flow.IsBusy);
SessionState.SetString("AIGame.G1.original", JsonUtility.ToJson(Flow.Game.State));
SessionState.SetString("AIGame.G1.originalEndpoint", Get<string>("_httpBase"));
AuthResponse account = null; string error = null; bool complete = false;
string user = "g1live_" + Guid.NewGuid().ToString("N");
System.IO.File.WriteAllText(System.IO.Path.Combine(Application.dataPath, "../../Tools/output/g1-live/account.txt"), user);
Flow.StartCoroutine(AuthApi.Register(Endpoint, user, "isolated-g1-test-password", (r,e) => { account = r; error = e; complete = true; }));
await Until(() => complete); Check(account != null, "Isolated register failed " + error);
var session = new SessionData { Pid = account.pid, Token = account.token, Username = user };
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_session", new Func<SessionData>(() => session)); Set("_profile", new Func<long>(() => session.Pid)); Set("_httpBase", Endpoint); Set("_boundProfile", 0L);
// New isolated DB/account, no old verification command may be replayed into it.
PlayerPrefs.DeleteKey("AIGame.G1.pending." + Uri.EscapeDataString(Endpoint) + "." + session.Pid);
Flow.Handle("Home"); await Until(() => Flow.Game != null && !Flow.IsBusy);
Check(Flow.Game.State.balls == 20 && Flow.Game.State.coins == 0, "Fresh server wallet incorrect");
Click("Adventure"); await Until(()=>Flow.Page=="UI_PrototypeStages");
Check(!UnityEngine.Object.FindObjectsOfType<Button>().Single(b=>b.name=="Btn_Stage2").interactable,"Stage2 unlocked before first clear");
Capture("stages_locked"); Click("Stage1"); await Until(()=>Flow.Page=="UI_PrototypeFormation");
Click("Back"); await Until(()=>Flow.Page=="Home"); await Task.Delay(150);
Click("NavPinball"); await Until(() => Flow.Page == "UI_PrototypePinball");
Click("AimRight"); await Shot();
for (int i=0; i<4 && Flow.Game.State.candy < 30; i++) await Shot();
Check(Flow.Game.State.candy >= 30, "No training currency");
Click("Capture"); await Until(() => Flow.Page == "UI_PrototypeCapture"); Capture("capture_tutorial"); Click("Capture"); await Until(() => !Flow.IsBusy && Flow.Game.State.stages[2] == 1);
Click("Ok"); await Until(() => Flow.Page == "UI_PrototypePets");
Click("Train"); await Until(() => !Flow.IsBusy && Flow.Game.State.level == 2);
Capture("pets");
Click("ToFormation"); await Until(() => Flow.Page == "UI_PrototypeFormation");
Click("Pet1"); Click("Slot0"); await Until(() => !Flow.IsBusy && Flow.Game.State.formation[0] == 1);
Check(Flow.Game.State.formation[1] == 0, "Formation lost occupant");
Click("AutoDeploy"); await Until(() => !Flow.IsBusy && Flow.Game.State.formation[0] == 0);
Capture("formation1");
SessionState.SetInt("AIGame.G1.coins", Flow.Game.State.coins); SessionState.SetInt("AIGame.G1.candy", Flow.Game.State.candy); SessionState.SetInt("AIGame.G1.balls", Flow.Game.State.balls);
Click("StartBattle"); await Until(() => Flow.Page == "UI_PrototypeBattle" && Flow.Battle != null);
Check(Flow.Battle.Id == Flow.Game.State.pendingBattle, "Battle not server-authorized");
Click("Pause"); float time = Flow.Battle.Time; await Task.Delay(200); Check(Flow.Battle.Time == time, "Pause advanced simulation");
Click("Speed"); Click("Pause"); await Until(() => Flow.Battle.Time >= 14);
return "PASS G1 UI: authenticated snapshot, server trajectory, capture, training, formation swap, server battle ticket, pause and 2x. Isolated battle running.";
}
public static string LiveFinish()
{
if (Flow?.Battle == null || !Flow.Battle.Finished || Flow.IsBusy || !Flow.Game.State.cleared)
return "RUNNING: battle time=" + Flow?.Battle?.Time + "; busy=" + Flow?.IsBusy;
Check(Flow.Game.State.coins == SessionState.GetInt("AIGame.G1.coins",0)+80 && Flow.Game.State.candy == SessionState.GetInt("AIGame.G1.candy",0)+40 && Flow.Game.State.balls == SessionState.GetInt("AIGame.G1.balls",0)+3, "First clear server reward mismatch");
Check(string.IsNullOrEmpty(Flow.Game.State.pendingBattle), "Battle not settled");
Capture("result1");
return "PASS G1 live battle: server-authoritative victory, exact 80 coins / 40 candy / 3 balls, persistent settlement and authored result UI.";
}
public static async Task<string> Recovery()
{
Check(Flow.Game.State.cleared && !Flow.IsBusy, "Finish battle first");
if (Flow.Page != "Home") Click("Home");
if (!Flow.Game.State.goalClaimed)
{
await Task.Delay(150); await Until(()=>!Flow.IsBusy);
int beforeClaim = Flow.Game.State.balls;
await Task.Delay(100); Click("Tasks"); await Until(() => Flow.Page == "UI_PrototypeTasks"); Click("ClaimGoal");
await Until(() => !Flow.IsBusy && Flow.Game.State.goalClaimed);
Click("Ok"); Click("Back"); await Until(() => Flow.Page == "Home");
Check(Flow.Game.State.balls == beforeClaim + 5, "Goal reward mismatch");
}
int balls = Flow.Game.State.balls, shots = Flow.Game.State.shots;
// Simulate a crash after the server committed a request but before the client cleared its journal.
var api = Get<PrototypeApi>("_api"); var command = api.Command("shot.begin", Flow.Game.State.revision, aim: -15);
PrototypeReply response = null; string error = null;
Flow.StartCoroutine(api.Send(command, (r,e) => { response = r; error = e; }));
await Until(() => response != null || error != null); Check(response?.ok == true, "Recovery shot failed " + error);
string key = "AIGame.G1.pending." + Uri.EscapeDataString(Endpoint) + "." + Get<Func<SessionData>>("_session")().Pid;
PlayerPrefs.SetString(key, JsonUtility.ToJson(command)); PlayerPrefs.Save();
Set("_needsSync", true); Flow.Handle("Home");
await Until(() => Flow.IsShooting && Flow.Page == "UI_PrototypePinball");
Check(Flow.Game.State.balls == balls - 1, "Recovery charged twice");
// Reattach once more while the server activity is pending, with no journal this time.
Set("_boundProfile", 0L); Flow.Handle("Home");
await Until(() => !Flow.IsBusy && Flow.Game.State.shots == shots+1 && string.IsNullOrEmpty(Flow.Game.State.pendingShot),15);
Check(Flow.Game.State.balls == balls-1 && !PlayerPrefs.HasKey(key), "Recovery debit or journal mismatch");
Click("Back");
return "PASS G1 recovery: one goal reward; lost-ack replay; scene/session reattach during flight; one ball debit and one settlement; journal cleared.";
}
public static async Task<string> Cleanup()
{
var net = UnityEngine.Object.FindObjectOfType<GameNet>();
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_session", new Func<SessionData>(() => net.Session)); Set("_profile", new Func<long>(() => net.Session?.Pid ?? 0));
Set("_httpBase", SessionState.GetString("AIGame.G1.originalEndpoint", "http://127.0.0.1:8080")); Set("_boundProfile", 0L);
Flow.Handle("Home"); await Until(() => Flow.Game != null && !Flow.IsBusy);
var original = JsonUtility.FromJson<PrototypeSave>(SessionState.GetString("AIGame.G1.original", ""));
Check(Flow.Game.State.coins == original.coins && Flow.Game.State.candy == original.candy && Flow.Game.State.shots == original.shots && Flow.Game.State.level == original.level, "Real account was changed by test");
return "PASS cleanup: real account restored; no gameplay operations were sent to it. All live verification used isolated AIServer :18081.";
}
public static async Task<string> RenewAuthentication()
{
var current = Get<Func<SessionData>>("_session")();
var testSession = new SessionData { Pid = current.Pid, Username = current.Username, Token = "expired-test-token" };
int renewals = 0; string loginError = null;
var api = new PrototypeApi(Endpoint, current.Pid, () => testSession, () => {
renewals++;
Flow.StartCoroutine(AuthApi.Login(Endpoint, current.Username, "isolated-g1-test-password", (r,e) => {
loginError = e;
if (r != null) testSession = new SessionData { Pid = r.pid, Username = current.Username, Token = r.token };
}));
});
PrototypeReply reply = null; string error = null; bool done = false;
var command = api.Command("goal.claim", Flow.Game.State.revision);
Flow.StartCoroutine(api.Send(command, (r,e) => { reply = r; error = e; done = true; }));
await Until(() => done, 25);
Check(reply != null && reply.error == "goal_claimed" && renewals == 1 && error == null && loginError == null && !api.HasPending, "401 renewal failed");
Check(reply.state.balls == Flow.Game.State.balls && reply.state.coins == Flow.Game.State.coins, "401 retry changed balances");
// A previous failed login can leave Session null; retry must still restart authentication.
testSession = null; reply = null; error = null; done = false;
Flow.StartCoroutine(api.Read((r,e) => { reply = r; error = e; done = true; }));
await Until(() => done,25);
Check(reply != null && reply.ok && renewals == 2 && error == null, "Null-session authentication retry failed");
return "PASS client 401/null-session recovery: renewed same guest identity, retried durable command once, kept balances, cleared acknowledged journal, recovered after missing session.";
}
public static async Task<string> AttachExisting(string username)
{
await Until(() => Flow?.Game != null && !Flow.IsBusy);
AuthResponse account = null; string error = null; bool done = false;
Flow.StartCoroutine(AuthApi.Login(Endpoint, username, "isolated-g1-test-password", (r,e) => { account=r; error=e; done=true; }));
await Until(() => done); Check(account != null, "Test account login failed " + error);
var session = new SessionData { Pid=account.pid, Token=account.token, Username=username };
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_session",new Func<SessionData>(()=>session)); Set("_profile",new Func<long>(()=>session.Pid)); Set("_httpBase",Endpoint); Set("_boundProfile",0L);
Flow.Handle("Home"); await Until(()=>Flow.Game != null && !Flow.IsBusy);
return "Attached isolated verification account without changing wallet.";
}
public static Task<string> AttachPrevious() => AttachExisting(System.IO.File.ReadAllText(System.IO.Path.Combine(Application.dataPath, "../../Tools/output/g1-live/account.txt")).Trim());
public static async Task<string> AbandonRetry()
{
Check(!Flow.IsBusy, "Wait for sync");
int coins=Flow.Game.State.coins,candy=Flow.Game.State.candy,balls=Flow.Game.State.balls;
Flow.Handle("Adventure"); await Until(()=>Flow.Page=="UI_PrototypeStages");
Click("Stage1"); await Until(()=>Flow.Page=="UI_PrototypeFormation");
Click("StartBattle"); await Until(()=>Flow.Page=="UI_PrototypeBattle" && !Flow.IsBusy);
var id=Flow.Battle.Id;
// Simulate a stale presentation snapshot, forcing a durable refusal of the first abandon request.
Flow.Game.State.revision--;
Click("Abandon"); await Task.Delay(100); Click("Ok");
await Until(()=>Get<bool>("_needsSync") && !Get<bool>("_busy"));
Click("Ok");
await Until(()=>!Flow.IsBusy && Flow.Game.State.lastBattle==id && string.IsNullOrEmpty(Flow.Game.State.pendingBattle));
Check(Flow.Game.State.coins==coins && Flow.Game.State.candy==candy && Flow.Game.State.balls==balls,"Abandon retry awarded currency");
Check(Get<PrototypeReply>("_reply").result.won==false,"Abandon retry became a victory");
return "PASS stale-abandon recovery: first request refused; sync retained abandon intent; new request ended battle immediately without rewards.";
}
public static string FinalState()
{
var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene();
Check(!EditorApplication.isPlaying && !EditorApplication.isCompiling, "Editor not idle");
Check(!scene.isDirty, "Scene has unsaved changes; do not discard user changes");
return "Editor stopped, compilation complete, active scene clean: " + scene.path;
}
public static async Task<string> NextStageStart()
{
Check(Flow.Game.State.clearedStages[0]==1&&!Flow.IsBusy,"Finish first stage");
Click("NextStage"); await Until(()=>Flow.Page=="UI_PrototypeFormation"&&Flow.Game.State.selectedStage==2&&!Flow.IsBusy);
Capture("formation2"); Click("ToStages"); await Until(()=>Flow.Page=="UI_PrototypeStages");
Check(Flow.Game.State.UnlockedStage==2,"Wrong unlocked stage");
Check(!UnityEngine.Object.FindObjectsOfType<Button>().Single(b=>b.name=="Btn_Stage3").interactable,"Stage3 unlocked too early");
Capture("stages_progress");Click("Stage2");await Until(()=>Flow.Page=="UI_PrototypeFormation"&&!Flow.IsBusy);
SessionState.SetInt("AIGame.G1.stage2Coins",Flow.Game.State.coins);SessionState.SetInt("AIGame.G1.stage2Candy",Flow.Game.State.candy);SessionState.SetInt("AIGame.G1.stage2Balls",Flow.Game.State.balls);
Click("StartBattle");await Until(()=>Flow.Page=="UI_PrototypeBattle"&&Flow.Battle?.StageId==2);
Check(Flow.Battle.Config.version==Get<PrototypeReply>("_reply").battle.snapshot.config.version,"Battle did not use frozen config");
Click("Speed");await Until(()=>Flow.Battle.Time>10);
return "PASS live progression: first clear unlocks only Stage2; NextStage opens saved Stage2 formation; map shows progress, Stage3 locked; grass-element battle running.";
}
public static string NextStageFinish()
{
if(Flow.Battle?.StageId!=2||!Flow.Battle.Finished||Flow.IsBusy||Flow.Game.State.clearedStages[1]!=1) return "RUNNING stage2 time="+Flow.Battle?.Time;
Check(Flow.Game.State.coins==SessionState.GetInt("AIGame.G1.stage2Coins",0)+90&&Flow.Game.State.candy==SessionState.GetInt("AIGame.G1.stage2Candy",0)+50&&Flow.Game.State.balls==SessionState.GetInt("AIGame.G1.stage2Balls",0)+3,"Stage2 configured reward mismatch");
Check(Flow.Game.State.UnlockedStage==3,"Stage3 not unlocked after second clear");Capture("result2");
return "PASS live Stage2: grass-element simulation wins; 90 coins / 50 candy / 3 balls awarded exactly; Stage3 unlocked.";
}
public static async Task<string> ResumeBattle()
{
await Until(()=>Flow.Battle!=null&&Flow.Page=="UI_PrototypeBattle");
if(Get<int>("_speed")==1) Click("Speed");
await Until(()=>Flow.Battle.Time>10);
return "Resumed server-persisted battle "+Flow.Battle.StageId+" at 2x without a new battle ticket.";
}
public static string LiveState() => "page="+Flow?.Page+" state="+JsonUtility.ToJson(Flow?.Game?.State);
public static async Task<string> RepeatFirstStage()
{
Check(Flow.Game.State.clearedStages[0]==1,"First clear not persisted");
Check(Flow.Game.State.coins==SessionState.GetInt("AIGame.G1.coins",0)+80&&Flow.Game.State.candy==SessionState.GetInt("AIGame.G1.candy",0)+40&&Flow.Game.State.balls==SessionState.GetInt("AIGame.G1.balls",0)+3,"Persisted first clear reward mismatch");
Flow.Handle("Adventure"); await Until(()=>Flow.Page=="UI_PrototypeStages");
Click("Stage1"); await Until(()=>Flow.Page=="UI_PrototypeFormation"&&!Flow.IsBusy);
Click("StartBattle");await Until(()=>Flow.Page=="UI_PrototypeBattle"&&Flow.Battle!=null);
Click("Speed");await Until(()=>Flow.Battle.Time>10);
return "Persisted first clear reward confirmed after restart; repeating first stage to verify result/next-stage navigation.";
}
public static async Task<string> HomePreview()
{
if(Flow.Page!="Home") Click("Home");
await Task.Delay(150);await Until(()=>!Flow.IsBusy);Capture("home");
return "Captured camp HUD with live next-stage config and resource values.";
}
}
+93
View File
@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using HotUpdate.Net;
using HotUpdate.UI;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
public static class VerifyInventory
{
static PrototypeFlow Flow=>UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
static void Check(bool b,string message){if(!b)throw new Exception(message);}
static T Get<T>(string name)=>(T)typeof(PrototypeFlow).GetField(name,BindingFlags.NonPublic|BindingFlags.Instance).GetValue(Flow);
static void Set(string name,object value)=>typeof(PrototypeFlow).GetField(name,BindingFlags.NonPublic|BindingFlags.Instance).SetValue(Flow,value);
static async Task Until(Func<bool> condition,int seconds=20){var end=DateTime.UtcNow.AddSeconds(seconds);while(!condition()){Check(DateTime.UtcNow<end,"Inventory timed out: "+Flow?.Page);await Task.Delay(100);}}
static Button Button(string name)=>UnityEngine.Object.FindObjectsOfType<Button>().Single(b=>b.name=="Btn_"+name&&b.gameObject.activeInHierarchy);
static void Click(string name)
{
var b=Button(name);Check(b.interactable,"Disabled "+name);Canvas.ForceUpdateCanvases();var rect=b.GetComponent<RectTransform>();
var pointer=new PointerEventData(EventSystem.current){position=RectTransformUtility.WorldToScreenPoint(null,rect.TransformPoint(rect.rect.center)),button=PointerEventData.InputButton.Left};
var hits=new List<RaycastResult>();EventSystem.current.RaycastAll(pointer,hits);Check(hits.Count>0&&ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject)==b.gameObject,"Click blocked "+name);ExecuteEvents.Execute(b.gameObject,pointer,ExecuteEvents.pointerClickHandler);
}
static async Task Capture(string name)
{
await Task.Delay(150);var type=AppDomain.CurrentDomain.GetAssemblies().Select(a=>a.GetType("Unity.Pipeline.Editor.Commands.Capture.CaptureCommands")).First(t=>t!=null);
type.GetMethod("CaptureGameView",BindingFlags.Static|BindingFlags.Public).Invoke(null,new object[]{1080,1920,null,"Screenshots/inventory_"+name+".png",false,0,"screen"});
}
static async Task SyncHome(){Flow.Handle("Home");Set("_needsSync",true);Flow.Handle("Home");await Until(()=>!Flow.IsBusy);await Task.Delay(150);}
static async Task ConfirmBuy(int item)
{
int count=Flow.Game.State.shopBought[item];Click("BuyGoods"+item);await Task.Delay(150);Click("Ok");await Until(()=>!Flow.IsBusy&&Flow.Game.State.shopBought[item]==count+1);Click("Ok");
}
public static async Task<string> Start()
{
await Until(()=>Flow?.Game!=null&&!Flow.IsBusy);SessionState.SetString("AIGame.G1.original",JsonUtility.ToJson(Flow.Game.State));SessionState.SetString("AIGame.G1.originalEndpoint",Get<string>("_httpBase"));
AuthResponse account=null;string error=null;bool done=false;string user="g1live_inventory_"+Guid.NewGuid().ToString("N");
Flow.StartCoroutine(AuthApi.Register("http://127.0.0.1:18081",user,"isolated-g1-test-password",(r,e)=>{account=r;error=e;done=true;}));await Until(()=>done);Check(account!=null,"Register failed "+error);
File.WriteAllText(Path.Combine(Application.dataPath,"../../Tools/output/g1-live/account.txt"),user);var session=new SessionData{Pid=account.pid,Token=account.token,Username=user};
Set("_session",new Func<SessionData>(()=>session));Set("_profile",new Func<long>(()=>session.Pid));Set("_httpBase","http://127.0.0.1:18081");Set("_boundProfile",0L);Flow.Handle("Home");await Until(()=>!Flow.IsBusy);await Task.Delay(150);
Check(Flow.Game.State.schema==7&&Flow.Game.State.shopBought.All(n=>n==0),"Fresh stock wrong");Click("Bag");await Until(()=>Flow.Page=="UI_PrototypeBag");Check(!Button("UseTicket").interactable,"Empty bag allows use");await Capture("bag_empty");
Click("BagTab1");await Capture("materials_empty");Click("BagPet0");await Until(()=>Flow.Page=="UI_PrototypePets");Click("Back");await Task.Delay(150);Click("Bag");await Until(()=>Flow.Page=="UI_PrototypeBag");
Click("BagTab2");Check(!Button("BagDecor0").interactable,"Locked construction opened");Click("BagTab3");await Capture("events_empty");Click("EventToConsumables");await Task.Delay(150);Click("EarnTicket");await Until(()=>Flow.Page=="UI_PrototypePinball");Click("Back");await Task.Delay(150);
Click("NavShop");await Until(()=>Flow.Page=="UI_PrototypeShop");await Capture("free");Click("ShopTab1");Check(!Button("BuyGoods1").interactable&&!Button("BuyGoods2").interactable,"Locked gold goods purchasable");await Capture("locked");
Click("ShopTab0");await Task.Delay(150);int balls=Flow.Game.State.balls;Click("BuyGoods0");await Until(()=>!Flow.IsBusy&&Flow.Game.State.shopBought[0]==1);Check(Flow.Game.State.balls==balls+5&&!Button("BuyGoods0").interactable,"Free supply not exactly once");Click("Ok");await Capture("free_claimed");
Set("_boundProfile",0L);Flow.Handle("Home");await Until(()=>!Flow.IsBusy);await Task.Delay(150);Click("NavShop");await Until(()=>Flow.Page=="UI_PrototypeShop");Check(!Button("BuyGoods0").interactable&&Flow.Game.State.balls==balls+5,"Reattach repeated free supply");
return "PASS bag empty/material/decor/event states and use doorways; shop locks, daily free +5 once and stock survives session reattach.";
}
public static async Task<string> Purchases()
{
await SyncHome();Click("Coin");await Until(()=>Flow.Page=="UI_PrototypeShop");Check(Button("BuyGoods1").interactable&&Flow.Game.State.coins==1000,"Gold fixture missing");
Click("BuyGoods1");await Capture("confirm");Check(Flow.Game.State.coins==1000&&Flow.Game.State.candy==0&&Flow.Game.State.shopBought[1]==0,"Opening confirm spent currency");Click("Cancel");Check(Flow.Game.State.coins==1000,"Cancelled purchase charged");
await ConfirmBuy(1);Check(Flow.Game.State.coins==900&&Flow.Game.State.candy==20,"First sugar purchase wrong");
var api=Get<PrototypeApi>("_api");var command=api.Command("shop.buy",Flow.Game.State.revision,item:1,day:Flow.Game.State.dailyDay);PrototypeReply response=null;string error=null;
Flow.StartCoroutine(api.Send(command,(r,e)=>{response=r;error=e;}));await Until(()=>response!=null||error!=null);Check(response?.ok==true,"Purchase before lost ack failed");
var session=Get<Func<SessionData>>("_session")();string key="AIGame.G1.pending."+Uri.EscapeDataString("http://127.0.0.1:18081")+"."+session.Pid;
PlayerPrefs.SetString(key,JsonUtility.ToJson(command));PlayerPrefs.Save();Set("_needsSync",true);Flow.Handle("Home");await Until(()=>!Flow.IsBusy&&Get<PrototypeReply>("_reply").replayed);
Check(Flow.Game.State.coins==800&&Flow.Game.State.candy==40&&Flow.Game.State.shopBought[1]==2&&!api.HasPending,"Shop replay duplicated price reward or stock");
var dialog=UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().CaptureState();Check(dialog.Visible&&dialog.Title=="补给已到账","Replayed receipt not shown");await Capture("recovery");Click("Ok");
await ConfirmBuy(1);Check(Flow.Game.State.coins==700&&Flow.Game.State.candy==60&&!Button("BuyGoods1").interactable,"Sugar daily stock not exhausted");
await ConfirmBuy(2);Check(Flow.Game.State.coins==400&&Flow.Game.State.tickets==1&&!Button("BuyGoods2").interactable,"Ticket purchase wrong");await Capture("sold_out");
Click("ShopToBag");await Until(()=>Flow.Page=="UI_PrototypeBag");Check(Button("UseTicket").interactable,"Bought ticket not available in bag");await Capture("bag_ticket");Click("UseTicket");await Until(()=>Flow.Page=="UI_PrototypeCapture");
Click("Capture");await Until(()=>!Flow.IsBusy&&Flow.Game.State.tickets==0&&Flow.Game.State.stages[2]==1);Click("Ok");await Until(()=>Flow.Page=="UI_PrototypePets");Click("Train");await Until(()=>!Flow.IsBusy&&Flow.Game.State.level==2);
Check(Flow.Game.State.candy==30&&Flow.Game.State.coins==400,"Purchased inventory did not feed existing capture/training");Click("Back");await Task.Delay(150);Click("Bag");await Until(()=>Flow.Page=="UI_PrototypeBag");Check(!Button("UseTicket").interactable,"Bag retained consumed ticket");Click("BagTab1");await Capture("materials");
Click("BagPet0");await Until(()=>Flow.Page=="UI_PrototypePets");Check(Get<int>("_selected")==0,"Fragment doorway lost selected species");
return "PASS confirm/cancel free, three sugar purchases exhaust limit, lost-ack +20 candy charges 100 once, ticket +1 usable in capture, candy usable in training, bag quantities and species doorway synchronized.";
}
public static async Task<string> Decorations()
{
await SyncHome();int coins=Flow.Game.State.coins;Click("NavShop");await Until(()=>Flow.Page=="UI_PrototypeShop");Click("ShopTab2");Check(Button("ShopDecor0").interactable,"Decorations gate wrong");await Capture("shop_decor");
Click("ShopDecor0");await Until(()=>Flow.Page=="UI_PrototypeBuild");Check(Get<int>("_decorItem")==0,"Shop decoration did not select item");Click("CampSlot0");Click("Place");await Until(()=>!Flow.IsBusy&&Flow.Game.State.campLayout[0]==0);Check(Flow.Game.State.coins==coins,"Owned decoration placement charged");
Click("Back");await Until(()=>Flow.Page=="UI_PrototypeCamp");Click("Back");await Task.Delay(150);Click("Bag");await Until(()=>Flow.Page=="UI_PrototypeBag");Click("BagTab2");await Capture("bag_decor");
Check(UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().Single(t=>t.name=="Txt_BagDecorInfo0").text.Contains("位置1"),"Bag placement state stale");Click("BagDecor0");await Until(()=>Flow.Page=="UI_PrototypeBuild");Check(Get<int>("_decorItem")==0,"Bag decoration did not select item");
return "PASS shop and bag decoration doorways share authored build preview, owned placement is free and bag shows actual saved position.";
}
public static async Task<string> Insufficient()
{
await SyncHome();Click("Coin");await Until(()=>Flow.Page=="UI_PrototypeShop");Check(Flow.Game.State.coins==99&&!Button("BuyGoods1").interactable,"Insufficient gold purchase enabled");await Capture("insufficient");
return "PASS insufficient gold disables purchase with stock remaining.";
}
public static async Task<string> Refreshed()
{
await SyncHome();Click("NavShop");await Until(()=>Flow.Page=="UI_PrototypeShop");Check(Flow.Game.State.shopBought.All(n=>n==0)&&Button("BuyGoods0").interactable,"Shared day refresh failed");await Capture("refreshed");
int balls=Flow.Game.State.balls;Click("BuyGoods0");await Until(()=>!Flow.IsBusy&&Flow.Game.State.shopBought[0]==1);Check(Flow.Game.State.balls==balls+5,"Refreshed free claim amount wrong");Click("Ok");
return "PASS shared day stock refresh and newly available free supply gives five once.";
}
}
+176
View File
@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using HotUpdate.Net;
using HotUpdate.UI;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public static class VerifyMail
{
static PrototypeFlow Flow => UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
static void Check(bool value, string message) { if (!value) throw new Exception(message); }
static T Get<T>(string name) => (T)typeof(PrototypeFlow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).GetValue(Flow);
static void Set(string name, object value) => typeof(PrototypeFlow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).SetValue(Flow, value);
static async Task Until(Func<bool> condition, int seconds = 20)
{ var end = DateTime.UtcNow.AddSeconds(seconds); while (!condition()) { Check(DateTime.UtcNow < end, "Mail timed out: " + Flow?.Page); await Task.Delay(100); } await Task.Delay(120); }
static Button Button(string name) => UnityEngine.Object.FindObjectsOfType<Button>().Single(b => b.name == "Btn_" + name && b.gameObject.activeInHierarchy);
static void Click(string name)
{
var b = Button(name); Check(b.interactable, "Disabled " + name); Canvas.ForceUpdateCanvases();
var rect = b.GetComponent<RectTransform>();
var pointer = new PointerEventData(EventSystem.current) { position = RectTransformUtility.WorldToScreenPoint(null, rect.TransformPoint(rect.rect.center)), button = PointerEventData.InputButton.Left };
var hits = new List<RaycastResult>(); EventSystem.current.RaycastAll(pointer, hits);
Check(hits.Count > 0 && ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject) == b.gameObject, "Blocked " + name);
ExecuteEvents.Execute(b.gameObject, pointer, ExecuteEvents.pointerClickHandler);
}
static async Task Capture(string name)
{
await Task.Delay(180);
var type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("Unity.Pipeline.Editor.Commands.Capture.CaptureCommands")).First(t => t != null);
type.GetMethod("CaptureGameView", BindingFlags.Public | BindingFlags.Static).Invoke(null, new object[] { 1080, 1920, null, "Screenshots/mail_" + name + ".png", false, 0, "screen" });
}
static async Task Refresh()
{
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Flow.Handle("Home"); Set("_needsSync", true); Flow.Handle("Home"); await Until(() => !Flow.IsBusy);
await Task.Delay(120); Click("Mail"); await Until(() => Flow.Page == "UI_PrototypeMail");
}
public static string Assets()
{
var p = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/UI_PrototypeMail.prefab"); Check(p != null, "Missing mail prefab");
Check(p.GetComponentsInChildren<TextMeshProUGUI>(true).All(t => t.font != null), "Missing mail font");
foreach (var i in p.GetComponentsInChildren<Image>(true).Where(i => i.type == Image.Type.Sliced)) Check(i.sprite != null && i.sprite.border.x > 0 && i.sprite.border.y > 0, "Invalid mail nine slice");
var scroll = p.GetComponentInChildren<ScrollRect>(true); Check(scroll != null && scroll.content != null && scroll.viewport != null, "Mail body scroll not bound");
var config = JsonUtility.FromJson<PrototypeConfig>(File.ReadAllText(Path.Combine(Application.dataPath, "../../Config/Gameplay/greenfield.json")));
Check(PrototypeConfig.Valid(config) && config.mail.initial.Length == 2, "Mail config invalid");
var oldJson = Newtonsoft.Json.Linq.JObject.Parse(JsonUtility.ToJson(config)); oldJson.Remove("mail");
Check(PrototypeConfig.Valid(JsonUtility.FromJson<PrototypeConfig>(oldJson.ToString())), "Pre-mail server/frozen config rejected");
var method = typeof(PrototypeApi).GetMethod("ValidMailReceipt", BindingFlags.Static | BindingFlags.NonPublic);
var request = new PrototypeCommand { action = "mail.claim", requestId = "test", mailId = "a" };
var reply = new PrototypeReply { action = request.action, ok = true, result = new PrototypeResult { id = "test", mailId = "a", claimedCount = 1, reward = new StageReward { balls = 5 } } };
Check((bool)method.Invoke(null, new object[] { reply, request }), "Valid mail receipt rejected");
var partial = Newtonsoft.Json.Linq.JObject.Parse(JsonUtility.ToJson(reply)); ((Newtonsoft.Json.Linq.JObject)partial["result"]).Remove("reward");
Check(!(bool)method.Invoke(null, new object[] { JsonUtility.FromJson<PrototypeReply>(partial.ToString()), request }), "Missing JSON reward acknowledged as empty nested object");
reply.result.reward = null; Check(!(bool)method.Invoke(null, new object[] { reply, request }), "Partial receipt acknowledged");
reply.result.reward = new StageReward { balls = 5 }; reply.result.id = "other"; Check(!(bool)method.Invoke(null, new object[] { reply, request }), "Wrong receipt acknowledged");
var gameView = Resources.FindObjectsOfTypeAll<EditorWindow>().FirstOrDefault(w => w.GetType().FullName == "UnityEditor.GameView");
if (gameView != null) SessionState.SetInt("AIGame.Mail.GameView", (int)gameView.GetType().GetProperty("selectedSizeIndex", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).GetValue(gameView));
return "PASS mail config, authored list/details/scroll, TMP fonts, nine-slices, complete versus malformed claim receipts.";
}
public static async Task<string> Start()
{
await Until(() => Flow?.Game != null && !Flow.IsBusy);
SessionState.SetString("AIGame.Mail.original", JsonUtility.ToJson(Flow.Game.State)); SessionState.SetString("AIGame.Mail.endpoint", Get<string>("_httpBase"));
AuthResponse account = null; string error = null; bool done = false; string user = "g1live_mail_" + Guid.NewGuid().ToString("N");
Flow.StartCoroutine(AuthApi.Register("http://127.0.0.1:18081", user, "isolated-mail-password", (r, e) => { account = r; error = e; done = true; }));
await Until(() => done); Check(account != null, "Register failed " + error);
File.WriteAllText(Path.Combine(Application.dataPath, "../../Tools/output/g1-live/mail-account.txt"), user);
var session = new SessionData { Pid = account.pid, Token = account.token, Username = user };
Set("_session", new Func<SessionData>(() => session)); Set("_profile", new Func<long>(() => session.Pid)); Set("_httpBase", "http://127.0.0.1:18081"); Set("_boundProfile", 0L);
Flow.Handle("Home"); await Until(() => !Flow.IsBusy); await Task.Delay(150);
Check(Flow.Game.State.schema >= 7 && Flow.Game.State.balls == 20 && Flow.Game.State.candy == 0, "New mail account auto-awarded wallet");
Check(GameObject.Find("Dot_Mail") != null, "Unread mail red dot missing");
Click("Mail"); await Until(() => Flow.Page == "UI_PrototypeMail"); await Capture("list");
Click("Mail0"); await Until(() => !Flow.IsBusy && Flow.Game.State.mails[0].read); Check(Flow.Game.State.balls == 20, "Read granted reward"); await Capture("detail");
Click("ClaimMail"); await Until(() => !Flow.IsBusy && Flow.Game.State.mails[0].claimed);
Check(Flow.Game.State.balls == 25 && Flow.Game.State.candy == 20 && !Button("ClaimMail").interactable, "Single claim not exact"); Click("Ok"); await Capture("claimed");
Click("Mail1"); await Until(() => !Flow.IsBusy && Flow.Game.State.mails[1].read); Check(!Button("ClaimMail").interactable, "Notification claim enabled"); await Capture("notice");
Click("Back"); await Task.Delay(150); Check(GameObject.Find("Dot_Mail") == null, "Mail dot remains after reading and claiming");
Set("_boundProfile", 0L); Flow.Handle("Home"); await Until(() => !Flow.IsBusy);
Check(Flow.Game.State.balls == 25 && Flow.Game.State.mails[0].claimed, "Reattach duplicated claim");
return "PASS actual mail entry, unread dot, read without award, exact +5 balls/+20 candy once, notice without attachment, dot cleared and state survives reattach.";
}
public static async Task<string> Diagnose()
{
var net = UnityEngine.Object.FindObjectOfType<GameNet>();
using (var request = UnityEngine.Networking.UnityWebRequest.Get(Get<string>("_httpBase") + "/game/state"))
{
request.SetRequestHeader("Authorization", "Bearer " + net.Session.Token);
var operation = request.SendWebRequest(); while (!operation.isDone) await Task.Delay(50);
var r = JsonUtility.FromJson<PrototypeReply>(request.downloadHandler.text);
var raw = Newtonsoft.Json.Linq.JObject.Parse(request.downloadHandler.text);
return "HTTP=" + request.responseCode + " schema=" + r?.state?.schema + " stateValid=" + PrototypeGame.Valid(r?.state) + " configValid=" + PrototypeConfig.Valid(r?.config) + " mailConfig=" + (r?.config?.mail != null) + " mailPresent=" + (raw["config"]?["mail"] != null) + " lifetime=" + r?.config?.mail?.lifetimeSeconds + " initial=" + r?.config?.mail?.initial?.Length + " versionMatch=" + (r?.configVersion == r?.config?.version) + " error=" + r?.error;
}
}
public static string ConsoleIssues()
{
var assembly = typeof(EditorWindow).Assembly;
var entries = assembly.GetType("UnityEditor.LogEntries"); var entryType = assembly.GetType("UnityEditor.LogEntry");
var flags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
var itemFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
int count = (int)entries.GetMethod("StartGettingEntries", flags).Invoke(null, null);
var messages = new List<string>();
try
{
for (int i = 0; i < count; i++)
{
var entry = Activator.CreateInstance(entryType);
entries.GetMethod("GetEntryInternal", flags).Invoke(null, new[] { (object)i, entry });
int mode = (int)entryType.GetField("mode", itemFlags).GetValue(entry);
if ((mode & (1 | 2 | 16 | 64 | 256 | 512 | 131072)) != 0)
messages.Add((string)entryType.GetField("message", itemFlags).GetValue(entry));
}
}
finally { entries.GetMethod("EndGettingEntries", flags).Invoke(null, null); }
return string.Join("\n", messages);
}
public static async Task<string> Batch()
{
await Refresh(); Check(Flow.Game.State.mails.Length == 8, "Mixed mail fixture missing"); await Capture("mixed");
Click("Mail1"); await Until(() => !Flow.IsBusy); Check(!Button("ClaimMail").interactable, "Expired claim enabled"); await Capture("expired");
Click("MailNext"); await Task.Delay(150); Check(Get<int>("_mailPage") == 1, "Next page failed");
Click("Mail0"); await Until(() => !Flow.IsBusy); await Capture("long_body");
var scroll = UnityEngine.Object.FindObjectOfType<ScrollRect>(); Canvas.ForceUpdateCanvases(); Check(scroll.content.rect.height > scroll.viewport.rect.height, "Long body cannot scroll");
scroll.verticalNormalizedPosition = 0; await Capture("long_body_bottom");
int coins = Flow.Game.State.coins, balls = Flow.Game.State.balls, candy = Flow.Game.State.candy, tickets = Flow.Game.State.tickets;
Click("ClaimAllMail"); await Until(() => !Flow.IsBusy && Get<PrototypeReply>("_reply").action == "mail.claimAll");
Check(Flow.Game.State.coins == coins + 7 && Flow.Game.State.balls == balls + 3 && Flow.Game.State.candy == candy + 20 && Flow.Game.State.tickets == tickets + 1, "Batch attachment sum wrong");
Check(Get<PrototypeReply>("_reply").result.claimedCount == 3 && !Button("ClaimAllMail").interactable, "Batch count or repeat button wrong"); await Capture("batch_result"); Click("Ok");
Click("MailPrev"); await Task.Delay(150); Check(Get<int>("_mailPage") == 0, "Previous page failed");
return "PASS paginated mixed statuses, disabled expired attachment, long-body scroll and exact three-mail batch reward with repeat disabled.";
}
public static async Task<string> LostAcknowledgement()
{
await Refresh(); var api = Get<PrototypeApi>("_api"); var mail = Flow.Game.State.mails.Single();
var command = api.Command("mail.claim", Flow.Game.State.revision, mailId: mail.mailId);
PrototypeReply response = null; string error = null; int balls = Flow.Game.State.balls;
Flow.StartCoroutine(api.Send(command, (r, e) => { response = r; error = e; })); await Until(() => response != null || error != null); Check(response?.ok == true, "First mail claim failed " + error);
var session = Get<Func<SessionData>>("_session")(); string key = "AIGame.G1.pending." + Uri.EscapeDataString("http://127.0.0.1:18081") + "." + session.Pid;
PlayerPrefs.SetString(key, JsonUtility.ToJson(command)); PlayerPrefs.Save(); Set("_needsSync", true); Flow.Handle("Mail");
await Until(() => !Flow.IsBusy && Get<PrototypeReply>("_reply").replayed);
Check(Flow.Game.State.balls == balls + 5 && !api.HasPending, "Mail replay duplicated award or retained confirmed request");
Check(Flow.Game.State.mails.Single().claimed && Flow.Game.State.mails.Single().read, "Recovered mail claim/read flags missing");
Check(UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().Single(t => t.name == "Txt_MailStatus0").text.Contains("附件已领取"), "Recovered list status stale");
var dialog = UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().CaptureState();
Check(dialog.Visible && dialog.Title == "邮件领取结果", "Recovered mail receipt not shown"); await Capture("recovery"); Click("Ok");
return "PASS lost acknowledgement keeps original mail request, grants five once, restores receipt and clears durable queue.";
}
public static async Task<string> Empty()
{
await Refresh(); Check(Flow.Game.State.mails.Length == 0 && !Button("ClaimAllMail").interactable, "Empty mail claim enabled");
Check(GameObject.Find("Txt_MailEmpty") != null, "Missing empty mail state"); await Capture("empty");
return "PASS empty mailbox and disabled batch/attachment actions.";
}
public static async Task<string> Cleanup()
{
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
var testSession = Get<Func<SessionData>>("_session")();
string key = "AIGame.G1.pending." + Uri.EscapeDataString("http://127.0.0.1:18081") + "." + testSession.Pid; Check(!PlayerPrefs.HasKey(key), "Test request still pending");
var net = UnityEngine.Object.FindObjectOfType<GameNet>();
Set("_session", new Func<SessionData>(() => net.Session)); Set("_profile", new Func<long>(() => net.Session?.Pid ?? 0)); Set("_httpBase", SessionState.GetString("AIGame.Mail.endpoint", "http://127.0.0.1:8080")); Set("_boundProfile", 0L);
Flow.Handle("Home"); await Until(() => Flow.Game != null && !Flow.IsBusy);
var original = JsonUtility.FromJson<PrototypeSave>(SessionState.GetString("AIGame.Mail.original", ""));
Check(Flow.Game.State.coins == original.coins && Flow.Game.State.candy == original.candy && Flow.Game.State.shots == original.shots && Flow.Game.State.level == original.level && Flow.Game.State.balls == original.balls, "Original account resources changed during mail test");
var gameView = Resources.FindObjectsOfTypeAll<EditorWindow>().FirstOrDefault(w => w.GetType().FullName == "UnityEditor.GameView");
if (gameView != null) gameView.GetType().GetProperty("selectedSizeIndex", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).SetValue(gameView, SessionState.GetInt("AIGame.Mail.GameView", 0));
return "PASS original account and Game View restored, wallet unchanged; no real claim or read operations sent.";
}
}
@@ -0,0 +1,145 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.UI;
public static class VerifyMainMenuMusic
{
public static string Preflight()
{
var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene();
return JsonConvert.SerializeObject(new { scene.path, scene.isDirty,
scenes = Enumerable.Range(0, UnityEngine.SceneManagement.SceneManager.sceneCount)
.Select(i => UnityEngine.SceneManagement.SceneManager.GetSceneAt(i))
.Select(s => new { s.path, s.isDirty }).ToArray() });
}
public static string PrepareBoot()
{
var previous = EditorSceneManager.playModeStartScene;
SessionState.SetString("AIGame.Music.PreviousPlayScene", previous == null ? "" : AssetDatabase.GetAssetPath(previous));
EditorSceneManager.playModeStartScene = AssetDatabase.LoadAssetAtPath<SceneAsset>("Assets/Scenes/Boot.unity");
return "Boot selected for Play Mode only; existing edit scenes preserved: " + Preflight();
}
public static string RestoreScenes()
{
Check(!EditorApplication.isPlaying, "Exit Play Mode before restore");
string path = SessionState.GetString("AIGame.Music.PreviousPlayScene", "");
EditorSceneManager.playModeStartScene = string.IsNullOrEmpty(path) ? null : AssetDatabase.LoadAssetAtPath<SceneAsset>(path);
SessionState.EraseString("AIGame.Music.PreviousPlayScene");
return Preflight();
}
static void Check(bool ok, string message) { if (!ok) throw new Exception(message); }
static async Task Until(Func<bool> condition, string message, int seconds = 25)
{
var deadline = DateTime.UtcNow.AddSeconds(seconds);
while (!condition())
{
Check(DateTime.UtcNow < deadline, message);
await Task.Delay(100);
}
}
public static async Task<string> Play()
{
Check(EditorApplication.isPlaying, "Play Mode required");
const string musicKey = "AIGame.Settings.music", sfxKey = "AIGame.Settings.sfx";
bool hadMusic = PlayerPrefs.HasKey(musicKey), hadSfx = PlayerPrefs.HasKey(sfxKey);
int musicValue = PlayerPrefs.GetInt(musicKey, 1), sfxValue = PlayerPrefs.GetInt(sfxKey, 1);
float listenerVolume = AudioListener.volume;
PrototypeFlow flow = null;
try
{
await Until(() => UnityEngine.Object.FindObjectOfType<MainMenuMusic>() != null, "Music not loaded by bootstrap");
var music = UnityEngine.Object.FindObjectOfType<MainMenuMusic>();
var source = music.Source;
Check(source != null && source.clip.name == "MeadowBreeze_MainMenu_Loop", "Wrong selected song");
Check(source.loop && !source.playOnAwake && source.spatialBlend == 0, "Invalid authored audio source");
PrototypeClientPrefs.Music = true;
PrototypeClientPrefs.Sfx = true;
await Until(() => source.isPlaying, "Main menu music did not start");
float before = source.time;
await Task.Delay(300);
Check(source.time > before, "Playback samples did not advance");
flow = UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
await Until(() => flow != null && flow.Game != null && !flow.IsBusy, "Gameplay not ready for navigation");
flow.Handle("Home");
for (int i = 0; i < 3; i++)
{
before = source.time;
flow.Handle("Settings");
await Until(() => flow.Page == "UI_PrototypeSettings", "Settings did not open");
Check(source.isPlaying && source.time >= before - 0.1f, "Opening settings restarted or stopped music");
var button = UnityEngine.Object.FindObjectsOfType<Button>().Single(b => b.name == "Btn_ToggleMusic");
button.onClick.Invoke();
await Task.Delay(100);
Check(!PrototypeClientPrefs.Music && !source.isPlaying && PrototypeClientPrefs.Sfx, "Music off did not pause independently of SFX");
float pausedAt = source.time;
await Task.Delay(180);
Check(Math.Abs(source.time - pausedAt) < 0.05f, "Muted music kept advancing");
button.onClick.Invoke();
await Until(() => source.isPlaying, "Music toggle did not resume");
Check(source.time >= pausedAt - 0.1f, "Music toggle restarted the song");
flow.Handle("Home");
await Until(() => flow.Page == "Home", "Did not return home");
Check(UnityEngine.Object.FindObjectsOfType<MainMenuMusic>().Length == 1, "Duplicate music player");
}
PrototypeClientPrefs.Sfx = false;
Check(source.isPlaying && AudioListener.volume > 0, "SFX off muted enabled music");
PrototypeClientPrefs.Music = false;
Check(!source.isPlaying && AudioListener.volume == 0, "Both audio channels off failed");
PrototypeClientPrefs.Music = true;
await Until(() => source.isPlaying, "Music on with SFX off failed");
flow.Handle("Pinball");
await Until(() => flow.Page == "UI_PrototypePinball", "Pinball did not open");
Check(!source.isPlaying, "Camp music continued in pinball gameplay");
PrototypeClientPrefs.Music = false;
PrototypeClientPrefs.Music = true;
Check(!source.isPlaying, "Preference change resumed music in gameplay");
flow.Handle("Home");
await Until(() => source.isPlaying, "Returning home did not resume music");
// Exercise the battle playback gate without starting a server battle.
music.SetInMenu(false);
Check(!source.isPlaying, "Gameplay playback gate failed");
music.SetInMenu(true);
await Until(() => source.isPlaying, "Menu playback gate did not resume");
music.SendMessage("OnApplicationPause", true);
Check(!source.isPlaying, "Background pause did not pause music");
music.SendMessage("OnApplicationPause", false);
await Until(() => source.isPlaying, "Foreground resume failed");
source.time = source.clip.length - 0.35f;
await Until(() => source.isPlaying && source.time < 2, "Clip did not loop", 5);
var report = new {
result = "PASS", selected = source.clip.name, source.clip.length,
startup = true, settingsToggleCycles = 3, resumesWithoutRestart = true,
independentSfxToggle = true, singlePlayer = true, pinballPauseAndReturn = true,
gameplayGate = true, applicationPauseAndResume = true, loopWrap = true,
preferencesRestoredInFinally = true, verifiedUtc = DateTime.UtcNow.ToString("o")
};
string json = JsonConvert.SerializeObject(report, Formatting.Indented);
string repo = Directory.GetParent(Application.dataPath).Parent.FullName;
File.WriteAllText(Path.Combine(repo, "Doc/Design/Sound/MainMenuSoft/playback_validation.json"), json);
return json;
}
finally
{
if (hadMusic) PlayerPrefs.SetInt(musicKey, musicValue); else PlayerPrefs.DeleteKey(musicKey);
if (hadSfx) PlayerPrefs.SetInt(sfxKey, sfxValue); else PlayerPrefs.DeleteKey(sfxKey);
PlayerPrefs.Save();
PrototypeClientPrefs.Apply();
AudioListener.volume = listenerVolume;
if (flow != null) flow.Handle("Home");
}
}
}
@@ -0,0 +1,55 @@
using System;
using System.IO;
using System.Linq;
using HotUpdate.Gameplay;
using Newtonsoft.Json.Linq;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
public static class VerifyProgression
{
static void Check(bool pass,string message) { if (!pass) throw new Exception(message); }
public static string Domain()
{
string repo=Directory.GetParent(Application.dataPath).Parent.FullName;
var config=JsonUtility.FromJson<PrototypeConfig>(File.ReadAllText(Path.Combine(repo,"Config/Gameplay/greenfield.json")));
Check(PrototypeConfig.Valid(config),"Invalid canonical config");
Check(config.pets.Length==10&&config.stages.Length==20,"Expected ten partners and twenty stages");
Check(config.capture.weights.Length==10&&config.rewards.chests[1].choices.Length==10,"Capture pool or chest choices not widened");
foreach(var stage in config.stages) Check(!string.IsNullOrEmpty(stage.chapter),"Stage missing chapter");
var vectors=(JArray)JObject.Parse(File.ReadAllText(Path.Combine(repo,"Tools/output/progression_parity.json")))["entries"];
Check(vectors.Count>=50,"Missing C++ parity vectors");
int wins=0,losses=0;
foreach(var v in vectors)
{
var s=JsonUtility.FromJson<BattleSnapshot>(v["snapshot"].ToString());
var result=JsonUtility.FromJson<PrototypeResult>(v["result"].ToString());
// Replay snapshots are presentation-only saves; give them a schema matching their roster width.
int replaySchema=s.stages.Length>=10?9:s.stages.Length==5?3:1;
var save=new PrototypeSave { schema=replaySchema,level=s.level,stages=s.stages,shards=new int[s.stages.Length],formation=s.formation };
if(replaySchema>=2){save.selectedStage=1;save.clearedStages=new int[replaySchema>=9?20:5];save.clearedStages[0]=1;}
if(replaySchema>=4){save.campLayout=new[]{-1,-1,-1};save.decorations=new int[3];save.restPet=0;}
if(replaySchema>=5){save.dailyDay=1;save.signinDays=1;save.dailyProgress=new int[3];save.dailyClaimed=new int[3];save.signinClaimed=new int[7];}
if(replaySchema>=6)save.shopBought=new int[3];
if(replaySchema>=7)save.mails=new PrototypeMail[0];
if(replaySchema>=8){save.chests=new int[3];save.chestDailyClaimed=0;}
var game=new PrototypeGame(save);
var battle=new PrototypeBattle("parity",game,s.legacy?null:s.config,s.legacy?1:s.stageId);
for(int tick=0;tick<2500&&!battle.Finished;tick++) battle.Tick();
Check(battle.Finished && battle.Won==result.won && battle.Durability==result.durability && battle.Defeated==result.defeated && Mathf.Abs(battle.Time-result.duration)<.002f,
"C#/C++ drift at stage "+s.stageId+" level "+s.level+" party "+string.Join(",",s.formation)+"; Unity="+battle.Won+"/"+battle.Time+" server="+result.won+"/"+result.duration);
if(battle.Won) wins++;else losses++;
}
Check(wins>0&&losses>0,"Parity must cover wins and losses");
foreach(string screen in new[]{"Stages","Pinball","Capture","Pets","Formation","Battle","Camp","Build","Tasks","Signin","Bag","Shop","Mail","Chest","Settings"})
{
var prefab=AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/UI_Prototype"+screen+".prefab");
Check(prefab!=null,"Missing "+screen);
Check(prefab.GetComponentsInChildren<TextMeshProUGUI>(true).All(t=>t.font!=null),"Missing TMP font "+screen);
foreach(var image in prefab.GetComponentsInChildren<Image>(true).Where(i=>i.type==Image.Type.Sliced)) Check(image.sprite!=null&&image.sprite.border.x>0&&image.sprite.border.y>0,"Invalid nine-slice "+screen);
}
return "PASS: "+vectors.Count+" C++/Unity parity cases including "+wins+" wins / "+losses+" losses and pending migration; sixteen authored UI prefabs, fonts, nine-slices.";
}
}
+117
View File
@@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.Linq;
using HotUpdate.Gameplay;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
public static class VerifyPrototype
{
sealed class MemoryStore : IPrototypeStore
{
public PrototypeSave Save;
public bool Fail;
public PrototypeSave Read() => Save == null ? null : JsonUtility.FromJson<PrototypeSave>(JsonUtility.ToJson(Save));
public void Write(PrototypeSave state)
{
if (Fail) throw new InvalidOperationException("Simulated storage failure.");
Save = JsonUtility.FromJson<PrototypeSave>(JsonUtility.ToJson(state));
}
}
static void Check(bool condition, string message) { if (!condition) throw new Exception(message); }
static PrototypeBattle Finish(PrototypeGame game, string id)
{
var battle = new PrototypeBattle(id, game);
for (int i = 0; i < 2500 && !battle.Finished; i++) battle.Tick();
Check(battle.Finished, "Battle did not terminate."); return battle;
}
public static string Domain()
{
var bins = new HashSet<int>();
foreach (int aim in new[] { -35, -20, -10, 0, 10, 20, 35 })
{
var first = PrototypePinball.Simulate(aim); var second = PrototypePinball.Simulate(aim);
Check(first.Path.SequenceEqual(second.Path) && first.Candy == second.Candy, "Pinball not deterministic.");
Check(first.Path.Count > 20 && first.Path.Count <= 601, "Invalid pinball duration.");
Check(first.Path.All(p => p.x >= .024f && p.x <= .976f), "Ball left the board.");
Check(first.Candy == PrototypePinball.Candy[first.Bin] && first.Coins == PrototypePinball.Coins[first.Bin], "Reward not tied to landing.");
bins.Add(first.Bin);
}
Check(bins.Count >= 2, "Aiming has no effect on landing.");
long clock = 1000;
var recoveryStore = new MemoryStore();
var recover = new PrototypeGame(recoveryStore, () => clock);
clock += 599; Check(!recover.RefreshBalls() && recover.State.balls == 20, "Recovery granted early.");
clock += 1; Check(recover.RefreshBalls() && recover.State.balls == 21, "Recovery did not grant one ball.");
clock += 99999; Check(recover.RefreshBalls() && recover.State.balls == 30, "Recovery exceeded cap.");
clock += 99999; Check(!recover.RefreshBalls(), "Full stock banked more balls.");
recover.BeginShot("leave-cap", 0, out _); recover.CompleteShot("leave-cap");
Check(!recover.RefreshBalls() && recover.State.balls == 29, "Leaving cap granted paused-time recovery.");
clock -= 999; Check(!recover.RefreshBalls(), "Clock rollback granted currency.");
for (int aim = -35; aim <= 35; aim++)
{
var shot = PrototypePinball.Simulate(aim);
Check(shot.Path[shot.Path.Count - 1].y <= .035f, "Ball did not reach reward row for aim " + aim);
}
var store = new MemoryStore(); var game = new PrototypeGame(store);
Check(game.State.balls == 20 && game.State.stages[2] == 0, "Wrong new-player state.");
Check(!game.BeginShot("bad", 99, out _) && game.State.balls == 20, "Invalid aim charged currency.");
string beforeFailedWrite = JsonUtility.ToJson(game.State);
store.Fail = true;
try { game.BeginShot("fail", 0, out _); throw new Exception("Storage failure was ignored."); }
catch (InvalidOperationException) { Check(JsonUtility.ToJson(game.State) == beforeFailedWrite && string.IsNullOrEmpty(game.State.pendingShot), "Failed write mutated state."); }
store.Fail = false;
Check(game.BeginShot("recover", 0, out _) && game.State.balls == 19, "Shot not charged exactly once.");
Check(!game.BeginShot("other", 0, out _) && game.State.balls == 19, "Two in-flight shots allowed.");
game = new PrototypeGame(store); Check(game.State.pendingShot == "recover", "In-flight shot lost on reload.");
Check(game.CompleteShot("recover"), "Recovery did not settle."); int coin = game.State.coins;
Check(!game.CompleteShot("recover") && game.State.coins == coin && game.State.shots == 1, "Shot reward duplicated.");
Check(game.State.tickets == 1, "First-shot capture ticket missing.");
Check(game.Capture(out _) == 2 && game.State.stages[2] == 1 && game.State.tickets == 0, "New pet not captured.");
Check(game.Capture(out _) == -1, "Capture allowed without ticket.");
Check(game.Deploy(1, 0, out _) && game.State.formation[0] == 1 && game.State.formation[1] == 0, "Formation swap lost an occupant.");
Check(!game.Deploy(1, 2, out _), "Locked route accepted deployment.");
game.AutoDeploy();
var win = Finish(game, "win"); Check(win.Won && win.Durability > 0, "Recommended tutorial formation failed.");
var replay = Finish(game, "replay");
Check(win.Durability == replay.Durability && win.Defeated == replay.Defeated && win.Time == replay.Time, "Battle results differ for identical formation.");
coin = game.State.coins; int balls = game.State.balls;
Check(game.SettleBattle(win) && game.State.coins == coin + 80 && game.State.balls == balls + 3, "First clear reward incorrect.");
Check(!game.SettleBattle(win), "Same battle settled twice.");
coin = game.State.coins; Check(game.SettleBattle(replay) && game.State.coins == coin, "Repeat clear paid first-clear reward.");
Check(game.ClaimGoal(out _) && !game.ClaimGoal(out _), "Goal reward not once-only.");
var rich = new PrototypeGame(new MemoryStore { Save = new PrototypeSave { candy = 1000, coins = 1000, tickets = 1, level = 5, shards = new[] { 40, 0, 0 }, stages = new[] { 1, 1, 1 } } });
Check(rich.Evolve(0, out _) && rich.State.coins == 500 && rich.State.shards[0] == 0 && rich.Growth(0) == 1.5f, "Evolution cost or growth incorrect.");
Check(!rich.Evolve(0, out _), "Already-evolved pet evolved twice.");
Check(rich.Train(out _) && rich.State.level == 6 && rich.State.candy == 850, "Training cost incorrect.");
Check(rich.Capture(out _) >= 0 && rich.State.shards.Sum() == 20, "Duplicate capture did not convert.");
var empty = new PrototypeGame(new MemoryStore { Save = new PrototypeSave { formation = new[] { -1, -1, -1, -1, -1, -1 } } });
var loss = Finish(empty, "loss"); Check(!loss.Won && loss.Durability == 0, "Unprotected defence did not fail.");
Check(empty.SettleBattle(loss) && empty.State.coins == 0 && !empty.State.cleared, "Failure paid clear rewards.");
string key = "verify-" + Guid.NewGuid().ToString("N");
try
{
var disk = new PrototypeLocalStore(key);
disk.Write(new PrototypeSave { revision = 1, coins = 11 });
disk.Write(new PrototypeSave { revision = 2, coins = 22 });
Check(disk.Read().coins == 22, "Latest local snapshot not restored.");
PlayerPrefs.SetString("AIGame.G0.v1." + key + ".0", "corrupt");
Check(disk.Read().coins == 11, "Corrupt latest slot did not fall back to valid alternate.");
}
finally { PlayerPrefs.DeleteKey("AIGame.G0.v1." + key + ".0"); PlayerPrefs.DeleteKey("AIGame.G0.v1." + key + ".1"); PlayerPrefs.Save(); }
var noMoney = new PrototypeGame(new MemoryStore()); Check(!noMoney.Train(out _) && noMoney.State.level == 1, "Training accepted insufficient funds.");
foreach (string screen in new[] { "Pinball", "Pets", "Formation", "Battle" })
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/UI_Prototype" + screen + ".prefab");
Check(prefab != null, "Missing UI prefab " + screen);
Check(prefab.GetComponentsInChildren<TextMeshProUGUI>(true).All(t => t.font != null), "Missing TMP font.");
foreach (var image in prefab.GetComponentsInChildren<Image>(true).Where(i => i.type == Image.Type.Sliced))
Check(image.sprite != null && image.sprite.border.x > 0 && image.sprite.border.y > 0, "Invalid nine-slice: " + image.name);
}
Check(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Scenes/Prototype/PrototypeArena.prefab") != null, "Missing authored arena.");
return "G0 domain + prefab checks passed: input-dependent deterministic board; pending recovery; once-only rewards; write failure; capture/duplicates; formation; win/loss; training/evolution; fonts and nine-slices. Tutorial won in " + win.Time + "s at durability " + win.Durability + "; aim reaches " + bins.Count + " reward bins.";
}
}
+75
View File
@@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using HotUpdate.UI;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public static class VerifyRoster
{
static PrototypeFlow Flow => UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
static void Check(bool value, string message) { if (!value) throw new Exception(message); }
static T Get<T>(string name) => (T)typeof(PrototypeFlow).GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).GetValue(Flow);
static void Set(string name, object value) => typeof(PrototypeFlow).GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Flow, value);
static async Task Until(Func<bool> condition, int seconds = 20)
{ var end = DateTime.UtcNow.AddSeconds(seconds); while (!condition()) { Check(DateTime.UtcNow < end, "Roster timed out: " + Flow?.Page); await Task.Delay(100); } await Task.Delay(120); }
static Button Button(string name) => UnityEngine.Object.FindObjectsOfType<Button>().Single(b => b.name == "Btn_" + name && b.gameObject.activeInHierarchy);
static Button Any(string name) => UnityEngine.Object.FindObjectsOfType<Button>(true).Single(b => b.name == "Btn_" + name);
static void Click(string name)
{
var b = Button(name); Check(b.interactable, "Disabled " + name); Canvas.ForceUpdateCanvases();
var rect = b.GetComponent<RectTransform>();
var pointer = new PointerEventData(EventSystem.current) { position = RectTransformUtility.WorldToScreenPoint(null, rect.TransformPoint(rect.rect.center)), button = PointerEventData.InputButton.Left };
var hits = new List<RaycastResult>(); EventSystem.current.RaycastAll(pointer, hits);
Check(hits.Count > 0 && ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject) == b.gameObject, "Blocked " + name);
ExecuteEvents.Execute(b.gameObject, pointer, ExecuteEvents.pointerClickHandler);
}
static async Task Capture(string name)
{
await Task.Delay(180);
var type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("Unity.Pipeline.Editor.Commands.Capture.CaptureCommands")).First(t => t != null);
type.GetMethod("CaptureGameView", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static).Invoke(null, new object[] { 1080, 1920, null, "Screenshots/roster_" + name + ".png", false, 0, "screen" });
}
static void Ok() => UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
public static async Task<string> Pages()
{
await Until(() => Flow?.Game != null && !Flow.IsBusy);
Ok(); Flow.Handle("Home"); await Until(() => Flow.Page == "Home");
Check(Flow.Game.State.stages.Length == 10 && Flow.Game.State.clearedStages.Length == 20, "Server save not widened to 10/20");
Click("Adventure"); await Until(() => Flow.Page == "UI_PrototypeStages");
Check(Any("Stage1").gameObject.activeInHierarchy && !Any("Stage11").gameObject.activeInHierarchy, "Chapter one rows not isolated");
await Capture("stages_ch1");
Click("StageTab1"); await Task.Delay(200); Canvas.ForceUpdateCanvases();
Check(Any("Stage11").gameObject.activeInHierarchy && !Any("Stage1").gameObject.activeInHierarchy, "Chapter two rows not isolated");
Check(UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().Single(t => t.name == "Txt_StageName11").text.Contains("2-1"), "Chapter two label wrong");
await Capture("stages_ch2");
Flow.Handle("Home"); await Until(() => Flow.Page == "Home");
Click("NavPets"); await Until(() => Flow.Page == "UI_PrototypePets");
int petRows = UnityEngine.Object.FindObjectsOfType<Button>().Count(b => b.name.StartsWith("Btn_Pet", StringComparison.Ordinal) && b.gameObject.activeInHierarchy);
Check(petRows == 10, "Expected ten partner rows, found " + petRows);
Check(UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().Single(t => t.name == "Txt_PetName9").text.Contains("潮汐鲸"), "Tenth partner missing");
await Capture("pets");
Flow.Handle("Home"); await Until(() => Flow.Page == "Home");
Click("NavPinball"); await Until(() => Flow.Page == "UI_PrototypePinball"); Click("Capture"); await Until(() => Flow.Page == "UI_PrototypeCapture");
int oddsRows = UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().Count(t => t.name.StartsWith("Txt_CaptureOdds", StringComparison.Ordinal));
Check(oddsRows == 10, "Expected ten capture rows, found " + oddsRows);
await Capture("capture");
Flow.Handle("Home"); await Until(() => Flow.Page == "Home");
Click("Rewards"); await Until(() => Flow.Page == "UI_PrototypeChest");
int choices = UnityEngine.Object.FindObjectsOfType<Button>(true).Count(b => b.name.StartsWith("Btn_ChestChoice", StringComparison.Ordinal));
Check(choices == 10, "Expected ten chest choices, found " + choices);
await Capture("chest");
Flow.Handle("Home"); await Until(() => Flow.Page == "Home");
return "PASS ten-partner pets/capture/chest pages and two chapter tabs over twenty stages.";
}
}
+86
View File
@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using HotUpdate.UI;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public static class VerifySettings
{
static PrototypeFlow Flow => UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
static void Check(bool value, string message) { if (!value) throw new Exception(message); }
static async Task Until(Func<bool> condition, int seconds = 20)
{
var end = DateTime.UtcNow.AddSeconds(seconds);
while (!condition())
{
Check(DateTime.UtcNow < end, "Settings timed out: " + Flow?.Page);
await Task.Delay(100);
}
}
static Button Button(string name) => UnityEngine.Object.FindObjectsOfType<Button>().Single(b => b.name == "Btn_" + name && b.gameObject.activeInHierarchy);
static void Click(string name)
{
var b = Button(name);
Check(b.interactable, "Disabled " + name);
Canvas.ForceUpdateCanvases();
var rect = b.GetComponent<RectTransform>();
var pointer = new PointerEventData(EventSystem.current) { position = RectTransformUtility.WorldToScreenPoint(null, rect.TransformPoint(rect.rect.center)), button = PointerEventData.InputButton.Left };
var hits = new List<RaycastResult>();
EventSystem.current.RaycastAll(pointer, hits);
Check(hits.Count > 0 && ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject) == b.gameObject, "Blocked " + name);
ExecuteEvents.Execute(b.gameObject, pointer, ExecuteEvents.pointerClickHandler);
}
static async Task Capture(string name)
{
await Task.Delay(180);
var type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("Unity.Pipeline.Editor.Commands.Capture.CaptureCommands")).First(t => t != null);
type.GetMethod("CaptureGameView", BindingFlags.Public | BindingFlags.Static).Invoke(null, new object[] { 1080, 1920, null, "Screenshots/settings_" + name + ".png", false, 0, "screen" });
}
public static string Assets()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/UI_PrototypeSettings.prefab");
Check(prefab != null, "Missing settings prefab");
Check(prefab.GetComponentsInChildren<TextMeshProUGUI>(true).All(t => t.font != null), "Missing settings font");
foreach (var image in prefab.GetComponentsInChildren<Image>(true).Where(i => i.type == Image.Type.Sliced))
Check(image.sprite != null && image.sprite.border.x > 0 && image.sprite.border.y > 0, "Invalid settings nine slice " + image.name);
var names = prefab.GetComponentsInChildren<Transform>(true).Select(t => t.name).ToHashSet();
foreach (var name in new[] { "Btn_ToggleMusic", "Btn_ToggleSfx", "Btn_CycleQuality", "Btn_ToggleVibrate", "Btn_Relogin", "Btn_DeleteAccount", "Txt_AccountInfo", "Btn_Close" })
Check(names.Contains(name), "Missing settings node " + name);
Check(!names.Contains("Btn_Back"), "Settings should not keep the camp back button");
bool music = PrototypeClientPrefs.Music;
PrototypeClientPrefs.Music = !music;
Check(PrototypeClientPrefs.Music == !music, "Music pref not persisted");
PrototypeClientPrefs.Music = music;
PrototypeClientPrefs.Apply();
return "PASS settings prefab, fonts, nine-slices, toggle nodes and local music preference.";
}
public static async Task<string> Play()
{
Check(EditorApplication.isPlaying, "Enter Play Mode first");
await Until(() => Flow?.Game != null && !Flow.IsBusy);
Check(Flow.Handle("Settings"), "Settings entry not handled");
await Until(() => Flow.Page == "UI_PrototypeSettings");
bool music = PrototypeClientPrefs.Music;
Click("ToggleMusic");
await Task.Delay(120);
Check(PrototypeClientPrefs.Music == !music, "Music toggle did not flip");
Click("ToggleMusic");
await Task.Delay(80);
Check(PrototypeClientPrefs.Music == music, "Music toggle did not restore");
Click("CycleQuality");
await Task.Delay(80);
await Capture("page");
Click("Close");
await Until(() => Flow.Page == "Home");
return "PASS settings opens from home, toggles music/quality, closes with X, screenshot settings_page.png";
}
}