Adding 9 Player level
This commit is contained in:
parent
fdada6f132
commit
a7be12fa9b
30 changed files with 45984 additions and 300 deletions
|
|
@ -12,8 +12,11 @@ namespace TD.Core
|
|||
///
|
||||
/// Conventions:
|
||||
/// - Tiles are 1.0 world unit on each side (TILE_SIZE).
|
||||
/// - Tiles are CENTER-BASED: tile (0, 0) has its center at world (0, 0, 0)
|
||||
/// and occupies world XZ from (-0.5, -0.5) to (+0.5, +0.5).
|
||||
/// - Tiles are EDGE-ALIGNED: tile (N, N) occupies world XZ from (N, N) to (N+1, N+1),
|
||||
/// with its center at (N+0.5, N+0.5). This makes integer-aligned BoxCollider bounds
|
||||
/// align naturally with tile boundaries — a volume sized to N whole tiles at an
|
||||
/// integer position covers exactly N tiles, with the rasterized tile geometry
|
||||
/// matching the bounds rectangle exactly (no half-tile overhang).
|
||||
/// - The grid lives on the XZ plane at Y = BUILDABLE_PLANE_Y. Grid-y maps to world-z.
|
||||
/// - 4-connected (no diagonals).
|
||||
/// </summary>
|
||||
|
|
@ -32,26 +35,27 @@ namespace TD.Core
|
|||
/// <summary>
|
||||
/// Returns the world-space center of the given tile, on the buildable plane.
|
||||
/// Use this for placing towers, drawing ghost previews, and for A* path waypoints.
|
||||
/// Tile (N, N) is centered at world (N+0.5, N+0.5) since tiles occupy [N, N+1].
|
||||
/// </summary>
|
||||
public static Vector3 GridToWorld(Vector2Int gridPos)
|
||||
{
|
||||
return new Vector3(
|
||||
gridPos.x * TILE_SIZE,
|
||||
(gridPos.x + 0.5f) * TILE_SIZE,
|
||||
BUILDABLE_PLANE_Y,
|
||||
gridPos.y * TILE_SIZE);
|
||||
(gridPos.y + 0.5f) * TILE_SIZE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the tile that contains the given world position.
|
||||
/// The Y component of worldPos is ignored. Uses round-to-nearest because
|
||||
/// tiles are center-based — any world point within ±0.5 of a tile's center
|
||||
/// belongs to that tile.
|
||||
/// The Y component of worldPos is ignored. Uses floor because tiles are
|
||||
/// edge-aligned — tile N occupies the half-open interval [N, N+1) on each axis,
|
||||
/// so any world point in that range floors to tile N.
|
||||
/// </summary>
|
||||
public static Vector2Int WorldToGrid(Vector3 worldPos)
|
||||
{
|
||||
return new Vector2Int(
|
||||
Mathf.RoundToInt(worldPos.x / TILE_SIZE),
|
||||
Mathf.RoundToInt(worldPos.z / TILE_SIZE));
|
||||
Mathf.FloorToInt(worldPos.x / TILE_SIZE),
|
||||
Mathf.FloorToInt(worldPos.z / TILE_SIZE));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -61,8 +65,8 @@ namespace TD.Core
|
|||
public static Vector2Int WorldToGrid(Vector2 worldPosXZ)
|
||||
{
|
||||
return new Vector2Int(
|
||||
Mathf.RoundToInt(worldPosXZ.x / TILE_SIZE),
|
||||
Mathf.RoundToInt(worldPosXZ.y / TILE_SIZE));
|
||||
Mathf.FloorToInt(worldPosXZ.x / TILE_SIZE),
|
||||
Mathf.FloorToInt(worldPosXZ.y / TILE_SIZE));
|
||||
}
|
||||
|
||||
// ----- Grid helpers --------------------------------------------------------
|
||||
|
|
@ -200,9 +204,10 @@ namespace TD.Core
|
|||
|
||||
/// <summary>
|
||||
/// Returns the world-space center of a footprint anchored at the given tile.
|
||||
/// For a 2x2 footprint at anchor (5, 7) with TILE_SIZE = 1.0, returns (5.5, 0, 7.5).
|
||||
/// Use this to position the tower's visual GameObject so it sits centered on its
|
||||
/// footprint rather than on the anchor tile's center.
|
||||
/// For a 2x2 footprint at anchor (5, 7) with TILE_SIZE = 1.0, returns (6, 0, 8) —
|
||||
/// the geometric center of the four tiles (5,7),(6,7),(5,8),(6,8), which span
|
||||
/// world XZ [5, 7]. Use this to position the tower's visual GameObject so it sits
|
||||
/// centered on its footprint rather than on the anchor tile's center.
|
||||
/// </summary>
|
||||
public static Vector3 GetFootprintCenterWorld(Vector2Int anchor, Vector2Int footprintSize)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1231,12 +1231,13 @@ namespace TD.Levels.Editor
|
|||
|
||||
private static bool RenderThumbnail(BakeContext ctx, string thumbnailAssetPath)
|
||||
{
|
||||
// Compute world-space bounds of the map's tile region.
|
||||
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
|
||||
float minX = ctx.MapMinTile.x - halfTile;
|
||||
float maxX = ctx.MapMaxTile.x + halfTile;
|
||||
float minZ = ctx.MapMinTile.y - halfTile;
|
||||
float maxZ = ctx.MapMaxTile.y + halfTile;
|
||||
// Compute world-space bounds of the map's tile region. Tile N spans world [N, N+1]
|
||||
// (edge-aligned), so the rect spans from MapMinTile to MapMaxTile + 1 on each axis.
|
||||
float tileSize = GridCoordinates.TILE_SIZE;
|
||||
float minX = ctx.MapMinTile.x * tileSize;
|
||||
float maxX = (ctx.MapMaxTile.x + 1) * tileSize;
|
||||
float minZ = ctx.MapMinTile.y * tileSize;
|
||||
float maxZ = (ctx.MapMaxTile.y + 1) * tileSize;
|
||||
float worldW = maxX - minX;
|
||||
float worldH = maxZ - minZ;
|
||||
|
||||
|
|
|
|||
|
|
@ -156,7 +156,6 @@ namespace TD.Levels.Editor
|
|||
float outwardDelta = edgeIsPositive ? worldDeltaSnapped : -worldDeltaSnapped;
|
||||
|
||||
Vector3 size = col.size;
|
||||
Vector3 center = col.center;
|
||||
|
||||
float currentSize = axisIsX ? size.x : size.z;
|
||||
float newSize = currentSize + outwardDelta;
|
||||
|
|
@ -165,29 +164,44 @@ namespace TD.Levels.Editor
|
|||
// (don't move the edge at all). This is more predictable than partially honoring it.
|
||||
if (newSize < MinSize) return;
|
||||
|
||||
// Apply the change. The edge that's NOT being dragged should stay put. To keep the
|
||||
// opposite edge fixed, the center must shift by half the size change in the direction
|
||||
// of the edge being dragged.
|
||||
// Apply the change. The edge that's NOT being dragged should stay put. We achieve this
|
||||
// by adjusting transform.position rather than BoxCollider.center, so the collider's
|
||||
// center stays locked at (0, 0, 0) — the "center" of the volume's local frame is always
|
||||
// the geometric center of the box. The position shifts by half the size change in the
|
||||
// direction of the edge being dragged.
|
||||
//
|
||||
// Example (east edge dragged outward by 2 tiles): size.x += 2; center.x += 1.
|
||||
// Example (west edge dragged outward by 1 tile): size.x += 1; center.x -= 0.5.
|
||||
float centerShift = (edgeIsPositive ? 1f : -1f) * (outwardDelta * 0.5f);
|
||||
// Example (east edge dragged outward by 2 tiles): size.x += 2; transform.position.x += 1.
|
||||
// Example (west edge dragged outward by 1 tile): size.x += 1; transform.position.x -= 0.5.
|
||||
//
|
||||
// Note: positions may land at half-integer values when the size is odd. That's correct
|
||||
// under the edge-aligned tile convention — bounds align with tile edges when
|
||||
// (position - size/2) and (position + size/2) are both integers,
|
||||
// which requires position to have the same fractional part as size/2.
|
||||
float positionShift = (edgeIsPositive ? 1f : -1f) * (outwardDelta * 0.5f);
|
||||
|
||||
if (axisIsX)
|
||||
{
|
||||
size.x = newSize;
|
||||
center.x += centerShift;
|
||||
}
|
||||
else
|
||||
{
|
||||
size.z = newSize;
|
||||
center.z += centerShift;
|
||||
}
|
||||
|
||||
Vector3 newPosition = col.transform.position;
|
||||
if (axisIsX) newPosition.x += positionShift;
|
||||
else newPosition.z += positionShift;
|
||||
|
||||
Undo.RecordObject(col.transform, "Resize Volume Edge");
|
||||
Undo.RecordObject(col, "Resize Volume Edge");
|
||||
col.transform.position = newPosition;
|
||||
col.size = size;
|
||||
col.center = center;
|
||||
// Force-lock collider center to zero in case it had drifted from prior edits made
|
||||
// before this behavior change. Safe to do unconditionally — by design, this tool now
|
||||
// never wants a non-zero center.
|
||||
col.center = Vector3.zero;
|
||||
EditorUtility.SetDirty(col);
|
||||
EditorUtility.SetDirty(col.transform);
|
||||
}
|
||||
|
||||
private static Vector3 WithY(Vector3 v, float y)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
196
Assets/_Project/Scripts/Gameplay/MapRegistry.cs
Normal file
196
Assets/_Project/Scripts/Gameplay/MapRegistry.cs
Normal 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 <= 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>")}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Gameplay/MapRegistry.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/MapRegistry.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c4dafc49d16a2eb4c8db0b00440af991
|
||||
|
|
@ -6,6 +6,7 @@ using UnityEngine;
|
|||
using UnityEngine.UIElements;
|
||||
using TD.Core;
|
||||
using TD.Gameplay;
|
||||
using TD.Levels;
|
||||
using TD.Net;
|
||||
|
||||
namespace TD.UI
|
||||
|
|
@ -41,11 +42,18 @@ namespace TD.UI
|
|||
{
|
||||
// ----- Cached UI elements -----------------------------------------
|
||||
|
||||
private VisualElement mapListContainer;
|
||||
private VisualElement playerListContainer;
|
||||
private Button startMatchButton;
|
||||
private Button leaveButton;
|
||||
private Label statusLabel;
|
||||
|
||||
// Signature snapshot for the map list, mirroring the player-list pattern.
|
||||
// Components: registry count, selected index, current player count, host flag.
|
||||
// Without this, the cards rebuild every frame and the Clickable manipulator
|
||||
// loses presses (same root cause as the player-list signature).
|
||||
private string lastMapListSignature = string.Empty;
|
||||
|
||||
// ----- Race selection overlay ------------------------------------
|
||||
|
||||
[Tooltip("Sibling RaceSelectionOverlay component that owns the race-pick UI. " +
|
||||
|
|
@ -88,6 +96,7 @@ namespace TD.UI
|
|||
private void Update()
|
||||
{
|
||||
if (playerListContainer == null) return;
|
||||
RefreshMapList();
|
||||
RefreshPlayerList();
|
||||
RefreshStartButton();
|
||||
}
|
||||
|
|
@ -112,6 +121,28 @@ namespace TD.UI
|
|||
title.style.marginBottom = 24;
|
||||
root.Add(title);
|
||||
|
||||
// Map selection panel — host clicks to change, everyone sees the current pick.
|
||||
// Horizontal row of cards rebuilt by RefreshMapList when its signature changes.
|
||||
var mapPanelLabel = new Label("Map");
|
||||
mapPanelLabel.style.fontSize = 18;
|
||||
mapPanelLabel.style.color = new Color(0.85f, 0.85f, 0.85f);
|
||||
mapPanelLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
mapPanelLabel.style.marginBottom = 6;
|
||||
root.Add(mapPanelLabel);
|
||||
|
||||
mapListContainer = new VisualElement();
|
||||
mapListContainer.style.flexDirection = FlexDirection.Row;
|
||||
mapListContainer.style.justifyContent = Justify.Center;
|
||||
mapListContainer.style.flexWrap = Wrap.Wrap;
|
||||
mapListContainer.style.minWidth = 520;
|
||||
mapListContainer.style.paddingTop = 8;
|
||||
mapListContainer.style.paddingBottom = 8;
|
||||
mapListContainer.style.paddingLeft = 8;
|
||||
mapListContainer.style.paddingRight = 8;
|
||||
mapListContainer.style.backgroundColor = new Color(0f, 0f, 0f, 0.4f);
|
||||
mapListContainer.style.marginBottom = 24;
|
||||
root.Add(mapListContainer);
|
||||
|
||||
// Player list container (rebuilt every Update from AllPlayers).
|
||||
playerListContainer = new VisualElement();
|
||||
playerListContainer.style.flexDirection = FlexDirection.Column;
|
||||
|
|
@ -153,6 +184,157 @@ namespace TD.UI
|
|||
|
||||
// ----- Per-frame refresh ------------------------------------------
|
||||
|
||||
private void RefreshMapList()
|
||||
{
|
||||
// Inputs that determine the rendered state of the map row. Anything not in this
|
||||
// signature won't trigger a rebuild — match what BuildMapCard reads.
|
||||
var registry = MapRegistry.Instance;
|
||||
var svc = LobbyService.Instance;
|
||||
int registryCount = registry != null ? registry.Count : 0;
|
||||
int selectedIndex = svc != null ? svc.SelectedMapIndex : -1;
|
||||
int playerCount = LobbyService.CountConnectedPlayers();
|
||||
bool isHost = NetworkManager.Singleton != null && NetworkManager.Singleton.IsHost;
|
||||
|
||||
string signature = $"{registryCount}:{selectedIndex}:{playerCount}:{(isHost ? 'H' : 'C')}";
|
||||
if (signature == lastMapListSignature) return;
|
||||
lastMapListSignature = signature;
|
||||
|
||||
mapListContainer.Clear();
|
||||
|
||||
if (registry == null || registryCount == 0)
|
||||
{
|
||||
var emptyLabel = new Label("(no maps available — MapRegistry missing)");
|
||||
emptyLabel.style.color = new Color(0.7f, 0.5f, 0.5f);
|
||||
emptyLabel.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
mapListContainer.Add(emptyLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < registryCount; i++)
|
||||
{
|
||||
var map = registry.Get(i);
|
||||
if (map == null) continue;
|
||||
|
||||
bool isSelected = i == selectedIndex;
|
||||
bool fitsLobby = MapRegistry.IsSelectableFor(map, playerCount);
|
||||
bool clickable = isHost && fitsLobby;
|
||||
|
||||
mapListContainer.Add(BuildMapCard(map, i, isSelected, fitsLobby, clickable, playerCount));
|
||||
}
|
||||
}
|
||||
|
||||
private VisualElement BuildMapCard(LevelData map, int index, bool isSelected,
|
||||
bool fitsLobby, bool clickable, int playerCount)
|
||||
{
|
||||
var card = new VisualElement();
|
||||
card.style.width = 180;
|
||||
card.style.height = 220;
|
||||
card.style.marginRight = 8;
|
||||
card.style.marginLeft = 8;
|
||||
card.style.paddingTop = 8;
|
||||
card.style.paddingBottom = 8;
|
||||
card.style.paddingLeft = 8;
|
||||
card.style.paddingRight = 8;
|
||||
card.style.alignItems = Align.Center;
|
||||
card.style.justifyContent = Justify.FlexStart;
|
||||
|
||||
// Background reflects selectability: highlighted when selected, dim when oversized.
|
||||
Color background = isSelected
|
||||
? new Color(0.20f, 0.32f, 0.55f)
|
||||
: new Color(0.12f, 0.12f, 0.16f);
|
||||
if (!fitsLobby) background.a *= 0.7f;
|
||||
card.style.backgroundColor = background;
|
||||
|
||||
// Border to make the selection visually unambiguous.
|
||||
float borderWidth = isSelected ? 3f : 1f;
|
||||
Color borderColor = isSelected
|
||||
? new Color(0.45f, 0.70f, 1f)
|
||||
: new Color(0.25f, 0.25f, 0.30f);
|
||||
card.style.borderTopWidth = borderWidth;
|
||||
card.style.borderBottomWidth = borderWidth;
|
||||
card.style.borderLeftWidth = borderWidth;
|
||||
card.style.borderRightWidth = borderWidth;
|
||||
card.style.borderTopColor = borderColor;
|
||||
card.style.borderBottomColor = borderColor;
|
||||
card.style.borderLeftColor = borderColor;
|
||||
card.style.borderRightColor = borderColor;
|
||||
|
||||
// Thumbnail (top of card). Falls back to a neutral placeholder if MapThumbnail isn't set.
|
||||
var thumb = new VisualElement();
|
||||
thumb.style.width = 140;
|
||||
thumb.style.height = 105;
|
||||
thumb.style.marginBottom = 6;
|
||||
thumb.style.backgroundColor = new Color(0.05f, 0.05f, 0.08f);
|
||||
if (map.MapThumbnail != null)
|
||||
{
|
||||
thumb.style.backgroundImage = new StyleBackground(map.MapThumbnail);
|
||||
}
|
||||
// Dim the thumbnail when the map doesn't fit the lobby — reinforces the disabled look.
|
||||
if (!fitsLobby)
|
||||
{
|
||||
thumb.style.opacity = 0.45f;
|
||||
}
|
||||
card.Add(thumb);
|
||||
|
||||
// Map name.
|
||||
var nameLabel = new Label(map.MapName);
|
||||
nameLabel.style.color = fitsLobby ? Color.white : new Color(0.7f, 0.6f, 0.6f);
|
||||
nameLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
nameLabel.style.fontSize = 14;
|
||||
nameLabel.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
card.Add(nameLabel);
|
||||
|
||||
// Player count label — tells the user the capacity, and why the card is greyed out
|
||||
// when oversized. Live player count is shown alongside the cap so it's obvious why.
|
||||
string countText = fitsLobby
|
||||
? $"Up to {map.PlayerCount} players"
|
||||
: $"Up to {map.PlayerCount} — needs ≤ {map.PlayerCount} (lobby has {playerCount})";
|
||||
var countLabel = new Label(countText);
|
||||
countLabel.style.color = fitsLobby
|
||||
? new Color(0.75f, 0.75f, 0.75f)
|
||||
: new Color(0.85f, 0.55f, 0.45f);
|
||||
countLabel.style.fontSize = 11;
|
||||
countLabel.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
countLabel.style.marginTop = 2;
|
||||
countLabel.style.whiteSpace = WhiteSpace.Normal;
|
||||
card.Add(countLabel);
|
||||
|
||||
// Make the whole card clickable for host (when the map fits the lobby). Non-hosts
|
||||
// see the cards but can't change selection. Capture `index` in a local so the
|
||||
// closure doesn't bind to the loop variable.
|
||||
if (clickable)
|
||||
{
|
||||
int capturedIndex = index;
|
||||
card.RegisterCallback<ClickEvent>(_ => OnMapCardClicked(capturedIndex));
|
||||
// Standard hover affordance to advertise interactivity.
|
||||
card.RegisterCallback<MouseEnterEvent>(_ =>
|
||||
{
|
||||
if (!isSelected)
|
||||
card.style.backgroundColor = new Color(0.17f, 0.20f, 0.28f);
|
||||
});
|
||||
card.RegisterCallback<MouseLeaveEvent>(_ =>
|
||||
{
|
||||
if (!isSelected)
|
||||
card.style.backgroundColor = new Color(0.12f, 0.12f, 0.16f);
|
||||
});
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private void OnMapCardClicked(int index)
|
||||
{
|
||||
var svc = LobbyService.Instance;
|
||||
if (svc == null)
|
||||
{
|
||||
Debug.LogError("[LobbyController] LobbyService.Instance is null — cannot change map.");
|
||||
return;
|
||||
}
|
||||
// Don't bother round-tripping if we're already on this map.
|
||||
if (svc.SelectedMapIndex == index) return;
|
||||
svc.RequestSelectMapRpc(index);
|
||||
}
|
||||
|
||||
private void RefreshPlayerList()
|
||||
{
|
||||
// Sort by slot for stable ordering. AllPlayers is keyed by clientId
|
||||
|
|
@ -285,7 +467,32 @@ namespace TD.UI
|
|||
startMatchButton.style.display = isHost ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
if (!isHost) return;
|
||||
|
||||
bool canStart = LobbyService.AreAllPlayersReady(out string reason);
|
||||
// Two gates: readiness (existing) and map selectability (new). Show whichever
|
||||
// failure is preventing the start so the host knows what to fix. Readiness
|
||||
// is checked first because it's the more common blocker.
|
||||
bool readyOk = LobbyService.AreAllPlayersReady(out string readyReason);
|
||||
string reason = readyOk ? string.Empty : readyReason;
|
||||
|
||||
bool mapOk = true;
|
||||
if (readyOk)
|
||||
{
|
||||
var svc = LobbyService.Instance;
|
||||
var selected = svc != null ? svc.SelectedMap : null;
|
||||
int playerCount = LobbyService.CountConnectedPlayers();
|
||||
if (selected == null)
|
||||
{
|
||||
mapOk = false;
|
||||
reason = "No map selected.";
|
||||
}
|
||||
else if (!MapRegistry.IsSelectableFor(selected, playerCount))
|
||||
{
|
||||
mapOk = false;
|
||||
reason = $"'{selected.MapName}' supports up to {selected.PlayerCount} " +
|
||||
$"players; lobby has {playerCount}.";
|
||||
}
|
||||
}
|
||||
|
||||
bool canStart = readyOk && mapOk;
|
||||
startMatchButton.SetEnabled(canStart);
|
||||
statusLabel.text = canStart ? string.Empty : reason;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,13 +191,13 @@ namespace TD.UI.Minimap
|
|||
terrainLayer.style.backgroundImage =
|
||||
new StyleBackground(Background.FromTexture2D(bakedTerrain));
|
||||
|
||||
// World extents of the baked rectangle. Tile (n) covers world n - 0.5 to n + 0.5.
|
||||
// World extents of the baked rectangle. Tile (n) spans world [n, n+1] (edge-aligned),
|
||||
// so the rectangle covers [GridOriginTile, GridOriginTile + GridSize] on each axis.
|
||||
var data = loader.LevelData;
|
||||
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
|
||||
float minX = data.GridOriginTile.x * GridCoordinates.TILE_SIZE - halfTile;
|
||||
float maxX = (data.GridOriginTile.x + data.GridSize.x) * GridCoordinates.TILE_SIZE - halfTile;
|
||||
float minZ = data.GridOriginTile.y * GridCoordinates.TILE_SIZE - halfTile;
|
||||
float maxZ = (data.GridOriginTile.y + data.GridSize.y) * GridCoordinates.TILE_SIZE - halfTile;
|
||||
float minX = data.GridOriginTile.x * GridCoordinates.TILE_SIZE;
|
||||
float maxX = (data.GridOriginTile.x + data.GridSize.x) * GridCoordinates.TILE_SIZE;
|
||||
float minZ = data.GridOriginTile.y * GridCoordinates.TILE_SIZE;
|
||||
float maxZ = (data.GridOriginTile.y + data.GridSize.y) * GridCoordinates.TILE_SIZE;
|
||||
worldMin = new Vector2(minX, minZ);
|
||||
worldMax = new Vector2(maxX, maxZ);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue