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; } /// Pipeline authoring entry for the selected battlefield design. It never creates gameplay scenery at runtime. 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(SliceObject(text, "ground")), assembly = JsonUtility.FromJson(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(); 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(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(); 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(); var types = new HashSet(); 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(item.prefab) == null) throw new Exception("Missing component prefab " + item.prefab); } var groundPrefab = AssetDatabase.LoadAssetAtPath(data.ground.gamePrefab); if (groundPrefab == null) throw new Exception("Missing ground prefab " + data.ground.gamePrefab); var groundMeshes = groundPrefab.GetComponentsInChildren(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(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(true)) transform.gameObject.layer = layer; } static GameObject AddPrefab(string assetPath, Transform parent, string name, BattlefieldVec3 position, BattlefieldVec3 rotation, BattlefieldVec3 scale) { var prefab = AssetDatabase.LoadAssetAtPath(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; } /// /// 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. /// 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(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(); 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(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(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(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(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"; } }