UnityTowerDefense/Assets/_Project/Scripts/Editor/Levels/NewMapBuilder.cs

578 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
{
/// <summary>
/// Everything the <c>TD/New Map…</c> 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).
/// </summary>
/// <remarks>
/// The generated scene has four roots — <c>_LevelAuthoring</c>, <c>_MatchServices</c>,
/// <c>_MatchNetwork</c>, <c>Terrain</c> — replacing the ~30 the hand-authored maps carry.
/// </remarks>
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
// -------------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Computes the starter layout, or explains why the requested dimensions can't hold one.
/// Pure — safe to call every OnGUI frame for live validation.
/// </summary>
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
// -------------------------------------------------------------------
/// <summary>
/// Creates the map. Returns false with a human-readable reason on any precondition failure;
/// nothing is written to disk in that case.
/// </summary>
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<LevelData>();
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<string>();
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<LevelAuthoring>();
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<MapAreaVolume>("MapArea", rootGO.transform, layout.MapArea);
var goal = CreateVolume<GoalVolume>("Goal", rootGO.transform, layout.Goal);
goal.GetComponent<GoalVolume>().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<PlayerZoneVolume>(
$"Player{playerNumber}Zone", group.transform, layout.Zones[i]);
var zoneVolume = zone.GetComponent<PlayerZoneVolume>();
zoneVolume.owner = slot;
zoneVolume.placementValidity = PlacementValidity.Allowed;
var spawn = CreateVolume<SpawnerVolume>(
$"Player{playerNumber}Spawn", group.transform, layout.Spawners[i]);
var spawnVolume = spawn.GetComponent<SpawnerVolume>();
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<LeakExitVolume>(
$"Player{playerNumber}Leak", group.transform, layout.Leaks[i]);
var leakVolume = leak.GetComponent<LeakExitVolume>();
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<T>(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<BoxCollider>();
collider.center = Vector3.zero;
collider.size = new Vector3(
tiles.width * GridCoordinates.TILE_SIZE,
VolumeHeight,
tiles.height * GridCoordinates.TILE_SIZE);
go.AddComponent<T>();
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<Terrain>().materialTemplate = template.terrainMaterial;
}
}
// -------------------------------------------------------------------
// Wiring and registration
// -------------------------------------------------------------------
private static void WireLevelLoader(GameObject services, LevelData levelData)
{
var loader = services.GetComponentInChildren<LevelLoader>(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<MapRegistry>(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);
}
}
}
}
}