A game component representing a pile/stack of playing cards (stock, waste, foundations, columns). It stores host-authoritative ordering, exposes synced properties for role/slot/style, manages a drop collider and a client-only decal for empty piles, and provides methods to add/remove cards, compute slot poses, and arrange cards visually.
namespace CardGames;
public enum PileLayout
{
Squared, // a neat stack (stock, waste, foundation)
FannedDown // cards spread downward so you can read each one (Solitaire columns)
}
/// <summary>
/// An ordered stack of cards at a spot in the world - the building block for slot/drag games
/// (Solitaire's stock, waste, foundations, columns). The host owns the order; cards carry a
/// synced <see cref="CardObject.Pile"/>/<see cref="CardObject.StackIndex"/> so any client can read
/// pile membership (e.g. to grab a card and the run above it). A card-sized collider lets the drag
/// system drop onto an empty pile.
/// </summary>
public sealed class Pile : Component
{
/// <summary>
/// Game-defined role tag, e.g. "stock", "waste", "foundation", "column".
/// </summary>
[Property, Sync] public string Role { get; set; } = "";
[Property, Sync] public string SlotMarker { get; set; } = "";
[Property, Sync] public PileLayout Style { get; set; } = PileLayout.Squared;
/// <summary>
/// Host-authoritative card order, bottom to top.
/// </summary>
public List<CardObject> Cards { get; } = new();
public int Count => Cards.Count;
public CardObject Top => Cards.Count > 0 ? Cards[^1] : null;
private Decal _decal; // a "ghost slot" marker projected onto the table while the pile is empty
protected override void OnAwake()
{
// Drop target - sized like a card so the drag-trace can hit an empty pile. Sunk below the
// table so cards stacked on top always win the trace (otherwise you can't grab the top card).
var collider = Components.GetOrCreate<BoxCollider>();
collider.Scale = new Vector3( CardMesh.Width, CardMesh.Height, 0.2f );
collider.Center = new Vector3( 0, 0, -1f );
}
protected override void OnUpdate()
{
if ( Application.IsHeadless ) return; // the empty-slot decal is purely visual
EnsureDecal();
if ( _decal.IsValid() )
{
bool empty = IsEmpty();
if ( _decal.Enabled != empty )
_decal.Enabled = empty;
}
}
// Lazily build the empty-slot decal once the active game's deck is available. It's a local,
// non-networked child (each client renders its own); destroyed with the pile.
private void EnsureDecal()
{
if ( _decal.IsValid() ) return;
var deck = Scene.Get<CardGame>()?.Deck;
if ( deck is null ) return;
var go = new GameObject( true, "SlotMarker" );
go.Parent = GameObject;
// Decals project along local +X (Forward): aim it straight down into the table, with the
// texture's "up" along the table's toward-dealer axis so the glyph reads upright.
go.WorldRotation = Rotation.LookAt( -WorldRotation.Up, WorldRotation.Left );
go.WorldPosition = WorldPosition + WorldRotation.Up * 2f;
_decal = go.Components.Create<Decal>();
_decal.Decals = new List<DecalDefinition>
{
new() { ColorTexture = SlotRenderer.BuildSlot( deck, string.IsNullOrEmpty( SlotMarker ) ? Role : SlotMarker ), Width = 1f, Height = 1f, ColorMix = 1f }
};
_decal.Size = new Vector2( CardMesh.Width, CardMesh.Height );
_decal.Depth = 8f;
_decal.Rotation = 0f; // ParticleFloat otherwise defaults to a random 0..360 spin
_decal.AttenuationAngle = 0f; // stay fully visible viewed top-down
_decal.LifeTime = 0f; // permanent
_decal.ColorTint = Color.White.WithAlpha( 0.55f );
}
// Empty = no card claims this pile. Uses the synced CardObject.Pile reference, so it's correct on
// every client (the host-only Cards list reads empty on clients).
private bool IsEmpty() => !Scene.GetAllComponents<CardObject>().Any( c => c.Pile == this );
/// <summary>
/// Host: append a card to the top of the pile.
/// </summary>
public void Add( CardObject card )
{
Cards.Add( card );
card.Pile = this;
card.StackIndex = Cards.Count - 1;
}
/// <summary>
/// Host: remove a card and re-number the rest.
/// </summary>
public void Remove( CardObject card )
{
Cards.Remove( card );
for ( int i = 0; i < Cards.Count; i++ )
Cards[i].StackIndex = i;
}
/// <summary>
/// The card at <paramref name="index"/> and everything above it, in order.
/// </summary>
public List<CardObject> From( int index ) => Cards.Skip( index ).ToList();
/// <summary>
/// Host: position every card according to <see cref="Style"/>. Transforms replicate. Pass
/// <paramref name="animate"/> = false to snap cards (and their faces) into place instantly - used by undo/redo.
/// </summary>
public void Arrange( bool animate = true )
{
for ( int i = 0; i < Cards.Count; i++ )
{
var pose = SlotPose( i );
if ( animate )
{
Cards[i].MoveTo( pose );
}
else
{
Cards[i].SnapFace(); // set _flip/texture before SnapTo reads them
Cards[i].SnapTo( pose );
}
}
}
/// <summary>The world pose a card at <paramref name="index"/> rests at in this pile.</summary>
public Transform SlotPose( int index ) => new( WorldPosition + WorldRotation * Offset( index ), WorldRotation );
private Vector3 Offset( int i ) => Style switch
{
PileLayout.FannedDown => new Vector3( 0, -i * (CardMesh.Height * 0.28f), i * 0.05f ),
_ => new Vector3( 0, 0, i * 0.05f ), // squared: tiny Z stagger so they don't z-fight
};
}