Adding new Races, Main Menu -> Lobby flow, and what's needed to set up multiplayer testing.

This commit is contained in:
Matt F 2026-05-17 23:31:02 -07:00
parent 60fa58b07f
commit fdada6f132
29 changed files with 2581 additions and 176 deletions

View file

@ -73,14 +73,24 @@ namespace TD.Core
}
/// <summary>
/// Identifies the race a player has chosen in the race-pick phase.
/// Backed by byte. Specific race values are defined in Phase 1.8.
/// Stable identifier per race. Values 1-16 reserve slots for the planned
/// 16-race grid in the lobby; only races with a corresponding
/// <c>RaceDefinition</c> asset registered with <c>RaceRegistry</c> are
/// playable. Unregistered slots render as "Coming Soon" in the selection UI.
///
/// Names are intentionally generic so display names / lore can be authored
/// on the asset without renaming the enum — renaming would change byte
/// values and break save data once persistence lands.
/// </summary>
public enum RaceId : byte
{
/// <summary>No race selected yet (lobby / pre-pick).</summary>
None = 0,
// Race entries added in Phase 1.8.
Race1 = 1, Race2 = 2, Race3 = 3, Race4 = 4,
Race5 = 5, Race6 = 6, Race7 = 7, Race8 = 8,
Race9 = 9, Race10 = 10, Race11 = 11, Race12 = 12,
Race13 = 13, Race14 = 14, Race15 = 15, Race16 = 16,
}
public enum PlayerSlot : byte

View file

@ -1,15 +1,18 @@
// Assets/_Project/Scripts/Gameplay/PlayerBuilderSpawner.cs
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.SceneManagement;
using TD.Core;
using TD.Levels;
using TD.Net;
namespace TD.Gameplay
{
/// <summary>
/// Lives on the Player Prefab. On the server, when the player NetworkObject spawns,
/// instantiates and spawns a separate <see cref="Builder"/> NetworkObject owned by that
/// player. The builder is positioned at the centroid of the player's zone before spawn.
/// Lives on the Player Prefab. On the server, spawns and re-spawns a separate
/// <see cref="Builder"/> NetworkObject owned by this player each time the Match
/// scene loads. The builder is positioned at the centroid of the player's zone.
/// </summary>
/// <remarks>
/// <para><b>Why a separate NetworkObject?</b> Multi-builder races (Path E) become "spawn
@ -20,31 +23,79 @@ namespace TD.Gameplay
/// no-ops; they just receive the resulting Builder NetworkObject like any other
/// replicated spawn.</para>
///
/// <para><b>Lifetime.</b> The spawned builder is destroyed when the player NetworkObject
/// despawns (e.g., disconnect). NGO does this automatically because we set
/// <c>destroyWithScene</c> and store no other references — the builder's despawn cleans
/// up the static registry in <see cref="Builder.OnNetworkDespawn"/>.</para>
/// <para><b>Lifetime &amp; scene flow.</b> The player NetworkObject persists across
/// scenes (MainMenu → Lobby → Match → Lobby …) because it's the
/// <c>NetworkManager.PlayerPrefab</c>. The Builder is spawned with
/// <c>destroyWithScene: true</c> so it's torn down on every scene unload — we
/// only want a builder in the Match scene. <see cref="HandleSceneLoadCompleted"/>
/// re-spawns when entering Match; <see cref="OnNetworkDespawn"/> handles
/// disconnect cleanup.</para>
///
/// <para><b>Race-driven builder prefab.</b> If a <see cref="RaceRegistry"/> exists
/// in the Match scene AND the player's selected <see cref="RaceDefinition"/> has
/// a <c>BuilderPrefab</c> assigned, that prefab is used. Otherwise this falls back
/// to the inspector-assigned <see cref="builderPrefab"/> (the universal default).
/// Phase 1.8 races just need to fill in their <c>BuilderPrefab</c> field; no code
/// change required.</para>
/// </remarks>
public class PlayerBuilderSpawner : NetworkBehaviour
{
[Tooltip("Builder prefab to instantiate. Must be registered with the NetworkManager " +
"as a network prefab.")]
[Tooltip("Default Builder prefab. Used when the player's race has no BuilderPrefab " +
"assigned (or when no RaceRegistry is present in the scene). Must be " +
"registered with the NetworkManager as a network prefab.")]
[SerializeField] private GameObject builderPrefab;
// Cached reference so we can despawn the builder if needed (e.g., player disconnects).
private NetworkObject spawnedBuilder;
// Track our scene-load subscription state so we can clean up correctly.
private bool sceneSubscribed;
public override void OnNetworkSpawn()
{
if (!IsServer) return;
if (builderPrefab == null)
{
Debug.LogError("[PlayerBuilderSpawner] No Builder prefab assigned. " +
Debug.LogError("[PlayerBuilderSpawner] No default Builder prefab assigned. " +
"Cannot spawn builder for client " + OwnerClientId + ".");
return;
}
// Subscribe to NGO's scene-load completion so we re-spawn the builder
// every time the Match scene comes up (initial match start, Retry, etc.).
if (NetworkManager != null && NetworkManager.SceneManager != null)
{
NetworkManager.SceneManager.OnLoadEventCompleted += HandleSceneLoadCompleted;
sceneSubscribed = true;
}
// Edge case: the player connected while a match is already in progress.
// The Match scene is already loaded, so OnLoadEventCompleted won't fire
// again until the next transition. Spawn now.
if (SceneManager.GetActiveScene().name == SceneNames.Match)
TrySpawnBuilder();
}
// NGO fires this on the server once a scene load is acknowledged complete
// by every connected client (or timed out). We only act when the Match
// scene loads; Lobby / MainMenu loads are no-ops here.
private void HandleSceneLoadCompleted(string sceneName,
LoadSceneMode loadSceneMode,
List<ulong> clientsCompleted,
List<ulong> clientsTimedOut)
{
if (!IsServer) return;
if (sceneName != SceneNames.Match) return;
TrySpawnBuilder();
}
// Spawns the builder if it doesn't already exist. Defers to a SlotReady
// event if PlayerMatchState hasn't finished assigning the slot yet.
private void TrySpawnBuilder()
{
if (spawnedBuilder != null && spawnedBuilder.IsSpawned) return;
var pms = GetComponent<PlayerMatchState>();
if (pms == null)
{
@ -53,8 +104,6 @@ namespace TD.Gameplay
return;
}
// PlayerMatchState.OnNetworkSpawn may have already fired (component order: it first)
// or may fire after us (component order: we first). Handle both cases.
if (pms.Slot != PlayerSlot.None)
SpawnBuilderForOwner(pms.Slot);
else
@ -65,11 +114,21 @@ namespace TD.Gameplay
{
var pms = GetComponent<PlayerMatchState>();
if (pms != null) pms.SlotReady -= OnOwnerSlotReady;
SpawnBuilderForOwner(slot);
// Only spawn if we're in the Match scene. SlotReady can fire in MainMenu
// (during initial connection) — we don't want a builder there.
if (SceneManager.GetActiveScene().name == SceneNames.Match)
SpawnBuilderForOwner(slot);
}
public override void OnNetworkDespawn()
{
if (sceneSubscribed && NetworkManager != null && NetworkManager.SceneManager != null)
{
NetworkManager.SceneManager.OnLoadEventCompleted -= HandleSceneLoadCompleted;
sceneSubscribed = false;
}
// When the player despawns (disconnect), also despawn their builder if it still exists.
if (IsServer && spawnedBuilder != null && spawnedBuilder.IsSpawned)
{
@ -84,7 +143,19 @@ namespace TD.Gameplay
// Falls back to origin if loader/zone data isn't available.
Vector3 spawnPos = ComputeZoneCentroid(slot);
var go = Instantiate(builderPrefab, spawnPos, Quaternion.identity);
// Pick the prefab: race-specific takes priority, default falls back.
// Falling back is what lets all races share the default builder
// during Phase 1.7 — each RaceDefinition just needs the same default
// assigned, and the spawner picks it up automatically.
GameObject prefab = ResolveBuilderPrefab();
if (prefab == null)
{
Debug.LogError("[PlayerBuilderSpawner] No builder prefab available. " +
"Set the default in the inspector or assign one to the player's race.");
return;
}
var go = Instantiate(prefab, spawnPos, Quaternion.identity);
var netObj = go.GetComponent<NetworkObject>();
if (netObj == null)
{
@ -114,6 +185,22 @@ namespace TD.Gameplay
// ----- Helpers ----------------------------------------------------
// Picks the builder prefab to spawn for this player. Race-specific takes
// priority when (a) RaceRegistry is in the scene, (b) the player picked
// a race, and (c) that race's RaceDefinition has a BuilderPrefab assigned.
// Otherwise falls back to the inspector-assigned default.
private GameObject ResolveBuilderPrefab()
{
var pms = GetComponent<PlayerMatchState>();
if (pms != null && pms.RaceSelection != RaceId.None && RaceRegistry.Instance != null)
{
var raceDef = RaceRegistry.Instance.Get(pms.RaceSelection);
if (raceDef != null && raceDef.BuilderPrefab != null)
return raceDef.BuilderPrefab;
}
return builderPrefab;
}
private static Vector3 ComputeZoneCentroid(PlayerSlot slot)
{
var loader = LevelLoader.Instance;

View file

@ -0,0 +1,75 @@
// Assets/_Project/Scripts/Gameplay/RaceDefinition.cs
using UnityEngine;
using TD.Core;
using TD.Towers;
namespace TD.Gameplay
{
/// <summary>
/// One asset per playable race. Holds the race's identity (the
/// <see cref="RaceId"/> binding for networked selection), the visual + lore
/// content shown in the lobby's race-selection UI, and stub fields for the
/// Phase 1.8 gameplay payload (race-specific builder + tower roster).
/// </summary>
/// <remarks>
/// <para><b>Creating a new race.</b> Right-click in the project window →
/// <c>Create → TD → Race Definition</c>. Fill in the inspector:
/// <list type="bullet">
/// <item><b>Id</b> — pick an unused <see cref="RaceId"/> value (Race1..Race16).</item>
/// <item><b>Display Name</b> — what shows in the grid + detail header.</item>
/// <item><b>Icon</b> — square sprite, ~256x256, drawn in the grid + detail.</item>
/// <item><b>Builder Name / Description</b> — text in the detail panel.</item>
/// <item><b>Lore Text</b> — longer description in the detail panel.</item>
/// <item><b>Builder Prefab / Towers</b> — <i>stubs</i>, wired in Phase 1.8.</item>
/// </list>
/// Then drag the asset into the <c>RaceRegistry</c>'s <c>Definitions</c>
/// array on the scene's <c>RaceRegistry</c> GameObject.</para>
///
/// <para><b>Why <see cref="Id"/> is serialized rather than inferred from the
/// asset name.</b> Race selection is networked via a
/// <see cref="UnityEngine.SerializeField"/>-backed enum on
/// <c>PlayerMatchState</c>. The enum byte value is the wire identity; the
/// asset is just the local lookup. Keeping the binding explicit on the
/// asset prevents accidental drift if assets get renamed.</para>
/// </remarks>
[CreateAssetMenu(fileName = "RaceDefinition", menuName = "TD/Race Definition", order = 5)]
public class RaceDefinition : ScriptableObject
{
[Header("Identity")]
[Tooltip("Enum value used by PlayerMatchState.RaceSelection on the network. " +
"Pick an unused RaceId (Race1..Race16). Each asset must use a unique value.")]
public RaceId Id = RaceId.None;
[Tooltip("Race name shown in the lobby grid and detail panel header.")]
public string DisplayName;
[Tooltip("Square icon shown in the grid cell and as the larger image in the detail panel. " +
"~256x256 PNG works well; the UI scales to fit.")]
public Sprite Icon;
[Header("Builder")]
[Tooltip("Builder name shown in the detail panel (the builder is the in-match avatar " +
"that gates tower placement by proximity).")]
public string BuilderName;
[Tooltip("Short description of the builder shown beneath the name in the detail panel.")]
[TextArea(2, 4)]
public string BuilderDescription;
[Header("Lore")]
[Tooltip("Race lore / background shown in the detail panel. Lorem ipsum is fine " +
"for placeholder races; replace when actual writing is ready.")]
[TextArea(5, 15)]
public string LoreText;
[Header("Gameplay payload (Phase 1.8 — not wired yet)")]
[Tooltip("STUB (Phase 1.8): race-specific builder prefab. Currently every race spawns " +
"the default builder. When Phase 1.8 lands, PlayerBuilderSpawner will pick " +
"the prefab based on the player's RaceSelection.")]
public GameObject BuilderPrefab;
[Tooltip("STUB (Phase 1.8): tower roster available to this race. TowerRegistry will " +
"filter to this list when the active player belongs to this race.")]
public TowerDefinition[] Towers;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 98b7d5d116870f94da912007f6aa5cbb

View file

@ -0,0 +1,152 @@
// Assets/_Project/Scripts/Gameplay/RaceRegistry.cs
using System.Collections.Generic;
using UnityEngine;
using TD.Core;
namespace TD.Gameplay
{
/// <summary>
/// Persistent (DontDestroyOnLoad) singleton that holds every
/// <see cref="RaceDefinition"/> available in the current build and lets
/// any code look one up by <see cref="RaceId"/>.
/// </summary>
/// <remarks>
/// <para><b>Inspector setup.</b> Place ONE <c>RaceRegistry</c> GameObject in
/// the <b>MainMenu scene</b> only. Drag every <c>RaceDefinition</c> asset
/// into the <c>Definitions</c> array. The registry marks itself
/// <c>DontDestroyOnLoad</c> on Awake, so it survives the scene transitions
/// MainMenu → Lobby → Match → back to Lobby and is available to all of
/// them through <see cref="Instance"/>.</para>
///
/// <para><b>Why not also in Lobby/Match scenes?</b> Maintaining the
/// <c>Definitions</c> array in multiple places is a designer trap — update
/// one, forget the other, runtime mismatch. Single source of truth in
/// MainMenu eliminates that class of bug. Duplicate instances in other
/// scenes are detected in <see cref="Awake"/> and self-destruct, so an
/// accidental copy doesn't break anything but does log a warning.</para>
///
/// <para><b>Editor-only standalone testing.</b> If you open the Lobby or
/// Match scene directly from the editor (without going through MainMenu
/// first), no RaceRegistry will exist and race-dependent code falls back
/// gracefully (<see cref="Get"/> returns null; UI shows "Coming Soon" or
/// the default builder is used). For standalone-scene testing, you can
/// temporarily add a registry to whatever scene you're testing — but don't
/// commit it as part of normal play flow.</para>
///
/// <para><b>Slot model.</b> The lobby grid shows 16 slots (one per
/// <see cref="RaceId"/> value 1-16) regardless of how many are filled.
/// <see cref="Get"/> returns null for unregistered slots, which the UI
/// renders as a "Coming Soon" placeholder.</para>
///
/// <para><b>Plain MonoBehaviour.</b> Not a NetworkBehaviour — the registry
/// is identical on every peer (same ScriptableObject assets), so nothing
/// to sync. Network state tracks only the chosen <see cref="RaceId"/> on
/// <c>PlayerMatchState</c>; the rest is local lookup.</para>
/// </remarks>
public class RaceRegistry : MonoBehaviour
{
// ----- Singleton -------------------------------------------------
public static RaceRegistry Instance { get; private set; }
// ----- Inspector --------------------------------------------------
[Tooltip("All RaceDefinition assets available in this build. Drag each asset " +
"into the array. Duplicate Ids are rejected with a warning; null entries " +
"are skipped.")]
[SerializeField] private RaceDefinition[] definitions;
// ----- Internal lookup -------------------------------------------
private readonly Dictionary<RaceId, RaceDefinition> byId
= new Dictionary<RaceId, RaceDefinition>();
// ----- Lifecycle --------------------------------------------------
private void Awake()
{
// Persistent-singleton pattern: the FIRST instance to wake up wins
// and survives scene loads. Subsequent instances (e.g. a stale
// copy left over in the Lobby or Match scene) are self-destroyed,
// not just ignored — we want the scene to "self-heal" if someone
// accidentally drops a second copy in.
if (Instance != null && Instance != this)
{
Debug.LogWarning(
$"[RaceRegistry] Persistent instance already exists. " +
$"Destroying duplicate in scene '{gameObject.scene.name}'. " +
$"Keep RaceRegistry in the MainMenu scene only.");
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
BuildLookup();
}
private void OnDestroy()
{
if (Instance == this) Instance = null;
}
// ----- Public API -------------------------------------------------
/// <summary>
/// Returns the <see cref="RaceDefinition"/> for the given id, or null
/// if no asset is registered for that id (e.g. "Coming Soon" slots).
/// </summary>
public RaceDefinition Get(RaceId id)
{
byId.TryGetValue(id, out var def);
return def;
}
/// <summary>
/// Iterates the canonical 16 lobby slots (Race1..Race16). For each
/// slot returns either the registered <see cref="RaceDefinition"/> or
/// null. UI consumers use this to render a stable 16-cell grid where
/// unfilled slots show a placeholder.
/// </summary>
public IEnumerable<(RaceId id, RaceDefinition def)> AllSlots()
{
for (int i = (int)RaceId.Race1; i <= (int)RaceId.Race16; i++)
{
var id = (RaceId)i;
yield return (id, Get(id));
}
}
// ----- Private ----------------------------------------------------
private void BuildLookup()
{
byId.Clear();
if (definitions == null || definitions.Length == 0)
{
Debug.LogWarning("[RaceRegistry] No RaceDefinition assets assigned. " +
"Drag assets into the Definitions array.");
return;
}
foreach (var def in definitions)
{
if (def == null) continue;
if (def.Id == RaceId.None)
{
Debug.LogWarning($"[RaceRegistry] '{def.name}' has Id=None — set it to " +
"an unused Race1..Race16 value.");
continue;
}
if (byId.ContainsKey(def.Id))
{
Debug.LogWarning($"[RaceRegistry] Duplicate Id '{def.Id}' detected on " +
$"'{def.name}'. Earlier registration kept; rename one.");
continue;
}
byId[def.Id] = def;
}
Debug.Log($"[RaceRegistry] Registered {byId.Count} race(s).");
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 50ccfdaa301b6a7439b4edfc750550aa

View file

@ -1,5 +1,6 @@
// Assets/_Project/Scripts/UI/LobbyController.cs
using System.Linq;
using System.Text;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.UIElements;
@ -45,6 +46,22 @@ namespace TD.UI
private Button leaveButton;
private Label statusLabel;
// ----- Race selection overlay ------------------------------------
[Tooltip("Sibling RaceSelectionOverlay component that owns the race-pick UI. " +
"Auto-located on the same GameObject if not assigned.")]
[SerializeField] private RaceSelectionOverlay raceOverlay;
// Snapshot of player-list state from the last rebuild. RefreshPlayerList
// skips rebuilding when this matches the current frame's signature —
// critical because rebuilding every frame destroys the per-row buttons
// mid-click, and UI Toolkit's Clickable manipulator needs the same
// element instance to receive PointerDown AND PointerUp for the action
// to fire. Pre-fix, clicks on Select Race / Ready / Unready were silently
// lost because the button was destroyed between press and release.
private string lastPlayerListSignature = string.Empty;
private readonly StringBuilder signatureBuffer = new StringBuilder();
// ----- Lifecycle --------------------------------------------------
private void Start()
@ -59,6 +76,13 @@ namespace TD.UI
}
BuildUI(root);
// Initialize the race overlay with our root so it can install its
// UI elements on top of the lobby. Hidden by default.
if (raceOverlay == null) raceOverlay = GetComponent<RaceSelectionOverlay>();
if (raceOverlay != null) raceOverlay.Initialize(root);
else Debug.LogWarning("[LobbyController] No RaceSelectionOverlay component found — " +
"race-picker button will be disabled. Add one to this GameObject.");
}
private void Update()
@ -131,16 +155,23 @@ namespace TD.UI
private void RefreshPlayerList()
{
// Sort by slot for stable ordering. AllPlayers is keyed by clientId
// which may not be slot-ordered.
var players = PlayerMatchState.AllPlayers.OrderBy(p => (int)p.Slot).ToList();
// Skip rebuild when the player-relevant state hasn't changed.
// Without this guard the per-row buttons are destroyed every frame,
// which loses clicks (see lastPlayerListSignature comment above).
string signature = ComputePlayerListSignature(players);
if (signature == lastPlayerListSignature) return;
lastPlayerListSignature = signature;
playerListContainer.Clear();
ulong localId = NetworkManager.Singleton != null
? NetworkManager.Singleton.LocalClientId
: ulong.MaxValue;
// Sort by slot for stable ordering. AllPlayers is keyed by clientId
// which may not be slot-ordered.
var players = PlayerMatchState.AllPlayers.OrderBy(p => (int)p.Slot).ToList();
foreach (var pms in players)
{
bool isLocal = pms.OwnerClientId == localId;
@ -151,6 +182,26 @@ namespace TD.UI
playerListContainer.Add(new Label("(no players connected)") { style = { color = Color.gray } });
}
// Compact signature of every field the player rows depend on. When this
// changes we rebuild; when it's identical we leave the existing rows
// (and their button event handlers) intact for click handling.
private string ComputePlayerListSignature(System.Collections.Generic.List<PlayerMatchState> players)
{
signatureBuffer.Clear();
foreach (var pms in players)
{
signatureBuffer.Append(pms.OwnerClientId);
signatureBuffer.Append(':');
signatureBuffer.Append((int)pms.Slot);
signatureBuffer.Append(':');
signatureBuffer.Append((int)pms.RaceSelection);
signatureBuffer.Append(':');
signatureBuffer.Append(pms.IsReady ? '1' : '0');
signatureBuffer.Append(';');
}
return signatureBuffer.ToString();
}
private VisualElement BuildPlayerRow(PlayerMatchState pms, bool isLocal)
{
var row = new VisualElement();
@ -200,23 +251,17 @@ namespace TD.UI
// Local-only controls.
if (isLocal)
{
// PLACEHOLDER race picker — the RaceId enum only has None right
// now (Phase 1.8 will fill it). For now the single button submits
// RaceId.None, which keeps the ready-up gate effectively a no-op
// (the server requires RaceSelection != None to allow ready). To
// exercise the flow end-to-end before races exist, comment out
// the gate in PlayerMatchState.SubmitReadyRpc.
//
// TODO (Phase 1.8): replace with a dropdown of RaceDefinition
// assets discovered at runtime, each option calling
// pms.SubmitRaceRpc(definition.Id).
var pickRaceBtn = new Button(() => pms.SubmitRaceRpc(RaceId.None))
// Race selection — opens the overlay that lets the player browse
// the 4x4 race grid and pick one. The overlay handles submission
// (PlayerMatchState.SubmitRaceRpc) directly, so we just open it.
var pickRaceBtn = new Button(OpenRaceOverlay)
{
text = "Pick Race (stub)"
text = "Select Race"
};
pickRaceBtn.style.minWidth = 130;
pickRaceBtn.style.height = 28;
pickRaceBtn.style.marginLeft = 12;
pickRaceBtn.SetEnabled(raceOverlay != null);
row.Add(pickRaceBtn);
var readyBtn = new Button(() => pms.SubmitReadyRpc(!pms.IsReady))
@ -247,6 +292,16 @@ namespace TD.UI
// ----- Button handlers --------------------------------------------
private void OpenRaceOverlay()
{
if (raceOverlay == null)
{
Debug.LogWarning("[LobbyController] Race overlay not assigned.");
return;
}
raceOverlay.Show();
}
private void OnStartMatchClicked()
{
var svc = LobbyService.Instance;

View file

@ -1,7 +1,10 @@
// Assets/_Project/Scripts/UI/MainMenuController.cs
using System.Collections;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.UIElements;
using TD.Core;
using TD.Gameplay;
using TD.Net;
namespace TD.UI
@ -44,6 +47,7 @@ namespace TD.UI
private Button hostButton;
private Button joinButton;
private Button quitButton;
private Button quickStartButton;
private VisualElement joinPanel;
private TextField joinAddressField;
private TextField joinPortField;
@ -96,12 +100,14 @@ namespace TD.UI
buttonColumn.style.alignItems = Align.Center;
root.Add(buttonColumn);
hostButton = MakeMenuButton("Host Game", OnHostClicked);
joinButton = MakeMenuButton("Join Game", OnJoinClicked);
quitButton = MakeMenuButton("Quit", OnQuitClicked);
hostButton = MakeMenuButton("Host Game", OnHostClicked);
joinButton = MakeMenuButton("Join Game", OnJoinClicked);
quitButton = MakeMenuButton("Quit", OnQuitClicked);
quickStartButton = MakeMenuButton("Quick Start", OnQuickStartClicked);
buttonColumn.Add(hostButton);
buttonColumn.Add(joinButton);
buttonColumn.Add(quitButton);
buttonColumn.Add(quickStartButton);
// Join sub-panel — hidden until Join is clicked. Holds the IP+port
// fields and the Connect / Cancel buttons.
@ -120,14 +126,14 @@ namespace TD.UI
joinAddressField = new TextField("Host address");
joinAddressField.value = defaultJoinAddress;
joinAddressField.style.width = 280;
joinAddressField.style.color = Color.white;
StyleJoinFieldText(joinAddressField);
joinPanel.Add(joinAddressField);
joinPortField = new TextField("Port");
joinPortField.value = defaultPort.ToString();
joinPortField.style.width = 280;
joinPortField.style.marginTop = 8;
joinPortField.style.color = Color.white;
StyleJoinFieldText(joinPortField);
joinPanel.Add(joinPortField);
var joinButtons = new VisualElement();
@ -154,6 +160,19 @@ namespace TD.UI
root.Add(statusLabel);
}
// TextField's visible text color lives on the inner "unity-text-input"
// element, not on the TextField root. Setting it on the root alone
// leaves the inner element's inherited white color in place — which is
// invisible against the default white input background. Same gotcha as
// the chat input's dark-styling path in HUDController.
private static void StyleJoinFieldText(TextField field)
{
field.style.color = Color.black;
var inner = field.Q("unity-text-input");
if (inner != null)
inner.style.color = Color.black;
}
private static Button MakeMenuButton(string text, System.Action onClick)
{
var btn = new Button(() => onClick?.Invoke()) { text = text };
@ -229,5 +248,53 @@ namespace TD.UI
Application.Quit();
#endif
}
// Dev / testing shortcut: skips the lobby entirely. Hosts a single-player
// session, auto-selects Race1 for the local player, and loads the Match
// scene directly. Useful for iterating on gameplay without clicking
// through Host → Lobby → Pick Race → Ready → Start every time.
//
// To remove for a shipping build: delete the button-creation lines in
// BuildUI plus this method and its coroutine. No other consumers.
private void OnQuickStartClicked()
{
statusLabel.text = "Quick starting…";
StartCoroutine(QuickStartCoroutine());
}
private IEnumerator QuickStartCoroutine()
{
if (!NetworkBootstrap.StartHost(defaultPort))
{
statusLabel.text = "Failed to start host. Check the console.";
yield break;
}
// Wait for the local player's PlayerMatchState to spawn. Empirically
// this happens synchronously inside StartHost, but waiting one frame
// is cheap insurance against future NGO changes to spawn timing.
// Cap the wait at 60 frames so a real failure doesn't silently hang.
int safetyFrames = 0;
while (PlayerMatchState.Local == null && safetyFrames++ < 60)
yield return null;
var pms = PlayerMatchState.Local;
if (pms == null)
{
Debug.LogError("[MainMenuController] Quick Start: PlayerMatchState.Local " +
"didn't appear within 60 frames after StartHost. Aborting.");
statusLabel.text = "Quick Start failed: player did not spawn.";
yield break;
}
// Server-only setters — host is server + client, so these are valid
// directly. Skipping the RPC round-trip avoids any frame-of-latency
// before LoadSceneAsHost reads RaceSelection on the way into Match.
pms.SetRaceSelection(RaceId.Race1);
pms.SetReady(true);
// Skip Lobby — drop straight into the Match scene.
NetworkBootstrap.LoadSceneAsHost(SceneNames.Match);
}
}
}

View file

@ -0,0 +1,510 @@
// Assets/_Project/Scripts/UI/RaceSelectionOverlay.cs
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UIElements;
using TD.Core;
using TD.Gameplay;
namespace TD.UI
{
/// <summary>
/// Lobby-scene overlay that lets the local player pick their race.
/// Shows a 4x4 grid of race icons + a detail panel that pops up to the
/// right when an icon is clicked. Reads other players' picks from
/// <see cref="PlayerMatchState.AllPlayers"/> each frame to grey out
/// races that have already been taken.
/// </summary>
/// <remarks>
/// <para><b>Wiring.</b> Attach this component to the same GameObject as
/// <c>LobbyController</c>. The controller calls <see cref="Initialize"/>
/// once with its root <see cref="VisualElement"/>, and <see cref="Show"/>
/// when the "Select Race" button is clicked.</para>
///
/// <para><b>Why same UIDocument.</b> The overlay's elements are inserted as
/// absolute-positioned children of the lobby root — that way one UIDocument
/// + one PanelSettings serves both the lobby and the overlay, and z-order is
/// natural (overlay last → on top).</para>
///
/// <para><b>Visual states per grid cell.</b>
/// <list type="bullet">
/// <item><b>Free</b> — race not picked by anyone. Icon in color, clickable.</item>
/// <item><b>Taken by another player</b> — greyed out, clickable for viewing
/// the detail panel but confirm button is disabled. Player slot
/// number overlaid in color so others know who claimed it.</item>
/// <item><b>Taken by local player</b> — bordered highlight, clickable.
/// Confirm button enabled (no-op if already selected, but lets the
/// player re-confirm). Local slot number overlaid.</item>
/// <item><b>Unregistered slot</b> — placeholder "Coming Soon", not
/// clickable. No RaceDefinition asset exists for this RaceId yet.</item>
/// </list></para>
/// </remarks>
public class RaceSelectionOverlay : MonoBehaviour
{
// ----- UI state ---------------------------------------------------
private VisualElement overlayRoot;
private VisualElement gridContainer;
private VisualElement detailPanel;
private Label detailHeader;
private VisualElement detailIcon;
private Label detailBuilderName;
private Label detailBuilderDesc;
private Label detailLore;
private Button detailConfirmButton;
private Label detailUnavailableNote;
// Currently-viewed race in the detail panel. RaceId.None means no
// detail is open and the panel shows a hint instead.
private RaceId viewedRace = RaceId.None;
// Re-built each Update from PlayerMatchState data. Maps each taken
// RaceId to the slot of the player that picked it.
private readonly Dictionary<RaceId, PlayerSlot> picksByRace = new Dictionary<RaceId, PlayerSlot>();
// Snapshot of pick-state from the last grid rebuild. RefreshGrid skips
// rebuilding when this matches the current frame — same click-eating
// problem as the lobby's player list. Cleared on Show() so the grid
// always rebuilds when the overlay reopens.
private string lastGridSignature = string.Empty;
private readonly StringBuilder signatureBuffer = new StringBuilder();
// ----- Public API -------------------------------------------------
public bool IsVisible =>
overlayRoot != null && overlayRoot.style.display.value == DisplayStyle.Flex;
/// <summary>
/// Called by LobbyController once to attach the overlay's UI to the
/// lobby's UIDocument root. Builds the elements (hidden) and is then
/// reused across Show / Hide cycles.
/// </summary>
public void Initialize(VisualElement lobbyRoot)
{
if (lobbyRoot == null)
{
Debug.LogError("[RaceSelectionOverlay] Initialize received null lobby root.");
return;
}
BuildUI(lobbyRoot);
Hide();
}
/// <summary>
/// Reveals the overlay, refreshes the grid against current picks, and
/// auto-opens the detail panel for the local player's currently-picked
/// race (if any) so they can change it directly.
/// </summary>
public void Show()
{
if (overlayRoot == null) return;
overlayRoot.style.display = DisplayStyle.Flex;
// If the local player already has a race, seed the detail panel with
// it. Otherwise leave the panel showing the placeholder hint.
var local = PlayerMatchState.Local;
viewedRace = (local != null) ? local.RaceSelection : RaceId.None;
// Force a fresh grid build on each open so visuals reflect any
// picks that happened while the overlay was closed.
lastGridSignature = string.Empty;
RefreshGrid();
RefreshDetail();
}
public void Hide()
{
if (overlayRoot == null) return;
overlayRoot.style.display = DisplayStyle.None;
viewedRace = RaceId.None;
}
// ----- Lifecycle --------------------------------------------------
private void Update()
{
if (!IsVisible) return;
// Esc closes the overlay. Read directly via Input System — we want
// this even when nothing is focused.
var kb = Keyboard.current;
if (kb != null && kb.escapeKey.wasPressedThisFrame)
{
Hide();
return;
}
// Refresh against current picks every frame — cheap (max 16 cells,
// max 9 players) and means other-player changes show up immediately.
RefreshGrid();
RefreshDetail();
}
// ----- UI construction --------------------------------------------
private void BuildUI(VisualElement lobbyRoot)
{
// Root overlay — fills the screen with a dark backdrop.
overlayRoot = new VisualElement();
overlayRoot.pickingMode = PickingMode.Position;
overlayRoot.style.position = Position.Absolute;
overlayRoot.style.left = 0;
overlayRoot.style.right = 0;
overlayRoot.style.top = 0;
overlayRoot.style.bottom = 0;
overlayRoot.style.backgroundColor = new Color(0f, 0f, 0f, 0.85f);
overlayRoot.style.alignItems = Align.Center;
overlayRoot.style.justifyContent = Justify.Center;
lobbyRoot.Add(overlayRoot);
// Title + close button in a top bar.
var topBar = new VisualElement();
topBar.style.position = Position.Absolute;
topBar.style.top = 20;
topBar.style.left = 40;
topBar.style.right = 40;
topBar.style.flexDirection = FlexDirection.Row;
topBar.style.justifyContent = Justify.SpaceBetween;
topBar.style.alignItems = Align.Center;
overlayRoot.Add(topBar);
var title = new Label("Select Race");
title.style.fontSize = 32;
title.style.color = Color.white;
title.style.unityFontStyleAndWeight = FontStyle.Bold;
topBar.Add(title);
var closeBtn = new Button(Hide) { text = "X" };
closeBtn.style.width = 44;
closeBtn.style.height = 44;
closeBtn.style.fontSize = 20;
topBar.Add(closeBtn);
// Main content row: grid on the left, detail panel on the right.
var content = new VisualElement();
content.style.flexDirection = FlexDirection.Row;
content.style.alignItems = Align.Stretch;
content.style.marginTop = 40;
overlayRoot.Add(content);
// Grid container — 4x4 of cells. Uses wrap to flow rows.
gridContainer = new VisualElement();
gridContainer.style.flexDirection = FlexDirection.Row;
gridContainer.style.flexWrap = Wrap.Wrap;
gridContainer.style.width = 560; // 4 cells * (120 + 8 margin) = 512 + slack
gridContainer.style.marginRight = 32;
content.Add(gridContainer);
// Detail panel — built once, populated in RefreshDetail.
detailPanel = new VisualElement();
detailPanel.style.width = 380;
detailPanel.style.minHeight = 520;
detailPanel.style.paddingTop = 20;
detailPanel.style.paddingBottom = 20;
detailPanel.style.paddingLeft = 20;
detailPanel.style.paddingRight = 20;
detailPanel.style.backgroundColor = new Color(0.10f, 0.10f, 0.14f, 0.95f);
detailPanel.style.borderTopWidth = detailPanel.style.borderBottomWidth =
detailPanel.style.borderLeftWidth = detailPanel.style.borderRightWidth = 2;
var detailBorder = new Color(0.4f, 0.4f, 0.5f);
detailPanel.style.borderTopColor = detailPanel.style.borderBottomColor =
detailPanel.style.borderLeftColor = detailPanel.style.borderRightColor = detailBorder;
detailPanel.style.flexDirection = FlexDirection.Column;
content.Add(detailPanel);
detailHeader = new Label("Pick a race");
detailHeader.style.fontSize = 22;
detailHeader.style.color = Color.white;
detailHeader.style.unityFontStyleAndWeight = FontStyle.Bold;
detailHeader.style.marginBottom = 12;
detailPanel.Add(detailHeader);
detailIcon = new VisualElement();
detailIcon.style.width = 200;
detailIcon.style.height = 200;
detailIcon.style.alignSelf = Align.Center;
detailIcon.style.backgroundColor = new Color(0.18f, 0.18f, 0.22f);
detailIcon.style.marginBottom = 12;
detailPanel.Add(detailIcon);
detailBuilderName = new Label();
detailBuilderName.style.fontSize = 16;
detailBuilderName.style.color = new Color(0.95f, 0.85f, 0.5f);
detailBuilderName.style.unityFontStyleAndWeight = FontStyle.Bold;
detailPanel.Add(detailBuilderName);
detailBuilderDesc = new Label();
detailBuilderDesc.style.fontSize = 13;
detailBuilderDesc.style.color = new Color(0.85f, 0.85f, 0.85f);
detailBuilderDesc.style.marginBottom = 10;
detailBuilderDesc.style.whiteSpace = WhiteSpace.Normal;
detailPanel.Add(detailBuilderDesc);
detailLore = new Label();
detailLore.style.fontSize = 12;
detailLore.style.color = new Color(0.75f, 0.75f, 0.75f);
detailLore.style.marginBottom = 16;
detailLore.style.whiteSpace = WhiteSpace.Normal;
detailLore.style.flexGrow = 1;
detailPanel.Add(detailLore);
// Notice shown when the viewed race is taken by another player.
detailUnavailableNote = new Label();
detailUnavailableNote.style.color = new Color(1f, 0.5f, 0.3f);
detailUnavailableNote.style.fontSize = 12;
detailUnavailableNote.style.marginBottom = 8;
detailUnavailableNote.style.whiteSpace = WhiteSpace.Normal;
detailPanel.Add(detailUnavailableNote);
detailConfirmButton = new Button(OnConfirmClicked) { text = "Confirm Selection" };
detailConfirmButton.style.height = 40;
detailConfirmButton.style.fontSize = 16;
detailPanel.Add(detailConfirmButton);
}
// ----- Per-frame refresh ------------------------------------------
// Walks every PlayerMatchState and records which RaceId each non-None
// pick belongs to. Used by both the grid (grey out taken) and the
// detail panel (gate the confirm button).
private void RebuildPickIndex()
{
picksByRace.Clear();
foreach (var pms in PlayerMatchState.AllPlayers)
{
if (pms.RaceSelection == RaceId.None) continue;
picksByRace[pms.RaceSelection] = pms.Slot;
}
}
private void RefreshGrid()
{
if (gridContainer == null) return;
if (RaceRegistry.Instance == null) return;
RebuildPickIndex();
// Skip rebuild when nothing changed — keeps the cell event handlers
// alive across frames so clicks aren't eaten mid-press.
PlayerSlot localSlot = PlayerMatchState.Local != null
? PlayerMatchState.Local.Slot
: PlayerSlot.None;
string signature = ComputeGridSignature(localSlot);
if (signature == lastGridSignature) return;
lastGridSignature = signature;
gridContainer.Clear();
foreach (var (id, def) in RaceRegistry.Instance.AllSlots())
{
gridContainer.Add(BuildCell(id, def, localSlot));
}
}
// Compact signature of every input the grid cells depend on: the
// current pick map (RaceId → PlayerSlot) plus the local player's slot
// (which affects "is this mine" highlighting on each cell).
private string ComputeGridSignature(PlayerSlot localSlot)
{
signatureBuffer.Clear();
signatureBuffer.Append((int)localSlot);
signatureBuffer.Append('|');
foreach (var kvp in picksByRace.OrderBy(p => (int)p.Key))
{
signatureBuffer.Append((int)kvp.Key);
signatureBuffer.Append(':');
signatureBuffer.Append((int)kvp.Value);
signatureBuffer.Append(';');
}
return signatureBuffer.ToString();
}
private VisualElement BuildCell(RaceId id, RaceDefinition def, PlayerSlot localSlot)
{
// Outer cell with fixed size for consistent grid layout.
var cell = new VisualElement();
cell.style.width = 120;
cell.style.height = 120;
cell.style.marginTop = cell.style.marginBottom =
cell.style.marginLeft = cell.style.marginRight = 4;
cell.style.borderTopWidth = cell.style.borderBottomWidth =
cell.style.borderLeftWidth = cell.style.borderRightWidth = 2;
bool isLocked = (def == null);
bool isPicked = picksByRace.TryGetValue(id, out var picker);
bool isMine = isPicked && picker == localSlot;
bool isTakenByOther = isPicked && !isMine;
// Border color signals state at a glance.
Color borderColor = isMine
? new Color(0.3f, 0.85f, 0.3f) // green for "mine"
: isTakenByOther
? new Color(0.6f, 0.2f, 0.2f) // red-ish for taken
: isLocked
? new Color(0.3f, 0.3f, 0.3f) // dim for placeholder
: new Color(0.5f, 0.5f, 0.6f); // neutral for free
cell.style.borderTopColor = cell.style.borderBottomColor =
cell.style.borderLeftColor = cell.style.borderRightColor = borderColor;
cell.style.backgroundColor = new Color(0.10f, 0.10f, 0.14f);
// Icon area (or placeholder text for locked slots).
var iconHolder = new VisualElement();
iconHolder.pickingMode = PickingMode.Ignore;
iconHolder.style.flexGrow = 1;
iconHolder.style.alignItems = Align.Center;
iconHolder.style.justifyContent = Justify.Center;
iconHolder.style.unityBackgroundImageTintColor = isTakenByOther
? new Color(0.4f, 0.4f, 0.4f) // greyed out
: Color.white;
if (def != null && def.Icon != null)
iconHolder.style.backgroundImage = new StyleBackground(def.Icon);
else if (isLocked)
{
var placeholder = new Label("?");
placeholder.style.fontSize = 36;
placeholder.style.color = new Color(0.4f, 0.4f, 0.4f);
iconHolder.Add(placeholder);
}
cell.Add(iconHolder);
// Race name strip at the bottom — visible even when icon is missing.
var nameLabel = new Label(def != null ? def.DisplayName : "Coming Soon");
nameLabel.pickingMode = PickingMode.Ignore;
nameLabel.style.unityTextAlign = TextAnchor.MiddleCenter;
nameLabel.style.fontSize = 11;
nameLabel.style.color = isLocked ? new Color(0.45f, 0.45f, 0.45f) : Color.white;
nameLabel.style.paddingTop = 2;
nameLabel.style.paddingBottom = 2;
nameLabel.style.backgroundColor = new Color(0f, 0f, 0f, 0.55f);
cell.Add(nameLabel);
// Picker badge — top-right corner, only when taken. Shows the slot
// number of whoever picked it.
if (isPicked)
{
var badge = new Label($"P{(int)picker}");
badge.pickingMode = PickingMode.Ignore;
badge.style.position = Position.Absolute;
badge.style.top = 4;
badge.style.right = 4;
badge.style.fontSize = 16;
badge.style.unityFontStyleAndWeight = FontStyle.Bold;
badge.style.color = isMine
? new Color(0.3f, 0.85f, 0.3f)
: new Color(1f, 0.7f, 0.2f);
badge.style.paddingLeft = 4;
badge.style.paddingRight = 4;
badge.style.backgroundColor = new Color(0f, 0f, 0f, 0.7f);
cell.Add(badge);
}
// Click handler — only enabled for non-locked cells. Locked cells
// still get the visual treatment but no click response.
if (!isLocked)
{
cell.RegisterCallback<ClickEvent>(_ => OnCellClicked(id));
}
return cell;
}
// ----- Detail panel -----------------------------------------------
private void OnCellClicked(RaceId id)
{
viewedRace = id;
RefreshDetail();
}
private void RefreshDetail()
{
if (detailPanel == null) return;
var registry = RaceRegistry.Instance;
if (registry == null) return;
var def = registry.Get(viewedRace);
if (def == null)
{
// Empty / locked / no selection — show placeholder hint.
detailHeader.text = "Pick a race";
detailBuilderName.text = string.Empty;
detailBuilderDesc.text = string.Empty;
detailLore.text = "Click a race icon to see details.";
detailIcon.style.backgroundImage = null;
detailUnavailableNote.text = string.Empty;
detailConfirmButton.SetEnabled(false);
detailConfirmButton.text = "Confirm Selection";
return;
}
detailHeader.text = def.DisplayName;
detailBuilderName.text = string.IsNullOrEmpty(def.BuilderName)
? "Builder: (unnamed)"
: $"Builder: {def.BuilderName}";
detailBuilderDesc.text = def.BuilderDescription ?? string.Empty;
detailLore.text = def.LoreText ?? string.Empty;
detailIcon.style.backgroundImage = def.Icon != null
? new StyleBackground(def.Icon)
: null;
// Is this race available to the local player?
bool isPicked = picksByRace.TryGetValue(viewedRace, out var picker);
PlayerSlot localSlot = PlayerMatchState.Local != null
? PlayerMatchState.Local.Slot
: PlayerSlot.None;
bool takenByOther = isPicked && picker != localSlot;
bool takenByMe = isPicked && picker == localSlot;
if (takenByOther)
{
detailUnavailableNote.text = $"Already selected by P{(int)picker}.";
detailConfirmButton.SetEnabled(false);
detailConfirmButton.text = "Unavailable";
}
else if (takenByMe)
{
detailUnavailableNote.text = "Your current selection.";
detailConfirmButton.SetEnabled(true);
detailConfirmButton.text = "Confirm Selection";
}
else
{
detailUnavailableNote.text = string.Empty;
detailConfirmButton.SetEnabled(true);
detailConfirmButton.text = "Confirm Selection";
}
}
private void OnConfirmClicked()
{
var local = PlayerMatchState.Local;
if (local == null)
{
Debug.LogWarning("[RaceSelectionOverlay] No local PlayerMatchState — cannot submit race.");
return;
}
if (viewedRace == RaceId.None) return;
// Re-check exclusivity right before submitting — between detail open
// and confirm click, another player may have grabbed it.
if (picksByRace.TryGetValue(viewedRace, out var picker) && picker != local.Slot)
{
Debug.Log($"[RaceSelectionOverlay] Race already taken by P{(int)picker}. " +
"Refreshing detail.");
RefreshDetail();
return;
}
local.SubmitRaceRpc(viewedRace);
// Per the spec: confirming clears the detail panel but keeps the
// overlay open. The grid will update to show the new picked state
// on the next Update tick.
viewedRace = RaceId.None;
RefreshDetail();
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9ab3ad11b37d4c54d9f310f5356d31ac