Cards/CardFaceRenderer.cs

Procedural renderer for playing card faces and backs. It builds and caches textures (front albedo, back, and metalness mask) using a Bitmap/TextRendering API, lays out pips and corner indices, and supports deck-supplied back images.

File Access
using System.Collections.Generic;

namespace CardGames;

/// <summary>
/// The textures for one card: front + back albedo, and a front metalness mask.
/// </summary>
public readonly struct CardTextures
{
	public Texture Front { get; init; }
	public Texture Back { get; init; }
	public Texture Metal { get; init; }
}

/// <summary>
/// Produces a card's textures. The front/back are drawn procedurally with the <see cref="Bitmap"/>
/// API (rank/suit ink + a metalness mask); a deck can still supply a custom back via
/// <see cref="DeckDefinition.BackImage"/>. The shader samples front vs back per card side, so no atlas
/// compositing is needed. Results are cached; a deck only builds each texture once.
/// </summary>
public static class CardFaceRenderer
{
	/// <summary>
	/// Card aspect ratio (width / height). Standard playing cards are 2.5" x 3.5".
	/// </summary>
	public const float Aspect = 2.5f / 3.5f;

	private static readonly Dictionary<string, CardTextures> Cache = new();

	/// <summary>
	/// Build a card's textures. Pass a face index, or -1 for a face-down card.
	/// </summary>
	public static CardTextures BuildCard( DeckDefinition deck, int faceIndex )
	{
		var key = $"{deck.ResourcePath}|{faceIndex}";
		if ( Cache.TryGetValue( key, out var cached ) && cached.Front.IsLoaded )
			return cached;

		int h = deck.Resolution;
		int w = (int)(h * Aspect);

		// Back is shared: a deck back texture if provided, otherwise drawn procedurally.
		var back = deck.BackImage ?? RenderBack( deck, w, h );

		Texture front, metal;
		if ( faceIndex < 0 )
		{
			front = back;           // a face-down card shows the back from the front too
			metal = Texture.Black;  // and nothing is metallic
		}
		else
		{
			var face = deck.Faces[faceIndex];
			front = RenderFront( deck, face, w, h );
			metal = RenderMetal( deck, face, w, h );
		}

		var result = new CardTextures { Front = front, Back = back, Metal = metal };
		Cache[key] = result;
		return result;
	}

	private static Texture RenderFront( DeckDefinition deck, CardFace face, int w, int h )
	{
		var bmp = new Bitmap( w, h );
		var area = new Rect( 0, 0, w, h );
		DrawFaceBackground( bmp, face.HasBackground ? face.Background : deck.FaceColor, deck.CornerRadius, area );
		DrawInk( bmp, deck, face, area, face.Accent );
		return bmp.ToTexture();
	}

	private static Texture RenderMetal( DeckDefinition deck, CardFace face, int w, int h )
	{
		var bmp = new Bitmap( w, h );
		bmp.Clear( Color.Black );                       // 0 = matte
		DrawInk( bmp, deck, face, new Rect( 0, 0, w, h ), Color.White ); // ink reads metallic
		return bmp.ToTexture();
	}

	private static Texture RenderBack( DeckDefinition deck, int w, int h )
	{
		var bmp = new Bitmap( w, h );
		DrawBack( bmp, deck, new Rect( 0, 0, w, h ) );
		return bmp.ToTexture();
	}

	private static void DrawFaceBackground( Bitmap bmp, Color faceColor, float cornerRadius, Rect area )
	{
		float radius = area.Width * cornerRadius;
		bmp.SetFill( faceColor );
		bmp.DrawRect( area );
		bmp.SetPen( Color.Lerp( faceColor, Color.Black, 0.18f ), area.Width * 0.02f );
		bmp.DrawRoundRect( Inset( area, area.Width * 0.04f ), radius );
	}

	// DontClip is important: without it, DrawText sets a MaxWidth and double-centres narrow text
	// (shifting it right) and ellipsizes anything that doesn't fit ("3...").
	private const TextFlag Centred = TextFlag.Center | TextFlag.DontClip;

	// Draws the rank/suit "ink": a modest central symbol plus the corner index in two opposite
	// corners (top-left upright, bottom-right rotated 180° like a real card). Used for both the
	// front albedo (accent colour) and the metalness mask (white).
	private static void DrawInk( Bitmap bmp, DeckDefinition deck, CardFace face, Rect area, Color ink )
	{
		// Number cards (2-10) get the classic pip layout; Ace and face/custom cards get one big symbol.
		if ( face.Pips >= 2 )
			DrawPips( bmp, deck, face, area, ink );
		else
			bmp.DrawText( new TextRendering.Scope( face.Center, ink, area.Height * 0.34f, deck.Font, 500 ), area, Centred );

		int cw = (int)(area.Width * 0.26f);
		int ch = (int)(area.Height * 0.30f);
		var index = new Bitmap( cw, ch );
		index.Clear( Color.Transparent );

		var rank = new TextRendering.Scope( face.Corner, ink, ch * 0.34f, deck.Font, 600 );
		index.DrawText( rank, new Rect( 0, 0, cw, ch * 0.55f ), Centred );

		// Weight 400: the suit glyphs aren't in Roboto's bold face, so a heavier weight ellipsizes.
		var suit = new TextRendering.Scope( face.Center, ink, ch * 0.30f, deck.Font, 400 );
		index.DrawText( suit, new Rect( 0, ch * 0.52f, cw, ch * 0.40f ), Centred );

		float pad = area.Width * 0.05f;
		bmp.DrawBitmap( index, new Rect( area.Left + pad, area.Top + pad, cw, ch ) );
		bmp.DrawBitmap( index.Rotate( 180f ), new Rect( area.Right - pad - cw, area.Bottom - pad - ch, cw, ch ) );
	}

	// Lays out 2-10 suit pips in the standard pattern, with the bottom half drawn upside-down like a real card.
	private static void DrawPips( Bitmap bmp, DeckDefinition deck, CardFace face, Rect area, Color ink )
	{
		var layout = PipLayout( face.Pips );
		if ( layout is null )
		{
			bmp.DrawText( new TextRendering.Scope( face.Center, ink, area.Height * 0.34f, deck.Font, 500 ), area, Centred );
			return;
		}

		// Render the suit glyph once (upright + rotated), then stamp it at each pip position.
		int size = (int)(area.Height * 0.14f);
		var pip = new Bitmap( size, size );
		pip.Clear( Color.Transparent );
		pip.DrawText( new TextRendering.Scope( face.Center, ink, size * 0.95f, deck.Font, 400 ), new Rect( 0, 0, size, size ), Centred );
		var pipFlipped = pip.Rotate( 180f );

		// The region the pips occupy: wide enough that the left/right columns are clearly separated
		// (smaller pips + a wider spread keep them from overlapping into a blob).
		float left = area.Left + area.Width * 0.17f;
		float top = area.Top + area.Height * 0.16f;
		float w = area.Width * 0.66f;
		float h = area.Height * 0.70f;

		foreach ( var p in layout )
		{
			float cx = left + p.x * w;
			float cy = top + p.y * h;
			var dst = new Rect( cx - size * 0.5f, cy - size * 0.5f, size, size );
			bmp.DrawBitmap( p.y > 0.5f ? pipFlipped : pip, dst ); // bottom-half pips point down
		}
	}

	// Standard playing-card pip positions, normalised to the pip column (0..1). x: left/centre/right.
	// I know, it's a lot of magic numbers, but this is the classic layout and it looks right.
	private static Vector2[] PipLayout( int count )
	{
		const float L = 0.18f, C = 0.5f, R = 0.82f;
		return count switch
		{
			2 => new[] { V( C, 0.04f ), V( C, 0.96f ) },
			3 => new[] { V( C, 0.04f ), V( C, 0.5f ), V( C, 0.96f ) },
			4 => new[] { V( L, 0.04f ), V( R, 0.04f ), V( L, 0.96f ), V( R, 0.96f ) },
			5 => new[] { V( L, 0.04f ), V( R, 0.04f ), V( C, 0.5f ), V( L, 0.96f ), V( R, 0.96f ) },
			6 => new[] { V( L, 0.04f ), V( R, 0.04f ), V( L, 0.5f ), V( R, 0.5f ), V( L, 0.96f ), V( R, 0.96f ) },
			7 => new[] { V( L, 0.04f ), V( R, 0.04f ), V( C, 0.27f ), V( L, 0.5f ), V( R, 0.5f ), V( L, 0.96f ), V( R, 0.96f ) },
			8 => new[] { V( L, 0.04f ), V( R, 0.04f ), V( C, 0.27f ), V( L, 0.5f ), V( R, 0.5f ), V( C, 0.73f ), V( L, 0.96f ), V( R, 0.96f ) },
			9 => new[] { V( L, 0.04f ), V( R, 0.04f ), V( L, 0.35f ), V( R, 0.35f ), V( C, 0.5f ), V( L, 0.65f ), V( R, 0.65f ), V( L, 0.96f ), V( R, 0.96f ) },
			10 => new[] { V( L, 0.04f ), V( R, 0.04f ), V( C, 0.20f ), V( L, 0.35f ), V( R, 0.35f ), V( L, 0.65f ), V( R, 0.65f ), V( C, 0.80f ), V( L, 0.96f ), V( R, 0.96f ) },
			_ => null
		};
	}

	private static Vector2 V( float x, float y ) => new( x, y );

	private static void DrawBack( Bitmap bmp, DeckDefinition deck, Rect area )
	{
		float radius = area.Width * deck.CornerRadius;
		bmp.SetFill( deck.BackColor );
		bmp.DrawRect( area );

		bmp.SetFill( Color.Lerp( deck.BackColor, Color.White, 0.25f ) );
		bmp.DrawRoundRect( Inset( area, area.Width * 0.12f ), radius );
		bmp.SetFill( deck.BackColor );
		bmp.DrawRoundRect( Inset( area, area.Width * 0.18f ), radius * 0.7f );
	}

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

	private static void DrawRoundRect( this Bitmap bmp, Rect rect, float radius ) => bmp.DrawRoundRect( rect, new Margin( radius ) );

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