UI component SeatTag.razor, a Razor UI Panel that displays a player seat card above the seat's hand showing avatar/initial, name, chips, current bet and per-seat badges. It projects the seat/hand position to screen space each Tick and positions the panel accordingly.
@namespace CardGames
@using Sandbox
@using Sandbox.UI
@using System
@using System.Linq
@inherits Panel
@*
One player's info card, positioned in screen space directly above that seat's hand (its world spot is
projected through the camera each frame). Shows the name, chips, current bet and the game's per-seat
badges. Display-only - generic across games via CardGame.SeatBadges.
*@
<root class="player @PlayerClass()">
<div class="head">
@if ( AvatarUrl is { } url )
{
<img class="avatar" src="@url" />
}
else
{
<div class="avatar fallback">@Initial</div>
}
<div class="who">
<span class="name">@Seat?.ToString()</span>
@if ( Game is { UsesBetting: true } )
{
<span class="chips">@Money( Seat?.Currency ?? 0 )</span>
}
</div>
</div>
@{ var badges = Game?.SeatBadges( Seat ) ?? Array.Empty<SeatBadge>(); }
@if ( (Seat?.Wager ?? 0) > 0 || badges.Count > 0 )
{
<div class="meta">
@if ( (Seat?.Wager ?? 0) > 0 )
{
<div class="bet">
<span class="lbl">Bet</span>
<span class="dot"></span>
<span class="amt">@Money( Seat.Wager )</span>
</div>
}
@foreach ( var b in badges )
{
<span class="badge @ToneClass( b.Tone )">@b.Text</span>
}
</div>
}
</root>
@code
{
public Seat Seat { get; set; }
private const float HalfWidth = 95f; // half the panel's min-width, to centre it over the hand
private const float DropBelow = 100f; // default pixels to drop the card below the hand's centre point (games can override)
private CardGame Game => Sandbox.Game.ActiveScene?.Get<GameDirector>()?.ActiveGame;
// The seat's Steam avatar (avatar:<steamid>), or null for bots / players we can't resolve - those
// fall back to the initial disc below.
private string AvatarUrl
{
get
{
if ( Seat is null || Seat.IsBot ) return null;
var conn = Connection.Find( Seat.Player );
return conn is null ? null : $"avatar:{conn.SteamId}";
}
}
// First letter of the seat's display name, for the fallback (bot) avatar disc.
private string Initial
{
get
{
var name = Seat?.ToString();
return string.IsNullOrWhiteSpace( name ) ? "?" : name.Trim()[0].ToString().ToUpper();
}
}
// Chips shown as a dollar amount with thousands separators ($3,200) to match the house style.
private static string Money( long amount ) => "$" + amount.ToString( "N0" );
public override void Tick()
{
base.Tick();
PositionOverHand();
}
// Project this seat's hand spot to the screen and pin the card just above it.
private void PositionOverHand()
{
var scene = Sandbox.Game.ActiveScene;
var cam = scene?.Camera;
var table = scene?.Get<TableAnchor>();
if ( cam is null || table is null || Game is null || Seat is null || !Seat.Occupied )
{
SetClass( "hidden", true );
return;
}
var occupied = Game.Seats.Where( s => s.Occupied ).OrderBy( s => s.Index ).ToList();
int slot = occupied.FindIndex( s => s == Seat );
if ( slot < 0 ) { SetClass( "hidden", true ); return; }
// Prefer the game's own seat layout so the tag tracks the hand exactly
var world = (Game.SeatHandSpot( Seat ) ?? table.SeatSpot( slot, occupied.Count )).Position;
var screen = cam.PointToScreenPixels( world, out bool behind );
SetClass( "hidden", behind );
if ( behind ) return;
// PointToScreenPixels is in physical pixels; the panel works in scaled units.
// The game decides the vertical drop so it can reposition tags per state (e.g. lift the local tag
// before the deal to clear HUD chrome) without this base UI needing to know any game specifics.
float drop = Game.SeatTagDrop( Seat, DropBelow );
Style.Left = Length.Pixels( screen.x * ScaleFromScreen - HalfWidth );
Style.Top = Length.Pixels( screen.y * ScaleFromScreen + drop );
}
private string PlayerClass()
{
var cls = "";
if ( Seat is { IsLocal: true } ) cls += " you";
if ( Game is { } g && g.State == GameState.PlayerTurn && Seat is { } s && s.Index == g.CurrentSeat ) cls += " acting";
if ( Seat is { Folded: true } ) cls += " folded";
return cls;
}
private static string ToneClass( SeatBadgeTone tone ) => tone switch
{
SeatBadgeTone.Good => "good",
SeatBadgeTone.Bad => "bad",
SeatBadgeTone.Gold => "gold",
_ => "",
};
protected override int BuildHash() => HashCode.Combine(
Game?.State, Game?.CurrentSeat, Game?.UsesBetting,
Seat?.Index, Seat?.Currency, Seat?.Wager,
HashCode.Combine( Seat?.Folded, Seat?.AllIn, Seat?.Ready ) );
}