Framework/BoardGame.cs

Abstract BoardGame class for card pile/board games. Implements pile creation, client-to-host RPCs for move/throw/click/double-click, move validation and application with undo snapshot hooks, drop snapping, and hooks for game-specific behavior.

Networking
namespace CardGames;

/// <summary>
/// Archetype for pile/board games (Solitaire, FreeCell, etc): cards live in <see cref="Pile"/>s on the
/// table and the player rearranges them by dragging. Adds the drag/drop + click pipeline and a built-in
/// undo/redo history (see BoardGame.Undo.cs) on top of <see cref="CardGame"/>.
///
/// A board game implements <see cref="CardGame.OnRoundStart"/> (build piles + deal),
/// <see cref="CanGrab"/>, <see cref="CanMove"/>, <see cref="AfterMove"/>, and usually
/// <see cref="CanClick"/>/<see cref="OnClickPile"/> for the stock. The drag UI (CardHover) calls
/// <see cref="RequestMove"/>/<see cref="RequestClick"/>; the host validates and applies them.
/// </summary>
public abstract partial class BoardGame : CardGame
{
	/// <summary>
	/// Does this game keep an undo/redo history? Default yes. Override to <c>false</c> to opt out -
	/// <see cref="PushUndo"/> then no-ops, so <see cref="CanUndo"/>/<see cref="CanRedo"/> stay false and the
	/// game's Undo/Redo buttons (its own <see cref="CardGame.ActionsFor"/> entries) never enable.
	/// </summary>
	protected virtual bool SupportsUndo => true;

	/// <summary>
	/// Host: spawn a <see cref="Pile"/> at a table-local offset. The building block for laying out a board;
	/// most games use the (col, row) overload and never compute an offset themselves.
	/// </summary>
	protected Pile CreatePile( string role, PileLayout style, Vector3 tableOffset )
		=> CreatePileAt( role, style, new Transform( Table.WorldPosition + Table.WorldRotation * tableOffset, Table.WorldRotation ) );

	/// <summary>
	/// Host: spawn a <see cref="Pile"/> on the table's card-grid. <paramref name="col"/> is card-step columns
	/// from centre (0 = centre); <paramref name="row"/> is card-height steps down from the top of the view.
	/// </summary>
	protected Pile CreatePile( string role, PileLayout style, float col, float row )
		=> CreatePileAt( role, style, Table.BoardCell( col, row, CameraHeight ) );

	private Pile CreatePileAt( string role, PileLayout style, Transform at )
	{
		var go = new GameObject( true, role );
		go.WorldPosition = at.Position;
		go.WorldRotation = at.Rotation;

		var pile = go.Components.Create<Pile>();
		pile.Role = role;
		pile.Style = style;
		go.NetworkSpawn();
		return pile;
	}

	/// <summary>
	/// Can the local player pick up the card at <paramref name="index"/> in this pile? Default no.
	/// </summary>
	public virtual bool CanGrab( Pile pile, int index ) => false;

	/// <summary>
	/// Does clicking this pile do something (e.g. the stock to draw)? Used for hover feedback. Default no.
	/// </summary>
	public virtual bool CanClick( Pile pile ) => false;

	/// <summary>
	/// Client to host: try to move the run starting at <paramref name="fromIndex"/> onto another pile.
	/// </summary>
	[Rpc.Host]
	public void RequestMove( Pile from, int fromIndex, Pile to )
	{
		if ( !Networking.IsHost ) return;

		ClearHint(); // the board's changing - any showing hint is now stale

		if ( from is null || to is null ) return; // an unresolved/destroyed reference arrives as null
		if ( from == to ) { from.Arrange(); return; }

		if ( !TryMove( from, fromIndex, to ) )
			from.Arrange(); // illegal (or out of range) → snap the cards back
	}

	/// <summary>
	/// Host: validate and apply a move of the run at <paramref name="fromIndex"/> onto <paramref name="to"/>,
	/// snapshotting undo and running <see cref="AfterMove"/>. Returns false (no change) when the move is
	/// illegal. Shared by the drag pipeline (<see cref="RequestMove"/>) and game shortcuts (double-click).
	/// </summary>
	protected bool TryMove( Pile from, int fromIndex, Pile to )
	{
		if ( !Networking.IsHost ) return false;
		if ( from is null || to is null || from == to ) return false;
		if ( fromIndex < 0 || fromIndex >= from.Count ) return false;
		if ( !CanMove( from, fromIndex, to ) ) return false;

		PushUndo(); // snapshot the board before the move so it can be taken back

		foreach ( var card in from.From( fromIndex ) )
		{
			from.Remove( card );
			to.Add( card );
		}

		from.Arrange();
		to.Arrange();
		AfterMove( from, to );
		return true;
	}

	/// <summary>
	/// Client to host: a run was dropped on no pile - toss it as physics instead of moving it. The cards
	/// stay in their pile (no move, no undo); <see cref="CardObject.Throw"/> lets them tumble and lerp home.
	/// </summary>
	[Rpc.Host]
	public void RequestThrow( Pile from, int fromIndex, Vector3 velocity, Vector3 angularVelocity )
	{
		if ( !Networking.IsHost || !AllowThrowing || from is null ) return;
		if ( fromIndex < 0 || fromIndex >= from.Count ) return;

		ClearHint();

		foreach ( var card in from.From( fromIndex ) )
			card.Throw( velocity, angularVelocity );
	}

	/// <summary>
	/// Would this move be legal? Used by the drag UI for green/red feedback before dropping.
	/// </summary>
	public bool CanDrop( Pile from, int fromIndex, Pile to )
	{
		if ( from is null || to is null || from == to ) return false;
		if ( fromIndex < 0 || fromIndex >= from.Count ) return false;
		return CanMove( from, fromIndex, to );
	}

	/// <summary>
	/// Host: is moving the run at <paramref name="fromIndex"/> onto <paramref name="to"/> legal?
	/// </summary>
	protected virtual bool CanMove( Pile from, int fromIndex, Pile to ) => false;

	/// <summary>
	/// Extra "catch" radius (world units) for dropping onto this pile - a forgiving snap area so a small,
	/// easily-missed target still registers a near miss. 0 (default) means an exact hit only. An exact hit
	/// on any pile always wins; this only kicks in when the cursor lands on no pile at all. Read on clients
	/// (the drag UI calls it), so derive it from synced state only.
	/// </summary>
	public virtual float DropSnapRadius( Pile pile ) => 0f;

	/// <summary>
	/// Host: react after a legal move (flip exposed cards, check for a win, …).
	/// </summary>
	protected virtual void AfterMove( Pile from, Pile to ) { }

	/// <summary>
	/// Client to host: the player clicked a pile (e.g. the stock to draw a card).
	/// </summary>
	[Rpc.Host]
	public void RequestClick( Pile pile )
	{
		if ( !Networking.IsHost ) return;
		if ( pile is not null )
			OnClickPile( pile );
	}

	/// <summary>
	/// Host: handle a click on a pile. Default does nothing.
	/// </summary>
	protected virtual void OnClickPile( Pile pile ) { }

	/// <summary>
	/// Client to host: the player double-clicked the card at <paramref name="index"/> in this pile - a
	/// shortcut gesture (e.g. Solitaire sends a double-clicked Ace straight to a foundation).
	/// </summary>
	[Rpc.Host]
	public void RequestDoubleClick( Pile pile, int index )
	{
		if ( !Networking.IsHost || pile is null ) return;
		if ( index < 0 || index >= pile.Count ) return;
		OnDoubleClickCard( pile, index );
	}

	/// <summary>
	/// Host: handle a double-clicked card (a shortcut to wherever it most usefully goes). Default none.
	/// </summary>
	protected virtual void OnDoubleClickCard( Pile pile, int index ) { }

	protected override void ClearTable()
	{
		base.ClearTable();
		ClearHistory(); // old card refs die with the table
	}
}