UI/Hud.razor

Razor UI component for the game HUD. Renders the on-screen dashboards (session, hourly, hours-won, sessions-won), central click button and status HUD, mute toggle, leader skin panel and a transient toast; it fetches some leaderboards and copies Steam profile links to clipboard.

Http CallsFile Access
🌐 https://steamcommunity.com/profiles/
@using System
@using System.Collections.Generic
@using System.Threading.Tasks
@using Sandbox
@using Sandbox.UI
@using Splitclicker.Api
@using Splitclicker.Game
@using Splitclicker.Audio
@namespace Splitclicker.UI
@inherits PanelComponent

@*
	The whole HUD as a SINGLE root PanelComponent. This matters: a ScreenPanel lays
	its child PanelComponent roots out in a flex row and ignores `position` set on
	those roots, so sibling panels can't pin/center themselves. The working pattern
	(see rotaliate's GameHud) is one root with `width/height: 100%` that genuinely
	fills the screen, with every piece an absolutely-positioned CHILD of it:

	  • session board   — pinned top-left
	  • the button + status HUD + "clicks sent" — dead-center of the screen
	  • hourly board     — pinned top-right, just left of hours-won
	  • hours-won board  — pinned hard to the top-right edge
*@

<root>
	@{ var c = ClickController.Instance; }

	@* ── session board: current game's cumulative standings, pinned top-left ── *@
	<div class="board session">
		<div class="title">SESSION · TOP CLICKERS</div>
		<div class="divider"></div>
		@if ( SessionEntries.Count == 0 )
		{
			<div class="empty">No scores yet</div>
		}
		else
		{
			@for ( int i = 0; i < SessionEntries.Count && i < DisplayLimit; i++ )
			{
				var s = SessionEntries[i];
				<div class="row @(IsMe( s ) ? "me" : "")">
					<div class="rank">@(i + 1)</div>
					<div class="name clickable" onclick="@(() => CopyProfile( s ))">@DisplayName( s )</div>
					<div class="pts">@s.Points</div>
				</div>
			}
		}
	</div>

	@* ── mute toggle: bottom edge, just right of the session list ── *@
	<div class="mute-btn @(IsMuted ? "muted" : "")" onclick="@ToggleMute">@MuteLabel()</div>

	@* ── the one global button, dead-center, with the status HUD over it ── *@
	@* StateClass also lands on .center so the HUD text can flip to black on the
	   yellow-orange WAIT background (and stay white everywhere else). *@
	<div class="center @StateClass()">
		<div class="btn @StateClass()" onclick="@OnPress"></div>

		@* Each line below is a SINGLE interpolated expression on purpose: mixing
		   literal text with razor expressions in one element emits several text nodes,
		   which s&box's panel reconciler reorders across rebuilds (the "arms in 2-6s"
		   -> "- arms in 26s" / vanishing-penalty bug). One expression = one stable node. *@
		<div class="hud">
			<div class="round">@($"Round {c?.Round ?? 0} / {c?.Of ?? 0}")</div>
			<div class="state @PhaseClass()">@PhaseText()</div>
			<div class="meta">
				<div class="players">@($"{c?.Players ?? 0} online")</div>
				<div class="sep">·</div>
				<div class="clicks">@($"{c?.ClicksToWin ?? 0} clicks exist for this round")</div>
			</div>
			@if ( c?.Phase == GamePhase.Pending && (c?.ArmMaxSec ?? 0) > 0 )
			{
				<div class="arm">@($"arms in {c.ArmMinSec}–{c.ArmMaxSec}s")</div>
			}
			@* throttle sits directly below the arms text and is always shown while
			   in a game (it counts up live as the player idle-clicks). *@
			@if ( c != null && c.Phase != GamePhase.Connecting && c.Phase != GamePhase.Disconnected )
			{
				<div class="penalty">@($"throttled +{c.PenaltyMs}ms")</div>
			}
		</div>

		<div class="clicks-sent">@($"clicks sent: {c?.ClicksSent ?? 0}")</div>
	</div>

	@* ── hourly board: Postgres "most clicks this hour", pinned right (inner) ── *@
	<div class="board hourly">
		<div class="title">HOURLY · TOP CLICKERS</div>
		<div class="reset">resets in @ResetCountdown()</div>
		<div class="divider"></div>
		@if ( _hourly.Count == 0 )
		{
			<div class="empty">@(_hourlyLoaded ? "No scores this hour yet" : "loading…")</div>
		}
		else
		{
			@for ( int i = 0; i < _hourly.Count; i++ )
			{
				var s = _hourly[i];
				<div class="row @(IsMe( s ) ? "me" : "")">
					<div class="rank">@(i + 1)</div>
					<div class="name clickable" onclick="@(() => CopyProfile( s ))">@DisplayName( s )</div>
					<div class="pts">@s.Points</div>
				</div>
			}
		}
	</div>

	@* ── hours-won board: career UTC clock-hours topped, pinned right (outer) ── *@
	<div class="board hourswon">
		<div class="title">HOURS WON</div>
		<div class="divider"></div>
		@if ( _hoursWon.Count == 0 )
		{
			<div class="empty">@(_hoursWonLoaded ? "No hours won yet" : "loading…")</div>
		}
		else
		{
			@for ( int i = 0; i < _hoursWon.Count; i++ )
			{
				var s = _hoursWon[i];
				<div class="row @(IsMe( s ) ? "me" : "")">
					<div class="rank">@(i + 1)</div>
					<div class="name clickable" onclick="@(() => CopyProfile( s ))">@DisplayName( s )</div>
					<div class="pts">@s.Points</div>
				</div>
			}
		}
	</div>

	@* ── sessions-won board: career games (sessions) topped, pinned bottom-right ── *@
	<div class="board sessionswon">
		<div class="title">SESSIONS WON</div>
		<div class="divider"></div>
		@if ( _sessionsWon.Count == 0 )
		{
			<div class="empty">@(_sessionsWonLoaded ? "No sessions won yet" : "loading…")</div>
		}
		else
		{
			@for ( int i = 0; i < _sessionsWon.Count; i++ )
			{
				var s = _sessionsWon[i];
				<div class="row @(IsMe( s ) ? "me" : "")">
					<div class="rank">@(i + 1)</div>
					<div class="name clickable" onclick="@(() => CopyProfile( s ))">@DisplayName( s )</div>
					<div class="pts">@s.Points</div>
				</div>
			}
		}
	</div>

	@* ── leader's "skin": bundled image pinned just left of the sessions-won board,
	     captioned with the current sessions-won leader's name ── *@
	<div class="skin-panel">
		<div class="skin-img"></div>
		<div class="skin-caption">@($"{SkinLeaderName()}'s Skin")</div>
	</div>

	@* ── transient "copied profile link" toast, top-center for ~a second ── *@
	@if ( ToastVisible )
	{
		<div class="toast">@_toast</div>
	}
</root>

<style>
	/* The root genuinely fills the screen, so every absolutely-positioned child
	   below anchors against the full screen rect. pointer-events:none here; only
	   the button re-enables them. */
	root {
		width: 100%;
		height: 100%;
		pointer-events: none;
	}

	/* ── boards: shared look; each pinned via its own position class ── */
	.board {
		position: absolute;
		top: 18px;
		width: 280px;
		flex-direction: column;
		background-color: rgba(7, 5, 26, 0.82);
		border: 1.5px solid rgba(255, 255, 255, 0.12);
		border-radius: 10px;
		padding: 12px 14px;
		gap: 3px;
	}

	.board.session { left: 14px; }

	/* mute toggle: bottom edge, just right of the session list's column. */
	.mute-btn {
		position: absolute;
		bottom: 18px;
		left: 308px; /* 14 (session edge) + 280 (session width) + 14 (gap) */
		pointer-events: all;
		cursor: pointer;
		flex-direction: row;
		align-items: center;
		justify-content: center;
		background-color: rgba(7, 5, 26, 0.82);
		border: 1.5px solid rgba(255, 255, 255, 0.12);
		border-radius: 8px;
		padding: 8px 14px;
		font-size: 13px;
		font-weight: bold;
		letter-spacing: 1px;
		color: rgba(255, 255, 255, 0.78);
	}

	.mute-btn:hover { color: #ffffff; border-color: rgba(255, 255, 255, 0.3); }
	.mute-btn.muted { color: #ffd166; }
	.board.hourly { right: 308px; } /* 14 (edge) + 280 (hours-won) + 14 (gap) */
	.board.hourswon { right: 14px; }
	/* sessions-won pins to the free bottom-right corner (top:auto cancels the
	   shared top:18px) so the three right-hand boards never crowd the button. */
	.board.sessionswon { top: auto; bottom: 18px; right: 14px; }

	.board .title {
		font-size: 14px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.85);
		letter-spacing: 1px;
		justify-content: center;
		margin-bottom: 4px;
	}

	.board .reset {
		font-size: 11px;
		color: rgba(255, 255, 255, 0.5);
		justify-content: center;
		margin-top: -2px;
		margin-bottom: 4px;
	}

	.board .divider {
		height: 1px;
		background-color: rgba(255, 255, 255, 0.1);
		margin-bottom: 5px;
	}

	.board .row {
		flex-direction: row;
		align-items: center;
		font-size: 13px;
		color: rgba(255, 255, 255, 0.78);
		padding: 2px 5px;
		border-radius: 4px;
	}

	.board .rank { width: 24px; color: rgba(255, 255, 255, 0.4); font-size: 12px; }
	.board .name { flex-grow: 1; overflow: hidden; white-space: nowrap; }
	.board .pts { color: #06d6a0; font-weight: bold; }
	.board .row.me { background-color: rgba(6, 214, 160, 0.15); }

	/* names are clickable (copy the player's Steam profile link); re-enable
	   pointer events on just these against the pointer-events:none root. */
	.board .name.clickable { pointer-events: all; cursor: pointer; }
	.board .name.clickable:hover { color: #ffffff; text-decoration: underline; }

	/* hours-won is the gold board */
	.board.hourswon .pts { color: #ffd166; }
	.board.hourswon .row.me { background-color: rgba(255, 209, 102, 0.15); }

	/* sessions-won is the blue board */
	.board.sessionswon .pts { color: #4cc9f0; }
	.board.sessionswon .row.me { background-color: rgba(76, 201, 240, 0.15); }

	/* the leader's "skin": bundled image + caption, pinned just left of the
	   sessions-won board (308 = 14 edge + 280 board + 14 gap), bottom-aligned. */
	.skin-panel {
		position: absolute;
		bottom: 18px;
		right: 308px;
		width: 200px;
		flex-direction: column;
		align-items: center;
		background-color: rgba(7, 5, 26, 0.82);
		border: 1.5px solid rgba(255, 255, 255, 0.12);
		border-radius: 10px;
		padding: 10px;
		gap: 6px;
	}

	/* the image scales to fit the panel width (contain keeps its aspect ratio). */
	.skin-img {
		width: 100%;
		height: 240px;
		background-image: url( "media/firstskintowin.png" );
		background-repeat: no-repeat;
		background-position: center;
		background-size: contain;
	}

	.skin-caption {
		font-size: 15px;
		font-weight: bold;
		color: #4cc9f0;
		justify-content: center;
		text-align: center;
	}

	.board .empty {
		justify-content: center;
		font-size: 12px;
		color: rgba(255, 255, 255, 0.35);
		margin-top: 4px;
	}

	/* ── center column: full-screen overlay that centers the button ── */
	.center {
		position: absolute;
		top: 0;
		left: 0;
		right: 0;
		bottom: 0;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		pointer-events: none;
	}

	/* Fixed square (ScreenPanel auto-scales to a 1080-tall reference) so the button
	   is a true circle, centered, at ~70% of screen height. */
	.btn {
		width: 700px;
		height: 700px;
		border-radius: 50%;
		background-color: #e63946; /* red: armed race not open (idle clicks still register) */
		border: 8px solid rgba(255, 255, 255, 0.14);
		transition: all 0.08s ease-out;
		pointer-events: all;
		/* the button is always pressable — idle clicks are a real (penalised) input —
		   so it always reads as clickable, not just when green. */
		cursor: pointer;
	}

	/* press feedback in any state, so a red-button click visibly does something. */
	.btn:active { transform: scale(0.98); }

	.btn.armed {
		background-color: #06d6a0; /* green only while a valid click can score */
		border-color: #ffffff;
		cursor: pointer;
		transform: scale(1.02);
	}

	.btn.armed:active { transform: scale(0.98); }

	/* WAIT (arming): yellow-orange. */
	.btn.wait { background-color: #ff9f1c; }

	/* ROUND OVER: start yellow-orange, then fade to black over 3s and hold there. */
	.btn.result {
		background-color: #000000;
		animation-name: round-over-fade;
		animation-duration: 3s;
		animation-timing-function: ease-out;
		animation-fill-mode: forwards;
	}

	@* @@ escapes the @ so Razor emits a literal @keyframes rather than parsing it as code *@
	@@keyframes round-over-fade {
		0% { background-color: #e63946; }
		100% { background-color: #000000; }
	}

	/* HUD overlaid dead-center on the button; clicks fall through to the button. */
	.hud {
		position: absolute;
		top: 0;
		left: 0;
		right: 0;
		bottom: 0;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		gap: 10px;
		pointer-events: none;
	}

	.round {
		font-size: 22px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.85);
		letter-spacing: 1px;
	}

	.state {
		font-size: 54px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.9);
		letter-spacing: 3px;
	}

	.state.armed { color: #ffffff; }
	.state.offline { color: #ffd166; }

	.meta {
		flex-direction: row;
		gap: 10px;
		align-items: center;
		font-size: 18px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.85);
	}

	.meta .sep { color: rgba(255, 255, 255, 0.4); }

	.arm {
		font-size: 16px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.6);
		letter-spacing: 1px;
	}

	.penalty {
		font-size: 16px;
		font-weight: bold;
		color: #ffffff;
	}

	/* HUD text overlaid on the button is black on the yellow-orange WAIT background
	   (for contrast) and white in every other state. These high-specificity rules
	   win over the per-element colors above. (clicks-sent sits *below* the button on
	   the dark page background, so it stays white — excluded here.) */
	.center.wait .round,
	.center.wait .state,
	.center.wait .meta,
	.center.wait .meta .sep,
	.center.wait .arm,
	.center.wait .penalty { color: #000000; }

	/* Centered just under the circle: the button is 700px (350px radius) centered
	   on screen, so 360px below screen-center sits 10px below its bottom edge. */
	.clicks-sent {
		position: absolute;
		top: 50%;
		margin-top: 360px;
		left: 0;
		right: 0;
		justify-content: center;
		font-size: 22px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.65);
		letter-spacing: 1px;
		pointer-events: none;
	}

	/* transient confirmation that a profile link was copied; top-center, fades by
	   simply un-rendering after ToastDuration (see BuildHash). */
	.toast {
		position: absolute;
		top: 40px;
		left: 0;
		right: 0;
		justify-content: center;
		font-size: 20px;
		font-weight: bold;
		color: #4cc9f0;
		letter-spacing: 1px;
		pointer-events: none;
	}
</style>

@code {
	const int DisplayLimit = 10;
	const float RefreshInterval = 20f;

	ClickController C => ClickController.Instance;

	// ── session: live standings pushed inside round_result/game_over (never fetched) ──
	static List<Standing> SessionEntries => ClickController.Instance?.Standings ?? new List<Standing>();

	// ── hourly + hours-won: fetched over HTTP, deliberately infrequently (once on
	//    connect, then when a round closes, throttled) so neither becomes a fan-in
	//    GET stampede. The hot click path never touches HTTP. ──
	List<Standing> _hourly = new();
	bool _hourlyLoaded, _hourlyFetching;
	RealTimeSince _hourlyLast;

	List<Standing> _hoursWon = new();
	bool _hoursWonLoaded, _hoursWonFetching;
	RealTimeSince _hoursWonLast;

	List<Standing> _sessionsWon = new();
	bool _sessionsWonLoaded, _sessionsWonFetching;
	RealTimeSince _sessionsWonLast;

	GamePhase _lastPhase = GamePhase.Connecting;

	// ── "copied profile link" toast: shown for ToastDuration after a name click ──
	const float ToastDuration = 1.2f;
	string _toast = "";
	RealTimeSince _toastSince = 1000f; // start expired
	bool ToastVisible => _toastSince < ToastDuration;

	protected override void OnTreeFirstBuilt()
	{
		_ = RefreshHourly();
		_ = RefreshHoursWon();
		_ = RefreshSessionsWon();
	}

	protected override void OnUpdate()
	{
		var phase = C?.Phase ?? GamePhase.Connecting;
		// Refresh when a round just closed (player is reading scores between presses),
		// throttled so rapid result→pending churn can't spam the endpoints.
		bool roundJustClosed = phase != _lastPhase &&
			( phase == GamePhase.Result || phase == GamePhase.GameOver );
		_lastPhase = phase;
		if ( roundJustClosed )
		{
			if ( _hourlyLast > RefreshInterval ) _ = RefreshHourly();
			if ( _hoursWonLast > RefreshInterval ) _ = RefreshHoursWon();
			if ( _sessionsWonLast > RefreshInterval ) _ = RefreshSessionsWon();
		}
	}

	async Task RefreshHourly()
	{
		if ( _hourlyFetching ) return;
		_hourlyFetching = true;
		_hourlyLast = 0;
		try
		{
			var board = await ApiClient.GetHourlyLeaderboard( DisplayLimit );
			_hourly = board ?? new List<Standing>();
			_hourlyLoaded = true;
			StateHasChanged();
		}
		finally { _hourlyFetching = false; }
	}

	async Task RefreshHoursWon()
	{
		if ( _hoursWonFetching ) return;
		_hoursWonFetching = true;
		_hoursWonLast = 0;
		try
		{
			var board = await ApiClient.GetHoursWonLeaderboard( DisplayLimit );
			_hoursWon = board ?? new List<Standing>();
			_hoursWonLoaded = true;
			StateHasChanged();
		}
		finally { _hoursWonFetching = false; }
	}

	async Task RefreshSessionsWon()
	{
		if ( _sessionsWonFetching ) return;
		_sessionsWonFetching = true;
		_sessionsWonLast = 0;
		try
		{
			var board = await ApiClient.GetSessionsWonLeaderboard( DisplayLimit );
			_sessionsWon = board ?? new List<Standing>();
			_sessionsWonLoaded = true;
			StateHasChanged();
		}
		finally { _sessionsWonFetching = false; }
	}

	// Copy the clicked player's public Steam community profile link to the
	// clipboard and flash a short confirmation toast. SteamID64 is public, so the
	// link works for anyone; no-op if the row carries no id.
	void CopyProfile( Standing s )
	{
		if ( s == null || string.IsNullOrEmpty( s.SteamId ) ) return;
		var url = $"https://steamcommunity.com/profiles/{s.SteamId}";
		try { Clipboard.SetText( url ); }
		catch ( Exception e ) { Log.Warning( $"[Splitclicker] clipboard copy failed: {e.Message}" ); }
		_toast = $"Copied {DisplayName( s )}'s profile link";
		_toastSince = 0f;
		StateHasChanged();
	}

	static bool IsMe( Standing s )
	{
		var tag = ClickController.Instance?.Tag;
		return !string.IsNullOrEmpty( tag ) && s.Tag == tag;
	}

	// Show the player's name (claimed username or Steam display name, resolved
	// server-side), never the opaque hex tag.
	static string DisplayName( Standing s ) =>
		!string.IsNullOrWhiteSpace( s.Username ) ? s.Username : "anonymous";

	// The "current leader" for the skin caption: the sessions-won #1, falling back
	// to the live session leader, then to a placeholder when nobody has scored.
	string SkinLeaderName()
	{
		if ( _sessionsWon.Count > 0 ) return DisplayName( _sessionsWon[0] );
		var session = SessionEntries;
		if ( session.Count > 0 ) return DisplayName( session[0] );
		return "Nobody";
	}

	// Wall-clock time left until the UTC hour ticks over (when the hourly board
	// resets), as MM:SS — not a rolling 60-minute window.
	static string ResetCountdown()
	{
		var now = DateTime.UtcNow;
		var nextHour = now.Date.AddHours( now.Hour + 1 );
		var left = nextHour - now;
		return $"{(int)left.TotalMinutes:D2}:{left.Seconds:D2}";
	}

	bool Armed => C?.CanClick ?? false;

	void OnPress() => ClickController.Instance?.SendClick();

	// ── music mute toggle ──
	static bool IsMuted => MusicController.Instance?.Muted ?? false;
	void ToggleMute() => MusicController.Instance?.ToggleMute();
	// Label states the action a click performs.
	static string MuteLabel() => IsMuted ? "UNMUTE MUSIC" : "MUTE MUSIC";

	// Button/center state class drives the colour:
	//   armed  → green (a valid click can score)
	//   wait   → yellow-orange (arming)
	//   result → yellow-orange fading to black over 3s
	//   ""     → red base (connecting / disconnected / game over)
	// Armed takes precedence so the green only shows while a click can actually score.
	string StateClass()
	{
		if ( Armed ) return "armed";
		return C?.Phase switch
		{
			GamePhase.Pending => "wait",
			GamePhase.Result => "result",
			_ => "",
		};
	}

	string PhaseText() => C?.Phase switch
	{
		GamePhase.Armed => "CLICK!",
		GamePhase.Pending => "WAIT…",
		GamePhase.Result => "ROUND OVER",
		GamePhase.GameOver => "GAME OVER",
		GamePhase.Disconnected => "RECONNECTING…",
		GamePhase.Connecting => "CONNECTING…",
		_ => "",
	};

	string PhaseClass() => C?.Phase switch
	{
		GamePhase.Armed => "armed",
		GamePhase.Disconnected => "offline",
		_ => "",
	};

	// Include the seconds-remaining so the countdown re-renders as it ticks, and
	// ToastVisible so the copied-link toast appears and clears itself on time.
	protected override int BuildHash() => HashCode.Combine(
		HashCode.Combine( C?.Phase ?? GamePhase.Connecting, C?.Round ?? 0, C?.Of ?? 0,
			C?.Players ?? 0, C?.ClicksToWin ?? 0, C?.PenaltyMs ?? 0, C?.ClicksSent ?? 0 ),
		HashCode.Combine( SessionEntries.Count, _hourly.Count, _hourlyLoaded, _hoursWon.Count, _hoursWonLoaded,
			C?.ArmMaxSec ?? 0, C?.Tag, (int)(DateTime.UtcNow - DateTime.UtcNow.Date).TotalSeconds ),
		HashCode.Combine( _sessionsWon.Count, _sessionsWonLoaded, ToastVisible, SkinLeaderName(), IsMuted ) );
}