Cards/HandValueDecalRenderer.cs

A client-side renderer component that creates and updates on-table "value" decals for hands (e.g. Blackjack totals, Poker categories). It watches the active CardGame.HandValueDecals collection each update, creates Decal GameObjects for new entries, positions and orients them relative to the hand spot, updates their texture from HandValueRenderer.BuildLabel, and removes decals for stale keys.

Native Interop
using System.Collections.Generic;

namespace CardGames;

/// <summary>
/// Draws the on-table "value" decals beside hands (Blackjack totals, Poker categories, …). Runs locally on
/// every client off the active game's <see cref="CardGame.HandValueDecals"/>; the decals are non-networked
/// children of this component and are reused/updated by key. Mirrors the empty-slot decals on <c>Pile</c>.
/// </summary>
public sealed class HandValueDecalRenderer : Component
{
	private sealed class Entry
	{
		public GameObject Go;
		public Decal Decal;
		public Texture Tex;
	}

	private readonly Dictionary<string, Entry> _decals = new();
	private readonly List<string> _stale = new();

	protected override void OnUpdate()
	{
		if ( Application.IsHeadless ) return; // decals are purely visual

		var game = Scene.Get<CardGame>();
		var deck = game?.Deck;

		var live = new HashSet<string>();
		if ( game is not null && deck is not null )
		{
			foreach ( var d in game.HandValueDecals() )
			{
				if ( string.IsNullOrEmpty( d.Text ) ) continue;
				live.Add( d.Key );
				Apply( d, deck );
			}
		}

		// Drop decals whose hand is gone or whose label emptied (e.g. between rounds).
		_stale.Clear();
		foreach ( var key in _decals.Keys )
			if ( !live.Contains( key ) ) _stale.Add( key );
		foreach ( var key in _stale )
		{
			_decals[key].Go?.Destroy();
			_decals.Remove( key );
		}
	}

	private void Apply( HandValueDecal d, DeckDefinition deck )
	{
		if ( !_decals.TryGetValue( d.Key, out var e ) )
		{
			var go = new GameObject( true, $"HandValue:{d.Key}" );
			go.Parent = GameObject;

			e = new Entry { Go = go, Decal = go.Components.Create<Decal>(), Tex = null };

			float w = CardMesh.Width * 1.6f;
			e.Decal.Size = new Vector2( w, w / HandValueRenderer.Aspect );
			e.Decal.Depth = 8f;
			e.Decal.Rotation = 0f;          // ParticleFloat otherwise defaults to a random spin
			e.Decal.AttenuationAngle = 0f;  // stay visible top-down
			e.Decal.LifeTime = 0f;          // permanent (we manage its lifetime)
			e.Decal.ColorTint = Color.White.WithAlpha( 0.85f ); // mostly opaque so the white-on-black pill reads

			_decals[d.Key] = e;
		}

		// Sit the pill just past the hand, projected straight down onto the felt. Offset only along the hand's
		// toward-dealer axis (no sideways drift for off-centre seats), picking the sign that points toward the
		// table centre - so player pills sit above their near-edge hands and the dealer's sits below its far one.
		var spot = d.Spot;
		var centre = Scene.Get<TableAnchor>()?.WorldPosition ?? spot.Position;
		float sign = Vector3.Dot( centre - spot.Position, spot.Rotation.Left ) < 0f ? -1f : 1f;

		e.Go.WorldRotation = Rotation.LookAt( -spot.Rotation.Up, spot.Rotation.Left );
		e.Go.WorldPosition = spot.Position + spot.Rotation.Left * (sign * CardMesh.Height * 0.7f) + spot.Rotation.Up * 2f;

		// Compare the actual texture, not the text: the build cache clears on hotload and returns a fresh
		// texture, so a style edit (colours, layout) shows live instead of being masked by a per-text guard.
		var tex = HandValueRenderer.BuildLabel( deck, d.Text );
		if ( e.Tex != tex )
		{
			e.Decal.Decals = new List<DecalDefinition>
			{
				new() { ColorTexture = tex, Width = 1f, Height = 1f, ColorMix = 1f }
			};
			e.Tex = tex;
		}
	}
}