Abstract base for turn-based seated card games. Extends CardGame to manage occupied seats, hand layout (spacing and fan), seat spots, dealing cards to seats, relayout on seat changes, ownership checks for loose cards, and advancing turns among in-round seats.
namespace CardGames;
/// <summary>
/// Archetype for turn-based seated games (Blackjack, Poker, etc): players sit at the table, take turns
/// in order, and are dealt hands. Adds turn order and hand dealing on top of <see cref="CardGame"/>.
/// Betting is <b>not</b> baked in here - a game that wagers composes a <see cref="Bank"/> (and a
/// <see cref="Pot"/> if pot-banked), so non-betting seated games (trick-taking) carry no betting surface.
///
/// A seated game typically implements <see cref="CardGame.OnRoundStart"/> (deal + set the first turn),
/// <see cref="CardGame.OnPlayerAction"/>, <see cref="CardGame.ActionsFor"/>, and often
/// <see cref="CardGame.OnRoundEnd"/>; it calls <see cref="AdvanceTurn"/> as players act.
/// </summary>
public abstract class SeatedGame : CardGame
{
/// <summary>
/// Occupied seats, ordered - used to place hands space-evenly across the table.
/// </summary>
protected List<Seat> OccupiedSeats => Seats.Where( s => s.Occupied ).OrderBy( s => s.Index ).ToList();
/// <summary>How tightly a hand packs: 1 = the table's normal no-overlap spread, <1 overlaps the cards.</summary>
[Property] public float HandSpacingScale { get; set; } = 1f;
/// <summary>Total fan tilt across a hand, in degrees - 0 is a flat row (Blackjack); a poker hand fans out.</summary>
[Property] public float HandFanDegrees { get; set; } = 0f;
/// <summary>
/// Spacing scale used when laying out <paramref name="seat"/>'s hand
/// </summary>
protected virtual float HandSpacing( Seat seat ) => HandSpacingScale;
/// <summary>
/// The table spot a seat's hand sits at - cards, chips and the round result all emanate from here.
/// Override for games that distribute seats in a non-standard layout (e.g. around the table).
/// </summary>
protected virtual Transform SeatSpot( Seat seat )
{
var occupied = OccupiedSeats;
return Table.SeatSpot( occupied.IndexOf( seat ), occupied.Count );
}
public override Transform? SeatHandSpot( Seat seat ) => SeatSpot( seat );
// Ensure the (local, cosmetic) renderer that draws on-table hand-value decals exists on every client.
protected override void OnEnabled()
{
base.OnEnabled();
if ( !Application.IsHeadless )
Components.GetOrCreate<HandValueDecalRenderer>();
}
/// <summary>
/// One value decal per occupied seat, at its hand spot, with the local viewer's <see cref="CardGame.HandValueText"/>.
/// The renderer skips empty text, so a game only fills in HandValueText (and may add non-seat hands, e.g. a dealer).
/// </summary>
public override IEnumerable<HandValueDecal> HandValueDecals()
{
foreach ( var seat in OccupiedSeats )
yield return new( $"seat{seat.Index}", SeatSpot( seat ), HandValueText( seat ) );
}
/// <summary>
/// A loose card belongs to the local player when it's in their hand. The host has the authoritative
/// hand list; a remote client identifies its own cards by network ownership (cards are dealt to their
/// owner). Drives whether the local player can grab the card to throw it.
/// </summary>
public override bool OwnsCard( CardObject card )
{
if ( !card.IsValid() || card.Pile is not null ) return false;
return Networking.IsHost
? LocalSeat?.Cards.Contains( card ) == true
: card.GameObject.Network.IsOwner;
}
/// <summary>
/// Host: deal the next card from the shoe into a seat's hand.
/// </summary>
protected CardObject DealToSeat( Seat seat, bool faceUp, Connection privateTo = null )
{
var spot = SeatSpot( seat );
var co = SpawnCard( Shoe.Draw(), spot, faceUp, privateTo );
seat.Cards.Add( co );
Table.LayoutHand( seat.Cards, spot, HandSpacing( seat ), HandFanDegrees ); // re-centre the whole hand after each card
PlaySound( TableSound.CardDraw, co.WorldPosition );
return co;
}
/// <summary>
/// Host: re-place every hand for the current seat count (e.g. when someone joins).
/// </summary>
protected void RelayoutHands()
{
var occupied = OccupiedSeats;
for ( int i = 0; i < occupied.Count; i++ )
if ( occupied[i].Cards.Count > 0 )
Table.LayoutHand( occupied[i].Cards, SeatSpot( occupied[i] ), HandSpacing( occupied[i] ), HandFanDegrees );
}
protected override void OnSeatsChanged() => RelayoutHands(); // keep hands space-evenly on join/leave
/// <summary>
/// Is this seat taking turns this round? Default: any occupied seat. Games override to skip players
/// who sat out or folded (e.g. Blackjack skips seats with no wager).
/// </summary>
protected virtual bool InRound( Seat seat ) => seat.Occupied;
/// <summary>
/// Host: advance to the next in-round seat. Calls <see cref="CardGame.OnRoundEnd"/> when none remain.
/// </summary>
protected void AdvanceTurn()
{
var occupied = Seats.Where( InRound ).Select( s => s.Index ).OrderBy( i => i ).ToList();
var next = occupied.FirstOrDefault( i => i > CurrentSeat, -1 );
if ( next < 0 )
{
State = GameState.Resolving;
OnRoundEnd();
}
else
{
CurrentSeat = next;
}
}
}