Cards/CardObject.cs

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.

NetworkingFile Access
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;
	}
}