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();
}