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.
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
}
}