355 lines
19 KiB
C#
355 lines
19 KiB
C#
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";
|
|
}
|
|
}
|