Cards/SlotRenderer.cs

Renderer for empty-slot markers in a card game. Builds or reuses a cached Texture showing an inset rounded rectangle and role-specific hints (suit pips for foundations, rank letter for columns, recycle symbol for stock). Caches textures by deck resource path and role and exposes ClearCache().

File Access
namespace CardGames;

/// <summary>
/// Draws the ghost slot marker for a pile of a given role (foundation, column, stock, waste).
/// </summary>
public static class SlotRenderer
{
	// Reset on hotload so edits to the slot art (e.g. ink colour) show live, not masked by a cached texture.
	[Sandbox.SkipHotload]
	private static readonly Dictionary<string, Texture> Cache = new();

	private const TextFlag Centered = TextFlag.Center | TextFlag.DontClip;

	/// <summary>
	/// Build (or fetch) the empty-slot marker texture for a pile of the given role.
	/// </summary>
	public static Texture BuildSlot( DeckDefinition deck, string role )
	{
		var key = $"{deck.ResourcePath}|{role}";
		if ( Cache.TryGetValue( key, out var cached ) && cached.IsLoaded )
			return cached;

		int h = deck.Resolution;
		int w = (int)(h * CardFaceRenderer.Aspect);
		var area = new Rect( 0, 0, w, h );

		var bmp = new Bitmap( w, h );
		bmp.Clear( Color.Transparent );

		// Faint ink so the marker reads as a watermark, not a real card. Overall opacity is also scaled by the
		// decal's tint alpha at projection time. Foundations use a dark watermark; the other slots a light one.
		var ink = new Color( 0f, 0f, 0f, 1 );

		// Inset rounded-rect outline (stroke only - transparent interior).
		bmp.SetFill( Color.Transparent );
		bmp.SetPen( ink, w * 0.04f );
		bmp.DrawRoundRect( Inset( area, w * 0.06f ), new Margin( w * deck.CornerRadius ) );

		DrawHint( bmp, deck, role, area, ink );

		var tex = bmp.ToTexture();
		Cache[key] = tex;
		return tex;
	}

	private static void DrawHint( Bitmap bmp, DeckDefinition deck, string role, Rect area, Color ink )
	{
		switch ( role )
		{
			case "foundation":
				DrawSuitPips( bmp, deck, area, ink ); // "build a suit here"
				break;
			case "column":
				bmp.DrawText( new TextRendering.Scope( "K", ink, area.Height * 0.34f, deck.Font, 500 ), area, Centered );
				break;
			case "column-any":
				// Spider solitaire: any card may start an empty column, so there's no rank hint... outline only.
				break;
			case "stock":
				// Recycle hint. Uses the default font (the card font has no ↻); if it's missing the outline still reads.
				bmp.DrawText( new TextRendering.Scope( "↻", ink, area.Height * 0.42f, "Roboto", 500 ), area, Centered );
				break;
				// "waste" / default: outline only.
		}
	}

	// The four suit glyphs in a faint row.
	private static void DrawSuitPips( Bitmap bmp, DeckDefinition deck, Rect area, Color ink )
	{
		var suits = new[] { Suit.Clubs, Suit.Diamonds, Suit.Hearts, Suit.Spades };
		float cx = area.Left + area.Width * 0.5f;
		float cy = area.Top + area.Height * 0.5f;
		float gap = area.Width * 0.2f;
		float size = area.Height * 0.16f;
		float startX = cx - gap * 1.5f;

		for ( int i = 0; i < suits.Length; i++ )
		{
			var r = new Rect( startX + i * gap - size * 0.5f, cy - size * 0.5f, size, size );
			bmp.DrawText( new TextRendering.Scope( suits[i].Glyph(), ink, size, deck.Font, 400 ), r, Centered );
		}
	}

	private static Rect Inset( Rect r, float by ) => new( r.Left + by, r.Top + by, r.Width - by * 2, r.Height - by * 2 );

	/// <summary>
	/// Clears the cache, e.g. after editing a deck asset.
	/// </summary>
	public static void ClearCache() => Cache.Clear();
}