// Assets/_Project/Scripts/Editor/Levels/MapTemplate.cs using UnityEditor; using UnityEngine; namespace TD.Levels.Editor { /// /// Editor-only settings asset that drives TD/New Map…. Holds the two core prefabs /// produced by plus every default the new-map generator needs, /// so tuning the starter layout is an inspector edit rather than a code change. /// /// /// 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 . If it goes missing, both /// TD/New Map… and the extractor recreate it with defaults; only the prefab references /// and captured terrain settings are lost, and re-running the extractor restores those. /// 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"; /// /// Folder new maps are written to. Prefers ; falls back to the /// legacy path string if the reference is unset or doesn't point at a folder. /// 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; } } /// /// Scene containing the MapRegistry. Prefers ; falls back to the /// legacy path string. Returns empty if neither resolves. /// 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 // ------------------------------------------------------------------- /// /// Returns the template asset, creating it with defaults if it doesn't exist yet. /// Never returns null. /// public static MapTemplate LoadOrCreate() { var existing = AssetDatabase.LoadAssetAtPath(AssetPath); if (existing != null) { existing.MigratePathsToAssets(); return existing; } EnsureFolder(System.IO.Path.GetDirectoryName(AssetPath).Replace("\\", "/")); var created = CreateInstance(); AssetDatabase.CreateAsset(created, AssetPath); created.MigratePathsToAssets(); AssetDatabase.SaveAssets(); Debug.Log($"[MapPipeline] Created default map template at '{AssetPath}'."); return created; } /// /// 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. /// /// /// 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. /// private void MigratePathsToAssets() { bool changed = false; if (sceneFolderAsset == null && !string.IsNullOrEmpty(sceneFolder) && AssetDatabase.IsValidFolder(sceneFolder)) { sceneFolderAsset = AssetDatabase.LoadAssetAtPath(sceneFolder); changed |= sceneFolderAsset != null; } if (mainMenuScene == null && !string.IsNullOrEmpty(mainMenuScenePath)) { var scene = AssetDatabase.LoadAssetAtPath(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(path); } if (matches > 1) { Debug.LogWarning($"[MapPipeline] Found {matches} scenes named '{sceneName}'. " + "Assign Main Menu Scene on MapTemplate manually."); return null; } return first; } /// /// Creates and any missing ancestors. Path is asset-relative and /// uses forward slashes ("Assets/_Project/Prefabs/MatchCore"). No-op if it already exists. /// 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; } } } }