299 lines
13 KiB
C#
299 lines
13 KiB
C#
// Assets/_Project/Scripts/Gameplay/LobbyService.cs
|
|
using Unity.Netcode;
|
|
using UnityEngine;
|
|
using TD.Core;
|
|
using TD.Levels;
|
|
using TD.Net;
|
|
|
|
namespace TD.Gameplay
|
|
{
|
|
/// <summary>
|
|
/// Scene singleton that owns lobby-wide actions: Start Match (host),
|
|
/// Return to Lobby (after a match), and the "host disconnected" broadcast.
|
|
/// Lives in the <c>Lobby</c> scene with a NetworkObject; spawns when the
|
|
/// scene loads.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para><b>What lives here vs. on PlayerMatchState.</b> Per-player state
|
|
/// (race, ready, slot, display name) lives on <see cref="PlayerMatchState"/>
|
|
/// 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.</para>
|
|
///
|
|
/// <para><b>Start Match flow.</b>
|
|
/// <list type="number">
|
|
/// <item>Host clicks Start in the lobby UI.</item>
|
|
/// <item>UI calls <see cref="RequestStartMatchRpc"/>; server validates:
|
|
/// every PlayerMatchState has a race and IsReady.</item>
|
|
/// <item>Server calls <c>NetworkBootstrap.LoadSceneAsHost(SceneNames.Match)</c>.
|
|
/// NGO replicates the scene load to every client.</item>
|
|
/// <item>Match scene loads; <see cref="MatchState"/> spawns there; gameplay
|
|
/// begins as usual.</item>
|
|
/// </list></para>
|
|
///
|
|
/// <para><b>Return to Lobby flow.</b> Triggered by the match-end overlay's
|
|
/// "Retry" button. Server calls <see cref="RequestReturnToLobbyRpc"/>;
|
|
/// resets <see cref="PlayerMatchState.IsReady"/> on every player; loads
|
|
/// the Lobby scene. Race picks are preserved so players don't have to
|
|
/// re-select.</para>
|
|
///
|
|
/// <para><b>Host-leaves behavior (Option A).</b> When the host quits, NGO
|
|
/// shuts down the connection for every client. Clients detect the disconnect
|
|
/// in <see cref="MainMenuRedirectOnDisconnect"/> (subscribed to
|
|
/// <c>NetworkManager.OnClientDisconnectCallback</c> 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.</para>
|
|
/// </remarks>
|
|
[RequireComponent(typeof(NetworkObject))]
|
|
public class LobbyService : NetworkBehaviour
|
|
{
|
|
// ----- Singleton --------------------------------------------------
|
|
|
|
public static LobbyService Instance { get; private set; }
|
|
|
|
// ----- Networked lobby state --------------------------------------
|
|
|
|
// Index into MapRegistry.Maps of the currently selected map. Server-write,
|
|
// everyone-read. Default 0 (the first registered map = MapRegistry.Default).
|
|
// UI subscribes to OnValueChanged to refresh the map browser highlight.
|
|
// A stale index (map removed between sessions) is tolerated by MapRegistry.Get
|
|
// returning null; RequestStartMatchRpc validates before loading.
|
|
private readonly NetworkVariable<int> selectedMapIndex =
|
|
new NetworkVariable<int>(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
|
|
|
|
/// <summary>
|
|
/// The index of the currently selected map within <see cref="MapRegistry.Maps"/>.
|
|
/// All clients read the same value; only the host can change it via
|
|
/// <see cref="RequestSelectMapRpc"/>.
|
|
/// </summary>
|
|
public int SelectedMapIndex => selectedMapIndex.Value;
|
|
|
|
/// <summary>
|
|
/// Exposed for UI subscription to <c>OnValueChanged</c>. Treat as read-only —
|
|
/// mutations must go through the server via <see cref="RequestSelectMapRpc"/>
|
|
/// so the host-only gate is enforced.
|
|
/// </summary>
|
|
public NetworkVariable<int> SelectedMapIndexVar => selectedMapIndex;
|
|
|
|
/// <summary>
|
|
/// Convenience: resolves the currently selected <see cref="LevelData"/> via
|
|
/// <see cref="MapRegistry"/>. Returns null if the registry is missing (e.g. the
|
|
/// editor was started directly in the Lobby scene) or the index is stale.
|
|
/// </summary>
|
|
public LevelData SelectedMap =>
|
|
MapRegistry.Instance != null
|
|
? MapRegistry.Instance.Get(selectedMapIndex.Value)
|
|
: null;
|
|
|
|
// ----- Lifecycle --------------------------------------------------
|
|
|
|
public override void OnNetworkSpawn()
|
|
{
|
|
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();
|
|
|
|
// If the current selection is invalid for any reason (registry missing,
|
|
// index stale from a previous session), snap to the default. Index 0
|
|
// is already the default-default; this is a no-op except after the
|
|
// registry's contents change between sessions.
|
|
var registry = MapRegistry.Instance;
|
|
if (registry != null && registry.Get(selectedMapIndex.Value) == null && registry.Count > 0)
|
|
{
|
|
selectedMapIndex.Value = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
public override void OnNetworkDespawn()
|
|
{
|
|
if (Instance == this) Instance = null;
|
|
}
|
|
|
|
// ----- Public RPC API (client → server) --------------------------
|
|
|
|
/// <summary>
|
|
/// Host-only request to begin the match. Validates that every connected
|
|
/// player has picked a race and is ready AND that the selected map can
|
|
/// accommodate the current lobby's player count, then transitions every
|
|
/// peer to the selected map's scene via NGO scene management.
|
|
/// </summary>
|
|
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
|
|
public void RequestStartMatchRpc(RpcParams rpcParams = default)
|
|
{
|
|
// 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;
|
|
}
|
|
|
|
// Re-validate the selected map server-side. The UI greys out unselectable
|
|
// maps based on lobby size, but a late join could push us above the map's
|
|
// PlayerCount between the click and the RPC arriving. Defensive check.
|
|
var registry = MapRegistry.Instance;
|
|
if (registry == null)
|
|
{
|
|
Debug.LogError("[LobbyService] Cannot start match: MapRegistry.Instance is null. " +
|
|
"Make sure MainMenu was loaded before the lobby (the registry " +
|
|
"DontDestroyOnLoads from there).");
|
|
return;
|
|
}
|
|
|
|
var selected = registry.Get(selectedMapIndex.Value);
|
|
if (selected == null)
|
|
{
|
|
Debug.LogError($"[LobbyService] Cannot start match: selected map index " +
|
|
$"{selectedMapIndex.Value} is not registered.");
|
|
return;
|
|
}
|
|
|
|
int playerCount = CountConnectedPlayers();
|
|
if (!MapRegistry.IsSelectableFor(selected, playerCount))
|
|
{
|
|
Debug.Log($"[LobbyService] Cannot start match: map '{selected.MapName}' supports " +
|
|
$"up to {selected.PlayerCount} players but the lobby has {playerCount}.");
|
|
return;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(selected.SceneName))
|
|
{
|
|
Debug.LogError($"[LobbyService] Cannot start match: '{selected.MapName}' has " +
|
|
$"empty SceneName (ScenePath='{selected.ScenePath}'). Re-bake the level.");
|
|
return;
|
|
}
|
|
|
|
Debug.Log($"[LobbyService] Starting match on '{selected.MapName}' " +
|
|
$"(index={selectedMapIndex.Value}, scene='{selected.SceneName}', " +
|
|
$"scenePath='{selected.ScenePath}', players={playerCount}).");
|
|
NetworkBootstrap.LoadSceneAsHost(selected.SceneName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Host-only request to change the selected map. Validates that the requested index
|
|
/// resolves to a real map in <see cref="MapRegistry"/>; if so, writes
|
|
/// <see cref="SelectedMapIndex"/> which replicates to every client.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Selectability for the current lobby size is NOT enforced here — players are
|
|
/// allowed to highlight an oversized map (e.g. while waiting for more friends to
|
|
/// join); the actual Start Match call enforces the rule. This keeps the host's
|
|
/// intent visible to everyone without preventing them from "claiming" a future map.
|
|
/// </remarks>
|
|
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
|
|
public void RequestSelectMapRpc(int mapIndex, RpcParams rpcParams = default)
|
|
{
|
|
if (rpcParams.Receive.SenderClientId != NetworkManager.Singleton.LocalClientId)
|
|
{
|
|
Debug.LogWarning("[LobbyService] Non-host client attempted to change the map. Ignored.");
|
|
return;
|
|
}
|
|
|
|
var registry = MapRegistry.Instance;
|
|
if (registry == null)
|
|
{
|
|
Debug.LogError("[LobbyService] Cannot change map: MapRegistry.Instance is null.");
|
|
return;
|
|
}
|
|
|
|
if (registry.Get(mapIndex) == null)
|
|
{
|
|
Debug.LogWarning($"[LobbyService] Rejected map index {mapIndex} — not registered.");
|
|
return;
|
|
}
|
|
|
|
selectedMapIndex.Value = mapIndex;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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 --------------------------------------------
|
|
|
|
/// <summary>
|
|
/// True if every connected player has picked a race AND marked themselves
|
|
/// ready. Returns the reason as <paramref name="reason"/> when false
|
|
/// (for debug logs / future UI tooltips on the disabled Start button).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current number of connected players (every <see cref="PlayerMatchState"/> in the
|
|
/// static registry). Used by map selectability checks both in the UI and the server-side
|
|
/// Start Match validator.
|
|
/// </summary>
|
|
public static int CountConnectedPlayers()
|
|
{
|
|
int n = 0;
|
|
foreach (var _ in PlayerMatchState.AllPlayers) n++;
|
|
return n;
|
|
}
|
|
|
|
// ----- Server helpers --------------------------------------------
|
|
|
|
private static void ResetAllReady()
|
|
{
|
|
foreach (var pms in PlayerMatchState.AllPlayers)
|
|
pms.SetReady(false);
|
|
}
|
|
}
|
|
}
|