Framework/Pile.cs

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.

File Access
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
	};
}