Major pipeline changes to map building, introducing 3player map, and adding Shared build mode to level authoring options

This commit is contained in:
Matt F 2026-08-04 22:50:33 -07:00
parent 749c9203de
commit 2b1aee3e03
37 changed files with 5418 additions and 30 deletions

View file

@ -802,17 +802,33 @@ namespace TD.Levels.Editor
}
// P5-8: zones with neither outgoing leak nor goal adjacency.
var zonesWithLeaks = new HashSet<PlayerSlot>();
foreach (var l in ctx.LeakExitVolumes) zonesWithLeaks.Add(l.sourceZone);
foreach (var owner in ctx.DeclaredOwners)
//
// OwnerOnly maps only. The rule asserts the lane-chain topology those maps are built
// around: each player's zone either hands off to the next or is the final defender.
//
// On a Shared map that assertion is wrong, not merely unnecessary. Zones there are
// typically directly adjacent with no gate between them, so most have no outgoing leak
// and are not goal-adjacent — and that is the intended shape, because the whole map is
// one contiguous maze. The property P5-8 actually protects ("enemies from this zone have
// somewhere to go") is enforced more strictly by P5-4 above, which runs a real BFS from
// every spawner to the exit set — and with no leak volumes present, that exit set is the
// goal tiles, which is exactly the right requirement. Zones with no spawners can't
// strand anything: WaveManager skips them.
if (ctx.Authoring.buildAccess == BuildAccess.OwnerOnly)
{
bool hasLeak = zonesWithLeaks.Contains(owner);
bool isGoalAdj = goalAdjacentZones.Contains(owner);
if (!hasLeak && !isGoalAdj)
var zonesWithLeaks = new HashSet<PlayerSlot>();
foreach (var l in ctx.LeakExitVolumes) zonesWithLeaks.Add(l.sourceZone);
foreach (var owner in ctx.DeclaredOwners)
{
report.Error("P5-8", $"Player zone {owner} has no outgoing leak exit AND is not goal-adjacent. " +
"Every zone must either lead somewhere or be a final defender.");
bool hasLeak = zonesWithLeaks.Contains(owner);
bool isGoalAdj = goalAdjacentZones.Contains(owner);
if (!hasLeak && !isGoalAdj)
{
report.Error("P5-8", $"Player zone {owner} has no outgoing leak exit AND is not goal-adjacent. " +
"Every zone must either lead somewhere or be a final defender. " +
"(This rule is skipped on Shared-build maps.)");
}
}
}
@ -919,6 +935,7 @@ namespace TD.Levels.Editor
data.PlayerCount = ctx.Authoring.playerCount;
data.MapDescription = ctx.Authoring.mapDescription;
data.Author = ctx.Authoring.author;
data.BuildAccess = ctx.Authoring.buildAccess;
data.GridOriginTile = ctx.GridOriginTile;
data.GridSize = ctx.GridSize;
@ -1152,7 +1169,10 @@ namespace TD.Levels.Editor
sb.Append("playerCount=").Append(ctx.Authoring.playerCount.ToString(inv)).Append('|');
sb.Append("expectedGoalCount=").Append(ctx.Authoring.expectedGoalCount.ToString(inv)).Append('|');
sb.Append("mapDescription=").Append(ctx.Authoring.mapDescription ?? "").Append('|');
sb.Append("author=").Append(ctx.Authoring.author ?? "").Append('\n');
sb.Append("author=").Append(ctx.Authoring.author ?? "").Append('|');
// By NAME, matching the per-volume enum convention below — a future reordering of the
// enum must not silently produce a matching hash for different rules.
sb.Append("buildAccess=").Append(ctx.Authoring.buildAccess.ToString()).Append('\n');
// Volume blocks, ordered by canonical path.
var rootTransform = ctx.Authoring.transform;
@ -1354,6 +1374,7 @@ namespace TD.Levels.Editor
target.PlayerCount = src.PlayerCount;
target.MapDescription = src.MapDescription;
target.Author = src.Author;
target.BuildAccess = src.BuildAccess;
target.ScenePath = src.ScenePath;
target.AuthoringHash = src.AuthoringHash;
target.LastBakeTimestamp = src.LastBakeTimestamp;

View file

@ -0,0 +1,356 @@
// Assets/_Project/Scripts/Editor/Levels/MapCoreExtractor.cs
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEditorInternal;
using UnityEngine;
using UnityEngine.SceneManagement;
using TD.Audio;
using TD.Dev;
using TD.Gameplay;
using TD.Gameplay.BuilderEffects;
using TD.Gameplay.BuilderSpells;
using TD.Gameplay.Draft;
using TD.Gameplay.EnemyAbilities;
using TD.Gameplay.EnemyUpgrades;
using TD.Gameplay.Waves;
using TD.UI;
using TD.VFX;
namespace TD.Levels.Editor
{
/// <summary>
/// One-time (re-runnable) utility that lifts every map-independent scene object out of a
/// reference match scene and bakes it into the two prefabs <c>TD/New Map…</c> instantiates.
/// </summary>
/// <remarks>
/// <para><b>Why extract instead of generating from scratch?</b> Several of these components hold
/// ORDER-SENSITIVE arrays that double as network identity — <c>EnemyUpgradePool.options</c>
/// indices are card network ids, and <c>TowerPlacementManager.towerDefinitions</c> indices are
/// <c>TowerTypeId</c>. Re-deriving those from an AssetDatabase scan would produce a different
/// order than the shipping map and desync peers. Copying the live components preserves them
/// exactly.</para>
///
/// <para><b>Two prefabs, two lifetimes.</b> <c>MatchServices</c> contains no NetworkObject, so
/// new maps keep it as a LINKED prefab instance and later edits propagate everywhere.
/// <c>MatchNetwork</c> holds the six scene-placed NetworkBehaviours and is UNPACKED on use, so
/// what NGO sees in a generated scene is indistinguishable from a hand-authored one.</para>
///
/// <para><b>This does not modify the reference scene's contents</b> — temporary objects are
/// created and destroyed — but Unity still marks the scene dirty. Don't save it afterwards.</para>
/// </remarks>
public static class MapCoreExtractor
{
private const string PrefabFolder = "Assets/_Project/Prefabs/MatchCore";
private const string ServicesPrefabPath = PrefabFolder + "/MatchServices.prefab";
private const string NetworkPrefabPath = PrefabFolder + "/MatchNetwork.prefab";
// Plain (non-networked, no UIDocument) singleton services, collapsed onto the
// _MatchServices ROOT GameObject in this order.
//
// Order matters in exactly one place: AudioSource must precede MusicPlayer, whose
// [RequireComponent(typeof(AudioSource))] would otherwise auto-add a second, default-valued
// AudioSource before we get a chance to copy the authored one.
private static readonly System.Type[] RootServiceTypes =
{
typeof(LevelLoader),
typeof(PathfindingService),
typeof(SelectionState),
typeof(SelectionVisualizer),
typeof(TowerPlacementController),
typeof(TowerPaintController),
typeof(BuilderSpellCastController),
typeof(FloatingTextSpawner),
typeof(SellEffectSpawner),
typeof(AudioManager),
typeof(AudioSource), // must come before MusicPlayer — see note above
typeof(MusicPlayer),
typeof(BuilderEffectPool),
typeof(BuilderSpellPool),
typeof(EnemyAbilityPool),
typeof(EnemyUpgradePool),
typeof(DraftPool),
typeof(DraftService),
typeof(DevWaveControls),
};
// Scene-placed NetworkBehaviours. Each keeps its own GameObject and its own NetworkObject —
// they are only re-parented under a shared root, so network topology is unchanged.
private static readonly System.Type[] NetworkServiceTypes =
{
typeof(ChatService),
typeof(MatchState),
typeof(RunState),
typeof(TowerPlacementManager),
typeof(WaveManager),
typeof(WaveVote),
};
// -------------------------------------------------------------------
// Menu entry
// -------------------------------------------------------------------
[MenuItem("TD/Map Pipeline/Build Core Prefabs From Open Scene", false, 200)]
public static void BuildCorePrefabs()
{
Scene scene = EditorSceneManager.GetActiveScene();
if (!scene.IsValid() || string.IsNullOrEmpty(scene.path))
{
EditorUtility.DisplayDialog(
"No saved scene open",
"Open a fully-wired match scene (9Player) before running this. The extractor " +
"reads its components to build the core prefabs.",
"OK");
return;
}
// Pre-scan so a missing service aborts before we create anything.
if (!TryResolveSources(scene, out var rootSources, out var missing))
{
EditorUtility.DisplayDialog(
"Reference scene is incomplete",
$"'{scene.name}' is missing {missing.Count} component(s) the core prefabs need:\n\n" +
string.Join("\n", missing) +
"\n\nOpen a complete match scene (9Player) and try again.",
"OK");
return;
}
bool proceed = EditorUtility.DisplayDialog(
"Build core prefabs",
$"Read '{scene.name}' and (re)write:\n\n" +
$" {ServicesPrefabPath}\n" +
$" {NetworkPrefabPath}\n\n" +
"The scene's own contents are not changed, but Unity will mark it dirty. " +
"Don't save it afterwards.\n\nProceed?",
"Build Prefabs",
"Cancel");
if (!proceed) return;
MapTemplate.EnsureFolder(PrefabFolder);
GameObject servicesPrefab = BuildServicesPrefab(scene, rootSources);
GameObject networkPrefab = BuildNetworkPrefab(scene);
var template = MapTemplate.LoadOrCreate();
template.matchServicesPrefab = servicesPrefab;
template.matchNetworkPrefab = networkPrefab;
CaptureTerrainDefaults(scene, template);
EditorUtility.SetDirty(template);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log(
$"[MapPipeline] Core prefabs built from '{scene.name}'.\n" +
$" MatchServices: {RootServiceTypes.Length} components on the root + HUD / DebugConsole / " +
$"CameraRig / lighting children.\n" +
$" MatchNetwork: {NetworkServiceTypes.Length} NetworkBehaviours under one root.\n" +
$" Template: {MapTemplate.AssetPath}\n" +
$" '{scene.name}' is now marked dirty by the temporary objects — do NOT save it.");
EditorGUIUtility.PingObject(template);
}
// -------------------------------------------------------------------
// Source resolution
// -------------------------------------------------------------------
// Resolves every root-service component up front. Returns false (with a human-readable list)
// if anything is absent, so we never write a half-populated prefab.
private static bool TryResolveSources(Scene scene, out List<Component> rootSources,
out List<string> missing)
{
rootSources = new List<Component>(RootServiceTypes.Length);
missing = new List<string>();
// AudioSource is resolved from MusicPlayer's GameObject rather than "first in scene",
// so we copy the music source's authored volume rather than some unrelated emitter.
var musicPlayer = FindOne(scene, typeof(MusicPlayer)) as MusicPlayer;
foreach (var type in RootServiceTypes)
{
Component found = type == typeof(AudioSource)
? (musicPlayer != null ? musicPlayer.GetComponent<AudioSource>() : null)
: FindOne(scene, type);
if (found == null) missing.Add(" • " + type.Name);
rootSources.Add(found);
}
foreach (var type in NetworkServiceTypes)
{
if (FindOne(scene, type) == null) missing.Add(" • " + type.Name);
}
if (FindOne(scene, typeof(HUDController)) == null) missing.Add(" • HUDController");
if (FindOne(scene, typeof(CameraController)) == null) missing.Add(" • CameraController");
return missing.Count == 0;
}
// Scans the scene's root objects rather than Object.FindObjectsByType: the FindObjectsByType
// overload set has churned across Unity 6.x (FindObjectsSortMode deprecation), and walking
// roots is stable across all of them.
private static Component FindOne(Scene scene, System.Type type)
{
foreach (var root in scene.GetRootGameObjects())
{
var found = root.GetComponentInChildren(type, true);
if (found != null) return found;
}
return null;
}
private static Light FindDirectionalLight(Scene scene)
{
foreach (var root in scene.GetRootGameObjects())
{
foreach (var light in root.GetComponentsInChildren<Light>(true))
{
if (light.type == LightType.Directional) return light;
}
}
return null;
}
// -------------------------------------------------------------------
// MatchServices prefab
// -------------------------------------------------------------------
private static GameObject BuildServicesPrefab(Scene scene, List<Component> rootSources)
{
var root = new GameObject("_MatchServices");
// Flatten every plain service onto the root. CopyComponent/PasteComponentAsNew carries
// all serialized values, including the order-sensitive asset arrays.
for (int i = 0; i < rootSources.Count; i++)
{
var src = rootSources[i];
if (src == null) continue;
ComponentUtility.CopyComponent(src);
ComponentUtility.PasteComponentAsNew(root);
}
// Components that need their own GameObject: UIDocument is [DisallowMultipleComponent],
// so HUD and DebugConsole can't share one; CameraRig needs an independently-moving
// transform with the Camera as its child.
GameObject hud = CloneUnder(FindOne(scene, typeof(HUDController)), root, "HUD");
CloneUnder(FindOne(scene, typeof(DebugConsole)), root, "DebugConsole");
GameObject cameraRig = CloneUnder(FindOne(scene, typeof(CameraController)), root, "CameraRig");
CloneUnder(FindDirectionalLight(scene), root, "Directional Light");
// Post-processing volume is optional — 9Player doesn't have one, Main does.
CloneUnder(FindOne(scene, typeof(UnityEngine.Rendering.Volume)), root, "Global Volume");
RewireHudReferences(hud, root, cameraRig);
ClearBakedLevelReference(root);
var prefab = PrefabUtility.SaveAsPrefabAsset(root, ServicesPrefabPath);
Object.DestroyImmediate(root);
return prefab;
}
// Instantiate() remaps references that point INSIDE the copied hierarchy but leaves
// references that point outside it aimed at the original scene objects. Saving a prefab
// silently nulls those, so re-aim them at the prefab-internal equivalents first.
private static void RewireHudReferences(GameObject hud, GameObject root, GameObject cameraRig)
{
if (hud == null) return;
var controller = hud.GetComponent<HUDController>();
if (controller == null) return;
var so = new SerializedObject(controller);
SetRef(so, "placementController", root.GetComponent<TowerPlacementController>());
SetRef(so, "paintController", root.GetComponent<TowerPaintController>());
SetRef(so, "cameraController",
cameraRig != null ? cameraRig.GetComponent<CameraController>() : null);
// TowerPlacementManager lives in the OTHER prefab (it's a NetworkBehaviour), so this
// reference cannot be satisfied here. HUDController already falls back to
// TowerPlacementManager.Instance and retries per-frame until the object spawns, so
// leaving it null is the correct wiring, not a gap.
SetRef(so, "placementManager", null);
so.ApplyModifiedPropertiesWithoutUndo();
}
// The reference scene's LevelLoader points at that map's LevelData. The prefab is map-
// independent, so blank it — NewMapBuilder sets it per-instance as a prefab override.
private static void ClearBakedLevelReference(GameObject root)
{
var loader = root.GetComponent<LevelLoader>();
if (loader == null) return;
var so = new SerializedObject(loader);
SetRef(so, "level", null);
so.ApplyModifiedPropertiesWithoutUndo();
}
// -------------------------------------------------------------------
// MatchNetwork prefab
// -------------------------------------------------------------------
private static GameObject BuildNetworkPrefab(Scene scene)
{
var root = new GameObject("_MatchNetwork");
foreach (var type in NetworkServiceTypes)
{
CloneUnder(FindOne(scene, type), root, type.Name);
}
var prefab = PrefabUtility.SaveAsPrefabAsset(root, NetworkPrefabPath);
Object.DestroyImmediate(root);
return prefab;
}
// -------------------------------------------------------------------
// Terrain defaults
// -------------------------------------------------------------------
private static void CaptureTerrainDefaults(Scene scene, MapTemplate template)
{
var terrain = FindOne(scene, typeof(Terrain)) as Terrain;
if (terrain == null) return;
if (terrain.materialTemplate != null) template.terrainMaterial = terrain.materialTemplate;
if (terrain.terrainData != null && terrain.terrainData.terrainLayers != null &&
terrain.terrainData.terrainLayers.Length > 0)
{
template.terrainLayers = terrain.terrainData.terrainLayers;
}
}
// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------
// Clones the component's whole GameObject subtree and parents it under root.
// Returns null (silently) when source is null — callers treat those as optional.
private static GameObject CloneUnder(Component source, GameObject root, string name)
{
if (source == null) return null;
var clone = Object.Instantiate(source.gameObject);
clone.name = name;
clone.transform.SetParent(root.transform, worldPositionStays: false);
return clone;
}
private static void SetRef(SerializedObject so, string propertyPath, Object value)
{
var prop = so.FindProperty(propertyPath);
if (prop == null)
{
Debug.LogWarning($"[MapPipeline] Serialized field '{propertyPath}' not found on " +
$"{so.targetObject.GetType().Name}. It was probably renamed — " +
"update MapCoreExtractor.");
return;
}
prop.objectReferenceValue = value;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d1b948bcbbe8d8b4788f2056372280b3

View file

@ -0,0 +1,292 @@
// Assets/_Project/Scripts/Editor/Levels/MapTemplate.cs
using UnityEditor;
using UnityEngine;
namespace TD.Levels.Editor
{
/// <summary>
/// Editor-only settings asset that drives <c>TD/New Map…</c>. Holds the two core prefabs
/// produced by <see cref="MapCoreExtractor"/> plus every default the new-map generator needs,
/// so tuning the starter layout is an inspector edit rather than a code change.
/// </summary>
/// <remarks>
/// Lives in the Editor assembly deliberately — nothing at runtime reads it, and keeping the
/// type editor-only stops it from being pulled into a player build.
///
/// The asset is created on demand at <see cref="AssetPath"/>. If it goes missing, both
/// <c>TD/New Map…</c> and the extractor recreate it with defaults; only the prefab references
/// and captured terrain settings are lost, and re-running the extractor restores those.
/// </remarks>
public class MapTemplate : ScriptableObject
{
public const string AssetPath = "Assets/_Project/Settings/MapTemplate.asset";
// -------------------------------------------------------------------
// Core prefabs. Populated by MapCoreExtractor; consumed by NewMapBuilder.
// -------------------------------------------------------------------
[Header("Core Prefabs (built by TD/Map Pipeline/Build Core Prefabs)")]
[Tooltip("Every map-independent, non-networked service collapsed onto one GameObject " +
"(plus HUD / DebugConsole / CameraRig / light children). Instantiated as a LINKED " +
"prefab instance so edits here propagate to every map.")]
public GameObject matchServicesPrefab;
[Tooltip("The six scene-placed NetworkBehaviours, parented under one root. Instantiated " +
"and immediately UNPACKED so the scene contains plain GameObjects — network " +
"topology stays byte-identical to hand-authored maps.")]
public GameObject matchNetworkPrefab;
// -------------------------------------------------------------------
// Output locations.
// -------------------------------------------------------------------
[Header("Output Locations")]
[Tooltip("Folder new map scenes, their LevelData, and their TerrainData are written to. " +
"Drag the folder itself here — an asset reference follows renames and moves, " +
"which a path string does not.")]
public DefaultAsset sceneFolderAsset;
[Tooltip("Scene holding the MapRegistry that the lobby's map browser reads. New maps are " +
"appended to its Maps array automatically.")]
public SceneAsset mainMenuScene;
// Legacy path strings, superseded by the asset references above. Kept so an existing
// template migrates itself (see MigratePathsToAssets) and as a last resort if a reference
// is cleared. Hidden so there's exactly one obvious place to edit.
[HideInInspector] public string sceneFolder = "Assets/_Project/Scenes/Levels";
[HideInInspector] public string mainMenuScenePath = "Assets/_Project/Scenes/UI/MainMenu.unity";
/// <summary>
/// Folder new maps are written to. Prefers <see cref="sceneFolderAsset"/>; falls back to the
/// legacy path string if the reference is unset or doesn't point at a folder.
/// </summary>
public string SceneFolderPath
{
get
{
if (sceneFolderAsset != null)
{
string path = AssetDatabase.GetAssetPath(sceneFolderAsset);
if (AssetDatabase.IsValidFolder(path)) return path;
Debug.LogWarning($"[MapPipeline] MapTemplate's Scene Folder Asset points at " +
$"'{path}', which is not a folder. Falling back to '{sceneFolder}'.");
}
return sceneFolder;
}
}
/// <summary>
/// Scene containing the MapRegistry. Prefers <see cref="mainMenuScene"/>; falls back to the
/// legacy path string. Returns empty if neither resolves.
/// </summary>
public string MainMenuScenePath
{
get
{
if (mainMenuScene != null)
{
string path = AssetDatabase.GetAssetPath(mainMenuScene);
if (!string.IsNullOrEmpty(path)) return path;
}
return mainMenuScenePath ?? string.Empty;
}
}
// -------------------------------------------------------------------
// Terrain defaults. Captured from the reference scene by the extractor.
// -------------------------------------------------------------------
[Header("Terrain Defaults")]
[Tooltip("Material applied to new terrain. Captured from the reference scene's Terrain.")]
public Material terrainMaterial;
[Tooltip("Terrain layers (splat textures) applied to new terrain.")]
public TerrainLayer[] terrainLayers;
[Tooltip("Heightmap resolution. Must be 2^n + 1 (129, 257, 513, 1025…).")]
public int terrainHeightmapResolution = 513;
[Tooltip("Total vertical range of the terrain in world units.")]
public float terrainHeight = 60f;
[Tooltip("How far the terrain extends past the map bounds on each side, in tiles. Gives " +
"the camera something to look at beyond the playable area.")]
public int terrainSkirtTiles = 20;
[Tooltip("Terrain is created flattened to this height above its own base, and its transform " +
"is dropped by the same amount — so the flat surface lands exactly on the buildable " +
"plane (Y=0) while leaving headroom to carve DOWN as well as up. Set 0 to match the " +
"older 9Player setup (surface at the very bottom of the height range).")]
public float terrainCarveDepth = 5f;
// -------------------------------------------------------------------
// Starter layout. All values in tiles.
// -------------------------------------------------------------------
[Header("Starter Layout (tiles)")]
[Tooltip("Default map width for the New Map dialog.")]
public int defaultMapWidth = 60;
[Tooltip("Default map height for the New Map dialog.")]
public int defaultMapHeight = 70;
[Tooltip("Gap between the map edge (MapAreaVolume) and the gameplay volumes. The map area " +
"must fully contain every gameplay volume, and the buffer gives the camera room " +
"to pan past the playfield.")]
public int playAreaMargin = 4;
[Tooltip("Depth (north-south) of the GoalVolume.")]
public int goalDepth = 5;
[Tooltip("Width (east-west) of the GoalVolume.")]
public int goalWidth = 10;
[Tooltip("Depth of each SpawnerVolume, measured from the south edge of its owner's band.")]
public int spawnerDepth = 3;
[Tooltip("Width of each SpawnerVolume.")]
public int spawnerWidth = 10;
[Tooltip("Width of the one-tile-deep LeakExitVolume gate between adjacent player bands.")]
public int leakGateWidth = 8;
[Tooltip("Smallest acceptable north-south depth for a player band. The New Map dialog " +
"refuses to generate a layout thinner than this and tells you the map height needed.")]
public int minBandDepth = 8;
// -------------------------------------------------------------------
// Loading
// -------------------------------------------------------------------
/// <summary>
/// Returns the template asset, creating it with defaults if it doesn't exist yet.
/// Never returns null.
/// </summary>
public static MapTemplate LoadOrCreate()
{
var existing = AssetDatabase.LoadAssetAtPath<MapTemplate>(AssetPath);
if (existing != null)
{
existing.MigratePathsToAssets();
return existing;
}
EnsureFolder(System.IO.Path.GetDirectoryName(AssetPath).Replace("\\", "/"));
var created = CreateInstance<MapTemplate>();
AssetDatabase.CreateAsset(created, AssetPath);
created.MigratePathsToAssets();
AssetDatabase.SaveAssets();
Debug.Log($"[MapPipeline] Created default map template at '{AssetPath}'.");
return created;
}
/// <summary>
/// Upgrades a template that still stores locations as path strings, resolving each to an
/// asset reference. Idempotent and cheap — safe to call on every load.
/// </summary>
/// <remarks>
/// If the recorded path no longer resolves (the folder was renamed out from under it, which
/// is exactly the failure the asset references exist to prevent), falls back to searching
/// the project for a uniquely-named match. An ambiguous match is left unresolved on purpose:
/// guessing which of several same-named scenes holds the MapRegistry would be worse than
/// asking.
/// </remarks>
private void MigratePathsToAssets()
{
bool changed = false;
if (sceneFolderAsset == null && !string.IsNullOrEmpty(sceneFolder)
&& AssetDatabase.IsValidFolder(sceneFolder))
{
sceneFolderAsset = AssetDatabase.LoadAssetAtPath<DefaultAsset>(sceneFolder);
changed |= sceneFolderAsset != null;
}
if (mainMenuScene == null && !string.IsNullOrEmpty(mainMenuScenePath))
{
var scene = AssetDatabase.LoadAssetAtPath<SceneAsset>(mainMenuScenePath);
if (scene == null)
{
string wanted = System.IO.Path.GetFileNameWithoutExtension(mainMenuScenePath);
scene = FindUniqueSceneNamed(wanted);
if (scene != null)
{
Debug.Log($"[MapPipeline] MapTemplate's recorded Main Menu path " +
$"'{mainMenuScenePath}' no longer exists; relinked to " +
$"'{AssetDatabase.GetAssetPath(scene)}'.");
}
}
if (scene != null)
{
mainMenuScene = scene;
changed = true;
}
}
if (changed)
{
EditorUtility.SetDirty(this);
AssetDatabase.SaveAssets();
}
}
// Returns the one SceneAsset whose file name matches exactly, or null if there are zero or
// several. Name search only — the caller has already failed to resolve by path.
private static SceneAsset FindUniqueSceneNamed(string sceneName)
{
if (string.IsNullOrEmpty(sceneName)) return null;
string[] guids = AssetDatabase.FindAssets($"{sceneName} t:SceneAsset");
SceneAsset first = null;
int matches = 0;
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
if (!string.Equals(System.IO.Path.GetFileNameWithoutExtension(path), sceneName,
System.StringComparison.OrdinalIgnoreCase))
{
continue; // FindAssets does substring matching; we want an exact file name.
}
matches++;
if (first == null) first = AssetDatabase.LoadAssetAtPath<SceneAsset>(path);
}
if (matches > 1)
{
Debug.LogWarning($"[MapPipeline] Found {matches} scenes named '{sceneName}'. " +
"Assign Main Menu Scene on MapTemplate manually.");
return null;
}
return first;
}
/// <summary>
/// Creates <paramref name="folder"/> and any missing ancestors. Path is asset-relative and
/// uses forward slashes ("Assets/_Project/Prefabs/MatchCore"). No-op if it already exists.
/// </summary>
public static void EnsureFolder(string folder)
{
if (string.IsNullOrEmpty(folder) || AssetDatabase.IsValidFolder(folder)) return;
string[] parts = folder.Split('/');
// parts[0] is always "Assets" for a valid asset path; walk down creating as needed.
string running = parts[0];
for (int i = 1; i < parts.Length; i++)
{
string next = running + "/" + parts[i];
if (!AssetDatabase.IsValidFolder(next))
{
AssetDatabase.CreateFolder(running, parts[i]);
}
running = next;
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fb7355881928ea146a26471078f207c2

View 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);
}
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5eda54745c5c78c489eb2cf9aa6265b7

View file

@ -0,0 +1,205 @@
// Assets/_Project/Scripts/Editor/Levels/NewMapWindow.cs
using UnityEditor;
using UnityEngine;
using TD.Core;
namespace TD.Levels.Editor
{
/// <summary>
/// The <c>TD/New Map…</c> dialog. Collects the handful of decisions only a designer can make,
/// validates the resulting layout live, and hands off to <see cref="NewMapBuilder"/>.
/// </summary>
public class NewMapWindow : EditorWindow
{
// Mirrors LevelBakePipeline's accepted set — anything else is a hard bake error (P2-15),
// so there's no point offering it here.
private static readonly int[] PlayerCountValues = { 1, 2, 3, 4, 5, 9 };
private static readonly string[] PlayerCountLabels =
{ "1 player", "2 players", "3 players", "4 players", "5 players", "9 players" };
private const string AuthorPrefKey = "TD.MapPipeline.LastAuthor";
private readonly NewMapBuilder.Request request = new NewMapBuilder.Request();
private bool initialized;
private Vector2 scroll;
[MenuItem("TD/New Map...", false, 100)]
public static void Open()
{
var window = GetWindow<NewMapWindow>(utility: true, title: "New Map", focus: true);
window.minSize = new Vector2(420f, 460f);
window.Show();
}
private void Initialize()
{
if (initialized) return;
initialized = true;
var template = MapTemplate.LoadOrCreate();
request.MapWidth = template.defaultMapWidth;
request.MapHeight = template.defaultMapHeight;
request.Author = EditorPrefs.GetString(AuthorPrefKey, "");
}
private void OnGUI()
{
Initialize();
var template = MapTemplate.LoadOrCreate();
scroll = EditorGUILayout.BeginScrollView(scroll);
bool prefabsReady = template.matchServicesPrefab != null
&& template.matchNetworkPrefab != null;
if (!prefabsReady)
{
EditorGUILayout.HelpBox(
"Core prefabs haven't been built yet.\n\n" +
"Open the 9Player scene, then run:\n" +
"TD → Map Pipeline → Build Core Prefabs From Open Scene",
MessageType.Error);
EditorGUILayout.Space(6);
}
EditorGUILayout.LabelField("Identity", EditorStyles.boldLabel);
request.MapName = EditorGUILayout.TextField(
new GUIContent("Map Name",
"Becomes the scene file name, the LevelData asset name, and the string NGO " +
"uses to load the scene. Letters, digits and underscores only."),
request.MapName);
request.Author = EditorGUILayout.TextField("Author", request.Author);
EditorGUILayout.LabelField(new GUIContent("Description",
"Shown in the lobby's map browser. Empty produces a bake warning."));
request.Description = EditorGUILayout.TextArea(
request.Description, GUILayout.Height(38f));
EditorGUILayout.Space(8);
EditorGUILayout.LabelField("Shape", EditorStyles.boldLabel);
request.PlayerCount = EditorGUILayout.IntPopup(
"Players", request.PlayerCount, PlayerCountLabels, PlayerCountValues);
request.MapWidth = Mathf.Max(8, EditorGUILayout.IntField(
new GUIContent("Width (tiles)", "1 tile = 1 world unit."), request.MapWidth));
request.MapHeight = Mathf.Max(8, EditorGUILayout.IntField(
new GUIContent("Height (tiles)", "1 tile = 1 world unit."), request.MapHeight));
request.BuildAccess = (BuildAccess)EditorGUILayout.EnumPopup(
new GUIContent("Build Access",
"OwnerOnly: each player builds only in their own zone.\n" +
"Shared: everyone builds anywhere on the map's buildable area.\n\n" +
"Changeable later on _LevelAuthoring — it just needs a re-bake."),
request.BuildAccess);
if (request.BuildAccess == BuildAccess.Shared)
{
EditorGUILayout.HelpBox(
"Shared: every band forms one cooperative maze. Zones still define " +
"territory for leak attribution, camera start and builder spawn. Towers stay " +
"owned by whoever paid — only that player can sell or upgrade them.",
MessageType.None);
}
EditorGUILayout.Space(8);
EditorGUILayout.LabelField("On Create", EditorStyles.boldLabel);
request.CreateTerrain = EditorGUILayout.Toggle(
new GUIContent("Create Terrain",
"Adds a Terrain sized to the map plus a skirt, on the TerrainGeometry layer, " +
"flattened onto the buildable plane."),
request.CreateTerrain);
request.AddToBuildSettings = EditorGUILayout.Toggle(
new GUIContent("Add to Build Settings",
"Required — NGO cannot load a scene that isn't in the build list."),
request.AddToBuildSettings);
request.AddToMapRegistry = EditorGUILayout.Toggle(
new GUIContent("Add to MapRegistry",
"Opens the MainMenu scene, appends the LevelData to the lobby's map browser, " +
"and saves it."),
request.AddToMapRegistry);
request.BakeImmediately = EditorGUILayout.Toggle(
new GUIContent("Bake immediately",
"Runs the bake on the generated starter layout so the map is valid and " +
"playable the moment it's created."),
request.BakeImmediately);
// Registry wiring is the one step that reaches into a scene the designer isn't looking
// at, so a broken reference should be visible BEFORE Create, not in the summary after.
if (request.AddToMapRegistry && !System.IO.File.Exists(template.MainMenuScenePath))
{
EditorGUILayout.HelpBox(
"Main Menu Scene isn't resolving, so the new map won't be added to the lobby's " +
"map browser. Assign it on MapTemplate, or add the LevelData to MapRegistry.Maps " +
"by hand afterwards.",
MessageType.Warning);
}
EditorGUILayout.Space(8);
DrawLayoutPreview(template);
EditorGUILayout.EndScrollView();
EditorGUILayout.Space(6);
bool canCreate = prefabsReady &&
NewMapBuilder.TryComputeLayout(
template, request.MapWidth, request.MapHeight,
request.PlayerCount, request.BuildAccess, out _, out _);
using (new EditorGUI.DisabledScope(!canCreate))
{
if (GUILayout.Button("Create Map", GUILayout.Height(30f)))
{
CreateMap();
}
}
}
private void DrawLayoutPreview(MapTemplate template)
{
EditorGUILayout.LabelField("Starter Layout", EditorStyles.boldLabel);
if (!NewMapBuilder.TryComputeLayout(template, request.MapWidth, request.MapHeight,
request.PlayerCount, request.BuildAccess,
out var layout, out string error))
{
EditorGUILayout.HelpBox(error, MessageType.Error);
return;
}
string routing = layout.Leaks.Length > 0
? $"through {layout.Leaks.Length} one-tile leak gate(s) " +
$"{Mathf.Min(template.leakGateWidth, layout.Zones[0].width)} tiles wide"
: "across directly-adjacent band borders (no leak gates)";
EditorGUILayout.HelpBox(
$"{request.PlayerCount} full-width band(s), {layout.BandDepth} tiles deep each, " +
$"stacked south to north.\n" +
$"Each band gets a spawner at its south edge; enemies travel north " +
$"{routing} to the goal.\n\n" +
$"Grid will bake to {layout.MapArea.width}×{layout.MapArea.height} with its " +
$"south-west corner at tile (0, 0).",
MessageType.Info);
EditorGUILayout.LabelField(
"Tune the starter proportions on the MapTemplate asset:",
EditorStyles.miniLabel);
if (GUILayout.Button("Select MapTemplate", EditorStyles.miniButton))
{
Selection.activeObject = template;
EditorGUIUtility.PingObject(template);
}
}
private void CreateMap()
{
EditorPrefs.SetString(AuthorPrefKey, request.Author ?? "");
if (NewMapBuilder.Create(request, out string error))
{
Close();
return;
}
EditorUtility.DisplayDialog("Could not create map", error, "OK");
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 50a9cf35b1b63204da8774b8afe7003e