Add prototype gameplay loop with character/battlefield assets and server game APIs.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user