Player/Car/Gameplay/CarRespawn.cs

Component on a player car that handles non-destructive respawning. It records the last grounded position, computes a safe respawn transform (using the racing line, spawn points, checkpoints), hides/disables the car for a brief period, repositions it, restores physics and visuals, and spawns sound/visual effects.

NetworkingFile Access
using Machines.Components;
using Machines.Events;
using Machines.Race;
using Machines.Systems;

namespace Machines.Player;

/// <summary>
/// Non-destructive respawn: disables the car briefly, repositions it on the racing line, re-enables it
/// </summary>
public sealed class CarRespawn : Component
{
	[RequireComponent]
	public Car Car { get; private set; }

	/// <summary>
	/// How long the car stays hidden/disabled before reappearing on the track.
	/// </summary>
	[Property]
	public float RespawnHideTime { get; set; } = 3f;

	/// <summary>
	/// Minimum time between respawns (debounces overlapping kill-volume triggers).
	/// </summary>
	[Property]
	public float RespawnDebounce { get; set; } = 1.5f;

	/// <summary>
	/// How far above the surface the car is placed on respawn so it settles cleanly.
	/// </summary>
	[Property]
	public float SpawnLift { get; set; } = 16f;

	/// <summary>
	/// Step size backward along the racing line when a respawn spot is blocked.
	/// </summary>
	[Property]
	public float ClearSpotStep { get; set; } = 80f;

	/// <summary>
	/// How many backward steps to try before giving up and using the blocked spot anyway.
	/// </summary>
	[Property]
	public int ClearSpotAttempts { get; set; } = 8;

	/// <summary>
	/// Prefab to spawn as a visual effect when the car reappears after respawn.
	/// </summary>
	[Property]
	public GameObject RespawnEffectPrefab { get; set; }

	/// <summary>
	/// Sound event to play when the car reappears after respawn.
	/// </summary>
	[Property]
	public SoundEvent RespawnSound { get; set; }

	/// <summary>
	/// True while hidden/disabled during respawn, synced to all clients.
	/// </summary>
	[Sync]
	public bool IsRespawning { get; private set; }

	// Owner-only: last grounded transform and cached racing-line projection.
	private Vector3 _lastGroundedPos;
	private float _lastGroundedYaw;
	private bool _hasGrounded;
	private float _lastGroundedPathDistance = -1f;
	private float _reenableAt;
	private float _nextRespawnAllowed;

	// All machines: idempotency + cached components/objects.
	private bool _appliedState;
	private Rigidbody _rb;
	private Collider _collider;
	private GameObject _meshObject;

	private bool IsAuthority => Car.IsValid() && Car.IsAuthority;

	protected override void OnStart()
	{
		_rb = GetComponent<Rigidbody>();
		_collider = GetComponent<Collider>();
		_meshObject = Car.Renderer.IsValid() ? Car.Renderer.GameObject : null;
	}

	protected override void OnFixedUpdate()
	{
		if ( !IsAuthority || IsRespawning || !Car.Movement.IsValid() )
			return;

		// Track last grounded position; no-respawn zones are skipped.
		if ( Car.Movement.IsGrounded && !InNoRespawnZone( WorldPosition ) )
		{
			_lastGroundedPos = WorldPosition;
			_lastGroundedYaw = Car.Movement.Yaw;
			_hasGrounded = true;

			var line = RacingPath.Current?.Optimal;
			if ( line is not null && line.IsValid )
				_lastGroundedPathDistance = line.GetDistanceAtPosition( _lastGroundedPos, _lastGroundedPathDistance, 1500f );
		}
	}

	protected override void OnUpdate()
	{
		ApplyState();

		if ( IsAuthority && IsRespawning && Time.Now >= _reenableAt )
		{
			IsRespawning = false;
			ApplyState();

			// Restore music volume for local player
			if ( Car.IsLocalPlayer )
				MusicManager.Unduck( fadeUp: 1.5f );

			// Nudge bot AI to re-sync to the new position.
			GetComponent<BotBrain>()?.OnRespawned();

			// Visual/audio feedback + notify other systems.
			SpawnRespawnEffect();
			Scene.RunEvent<ICarRespawnListener>( x => x.OnCarRespawned( Car ) );
		}
	}

	/// <summary>
	/// Begin a respawn. Owner-authoritative; a no-op on proxies or while already respawning.
	/// </summary>
	public void Respawn()
	{
		if ( !IsAuthority || IsRespawning || Time.Now < _nextRespawnAllowed )
			return;

		_nextRespawnAllowed = Time.Now + RespawnDebounce;
		IsRespawning = true;
		ApplyState();

		// Duck music while the local player is dead
		if ( Car.IsLocalPlayer )
			MusicManager.Duck( 0.2f, fadeDown: 0.3f );

		var (pos, rot) = ComputeRespawnTransform();

		Car.Movement?.ResetMotion();
		WorldPosition = pos;
		WorldRotation = rot;
		Car.Movement?.SetYaw( rot.Yaw() );

		_reenableAt = Time.Now + RespawnHideTime;
	}

	private (Vector3 pos, Rotation rot) ComputeRespawnTransform()
	{
		static Rotation AlongLine( RacingLine line, float distance )
			=> Rotation.LookAt( line.GetTangentAtDistance( distance ), Vector3.Up );

		// Primary: project last grounded position onto the optimal racing line.
		var line = RacingPath.Current?.Optimal;
		if ( _hasGrounded && line is not null && line.IsValid )
		{
			var d = _lastGroundedPathDistance >= 0f
				? line.GetDistanceAtPosition( _lastGroundedPos, _lastGroundedPathDistance, 1500f )
				: line.GetDistanceAtPosition( _lastGroundedPos );

			// Step backward until spot is clear; never forward (no progress grants).
			for ( int i = 0; i <= ClearSpotAttempts; i++ )
			{
				var cd = d - i * ClearSpotStep;
				if ( TryFindClearSpot( line.GetPointAtDistance( cd ), out var clear ) )
					return (clear, AlongLine( line, cd ));
			}

			// Nothing clear: fall back to the projected point.
			return (SnapToSurface( line.GetPointAtDistance( d ) ), AlongLine( line, d ));
		}

		// Fallback 1: last grounded transform (was on the surface).
		if ( _hasGrounded && TryFindClearSpot( _lastGroundedPos, out var grounded ) )
			return (grounded, Rotation.FromYaw( _lastGroundedYaw ));

		// Fallback 2: authored spawn point (no surface snap).
		var slot = Car.Slot >= 0 ? Car.Slot : 0;
		var sp = Machines.Components.SpawnPoint.ForSlot( slot );
		if ( sp.IsValid() )
			return (sp.WorldPosition, sp.WorldRotation);

		// Fallback 3: nearest clear checkpoint, else nearest regardless.
		Checkpoint nearest = null;
		foreach ( var cp in Scene.GetAll<Checkpoint>().OrderBy( c => (c.WorldPosition - WorldPosition).LengthSquared ) )
		{
			nearest ??= cp;
			if ( TryFindClearSpot( cp.WorldPosition, out var clear ) )
				return (clear, cp.WorldRotation);
		}

		if ( nearest.IsValid() )
			return (SnapToSurface( nearest.WorldPosition ), nearest.WorldRotation);

		// Last resort: last grounded (even if blocked), else stay put.
		if ( _hasGrounded )
			return (SnapToSurface( _lastGroundedPos ), Rotation.FromYaw( _lastGroundedYaw ));

		return (WorldPosition, WorldRotation);
	}

	/// <summary>
	/// Snap a candidate to the ground and validate the car can actually stand there.
	/// </summary>
	private bool TryFindClearSpot( Vector3 basePos, out Vector3 pos )
	{
		return TrySnapToSurface( basePos, out pos ) && IsSpotClear( pos );
	}

	private bool InNoRespawnZone( Vector3 pos )
	{
		return Scene.GetAll<NoRespawnZone>().Any( z => z.IsValid() && z.Contains( pos ) );
	}

	/// <summary>
	/// True if the car can stand at the given surface-snapped position: not inside geometry or another car.
	/// </summary>
	private bool IsSpotClear( Vector3 pos )
	{
		if ( InNoRespawnZone( pos ) )
			return false;

		var movement = Car.Movement;
		if ( !movement.IsValid() )
			return true;

		var radius = movement.Radius;

		// Overlap the same sphere the mover uses so "clear" matches collision.
		var center = pos + Vector3.Up * (radius - movement.HoverHeight + movement.GroundClearance);
		var tr = Scene.Trace.Sphere( radius, center, center )
			.IgnoreGameObjectHierarchy( GameObject )
			.WithCollisionRules( "player" )
			.Run();

		if ( tr.Hit || tr.StartedSolid )
			return false;

		// Spot must be on the navmesh (drivable track); scenes without a navmesh skip this check.
		var nav = Scene.NavMesh;
		if ( nav is not null && nav.IsEnabled )
		{
			var ground = pos - Vector3.Up * SpawnLift;
			var onMesh = nav.GetClosestPoint( ground );
			if ( onMesh is null )
				return false;

			// Reject if nearest mesh point is off to the side or on a different floor.
			if ( (onMesh.Value - ground).WithZ( 0f ).Length > radius )
				return false;
			if ( MathF.Abs( onMesh.Value.z - ground.z ) > 64f )
				return false;
		}

		// Player-player rule is Trigger, so check cars by distance manually.
		foreach ( var other in Scene.GetAll<Car>() )
		{
			if ( !other.IsValid() || other == Car || !other.IsPhysicallyPresent )
				continue;

			var otherRadius = other.Movement.IsValid() ? other.Movement.Radius : radius;
			var minDist = radius + otherRadius;
			if ( MathF.Abs( pos.z - other.WorldPosition.z ) > minDist )
				continue;
			if ( (pos - other.WorldPosition).WithZ( 0f ).Length < minDist )
				return false;
		}

		return true;
	}

	/// <summary>
	/// Trace down to find ground and lift the car above it; falls back to a flat offset if nothing walkable is found.
	/// </summary>
	private Vector3 SnapToSurface( Vector3 basePos )
	{
		TrySnapToSurface( basePos, out var pos );
		return pos;
	}

	/// <summary>
	/// Like <see cref="SnapToSurface"/> but returns false when falling back to the flat offset.
	/// </summary>
	private bool TrySnapToSurface( Vector3 basePos, out Vector3 pos )
	{
		var tr = Scene.Trace.Ray( basePos + Vector3.Up * 30f, basePos + Vector3.Down * 80f )
			.WithoutTags( "player", "car" )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		if ( !tr.Hit || tr.Normal.z < 0.5f )
		{
			pos = basePos + Vector3.Up * SpawnLift;
			return false;
		}

		pos = tr.HitPosition + Vector3.Up * SpawnLift;
		return true;
	}

	[Rpc.Broadcast( NetFlags.OwnerOnly )]
	private void SpawnRespawnEffect()
	{
		if ( RespawnEffectPrefab.IsValid() )
		{
			RespawnEffectPrefab.Clone( WorldPosition );
		}

		if ( RespawnSound.IsValid() )
			Sound.Play( RespawnSound, WorldPosition );
	}

	/// <summary>
	/// Toggle components to match <see cref="IsRespawning"/>. Idempotent; visuals toggle everywhere, sim toggles owner-only.
	/// </summary>
	private void ApplyState()
	{
		if ( _appliedState == IsRespawning )
			return;
		_appliedState = IsRespawning;

		if ( IsRespawning )
		{
			// Disable in order: physics -> sim -> visuals.
			SetSimEnabled( false );
			SetVisualsEnabled( false );
		}
		else
		{
			// Restore in order: visuals -> sim -> physics (physics last to settle on final transform).
			SetVisualsEnabled( true );
			SetSimEnabled( true );

			if ( IsAuthority && _rb.IsValid() )
			{
				_rb.Velocity = Vector3.Zero;
				_rb.AngularVelocity = Vector3.Zero;
			}

			if ( IsAuthority )
				Car.Movement?.SetYaw( WorldRotation.Yaw() );
		}
	}

	private void SetVisualsEnabled( bool on )
	{
		if ( _meshObject.IsValid() )
			_meshObject.Enabled = on;

		if ( Car.BodyTilt.IsValid() )
			Car.BodyTilt.Enabled = on;

		var wheels = GetComponent<CarWheelVisuals>();
		if ( wheels.IsValid() )
			wheels.Enabled = on;
	}

	private void SetSimEnabled( bool on )
	{
		if ( !IsAuthority )
			return;

		if ( Car.Movement.IsValid() ) Car.Movement.Enabled = on;
		if ( Car.Input.IsValid() ) Car.Input.Enabled = on;
		if ( Car.Drift.IsValid() ) Car.Drift.Enabled = on;
		if ( Car.Boost.IsValid() ) Car.Boost.Enabled = on;
		if ( Car.Collision.IsValid() ) Car.Collision.Enabled = on;

		// Collider toggled so the hidden car can't be hit; rigidbody stays kinematic (mover drives it).
		if ( _collider.IsValid() ) _collider.Enabled = on;
	}
}