UI/GameHud.razor

Razor UI component for the in-game HUD. Renders top status/subtitle/timer, players panel when applicable, system controls (forfeit/tutorial), action buttons grouped into runs, end-of-game and quit modals, tutorial modal and announcement banner. Manages modal open/close state, end-screen timing, and computes repaint hash for efficient UI updates.

NetworkingFile Access
@namespace CardGames
@using Sandbox
@using Sandbox.UI
@using System
@using System.Linq
@using System.Collections.Generic
@inherits Panel

@*
	The in-game HUD. Three zones: the status (and, for poker, the pot) up top; the players panel down the
	left; the action controls along the bottom. System controls (Forfeit) sit in the top-right corner so
	they're never mixed in with the moves you actually make.
*@

<root class="cgui">
	<div class="top">
		@if ( !string.IsNullOrEmpty( Status ) )
		{
			<div class="statuscard">
				<div class="cg-status">@Status</div>
				@if ( TimerFraction > 0f )
				{
					<div class="timerbar"><div class="fill" style="width: @(TimerPercent)%;"></div></div>
				}
			</div>
		}
		@if ( !string.IsNullOrEmpty( Subtitle ) || !string.IsNullOrEmpty( SubtitleValue ) )
		{
			<div class="subtitle">
				@if ( !string.IsNullOrEmpty( Subtitle ) ) { <span class="sub-label">@Subtitle</span> }
				@if ( !string.IsNullOrEmpty( SubtitleValue ) ) { <span class="sub-value">@SubtitleValue</span> }
			</div>
		}
	</div>

	@if ( Game is SeatedGame )
	{
		<Players />
	}

	@if ( ShowForfeit || HasTutorial )
	{
		<div class="system">
			@if ( HasTutorial )
			{
				<button class="cg-btn ghost icon" onclick=@(() => _tutorialOpen = true)>
					<span class="ico">help</span>
				</button>
			}
			@if ( ShowForfeit )
			{
				<button class="cg-btn ghost icon" onclick=@(() => _confirmQuit = true)>
					<span class="ico">logout</span>
				</button>
			}
		</div>
	}

	<div class="bottom">
		@if ( !string.IsNullOrEmpty( Game?.HintText ) )
		{
			<div class="cg-hint"><div class="body">@Game.HintText</div></div>
		}

		@if ( Game is null )
		{
			<div class="cg-note">Waiting for the host to pick a game…</div>
		}
		else if ( Game.State == GameState.Waiting )
		{
			@if ( Networking.IsHost )
			{
				<div class="cg-bar">
					<button class="cg-btn primary" onclick=@(() => Director?.DealRound())>Deal</button>
					<button class="cg-btn ghost" onclick=@(() => Director?.QuitGame())>Return</button>
				</div>
			}
			else
			{
				<div class="cg-note">Seated. Waiting for the host to deal…</div>
			}
		}
		else
		{
			<div class="cg-bar">
				@foreach ( var run in ActionRuns() )
				{
					@if ( run.Count > 1 )
					{
						<div class="cg-group">
							@for ( int i = 0; i < run.Count; i++ )
							{
								var a = run[i];
								var cls = ButtonClass( a ) + (i == 0 ? " lead" : "");
								<button class="@cls" onclick=@(() => Game.SendAction( a.Id, a.Amount ))>
									@if ( !string.IsNullOrEmpty( a.Icon ) ) { <span class="ico">@a.Icon</span> }
									@if ( !string.IsNullOrEmpty( a.Label ) ) { <span>@a.Label</span> }
									@if ( !string.IsNullOrEmpty( a.Key ) ) { <span class="cg-key">@a.Key</span> }
								</button>
							}
						</div>
					}
					else
					{
						var a = run[0];
						<button class="@ButtonClass( a )" onclick=@(() => Game.SendAction( a.Id, a.Amount ))>
							@if ( !string.IsNullOrEmpty( a.Icon ) ) { <span class="ico">@a.Icon</span> }
							@if ( !string.IsNullOrEmpty( a.Label ) ) { <span>@a.Label</span> }
							@if ( !string.IsNullOrEmpty( a.Key ) ) { <span class="cg-key">@a.Key</span> }
						</button>
					}
				}
			</div>
		}
	</div>

	@if ( _quitOpen && Networking.IsHost )
	{
		<div class="cg-modal @(_quitClosing ? "closing" : "")">
			<div class="dialog cg-panel">
				<div class="cg-display">Leave game?</div>
				<div class="cg-body">Quit this game and return to the menu?</div>
				<div class="row">
					<button class="cg-btn primary" onclick=@Quit>Quit</button>
					<button class="cg-btn ghost" onclick=@(() => _confirmQuit = false)>Cancel</button>
				</div>
			</div>
		</div>
	}

	@if ( _endOpen )
	{
		var end = _end;
		<div class="cg-modal @(_endClosing ? "closing" : "")">
			<div class="dialog cg-panel end @ToneClass( end.Tone )">
				<div class="cg-eyebrow">@ToneLabel( end.Tone )</div>
				<div class="cg-display">@end.Title</div>
				@if ( !string.IsNullOrEmpty( end.Detail ) )
				{
					<div class="cg-body">@end.Detail</div>
				}
				<div class="row">
					@foreach ( var a in EndActions( end ) )
					{
						var action = a;
						<button class="@EndButtonClass( action )" onclick=@(() => RunEnd( action ))>@action.Label</button>
					}
				</div>
				@if ( !Networking.IsHost )
				{
					<div class="cg-note">Waiting for the host…</div>
				}
			</div>
		</div>
	}

	@if ( _tutorialOpen && HasTutorial )
	{
		<TutorialModal [email protected] [email protected] OnClose=@(() => { _tutorialOpen = false; }) />
	}

	@if ( Game is { AnnounceActive: true } announcing )
	{
		<div class="announce">
			<div class="banner">@announcing.AnnounceText</div>
		</div>
	}

	@*
		A client briefly has the game object before its cloud package finishes downloading - cover the
		table until the definition (deck, art, scale) resolves, so we never flash a half-dressed game.
	*@
	@if ( Game is { DefinitionReady: false } )
	{
		<div class="loading-cover">
			<div class="cg-note">Loading game…</div>
		</div>
	}
</root>

@code
{
	private GameDirector _director;
	private GameDirector Director => _director ??= Sandbox.Game.ActiveScene?.Get<GameDirector>();
	private CardGame Game => Director?.ActiveGame;
	private string Status => Game?.StatusText ?? "Pick a card game to begin.";
	private string Subtitle => Game?.Subtitle ?? "";
	private string SubtitleValue => Game?.SubtitleValue ?? "";
	private float TimerFraction => Game?.TurnFraction ?? 0f;
	private string TimerPercent => (TimerFraction * 100f).ToString( "0" );

	private bool _tutorialOpen;                // the "How to play" overlay (anyone can open it when a tutorial exists)
	private bool HasTutorial => !string.IsNullOrEmpty( Game?.Definition?.Tutorial );

	private bool _confirmQuit;                 // forfeit opens a confirm modal instead of quitting outright
	private bool _quitOpen;                    // the confirm modal's DOM is present (animates in/out around _confirmQuit)
	private bool _quitClosing;
	private RealTimeSince _quitCloseStarted;

	// The host can forfeit during live play (not in the lobby, and not while the end-screen is up).
	private bool ShowForfeit => Networking.IsHost && Game is not null
		&& Game.State != GameState.Waiting && (Game.State != GameState.GameOver || _endDismissed);

	private void Quit()
	{
		_confirmQuit = false;
		Director?.QuitGame();
	}

	// - End-of-game overlay -

	private bool _endDismissed; // local "Keep Playing" - hides the overlay for this client until the next game-over

	// The end overlay opens a beat after game-over (so it doesn't slam in), fades+scales in, and plays a
	// matching fade-out before it unmounts. This little state machine drives that; _end is the snapshot shown.
	private const float EndDelay = 0.5f;  // game-over → overlay appears
	private const float EndOutro = 0.3f;  // fade-out duration before it's removed (matches the CSS)
	private bool _wasGameOver;
	private bool _endOpen;     // the overlay DOM is present
	private bool _endClosing;  // it's animating out
	private RealTimeSince _enteredGameOver;
	private RealTimeSince _closeStarted;
	private EndScreen _end;    // captured when the overlay opens, so the outro keeps showing the final result

	private static string ToneClass( EndTone t ) => t switch { EndTone.Win => "win", EndTone.Loss => "loss", _ => "" };
	private static string ToneLabel( EndTone t ) => t switch { EndTone.Win => "Victory", EndTone.Loss => "Defeat", _ => "Game Over" };
	private static string EndButtonClass( EndAction a ) => a.Primary ? "cg-btn primary" : "cg-btn";

	// Non-host players can dismiss their own overlay but can't drive the round - only Keep Playing shows.
	private IEnumerable<EndAction> EndActions( EndScreen end )
	{
		var actions = end.Actions ?? Array.Empty<EndAction>();
		return Networking.IsHost ? actions : actions.Where( a => a.Kind == EndActionKind.KeepPlaying );
	}

	private void RunEnd( EndAction a )
	{
		switch ( a.Kind )
		{
			case EndActionKind.PlayAgain: Director?.DealRound(); break;
			case EndActionKind.MainMenu: Director?.QuitGame(); break;
			case EndActionKind.KeepPlaying: _endDismissed = true; break;
			case EndActionKind.Custom: Game?.SendEndAction( a.Id ); break;
		}
	}

	public override void Tick()
	{
		base.Tick();

		bool gameOver = Game?.State == GameState.GameOver;

		// Entering game-over: start the delay timer and re-arm the overlay.
		if ( gameOver && !_wasGameOver )
		{
			_enteredGameOver = 0f;
			_endDismissed = false;
			_endOpen = false;
			_endClosing = false;
		}
		_wasGameOver = gameOver;

		// Re-arm once the next round is under way.
		if ( !gameOver ) _endDismissed = false;

		// Open a beat after game-over (snapshotting the result so the outro can outlive the state change).
		if ( gameOver && !_endDismissed && !_endOpen && !_endClosing && _enteredGameOver > EndDelay )
		{
			_end = Game.EndScreen;
			_endOpen = true;
		}

		// Begin the outro when it should no longer be up (dismissed, or the game moved on).
		if ( _endOpen && !_endClosing && (!gameOver || _endDismissed) )
		{
			_endClosing = true;
			_closeStarted = 0f;
		}

		// Remove it once the outro has played.
		if ( _endClosing && _closeStarted > EndOutro )
		{
			_endOpen = false;
			_endClosing = false;
		}

		// The forfeit confirm modal animates in/out the same way around the _confirmQuit intent flag.
		if ( _confirmQuit && !_quitOpen && !_quitClosing ) _quitOpen = true;
		if ( _quitOpen && !_quitClosing && !_confirmQuit ) { _quitClosing = true; _quitCloseStarted = 0f; }
		if ( _quitClosing && _quitCloseStarted > EndOutro ) { _quitOpen = false; _quitClosing = false; }
	}

	// "cg-btn" plus the modifiers an action needs: primary accent, and compact "icon" sizing when it's
	// icon-only (an Icon with no Label).
	private string ButtonClass( GameAction a )
	{
		var cls = "cg-btn";
		if ( a.Primary ) cls += " primary";
		if ( !string.IsNullOrEmpty( a.Icon ) && string.IsNullOrEmpty( a.Label ) ) cls += " icon";
		if ( a.Disabled ) cls += " disabled";
		return cls;
	}

	// Chunk the actions into runs: consecutive actions sharing a non-empty Group cluster together;
	// everything else is its own single-action run (rendered as a standalone button).
	private List<List<GameAction>> ActionRuns()
	{
		var runs = new List<List<GameAction>>();
		if ( Game is null ) return runs;

		// Variable-amount actions (e.g. a no-limit raise) aren't simple buttons - their game's own UI renders them.
		foreach ( var a in Game.ActionsFor( Game.LocalSeat ).Where( a => !a.Variable ) )
		{
			var last = runs.Count > 0 ? runs[^1] : null;
			if ( !string.IsNullOrEmpty( a.Group ) && last is not null && last[0].Group == a.Group )
				last.Add( a );
			else
				runs.Add( new List<GameAction> { a } );
		}
		return runs;
	}

	protected override int BuildHash() => HashCode.Combine(
		// repaint on phase changes (e.g. into GameOver) so the end overlay appears even when the game has no
		// action buttons; DefinitionReady drops the loading cover once a cloud game's package has mounted
		HashCode.Combine( Game?.State, Game?.DefinitionReady ),
		HashCode.Combine( Game?.Subtitle, Game?.SubtitleValue, (int)MathF.Ceiling( Game?.TurnSecondsLeft ?? 0f ) ), // timer repaints ~1/s; CSS smooths it
		ActionsHash(),
		Game?.LocalSeat?.Currency, Game?.LocalSeat?.Wager,
		(Game as BoardGame)?.CanUndo, (Game as BoardGame)?.CanRedo,
		HashCode.Combine( _confirmQuit, _quitOpen, _quitClosing, _endDismissed, _endOpen, _endClosing, _tutorialOpen, HashCode.Combine( Game?.AnnounceActive, Game?.AnnounceId ) ) ); // AnnounceId restarts the banner animation

	// Fold the actions' contents in (not just the count), so the bar repaints when a label/amount changes
	// (e.g. the poker raise stepper) and not only when buttons are added or removed.
	private int ActionsHash()
	{
		if ( Game is null ) return 0;
		int h = 0;
		foreach ( var a in Game.ActionsFor( Game.LocalSeat ) )
			h = HashCode.Combine( h, a.Id, a.Label, a.Amount, a.Disabled, a.Primary );
		return h;
	}
}