// Assets/_Project/Scripts/Gameplay/MapRegistry.cs using System.Collections.Generic; using UnityEngine; using TD.Levels; namespace TD.Gameplay { /// /// Persistent (DontDestroyOnLoad) singleton that holds every available /// to the lobby's map browser. Mirrors 's pattern. /// /// /// Inspector setup. Place ONE MapRegistry GameObject in the MainMenu /// scene only. Drag every asset that should appear in the lobby's /// map browser into the Maps array. The first non-null entry becomes the /// map (auto-selected when a lobby first opens). Mark itself /// DontDestroyOnLoad on Awake so it survives MainMenu → Lobby → Match transitions and /// is reachable from any scene via . /// /// Why MainMenu-only? Same reason as : a single /// authoritative array prevents the "I updated one and forgot the other" designer trap. /// Duplicate instances dropped into Lobby or Match scenes self-destroy on Awake. /// /// Editor-only standalone testing. If you open the Lobby or Match scene directly /// from the editor without going through MainMenu, no MapRegistry exists. /// is null; callers should handle that (the lobby UI shows an empty browser, Quick Start falls /// back to a hardcoded scene, etc.). For standalone testing, temporarily add a registry to /// whatever scene you're testing — but don't commit it. /// /// Selectability. A map is selectable in a lobby of N players iff /// N <= map.PlayerCount. The lobby UI still shows maps it can't currently use, just /// greyed out with an explanatory label — so players can see what other maps exist and how /// many players they'd need to play them. /// /// Plain MonoBehaviour. Not a NetworkBehaviour — every peer has the same LevelData /// assets in the build. Network state tracks only the selected map's index via /// LobbyService.SelectedMapIndex. /// public class MapRegistry : MonoBehaviour { // ----- Singleton ------------------------------------------------- public static MapRegistry Instance { get; private set; } // ----- Inspector -------------------------------------------------- [Tooltip("All LevelData assets that should appear in the lobby's map browser. Drag each " + "asset into the array. The FIRST non-null entry becomes the default selection " + "when a lobby first opens; order subsequent entries however you want them sorted " + "in the UI. Null entries and assets with empty MapName are skipped with a warning.")] [SerializeField] private LevelData[] maps; // ----- Public API ------------------------------------------------- /// /// All registered maps in inspector order. Never returns null entries; entries that failed /// validation in Awake are filtered out. Safe to iterate any time after Awake. /// public IReadOnlyList Maps => validatedMaps; /// /// Total number of valid registered maps. Equivalent to Maps.Count but cheaper /// since it doesn't allocate an enumerator. /// public int Count => validatedMaps.Count; /// /// The map auto-selected when a lobby first opens. Returns the first valid entry in the /// inspector array, or null if the registry has no valid maps (which would be a setup /// error — Quick Start and lobby will both log and degrade). /// public LevelData Default => validatedMaps.Count > 0 ? validatedMaps[0] : null; /// /// Returns the map at the given index, or null if the index is out of range. Tolerant of /// stale indices that might survive a registry edit (e.g. a NetworkVariable holding an /// index whose map was removed). /// public LevelData Get(int index) { if (index < 0 || index >= validatedMaps.Count) return null; return validatedMaps[index]; } /// /// Returns the index of the given map in , or -1 if it isn't registered. /// Use this when you have a LevelData reference and need to sync the selection over the /// network as an integer. /// public int IndexOf(LevelData map) { if (map == null) return -1; for (int i = 0; i < validatedMaps.Count; i++) { if (validatedMaps[i] == map) return i; } return -1; } /// /// True if a lobby with players is allowed to start a match /// on . Currently the rule is just "lobby size must fit"; if minimum /// player counts become a thing later, gate them here. /// public static bool IsSelectableFor(LevelData map, int playerCount) { if (map == null) return false; if (playerCount < 1) return false; return playerCount <= map.PlayerCount; } // ----- Lifecycle -------------------------------------------------- // Validated subset of the inspector array; built once in Awake and never mutated. // Holding a separate list lets us silently filter out null/invalid entries without // mutating the inspector array (which would surprise designers). private readonly List validatedMaps = new List(); private void Awake() { // Persistent-singleton pattern: first instance wins, duplicates self-destroy. if (Instance != null && Instance != this) { Debug.LogWarning( $"[MapRegistry] Persistent instance already exists. " + $"Destroying duplicate in scene '{gameObject.scene.name}'. " + $"Keep MapRegistry in the MainMenu scene only."); Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); BuildLookup(); } private void OnDestroy() { if (Instance == this) Instance = null; } // ----- Private ---------------------------------------------------- private void BuildLookup() { validatedMaps.Clear(); if (maps == null || maps.Length == 0) { Debug.LogWarning("[MapRegistry] No LevelData assets assigned. Drag assets into " + "the Maps array. Lobby map browser will be empty."); return; } var seen = new HashSet(); foreach (var map in maps) { if (map == null) continue; // Duplicates are likely an authoring mistake (designer dragged the same asset // twice); keep the first occurrence and warn. if (!seen.Add(map)) { Debug.LogWarning($"[MapRegistry] Duplicate LevelData '{map.name}' in Maps " + $"array. Keeping first occurrence only."); continue; } if (string.IsNullOrEmpty(map.MapName)) { Debug.LogWarning($"[MapRegistry] '{map.name}' has empty MapName — skipping. " + "Set MapName on the LevelData asset."); continue; } if (string.IsNullOrEmpty(map.ScenePath)) { Debug.LogWarning($"[MapRegistry] '{map.name}' has empty ScenePath — skipping. " + "Bake the level so ScenePath gets populated."); continue; } if (map.PlayerCount < 1) { Debug.LogWarning($"[MapRegistry] '{map.name}' has PlayerCount={map.PlayerCount} " + "(must be >= 1) — skipping."); continue; } validatedMaps.Add(map); } Debug.Log($"[MapRegistry] Registered {validatedMaps.Count} map(s). " + $"Default = '{(Default != null ? Default.MapName : "")}'."); } } }