diff --git a/Assets/Scripts/Network/MatchManager.cs b/Assets/Scripts/Network/MatchManager.cs index 9cb5b92..96e0e9e 100644 --- a/Assets/Scripts/Network/MatchManager.cs +++ b/Assets/Scripts/Network/MatchManager.cs @@ -55,6 +55,29 @@ public class MatchManager : MonoBehaviour /// public enum MatchMode { Classic, Conversion } + /// Who won the finished match. Computed server-side the moment Seek ends (hunters win when no + /// un-hit hider is left, hiders win when the Seek timer ran out with a survivor) and broadcast with the + /// result rows. None while no result is available (match running / back in the lobby). + public enum MatchWinner : byte { None, Hunters, Hiders } + + /// A participant's end-of-match outcome. Hiders end as Survived (still un-hit when Seek ran + /// out), Found (Classic hit) or Converted (Conversion hit); Left = disconnected mid-match. + public enum PlayerOutcome : byte { Hunter, Survived, Found, Converted, Left } + + /// One row of the match scoreboard, replicated to every client: LIVE while the match runs + /// (roster at Hide start, re-sent on every hit / mid-match leave; winner still None) and frozen + /// together with the winner when Seek ends. Plain data - names resolve server-side. + public class PlayerResult + { + public string Name; + public PlayerOutcome Outcome; + /// Seconds into Seek the hider lasted: the hit moment for Found/Converted, the full Seek + /// length for Survived. 0 (not displayed) for Hunter/Left. + public float SurvivalTime; + /// Display name of the hunter that found/converted this hider; "" otherwise. + public string FoundBy; + } + public static MatchManager Instance { get; private set; } [Header("Scenes (must be in Build Settings)")] @@ -110,7 +133,8 @@ public class MatchManager : MonoBehaviour [Min(0f)] [SerializeField] private float seekDuration = 120f; - [Tooltip("RESOLUTION phase length. Nothing happens here yet; after it elapses everyone returns to the lobby.")] + [Tooltip("RESOLUTION phase length. The result screen (winner + per-player outcomes) is shown; after it " + + "elapses everyone returns to the lobby.")] [Min(0f)] [SerializeField] private float resolveDuration = 8f; @@ -255,6 +279,9 @@ public class MatchManager : MonoBehaviour // on every hunter's radar exactly like an interval sweep. The server gates the Seek phase and that the // sender is a live hider before emitting; the sender id is the trusted routing (no payload). private const string MsgManualPing = "Match_ManualPing"; + // Server -> all clients, once Seek ends: the frozen match result (winner + one row per participant). + // Also sent directly to a client that joins during Resolution, so its result screen is populated too. + private const string MsgMatchResult = "Match_Result"; // Server-only sub-phase machine. _pendingHiders holds the hiders not yet found; seek ends the instant it // empties. _phaseRoutine is the running Hide->Seek->Resolution coroutine (stopped if the match is ended @@ -298,6 +325,23 @@ public class MatchManager : MonoBehaviour private readonly List _conversionRecords = new List(); private readonly List _statues = new List(); + // Server-only. Per-participant outcome records for the running match, keyed by clientId. Created on match + // start (Hunter, or Survived as the pending-hider default), updated on hits/disconnects and frozen when + // Seek ends (FinalizeMatchResults: outstanding names resolved, survivor times filled, winner computed). + private class ResultRecord { public PlayerOutcome outcome; public float time; public string name; public string foundBy; } + private readonly Dictionary _matchRecords = new Dictionary(); + + // Replicated result mirror (every machine): the finished match's winner + rows, cleared when the + // sub-phase drops back to None. Read by the Resolution result screen. + private readonly List _localResults = new List(); + + /// The finished match's winner; None while no result is available. + public MatchWinner LocalWinner { get; private set; } = MatchWinner.None; + /// The finished match's result rows (scoreboard order); empty while no result is available. + public IReadOnlyList LocalResults => _localResults; + /// Bumped whenever the local result set changes, so the UI can change-detect cheaply. + public int ResultVersion { get; private set; } + private void Awake() { if (Instance && Instance != this) { Destroy(this); return; } @@ -355,6 +399,7 @@ public class MatchManager : MonoBehaviour _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgBeginLoad); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReady); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgDebugSkip); + _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgMatchResult); } } @@ -411,6 +456,7 @@ public class MatchManager : MonoBehaviour _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgPhase, OnPhaseMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRadarPing, OnRadarPingMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgBeginLoad, OnBeginLoadMessage); + _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgMatchResult, OnMatchResultMessage); } // Report our persistent id to the server the moment our own connection completes (host records its own @@ -447,6 +493,7 @@ public class MatchManager : MonoBehaviour // Teardown unloads the gameplay scenes (destroys the statue objects); just drop our references. _statues.Clear(); _conversionRecords.Clear(); + _matchRecords.Clear(); if (_net) UnhookSessionCallbacks(); ReturnToMainOnly(); @@ -504,6 +551,8 @@ public class MatchManager : MonoBehaviour SpawnFor(clientId, spectatorPrefab, matchSpawnCenter); // Sync the joiner's HUD/visibility to the running sub-phase + mode (remaining time as the duration). BroadcastPhase(_subPhase, LocalPhaseRemaining, matchMode, clientId, false); + // Hand it the current scoreboard too (live mid-match, frozen in Resolution). + if (_localResults.Count > 0) SendMatchResult(clientId, false); return; } SpawnFor(clientId, _playerPrefab, lobbySpawnCenter); @@ -525,6 +574,18 @@ public class MatchManager : MonoBehaviour // Free the ephemeral clientId -> identity mapping (the clientId will be reused by a later joiner). _persistentIds.Remove(clientId); + // Freeze a mid-match leaver's result row: a Found/Converted outcome stands (the hit landed), any + // still-open outcome becomes Left. The name resolves NOW or never - the avatar is despawning. + if (Phase == MatchPhase.InMatch && _matchRecords.TryGetValue(clientId, out var leftRec) && + (leftRec.outcome == PlayerOutcome.Hunter || leftRec.outcome == PlayerOutcome.Survived)) + { + leftRec.outcome = PlayerOutcome.Left; + if (leftRec.name == null) leftRec.name = ResolveDisplayName(clientId); + // Live scoreboard update; no-op once the result is frozen (a leave during Resolution must not + // rewrite the final rows). The leaver itself is mid-teardown - excluded from the send. + BroadcastLiveResults(clientId); + } + if (!_hasAuthorityClient || clientId != _authorityClientId) return; if (_net.IsHost && clientId == _net.LocalClientId) return; // host shutting down; teardown handles it @@ -638,6 +699,14 @@ public class MatchManager : MonoBehaviour { _conversionRecords.Clear(); // fresh match inherits no stale statue captures (EndMatch also clears) + // One outcome record per participant. Survived is the pending-hider default (flips on a hit / + // disconnect); names are resolved later - the freshly respawned avatars' name NetworkVariables + // may not have replicated to the server yet at this point. + _matchRecords.Clear(); + foreach (ulong id in _net.ConnectedClientsIds) + _matchRecords[id] = new ResultRecord + { outcome = hunterIds.Contains(id) ? PlayerOutcome.Hunter : PlayerOutcome.Survived }; + // Freeze everyone in Loading BEFORE unloading the lobby: the lobby floor vanishes a moment before the // map's is loaded, so the still-bound lobby avatars would otherwise drop into the void during the swap. // Loading gates movement, gravity AND the local own-body mesh on every client (see GameModeManager); the @@ -671,12 +740,20 @@ public class MatchManager : MonoBehaviour // _phaseRoutine (yielded inline), so an early Back-to-Lobby that stops that routine stops the barrier too. yield return RunLoadingBarrier(); + // Initial live scoreboard (everyone still Hunter/hiding). Sent AFTER the loading barrier so every + // avatar's name NetworkVariable has replicated to the server. SetPhaseServer(MatchSubPhase.Hide, hideDuration); + BroadcastLiveResults(); yield return WaitPhase(hideDuration, null); SetPhaseServer(MatchSubPhase.Seek, seekDuration); yield return RunSeekPhase(seekDuration); + // Seek is over - freeze the outcome (winner + per-player rows), apply it locally (host screen) and + // broadcast it to every client. The pause menu auto-opens on Resolution and presents the scoreboard + // (which was already live during the match). + FinalizeMatchResults(); + // Conversion mode: materialize a frozen statue of every converted hider at its capture spot. The last // hider is often still converting (its statue's painting hasn't replicated to every client yet), so // hold the Resolution countdown - frozen at 0:00, exactly like the pre-match loading barrier - until @@ -968,6 +1045,8 @@ public class MatchManager : MonoBehaviour _seekStartTime = Time.time; DestroyPreMatchObjects(); } + // Match fully over (back to lobby / teardown): nobody carries a stale scoreboard around. + if (changed && phase == MatchSubPhase.None) ClearLocalResults(); } // Runs on every machine (server directly, clients via the phase message); the tagged props are plain @@ -1129,7 +1208,7 @@ public class MatchManager : MonoBehaviour if (_net.IsServer) { if (_pendingHiders.Contains(targetClientId) && IsHunter(_net.LocalClientId)) - MarkHiderFound(targetClientId); + MarkHiderFound(targetClientId, _net.LocalClientId); return; } @@ -1153,7 +1232,7 @@ public class MatchManager : MonoBehaviour if (!_pendingHiders.Contains(targetClientId)) return; // not a live (un-found) hider if (!IsHunter(senderClientId)) return; // only a hunter may shoot - MarkHiderFound(targetClientId); + MarkHiderFound(targetClientId, senderClientId); } // Server-only. Resolves a hit on a hider: flashes its X-ray glow on every client, then (after hitRevealDelay @@ -1161,10 +1240,21 @@ public class MatchManager : MonoBehaviour // the avatar found (replicated by its NetworkPlayerSetup -> free-cam); Conversion respawns it as a Hunter. // The hider stays in _pendingHiders during the flash so seek doesn't end early on the last hit; the seek // loop ends the phase once _pendingHiders empties (no hiders left to find/convert). - private void MarkHiderFound(ulong hiderClientId) + private void MarkHiderFound(ulong hiderClientId, ulong shooterClientId) { if (_revealingHiders.Contains(hiderClientId)) return; // already flashing / being resolved + // Freeze the result row at the HIT moment: both avatars are guaranteed live here, so the names + // resolve now (a Conversion despawns the hider; a later resolve could miss it). + if (_matchRecords.TryGetValue(hiderClientId, out var rec)) + { + rec.outcome = matchMode == MatchMode.Conversion ? PlayerOutcome.Converted : PlayerOutcome.Found; + rec.time = Mathf.Max(0f, Time.time - _seekStartTime); + rec.name = ResolveDisplayName(hiderClientId); + rec.foundBy = ResolveDisplayName(shooterClientId); + BroadcastLiveResults(); // live scoreboard: the hit row updates on every client immediately + } + if (_net.ConnectedClients.TryGetValue(hiderClientId, out var c) && c.PlayerObject) { var setup = c.PlayerObject.GetComponent(); @@ -1293,6 +1383,164 @@ public class MatchManager : MonoBehaviour return setup && setup.ScopedModes && setup.Role == PlayerRole.Hunter; } + // --- Match result ----------------------------------------------------------------------------------- + // Server-only. Resolves a client's current display name; same "Player " fallback the nameplates + // use when the avatar is already gone or the name is not resolvable. + private string ResolveDisplayName(ulong clientId) + { + if (_net.ConnectedClients.TryGetValue(clientId, out var c) && c.PlayerObject) + { + var setup = c.PlayerObject.GetComponent(); + if (setup) return setup.DisplayName; + } + return $"Player {clientId}"; + } + + // Server-only, once when Seek ends. Resolves outstanding names, fills survivor times and computes the + // winner: any un-hit hider left -> the hiders win. A hider still in its hit-reveal flash was hit IN time + // (its record is already Found/Converted), so it counts for the hunters even though it is still in + // _pendingHiders. Applies the result locally (host screen; no loopback) and broadcasts it. + private void FinalizeMatchResults() + { + float elapsed = Mathf.Max(0f, Time.time - _seekStartTime); + bool anySurvivor = false; + foreach (var kv in _matchRecords) + { + ResultRecord rec = kv.Value; + if (rec.name == null) rec.name = ResolveDisplayName(kv.Key); + if (rec.outcome == PlayerOutcome.Survived) + { + rec.time = elapsed; + anySurvivor = true; + } + } + + RebuildLocalResults(); + LocalWinner = anySurvivor ? MatchWinner.Hiders : MatchWinner.Hunters; + ResultVersion++; + + SendMatchResult(0, true); + } + + // Server-only. Rebuilds the replicated row list (scoreboard order) from the outcome records. A record + // whose name is not frozen yet resolves it transiently (NOT stored), so FinalizeMatchResults still + // re-resolves the final names when Seek ends. + private void RebuildLocalResults() + { + _localResults.Clear(); + foreach (var kv in _matchRecords) + { + ResultRecord rec = kv.Value; + _localResults.Add(new PlayerResult + { + Name = rec.name ?? ResolveDisplayName(kv.Key), + Outcome = rec.outcome, + SurvivalTime = rec.time, + FoundBy = rec.foundBy ?? "" + }); + } + // Scoreboard order: survivors, then found/converted (longest-lasting first), hunters, leavers. + _localResults.Sort(CompareResults); + } + + // Server-only. Replicates the CURRENT scoreboard while the match is still running (winner not yet + // decided): the roster at Hide start, then every hit / mid-match leave. Applies locally too (host + // screen; ResultVersion bump) - the client handler is the same one the final result uses, rows simply + // re-bind. No-op once the result is frozen so nothing rewrites the final scoreboard. + private void BroadcastLiveResults(ulong excludeClient = ulong.MaxValue) + { + if (Phase != MatchPhase.InMatch || LocalWinner != MatchWinner.None) return; + RebuildLocalResults(); + ResultVersion++; + SendMatchResult(0, true, excludeClient); + } + + private static int CompareResults(PlayerResult a, PlayerResult b) + { + int rank = OutcomeRank(a.Outcome).CompareTo(OutcomeRank(b.Outcome)); + if (rank != 0) return rank; + int time = b.SurvivalTime.CompareTo(a.SurvivalTime); + if (time != 0) return time; + return string.Compare(a.Name, b.Name, System.StringComparison.OrdinalIgnoreCase); + } + + private static int OutcomeRank(PlayerOutcome outcome) + { + switch (outcome) + { + case PlayerOutcome.Survived: return 0; + case PlayerOutcome.Found: + case PlayerOutcome.Converted: return 1; + case PlayerOutcome.Hunter: return 2; + default: return 3; // Left + } + } + + // Sends the current result rows (live mid-match or frozen final) to one client (late joiner) or to all + // clients except the server itself (which applied them directly - no loopback, mirroring BroadcastPhase). + // toAll can exclude one extra client (a mid-teardown leaver). + private void SendMatchResult(ulong targetClient, bool toAll, ulong excludeClient = ulong.MaxValue) + { + var cmm = _net ? _net.CustomMessagingManager : null; + if (cmm == null || _localResults.Count == 0) return; + + // Rows carry variable-size strings: start modest, allow growth (names are capped at 24 chars). + using (var writer = new FastBufferWriter(1024, Allocator.Temp, 64 * 1024)) + { + writer.WriteValueSafe((byte)LocalWinner); + writer.WriteValueSafe(_localResults.Count); + for (int i = 0; i < _localResults.Count; i++) + { + PlayerResult r = _localResults[i]; + writer.WriteValueSafe(r.Name); + writer.WriteValueSafe((byte)r.Outcome); + writer.WriteValueSafe(r.SurvivalTime); + writer.WriteValueSafe(r.FoundBy ?? ""); + } + + if (toAll) + { + _phaseRecipients.Clear(); + foreach (ulong id in _net.ConnectedClientsIds) + if (id != _net.LocalClientId && id != excludeClient) _phaseRecipients.Add(id); + if (_phaseRecipients.Count > 0) + cmm.SendNamedMessage(MsgMatchResult, _phaseRecipients, writer); + } + else + { + cmm.SendNamedMessage(MsgMatchResult, targetClient, writer); + } + } + } + + // Client handler: the server sent the finished match's result rows (kept in wire = scoreboard order). + private void OnMatchResultMessage(ulong senderClientId, FastBufferReader reader) + { + reader.ReadValueSafe(out byte winner); + reader.ReadValueSafe(out int count); + _localResults.Clear(); + for (int i = 0; i < count; i++) + { + reader.ReadValueSafe(out string name); + reader.ReadValueSafe(out byte outcome); + reader.ReadValueSafe(out float time); + reader.ReadValueSafe(out string foundBy); + _localResults.Add(new PlayerResult + { Name = name, Outcome = (PlayerOutcome)outcome, SurvivalTime = time, FoundBy = foundBy }); + } + LocalWinner = (MatchWinner)winner; + ResultVersion++; + } + + // Every machine: drop the scoreboard when the match fully ends (sub-phase back to None). + private void ClearLocalResults() + { + if (LocalWinner == MatchWinner.None && _localResults.Count == 0) return; + LocalWinner = MatchWinner.None; + _localResults.Clear(); + ResultVersion++; + } + /// Server-only. Ends the running match: swap Test Map -> Lobby and respawn everyone as the /// neutral lobby Player (including any spectator-held mid-match joiners). Only acts while in a match. public void EndMatch() @@ -1306,6 +1554,7 @@ public class MatchManager : MonoBehaviour _pendingHiders.Clear(); DespawnConversionStatues(); // drop any Resolution statues before the map unloads _conversionRecords.Clear(); // and any captures from a match ended before Resolution + _matchRecords.Clear(); // and the outcome records (the None broadcast clears every client's mirror) SetPhaseServer(MatchSubPhase.None, 0f); Phase = MatchPhase.Transitioning; diff --git a/Assets/Scripts/Network/UI/MatchResultScreen.cs b/Assets/Scripts/Network/UI/MatchResultScreen.cs new file mode 100644 index 0000000..9fb7a26 --- /dev/null +++ b/Assets/Scripts/Network/UI/MatchResultScreen.cs @@ -0,0 +1,166 @@ +using System.Collections.Generic; +using TMPro; +using UnityEngine; + +/// +/// Binds the match scoreboard section inside the pause/session panel (Online Session Manager) to the +/// result rows replicates: LIVE while a match runs (roster from Hide start, +/// re-sent on every hit / mid-match leave; winner still None -> pending hiders read "Hiding") and frozen +/// with the winner banner when Seek ends (the browser UI auto-opens the panel then). The section root is +/// hidden while no result data exists (Main / lobby). Rows are pooled clones of the disabled row +/// template, rebuilt only when a new result version arrives (). +/// +[DisallowMultipleComponent] +public class MatchResultScreen : MonoBehaviour +{ + [Tooltip("Big winner banner (\"Hunters win!\" / \"Hiders win!\").")] + [SerializeField] private TMP_Text _winnerText; + + [Tooltip("One-line summary under the banner (counts: survived / found / converted).")] + [SerializeField] private TMP_Text _summaryText; + + [Tooltip("Parent the result rows are instantiated under (a static header row may live here too).")] + [SerializeField] private RectTransform _rowContainer; + + [Tooltip("Disabled template row with TMP children named Name / Outcome / Time / FoundBy; cloned per result row.")] + [SerializeField] private GameObject _rowTemplate; + + [Tooltip("Optional: root of the whole result section; hidden while no result data exists (in Main / " + + "the lobby) so the pause panel shows no empty scoreboard scaffold.")] + [SerializeField] private GameObject _content; + + private sealed class Row + { + public GameObject Root; + public TMP_Text Name, Outcome, Time, FoundBy; + } + + private readonly List _rows = new List(); + private int _boundVersion = -1; + + private void Awake() + { + bool templateOk = _rowTemplate && FindText(_rowTemplate, "Name") && FindText(_rowTemplate, "Outcome") && + FindText(_rowTemplate, "Time") && FindText(_rowTemplate, "FoundBy"); + if (!_winnerText || !_summaryText || !_rowContainer || !templateOk) + { + Debug.LogError("[MatchResultScreen] Unwired reference(s) or row template missing a TMP child " + + "(Name/Outcome/Time/FoundBy) - fix the wiring in the Inspector.", this); + enabled = false; + return; + } + } + + private void Update() + { + var match = MatchManager.Instance; + bool hasResults = match && match.LocalResults.Count > 0; + if (_content && _content.activeSelf != hasResults) _content.SetActive(hasResults); + if (!hasResults) + { + _boundVersion = -1; + return; + } + if (_boundVersion != match.ResultVersion) Bind(match); + } + + private void Bind(MatchManager match) + { + _boundVersion = match.ResultVersion; + + // Winner == None means the rows are the LIVE mid-match scoreboard: un-hit hiders read "Hiding" + // (their survival time is still open), everything else is already frozen at its hit moment. + bool live = match.LocalWinner == MatchManager.MatchWinner.None; + _winnerText.text = live ? "Match in progress" + : match.LocalWinner == MatchManager.MatchWinner.Hunters ? "Hunters win!" : "Hiders win!"; + + IReadOnlyList results = match.LocalResults; + + int survived = 0, found = 0, converted = 0; + for (int i = 0; i < results.Count; i++) + { + switch (results[i].Outcome) + { + case MatchManager.PlayerOutcome.Survived: survived++; break; + case MatchManager.PlayerOutcome.Found: found++; break; + case MatchManager.PlayerOutcome.Converted: converted++; break; + } + } + _summaryText.text = BuildSummary(survived, found, converted, live); + + for (int i = 0; i < results.Count; i++) + { + Row row = i < _rows.Count ? _rows[i] : CreateRow(); + MatchManager.PlayerResult r = results[i]; + bool pending = live && r.Outcome == MatchManager.PlayerOutcome.Survived; + row.Root.SetActive(true); + row.Name.text = r.Name; + row.Outcome.text = pending ? "Hiding" : OutcomeLabel(r.Outcome); + row.Time.text = !pending && HasTime(r.Outcome) ? FormatTime(r.SurvivalTime) : "-"; + row.FoundBy.text = string.IsNullOrEmpty(r.FoundBy) ? "-" : r.FoundBy; + } + for (int i = results.Count; i < _rows.Count; i++) + _rows[i].Root.SetActive(false); + } + + private Row CreateRow() + { + GameObject go = Instantiate(_rowTemplate, _rowContainer); + var row = new Row + { + Root = go, + Name = FindText(go, "Name"), + Outcome = FindText(go, "Outcome"), + Time = FindText(go, "Time"), + FoundBy = FindText(go, "FoundBy") + }; + _rows.Add(row); + return row; + } + + private static TMP_Text FindText(GameObject root, string childName) + { + Transform t = root.transform.Find(childName); + return t ? t.GetComponent() : null; + } + + private static string BuildSummary(int survived, int found, int converted, bool live) + { + var sb = new System.Text.StringBuilder(64); + AppendCount(sb, survived, live ? "hiding" : "survived"); + AppendCount(sb, found, "found"); + AppendCount(sb, converted, "converted"); + return sb.Length > 0 ? sb.ToString() : "No hiders in this match"; + } + + private static void AppendCount(System.Text.StringBuilder sb, int count, string label) + { + if (count == 0) return; + if (sb.Length > 0) sb.Append(" • "); + sb.Append(count).Append(' ').Append(label); + } + + private static bool HasTime(MatchManager.PlayerOutcome outcome) => + outcome == MatchManager.PlayerOutcome.Survived || + outcome == MatchManager.PlayerOutcome.Found || + outcome == MatchManager.PlayerOutcome.Converted; + + private static string FormatTime(float seconds) + { + int s = Mathf.Max(0, Mathf.RoundToInt(seconds)); + return $"{s / 60:0}:{s % 60:00}"; + } + + private static string OutcomeLabel(MatchManager.PlayerOutcome outcome) + { + switch (outcome) + { + case MatchManager.PlayerOutcome.Hunter: return "Hunter"; + case MatchManager.PlayerOutcome.Survived: return "Survived"; + case MatchManager.PlayerOutcome.Found: return "Found"; + case MatchManager.PlayerOutcome.Converted: return "Converted"; + case MatchManager.PlayerOutcome.Left: return "Left"; + default: return ""; + } + } +} diff --git a/Assets/Scripts/Network/UI/MatchResultScreen.cs.meta b/Assets/Scripts/Network/UI/MatchResultScreen.cs.meta new file mode 100644 index 0000000..92392e0 --- /dev/null +++ b/Assets/Scripts/Network/UI/MatchResultScreen.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 17b13eac332f8184d9a5a09e1c08651b \ No newline at end of file diff --git a/Assets/Scripts/Network/UI/OnlineSessionBrowserUI.cs b/Assets/Scripts/Network/UI/OnlineSessionBrowserUI.cs index 678abf5..54f49cb 100644 --- a/Assets/Scripts/Network/UI/OnlineSessionBrowserUI.cs +++ b/Assets/Scripts/Network/UI/OnlineSessionBrowserUI.cs @@ -14,7 +14,9 @@ using UnityEngine.InputSystem; /// /// The panel is always shown in Main (not connected); once a connection is up it hides, and in the /// lobby/map Esc toggles it back as a modal pause overlay (cursor freed, gameplay suspended via -/// ). The Disconnect button is context-sensitive: in Main it is an +/// ). It also auto-opens when the Resolution sub-phase begins so +/// the final match scoreboard (a section of this panel) is presented. The Disconnect button is +/// context-sensitive: in Main it is an /// application "Exit"; on the map it becomes "Back to Lobby" for the match authority (ends the match); /// everyone else gets a plain disconnect. Only the visual is toggled - this binder /// stays alive on its (always-active) canvas so it keeps reading Esc while the panel is hidden. @@ -60,6 +62,7 @@ public class OnlineSessionBrowserUI : MonoBehaviour private bool _connected; // last-seen connection state (to detect transitions) private Scene _lastScene; // last-seen active scene (to auto-close on a scene change) private string _disconnectText; // last applied caption (avoids per-frame TMP churn) + private MatchManager.MatchSubPhase _lastSubPhase; // last-seen sub-phase (auto-open on Resolution) private void Awake() { @@ -122,6 +125,16 @@ public class OnlineSessionBrowserUI : MonoBehaviour if (!connected) return; // Main: panel always shown, Esc ignored + // Resolution just began -> auto-open the overlay so the final scoreboard is presented. Esc can + // still close it; the lobby scene swap afterwards auto-closes it (see above). + MatchManager.MatchSubPhase subPhase = _match ? _match.LocalSubPhase : MatchManager.MatchSubPhase.None; + if (subPhase != _lastSubPhase) + { + _lastSubPhase = subPhase; + if (subPhase == MatchManager.MatchSubPhase.Resolution && !_open) + SetOpen(true); + } + bool inLobbyOrMap = _match && (_match.IsLocalSceneLobby || _match.IsLocalSceneMap); if (inLobbyOrMap && EscapePressed()) SetOpen(!_open); @@ -178,6 +191,7 @@ public class OnlineSessionBrowserUI : MonoBehaviour // Connection up (hosted/joined) -> hide the panel; Esc reopens it in the lobby/map. // Back to Main (disconnect/shutdown) -> show it again and drop any pause. _open = false; + _lastSubPhase = MatchManager.MatchSubPhase.None; // rejoining into Resolution must re-trigger if (!connected && _gameMode) _gameMode.SetPaused(false); ApplyVisibility(); }