diff --git a/Assets/_Project/Scripts/Gameplay/LobbyService.cs b/Assets/_Project/Scripts/Gameplay/LobbyService.cs new file mode 100644 index 0000000..3515407 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/LobbyService.cs @@ -0,0 +1,165 @@ +// Assets/_Project/Scripts/Gameplay/LobbyService.cs +using Unity.Netcode; +using UnityEngine; +using TD.Core; +using TD.Net; + +namespace TD.Gameplay +{ + /// + /// Scene singleton that owns lobby-wide actions: Start Match (host), + /// Return to Lobby (after a match), and the "host disconnected" broadcast. + /// Lives in the Lobby scene with a NetworkObject; spawns when the + /// scene loads. + /// + /// + /// What lives here vs. on PlayerMatchState. Per-player state + /// (race, ready, slot, display name) lives on + /// because it carries across scene transitions with the player connection. + /// Lobby-wide ACTIONS (Start Match, Return to Lobby) live here because they + /// are scoped to the lobby scene and don't need to persist. + /// + /// Start Match flow. + /// + /// Host clicks Start in the lobby UI. + /// UI calls ; server validates: + /// every PlayerMatchState has a race and IsReady. + /// Server calls NetworkBootstrap.LoadSceneAsHost(SceneNames.Match). + /// NGO replicates the scene load to every client. + /// Match scene loads; spawns there; gameplay + /// begins as usual. + /// + /// + /// Return to Lobby flow. Triggered by the match-end overlay's + /// "Retry" button. Server calls ; + /// resets on every player; loads + /// the Lobby scene. Race picks are preserved so players don't have to + /// re-select. + /// + /// Host-leaves behavior (Option A). When the host quits, NGO + /// shuts down the connection for every client. Clients detect the disconnect + /// in (subscribed to + /// NetworkManager.OnClientDisconnectCallback on the local client) + /// and load the MainMenu scene locally. See roadmap §1.7-Future Steam Lobby + /// Migration for the Option C upgrade (lobby persists, new host elected) + /// once Steam SDK is in. + /// + [RequireComponent(typeof(NetworkObject))] + public class LobbyService : NetworkBehaviour + { + // ----- Singleton -------------------------------------------------- + + public static LobbyService Instance { get; private set; } + + // ----- Lifecycle -------------------------------------------------- + + public override void OnNetworkSpawn() + { + if (Instance != null && Instance != this) + { + Debug.LogError("[LobbyService] Duplicate instance detected. " + + "Only one LobbyService should exist per scene."); + return; + } + Instance = this; + + // Entering the lobby always starts everyone un-ready. Race picks + // are preserved from the previous lobby visit, but ready state + // resets so each match requires explicit re-readying. + if (IsServer) + ResetAllReady(); + } + + public override void OnNetworkDespawn() + { + if (Instance == this) Instance = null; + } + + // ----- Public RPC API (client → server) -------------------------- + + /// + /// Host-only request to begin the match. Validates that every connected + /// player has picked a race and is ready, then transitions every peer + /// to the Match scene via NGO scene management. + /// + [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] + public void RequestStartMatchRpc(RpcParams rpcParams = default) + { + // Only the host's local client may start a match. We compare the + // sender's clientId to the server's clientId (which == the host's + // local clientId in host mode). + if (rpcParams.Receive.SenderClientId != NetworkManager.Singleton.LocalClientId) + { + Debug.LogWarning("[LobbyService] Non-host client attempted to start the match. Ignored."); + return; + } + + if (!AreAllPlayersReady(out string reason)) + { + Debug.Log($"[LobbyService] Cannot start match: {reason}"); + return; + } + + NetworkBootstrap.LoadSceneAsHost(SceneNames.Match); + } + + /// + /// Anyone can request a return to the lobby (typically the host clicks + /// "Retry" after a match). Server-side: resets ready state on every + /// player and triggers the scene transition. Race picks are preserved. + /// + [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] + public void RequestReturnToLobbyRpc() + { + // We don't gate this on host-only currently — any client retrying is + // semantically equivalent (the host needs to be willing to keep + // running). When dedicated server / Steam migration lands this + // becomes host-only. + ResetAllReady(); + NetworkBootstrap.LoadSceneAsHost(SceneNames.Lobby); + } + + // ----- Public queries -------------------------------------------- + + /// + /// True if every connected player has picked a race AND marked themselves + /// ready. Returns the reason as when false + /// (for debug logs / future UI tooltips on the disabled Start button). + /// + public static bool AreAllPlayersReady(out string reason) + { + int count = 0; + foreach (var pms in PlayerMatchState.AllPlayers) + { + count++; + if (pms.RaceSelection == RaceId.None) + { + reason = $"Slot {pms.Slot} hasn't picked a race."; + return false; + } + if (!pms.IsReady) + { + reason = $"Slot {pms.Slot} is not ready."; + return false; + } + } + + if (count == 0) + { + reason = "No players connected."; + return false; + } + + reason = string.Empty; + return true; + } + + // ----- Server helpers -------------------------------------------- + + private static void ResetAllReady() + { + foreach (var pms in PlayerMatchState.AllPlayers) + pms.SetReady(false); + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/LobbyService.cs.meta b/Assets/_Project/Scripts/Gameplay/LobbyService.cs.meta new file mode 100644 index 0000000..e178ddd --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/LobbyService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 46ab03868ea4bd541bd6be3446c2bd3d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs b/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs index 884a5c8..212dbd7 100644 --- a/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs +++ b/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs @@ -89,30 +89,91 @@ namespace TD.Gameplay writePerm: NetworkVariableWritePermission.Server ); - // STUBBED — written by the race-pick system (Phase 1.8). None until a race is chosen. - [Tooltip("STUBBED — not written until the race-pick system lands (Phase 1.8).")] + // Race chosen by this player. Written by the lobby system in response to + // SetRaceRpc; carries over scene transitions because PlayerMatchState is + // the player prefab (owned by the connection, persists across scenes). private readonly NetworkVariable raceSelection = new NetworkVariable( value: RaceId.None, readPerm: NetworkVariableReadPermission.Everyone, writePerm: NetworkVariableWritePermission.Server ); + // Lobby ready-state. Cleared back to false on scene transitions out of + // the lobby (the new lobby session starts everyone un-ready). + private readonly NetworkVariable isReady = new NetworkVariable( + value: false, + readPerm: NetworkVariableReadPermission.Everyone, + writePerm: NetworkVariableWritePermission.Server + ); + /// This player's assigned slot in the match. Authoritative once spawned. public PlayerSlot Slot => slot.Value; /// Display name. Stub until a lobby/name system provides it. public string DisplayName => displayName.Value.ToString(); - /// STUBBED. Race chosen by this player. until Phase 1.8. + /// Race chosen by this player. until selected in the lobby. public RaceId RaceSelection => raceSelection.Value; - /// STUBBED. Sets this player's race selection. Called by the race-pick system (Phase 1.8). + /// True if this player has marked themselves ready in the lobby. + public bool IsReady => isReady.Value; + + /// Server-only. Sets this player's race selection. public void SetRaceSelection(RaceId race) { if (!IsServer) { Debug.LogWarning("[PlayerMatchState] SetRaceSelection called on a client — ignored."); return; } raceSelection.Value = race; } + /// Server-only. Sets this player's ready state. + public void SetReady(bool ready) + { + if (!IsServer) { Debug.LogWarning("[PlayerMatchState] SetReady called on a client — ignored."); return; } + isReady.Value = ready; + } + + // ----- Client → Server lobby RPCs -------------------------------- + + /// + /// Client → server: submit a race selection. Owner-only — players can + /// only change their own race. Validates that the caller is the owner + /// of this PlayerMatchState; ignored otherwise. + /// + [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] + public void SubmitRaceRpc(RaceId race) + { + // Reset ready state when race changes — players shouldn't be "ready" + // with a stale pick. + if (raceSelection.Value != race) + { + raceSelection.Value = race; + isReady.Value = false; + } + } + + /// + /// Client → server: submit ready state. Owner-only. A player can only + /// ready up if they've picked a race; the lobby UI gates this already + /// but the server enforces it as a safety net. + /// + [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] + public void SubmitReadyRpc(bool ready) + { + if (ready && raceSelection.Value == RaceId.None) + { + Debug.Log($"[PlayerMatchState] Slot {Slot} tried to ready up without a race. Ignored."); + return; + } + isReady.Value = ready; + } + + /// + /// Server-side helper: enumerate every currently-spawned PlayerMatchState. + /// Used by LobbyService to evaluate "are all players ready" and + /// by LobbyController on every peer to render the player list. + /// + public static IEnumerable AllPlayers => s_byClientId.Values; + /// /// Server-only. Fires on the server immediately after the slot is assigned in /// . Sibling components (e.g. PlayerBuilderSpawner) diff --git a/Assets/_Project/Scripts/Net.meta b/Assets/_Project/Scripts/Net.meta new file mode 100644 index 0000000..409a755 --- /dev/null +++ b/Assets/_Project/Scripts/Net.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6bc7137210913b24da6d85326de66ed5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Net/NetworkBootstrap.cs b/Assets/_Project/Scripts/Net/NetworkBootstrap.cs new file mode 100644 index 0000000..c36241e --- /dev/null +++ b/Assets/_Project/Scripts/Net/NetworkBootstrap.cs @@ -0,0 +1,190 @@ +// Assets/_Project/Scripts/Net/NetworkBootstrap.cs +using System; +using Unity.Netcode; +using Unity.Netcode.Transports.UTP; +using UnityEngine; + +namespace TD.Net +{ + /// + /// Static facade for starting a host, joining a host, and disconnecting. + /// Wraps NGO's and + /// so the rest of the codebase (lobby UI, main menu UI) doesn't talk to + /// NGO directly — and so the transport implementation can be swapped + /// without touching lobby code. + /// + /// + /// Why a static facade. Today this is a thin wrapper over + /// . When Steam integration lands + /// (see Project_Roadmap.md §1.7-Future Steam Lobby Migration), this + /// class becomes the seam: it grows into an IConnectionProvider + /// abstraction with two implementations (DirectIpConnectionProvider + /// for LAN / DRM-free distribution, SteamConnectionProvider for + /// Steam friend-invite + lobby-browser flows). The lobby UI never changes; + /// only this class does. + /// + /// Default port. NGO's NetworkConfig holds the canonical + /// transport settings (port, listen address). + /// is just the value we use when the user doesn't override it in the UI. + /// + /// Scene management. NGO's SceneManager.LoadScene is + /// called by the host AFTER succeeds — see + /// . The lobby and match scene names are + /// canonicalized in . + /// + public static class NetworkBootstrap + { + // ----- Configuration --------------------------------------------- + + /// Default port used by Host / Join when the UI doesn't override. + public const ushort DefaultPort = 7777; + + /// Default listen address for the host. 0.0.0.0 listens on all interfaces. + public const string DefaultListenAddress = "0.0.0.0"; + + /// Default address clients use when no IP is entered (loopback for solo testing). + public const string DefaultConnectAddress = "127.0.0.1"; + + // ----- Public API ------------------------------------------------ + + /// True if a host or client connection is currently active. + public static bool IsConnected => + NetworkManager.Singleton != null + && (NetworkManager.Singleton.IsHost + || NetworkManager.Singleton.IsServer + || NetworkManager.Singleton.IsClient); + + /// True if the local peer is the host (server + client in one process). + public static bool IsHost => + NetworkManager.Singleton != null && NetworkManager.Singleton.IsHost; + + /// + /// Starts a host on :. + /// Returns true on success. Logs and returns false if NetworkManager + /// is missing or already running. + /// + public static bool StartHost(ushort port = DefaultPort, string listenAddress = DefaultListenAddress) + { + var nm = NetworkManager.Singleton; + if (nm == null) + { + Debug.LogError("[NetworkBootstrap] NetworkManager.Singleton is null. " + + "Make sure a NetworkManager exists in the active scene."); + return false; + } + if (IsConnected) + { + Debug.LogWarning("[NetworkBootstrap] StartHost called while already connected. Ignored."); + return false; + } + + ConfigureTransport(listenAddress, port); + + bool ok = nm.StartHost(); + if (!ok) Debug.LogError("[NetworkBootstrap] NetworkManager.StartHost() returned false."); + return ok; + } + + /// + /// Starts a client and connects to :. + /// Returns true on success. Logs and returns false if NetworkManager + /// is missing or already running. + /// + public static bool StartClient(string address, ushort port = DefaultPort) + { + var nm = NetworkManager.Singleton; + if (nm == null) + { + Debug.LogError("[NetworkBootstrap] NetworkManager.Singleton is null. " + + "Make sure a NetworkManager exists in the active scene."); + return false; + } + if (IsConnected) + { + Debug.LogWarning("[NetworkBootstrap] StartClient called while already connected. Ignored."); + return false; + } + if (string.IsNullOrWhiteSpace(address)) + address = DefaultConnectAddress; + + ConfigureTransport(address, port); + + bool ok = nm.StartClient(); + if (!ok) Debug.LogError("[NetworkBootstrap] NetworkManager.StartClient() returned false."); + return ok; + } + + /// + /// Shuts down the current host or client connection. Safe to call when + /// already disconnected. + /// + public static void Disconnect() + { + var nm = NetworkManager.Singleton; + if (nm == null) return; + if (!IsConnected) return; + + nm.Shutdown(); + } + + /// + /// Server-only helper that transitions every peer to + /// via NGO's networked scene manager. Logs and no-ops if not the server, + /// if NGO scene management is disabled, or if the scene name is invalid. + /// + public static void LoadSceneAsHost(string sceneName) + { + var nm = NetworkManager.Singleton; + if (nm == null) { Debug.LogError("[NetworkBootstrap] NetworkManager null."); return; } + if (!nm.IsServer) + { + Debug.LogWarning($"[NetworkBootstrap] LoadSceneAsHost('{sceneName}') called on a client. Ignored."); + return; + } + if (nm.SceneManager == null) + { + Debug.LogError("[NetworkBootstrap] NetworkManager.SceneManager is null — " + + "Enable Scene Management on the NetworkManager."); + return; + } + nm.SceneManager.LoadScene(sceneName, UnityEngine.SceneManagement.LoadSceneMode.Single); + } + + // ----- Internals -------------------------------------------------- + + // Writes connection data into the UnityTransport component attached to + // the NetworkManager. Doesn't allocate; reuses the transport instance + // that's already in the scene. + // + // FUTURE STEAM MIGRATION: when SteamConnectionProvider lands, this + // method's body is replaced (or split) to configure the appropriate + // transport. The public API above stays as-is so call sites in the + // lobby UI don't change. + private static void ConfigureTransport(string address, ushort port) + { + var nm = NetworkManager.Singleton; + var transport = nm.NetworkConfig?.NetworkTransport as UnityTransport; + if (transport == null) + { + Debug.LogError("[NetworkBootstrap] NetworkManager's transport is not UnityTransport. " + + "If you switched transports (e.g. Steam), update NetworkBootstrap " + + "to configure the new one."); + return; + } + transport.SetConnectionData(address, port, DefaultListenAddress); + } + } + + /// + /// Canonical scene names used by scene transitions. + /// Centralized so renames or additions touch one place. + /// + public static class SceneNames + { + public const string MainMenu = "MainMenu"; + public const string Lobby = "Lobby"; + + /// The gameplay scene (currently "Main" — the original prototype scene). + public const string Match = "Main"; + } +} diff --git a/Assets/_Project/Scripts/Net/NetworkBootstrap.cs.meta b/Assets/_Project/Scripts/Net/NetworkBootstrap.cs.meta new file mode 100644 index 0000000..ed5356d --- /dev/null +++ b/Assets/_Project/Scripts/Net/NetworkBootstrap.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0f83a5afd3f38414da451d1250c92be4 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Net/SessionFlow.cs b/Assets/_Project/Scripts/Net/SessionFlow.cs new file mode 100644 index 0000000..38f3d6e --- /dev/null +++ b/Assets/_Project/Scripts/Net/SessionFlow.cs @@ -0,0 +1,136 @@ +// Assets/_Project/Scripts/Net/SessionFlow.cs +using Unity.Netcode; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace TD.Net +{ + /// + /// Bootstrap script — drop one on a GameObject in the MainMenu scene so it + /// runs once when the app boots. Subscribes to NGO disconnect callbacks + /// and routes the local peer back to the MainMenu scene whenever its + /// connection ends (host left, server kicked it, transport error, etc.). + /// + /// + /// Why this exists. Per Option A (see Project_Roadmap.md §1.7), + /// host disconnect tears down the session for everyone. Each client needs + /// to recover gracefully and return to its own main menu. Without this, + /// a client would be stuck in the Lobby or Match scene with no working + /// network connection after the host quits. + /// + /// Lifecycle. The NetworkManager itself is marked + /// DontDestroyOnLoad by NGO once it spawns. This script is also marked + /// DontDestroyOnLoad so it survives scene transitions and its subscription + /// stays alive across MainMenu → Lobby → Match → back. + /// + /// Server side. When the host (server) shuts down, the + /// OnServerStopped callback fires on the host too. For the host + /// that's fine — they've initiated the shutdown deliberately (e.g., from + /// the "Return to Main Menu" button) and being sent to MainMenu is what + /// they wanted. + /// + public class SessionFlow : MonoBehaviour + { + // ----- Singleton -------------------------------------------------- + + public static SessionFlow Instance { get; private set; } + + private void Awake() + { + if (Instance != null && Instance != this) + { + Destroy(gameObject); + return; + } + Instance = this; + DontDestroyOnLoad(gameObject); + } + + private void Start() + { + HookCallbacks(); + } + + private void OnDestroy() + { + UnhookCallbacks(); + if (Instance == this) Instance = null; + } + + // ----- NGO callback wiring --------------------------------------- + + private bool callbacksHooked; + + private void HookCallbacks() + { + var nm = NetworkManager.Singleton; + if (nm == null) + { + // NetworkManager may not be alive yet on the very first frame + // depending on script execution order — retry next frame. + Invoke(nameof(HookCallbacks), 0.1f); + return; + } + if (callbacksHooked) return; + + nm.OnClientDisconnectCallback += HandleClientDisconnect; + nm.OnServerStopped += HandleServerStopped; + callbacksHooked = true; + } + + private void UnhookCallbacks() + { + if (!callbacksHooked) return; + var nm = NetworkManager.Singleton; + if (nm != null) + { + nm.OnClientDisconnectCallback -= HandleClientDisconnect; + nm.OnServerStopped -= HandleServerStopped; + } + callbacksHooked = false; + } + + // ----- Disconnect handlers --------------------------------------- + + // Fires when ANY client disconnects on the server, and when the LOCAL + // client gets disconnected on a client. We only care about the latter: + // when the local peer loses its connection (host left, transport error, + // explicit Shutdown call), route back to the main menu. + private void HandleClientDisconnect(ulong clientId) + { + var nm = NetworkManager.Singleton; + if (nm == null) return; + + // On the server, this fires when other clients disconnect — ignore + // those, the server keeps running. We only act when the LOCAL + // client's connection ends. + if (clientId != nm.LocalClientId) return; + + ReturnToMainMenu(); + } + + // Fires on the server when the server shuts down (including the host). + // The host's own client also gets HandleClientDisconnect — but + // OnServerStopped is the canonical "server is gone" signal and is + // safer to act on for the host path. + private void HandleServerStopped(bool isHost) + { + ReturnToMainMenu(); + } + + // ----- Scene transition ------------------------------------------ + + // Returns the local peer to the MainMenu scene if they're not already + // there. Uses Unity's local SceneManager (NOT NGO's networked scene + // manager) because by the time this fires the network connection is + // already gone. + private static void ReturnToMainMenu() + { + if (SceneManager.GetActiveScene().name == SceneNames.MainMenu) + return; + + Debug.Log("[SessionFlow] Connection ended — returning to main menu."); + SceneManager.LoadScene(SceneNames.MainMenu); + } + } +} diff --git a/Assets/_Project/Scripts/Net/SessionFlow.cs.meta b/Assets/_Project/Scripts/Net/SessionFlow.cs.meta new file mode 100644 index 0000000..c5cd9a1 --- /dev/null +++ b/Assets/_Project/Scripts/Net/SessionFlow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 474003b8e462dcc479e313f3d4f1cf12 \ No newline at end of file diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs index 647aeb0..36772ff 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -1322,12 +1322,25 @@ namespace TD.UI matchEndTitle.style.unityFontStyleAndWeight = FontStyle.Bold; panel.Add(matchEndTitle); + // Action row — Retry (back to lobby with everyone who retried) + // and Return to Main Menu (this player only disconnects). + var actionRow = new VisualElement(); + actionRow.style.flexDirection = FlexDirection.Row; + actionRow.style.marginTop = 8; + panel.Add(actionRow); + var retryBtn = new Button(OnRetryClicked) { text = "Retry" }; - retryBtn.style.minWidth = 120; + retryBtn.style.minWidth = 140; retryBtn.style.height = 36; retryBtn.style.fontSize = 16; - retryBtn.style.marginTop = 8; - panel.Add(retryBtn); + retryBtn.style.marginRight = 12; + actionRow.Add(retryBtn); + + var menuBtn = new Button(OnReturnToMainMenuClicked) { text = "Return to Main Menu" }; + menuBtn.style.minWidth = 200; + menuBtn.style.height = 36; + menuBtn.style.fontSize = 16; + actionRow.Add(menuBtn); matchEndOverlay.Add(panel); root.Add(matchEndOverlay); @@ -1368,41 +1381,34 @@ namespace TD.UI // sceneLoaded callback survives the scene reload (HUDController dies), // re-arms StartHost once the fresh scene has finished loading, and // unsubscribes itself. + // Retry: take everyone back to the Lobby scene via LobbyService. The + // lobby preserves race picks, clears ready state. Anyone who clicked + // Return to Main Menu instead has already disconnected — they don't + // come along. private void OnRetryClicked() { + var svc = LobbyService.Instance; + if (svc != null) + { + svc.RequestReturnToLobbyRpc(); + return; + } + + // Fallback: LobbyService isn't spawned (e.g. testing the gameplay + // scene standalone without the lobby flow). Hard-reload the scene. + Debug.LogWarning("[HUDController] LobbyService not found — falling back to scene reload."); var nm = NetworkManager.Singleton; - if (nm == null) - { - Debug.LogWarning("[HUDController] Retry clicked but NetworkManager is null."); - return; - } - if (!nm.IsServer) - { - Debug.LogWarning("[HUDController] Retry only works on the host. " + - "Clients should ask the host to retry."); - return; - } - - Scene active = SceneManager.GetActiveScene(); - s_pendingHostRestartBuildIndex = active.buildIndex; - SceneManager.sceneLoaded += OnSceneLoadedForRetry; - - nm.Shutdown(); - SceneManager.LoadScene(active.buildIndex); + if (nm != null && nm.IsServer && nm.SceneManager != null) + nm.SceneManager.LoadScene(SceneManager.GetActiveScene().name, LoadSceneMode.Single); } - private static int s_pendingHostRestartBuildIndex = -1; - - private static void OnSceneLoadedForRetry(Scene loaded, LoadSceneMode mode) + // Return to Main Menu: disconnect only this player. SessionFlow's + // OnClientDisconnect handler routes us back to MainMenu locally. Other + // peers remain in the match (until the host quits, at which point + // SessionFlow on each remaining client routes them out too). + private void OnReturnToMainMenuClicked() { - if (loaded.buildIndex != s_pendingHostRestartBuildIndex) return; - - SceneManager.sceneLoaded -= OnSceneLoadedForRetry; - s_pendingHostRestartBuildIndex = -1; - - var nm = NetworkManager.Singleton; - if (nm != null) nm.StartHost(); - else Debug.LogWarning("[HUDController] Retry: no NetworkManager in reloaded scene."); + TD.Net.NetworkBootstrap.Disconnect(); } // ----- Helpers ---------------------------------------------------- diff --git a/Assets/_Project/Scripts/UI/LobbyController.cs b/Assets/_Project/Scripts/UI/LobbyController.cs new file mode 100644 index 0000000..e6906df --- /dev/null +++ b/Assets/_Project/Scripts/UI/LobbyController.cs @@ -0,0 +1,270 @@ +// Assets/_Project/Scripts/UI/LobbyController.cs +using System.Linq; +using Unity.Netcode; +using UnityEngine; +using UnityEngine.UIElements; +using TD.Core; +using TD.Gameplay; +using TD.Net; + +namespace TD.UI +{ + /// + /// Drives the lobby UI. Requires a on the same + /// GameObject. Programmatic UI for v1 — UXML can be added later if needed. + /// + /// + /// What's displayed. A player list with one row per connected + /// player showing slot, display name, race pick, and ready ✓. The local + /// player's row has interactive controls: a race dropdown (currently a + /// placeholder with just until Phase 1.8 fills + /// the enum) and a Ready toggle button. The host sees a Start Match + /// button at the bottom, enabled only when every player is ready. + /// + /// Polling vs reactive. The list is rebuilt every Update from + /// . PlayerMatchState's + /// NetworkVariables expose OnValueChanged events we could + /// hook for reactive updates, but polling is simpler at lobby scale + /// (max 9 players × a handful of fields each) and avoids tracking + /// subscription lifecycles across spawn/despawn. + /// + /// Race picker placeholder. The enum + /// currently only has None. The picker shows a single "Test Race" + /// button that calls SubmitRaceRpc(RaceId.None) — temporary, so + /// ready-up flow can be exercised end-to-end before Phase 1.8 lands real + /// races. Replace with the actual race-selection UI when + /// assets exist. + /// + [RequireComponent(typeof(UIDocument))] + public class LobbyController : MonoBehaviour + { + // ----- Cached UI elements ----------------------------------------- + + private VisualElement playerListContainer; + private Button startMatchButton; + private Button leaveButton; + private Label statusLabel; + + // ----- Lifecycle -------------------------------------------------- + + private void Start() + { + var doc = GetComponent(); + var root = doc?.rootVisualElement; + if (root == null) + { + Debug.LogError("[LobbyController] rootVisualElement is null. " + + "Check the UIDocument's Panel Settings."); + return; + } + + BuildUI(root); + } + + private void Update() + { + if (playerListContainer == null) return; + RefreshPlayerList(); + RefreshStartButton(); + } + + // ----- UI construction -------------------------------------------- + + private void BuildUI(VisualElement root) + { + root.style.flexDirection = FlexDirection.Column; + root.style.alignItems = Align.Center; + root.style.justifyContent = Justify.FlexStart; + root.style.backgroundColor = new Color(0.05f, 0.05f, 0.08f, 1f); + root.style.width = Length.Percent(100); + root.style.height = Length.Percent(100); + root.style.paddingTop = 40; + + // Title. + var title = new Label("Lobby"); + title.style.fontSize = 36; + title.style.color = Color.white; + title.style.unityFontStyleAndWeight = FontStyle.Bold; + title.style.marginBottom = 24; + root.Add(title); + + // Player list container (rebuilt every Update from AllPlayers). + playerListContainer = new VisualElement(); + playerListContainer.style.flexDirection = FlexDirection.Column; + playerListContainer.style.minWidth = 520; + playerListContainer.style.paddingTop = 12; + playerListContainer.style.paddingBottom = 12; + playerListContainer.style.paddingLeft = 16; + playerListContainer.style.paddingRight = 16; + playerListContainer.style.backgroundColor = new Color(0f, 0f, 0f, 0.4f); + playerListContainer.style.marginBottom = 24; + root.Add(playerListContainer); + + // Action row: Start Match (host) + Leave (everyone). + var actionRow = new VisualElement(); + actionRow.style.flexDirection = FlexDirection.Row; + actionRow.style.marginTop = 8; + root.Add(actionRow); + + startMatchButton = new Button(OnStartMatchClicked) { text = "Start Match" }; + startMatchButton.style.minWidth = 200; + startMatchButton.style.height = 44; + startMatchButton.style.fontSize = 18; + startMatchButton.style.marginRight = 16; + startMatchButton.SetEnabled(false); + actionRow.Add(startMatchButton); + + leaveButton = new Button(OnLeaveClicked) { text = "Leave Lobby" }; + leaveButton.style.minWidth = 200; + leaveButton.style.height = 44; + leaveButton.style.fontSize = 18; + actionRow.Add(leaveButton); + + statusLabel = new Label(string.Empty); + statusLabel.style.color = new Color(1f, 0.6f, 0.3f); + statusLabel.style.marginTop = 16; + statusLabel.style.minHeight = 20; + root.Add(statusLabel); + } + + // ----- Per-frame refresh ------------------------------------------ + + private void RefreshPlayerList() + { + 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; + playerListContainer.Add(BuildPlayerRow(pms, isLocal)); + } + + if (players.Count == 0) + playerListContainer.Add(new Label("(no players connected)") { style = { color = Color.gray } }); + } + + private VisualElement BuildPlayerRow(PlayerMatchState pms, bool isLocal) + { + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Row; + row.style.alignItems = Align.Center; + row.style.paddingTop = 6; + row.style.paddingBottom = 6; + row.style.paddingLeft = 8; + row.style.paddingRight = 8; + row.style.marginBottom = 4; + row.style.backgroundColor = isLocal + ? new Color(0.18f, 0.22f, 0.30f) + : new Color(0.10f, 0.10f, 0.12f); + + // Slot label. + var slotLabel = new Label($"P{(int)pms.Slot}"); + slotLabel.style.minWidth = 40; + slotLabel.style.color = Color.white; + slotLabel.style.unityFontStyleAndWeight = FontStyle.Bold; + row.Add(slotLabel); + + // Display name. + var nameLabel = new Label(string.IsNullOrEmpty(pms.DisplayName) + ? $"Player {(int)pms.Slot}" + : pms.DisplayName); + nameLabel.style.minWidth = 140; + nameLabel.style.color = Color.white; + row.Add(nameLabel); + + // Race indicator. + var raceLabel = new Label($"Race: {pms.RaceSelection}"); + raceLabel.style.minWidth = 160; + raceLabel.style.color = pms.RaceSelection == RaceId.None + ? new Color(0.7f, 0.5f, 0.5f) + : new Color(0.85f, 0.85f, 0.85f); + row.Add(raceLabel); + + // Ready indicator. + var readyLabel = new Label(pms.IsReady ? "READY ✓" : "not ready"); + readyLabel.style.minWidth = 100; + readyLabel.style.color = pms.IsReady + ? new Color(0.3f, 0.85f, 0.3f) + : new Color(0.6f, 0.6f, 0.6f); + readyLabel.style.unityFontStyleAndWeight = FontStyle.Bold; + row.Add(readyLabel); + + // 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)) + { + text = "Pick Race (stub)" + }; + pickRaceBtn.style.minWidth = 130; + pickRaceBtn.style.height = 28; + pickRaceBtn.style.marginLeft = 12; + row.Add(pickRaceBtn); + + var readyBtn = new Button(() => pms.SubmitReadyRpc(!pms.IsReady)) + { + text = pms.IsReady ? "Unready" : "Ready" + }; + readyBtn.style.minWidth = 100; + readyBtn.style.height = 28; + readyBtn.style.marginLeft = 8; + row.Add(readyBtn); + } + + return row; + } + + private void RefreshStartButton() + { + var nm = NetworkManager.Singleton; + bool isHost = nm != null && nm.IsHost; + + startMatchButton.style.display = isHost ? DisplayStyle.Flex : DisplayStyle.None; + if (!isHost) return; + + bool canStart = LobbyService.AreAllPlayersReady(out string reason); + startMatchButton.SetEnabled(canStart); + statusLabel.text = canStart ? string.Empty : reason; + } + + // ----- Button handlers -------------------------------------------- + + private void OnStartMatchClicked() + { + var svc = LobbyService.Instance; + if (svc == null) + { + Debug.LogError("[LobbyController] LobbyService.Instance is null. " + + "Scene setup is incomplete."); + return; + } + svc.RequestStartMatchRpc(); + } + + private void OnLeaveClicked() + { + // Disconnect drops our connection. SessionFlow will route the + // local peer back to the MainMenu scene when the disconnect + // callback fires. + NetworkBootstrap.Disconnect(); + } + } +} diff --git a/Assets/_Project/Scripts/UI/LobbyController.cs.meta b/Assets/_Project/Scripts/UI/LobbyController.cs.meta new file mode 100644 index 0000000..50173be --- /dev/null +++ b/Assets/_Project/Scripts/UI/LobbyController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8c40427d598ba944c82f3790429c5532 \ No newline at end of file diff --git a/Assets/_Project/Scripts/UI/MainMenuController.cs b/Assets/_Project/Scripts/UI/MainMenuController.cs new file mode 100644 index 0000000..b72ab09 --- /dev/null +++ b/Assets/_Project/Scripts/UI/MainMenuController.cs @@ -0,0 +1,233 @@ +// Assets/_Project/Scripts/UI/MainMenuController.cs +using Unity.Netcode; +using UnityEngine; +using UnityEngine.UIElements; +using TD.Net; + +namespace TD.UI +{ + /// + /// Drives the main menu UI. Requires a on the same + /// GameObject. Builds the UI programmatically — no UXML needed for v1. + /// + /// + /// Buttons. + /// + /// Host — calls on + /// the default port, then NGO scene-loads the Lobby. Clients that + /// join later get pulled into whatever scene the server is in. + /// Join — reveals IP + port fields, then calls + /// . The server will pull + /// the client into the current networked scene (Lobby or Match) + /// once the connection completes. + /// Quit — Application.Quit (no-op in the editor). + /// + /// + /// Future Steam integration. A lobby-browser panel will be + /// added here. The Host button will create a Steam lobby instead of a + /// direct-IP host; Join becomes "browse public lobbies" + "accept friend + /// invite". See Project_Roadmap.md §1.7-Future Steam Lobby Migration. + /// + [RequireComponent(typeof(UIDocument))] + public class MainMenuController : MonoBehaviour + { + // ----- Inspector -------------------------------------------------- + + [Tooltip("Default port shown in the Join field and used by the Host button.")] + [SerializeField] private ushort defaultPort = NetworkBootstrap.DefaultPort; + + [Tooltip("Default address pre-filled in the Join field for solo testing.")] + [SerializeField] private string defaultJoinAddress = NetworkBootstrap.DefaultConnectAddress; + + // ----- Cached UI elements ----------------------------------------- + + private Button hostButton; + private Button joinButton; + private Button quitButton; + private VisualElement joinPanel; + private TextField joinAddressField; + private TextField joinPortField; + private Button joinConfirmButton; + private Button joinCancelButton; + private Label statusLabel; + + // ----- Lifecycle -------------------------------------------------- + + private void Start() + { + // UIDocument creates its panel in OnEnable, which runs after Awake. + // Start is the safe time to access rootVisualElement. + var doc = GetComponent(); + var root = doc?.rootVisualElement; + if (root == null) + { + Debug.LogError("[MainMenuController] rootVisualElement is null. " + + "Check the UIDocument's Panel Settings."); + return; + } + + BuildUI(root); + } + + // ----- UI construction -------------------------------------------- + + private void BuildUI(VisualElement root) + { + // Centered vertical stack on a dark background. + root.style.flexDirection = FlexDirection.Column; + root.style.justifyContent = Justify.Center; + root.style.alignItems = Align.Center; + root.style.backgroundColor = new Color(0.05f, 0.05f, 0.08f, 1f); + // The UIDocument root needs explicit width/height to fill the screen. + root.style.width = Length.Percent(100); + root.style.height = Length.Percent(100); + + // Title. + var title = new Label("Unity Tower Defense"); + title.style.fontSize = 48; + title.style.color = Color.white; + title.style.unityFontStyleAndWeight = FontStyle.Bold; + title.style.marginBottom = 60; + root.Add(title); + + // Primary button column. + var buttonColumn = new VisualElement(); + buttonColumn.style.flexDirection = FlexDirection.Column; + buttonColumn.style.alignItems = Align.Center; + root.Add(buttonColumn); + + hostButton = MakeMenuButton("Host Game", OnHostClicked); + joinButton = MakeMenuButton("Join Game", OnJoinClicked); + quitButton = MakeMenuButton("Quit", OnQuitClicked); + buttonColumn.Add(hostButton); + buttonColumn.Add(joinButton); + buttonColumn.Add(quitButton); + + // Join sub-panel — hidden until Join is clicked. Holds the IP+port + // fields and the Connect / Cancel buttons. + joinPanel = new VisualElement(); + joinPanel.style.flexDirection = FlexDirection.Column; + joinPanel.style.alignItems = Align.Center; + joinPanel.style.marginTop = 24; + joinPanel.style.paddingTop = 16; + joinPanel.style.paddingBottom = 16; + joinPanel.style.paddingLeft = 24; + joinPanel.style.paddingRight = 24; + joinPanel.style.backgroundColor = new Color(0f, 0f, 0f, 0.4f); + joinPanel.style.display = DisplayStyle.None; + root.Add(joinPanel); + + joinAddressField = new TextField("Host address"); + joinAddressField.value = defaultJoinAddress; + joinAddressField.style.width = 280; + joinAddressField.style.color = Color.white; + joinPanel.Add(joinAddressField); + + joinPortField = new TextField("Port"); + joinPortField.value = defaultPort.ToString(); + joinPortField.style.width = 280; + joinPortField.style.marginTop = 8; + joinPortField.style.color = Color.white; + joinPanel.Add(joinPortField); + + var joinButtons = new VisualElement(); + joinButtons.style.flexDirection = FlexDirection.Row; + joinButtons.style.marginTop = 12; + joinPanel.Add(joinButtons); + + joinConfirmButton = new Button(OnJoinConfirmClicked) { text = "Connect" }; + joinConfirmButton.style.minWidth = 120; + joinConfirmButton.style.height = 32; + joinConfirmButton.style.marginRight = 8; + joinButtons.Add(joinConfirmButton); + + joinCancelButton = new Button(OnJoinCancelClicked) { text = "Cancel" }; + joinCancelButton.style.minWidth = 120; + joinCancelButton.style.height = 32; + joinButtons.Add(joinCancelButton); + + // Status line at the bottom for connection feedback. + statusLabel = new Label(string.Empty); + statusLabel.style.color = new Color(1f, 0.6f, 0.3f); + statusLabel.style.marginTop = 32; + statusLabel.style.minHeight = 20; + root.Add(statusLabel); + } + + private static Button MakeMenuButton(string text, System.Action onClick) + { + var btn = new Button(() => onClick?.Invoke()) { text = text }; + btn.style.minWidth = 240; + btn.style.height = 48; + btn.style.fontSize = 20; + btn.style.marginBottom = 8; + return btn; + } + + // ----- Button handlers -------------------------------------------- + + private void OnHostClicked() + { + statusLabel.text = "Starting host…"; + if (!NetworkBootstrap.StartHost(defaultPort)) + { + statusLabel.text = "Failed to start host. Check the console."; + return; + } + + // After StartHost the local peer is the server + client. Trigger + // the networked scene load to take everyone to the Lobby. NGO + // replicates this to any future-joining clients automatically. + NetworkBootstrap.LoadSceneAsHost(SceneNames.Lobby); + } + + private void OnJoinClicked() + { + // Toggle the join sub-panel. + joinPanel.style.display = joinPanel.style.display == DisplayStyle.None + ? DisplayStyle.Flex + : DisplayStyle.None; + } + + private void OnJoinConfirmClicked() + { + string address = string.IsNullOrWhiteSpace(joinAddressField.value) + ? defaultJoinAddress + : joinAddressField.value.Trim(); + + ushort port = defaultPort; + if (!string.IsNullOrWhiteSpace(joinPortField.value) + && ushort.TryParse(joinPortField.value.Trim(), out var parsed)) + { + port = parsed; + } + + statusLabel.text = $"Connecting to {address}:{port}…"; + if (!NetworkBootstrap.StartClient(address, port)) + { + statusLabel.text = "Failed to start client. Check the console."; + return; + } + + // The server's NGO SceneManager will pull this client into whatever + // scene the server is in (Lobby or Match) once the connection + // completes. SessionFlow handles disconnect recovery. + joinPanel.style.display = DisplayStyle.None; + } + + private void OnJoinCancelClicked() + { + joinPanel.style.display = DisplayStyle.None; + statusLabel.text = string.Empty; + } + + private void OnQuitClicked() + { +#if UNITY_EDITOR + UnityEditor.EditorApplication.isPlaying = false; +#else + Application.Quit(); +#endif + } + } +} diff --git a/Assets/_Project/Scripts/UI/MainMenuController.cs.meta b/Assets/_Project/Scripts/UI/MainMenuController.cs.meta new file mode 100644 index 0000000..b407fe3 --- /dev/null +++ b/Assets/_Project/Scripts/UI/MainMenuController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 01199add5a12f4a4bb9a94d1e44fbb4d \ No newline at end of file diff --git a/Project_Roadmap.md b/Project_Roadmap.md index cdb4901..52afb7f 100644 --- a/Project_Roadmap.md +++ b/Project_Roadmap.md @@ -75,7 +75,45 @@ The most architecturally significant pending system. This blocks the Phase 2 tow - 4-connected, no diagonals (matches existing maze validation) - Re-pathing on tower placement/removal events. Fire on: `TowerInstance` spawn/despawn, construction-start, construction-finish, shelved-tower despawn. -### 1.7 Terrain architecture +### 1.7 Lobby & Connection (CURRENT) + +Replaces the current bare "Start Host" button with a proper main menu → lobby → match flow. +Lobby is its own scene; players gather, pick races, ready up, and the host starts the match. + +**v1 scope (Direct IP, Option A host-leaves-closes-lobby):** + +- `MainMenu` scene — Host / Join (with IP+port field) / Quit +- `Lobby` scene — player list, per-player race picker, ready toggle, host-only Start button, Leave button +- `LobbyService` `NetworkBehaviour` scene singleton — Start Match RPC, Return to Lobby RPC +- `NetworkBootstrap` static — wraps `UnityTransport` host/join/disconnect. Designed for the Steam swap-out below: same call sites, different transport. +- `PlayerMatchState` extended with `IsReady` NetworkVariable and `SetReadyRpc` / `SetRaceRpc` for client → server submissions +- Match-end overlay extended: **Retry** (back to lobby) AND **Return to Main Menu** (this player only) +- Host leaves → server raises a "lobby closed" RPC; all clients shutdown + return to main menu. Same applies if the host clicks "Return to Main Menu" after a match — session ends for everyone, each player returns independently to their own main menu. + +### 1.7-Future Steam Lobby Migration (Option C — DEFERRED) + +When the Steam SDK is integrated (post-1.7, before 1.8 or in parallel), the Direct IP backend is replaced with a Steam-backed lobby provider. **The lobby UI and gameplay code do not change.** The migration touches only `NetworkBootstrap` and adds a `LobbyProvider` abstraction behind it. + +**What changes:** + +- Add `Facepunch.Steamworks` (recommended) or `Steamworks.NET` to the project +- Use Steam app ID **480 (Spacewar)** during development; switch to the actual app ID once Steam page is provisioned +- Replace `UnityTransport` with `SteamNetworkingSocketsTransport` (community-maintained NGO transport) +- Refactor `NetworkBootstrap` from static helpers into an `IConnectionProvider` interface with two implementations: + - `DirectIpConnectionProvider` (existing behavior, kept for LAN testing and DRM-free distribution) + - `SteamConnectionProvider` (Steam lobby create/join, friend invite via Steam overlay, Steam P2P sockets) +- Add lobby browser UI to `MainMenu` scene (currently just Host/Join buttons) +- Friend-invite flow: handled by Steam overlay (Shift+Tab → invite friend); join request lands in the existing Lobby scene +- Host-leaves behavior upgrades from Option A → Option C: Steam lobbies persist independently of the game host, so a new host can be elected from remaining members rather than tearing the lobby down. **This is the deferred behavioral upgrade flagged for migration time.** + +**Notes for the migration:** + +- Lobby code already factored to read player state from `PlayerMatchState` (carries across scene loads) and lobby-wide state from `LobbyService` — no UI rewrite needed +- The `IConnectionProvider` abstraction is the single seam between gameplay and transport; everything else stays put +- Both providers should coexist in the codebase so DRM-free builds (itch.io, direct distribution) can still ship without Steam +- Steam Direct fee ($100 USD) is required to publish on Steam but NOT for development — Spacewar app ID 480 is free to use + +### 1.7.5 Terrain architecture Decision deferred per existing context document; Builder code is already terrain-agnostic. Recommend deciding after the character pipeline mini-session and before Phase 2 begins (the visual prototype will look very different on Unity Terrain vs mesh-based terrain). @@ -100,6 +138,59 @@ Flagged for revisit; not blocking anything. - Center-on-builder hotkey (e.g., Space) - Initial camera position taking race or match phase into account +### 1.10 Tower Customization & Meta-Progression (DEFERRED — DESIGN CAPTURED) + +Long-term cross-match progression loop. Recorded here so Phase 1.8 race-system design and Phase 2 visual prototype work can anticipate the customization data model when they're scheduled. Not blocking Phase 1 exit criteria — a single match is fully playable without it. + +**Core concept** + +- Every player starts each match with the same **base tower set** — a small fixed roster (universal or per-race; see open question below). +- **End of match awards a customization reward**, drawn from a Warhammer-themed pool. Win and loss draw from **different pools** — winning unlocks higher-tier / rarer rewards, losing still progresses at a slower rate so no match is truly wasted. +- Reward types: + - **Paint color** (e.g. Macragge Blue, Mephiston Red, Caliban Green) — applied to tower visuals, can carry gameplay effects (see below) + - **Sticker decal** — chapter badges, regimental icons, faction sigils + - Other Warhammer-aligned customizations: transfers, weathering, base styles, freehand-style elements +- Customizations are **persistent across matches** via a player profile / save system. +- Applied via a **customization menu** (does not exist yet) reachable from the main menu, showing owned customizations and per-tower application slots. + +**Gameplay enhancements via customization** + +Cosmetics double as upgrades. The pattern mirrors the real-world Warhammer hobby loop (collect → paint → customize) translated into mechanical progression: + +- **Stat modifiers** — extended range, increased damage, faster fire rate, larger splash radius +- **Damage-type changes** — applying a particular paint scheme grants Fire / Cold / Poison damage to an otherwise basic tower +- **Special effects** — chain hits, slow effects, DoT — driven by specific decal or paint combinations +- **Layering / set bonuses** — applying multiple customizations from the same "faction" might unlock additional effects (e.g. full Salamanders paint kit + chapter decal grants a flamer-style damage profile) + +**Required systems (none exist yet)** + +- **Player profile / persistence** — local save for now, account-bound later. Tracks owned customizations + per-tower application state. +- **`CustomizationDefinition` ScriptableObject** — stat-delta fields, visual asset references (decal texture, color values), rarity tier, reward-pool tags. +- **Tower stat modifier stack** — extend `TowerDefinition` (currently flat fields) to accept a layered modifier stack from applied customizations. Affects targeting, damage, range, fire rate at runtime. +- **End-of-match reward roll** — server-authoritative draw from win/loss pool, replicated to the relevant player. Anti-cheat: server owns the roll, not the client. +- **Customization menu UI** — main-menu-accessible. Browse owned, browse locked, apply / remove per tower in the base set. +- **Networked applied-customization state during a match** — towers must visually reflect each owner's applied customizations on every peer, AND stat modifiers must be authoritative on the server. + +**Open question — interaction with the Phase 1.8 race system** + +Phase 1.8 currently assumes **race-driven tower rosters** (each race has its own distinct set of towers). This new direction suggests a flatter, customization-driven model. The two systems need reconciliation before either ships: + +- **Option A:** Race remains the primary tower-set distinction. Customizations layer on top of race-specific towers. Each race owns its own customization pool. +- **Option B:** Race becomes one customization category among many. Tower set is universal across players; "race" identity emerges from the player's chosen paint scheme / decal kit. +- **Option C:** Hybrid — small universal base set + larger race-locked roster, both customizable. Customizations affect both. + +Decision deferred until both 1.8 and 1.10 are actively scheduled. The choice has significant implications for content scope: Option A means N races × M customizations each; Option B means one tower set × (M customizations × N "factions"). Option B is dramatically less art work per tower. + +**Why this is deferred** + +- Not required for Phase 1 exit criteria (a single match plays fine without cross-match progression). +- Phase 2 visual prototype doesn't depend on this — the painted-miniature aesthetic provides the visual scaffolding customization will eventually exploit, but the prototype tower's paint scheme can be hard-coded for the demo. +- The data model interaction with Phase 1.8 needs to be resolved first (see open question above). + +**When to schedule** + +Earliest reasonable slot: after Phase 1 exit criteria are met and Phase 1.8 race-system design has the customization-interaction question resolved. Could run in parallel with Phase 2 (no overlap with visual prototype scope) or after Phase 2 if visual direction needs to prove out first. + ### Phase 1 exit criteria A complete match is playable end-to-end: race pick → builder spawn → tower placement and construction → wave spawning → enemies path through the maze → towers shoot enemies → enemies die or leak → match concludes. Visuals are placeholder. All systems in this section are functional.