// Assets/_Project/Scripts/Editor/Levels/NewMapBuilder.cs using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; using TD.Core; using TD.Gameplay; namespace TD.Levels.Editor { /// /// Everything the TD/New Map… command does once the designer presses Create: build the /// scene, generate a starter volume layout that bakes clean, create and wire the LevelData, and /// perform the three registration steps that otherwise fail silently (Build Settings, the /// LevelLoader reference, and the lobby's MapRegistry). /// /// /// The generated scene has four roots — _LevelAuthoring, _MatchServices, /// _MatchNetwork, Terrain — replacing the ~30 the hand-authored maps carry. /// public static class NewMapBuilder { // The physics layer terrain must sit on. Builder.SampleTerrainY and // BuilderSpellCastController both raycast against it; terrain left on Default silently // falls back to Y=0 and the builder walks through hills instead of over them. private const string TerrainLayerName = "TerrainGeometry"; // Vertical size of every authoring volume's BoxCollider. Any value works so long as the // bounds straddle Y=0 (bake rule P2-18); 1 unit centred on zero matches 9Player. private const float VolumeHeight = 1f; private static readonly Regex ValidName = new Regex(@"^[A-Za-z0-9_]+$"); // ------------------------------------------------------------------- // Request // ------------------------------------------------------------------- public class Request { public string MapName = "NewMap"; public int PlayerCount = 3; public int MapWidth = 60; public int MapHeight = 70; public string Author = ""; public string Description = ""; public BuildAccess BuildAccess = BuildAccess.OwnerOnly; public bool CreateTerrain = true; public bool AddToBuildSettings = true; public bool AddToMapRegistry = true; public bool BakeImmediately = true; } // ------------------------------------------------------------------- // Starter layout // ------------------------------------------------------------------- /// /// Tile rectangles for a generated starter map. Zones are stacked south-to-north as /// full-width bands; enemies spawn at the south edge of their owner's band and travel north /// through a one-tile leak gate into the next player's band, with the goal north of the /// final band. /// public struct Layout { public RectInt MapArea; public RectInt[] Zones; // index 0 = Player1 public RectInt[] Spawners; // parallel to Zones public RectInt[] Leaks; // Zones.Length - 1 entries; Leaks[i] runs zone i -> zone i+1 public RectInt Goal; public int BandDepth; } /// /// Computes the starter layout, or explains why the requested dimensions can't hold one. /// Pure — safe to call every OnGUI frame for live validation. /// public static bool TryComputeLayout(MapTemplate template, int mapWidth, int mapHeight, int playerCount, BuildAccess buildAccess, out Layout layout, out string error) { layout = default; error = null; // OwnerOnly maps get a one-tile leak gate between bands: it is the only walkable // connection, which is what forces enemies through a chokepoint and makes the handoff // between lanes legible. Shared maps skip gates entirely and butt the bands together — // the whole map is one maze, so a gate would just be an arbitrary pinch, and with no // LeakExitVolume the gap row would not be walkable at all. bool useLeakGates = buildAccess == BuildAccess.OwnerOnly; int gapRows = useLeakGates ? playerCount - 1 : 0; int margin = Mathf.Max(0, template.playAreaMargin); int playX0 = margin; int playY0 = margin; int playW = mapWidth - margin * 2; int playH = mapHeight - margin * 2; int widestFeature = Mathf.Max(template.goalWidth, Mathf.Max(template.spawnerWidth, template.leakGateWidth)); if (playW < widestFeature) { error = $"Map is too narrow. The play area is {playW} tiles wide after the " + $"{margin}-tile margin, but the widest starter feature needs {widestFeature}. " + $"Use a width of at least {widestFeature + margin * 2}."; return false; } // Vertical budget: N bands + the gate rows (if any) + the goal. int budget = playH - gapRows - template.goalDepth; int bandDepth = playerCount > 0 ? budget / playerCount : 0; int minBand = Mathf.Max(template.minBandDepth, template.spawnerDepth + 1); if (bandDepth < minBand) { int required = playerCount * minBand + gapRows + template.goalDepth + margin * 2; error = $"Map is too shallow for {playerCount} player band(s). Each band would be " + $"{Mathf.Max(bandDepth, 0)} tiles deep; the minimum is {minBand}. " + $"Use a height of at least {required}."; return false; } layout.MapArea = new RectInt(0, 0, mapWidth, mapHeight); layout.BandDepth = bandDepth; layout.Zones = new RectInt[playerCount]; layout.Spawners = new RectInt[playerCount]; layout.Leaks = new RectInt[useLeakGates ? Mathf.Max(0, playerCount - 1) : 0]; int y = playY0; for (int i = 0; i < playerCount; i++) { layout.Zones[i] = new RectInt(playX0, y, playW, bandDepth); // Spawner sits at the south edge of its own band, so enemies always have the full // band depth to walk through before reaching the exit (bake rule P5-5 wants it // inside the owner's zone). int spawnW = Mathf.Min(template.spawnerWidth, playW); layout.Spawners[i] = new RectInt( CenteredStart(playX0, playW, spawnW), y, spawnW, template.spawnerDepth); y += bandDepth; if (useLeakGates && i < playerCount - 1) { // One-tile gate wedged between two bands: its south neighbours are band i and // its north neighbours are band i+1, satisfying both adjacency rules (P5-6/P5-7). int gateW = Mathf.Min(template.leakGateWidth, playW); layout.Leaks[i] = new RectInt( CenteredStart(playX0, playW, gateW), y, gateW, 1); y += 1; } } int goalW = Mathf.Min(template.goalWidth, playW); layout.Goal = new RectInt( CenteredStart(playX0, playW, goalW), y, goalW, template.goalDepth); return true; } private static int CenteredStart(int areaStart, int areaWidth, int featureWidth) { return areaStart + (areaWidth - featureWidth) / 2; } // ------------------------------------------------------------------- // Creation // ------------------------------------------------------------------- /// /// Creates the map. Returns false with a human-readable reason on any precondition failure; /// nothing is written to disk in that case. /// public static bool Create(Request request, out string error) { error = null; var template = MapTemplate.LoadOrCreate(); if (!ValidatePreconditions(request, template, out error)) return false; if (!TryComputeLayout(template, request.MapWidth, request.MapHeight, request.PlayerCount, request.BuildAccess, out Layout layout, out error)) { return false; } string folder = template.SceneFolderPath; string scenePath = $"{folder}/{request.MapName}.unity"; string dataPath = $"{folder}/{request.MapName}.asset"; // Give the designer a chance to save whatever they had open — NewScene(Single) would // otherwise discard it. if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) { error = "Cancelled while saving the currently open scene."; return false; } MapTemplate.EnsureFolder(folder); Scene scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); // ---- Core objects ------------------------------------------- var services = (GameObject)PrefabUtility.InstantiatePrefab(template.matchServicesPrefab); services.name = "_MatchServices"; var network = (GameObject)PrefabUtility.InstantiatePrefab(template.matchNetworkPrefab); network.name = "_MatchNetwork"; // Unpack so the six NetworkObjects are plain scene objects. NGO treats in-scene placed // NetworkObjects that are prefab instances differently from loose ones; unpacking keeps // generated maps structurally identical to the hand-authored ones. PrefabUtility.UnpackPrefabInstance( network, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); // ---- LevelData ---------------------------------------------- var levelData = ScriptableObject.CreateInstance(); AssetDatabase.CreateAsset(levelData, dataPath); // ---- Authoring hierarchy ------------------------------------ LevelAuthoring authoring = BuildAuthoring(request, layout, levelData); // ---- Terrain ------------------------------------------------- if (request.CreateTerrain) { CreateTerrain(request, template); } // ---- Wire the runtime loader --------------------------------- // LevelLoader lives inside the linked MatchServices prefab; assigning here records a // per-instance override, which is exactly the intent — the asset differs per map. WireLevelLoader(services, levelData); // ---- Save + register ----------------------------------------- EditorSceneManager.SaveScene(scene, scenePath); var notes = new List(); if (request.AddToBuildSettings) notes.Add(RegisterInBuildSettings(scenePath)); if (request.AddToMapRegistry) notes.Add(RegisterInMapRegistry(template, levelData)); // Bake last: it stamps LevelData.ScenePath from the ACTIVE scene, so the scene has to // be saved (and therefore have a path) before this runs. if (request.BakeImmediately) { notes.Add(LevelBakePipeline.Bake(authoring) ? "Baked LevelData (see console for the bake report)." : "Bake FAILED — see console. The scene and assets were still created."); } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Selection.activeGameObject = authoring.gameObject; if (SceneView.lastActiveSceneView != null) SceneView.lastActiveSceneView.FrameSelected(); Debug.Log( $"[MapPipeline] Created map '{request.MapName}' " + $"({request.PlayerCount} player(s), {request.MapWidth}×{request.MapHeight} tiles, " + $"band depth {layout.BandDepth}).\n" + $" Scene: {scenePath}\n" + $" LevelData: {dataPath}\n" + string.Join("\n", notes.Where(n => !string.IsNullOrEmpty(n)).Select(n => " " + n))); return true; } private static bool ValidatePreconditions(Request request, MapTemplate template, out string error) { error = null; if (string.IsNullOrWhiteSpace(request.MapName) || !ValidName.IsMatch(request.MapName)) { error = "Map name must be letters, digits and underscores only — it becomes the " + "scene file name and the string NGO uses to load the scene."; return false; } if (template.matchServicesPrefab == null || template.matchNetworkPrefab == null) { error = "Core prefabs are missing. Open the 9Player scene and run " + "TD → Map Pipeline → Build Core Prefabs From Open Scene first."; return false; } string folder = template.SceneFolderPath; if (string.IsNullOrEmpty(folder)) { error = "MapTemplate has no output folder. Assign Scene Folder Asset on " + MapTemplate.AssetPath + "."; return false; } string scenePath = $"{folder}/{request.MapName}.unity"; if (System.IO.File.Exists(scenePath)) { error = $"A scene already exists at '{scenePath}'. Pick a different name."; return false; } string dataPath = $"{folder}/{request.MapName}.asset"; if (System.IO.File.Exists(dataPath)) { error = $"A LevelData asset already exists at '{dataPath}'. Pick a different name."; return false; } return true; } // ------------------------------------------------------------------- // Authoring hierarchy // ------------------------------------------------------------------- private static LevelAuthoring BuildAuthoring(Request request, Layout layout, LevelData levelData) { var rootGO = new GameObject("_LevelAuthoring"); var authoring = rootGO.AddComponent(); authoring.targetAsset = levelData; authoring.mapName = request.MapName; authoring.playerCount = request.PlayerCount; authoring.expectedGoalCount = 1; authoring.mapDescription = request.Description ?? ""; authoring.author = request.Author ?? ""; authoring.buildAccess = request.BuildAccess; // Default every gizmo category on. A designer opening a fresh map wants to SEE the // starter layout without hunting for toggles; turning them off is the deliberate act. authoring.alwaysShowPlayerZones = true; authoring.alwaysShowSpawners = true; authoring.alwaysShowLeakExits = true; authoring.alwaysShowGoals = true; authoring.alwaysShowMapArea = true; CreateVolume("MapArea", rootGO.transform, layout.MapArea); var goal = CreateVolume("Goal", rootGO.transform, layout.Goal); goal.GetComponent().placementValidity = PlacementValidity.Invalid; for (int i = 0; i < request.PlayerCount; i++) { int playerNumber = i + 1; var slot = (PlayerSlot)(byte)playerNumber; var group = new GameObject($"Player {playerNumber}"); group.transform.SetParent(rootGO.transform, worldPositionStays: false); var zone = CreateVolume( $"Player{playerNumber}Zone", group.transform, layout.Zones[i]); var zoneVolume = zone.GetComponent(); zoneVolume.owner = slot; zoneVolume.placementValidity = PlacementValidity.Allowed; var spawn = CreateVolume( $"Player{playerNumber}Spawn", group.transform, layout.Spawners[i]); var spawnVolume = spawn.GetComponent(); spawnVolume.owner = slot; spawnVolume.spawnerIdInZone = 0; spawnVolume.spawnFacing = Direction.North; // bands run south -> north spawnVolume.placementValidity = PlacementValidity.Invalid; // The northernmost band has no leak — it's the final defender, and its zone is // goal-adjacent instead (bake rule P5-8). if (i < layout.Leaks.Length) { var leak = CreateVolume( $"Player{playerNumber}Leak", group.transform, layout.Leaks[i]); var leakVolume = leak.GetComponent(); leakVolume.sourceZone = slot; leakVolume.target = (PlayerSlot)(byte)(playerNumber + 1); leakVolume.weight = 1f; leakVolume.placementValidity = PlacementValidity.Invalid; } } return authoring; } // Builds one authoring volume covering the given tile rectangle. Tiles are edge-aligned, so // a rect spanning tiles [x0..x1] occupies world X [x0, x1+1] — which is exactly // RectInt.xMin..RectInt.xMax. Geometry goes on the BoxCollider (centre zero, size = extent) // with the transform at the volume's centre, matching the existing maps and keeping // VolumeEditTool's edge-drag behaviour predictable. private static GameObject CreateVolume(string name, Transform parent, RectInt tiles) where T : VolumeBase { var go = new GameObject(name); go.transform.SetParent(parent, worldPositionStays: false); go.transform.localPosition = new Vector3( (tiles.xMin + tiles.xMax) * 0.5f * GridCoordinates.TILE_SIZE, 0f, (tiles.yMin + tiles.yMax) * 0.5f * GridCoordinates.TILE_SIZE); // Added before the volume component so VolumeBase's [RequireComponent] doesn't add a // second, default-sized collider. var collider = go.AddComponent(); collider.center = Vector3.zero; collider.size = new Vector3( tiles.width * GridCoordinates.TILE_SIZE, VolumeHeight, tiles.height * GridCoordinates.TILE_SIZE); go.AddComponent(); return go; } // ------------------------------------------------------------------- // Terrain // ------------------------------------------------------------------- private static void CreateTerrain(Request request, MapTemplate template) { int skirt = Mathf.Max(0, template.terrainSkirtTiles); float carve = Mathf.Clamp(template.terrainCarveDepth, 0f, template.terrainHeight); var data = new TerrainData { name = request.MapName + "_TerrainData" }; // Resolution before size: changing heightmapResolution resets size. data.heightmapResolution = template.terrainHeightmapResolution; data.size = new Vector3( request.MapWidth + skirt * 2f, Mathf.Max(1f, template.terrainHeight), request.MapHeight + skirt * 2f); if (template.terrainLayers != null && template.terrainLayers.Length > 0) { data.terrainLayers = template.terrainLayers; } // Start the surface partway up its own height range and drop the transform to match, so // the flat ground lands on the buildable plane (Y=0) with room to carve downwards. if (carve > 0f) { int res = data.heightmapResolution; float normalized = carve / Mathf.Max(1f, template.terrainHeight); var heights = new float[res, res]; for (int y = 0; y < res; y++) { for (int x = 0; x < res; x++) heights[y, x] = normalized; } data.SetHeights(0, 0, heights); } AssetDatabase.CreateAsset( data, $"{template.SceneFolderPath}/{request.MapName}_TerrainData.asset"); var terrainGO = Terrain.CreateTerrainGameObject(data); terrainGO.name = "Terrain"; terrainGO.transform.position = new Vector3(-skirt, -carve, -skirt); int layer = LayerMask.NameToLayer(TerrainLayerName); if (layer >= 0) { terrainGO.layer = layer; } else { Debug.LogWarning( $"[MapPipeline] Layer '{TerrainLayerName}' does not exist, so the new terrain was " + "left on Default. The builder's ground raycast and spell targeting both filter " + "on that layer and will ignore this terrain until it's fixed."); } if (template.terrainMaterial != null) { terrainGO.GetComponent().materialTemplate = template.terrainMaterial; } } // ------------------------------------------------------------------- // Wiring and registration // ------------------------------------------------------------------- private static void WireLevelLoader(GameObject services, LevelData levelData) { var loader = services.GetComponentInChildren(true); if (loader == null) { Debug.LogError("[MapPipeline] MatchServices prefab has no LevelLoader — the map " + "will not load at runtime. Re-run Build Core Prefabs."); return; } var so = new SerializedObject(loader); var prop = so.FindProperty("level"); if (prop == null) { Debug.LogError("[MapPipeline] LevelLoader has no serialized field 'level'. It was " + "renamed — update NewMapBuilder."); return; } prop.objectReferenceValue = levelData; so.ApplyModifiedPropertiesWithoutUndo(); } private static string RegisterInBuildSettings(string scenePath) { var scenes = EditorBuildSettings.scenes.ToList(); if (scenes.Any(s => s.path == scenePath)) { return "Already in Build Settings."; } scenes.Add(new EditorBuildSettingsScene(scenePath, true)); EditorBuildSettings.scenes = scenes.ToArray(); return $"Added to Build Settings (index {scenes.Count - 1})."; } // MapRegistry lives in the MainMenu scene, not the map scene — the one registration step // that isn't visible from where the designer is working, and therefore the one most often // forgotten. Open it additively, append, save, close. private static string RegisterInMapRegistry(MapTemplate template, LevelData levelData) { string path = template.MainMenuScenePath; if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) { return $"SKIPPED MapRegistry — no scene at '{path}'. Assign Main Menu Scene on " + $"{MapTemplate.AssetPath}, then add this LevelData to the registry by hand."; } Scene menu = default; bool opened = false; try { menu = EditorSceneManager.OpenScene(path, OpenSceneMode.Additive); opened = true; MapRegistry registry = null; foreach (var root in menu.GetRootGameObjects()) { registry = root.GetComponentInChildren(true); if (registry != null) break; } if (registry == null) { return $"SKIPPED MapRegistry — none found in '{menu.name}'. Add the LevelData by hand."; } var so = new SerializedObject(registry); var maps = so.FindProperty("maps"); if (maps == null) { return "SKIPPED MapRegistry — serialized field 'maps' not found (renamed?)."; } for (int i = 0; i < maps.arraySize; i++) { if (maps.GetArrayElementAtIndex(i).objectReferenceValue == levelData) { return "Already in MapRegistry."; } } maps.InsertArrayElementAtIndex(maps.arraySize); maps.GetArrayElementAtIndex(maps.arraySize - 1).objectReferenceValue = levelData; so.ApplyModifiedPropertiesWithoutUndo(); EditorSceneManager.MarkSceneDirty(menu); EditorSceneManager.SaveScene(menu); return $"Added to MapRegistry in '{menu.name}' (slot {maps.arraySize - 1})."; } finally { if (opened && menu.IsValid()) { EditorSceneManager.CloseScene(menu, removeScene: true); } } } } }