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().
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();
}