UI/GameSetup.razor

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.

Networking
@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();
	}
}