Adding 9 Player level

This commit is contained in:
Matt F 2026-05-21 23:36:19 -07:00
parent fdada6f132
commit a7be12fa9b
30 changed files with 45984 additions and 300 deletions

View file

@ -0,0 +1,196 @@
// Assets/_Project/Scripts/Gameplay/MapRegistry.cs
using System.Collections.Generic;
using UnityEngine;
using TD.Levels;
namespace TD.Gameplay
{
/// <summary>
/// Persistent (DontDestroyOnLoad) singleton that holds every <see cref="LevelData"/> available
/// to the lobby's map browser. Mirrors <see cref="RaceRegistry"/>'s pattern.
/// </summary>
/// <remarks>
/// <para><b>Inspector setup.</b> Place ONE <c>MapRegistry</c> GameObject in the <b>MainMenu
/// scene</b> only. Drag every <see cref="LevelData"/> asset that should appear in the lobby's
/// map browser into the <c>Maps</c> array. The first non-null entry becomes the
/// <see cref="Default"/> map (auto-selected when a lobby first opens). Mark itself
/// <c>DontDestroyOnLoad</c> on Awake so it survives MainMenu → Lobby → Match transitions and
/// is reachable from any scene via <see cref="Instance"/>.</para>
///
/// <para><b>Why MainMenu-only?</b> Same reason as <see cref="RaceRegistry"/>: 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.</para>
///
/// <para><b>Editor-only standalone testing.</b> If you open the Lobby or Match scene directly
/// from the editor without going through MainMenu, no MapRegistry exists. <see cref="Instance"/>
/// 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.</para>
///
/// <para><b>Selectability.</b> A map is selectable in a lobby of N players iff
/// <c>N &lt;= map.PlayerCount</c>. 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.</para>
///
/// <para><b>Plain MonoBehaviour.</b> Not a NetworkBehaviour — every peer has the same LevelData
/// assets in the build. Network state tracks only the selected map's index via
/// <c>LobbyService.SelectedMapIndex</c>.</para>
/// </remarks>
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 -------------------------------------------------
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<LevelData> Maps => validatedMaps;
/// <summary>
/// Total number of valid registered maps. Equivalent to <c>Maps.Count</c> but cheaper
/// since it doesn't allocate an enumerator.
/// </summary>
public int Count => validatedMaps.Count;
/// <summary>
/// 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).
/// </summary>
public LevelData Default => validatedMaps.Count > 0 ? validatedMaps[0] : null;
/// <summary>
/// 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).
/// </summary>
public LevelData Get(int index)
{
if (index < 0 || index >= validatedMaps.Count) return null;
return validatedMaps[index];
}
/// <summary>
/// Returns the index of the given map in <see cref="Maps"/>, 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.
/// </summary>
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;
}
/// <summary>
/// True if a lobby with <paramref name="playerCount"/> players is allowed to start a match
/// on <paramref name="map"/>. Currently the rule is just "lobby size must fit"; if minimum
/// player counts become a thing later, gate them here.
/// </summary>
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<LevelData> validatedMaps = new List<LevelData>();
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<LevelData>();
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 : "<none>")}'.");
}
}
}