Major pipeline changes to map building, introducing 3player map, and adding Shared build mode to level authoring options
This commit is contained in:
parent
749c9203de
commit
2b1aee3e03
37 changed files with 5418 additions and 30 deletions
578
Assets/_Project/Scripts/Editor/Levels/NewMapBuilder.cs
Normal file
578
Assets/_Project/Scripts/Editor/Levels/NewMapBuilder.cs
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue