Framework/GameDirector.cs

Scene component that hosts the lobby and menu for the card game collection. It lists local .cggame assets, spawns and network-spawns CardGame prefabs (local or from mounted cloud packages), manages lobby state, deals/starts rounds, and handles joining/leaving network connections.

NetworkingExternal DownloadFile Access
using System.Threading.Tasks;
using Sandbox.Network;

namespace CardGames;

/// <summary>
/// Which host-side menu screen is showing while no game is running. Purely local UI state.
/// </summary>
public enum MenuScreen
{
	Launcher,   // the hall of built-in games
	Community,  // browse + load games published to the cloud
}

/// <summary>
/// The one component placed in the scene. It hosts the lobby, lists the available games (every
/// <c>.cggame</c> <see cref="GameDefinition"/> asset) for the menu, and spawns the chosen game's prefab.
/// Late joiners are seated automatically.
/// </summary>
public sealed class GameDirector : Component, Component.INetworkListener
{
	/// <summary>
	/// The games this build ships with, one per local <c>.cggame</c> asset - add a new one and it shows up,
	/// no code. Captured once at startup and never repopulated, so a mounted cloud game (which also lands
	/// in <see cref="ResourceLibrary"/>) doesn't sneak into the local Card Room.
	/// </summary>
	public IReadOnlyList<GameDefinition> AvailableGames { get; private set; } = new List<GameDefinition>();

	/// <summary>
	/// Which menu screen the host is looking at while no game is running. Host-local UI state (the menu is
	/// only ever shown to the host), so it isn't synced - <see cref="Hud"/> routes on it.
	/// </summary>
	public MenuScreen Screen { get; set; } = MenuScreen.Launcher;

	/// <summary>Open the full games browser.</summary>
	public void BrowseCommunity() => Screen = MenuScreen.Community;

	/// <summary>Return from the browser to the hall of built-in games.</summary>
	public void CloseBrowser() => Screen = MenuScreen.Launcher;

	/// <summary>
	/// The running game, or null in the menu. Discovered from the scene (the host spawns it, the network
	/// replicates it to clients), so it resolves the same on every machine without any global state.
	/// </summary>
	public CardGame ActiveGame => Scene.Get<CardGame>();

	protected override void OnStart()
	{
		// This is a point-and-click card game with no first-person view, so the cursor should always be
		// available - not hidden when the pointer falls through the UI onto the bare table (the Auto default).
		Mouse.Visibility = MouseVisibility.Visible;

		CardFaceRenderer.ClearCache();
		SlotRenderer.ClearCache();
		CardMesh.Invalidate();

		// Snapshot the local games once. Cloud games are mounted on demand and kept out of this list.
		AvailableGames = ResourceLibrary.GetAll<GameDefinition>()
			.OrderBy( g => g.DisplayName )
			.Where( g => !g.IsRemote )
			.ToList();
	}

	/// <summary>
	/// Host: clone the chosen local game's prefab and open its lobby.
	/// </summary>
	public void StartGame( string definitionPath )
	{
		var def = AvailableGames.FirstOrDefault( g => g.ResourcePath == definitionPath );
		Start( def );
	}

	// Host: spawn a definition and open its lobby. Shared by local and cloud games - packageIdent is the
	// cloud package a game came from (null for local), synced so clients can mount it too.
	private void Start( GameDefinition def, string packageIdent = null )
	{
		if ( !Networking.IsHost || ActiveGame is not null ) return;
		if ( SpawnGame( def, packageIdent ) is not { } game ) return;

		// A single-player game (e.g. Solitaire) has nobody to invite, so it never gets a real Steam
		// lobby - NetworkSpawn/[Sync]/RPCs all work fine off just the local connection. Only a
		// multiplayer game needs one to be joinable by anyone else.
		if ( def.Multiplayer )
		{
			// Nothing to host until a game's actually picked, so the lobby opens here instead of at menu
			// start - named after the game so anyone browsing sees what's being played (e.g. "Hearts").
			// A later game this same session reuses the existing lobby and just renames it.
			if ( !Networking.IsActive )
				Networking.CreateLobby( new LobbyConfig { Name = game.Title, Privacy = LobbyPrivacy.Public, AutoSwitchToBestHost = false, DestroyWhenHostLeaves = true } );
			else
				Networking.ServerName = game.Title;

			// Freshly started, still in the Waiting lobby - open to join. CardGame.WatchStateForStats keeps
			// this in sync with the game's phase from here (flips to "in_progress" once a round deals).
			Networking.SetData( "state", "joinable" );
		}

		game.HostSetup();

		// Single-player games (e.g. Solitaire) have nobody to wait for - deal right away
		// (unless the game has table settings to choose first, then the host gets the setup screen and starts manually).
		if ( !def.Multiplayer && !GameSettings.Any( game ) )
			game.StartRound();
	}

	// Host: clone a definition's prefab, sync its path and network-spawn it. Returns the game component, or
	// null (with a logged reason) if the definition has no prefab or the prefab carries no CardGame rules -
	// the usual ways a cloud game's package fails to start.
	private CardGame SpawnGame( GameDefinition def, string packageIdent = null )
	{
		if ( def is null ) return null;
		if ( def.Prefab is null )
		{
			Log.Warning( $"Game '{def.DisplayName}' ({def.ResourcePath}) has no prefab - can't start it." );
			return null;
		}

		var go = GameObject.Clone( def.Prefab );
		if ( go.Components.Get<CardGame>() is not { } game )
		{
			Log.Warning( $"Prefab '{def.Prefab.ResourcePath}' has no CardGame component - can't start '{def.DisplayName}'." );
			go.Destroy();
			return null;
		}

		game.DefinitionPath = def.ResourcePath;
		game.PackageIdent = packageIdent;
		go.NetworkSpawn();
		return game;
	}

	/// <summary>
	/// Host: download and mount a game's cloud package without starting it - just enough to resolve its
	/// <see cref="GameDefinition"/> (custom stat picks, richer listing info) for the launcher's hero panel
	/// before Play is pressed. Best-effort: returns null silently on any failure - <see cref="StartCommunityGame"/>
	/// does the same fetch+mount again when Play is actually pressed, and reports the failure reason then.
	/// </summary>
	public async Task<GameDefinition> MountCommunityGame( Package pkg )
	{
		if ( pkg is null ) return null;

		var full = await Package.FetchAsync( pkg.FullIdent, partial: false );
		if ( full is null ) return null;

		var fs = await full.MountAsync( withCode: true );
		if ( fs is null ) return null;

		return string.IsNullOrEmpty( full.PrimaryAsset )
			? null
			: await ResourceLibrary.LoadAsync<GameDefinition>( full.PrimaryAsset );
	}

	/// <summary>
	/// The error <see cref="StartCommunityGame"/> returns when the package downloaded and mounted fine but
	/// the game itself is broken (no prefab, or the prefab carries no <see cref="CardGame"/> rules component)
	/// - a structural problem with the game, not a transient network one. The launcher checks for this
	/// specific message to show a permanent "can't play" state on the Play button instead of a retryable note.
	/// </summary>
	public const string StartFailedError = "That game couldn't be started — see the console for details.";

	/// <summary>
	/// Host: download and mount a game's cloud package, then start the card game it contains. Returns null
	/// on success, or a short error message to show the player (the package wouldn't mount, or it isn't a
	/// compatible card game - i.e. its primary asset isn't a <see cref="GameDefinition"/>). Leaves the browser open
	/// on failure so they can pick another.
	/// </summary>
	public async Task<string> StartCommunityGame( Package pkg )
	{
		if ( !Networking.IsHost ) return "Only the host can start a game.";
		if ( ActiveGame is not null ) return null;
		if ( pkg is null ) return "That game is unavailable.";

		// A search-result Package (from Package.FindAsync) carries only trimmed list metadata - its per-revision
		// Meta blob (which holds PrimaryAsset) isn't populated. Fetch the full package so PrimaryAsset resolves,
		// same as the engine's own Cloud.Load does before reading it.
		var full = await Package.FetchAsync( pkg.FullIdent, partial: false );
		if ( full is null ) return "That game is unavailable.";

		// withCode: the prefab's rules component is a CardGame subclass defined in the package's assembly.
		var fs = await full.MountAsync( withCode: true );
		if ( fs is null ) return "Couldn't download that game.";

		// A cggame package's primary asset IS its GameDefinition. Mounting registered it in ResourceLibrary;
		// LoadAsync returns that cached copy, so re-launching a game we already mounted this session just works.
		var def = string.IsNullOrEmpty( full.PrimaryAsset )
			? null
			: await ResourceLibrary.LoadAsync<GameDefinition>( full.PrimaryAsset );
		if ( def is null ) return "That isn't a compatible card game.";

		Start( def, pkg.FullIdent );

		// SpawnGame logs the specifics (no prefab / no rules component); tell the player it didn't take.
		if ( ActiveGame is null )
			return StartFailedError;

		Screen = MenuScreen.Launcher; // the game's now showing; leave the browser behind it
		return null;
	}

	/// <summary>
	/// Host: deal a round of the active game.
	/// </summary>
	public void DealRound() => ActiveGame?.StartRound();

	/// <summary>
	/// Host: tear the active game down and return to the menu.
	/// </summary>
	public void QuitGame()
	{
		if ( !Networking.IsHost || ActiveGame is null ) return;
		ActiveGame.ClearTablePublic();

		// Seats live at the scene root, not under the game - tear them down too so a game's players (and any
		// bots it seated) don't linger into the next game.
		foreach ( var seat in ActiveGame.Seats.ToList() )
			seat.GameObject.Destroy();

		ActiveGame.GameObject.Destroy();
	}

	/// <summary>
	/// Leave the table, whoever's asking. The host tears it down for everyone (<see cref="QuitGame"/>); a
	/// guest just steps out of the session - but disconnecting alone doesn't remove their own (replicated)
	/// copy of the game/seats, so clean those up directly too, or they'd keep staring at the old table
	/// instead of landing back on their own menu.
	/// </summary>
	public void LeaveGame()
	{
		if ( Networking.IsHost )
		{
			QuitGame();
			return;
		}

		Networking.Disconnect();

		if ( ActiveGame is not { } game ) return;

		foreach ( var seat in game.Seats.ToList() )
			seat.GameObject.Destroy();

		game.GameObject.Destroy();
	}

	// Seat anyone who joins mid-session, and free their seat when they leave.
	void INetworkListener.OnActive( Connection channel ) => ActiveGame?.SeatPlayer( channel );
	void INetworkListener.OnDisconnected( Connection channel ) => ActiveGame?.RemovePlayer( channel );
}