Adding new Races, Main Menu -> Lobby flow, and what's needed to set up multiplayer testing.
This commit is contained in:
parent
60fa58b07f
commit
fdada6f132
29 changed files with 2581 additions and 176 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 & 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;
|
||||
|
|
|
|||
75
Assets/_Project/Scripts/Gameplay/RaceDefinition.cs
Normal file
75
Assets/_Project/Scripts/Gameplay/RaceDefinition.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Gameplay/RaceDefinition.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/RaceDefinition.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 98b7d5d116870f94da912007f6aa5cbb
|
||||
152
Assets/_Project/Scripts/Gameplay/RaceRegistry.cs
Normal file
152
Assets/_Project/Scripts/Gameplay/RaceRegistry.cs
Normal 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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Gameplay/RaceRegistry.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/RaceRegistry.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 50ccfdaa301b6a7439b4edfc750550aa
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
510
Assets/_Project/Scripts/UI/RaceSelectionOverlay.cs
Normal file
510
Assets/_Project/Scripts/UI/RaceSelectionOverlay.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/UI/RaceSelectionOverlay.cs.meta
Normal file
2
Assets/_Project/Scripts/UI/RaceSelectionOverlay.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9ab3ad11b37d4c54d9f310f5356d31ac
|
||||
Loading…
Add table
Add a link
Reference in a new issue