A Razor UI panel for the game's pre-match setup and lobby. It shows lobby seats, avatars/initials, game settings panel, host-only controls (invite, tutorial, leave, play), and a tutorial modal when opened.
@namespace CardGames
@using Sandbox
@using Sandbox.UI
@using System
@using System.Linq
@using System.Collections.Generic
@inherits Panel
@*
The pre-game setup screen: a dedicated, full-screen menu shown to everyone while a game's in the
Waiting lobby. A multiplayer game gets the full lobby treatment - who's seated, an invite stub, and the
table settings side by side. A game that doesn't support multiplayer has nobody to wait for, so it
keeps the plain settings sheet instead. Only the host can actually act on any of it - everyone else
sees the same page with every control disabled except Leave.
*@
<root class="cgui">
@if ( Game is { SupportsMultiplayer: true } )
{
<div class="head wide">
<span class="cg-eyebrow">Game Lobby</span>
<div class="title">@Title</div>
<div class="cg-note">@SeatedLabel</div>
</div>
<div class="lobby">
<div class="body">
<div class="table-col">
<span class="cg-eyebrow">Table</span>
<div class="seats">
@foreach ( var seat in OccupiedSeats )
{
var s = seat;
<button class="seat-tile @(s.IsBot ? "disabled" : "")" onclick=@(() => ShowProfile( s ))>
<div class="avatar-wrap">
@if ( AvatarUrl( s ) is { } url )
{
<img class="avatar" src="@url" />
}
else
{
<div class="avatar @AvatarColorClass( s )">@Initials( s )</div>
}
</div>
<div class="name">@s.ToString()</div>
<span class="cg-tag seat-badge @BadgeClass( s )">@BadgeText( s )</span>
</button>
}
@for ( int i = 0; i < OpenSeatCount; i++ )
{
<button class="seat-tile open @(IsHost ? "" : "disabled")" onclick=@InviteFriends>
<div class="avatar-wrap"><span class="ico">add</span></div>
<div class="name">Open seat</div>
</button>
}
</div>
</div>
<div class="settings-col">
@if ( GameSettings.Any( Game ) )
{
<GameSettingsPanel Game=@Game />
}
else
{
<div class="cg-note">No options to set for this game — deal when you're ready.</div>
}
</div>
</div>
<div class="actions">
@if ( HasTutorial )
{
<button class="cg-btn ghost icon @(IsHost ? "" : "disabled")" onclick=@(() => _tutorialOpen = true)>
<span class="ico">help</span>
</button>
}
<div class="spacer"></div>
<button class="cg-btn ghost" onclick=@Leave>Leave</button>
<button class="cg-btn primary @(IsHost && CanStart ? "" : "disabled")" onclick=@(() => Director?.DealRound())>Play</button>
</div>
</div>
}
else
{
<div class="head">
<span class="cg-eyebrow">Game Setup</span>
<div class="title">@Title</div>
</div>
<div class="sheet">
@if ( Game is not null && GameSettings.Any( Game ) )
{
<GameSettingsPanel Game=@Game />
}
else
{
<div class="cg-note">No options to set for this game — deal when you're ready.</div>
}
<div class="actions">
@if ( HasTutorial )
{
<button class="cg-btn ghost icon @(IsHost ? "" : "disabled")" onclick=@(() => _tutorialOpen = true)>
<span class="ico">help</span>
</button>
}
<div class="spacer"></div>
<button class="cg-btn ghost" onclick=@Leave>Leave</button>
<button class="cg-btn primary @(IsHost ? "" : "disabled")" onclick=@(() => Director?.DealRound())>Play</button>
</div>
</div>
}
@if ( _tutorialOpen && HasTutorial )
{
<TutorialModal Title=@Title [email protected] OnClose=@(() => { _tutorialOpen = false; }) />
}
</root>
@code
{
private GameDirector _director;
private GameDirector Director => _director ??= Sandbox.Game.ActiveScene?.Get<GameDirector>();
private CardGame Game => Director?.ActiveGame;
private string Title => Game?.Title ?? "New Game";
// Only the host can act on any control here (settings, seats, Return, Play) - everyone else sees the
// same page with everything disabled and just a way out.
private bool IsHost => Networking.IsHost;
private bool _tutorialOpen;
private bool HasTutorial => !string.IsNullOrEmpty( Game?.Definition?.Tutorial );
// - Lobby (multiplayer games only) -
private List<Seat> OccupiedSeats => Game?.Seats.Where( s => s.Occupied ).OrderBy( s => s.Index ).ToList() ?? new();
private int MaxPlayers => Game?.MaxPlayers ?? 0;
private int OpenSeatCount => Math.Max( 0, MaxPlayers - OccupiedSeats.Count );
private string SeatedLabel => $"{OccupiedSeats.Count}/{MaxPlayers} seated";
// The host can deal once enough seats are filled - StartRound already guards this host-side; the button
// just reflects it so it doesn't look clickable when it wouldn't do anything.
private bool CanStart => Game is not null && OccupiedSeats.Count >= Game.MinPlayers;
// A little variety for the fallback (no-avatar) discs, keyed off the seat's stable join order.
private static readonly string[] AvatarColorClasses = { "c0", "c1", "c2", "c3" };
private string AvatarColorClass( Seat seat ) => AvatarColorClasses[seat.Index % AvatarColorClasses.Length];
private bool IsHostSeat( Seat seat ) => seat.Player == Connection.Host?.Id;
// A bot has no connection to fetch an avatar for; a human's Steam avatar loads by their connection's id.
private string AvatarUrl( Seat seat )
{
if ( seat.IsBot ) return null;
var conn = Connection.Find( seat.Player );
return conn is null ? null : $"avatar:{conn.SteamId}";
}
// Up to two initials from the seat's display name - one per word for a full name, or the first two
// letters of a single word (matches the "You" / "PR" style in the lobby mock-up).
private string Initials( Seat seat )
{
var name = seat.ToString();
if ( string.IsNullOrWhiteSpace( name ) ) return "?";
var words = name.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
var initials = words.Length > 1
? string.Concat( words.Take( 2 ).Select( w => w[0] ) )
: words[0][..Math.Min( 2, words[0].Length )];
return initials.ToUpper();
}
private string BadgeText( Seat seat ) => seat.IsBot ? "CPU" : IsHostSeat( seat ) ? "Host" : "Ready";
private string BadgeClass( Seat seat ) => seat.IsBot ? "cpu" : IsHostSeat( seat ) ? "host" : "ready";
private void InviteFriends()
{
Sandbox.Game.Overlay.ShowFriendsList( new Sandbox.Modals.FriendsListModalOptions()
{
ShowOfflineMembers = false
});
}
// A bot has no Steam profile to show; a human's opens in the same in-game player-profile overlay
// Steam friends lists use elsewhere.
private void ShowProfile( Seat seat )
{
if ( seat.IsBot ) return;
if ( Connection.Find( seat.Player ) is not { } conn ) return;
conn.FriendInfo.OpenInOverlay();
}
// GameDirector.LeaveGame already does the right thing for host vs. guest.
private void Leave() => Director?.LeaveGame();
protected override int BuildHash()
{
var hash = new HashCode();
hash.Add( Game );
hash.Add( Game?.State );
hash.Add( _tutorialOpen );
hash.Add( IsHost );
if ( Game is { SupportsMultiplayer: true } )
foreach ( var seat in Game.Seats )
hash.Add( HashCode.Combine( seat.Index, seat.Player, seat.IsBot ) );
return hash.ToHashCode();
}
}