Add match result evaluation with live scoreboard in the Esc menu

MatchManager tracks per-participant outcomes from match start (hunter /
hiding / found / converted / left), freezes hit rows with seek time and
finder name, and decides the winner at seek end (any untouched hider ->
hiders win). Results replicate via a Match_Result named message,
broadcast live on hide start, every hit and mid-match leave (no-op once
the winner is frozen), with late joiners served the current state.

MatchResultScreen renders the scoreboard as a section of the session
panel: winner banner ("Match in progress" while live), summary counts
and pooled rows (name, outcome, time, found by); the section hides when
no result data exists. OnlineSessionBrowserUI auto-opens the Esc
overlay when the Resolution sub-phase begins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5 committed 2026-07-08 20:40:36 +02:00
1 parent 262a20326a
commit b71cd61533
4 files changed
+436 -5

No files matched your search

+253 -4
View File
@@ -55,6 +55,29 @@ public class MatchManager : MonoBehaviour
/// </list></summary>
public enum MatchMode { Classic, Conversion }
/// <summary>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).</summary>
public enum MatchWinner : byte { None, Hunters, Hiders }
/// <summary>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.</summary>
public enum PlayerOutcome : byte { Hunter, Survived, Found, Converted, Left }
/// <summary>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.</summary>
public class PlayerResult
{
public string Name;
public PlayerOutcome Outcome;
/// <summary>Seconds into Seek the hider lasted: the hit moment for Found/Converted, the full Seek
/// length for Survived. 0 (not displayed) for Hunter/Left.</summary>
public float SurvivalTime;
/// <summary>Display name of the hunter that found/converted this hider; "" otherwise.</summary>
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<StatueRecord> _conversionRecords = new List<StatueRecord>();
private readonly List<NetworkObject> _statues = new List<NetworkObject>();
// 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<ulong, ResultRecord> _matchRecords = new Dictionary<ulong, ResultRecord>();
// 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<PlayerResult> _localResults = new List<PlayerResult>();
/// <summary>The finished match's winner; None while no result is available.</summary>
public MatchWinner LocalWinner { get; private set; } = MatchWinner.None;
/// <summary>The finished match's result rows (scoreboard order); empty while no result is available.</summary>
public IReadOnlyList<PlayerResult> LocalResults => _localResults;
/// <summary>Bumped whenever the local result set changes, so the UI can change-detect cheaply.</summary>
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<NetworkPlayerSetup>();
@@ -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 <id>" 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<NetworkPlayerSetup>();
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++;
}
/// <summary>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.</summary>
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;