A Razor UI panel (GameLauncher) for the card-games project, rendering a sidebar of local and community games, a hero pane with details for the focused game, inline session previews for multiplayer games, and actions to play or browse. It fetches community packages, queries lobbies for previews, mounts community game definitions for richer metadata, and starts or joins games via GameDirector and Networking APIs.
@namespace CardGames
@using Sandbox
@using Sandbox.UI
@using Sandbox.Network
@using System
@using System.Linq
@using System.Collections.Generic
@using System.Threading
@inherits Panel
@*
The launcher - shown to the host while no game is running. A main-menu page: a sidebar lists every
game (built-in first, then trending games fetched from the cloud), the hero pane shows
whichever one is selected, and "Browse Games" routes into the full games browser for everything
else. Selecting a sidebar tile just focuses it; Play actually starts it. A focused multiplayer game's
open tables (if any) list inline in the bottom-right column, above its stats - join one directly, or
just hit Play to host a fresh one.
*@
<root class="cgui menu">
<div class="sidebar">
<div class="tiles">
@for ( int i = 0; i < EntryCount; i++ )
{
var idx = i;
var entry = EntryAt( idx );
var pip = PipFor( entry );
<button class="tile @(idx == _focusedIndex ? "selected" : "")" onclick=@(() => Select( idx ))>
<div class="art">
@if ( !string.IsNullOrEmpty( entry.Package?.ThumbWide ) )
{
<img class="shot" src="@entry.Package.ThumbWide" />
}
else
{
<span class="pip @(pip.Red ? "red" : "black")">@pip.Suit</span>
}
</div>
<span class="label">@Title( entry )</span>
</button>
}
</div>
<button class="tile browse" onclick=@(() => Director?.BrowseCommunity())>
<div class="art"><i class="ico">casino</i></div>
<label class="text">Browse Games</label>
</button>
</div>
<div class="hero">
<div class="brand">
<div class="marks">
<span class="mark">The</span>
<span class="mark gold">Deck</span>
</div>
<div class="tagline">Your one-stop shop for all things card games.</div>
</div>
<div class="focus">
@if ( Focused is { } f )
{
<div class="tags">
@foreach ( var tag in f.Package?.Tags ?? Enumerable.Empty<string>() )
{
<span class="cg-tag mode">@tag</span>
}
</div>
<div class="title">@Title( f )</div>
@if ( IsMounted( f ) )
{
<div class="meta">
<span class="item"><i class="ico">group</i>@PlayersLabel( f )</span>
</div>
}
else
{
<div class="sk sk-meta"></div>
}
<div class="desc">@Description( f )</div>
<div class="actions">
@if ( IsBroken( f ) )
{
<button class="cg-btn primary play disabled" disabled>
<span class="ico">error</span>Game Error, Can't Play
</button>
}
else
{
<button class="cg-btn primary play @(IsLoading( f ) ? "disabled" : "")" onclick=@(() => ActivateFocused())>
<span class="ico">play_arrow</span>@(IsLoading( f ) ? "Loading…" : "Play")
</button>
}
</div>
@if ( _busy )
{
<div class="cg-note">Finding games…</div>
}
else if ( !string.IsNullOrEmpty( _error ) && _error != GameDirector.StartFailedError )
{
<div class="cg-note">@_error</div>
}
}
else
{
<div class="cg-note">
@(_busy ? "Finding games…" : !string.IsNullOrEmpty( _error ) ? _error : "No games yet — check back soon.")
</div>
}
</div>
</div>
@if ( Focused is { } side )
{
<div class="side">
@if ( IsMultiplayer( side ) )
{
<div class="sessions">
<div class="sessions-head">
<span class="cg-eyebrow">Sessions</span>
<button class="cg-btn primary icon" onclick=@RefreshPreview>
<span class="ico">refresh</span>
</button>
</div>
@if ( _previewLoadedFor != Title( side ) )
{
<div class="rows">
<div class="row skeleton">
<div class="who">
<span class="sk sk-dot"></span>
<span class="sk sk-name"></span>
</div>
<div class="join">
<span class="sk sk-btn"></span>
</div>
</div>
</div>
}
else
{
<div class="rows">
@if ( _previewSessions.Count > 0 )
{
@foreach ( var s in _previewSessions )
{
var session = s;
<div class="row">
<div class="who">
<span class="dot"></span>
@if ( IsFriendHost( session ) )
{
<span class="ico friend">star</span>
}
<span class="name">@HostName( session )</span>
</div>
<div class="join">
<span class="meta">@session.Members/@session.MaxMembers</span>
<button class="cg-btn primary @(session.IsFull ? "disabled" : "")" onclick=@(() => JoinSession( session ))>@(session.IsFull ? "Full" : "Join")</button>
</div>
</div>
}
}
else
{
<div class="row empty">No active sessions</div>
}
</div>
}
</div>
}
<div class="stats">
<div class="stats-head">
<span class="cg-eyebrow">Stats</span>
</div>
<div class="rows">
@foreach ( var (label, value) in RealStats( side ) )
{
<div class="row">
<label class="k">@label</label>
<label class="v">@value</label>
</div>
}
</div>
</div>
</div>
}
</root>
@code
{
private GameDirector _director;
private GameDirector Director => _director ??= Sandbox.Game.ActiveScene?.Get<GameDirector>();
private IReadOnlyList<GameDefinition> Games => Director?.AvailableGames ?? Array.Empty<GameDefinition>();
// If this build ships any local (non-cloud) game, the sidebar sticks to just those - no point
// padding it out with trending cloud games when there's already a home-grown lineup to show.
private bool HasLocalGames => Games.Any( g => !g.IsRemote );
private int _focusedIndex;
private int EntryCount => Games.Count + (HasLocalGames ? 0 : _community.Count);
private readonly record struct LauncherEntry( GameDefinition Game, Package Package );
// How many trending games to show in the sidebar; "Browse Games" opens the full browser for the rest.
private const int CommunityCount = 8;
private List<Package> _community = new();
private bool _busy = true; // fetching on first show
private string _error = "";
private string _loadingIdent; // the package currently being started (Play pressed)
private string _brokenIdent; // a package that failed to start with GameDirector.StartFailedError
private bool _fetched; // kick off the cloud fetch once, after the panel mounts
// A few open tables for the focused multiplayer game, shown inline in the bottom-right column.
// Debounced off the focused title (not the entry itself, since a re-focus of the same game shouldn't
// re-query) - mirrors CommunityBrowser's search debounce. The row list scrolls past a handful rather
// than being capped, so there's no separate "see everything" view to open.
private List<LobbyInformation> _previewSessions = new();
private string _previewTitle; // the title we currently want a preview for (null = none)
private string _previewLoadedFor; // the title _previewSessions actually reflects
private bool _previewPending;
private RealTimeSince _sincePreviewChanged;
private CancellationTokenSource _previewCts;
// A cloud game's GameDefinition, once downloaded+mounted just from being focused (not started) -
// keyed by package ident. Lets the hero/stats panels show the game's own data instead of just the
// package's cloud listing metadata. Local games already have theirs (LauncherEntry.Game).
private readonly Dictionary<string, GameDefinition> _mounted = new();
private readonly HashSet<string> _mounting = new();
private LauncherEntry EntryAt( int index )
{
var localCount = Games.Count;
if ( index < localCount ) return new LauncherEntry( Games[index], null );
return new LauncherEntry( null, _community[index - localCount] );
}
private LauncherEntry? Focused => EntryCount > 0 ? EntryAt( Math.Clamp( _focusedIndex, 0, EntryCount - 1 ) ) : null;
// The richest metadata we have for this entry: its own GameDefinition (local games always have one;
// cloud games get one once EnsureMounted resolves it) - null until then, so callers fall back to
// the package's cloud listing metadata.
private GameDefinition ResolvedDefinition( LauncherEntry entry )
{
if ( entry.Game is { } g ) return g;
if ( entry.Package is { } p && _mounted.TryGetValue( p.FullIdent, out var def ) ) return def;
return null;
}
// Local games always have their GameDefinition; a cloud game only does once EnsureMounted resolves it.
// The hero/stats panels stay hidden (a skeleton shows instead) until this is true, so we never show
// package-only placeholder content that then jumps once the real definition arrives.
private bool IsMounted( LauncherEntry entry ) => ResolvedDefinition( entry ) is not null;
private void Select( int index )
{
if ( index < 0 || index >= EntryCount ) return;
_focusedIndex = index;
StateHasChanged();
EnsureMounted( EntryAt( index ) );
}
private void ClampFocus()
{
_focusedIndex = EntryCount <= 0 ? 0 : Math.Clamp( _focusedIndex, 0, EntryCount - 1 );
}
private void ActivateFocused()
{
if ( Focused is { } f ) Activate( f );
}
private void Activate( LauncherEntry entry )
{
if ( entry.Game is { } game )
{
Director?.StartGame( game.ResourcePath );
return;
}
if ( entry.Package is { } package )
{
SelectCommunity( package );
}
}
public override void Tick()
{
base.Tick();
if ( !_fetched )
{
_fetched = true;
if ( HasLocalGames ) _busy = false;
else FetchCommunity();
}
if ( Focused is { } f )
EnsureMounted( f );
var wantPreview = Focused is { } foc && IsMultiplayer( foc ) ? Title( foc ) : null;
if ( wantPreview != _previewTitle )
{
_previewTitle = wantPreview;
_sincePreviewChanged = 0f;
_previewPending = true;
}
if ( _previewPending && _sincePreviewChanged > 0.3f )
{
_previewPending = false;
RefreshPreview();
}
}
// Queries for the currently-wanted preview title (re-read fresh, not a snapshot - if focus moved again
// during the debounce window, _previewTitle already reflects the newer one by the time this runs).
private async void RefreshPreview()
{
var title = _previewTitle;
_previewCts?.Cancel();
if ( string.IsNullOrEmpty( title ) )
{
_previewSessions = new();
_previewLoadedFor = title;
return;
}
var cts = _previewCts = new CancellationTokenSource();
List<LobbyInformation> found;
try
{
var all = await Networking.QueryLobbies( cts.Token );
found = all
.Where( l => string.Equals( l.Name, title, StringComparison.OrdinalIgnoreCase ) )
// A hand in progress isn't joinable - hide it instead of listing a table you can't sit at.
// Anything else (including a missing tag, from a stale lobby) is treated as open.
.Where( l => l.Get( "state" ) != "in_progress" )
.ToList();
Log.Info(found.Count);
}
catch ( Exception e )
{
found = new();
Log.Warning( e, "Session preview query failed" );
}
// Friends' tables float to the top of the list.
found = found.OrderByDescending( l => IsFriendHost( l ) ).ToList();
// Focus may have moved on again while this was in flight - a newer request already owns the
// result slot, so don't clobber it with this now-stale one.
if ( title != _previewTitle ) return;
_previewSessions = found;
_previewLoadedFor = title;
StateHasChanged();
}
private void JoinSession( LobbyInformation session )
{
if ( session.IsFull ) return;
Networking.Connect( session.LobbyId );
}
// The lobby's own Name is just the game's title (GameDirector names every lobby after whatever game
// it's hosting) - not useful for telling tables apart, so show who's hosting it instead. Guarded for
// the same reason as IsFriendHost below - a bad OwnerId shouldn't take the whole row down with it.
private string HostName( LobbyInformation session )
{
try { return new Friend( session.OwnerId ).Name; }
catch { return "Unknown"; }
}
// A bad/zero OwnerId (seen from dedicated-server entries that never reported one) or a Steamworks
// hiccup can throw here - this runs both mid-sort in RefreshPreview and live during render for the
// star icon, so an unguarded throw either kills the query silently (stuck skeleton) or the whole panel.
private bool IsFriendHost( LobbyInformation session )
{
try { return new Friend( session.OwnerId ).IsFriend; }
catch { return false; }
}
// Download + mount a focused game's cloud package - without starting it - so its GameDefinition (custom
// stat picks, richer listing info) is ready before Play is pressed. Best-effort and silent: a failure
// just leaves the hero/stats panels on the package's cloud metadata; StartCommunityGame does the same
// fetch+mount again (idempotent) and surfaces the real error if Play is actually pressed.
private async void EnsureMounted( LauncherEntry entry )
{
if ( entry.Package is not { } package ) return;
if ( _mounted.ContainsKey( package.FullIdent ) || !_mounting.Add( package.FullIdent ) ) return;
if ( Director is not { } director ) { _mounting.Remove( package.FullIdent ); return; }
var def = await director.MountCommunityGame( package );
_mounting.Remove( package.FullIdent );
if ( def is not null )
{
_mounted[package.FullIdent] = def;
StateHasChanged();
}
}
// Pull our own (Facepunch) card games from the cloud for the sidebar; "Browse Games" opens the full
// games browser with everyone's games. Best-effort - a failure just shows a quiet note.
private async void FetchCommunity()
{
_busy = true;
_error = "";
StateHasChanged();
try
{
var result = await Package.FindAsync( "type:cggame sort:popular org:facepunch", CommunityCount );
_community = result.Packages?.ToList() ?? new();
ClampFocus();
}
catch ( Exception e )
{
_community = new();
ClampFocus();
_error = "Couldn't reach games. Check your connection.";
Log.Warning( e, "Games fetch failed" );
}
_busy = false;
StateHasChanged();
}
// Mount + start the chosen game's cloud package. On success the HUD swaps this panel out; on failure we
// surface the reason in place of the note.
private async void SelectCommunity( Package package )
{
if ( _loadingIdent is not null || Director is null ) return;
_loadingIdent = package.FullIdent;
_error = "";
StateHasChanged();
var error = await Director.StartCommunityGame( package );
_loadingIdent = null;
if ( !string.IsNullOrEmpty( error ) )
{
_error = error;
// A structural problem with the game itself (no prefab / no rules component) - not worth
// retrying, so the Play button switches to a permanent "can't play" state instead of Play/Join.
if ( error == GameDirector.StartFailedError )
_brokenIdent = package.FullIdent;
}
StateHasChanged();
}
private bool IsLoading( LauncherEntry entry ) => entry.Package is { } p && _loadingIdent == p.FullIdent;
private bool IsBroken( LauncherEntry entry ) => entry.Package is { } p && _brokenIdent == p.FullIdent;
private string Title( LauncherEntry entry ) => entry.Game?.DisplayName ?? entry.Package?.Title ?? "";
// Player count comes from the GameDefinition once it's resolved (local games always have one; cloud
// games get one once EnsureMounted mounts their package) - it's the authoritative source. Until a
// cloud game is mounted, fall back to the package's own cloud listing metadata.
private (int Min, int Max) PlayersFor( LauncherEntry entry )
{
if ( ResolvedDefinition( entry ) is { } def ) return (def.MinPlayers, def.MaxPlayers);
if ( entry.Package is { } p ) return (p.Info.MinPlayers, p.Info.MaxPlayers);
return (1, 1);
}
private string PlayersLabel( LauncherEntry entry )
{
var (min, max) = PlayersFor( entry );
return min == max ? $"{min} player{(min == 1 ? "" : "s")}" : $"{min}-{max} players";
}
private bool IsMultiplayer( LauncherEntry entry ) => PlayersFor( entry ).Max > 1;
private string Tag( LauncherEntry entry ) => IsMultiplayer( entry ) ? "Multiplayer" : "Solo";
// The cloud package's own summary/description is the description; local dev games have no package to
// pull one from, so they fall back to a generic line instead of a new GameDefinition field.
private string Description( LauncherEntry entry )
{
if ( entry.Package is { } p )
{
if ( !string.IsNullOrEmpty( p.Summary ) ) return p.Summary;
if ( !string.IsNullOrEmpty( p.Description ) ) return p.Description;
return "A card game.";
}
if ( entry.Game is { } g )
return g.Multiplayer ? "Invite friends to the table and play together." : "A solo card game — deal yourself in.";
return "";
}
// A representative pip per game, keyed by title, with a stable hash-based fallback so unrecognised
// games still get some visual variety instead of all looking the same.
private readonly record struct Pip( string Rank, string Suit, bool Red );
private static readonly (string Suit, bool Red)[] FallbackSuits = { ("♠", false), ("♥", true), ("♦", true), ("♣", false) };
private Pip PipFor( LauncherEntry entry ) => FallbackPip( Title( entry ) );
private static Pip FallbackPip( string name )
{
var s = FallbackSuits[Math.Abs( name?.GetHashCode() ?? 0 ) % FallbackSuits.Length];
return new Pip( "A", s.Suit, s.Red );
}
private IEnumerable<(string Label, string Value)> RealStats( LauncherEntry entry )
{
var custom = ResolvedDefinition( entry )?.Stats?.Where( s => !string.IsNullOrWhiteSpace( s.Key ) ).ToList();
var displays = custom is { Count: > 0 } ? custom : GameStats.DefaultDisplay;
var gameKey = GameStats.KeyFor( entry.Game, entry.Package );
foreach ( var display in displays )
{
yield return (GameStats.Label(display), GameStats.Format(gameKey, display));
}
}
protected override int BuildHash() => HashCode.Combine( EntryCount, _focusedIndex, _busy, _error, _loadingIdent, _brokenIdent, _previewLoadedFor, _previewSessions.Count );
}