A world-card component for a card game. It stores synced visibility and pile/stack info, manages rendering (materials, textures), animates movement, hover/pulse/glow, flipping between face/back, and handles a cosmetic physics "throw" that temporarily adds a Rigidbody and then eases the card back to its slot.
namespace CardGames;
/// <summary>
/// A card in the world.
/// </summary>
public sealed class CardObject : Component
{
/// <summary>
/// Is the value shown to everyone? (A public, face-up card)
/// </summary>
[Sync] public bool FaceUp { get; set; }
/// <summary>
/// The publicly known face index, or -1 if not public.
/// </summary>
[Sync] public int FaceIndex { get; set; } = -1;
/// <summary>
/// Which <see cref="Pile"/> this card belongs to (for drag/drop games), or null. Synced, so any
/// client can read pile membership directly - no id lookup needed.
/// </summary>
[Sync] public Pile Pile { get; set; }
/// <summary>
/// Position within its pile, bottom (0) to top. Lets clients grab a card + the run above it.
/// </summary>
[Sync] public int StackIndex { get; set; }
/// <summary>
/// Host-side bookkeeping: the card's true value. Never synced.
/// </summary>
public Card Value { get; set; }
private int _privateFaceIndex = -1; // owner-only knowledge, e.g. poker hole cards
private int _shown = int.MinValue; // the face index currently rendered
private ModelRenderer _renderer;
private Material _material;
// Movement easing + a physical face-down/up flip. A face-down card is simply rotated 180°;
// revealing eases it back to 0° (one clean half-flip), with a little lift at the midpoint.
private Vector3 _targetPos;
private Rotation _targetRot;
private Vector3 _pos; // eased travel position (the flip lift is added on top)
private float _flip; // current flip angle: 0 = face up, 180 = face down
private bool _moving;
private const float MoveSpeed = 12f;
private const float FlipSpeed = 540f; // degrees/sec for the reveal flip
private const float FlipLiftMax = 4f; // how high the card rises at the midpoint of a flip
// A one-shot "look here" pulse: tints the card gold and lifts it a little over ~half a second, then
// settles. Purely cosmetic and local (driven each frame); used by the hint system.
private float _pulseAge = -1f; // seconds since triggered; < 0 = not pulsing
private const float PulseDuration = 0.5f;
private const float PulseRise = 2.5f; // peak lift in units
// Glow: a steady gold shimmer (no lift) to indicate playability.
private bool _glow;
// Hover: a card not in a pile (a poker/blackjack hand) rises toward the camera while pointed at, so the
// hovered card pulls in front of the ones it overlaps.
private float _hover; // smoothed 0..1
private float _hoverTarget; // set by SetHover
private const float HoverLift = 1.5f; // peak lift in units when fully hovered (just enough to clear neighbours)
// A no-target drop tosses the card as a physics body (see Throw): a Rigidbody owns the transform while
// it tumbles, then once it settles - or a hard time cap hits - the physics is stripped and the card
// eases home to its pile slot. The card never leaves its pile logically; this is pure flourish.
private bool _thrown;
private float _throwAge; // seconds since launch
private float _restAge; // seconds spent ~stationary
private Transform? _throwHome; // a pile-less (hand) card's slot to ease back to once it settles
private const float ThrowMaxTime = 2f; // hard cap: recall no matter what
private const float ThrowRestTime = 1f; // recall once stationary this long
private const float ThrowRestSpeed = 8f; // linear & angular speed under which it counts as "at rest"
// The active game, discovered from the scene (no global). Gives us the deck to render and the card size.
private CardGame Game => Scene.Get<CardGame>();
private DeckDefinition Deck => Game?.Deck;
protected override void OnAwake()
{
// Make sure the shared mesh is this game's card size before we grab it (clients build here too).
CardMesh.SetSize( CardMesh.DefaultWidth * (Game?.CardScale ?? 1f) );
_renderer = GetOrAddComponent<ModelRenderer>();
var collider = GetOrAddComponent<BoxCollider>();
collider.Scale = new Vector3( CardMesh.Width, CardMesh.Height, 0.2f );
_pos = _targetPos = WorldPosition;
_targetRot = WorldRotation;
// No GPU off-screen (unit tests / dedicated server). The card's synced state still runs - it just
// has no model/material/texture. Same guard the engine's own renderers use.
if ( Application.IsHeadless ) return;
_renderer.Model = CardMesh.Shared;
_material = Material.Create( $"card_{GameObject.Id}", "card.shader" );
_renderer.MaterialOverride = _material;
// Draw something this frame, but leave _shown uninitialised so the first sync to this card's real
// face (set just after spawn) snaps instantly - only a later hidden↔shown change animates a flip.
Rebuild( ShownIndex() );
_shown = int.MinValue;
}
/// <summary>
/// Animate the card to a pose (deal/move/re-flow). The host drives this; clients get it replicated.
/// </summary>
public void MoveTo( Transform pose )
{
if ( GameObject.Network.IsProxy )
{
MoveToOwner( pose );
return;
}
_pos = WorldPosition; // start from where the card currently is (e.g. the cursor after a drag), not its old slot
_targetPos = pose.Position;
_targetRot = pose.Rotation;
_moving = true;
}
[Rpc.Owner]
private void MoveToOwner( Transform pose ) => MoveTo( pose );
/// <summary>
/// The pose this card eases to at rest - its pile/hand slot (the flat layout pose, before the flip and any
/// hover lift). Use this to send a card home, rather than its live world transform, which may be a
/// hover-lifted spot or - if it's been tossed - wherever the physics left it on the table.
/// </summary>
public Transform RestPose => new( _targetPos, _targetRot );
/// <summary>
/// Place the card instantly (no animation) - e.g. its spawn pose.
/// </summary>
public void SnapTo( Transform pose )
{
// not our card, let its owner place it
if ( GameObject.Network.IsProxy )
{
SnapToOwner( pose );
return;
}
_pos = _targetPos = pose.Position;
_targetRot = pose.Rotation;
_moving = false;
WorldPosition = _pos;
WorldRotation = FlipRotation();
}
[Rpc.Owner]
private void SnapToOwner( Transform pose ) => SnapTo( pose );
/// <summary>
/// Host: turn this card loose as a physics body with an initial velocity (a no-target drop). It tumbles
/// under gravity, settles on the table, then - via <see cref="UpdateThrow"/> - strips its physics and
/// lerps back to its pile slot. The card stays in its pile the whole time; this is purely cosmetic.
/// </summary>
public void Throw( Vector3 velocity, Vector3 angularVelocity )
{
// Run the physics on the card's owner so its transform replicates to everyone (the host may toss a
// remotely-owned hand card). Redirect before touching the Rigidbody so no stray body lands on a proxy.
if ( GameObject.Network.IsProxy )
{
ThrowOwner( velocity, angularVelocity );
return;
}
// A pile card recalls to its pile slot; a loose hand card has no pile, so remember the slot it was
// resting at to ease back to. _targetPos/_targetRot still hold that slot - OnUpdate leaves them be
// while the card is being dragged.
_throwHome = Pile.IsValid() ? null : new Transform( _targetPos, _targetRot );
var rb = GetOrAddComponent<Rigidbody>();
rb.MotionEnabled = true;
rb.Gravity = true;
rb.EnhancedCcd = true; // a thin, fast-tumbling card can tunnel through the table at normal toss speeds
rb.Velocity = velocity;
rb.AngularVelocity = angularVelocity;
_thrown = true;
_throwAge = 0f;
_restAge = 0f;
}
[Rpc.Owner]
private void ThrowOwner( Vector3 velocity, Vector3 angularVelocity ) => Throw( velocity, angularVelocity );
/// <summary>
/// Cancel an in-flight throw and strip the physics body - e.g. when the card is grabbed again mid-toss,
/// so the drag doesn't fight the Rigidbody. No-op if the card isn't thrown.
/// </summary>
public void CancelThrow()
{
if ( !_thrown ) return;
StripThrow();
}
/// <summary>
/// Set the shown face instantly - no flip animation. Call before <see cref="SnapTo"/> when restoring.
/// </summary>
public void SnapFace()
{
int desired = ShownIndex();
_flip = desired >= 0 ? 0f : 180f; // 0 = face up, 180 = face down
if ( Rebuild( desired ) ) _shown = desired;
}
protected override void OnUpdate()
{
SyncFace(); // texture + flip angle; runs everywhere (texture/flip timing are local)
UpdatePulse(); // gold "look here" flash; local + cosmetic, harmless when idle
if ( GameObject.Network.IsProxy ) return; // clients receive the replicated transform
if ( GameObject.Tags.Has( "dragging" ) ) return; // the dragger drives position directly
if ( _thrown ) { UpdateThrow(); return; } // a Rigidbody owns the transform while airborne
if ( _moving )
{
float t = 1f - MathF.Exp( -MoveSpeed * Time.Delta );
_pos = Vector3.Lerp( _pos, _targetPos, t );
if ( Vector3.DistanceBetween( _pos, _targetPos ) < 0.05f ) { _pos = _targetPos; _moving = false; }
}
else
{
_pos = _targetPos;
}
_hover = _hover.Approach( _hoverTarget, Time.Delta * 10f );
float lift = MathF.Sin( _flip.DegreeToRadian() ) * FlipLiftMax + PulseAmount() * PulseRise;
if ( Pile is null ) lift += _hover * HoverLift; // hand cards (no pile) pull to the front on hover
WorldPosition = _pos + Vector3.Up * lift;
WorldRotation = FlipRotation();
}
// While thrown, the Rigidbody drives the transform. Watch for it to settle (or time out), then strip the
// physics and ease the card home to its slot.
private void UpdateThrow()
{
_throwAge += Time.Delta;
var rb = GetComponent<Rigidbody>();
bool atRest = rb is null
|| (rb.Velocity.Length < ThrowRestSpeed && rb.AngularVelocity.Length < ThrowRestSpeed);
_restAge = atRest ? _restAge + Time.Delta : 0f;
if ( _restAge < ThrowRestTime && _throwAge < ThrowMaxTime )
return;
StripThrow();
// Ease home from wherever it landed (MoveTo seeds _pos from the current WorldPosition).
if ( Pile.IsValid() )
MoveTo( Pile.SlotPose( StackIndex ) );
else if ( _throwHome is { } home )
MoveTo( home ); // loose hand card: ease back to the slot it was thrown from
else
_pos = _targetPos = WorldPosition;
}
// Remove the physics body and clear the thrown state. Disable motion before the (deferred) destroy so a
// lingering body can't nudge the transform for a frame.
private void StripThrow()
{
if ( GetComponent<Rigidbody>() is { } rb )
{
rb.MotionEnabled = false;
rb.Destroy();
}
_thrown = false;
LocalScale = 1f; // undo whatever hover lift was frozen in place for the duration of the toss (see SetHover)
}
// 0 → 1 → 0 bump across the pulse's lifetime.
private float PulseAmount() => _pulseAge < 0f ? 0f : MathF.Sin( _pulseAge / PulseDuration * MathF.PI );
// Steady breathing level for the sustained glow (no lift) - a gentle gold shimmer, never fully off.
private static float GlowAmount() => 0.3f + 0.4f * (0.5f + 0.5f * MathF.Sin( Time.Now * 4f ));
// Advance the gold pulse and drive the shader's g_flHighlight; clears itself when finished. When no
// one-shot pulse is running, the sustained glow (if set) drives the same attribute instead.
private void UpdatePulse()
{
if ( _pulseAge >= 0f )
{
_pulseAge += Time.Delta;
if ( _pulseAge >= PulseDuration )
{
_pulseAge = -1f;
_renderer.SceneObject?.Attributes.Set( "g_flHighlight", _glow ? GlowAmount() : 0f );
return;
}
_renderer.SceneObject?.Attributes.Set( "g_flHighlight", PulseAmount() );
return;
}
if ( _glow )
_renderer.SceneObject?.Attributes.Set( "g_flHighlight", GlowAmount() );
}
// Face-down = the card rotated 180° about its edge; face-up = flat.
private Rotation FlipRotation() => Rotation.FromAxis( _targetRot.Right, _flip ) * _targetRot;
// Ease the flip toward face-up (0°) / face-down (180°) and swap the texture while the front is turned away.
private void SyncFace()
{
int desired = ShownIndex();
float flipTarget = desired >= 0 ? 0f : 180f;
if ( _shown == int.MinValue ) // first establish: snap, no flip
{
if ( Rebuild( desired ) ) { _shown = desired; _flip = flipTarget; }
return;
}
_flip = _flip.Approach( flipTarget, FlipSpeed * Time.Delta );
// Swap to the new face only once the front is facing away (past edge-on), so the change is unseen.
if ( _shown != desired && _flip > 90f && Rebuild( desired ) )
_shown = desired;
}
private int ShownIndex()
{
if ( FaceUp ) return FaceIndex;
// Face down on the table, but its owner may privately know what it is.
if ( _privateFaceIndex >= 0 && GameObject.Network.IsOwner ) return _privateFaceIndex;
return -1; // renders the back
}
private bool Rebuild( int index )
{
if ( _material is null ) return false; // headless: no material was created, nothing to draw
var deck = Deck;
if ( deck is null ) return false;
var tex = CardFaceRenderer.BuildCard( deck, index );
_material.Set( "Front", tex.Front );
_material.Set( "Back", tex.Back );
_material.Set( "Metal", tex.Metal );
if ( _renderer.SceneObject is { } so )
{
so.Attributes.Set( "MetalScale", deck.MetallicInk );
so.Attributes.Set( "BackTint", deck.BackColor );
so.Attributes.Set( "TintBack", deck.TintBack ? 1f : 0f );
}
return true;
}
/// <summary>
/// Host: turn this card face up for everyone.
/// </summary>
public void RevealPublic()
{
if ( !GameObject.Network.IsOwner )
GameObject.Network.TakeOwnership();
FaceUp = true;
FaceIndex = Value.FaceIndex;
}
/// <summary>
/// Host to owning client only: privately reveal this card's value (hidden hands).
/// </summary>
[Rpc.Owner]
public void RevealToOwner( int faceIndex ) => _privateFaceIndex = faceIndex;
/// <summary>
/// Apply a 0..1 hover amount. The card shader reads "g_flHover" and draws a silver rim/sheen;
/// we add a tiny scale lift for feel. (No tint - the effect lives in the shader.)
/// </summary>
public void SetHover( float t )
{
_hoverTarget = t; // drives the to-front lift in OnUpdate (hand cards only)
_renderer.SceneObject?.Attributes.Set( "g_flHover", t );
// Skip while a Rigidbody owns this card (mid-toss) - rescaling a live physics body's collider makes
// the simulation misbehave (jitter/tunnelling). Purely cosmetic, so it's fine to just drop the lift.
if ( !_thrown )
LocalScale = 1f + 0.04f * t; // self-restoring: t=0 → scale 1
}
/// <summary>
/// Play a one-shot gold "look here" pulse - tint + a small lift over half a second. Used by hints.
/// </summary>
public void Pulse() => _pulseAge = 0f;
/// <summary>
/// The face index the local client can legitimately see (a public face, or this owner's private knowledge), or -1 if it only shows a back.
/// Lets a client read its own hand for playability cues.
/// </summary>
public int KnownFace => ShownIndex();
/// <summary>
/// Toggle the sustained "playable" glow (the hint highlight shader)
/// </summary>
public void SetGlow( bool on )
{
if ( _glow == on ) return;
_glow = on;
if ( !on && _pulseAge < 0f )
_renderer?.SceneObject?.Attributes.Set( "g_flHighlight", 0f );
}
/// <summary>
/// Tint + glow this card a colour - used for drag drop-target feedback (green = valid).
/// </summary>
public void Highlight( Color tint, float glow )
{
_renderer.Tint = tint;
_renderer.SceneObject?.Attributes.Set( "g_flHover", glow );
}
/// <summary>
/// Reset any hover/highlight back to a plain card.
/// </summary>
public void ClearHighlight()
{
_renderer.Tint = Color.White;
_renderer.SceneObject?.Attributes.Set( "g_flHover", 0f );
if ( !_thrown )
LocalScale = 1f;
}
}