Client-side component attached to the camera handling card hover, clicks, drags and drops. It highlights hovered cards, arms pending drags on press, moves grabbed cards with smoothing and banking, detects valid drop targets, and sends requests to the game/board (RequestMove/RequestThrow/etc.) or performs local interactions for seated games.
namespace CardGames;
/// <summary>
/// Local pointer handling for cards: highlights the card under the cursor, and - for drag games like
/// Solitaire - lets you pick up a card (plus the run above it) and drop it on another pile. Lives on
/// the camera. The actual move is validated/applied host-side via <see cref="BoardGame.RequestMove"/>.
/// </summary>
public sealed class CardHover : Component
{
private CardObject _hovered;
private readonly List<CardObject> _dragging = new();
private Rotation _dragFlat; // the resting rotation at grab time, for banking
private Pile _sourcePile;
private int _fromIndex;
private Transform _dragOrigin; // a loose hand card's home pose, to ease back to if it isn't played
private Vector3 _dragPos; // smoothed follow position
private Rotation _dragRot; // smoothed banking rotation
private Vector3 _dragVelocity; // last frame's follow velocity, for throwing on a no-target drop
private CardObject _dropTarget; // top card of the pile currently under the drag, for glow
private const float ClickDist = 4f; // world units below which a press/release counts as a click, not a drag
private const float ThrowMinSpeed = 40f; // release speed (u/s) above which a hand card is flung; a slower release just sets it back down
private CardObject _lastClickCard; // the card the previous left-click landed on, for double-click detection
private float _lastClickTime;
private const float DoubleClickTime = 0.35f; // max seconds between two clicks on a card to count as a double-click
private bool _pendingDrag; // pressed on a grabbable card; waiting to see drag vs click
private CardObject _pendingCard; // the card that press landed on
private Vector2 _pressScreen; // cursor position (pixels) at press, to measure the move threshold
private const float DragThresholdPixels = 6f; // cursor must travel this far before a press becomes a drag
private static readonly Color ValidTint = new( 0.5f, 1f, 0.5f );
private const float FollowSpeed = 16f; // higher = snappier follow
private const float MaxBank = 18f; // degrees the cards lean into the drag
private const float ThrowScale = 1f; // drag velocity → launch velocity (tunable feel)
private const float SpinScale = 0.05f; // how much sideways throw becomes tumble
public CardObject Hovered => _hovered;
public bool IsDragging => _dragging.Count > 0;
// The active game, and (when it's a pile game) its BoardGame face for the drag pipeline.
private CardGame Game => Scene.Get<CardGame>();
private BoardGame Board => Game as BoardGame;
// The scene's sound catalog. Pickup/drop are local gestures, so they play here directly (not over RPC).
private GameSounds Sounds => Scene.Get<GameSounds>();
protected override void OnUpdate()
{
var camera = Scene.Camera;
if ( camera is null ) return;
var ray = camera.ScreenPixelToRay( Mouse.Position );
if ( IsDragging )
{
UpdateDrag( ray );
return;
}
// A press over a grabbable card arms a drag but doesn't lift the card until the cursor moves past a
// small threshold - so a plain click (or double-click) leaves it sitting in place.
if ( _pendingDrag )
{
UpdatePending( ray );
return;
}
Hover( InteractableUnder( ray ) );
if ( Input.Pressed( "attack1" ) )
OnPressed( ray );
}
// A left-click began. A quick second click on the same card is a double-click shortcut (handed to the game,
// e.g. Solitaire sends an Ace home); a grabbable card arms a pending drag; anything else is a pile click.
private void OnPressed( Ray ray )
{
var card = _hovered;
bool isDouble = card is not null && card == _lastClickCard && Time.Now - _lastClickTime < DoubleClickTime;
_lastClickCard = isDouble ? null : card; // consume on a double so a third quick click doesn't re-fire
_lastClickTime = Time.Now;
if ( isDouble )
{
if ( Board is { } board && card.Pile is not null )
board.RequestDoubleClick( card.Pile, card.StackIndex );
return;
}
if ( card is not null && CanStartDrag( card ) )
{
_pendingCard = card;
_pressScreen = Mouse.Position;
_pendingDrag = true;
}
else
{
TryClick( ray ); // not a draggable card → treat as a click on a pile (e.g. the stock)
}
}
// Could a drag begin on this card right now? (Mirrors what TryBeginDrag will accept once committed.)
private bool CanStartDrag( CardObject card )
{
if ( Board is { } board )
return card.Pile is not null && board.CanGrab( card.Pile, card.StackIndex );
// Seated: a card you can play, or - so you can fling it - any of your own loose hand cards.
return Game is { } game && (game.CanInteractCard( card ) || (game.AllowThrowing && game.OwnsCard( card )));
}
// Button held after a press on a grabbable card: commit to the drag once the cursor moves far enough,
// or - if released first - treat it as a click (which, for a seated hand card, plays it).
private void UpdatePending( Ray ray )
{
if ( Input.Released( "attack1" ) )
{
if ( Board is null && Game is { } game && _pendingCard.IsValid() && game.CanInteractCard( _pendingCard ) )
game.InteractCard( _pendingCard ); // seated: a click on a playable hand card plays it
ClearPending();
return;
}
if ( Vector2.DistanceBetween( Mouse.Position, _pressScreen ) >= DragThresholdPixels )
{
var card = _pendingCard;
ClearPending();
if ( card.IsValid() )
TryBeginDrag( card );
}
}
private void ClearPending()
{
_pendingDrag = false;
_pendingCard = null;
}
// The card the cursor should highlight: a grabbable/clickable card in a pile game, or - in a seated game
// (poker, blackjack) - the hand card under the cursor, so hovering it pulls it to the front.
private CardObject InteractableUnder( Ray ray )
{
// Seated games hover via the flat footprint (below), not the 3D collider.
if ( Board is not { } game )
return SeatedHoverTarget( ray );
var card = TraceCard( ray );
if ( card is null || card.Pile is null ) return null;
var pile = card.Pile;
if ( game.CanGrab( pile, card.StackIndex ) )
return card;
// Clickable piles (e.g. the stock) only highlight their very top card.
if ( game.CanClick( pile ) && pile.Top == card )
return card;
return null;
}
// Hover for seated games: intersect the cursor with the table plane and pick the front-most card whose
// flat footprint contains that point. The hover lift is purely vertical, and this test only uses the
// card's horizontal axes - so a lifted card can't shift itself out from under the cursor (no jitter).
private CardObject SeatedHoverTarget( Ray ray )
{
float z = Scene.Get<TableAnchor>()?.WorldPosition.z ?? 0f;
if ( new Plane( new Vector3( 0, 0, z ), Vector3.Up ).Trace( ray ) is not Vector3 p )
return null;
float hw = CardMesh.Width * 0.5f, hh = CardMesh.Height * 0.5f;
CardObject best = null;
float bestZ = float.MinValue;
foreach ( var c in Scene.GetAllComponents<CardObject>() )
{
var rel = p - c.WorldPosition;
float fx = Vector3.Dot( rel, c.WorldRotation.Forward ); // along the card's width
float fy = Vector3.Dot( rel, c.WorldRotation.Left ); // along the card's height
if ( MathF.Abs( fx ) > hw || MathF.Abs( fy ) > hh ) continue;
if ( c.WorldPosition.z > bestZ ) { bestZ = c.WorldPosition.z; best = c; } // front-most wins
}
return best;
}
private void TryClick( Ray ray )
{
// PileUnder walks past the desk to the pile's own collider, so an empty pile (e.g. a drained stock
// you click to recycle the waste) still registers - a plain first-hit trace would stop at the desk.
var pile = PileUnder( ray );
if ( pile is null ) return;
// Board games click any pile (the stock); seated games only the piles the game allows (the draw pile).
if ( Board is { } board )
board.RequestClick( pile );
else if ( Game is { } game && game.CanInteractPile( pile ) )
game.InteractPile( pile );
}
private void Hover( CardObject card )
{
if ( !card.IsValid() ) card = null;
if ( card == _hovered ) return;
if ( _hovered.IsValid() ) _hovered.SetHover( 0f );
_hovered = card;
if ( _hovered.IsValid() ) _hovered.SetHover( 1f );
}
private CardObject TraceCard( Ray ray )
{
var tr = Scene.Trace.Ray( ray, 5000f ).Run();
return tr.GameObject?.Components.Get<CardObject>();
}
private bool TryBeginDrag( CardObject card )
{
// Board games grab a card plus the run stacked above it from its pile; seated games grab a single
// loose hand card the game has opted in to (Crazy Eights). The rest of the drag is shared.
if ( Board is { } board )
{
if ( card.Pile is null || !board.CanGrab( card.Pile, card.StackIndex ) ) return false;
_sourcePile = card.Pile;
_fromIndex = card.StackIndex;
_dragging.Clear();
_dragging.AddRange( Scene.GetAllComponents<CardObject>()
.Where( c => c.Pile == _sourcePile && c.StackIndex >= _fromIndex )
.OrderBy( c => c.StackIndex ) );
_dragFlat = _sourcePile.WorldRotation;
}
else if ( Game is { } game && (game.CanInteractCard( card ) || (game.AllowThrowing && game.OwnsCard( card ))) )
{
_sourcePile = null;
_fromIndex = 0;
_dragOrigin = new Transform( card.WorldPosition, card.WorldRotation );
_dragging.Clear();
_dragging.Add( card );
// Bank around the card's upright hand-slot pose, not its live rotation - so a card picked up off the
// table after a toss rights itself face-up in your grip instead of staying at its tumbled angle.
_dragFlat = card.RestPose.Rotation;
}
else
{
return false;
}
Hover( null );
foreach ( var c in _dragging )
{
c.CancelThrow(); // grabbing a mid-air card cancels its throw
c.GameObject.Tags.Set( "dragging", true ); // excluded from the drop trace + held by its owner
}
Sounds?.Play( TableSound.CardPickup, card.WorldPosition ); // local gesture - the grabbing player hears it
// Ease in from the card's current pose - so a card snatched mid-throw flips upright into your grip.
_dragPos = _dragging[0].WorldPosition;
_dragRot = _dragging[0].WorldRotation;
return true;
}
// Is the pile under the cursor a legal drop right now? Board games defer to CanDrop; seated games accept
// only the game's nominated drop pile (the discard).
private bool DropValid( Pile drop )
{
if ( drop is null ) return false;
if ( Board is { } board ) return drop != _sourcePile && board.CanDrop( _sourcePile, _fromIndex, drop );
return Game is { } game && drop == game.InteractDropPile;
}
private void UpdateDrag( Ray ray )
{
// A card we're holding can be destroyed out from under us (the round ended, the hand was cleared, the
// host reclaimed it). Drop any that went invalid before we touch their transforms; if none are left,
// the drag is over.
_dragging.RemoveAll( c => !c.IsValid() );
if ( _dragging.Count == 0 ) { CancelDrag(); return; }
// Where the cursor hits a horizontal plane just above the table.
float z = (Scene.Get<TableAnchor>()?.WorldPosition.z ?? 0f) + 6f;
var target = new Plane( new Vector3( 0, 0, z ), Vector3.Up ).Trace( ray ) ?? _dragPos;
float t = 1f - MathF.Exp( -FollowSpeed * Time.Delta );
// Lerp toward the cursor; the per-frame movement is our velocity.
var previous = _dragPos;
_dragPos = Vector3.Lerp( _dragPos, target, t );
var velocity = ((_dragPos - previous) / MathF.Max( Time.Delta, 1e-4f )).WithZ( 0 );
_dragVelocity = velocity; // remembered for a throw if this drag ends over nothing
// Bank into the motion - leans more the faster you drag, eases back to flat when still.
var flat = _dragFlat;
var wanted = flat;
float speed = velocity.Length;
if ( speed > 1f )
{
var axis = Vector3.Cross( Vector3.Up, velocity.Normal );
wanted = Rotation.FromAxis( axis, MathF.Min( speed * 0.35f, MaxBank ) ) * flat;
}
_dragRot = Rotation.Slerp( _dragRot, wanted, t );
// Drop feedback: is the pile under the cursor a legal target?
var drop = DropPileUnder( ray );
bool valid = DropValid( drop );
for ( int i = 0; i < _dragging.Count; i++ )
{
// Offset each card from the lowest one in the drag's own frame, so the run banks as one rigid
// stack pivoting around the bottom card - not each card tilting about its own centre.
var offset = new Vector3( 0, -i * (CardMesh.Height * 0.28f), i * 0.1f );
_dragging[i].WorldPosition = _dragPos + _dragRot * offset;
_dragging[i].WorldRotation = _dragRot;
_dragging[i].Highlight( valid ? ValidTint : Color.White, valid ? 1f : 0f );
}
// Glow the target pile's top card so it's clear where the run will land.
var newTarget = valid ? drop?.Top : null;
if ( newTarget != _dropTarget )
{
_dropTarget?.ClearHighlight();
_dropTarget = newTarget;
_dropTarget?.SetHover( 1f );
}
if ( Input.Released( "attack1" ) )
EndDrag( ray );
}
private void EndDrag( Ray ray )
{
Sounds?.Play( TableSound.CardDrop, _dragPos ); // local gesture - the dropping player hears it
var drop = DropPileUnder( ray );
if ( Board is { } board )
{
if ( drop is null )
{
// Dropped on nothing - toss the run with the drag's momentum; it tumbles, then lerps home.
var velocity = _dragVelocity * ThrowScale;
var spin = Vector3.Cross( Vector3.Up, _dragVelocity ) * SpinScale; // tumble in the travel direction
board.RequestThrow( _sourcePile, _fromIndex, velocity, spin );
}
else
{
// A pile (the source, or another) is under the cursor: the host validates the move or snaps back.
board.RequestMove( _sourcePile, _fromIndex, drop );
}
}
else if ( Game is { } game )
{
// Seated hand card: a drop on the discard (or a barely-moved click) plays a playable card; releasing
// it with a real flick flings one of your own cards; any gentler release just sets it back in its
// hand slot (so you can pick a tossed card off the table and put it back instead of re-throwing it).
var card = _dragging[0];
bool overDrop = drop is not null && drop == game.InteractDropPile;
bool click = Vector3.DistanceBetween( _dragPos, _dragOrigin.Position ) < ClickDist;
bool flung = _dragVelocity.Length >= ThrowMinSpeed;
if ( game.CanInteractCard( card ) && (overDrop || click) )
{
game.InteractCard( card );
}
else if ( flung && game.AllowThrowing && game.OwnsCard( card ) )
{
// Toss it with the drag's momentum; it tumbles, then lerps back to its hand slot.
var velocity = _dragVelocity * ThrowScale;
var spin = Vector3.Cross( Vector3.Up, _dragVelocity ) * SpinScale; // tumble in the travel direction
game.RequestThrowCard( card, velocity, spin );
}
else
{
card.MoveTo( card.RestPose ); // set it back down in its hand slot - no physics
}
}
CancelDrag();
}
// Release everything held by the drag and clear its feedback. Safe to call when cards have already been
// destroyed (it skips invalid ones), so it doubles as the bail-out when a held card vanishes mid-drag.
private void CancelDrag()
{
foreach ( var c in _dragging )
{
if ( !c.IsValid() ) continue;
c.GameObject.Tags.Set( "dragging", false );
c.ClearHighlight();
}
_dragging.Clear();
_dropTarget?.ClearHighlight();
_dropTarget = null;
}
private Pile PileUnder( Ray ray )
{
// Walk every hit front-to-back and take the first that resolves to a pile. An empty pile's collider
// is sunk below the table surface (so cards win the trace when the pile has any), which means the
// desk's own collider sits in front of it - stopping at the first hit would return the desk (no Pile),
// so empty foundations/columns couldn't take a drop and an empty stock couldn't be clicked to recycle.
foreach ( var tr in Scene.Trace.Ray( ray, 5000f ).WithoutTags( "dragging" ).RunAll() )
{
var pile = tr.GameObject?.Components.Get<Pile>()
?? tr.GameObject?.Components.Get<CardObject>()?.Pile;
if ( pile is not null ) return pile;
}
return null;
}
// The pile a dragged run should land on. An exact hit always wins; only when the cursor lands on no pile
// at all do we sweep a fattened sphere and snap to the nearest pile the game marks forgiving (Solitaire's
// foundations) - so a card clipped just past a small target still banks instead of being thrown back.
private Pile DropPileUnder( Ray ray )
{
var exact = PileUnder( ray );
if ( exact is not null || Board is not { } board ) return exact;
float radius = SnapRadius( board );
if ( radius <= 0f ) return null;
foreach ( var tr in Scene.Trace.Ray( ray, 5000f ).Radius( radius ).WithoutTags( "dragging" ).RunAll() )
{
var pile = tr.GameObject?.Components.Get<Pile>()
?? tr.GameObject?.Components.Get<CardObject>()?.Pile;
if ( pile is not null && board.DropSnapRadius( pile ) > 0f )
return pile; // RunAll is distance-sorted, so this is the nearest forgiving target
}
return null;
}
// The largest forgiving catch radius any pile on the board declares (0 if none opt in).
private float SnapRadius( BoardGame board )
{
float max = 0f;
foreach ( var pile in Scene.GetAllComponents<Pile>() )
max = MathF.Max( max, board.DropSnapRadius( pile ) );
return max;
}
}