The Plotter Hand UI component manages the visual state of cards in a UI. It calculates and applies smoothed rotations and vertical offsets to cards based on whether they are hovered or selected.
@namespace CitySim.DesignSystem
@using CitySim.Punchboard
@using Sandbox.UI
@inherits Panel
<root class="plotter-hand"><div class="plotter-hand-cards">@ChildContent</div></root>
@code
{
/// <summary>Degrees between neighbouring cards.</summary>
[Parameter] public float FanStep { get; set; } = 7f;
/// <summary>How far a hovered card rises, in pixels.</summary>
[Parameter] public float HoverLift { get; set; } = 18f;
/// <summary>How far a selected card rises, in pixels.</summary>
[Parameter] public float SelectedLift { get; set; } = 30f;
// Per-card eased rotation and vertical offset.
readonly Dictionary<Panel, (float rot, float y)> pose = new();
public override void Tick()
{
base.Tick();
// Updating a retiring card's transform keeps its CSS transition alive indefinitely.
// Hand exits use the game's card-flight feedback; retired faces can disappear immediately.
foreach (var retired in Descendants.OfType<PlotterCard>().Where(c=>c.IsDeleting).ToArray()) retired.Delete(true);
var cards = Descendants.OfType<PlotterCard>().Where(c=>!c.IsDeleting).Cast<Panel>().ToList();
var n = cards.Count;
pose.Keys.Where( p => !cards.Contains( p ) ).ToList().ForEach( p => pose.Remove( p ) );
for ( var i = 0; i < n; i++ )
{
var card = cards[i];
var offset = i - (n - 1) * 0.5f;
// Hand slots remain stationary while their cards move, keeping hover stable.
var slot = card.Parent;
var inSlot = slot != null && slot.HasClass( "hand-slot" );
var active = card.HasClass( "selected" ) || card.HasFocus || card.Descendants.Any( p => p.HasFocus ) || (inSlot ? slot.HasHovered : card.HasHovered);
var targetRot = active ? 0f : offset * FanStep;
// Rest below the board edge; hover straightens and reveals the full face.
var targetY = active ? (card.HasClass("selected") ? -SelectedLift : -HoverLift) : 72f + 6f * MathF.Abs(offset);
pose.TryGetValue( card, out var current );
if ( !pose.ContainsKey( card ) ) current = (targetRot, targetY);
var k = Pb.Smooth( active ? 24f : 17f, Time.Delta );
current = (current.rot + (targetRot - current.rot) * k, current.y + (targetY - current.y) * k);
pose[card] = current;
card.Style.Set( "transform", $"translateY({Pb.F( current.y )}px) rotate({Pb.F( current.rot )}deg)" );
if ( inSlot ) slot.Style.Set( "z-index", active ? "5" : "1" );
else card.Style.Set( "z-index", active ? "5" : "1" );
}
}
}