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.
@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;
}
}