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

@ -470,13 +470,13 @@ namespace TD.Gameplay
{
Vector3 builderXZ = new Vector3(transform.position.x, 0f, transform.position.z);
// Find the point on the footprint rectangle nearest to the builder.
float minX = anchor.x * GridCoordinates.TILE_SIZE - GridCoordinates.TILE_SIZE * 0.5f;
float maxX = (anchor.x + footprintSize.x - 1) * GridCoordinates.TILE_SIZE
+ GridCoordinates.TILE_SIZE * 0.5f;
float minZ = anchor.y * GridCoordinates.TILE_SIZE - GridCoordinates.TILE_SIZE * 0.5f;
float maxZ = (anchor.y + footprintSize.y - 1) * GridCoordinates.TILE_SIZE
+ GridCoordinates.TILE_SIZE * 0.5f;
// Find the point on the footprint rectangle nearest to the builder. Tile N spans
// world [N, N+1] (edge-aligned), so a footprint at anchor (ax, ay) with size (sx, sy)
// spans world [ax, ax+sx] × [ay, ay+sy].
float minX = anchor.x * GridCoordinates.TILE_SIZE;
float maxX = (anchor.x + footprintSize.x) * GridCoordinates.TILE_SIZE;
float minZ = anchor.y * GridCoordinates.TILE_SIZE;
float maxZ = (anchor.y + footprintSize.y) * GridCoordinates.TILE_SIZE;
float nearestX = Mathf.Clamp(builderXZ.x, minX, maxX);
float nearestZ = Mathf.Clamp(builderXZ.z, minZ, maxZ);

View file

@ -219,19 +219,19 @@ namespace TD.Gameplay
//
// The grid covers tiles from GridOriginTile (inclusive, SW corner)
// to GridOriginTile + GridSize - (1,1) (inclusive, NE corner).
// Each tile is TILE_SIZE wide and centered on its integer coords.
// Each tile is TILE_SIZE wide and edge-aligned: tile N occupies world [N, N+1].
//
// World extent on X:
// left = (GridOriginTile.x - 0.5) * TILE_SIZE
// right = (GridOriginTile.x + GridSize.x - 0.5) * TILE_SIZE
// left = GridOriginTile.x * TILE_SIZE
// right = (GridOriginTile.x + GridSize.x) * TILE_SIZE
// width = GridSize.x * TILE_SIZE
// centerX = (left + right) / 2 = (GridOriginTile.x + (GridSize.x - 1) / 2) * TILE_SIZE
// centerX = (left + right) / 2 = (GridOriginTile.x + GridSize.x / 2) * TILE_SIZE
//
// Same shape on Z (grid-y maps to world-z).
float worldCenterX =
(level.GridOriginTile.x + (level.GridSize.x - 1) * 0.5f) * GridCoordinates.TILE_SIZE;
(level.GridOriginTile.x + level.GridSize.x * 0.5f) * GridCoordinates.TILE_SIZE;
float worldCenterZ =
(level.GridOriginTile.y + (level.GridSize.y - 1) * 0.5f) * GridCoordinates.TILE_SIZE;
(level.GridOriginTile.y + level.GridSize.y * 0.5f) * GridCoordinates.TILE_SIZE;
float worldSizeX = level.GridSize.x * GridCoordinates.TILE_SIZE;
float worldSizeZ = level.GridSize.y * GridCoordinates.TILE_SIZE;
@ -442,16 +442,17 @@ namespace TD.Gameplay
private void DrawGridBoundsGizmo()
{
// One outlined wire box covering the entire grid extent.
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
// One outlined wire box covering the entire grid extent. Tile N spans world
// [N, N+1], so the grid's SW corner is at GridOriginTile and its NE corner
// is at GridOriginTile + GridSize.
Vector3 sw = new Vector3(
level.GridOriginTile.x * GridCoordinates.TILE_SIZE - halfTile,
level.GridOriginTile.x * GridCoordinates.TILE_SIZE,
GridCoordinates.BUILDABLE_PLANE_Y,
level.GridOriginTile.y * GridCoordinates.TILE_SIZE - halfTile);
level.GridOriginTile.y * GridCoordinates.TILE_SIZE);
Vector3 ne = new Vector3(
(level.GridOriginTile.x + level.GridSize.x) * GridCoordinates.TILE_SIZE - halfTile,
(level.GridOriginTile.x + level.GridSize.x) * GridCoordinates.TILE_SIZE,
GridCoordinates.BUILDABLE_PLANE_Y,
(level.GridOriginTile.y + level.GridSize.y) * GridCoordinates.TILE_SIZE - halfTile);
(level.GridOriginTile.y + level.GridSize.y) * GridCoordinates.TILE_SIZE);
Gizmos.color = new Color(1f, 1f, 1f, 0.9f); // bright white outline
Vector3 nw = new Vector3(sw.x, sw.y, ne.z);
@ -466,11 +467,12 @@ namespace TD.Gameplay
{
// In play mode the collider exists; draw it directly. In edit mode
// we don't have a collider yet, but we can draw the rectangle that
// the loader WOULD instantiate, so designers can preview it.
// the loader WOULD instantiate, so designers can preview it. Uses
// the same formula as SpawnBuildablePlane (tiles are edge-aligned).
float worldCenterX =
(level.GridOriginTile.x + (level.GridSize.x - 1) * 0.5f) * GridCoordinates.TILE_SIZE;
(level.GridOriginTile.x + level.GridSize.x * 0.5f) * GridCoordinates.TILE_SIZE;
float worldCenterZ =
(level.GridOriginTile.y + (level.GridSize.y - 1) * 0.5f) * GridCoordinates.TILE_SIZE;
(level.GridOriginTile.y + level.GridSize.y * 0.5f) * GridCoordinates.TILE_SIZE;
float worldSizeX = level.GridSize.x * GridCoordinates.TILE_SIZE;
float worldSizeZ = level.GridSize.y * GridCoordinates.TILE_SIZE;
@ -505,10 +507,11 @@ namespace TD.Gameplay
{
int idx = y * level.GridSize.x + x;
Gizmos.color = walk[idx] ? walkable : blocked;
Vector3 c = new Vector3(
(level.GridOriginTile.x + x) * tile,
drawY,
(level.GridOriginTile.y + y) * tile);
// Use GridToWorld so tile centers stay consistent with the convention
// (tile (N, N) center at world (N+0.5, N+0.5)).
Vector3 c = GridCoordinates.GridToWorld(
new Vector2Int(level.GridOriginTile.x + x, level.GridOriginTile.y + y));
c.y = drawY;
Gizmos.DrawCube(c, size);
}
}
@ -523,7 +526,6 @@ namespace TD.Gameplay
level.OwnerGrid.Length != level.GridSize.x * level.GridSize.y) return;
float tile = GridCoordinates.TILE_SIZE;
float halfTile = tile * 0.5f;
float drawY = GridCoordinates.BUILDABLE_PLANE_Y + 0.010f;
for (int y = 0; y < level.GridSize.y; y++)
@ -534,18 +536,21 @@ namespace TD.Gameplay
PlayerSlot owner = level.OwnerGrid[idx];
if (owner == PlayerSlot.None) continue;
Vector3 c = new Vector3(
(level.GridOriginTile.x + x) * tile,
drawY,
(level.GridOriginTile.y + y) * tile);
// Tile (gx, gy) spans world XZ from (gx, gy) to (gx+1, gy+1) (edge-aligned).
int gx = level.GridOriginTile.x + x;
int gy = level.GridOriginTile.y + y;
float wMinX = gx * tile;
float wMaxX = (gx + 1) * tile;
float wMinZ = gy * tile;
float wMaxZ = (gy + 1) * tile;
Gizmos.color = PlayerColors.Get(owner);
// Draw four edges as a wire square. We could DrawWireCube
// but it would also draw vertical edges we don't want.
Vector3 sw = new Vector3(c.x - halfTile, drawY, c.z - halfTile);
Vector3 nw = new Vector3(c.x - halfTile, drawY, c.z + halfTile);
Vector3 ne = new Vector3(c.x + halfTile, drawY, c.z + halfTile);
Vector3 se = new Vector3(c.x + halfTile, drawY, c.z - halfTile);
Vector3 sw = new Vector3(wMinX, drawY, wMinZ);
Vector3 nw = new Vector3(wMinX, drawY, wMaxZ);
Vector3 ne = new Vector3(wMaxX, drawY, wMaxZ);
Vector3 se = new Vector3(wMaxX, drawY, wMinZ);
Gizmos.DrawLine(sw, nw);
Gizmos.DrawLine(nw, ne);
Gizmos.DrawLine(ne, se);

View file

@ -2,6 +2,7 @@
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Levels;
using TD.Net;
namespace TD.Gameplay
@ -51,6 +52,40 @@ namespace TD.Gameplay
public static LobbyService Instance { get; private set; }
// ----- Networked lobby state --------------------------------------
// Index into MapRegistry.Maps of the currently selected map. Server-write,
// everyone-read. Default 0 (the first registered map = MapRegistry.Default).
// UI subscribes to OnValueChanged to refresh the map browser highlight.
// A stale index (map removed between sessions) is tolerated by MapRegistry.Get
// returning null; RequestStartMatchRpc validates before loading.
private readonly NetworkVariable<int> selectedMapIndex =
new NetworkVariable<int>(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
/// <summary>
/// The index of the currently selected map within <see cref="MapRegistry.Maps"/>.
/// All clients read the same value; only the host can change it via
/// <see cref="RequestSelectMapRpc"/>.
/// </summary>
public int SelectedMapIndex => selectedMapIndex.Value;
/// <summary>
/// Exposed for UI subscription to <c>OnValueChanged</c>. Treat as read-only —
/// mutations must go through the server via <see cref="RequestSelectMapRpc"/>
/// so the host-only gate is enforced.
/// </summary>
public NetworkVariable<int> SelectedMapIndexVar => selectedMapIndex;
/// <summary>
/// Convenience: resolves the currently selected <see cref="LevelData"/> via
/// <see cref="MapRegistry"/>. Returns null if the registry is missing (e.g. the
/// editor was started directly in the Lobby scene) or the index is stale.
/// </summary>
public LevelData SelectedMap =>
MapRegistry.Instance != null
? MapRegistry.Instance.Get(selectedMapIndex.Value)
: null;
// ----- Lifecycle --------------------------------------------------
public override void OnNetworkSpawn()
@ -67,7 +102,19 @@ namespace TD.Gameplay
// are preserved from the previous lobby visit, but ready state
// resets so each match requires explicit re-readying.
if (IsServer)
{
ResetAllReady();
// If the current selection is invalid for any reason (registry missing,
// index stale from a previous session), snap to the default. Index 0
// is already the default-default; this is a no-op except after the
// registry's contents change between sessions.
var registry = MapRegistry.Instance;
if (registry != null && registry.Get(selectedMapIndex.Value) == null && registry.Count > 0)
{
selectedMapIndex.Value = 0;
}
}
}
public override void OnNetworkDespawn()
@ -79,8 +126,9 @@ namespace TD.Gameplay
/// <summary>
/// Host-only request to begin the match. Validates that every connected
/// player has picked a race and is ready, then transitions every peer
/// to the Match scene via NGO scene management.
/// player has picked a race and is ready AND that the selected map can
/// accommodate the current lobby's player count, then transitions every
/// peer to the selected map's scene via NGO scene management.
/// </summary>
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
public void RequestStartMatchRpc(RpcParams rpcParams = default)
@ -100,7 +148,81 @@ namespace TD.Gameplay
return;
}
NetworkBootstrap.LoadSceneAsHost(SceneNames.Match);
// Re-validate the selected map server-side. The UI greys out unselectable
// maps based on lobby size, but a late join could push us above the map's
// PlayerCount between the click and the RPC arriving. Defensive check.
var registry = MapRegistry.Instance;
if (registry == null)
{
Debug.LogError("[LobbyService] Cannot start match: MapRegistry.Instance is null. " +
"Make sure MainMenu was loaded before the lobby (the registry " +
"DontDestroyOnLoads from there).");
return;
}
var selected = registry.Get(selectedMapIndex.Value);
if (selected == null)
{
Debug.LogError($"[LobbyService] Cannot start match: selected map index " +
$"{selectedMapIndex.Value} is not registered.");
return;
}
int playerCount = CountConnectedPlayers();
if (!MapRegistry.IsSelectableFor(selected, playerCount))
{
Debug.Log($"[LobbyService] Cannot start match: map '{selected.MapName}' supports " +
$"up to {selected.PlayerCount} players but the lobby has {playerCount}.");
return;
}
if (string.IsNullOrEmpty(selected.SceneName))
{
Debug.LogError($"[LobbyService] Cannot start match: '{selected.MapName}' has " +
$"empty SceneName (ScenePath='{selected.ScenePath}'). Re-bake the level.");
return;
}
Debug.Log($"[LobbyService] Starting match on '{selected.MapName}' " +
$"(index={selectedMapIndex.Value}, scene='{selected.SceneName}', " +
$"scenePath='{selected.ScenePath}', players={playerCount}).");
NetworkBootstrap.LoadSceneAsHost(selected.SceneName);
}
/// <summary>
/// Host-only request to change the selected map. Validates that the requested index
/// resolves to a real map in <see cref="MapRegistry"/>; if so, writes
/// <see cref="SelectedMapIndex"/> which replicates to every client.
/// </summary>
/// <remarks>
/// Selectability for the current lobby size is NOT enforced here — players are
/// allowed to highlight an oversized map (e.g. while waiting for more friends to
/// join); the actual Start Match call enforces the rule. This keeps the host's
/// intent visible to everyone without preventing them from "claiming" a future map.
/// </remarks>
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
public void RequestSelectMapRpc(int mapIndex, RpcParams rpcParams = default)
{
if (rpcParams.Receive.SenderClientId != NetworkManager.Singleton.LocalClientId)
{
Debug.LogWarning("[LobbyService] Non-host client attempted to change the map. Ignored.");
return;
}
var registry = MapRegistry.Instance;
if (registry == null)
{
Debug.LogError("[LobbyService] Cannot change map: MapRegistry.Instance is null.");
return;
}
if (registry.Get(mapIndex) == null)
{
Debug.LogWarning($"[LobbyService] Rejected map index {mapIndex} — not registered.");
return;
}
selectedMapIndex.Value = mapIndex;
}
/// <summary>
@ -154,6 +276,18 @@ namespace TD.Gameplay
return true;
}
/// <summary>
/// Current number of connected players (every <see cref="PlayerMatchState"/> in the
/// static registry). Used by map selectability checks both in the UI and the server-side
/// Start Match validator.
/// </summary>
public static int CountConnectedPlayers()
{
int n = 0;
foreach (var _ in PlayerMatchState.AllPlayers) n++;
return n;
}
// ----- Server helpers --------------------------------------------
private static void ResetAllReady()

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>")}'.");
}
}
}

View file

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