Player/Car/Gameplay/CarMovement.cs

CarMovement component for a vehicle. Implements kinematic arcade-style driving: throttle/steer, grounded snap and airborne flight, collide-and-slide with walls, car-car analytical sweep, prop pushing, drift boost and teleportation, and exposes sync/display fields for networked clients.

NetworkingFile Access
using Machines.Events;
using Machines.Resources;
using Machines.Systems;
using Machines.Track;

namespace Machines.Player;

/// <summary>
/// Kinematic movement: arcade drive, collision sweep, ground snap, airborne flight, rideable surfaces.
/// </summary>
public sealed class CarMovement : Component
{
	/// <summary>
	/// Sibling Car component providing stats and identity.
	/// </summary>
	[RequireComponent]
	public Car Car { get; private set; }

	/// <summary>
	/// Sphere radius used for wall/car collision sweeps.
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public float Radius { get; set; } = 18f;

	/// <summary>
	/// Height of the car origin above the track when grounded. Collision sweep clearance is independent.
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public float HoverHeight { get; set; } = 0f;

	/// <summary>
	/// Min gap between sweep sphere bottom and track so the horizontal sweep never snags the floor.
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public float GroundClearance { get; set; } = 1f;

	/// <summary>
	/// Max downward snap per tick; drops beyond this launch the car airborne.
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public float SnapDistance { get; set; } = 24f;

	/// <summary>
	/// Gravity applied while airborne (units/s^2).
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public float Gravity { get; set; } = 800f;

	/// <summary>
	/// Fraction of horizontal speed lost per second while airborne.
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public float AirDrag { get; set; } = 0.5f;

	/// <summary>
	/// Min surface normal Z to count as drivable ground; below this is treated as a wall.
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public float WalkableZ { get; set; } = 0.5f;

	/// <summary>
	/// Upward climb speed above which the car launches even if still within snap range (kicker).
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public float LaunchSpeedZ { get; set; } = 400f;

	/// <summary>
	/// Maximum collide-and-slide iterations per move.
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public int MaxSlides { get; set; } = 5;

	/// <summary>
	/// Duration (s) grip-to-facing is suppressed after a wall touch so the car slides, not digs in.
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public float WallGripSuppress { get; set; } = 0.12f;

	/// <summary>
	/// Fraction of into-wall speed redirected along the wall on a glancing hit (0 = slam, 1 = grind).
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public float WallSlideKeep { get; set; } = 0.7f;

	/// <summary>
	/// Max height the car can step up per tick, independent of <see cref="SnapDistance"/> (which only snaps down).
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public float MaxStepUp { get; set; } = 24f;

	/// <summary>
	/// Units to ease the car out per tick when the sweep starts inside geometry. 0 = disabled.
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public float DepenetrationStep { get; set; } = 2f;

	/// <summary>
	/// Push-out speed when overlapping another car (spawn overlap, portal exit, extreme speed).
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public float CarPushOutSpeed { get; set; } = 600f;

	/// <summary>
	/// Force multiplier applied to physics props on impact.
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public float PropPushScale { get; set; } = 1f;

	/// <summary>
	/// Speed fraction shed per tick when plowing through a prop (0 = weightless props).
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public float PropHitSlowdown { get; set; } = 0.08f;

	/// <summary>
	/// Push-out distance off walls to prevent floating-point sticking.
	/// </summary>
	[Property, Group( "Trace Movement" )]
	public float SkinWidth { get; set; } = 0.5f;

	/// <summary>
	/// Velocity projected onto facing direction. Positive = forward, negative = reverse.
	/// </summary>
	[Sync]
	public float CurrentSpeed { get; set; }

	/// <summary>
	/// Current throttle input (-1 to 1), exposed for visuals.
	/// </summary>
	public float ThrottleInput => Car?.Input?.Current.Throttle ?? 0f;

	/// <summary>
	/// Current turn input (-1 to 1), exposed for visuals.
	/// </summary>
	public float TurnInput => Car?.Input?.Current.Steer ?? 0f;

	/// <summary>
	/// Current facing yaw in degrees, synced for remote display.
	/// </summary>
	[Sync]
	public float Yaw { get; private set; }

	/// <summary>
	/// Rendering yaw: equals <see cref="Yaw"/> on authority, smoothly interpolated on remotes.
	/// </summary>
	public float DisplayYaw => IsAuthority ? Yaw : _displayYaw;

	/// <summary>
	/// Current velocity travel direction in degrees, exposed for drift system.
	/// </summary>
	public float VelocityAngle => _velocityAngle;

	/// <summary>
	/// Set by CarDrift to override the velocity travel direction during drift.
	/// </summary>
	public float VelocityAngleOverride { set => _velocityAngle = value; }

	private float _velocityAngle;
	private float _currentSteer;
	private Rigidbody _rb;
	private Vector3 _velocity;
	private bool _isGrounded;
	private Vector3 _groundNormal = Vector3.Up;
	private float _lastClimbVel;
	private float _airborneAt;
	private float _gripSuppressUntil;
	private float _displayYaw;
	private float _surfaceFriction = 1f;

	/// <summary>
	/// Current car velocity, read by collision/knockback systems.
	/// </summary>
	public Vector3 Velocity => _velocity;

	/// <summary>
	/// Whether the car is currently on the ground.
	/// </summary>
	public bool IsGrounded => _isGrounded;

	/// <summary>
	/// Current ground surface normal; Up when airborne.
	/// </summary>
	public Vector3 GroundNormal => _groundNormal;

	/// <summary>
	/// Friction of the current surface (0-1); lower = more sliding.
	/// </summary>
	public float SurfaceFriction => _surfaceFriction;

	protected override void OnStart()
	{
		Yaw = WorldRotation.Yaw();
		_velocityAngle = Yaw;
		_displayYaw = Yaw;
		_isGrounded = true;

		// Keyframed only: doesn't drive motion but still fires triggers and takes trace hits
		_rb = GetComponent<Rigidbody>();
		if ( _rb.IsValid() )
		{
			_rb.MotionEnabled = false;
			_rb.Gravity = false;
		}
	}

	/// <summary>
	/// Whether this machine simulates the car (owner or bot).
	/// </summary>
	private bool IsAuthority => Car.IsValid() && Car.IsAuthority;

	protected override void OnUpdate()
	{
		if ( IsAuthority )
			return;

		// Interpolate display yaw toward synced value each render frame.
		_displayYaw = MathX.LerpDegrees( _displayYaw, Yaw, 20f * Time.Delta );
	}

	protected override void OnFixedUpdate()
	{
		if ( !IsAuthority )
			return;

		ApplyMovement();
		PushSplineProps();
	}

	/// <summary>
	/// Impulse spline props we drive into (sweep passes through them, so handled separately).
	/// </summary>
	private void PushSplineProps()
	{
		if ( PropPushScale <= 0f )
			return;

		// Smaller sphere than the movement sweep so we only shove props we genuinely overlap.
		var pushRadius = Radius * 0.6f;
		var center = WorldPosition + Vector3.Up * (Radius - HoverHeight + GroundClearance);

		foreach ( var hit in Scene.Trace.Sphere( pushRadius, center, center )
			.IgnoreGameObjectHierarchy( GameObject )
			.WithTag( "spline_prop" )
			.RunAll() )
		{
			var body = hit.GameObject?.GetComponentInParent<Rigidbody>();
			if ( !body.IsValid() || !body.MotionEnabled )
				continue;

			var away = (body.WorldPosition - center).WithZ( 0f );
			if ( away.IsNearlyZero() )
				away = _velocity.WithZ( 0f );
			away = away.IsNearlyZero() ? Rotation.FromYaw( Yaw ).Forward : away.Normal;

			// Only shove when driving into it, scaled by closing speed.
			var closing = MathF.Max( 0f, Vector3.Dot( _velocity, away ) );
			if ( closing <= 0f )
			{
				// Stationary overlap: ease out so the car can't get stuck.
				if ( _velocity.WithZ( 0f ).Length < 50f )
					WorldPosition -= away * CarPushOutSpeed * Time.Delta;

				continue;
			}

			body.ApplyImpulseAt( body.WorldPosition, away * closing * PropPushScale * body.Mass );

			// Plowing a prop costs momentum unless drifting.
			if ( PropHitSlowdown > 0f && !(Car.Drift.IsValid() && Car.Drift.IsDrifting) )
				_velocity *= 1f - PropHitSlowdown;

			// Register hit for camera shake / haptics (debounced in CarCollision).
			Car.Collision?.NotifyPropHit( body.WorldPosition, away, closing );
		}
	}

	/// <summary>
	/// Set the facing yaw directly (used by drift system).
	/// </summary>
	public void SetYaw( float yaw )
	{
		Yaw = yaw;
	}

	/// <summary>
	/// Apply an impulse in the car's forward direction.
	/// </summary>
	public void ApplyForwardImpulse( float force )
	{
		ApplyImpulse( Rotation.FromYaw( Yaw ).Forward * force );
	}

	// Drift boost window: speed cap is raised so the kick isn't immediately lost.
	private float _driftBoostBonus;
	private float _driftBoostUntil;

	/// <summary>
	/// True while a drift boost window is active.
	/// </summary>
	public bool IsDriftBoosting => Time.Now < _driftBoostUntil;

	/// <summary>
	/// Kick the car forward and raise the speed cap by <paramref name="bonusSpeed"/> for <paramref name="duration"/> seconds.
	/// </summary>
	public void StartDriftBoost( float bonusSpeed, float duration )
	{
		ApplyForwardImpulse( bonusSpeed );
		_driftBoostBonus = bonusSpeed;
		_driftBoostUntil = Time.Now + duration;
	}

	/// <summary>
	/// Apply a velocity impulse. Optionally suppress grip-to-facing so sideways shoves register.
	/// </summary>
	public void ApplyImpulse( Vector3 velocity, float gripSuppressSeconds = 0f )
	{
		_velocity += velocity;

		if ( gripSuppressSeconds > 0f )
			_gripSuppressUntil = MathF.Max( _gripSuppressUntil, Time.Now + gripSuppressSeconds );
	}

	/// <summary>
	/// Zero all motion and re-sync yaw/velocity angle to current facing (used by respawn).
	/// </summary>
	public void ResetMotion()
	{
		_velocity = Vector3.Zero;
		_isGrounded = true;
		_lastClimbVel = 0f;
		_groundNormal = Vector3.Up;

		Yaw = WorldRotation.Yaw();
		_velocityAngle = Yaw;
		_currentSteer = 0f;
		_gripSuppressUntil = 0f;
	}

	/// <summary>
	/// Teleport the car to a new position and rotation
	/// </summary>
	public void Teleport( Transform target )
	{
		var oldPosition = WorldPosition;
		var yawDelta = target.Rotation.Yaw() - Yaw;

		_velocity = Rotation.FromYaw( yawDelta ) * _velocity;
		Yaw += yawDelta;
		_velocityAngle += yawDelta;
		_displayYaw += yawDelta;

		WorldPosition = target.Position;
		WorldRotation = Rotation.FromYaw( Yaw );

		Network.ClearInterpolation();

		Scene.RunEvent<ICarTeleportListener>( x => x.OnCarTeleported( Car, target.Position - oldPosition, yawDelta ) );
	}

	/// <summary>
	/// Teleport from the host (e.g. starting-grid shuffle); runs on the owner, which drives the transform.
	/// </summary>
	[Rpc.Owner]
	public void TeleportTo( Vector3 position, Rotation rotation )
	{
		Teleport( new Transform( position, rotation ) );
	}

	private void ApplyMovement()
	{
		var dt = Time.Delta;
		var throttleInput = ThrottleInput;
		var turnInput = TurnInput;
		var stats = Car.ActiveStats;
		var isDrifting = Car?.Drift?.IsDrifting ?? false;

		// 1. Arcade drive: updates horizontal velocity, facing yaw and grip.
		ApplyHorizontalDrive( dt, throttleInput, turnInput, stats, isDrifting );

		// Stamp pre-collision velocity so CarCollision reads impact speed before the slide cancels it.
		Car.Collision?.StampPreImpactVelocity( _velocity );

		// 2. Move the car: glued to the track when grounded, ballistic when airborne.
		if ( _isGrounded )
			MoveGrounded( dt );
		else
			MoveAirborne( dt );

		// 3. Separate overlapping cars so contacts never wedge (knockback handles the impulse).
		SeparateFromCars();

		WorldRotation = Rotation.FromYaw( Yaw );
	}

	/// <summary>
	/// Arcade handling (drag, throttle, brake, reverse, steering, grip) on the horizontal velocity.
	/// </summary>
	private void ApplyHorizontalDrive( float dt, float throttleInput, float turnInput, CarStatValues stats, bool isDrifting )
	{
		var driveForward = Rotation.FromYaw( Yaw ).Forward;
		var hv = _velocity.WithZ( 0f );

		// Scale acceleration on inclines so the car maintains speed uphill.
		var slopeBoost = 1f;
		if ( _isGrounded && _groundNormal.z < 0.999f && _groundNormal.z > 0.01f )
		{
			var sinSlope = MathF.Sqrt( 1f - _groundNormal.z * _groundNormal.z );

			// Only boost when driving uphill.
			var uphillDir = _groundNormal.WithZ( 0f ).Normal;
			var climbDot = Vector3.Dot( driveForward, -uphillDir );
			if ( climbDot > 0f )
				slopeBoost = 1f + sinSlope * stats.HillStrength * climbDot;
		}

		// Drift boost raises the speed cap for its window.
		var maxSpeed = stats.MaxSpeed + (IsDriftBoosting ? _driftBoostBonus : 0f);

		if ( isDrifting )
		{
			var driftDrag = 0.05f * _surfaceFriction;
			if ( throttleInput <= 0f )
				driftDrag += 0.4f * _surfaceFriction;

			var dampFactor = 1f - (driftDrag * dt);
			hv *= dampFactor;

			var tractionFactor = MathX.Lerp( 0.75f, 1f, _surfaceFriction );
			var speed = hv.Length;
			if ( throttleInput > 0f && speed < maxSpeed )
				hv += driveForward * stats.Acceleration * slopeBoost * throttleInput * 0.7f * tractionFactor * dt;
		}
		else
		{
			// Ground coast-drag (reduced on low-friction surfaces so the car coasts more).
			// Airborne resistance is handled by AirDrag in MoveAirborne; applying it here too
			// would crater horizontal speed the moment you lift off the throttle midair.
			var speed = hv.Length;
			if ( _isGrounded && speed > 1f )
			{
				var dragFriction = _surfaceFriction * _surfaceFriction;
				var dragForce = hv.Normal * -stats.Drag * dragFriction * dt;
				var newHorizontal = hv + dragForce;

				// Don't let drag reverse direction.
				if ( Vector3.Dot( newHorizontal, hv ) < 0f )
					newHorizontal = Vector3.Zero;

				hv = newHorizontal;
			}

			// Throttle / brake / reverse
			var tractionFactor = MathX.Lerp( 0.75f, 1f, _surfaceFriction );
			speed = hv.Length;
			if ( throttleInput > 0f && speed < maxSpeed )
			{
				hv += driveForward * stats.Acceleration * slopeBoost * throttleInput * tractionFactor * dt;
			}
			else if ( throttleInput < 0f )
			{
				var forwardSpeed = Vector3.Dot( hv, driveForward );
				if ( forwardSpeed > 0f )
				{
					// Braking
					var brakeDecel = MathF.Min( forwardSpeed, stats.BrakeForce * tractionFactor * dt );
					hv -= driveForward * brakeDecel;
				}
				else
				{
					// Reverse
					if ( MathF.Abs( forwardSpeed ) < stats.ReverseSpeed )
						hv += driveForward * stats.Acceleration * throttleInput * tractionFactor * dt;
				}
			}
		}

		// Current speed: project horizontal velocity onto travel direction.
		CurrentSpeed = Vector3.Dot( hv, Rotation.FromYaw( _velocityAngle ).Forward );

		// Steering
		if ( !isDrifting )
		{
			// Bot/autopilot steer is already smoothed in RacingBrain; re-smoothing only adds lag
			if ( Car.IsBot || Car.Autopilot )
				_currentSteer = turnInput;
			else
				_currentSteer = MathX.Lerp( _currentSteer, turnInput, 7f * dt );

			var absSpeed = MathF.Abs( CurrentSpeed );
			var speedFactor = MathF.Min( 1f, absSpeed / 100f );
			var turnFalloff = MathF.Max( 0.5f, 1f - (absSpeed / stats.MaxSpeed) * 0.8f );

			var turnAmount = _currentSteer * stats.TurnRate * turnFalloff * speedFactor * dt;
			turnAmount *= MathX.Lerp( 0.4f, 1f, _surfaceFriction );
			if ( CurrentSpeed < 0f ) turnAmount = -turnAmount;

			Yaw += turnAmount;
			var velocityAngleGrip = MathX.Lerp( 0.1f, 8f, _surfaceFriction );
			_velocityAngle = MathX.LerpDegrees( _velocityAngle, Yaw, velocityAngleGrip * dt );
		}

		// Grip: rotate horizontal velocity toward facing direction.
		var hSpeed = hv.Length;
		if ( hSpeed > 1f && Time.Now >= _gripSuppressUntil )
		{
			var targetDir = Rotation.FromYaw( _velocityAngle ).Forward;
			var directionSign = Vector3.Dot( hv, targetDir ) < 0f ? -1f : 1f;
			var targetVel = targetDir * hSpeed * directionSign;
			var gripRate = isDrifting ? 10f : 8f;
			gripRate *= _surfaceFriction;

			hv = Vector3.Lerp( hv, targetVel, gripRate * dt );
		}

		_velocity = hv.WithZ( _velocity.z );
	}

	/// <summary>
	/// Move glued to the track: project onto ground plane, snap down; crests hand off to airborne.
	/// </summary>
	private void MoveGrounded( float dt )
	{
		var prevZ = WorldPosition.z;

		// Project horizontal velocity onto ground plane so the car follows ramps.
		var hv = _velocity.WithZ( 0f );
		var speed = hv.Length;
		Vector3 moveDelta;

		if ( speed > 0.01f )
		{
			var moveDir = hv / speed;
			// Project direction onto ground plane: remove the component along the normal.
			var surfaceDir = (moveDir - _groundNormal * Vector3.Dot( moveDir, _groundNormal ));
			if ( surfaceDir.Length > 0.001f )
				surfaceDir = surfaceDir.Normal;
			else
				surfaceDir = moveDir;

			moveDelta = surfaceDir * speed * dt;
		}
		else
		{
			moveDelta = Vector3.Zero;
		}

		MoveAndCollide( moveDelta );

		// Probe for ground and snap down.
		var g = ProbeGround();
		var targetZ = g.Hit ? g.GroundZ + HoverHeight : 0f;
		var dropFromHere = WorldPosition.z - targetZ;

		// Snap down (SnapDistance) or step up (MaxStepUp); beyond either threshold = launch.
		if ( g.Hit && g.Walkable && dropFromHere <= SnapDistance && dropFromHere >= -MaxStepUp )
		{
			var climbVel = (targetZ - prevZ) / dt;

			// At a ramp crest: was climbing, ground dropped away, so launch (not on platforms).
			if ( _lastClimbVel > 100f && climbVel < -50f )
			{
				Launch( _lastClimbVel );
				return;
			}

			SetWorldZ( targetZ );
			_groundNormal = g.Normal;
			_isGrounded = true;
			_surfaceFriction = g.Friction;

			var prevClimbVel = _lastClimbVel;
			_lastClimbVel = climbVel;

			// Launch only on a sharp spike in climb rate (kicker), not a steady ramp.
			if ( climbVel > LaunchSpeedZ && climbVel - prevClimbVel > LaunchSpeedZ )
				Launch( climbVel );
			else
			{
				_velocity = _velocity.WithZ( 0f );

				// Gentle uphill penalty: bleed ~25% of the gravity-on-slope deceleration.
				if ( climbVel > 0f && _groundNormal.z < 0.999f )
				{
					var sinSlope = MathF.Sqrt( 1f - _groundNormal.z * _groundNormal.z );
					var penalty = Gravity * sinSlope * 0.25f * dt;
					var hSpeed = _velocity.WithZ( 0f ).Length;
					if ( penalty < hSpeed )
					{
						var dir = _velocity.WithZ( 0f ).Normal;
						_velocity -= dir * penalty;
					}
				}
			}
		}
		else
		{
			// Surface fell past snap range: launch.
			Launch( MathF.Max( _velocity.z, _lastClimbVel ) );
		}
	}

	/// <summary>
	/// Ballistic flight with gravity and collide-and-slide until landing on a drivable surface.
	/// </summary>
	private void MoveAirborne( float dt )
	{
		// Apply air drag to horizontal speed and gravity to vertical.
		var drag = MathF.Pow( 1f - AirDrag, dt );
		_velocity = new Vector3( _velocity.x * drag, _velocity.y * drag, _velocity.z - Gravity * dt );

		MoveAndCollide( _velocity * dt );

		var g = ProbeGround();
		if ( g.Hit && g.Walkable && _velocity.z <= 0f && (WorldPosition.z - (g.GroundZ + HoverHeight)) <= HoverHeight )
		{
			SetWorldZ( g.GroundZ + HoverHeight );
			_groundNormal = g.Normal;
			_isGrounded = true;
			_surfaceFriction = g.Friction;
			_lastClimbVel = 0f;
			_velocity = _velocity.WithZ( 0f );

			// Record airtime; floor filters kerb hops, clamp drops outliers.
			var airTime = Time.Now - _airborneAt;
			if ( airTime >= 0.5f )
				GameStats.Increment( "airtime", MathF.Min( airTime, 30f ), car: Car );
		}
	}

	private void Launch( float verticalSpeed )
	{
		_isGrounded = false;
		_airborneAt = Time.Now;
		_groundNormal = Vector3.Up;
		_velocity = _velocity.WithZ( MathF.Max( 0f, verticalSpeed ) );
	}

	/// <summary>
	/// Push overlapping cars apart at <see cref="CarPushOutSpeed"/>; only fires on genuine interior overlaps.
	/// </summary>
	private void SeparateFromCars()
	{
		foreach ( var other in Scene.GetAll<Car>() )
		{
			if ( !OverlapsCar( other, out var otherRadius ) )
				continue;

			var minDist = Radius + otherRadius;
			var away = (WorldPosition - other.WorldPosition).WithZ( 0f );
			var dist = away.Length;
			if ( dist >= minDist )
				continue;

			Vector3 dir;
			if ( dist > 0.01f )
			{
				dir = away / dist;
			}
			else
			{
				// Fully coincident: split by slot order so the two cars go opposite ways.
				var lower = Car.Slot < other.Slot;
				dir = Vector3.Forward * (lower ? 1f : -1f);
			}

			WorldPosition += dir * MathF.Min( minDist - dist, CarPushOutSpeed * Time.Delta );

			// Strip velocity into the other car so throttle can't fight the eject.
			var into = -Vector3.Dot( _velocity, dir );
			if ( into > 0f )
				_velocity += dir * into;
		}
	}

	/// <summary>
	/// Returns true if <paramref name="other"/> is a valid, present car at roughly the same height.
	/// </summary>
	private bool OverlapsCar( Car other, out float otherRadius )
	{
		otherRadius = Radius;

		if ( !other.IsValid() || other == Car || !other.IsPhysicallyPresent )
			return false;

		if ( other.Movement.IsValid() )
			otherRadius = other.Movement.Radius;

		// Don't shove a car that's jumping over us.
		return MathF.Abs( WorldPosition.z - other.WorldPosition.z ) <= (Radius + otherRadius) * 0.5f;
	}

	private struct CarSweepHit
	{
		public bool Hit;
		public float Fraction;
		public Car OtherCar;
		public Vector3 Normal;
		public Vector3 Point;
	}

	/// <summary>
	/// Swept circle-vs-circle test against all cars (player-player rule is Trigger, so sweep misses them).
	/// Returns the earliest contact along the horizontal component of <paramref name="delta"/>.
	/// </summary>
	private CarSweepHit SweepCars( Vector3 delta )
	{
		var best = new CarSweepHit { Fraction = 1f };

		var d = delta.WithZ( 0f );
		var a = d.LengthSquared;
		if ( a < 0.0001f )
			return best;

		foreach ( var other in Scene.GetAll<Car>() )
		{
			if ( !OverlapsCar( other, out var otherRadius ) )
				continue;

			var minDist = Radius + otherRadius;

			// First t in [0,1] where |d*t - rel| = minDist.
			var rel = (other.WorldPosition - WorldPosition).WithZ( 0f );
			var b = -2f * Vector3.Dot( d, rel );
			var c = rel.LengthSquared - minDist * minDist;

			// Already overlapping (SeparateFromCars handles it) or moving away.
			if ( c <= 0f || b >= 0f )
				continue;

			var disc = b * b - 4f * a * c;
			if ( disc < 0f )
				continue;

			var t = (-b - MathF.Sqrt( disc )) / (2f * a);
			if ( t < 0f || t > 1f || t >= best.Fraction )
				continue;

			var normal = (d * t - rel).Normal;
			best = new CarSweepHit
			{
				Hit = true,
				Fraction = t,
				OtherCar = other,
				Normal = normal,
				Point = other.WorldPosition + normal * otherRadius + Vector3.Up * Radius,
			};
		}

		return best;
	}

	/// <summary>
	/// Collide-and-slide a displacement, classifying hits as ground, car, or wall, reporting to <see cref="CarCollision"/>.
	/// </summary>
	private void MoveAndCollide( Vector3 delta )
	{
		for ( int i = 0; i < MaxSlides; i++ )
		{
			if ( delta.Length < 0.01f )
				break;

			// Centre sweep sphere with fixed floor clearance, independent of ride height.
			var from = WorldPosition + Vector3.Up * (Radius - HoverHeight + GroundClearance);
			var to = from + delta;

			var tr = Scene.Trace.Sphere( Radius, from, to )
				.IgnoreGameObjectHierarchy( GameObject )
				.WithCollisionRules( "player" )
				.Run();

			// Started inside geometry (common with thin mesh shells like spline colliders): ease out
			// along the normal and keep sliding.
			if ( tr.StartedSolid )
			{
				var outDir = tr.Normal.WithZ( 0f );
				if ( outDir.IsNearlyZero() )
					break;

				outDir = outDir.Normal;
				WorldPosition += outDir * DepenetrationStep;

				var into = Vector3.Dot( _velocity, -outDir );
				if ( into > 0f )
					_velocity += outDir * into;

				delta = ProjectOnPlane( delta, outDir );

				// Report the wall so it bounces and scrapes like a clean sweep hit. The depenetration
				// above already cancelled the into-wall speed, so it survives the slide
				Car.Collision?.NotifyWallHit( WorldPosition, outDir, tr.GameObject );
				_gripSuppressUntil = MathF.Max( _gripSuppressUntil, Time.Now + WallGripSuppress );

				continue;
			}

			// Player-player rule is Trigger: detect car contacts analytically, take earliest hit.
			var carHit = SweepCars( delta );
			if ( carHit.Hit && (!tr.Hit || carHit.Fraction < tr.Fraction) )
			{
				// Advance to contact, nudge out, slide remainder along the contact plane.
				WorldPosition += delta * carHit.Fraction + carHit.Normal * SkinWidth;
				delta = ProjectOnPlane( delta * (1f - carHit.Fraction), carHit.Normal );

				var intoCar = -Vector3.Dot( _velocity, carHit.Normal );
				if ( intoCar > 0f )
					_velocity += carHit.Normal * intoCar;

				// Knockback after the slide so the impulse isn't clipped.
				Car.Collision?.NotifyCarHit( carHit.OtherCar, carHit.Point, carHit.Normal );
				continue;
			}

			if ( !tr.Hit )
			{
				WorldPosition += delta;
				break;
			}

			// Advance to the contact point and compute remaining motion.
			WorldPosition += tr.EndPosition - from;

			var normal = tr.Normal;
			var remaining = to - tr.EndPosition;

			if ( normal.z >= WalkableZ )
			{
				// Drivable surface: nudge off so the next sweep doesn't re-hit the same face.
				WorldPosition += normal * SkinWidth;
			}
			else
			{
				// nudge out and suppress grip so we slide instead of dig in
				WorldPosition += normal * SkinWidth;
				_gripSuppressUntil = MathF.Max( _gripSuppressUntil, Time.Now + WallGripSuppress );
			}

			// Shove any movable physics body out of the way.
			var hitBody = tr.GameObject?.GetComponentInParent<Rigidbody>();
			if ( hitBody.IsValid() && hitBody.MotionEnabled )
			{
				var pushForce = MathF.Max( 0f, Vector3.Dot( _velocity, -normal ) );
				var pushDir = _velocity.WithZ( 0f ).Normal;
				hitBody.ApplyImpulseAt( tr.HitPosition, pushDir * pushForce * PropPushScale * hitBody.Mass );
			}

			// Slide remaining motion and velocity along the contact plane
			delta = ProjectOnPlane( remaining, normal );

			// Walkable: re-project velocity, ground snap handles Z
			if ( normal.z >= WalkableZ )
			{
				_groundNormal = normal;
			}
			else
			{
				// redirect into-wall speed along the wall
				var preSpeed = _velocity.Length;
				var tangent = ProjectOnPlane( _velocity, normal );
				var into = -Vector3.Dot( _velocity, normal );
				if ( into > 0f && tangent.Length > 0.01f )
					tangent = (tangent + tangent.Normal * into * WallSlideKeep).ClampLength( preSpeed );

				_velocity = tangent;

				Car.Collision?.NotifyWallHit( tr.HitPosition, normal, tr.GameObject );
			}
		}
	}

	private struct GroundProbe
	{
		public bool Hit;
		public bool Walkable;
		public float GroundZ;
		public Vector3 Normal;
		public float Friction;
	}

	/// <summary>
	/// Trace down for the track surface beneath the car.
	/// </summary>
	private GroundProbe ProbeGround()
	{
		// Start above MaxStepUp to detect climbed ramps; reach down past snap range.
		var start = WorldPosition + Vector3.Up * MathF.Max( SnapDistance, MaxStepUp );
		var end = WorldPosition - Vector3.Up * (SnapDistance + Radius);

		var tr = Scene.Trace.Ray( start, end )
			.WithoutTags( "player", "car" )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		if ( !tr.Hit )
			return new GroundProbe { Hit = false, Normal = Vector3.Up, Friction = 1f };

		// Prefer collider friction override, fall back to trace surface friction.
		var collider = tr.GameObject?.GetComponent<Collider>();
		var friction = collider.Friction.HasValue ? collider.Friction.Value : (tr.Surface?.Friction ?? 1f);

		return new GroundProbe
		{
			Hit = true,
			Walkable = tr.Normal.z >= WalkableZ,
			GroundZ = tr.HitPosition.z,
			Normal = tr.Normal,
			Friction = friction
		};
	}

	private void SetWorldZ( float z )
	{
		var p = WorldPosition;
		WorldPosition = new Vector3( p.x, p.y, z );
	}

	private static Vector3 ProjectOnPlane( Vector3 v, Vector3 normal )
	{
		return v - normal * Vector3.Dot( v, normal );
	}
}