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

292 lines
13 KiB
C#

// 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;
}
}
}
}