Player/Car/Visuals/CarBodyTilt.cs

Component that visually tilts a car body. It smooths pitch from acceleration/braking, roll from turning scaled by speed, aligns model to ground slope, and simulates a drift hop arc with a post-drift boost and effect enabling.

Native Interop
namespace Machines.Player;

/// <summary>
/// Handles visually tilting the car body based on acceleration/braking, turning, and ground slope.
/// </summary>
public sealed class CarBodyTilt : Component
{
	/// <summary>
	/// Maximum pitch angle in degrees on acceleration/braking.
	/// </summary>
	[Property, Group( "Pitch" )]
	public float MaxPitchDegrees { get; set; } = 5f;

	/// <summary>
	/// Max roll angle when turning; positive = into turn (motorcycle), negative = body roll
	/// </summary>
	[Property, Group( "Roll" )]
	public float MaxRollDegrees { get; set; } = -8f;

	/// <summary>
	/// Speed fraction at which full roll is reached; below this roll scales down to prevent tilt at standstill
	/// </summary>
	[Property, Group( "Roll" ), Range( 0.05f, 1f )]
	public float RollSpeedThreshold { get; set; } = 0.2f;

	/// <summary>
	/// How quickly the accel tilt responds (higher = snappier).
	/// </summary>
	[Property, Group( "Feel" )]
	public float Responsiveness { get; set; } = 10f;

	/// <summary>
	/// Damping to prevent oscillation; 0 = none, 1 = critically damped
	/// </summary>
	[Property, Group( "Feel" )]
	public float Damping { get; set; } = 0.8f;

	/// <summary>
	/// How quickly the model aligns to the ground slope.
	/// </summary>
	[Property, Group( "Slope" )]
	public float SlopeLerpSpeed { get; set; } = 10f;

	/// <summary>
	/// Peak height of the drift hop arc; gravity is derived from this and CarDrift.HopDuration
	/// </summary>
	[Property, Group( "Drift Hop" )]
	public float DriftHopHeight { get; set; } = 20f;

	/// <summary>
	/// Maximum nose-up pitch (degrees) at the peak of the hop.
	/// </summary>
	[Property, Group( "Drift Hop" )]
	public float DriftHopPitchMax { get; set; } = 8f;

	/// <summary>
	/// Nose-up pitch (degrees) while the post-drift speed boost is active.
	/// </summary>
	[Property, Group( "Drift Hop" )]
	public float DriftBoostPitch { get; set; } = 6f;

	/// <summary>
	/// How quickly the boost pitch eases in and out.
	/// </summary>
	[Property, Group( "Drift Hop" )]
	public float DriftBoostPitchLerpSpeed { get; set; } = 6f;

	[RequireComponent]
	public Car Car { get; private set; }

	private float _currentPitch;
	private float _pitchVelocity;
	private float _currentRoll;
	private float _rollVelocity;
	private float _prevSpeed;
	private Vector3 _smoothNormal = Vector3.Up;
	private bool _wasDrifting;
	private float _hopOffset;
	private float _hopVelocity;
	private bool _hopEffectEnabled;
	private float _boostPitch;

	protected override void OnStart()
	{
		_prevSpeed = 0f;
	}

	protected override void OnUpdate()
	{
		if ( !Car.IsValid() || !Car.Renderer.IsValid() || !Car.Movement.IsValid() )
			return;

		var timeDelta = Time.Delta;
		if ( timeDelta <= 0f )
			return;

		// Use ground normal from CarMovement (already traced correctly)
		var targetNormal = Car.Movement.GroundNormal;
		_smoothNormal = Vector3.Lerp( _smoothNormal, targetNormal, SlopeLerpSpeed * timeDelta );

		// Target pitch: tilt back on acceleration, forward on braking
		var accel = (Car.Movement.CurrentSpeed - _prevSpeed) / timeDelta;
		_prevSpeed = Car.Movement.CurrentSpeed;

		var normalizedAccel = MathF.Max( -1f, MathF.Min( 1f, accel / Car.ActiveStats.Acceleration ) );
		var targetPitch = -normalizedAccel * MaxPitchDegrees;

		// Spring-damper smoothing for accel pitch
		var pitchForce = (targetPitch - _currentPitch) * Responsiveness;
		_pitchVelocity += pitchForce * timeDelta;
		_pitchVelocity *= 1f - Damping * timeDelta * 10f;
		_currentPitch += _pitchVelocity * timeDelta;

		// Target roll: lean when turning, scaled by speed
		var maxSpeed = Car.ActiveStats.MaxSpeed;
		var absSpeed = MathF.Abs( Car.Movement.CurrentSpeed );
		var speedFraction = maxSpeed > 0f ? MathX.Clamp( absSpeed / maxSpeed, 0f, 1f ) : 0f;
		var speedScale = MathX.Clamp( speedFraction / RollSpeedThreshold, 0f, 1f );
		var turnInput = Car.Movement.TurnInput;
		var targetRoll = turnInput * MaxRollDegrees * speedScale;

		// Spring-damper smoothing for roll
		var rollForce = (targetRoll - _currentRoll) * Responsiveness;
		_rollVelocity += rollForce * timeDelta;
		_rollVelocity *= 1f - Damping * timeDelta * 10f;
		_currentRoll += _rollVelocity * timeDelta;

		// Slope rotation: build world-space rotation aligned to ground
		var yaw = Car.Movement.DisplayYaw;
		var flatForward = Rotation.FromYaw( yaw ).Forward;
		var slopeForward = (flatForward - _smoothNormal * Vector3.Dot( flatForward, _smoothNormal )).Normal;
		var slopeWorldRot = Rotation.LookAt( slopeForward, _smoothNormal );

		// Get local rotation relative to the car root (which is always flat yaw-only)
		var parentRot = WorldRotation;
		var localSlope = parentRot.Inverse * slopeWorldRot;

		// Layer the accel pitch and turn roll on top
		var accelTilt = Rotation.From( _currentPitch, 0f, _currentRoll );

		// Ballistic hop arc; gravity and impulse derived from DriftHopHeight + HopDuration
		var isDrifting = Car.Drift.IsValid() && Car.Drift.IsDrifting;
		var hopDuration = Car.Drift.IsValid() ? Car.Drift.HopDuration : 0.3f;
		var halfT = hopDuration * 0.5f;
		var hopGravity = halfT > 0f ? 2f * DriftHopHeight / (halfT * halfT) : 800f;
		var hopImpulse = hopGravity * halfT;

		if ( isDrifting && !_wasDrifting )
		{
			_hopVelocity = hopImpulse;
			_hopEffectEnabled = false;
		}

		_hopVelocity -= hopGravity * timeDelta;
		_hopOffset += _hopVelocity * timeDelta;

		var hopLanded = false;
		if ( _hopOffset <= 0f )
		{
			// Detect landing moment (was airborne, now at ground)
			if ( _hopVelocity < 0f || _hopOffset < 0f )
				hopLanded = _wasDrifting && isDrifting;

			_hopOffset = 0f;
			_hopVelocity = 0f;
		}

		// Enable drift effect when hop lands
		if ( hopLanded && !_hopEffectEnabled )
		{
			_hopEffectEnabled = true;
			if ( Car.Drift.DriftEffect.IsValid() )
				Car.Drift.DriftEffect.Enabled = true;
		}

		// Disable drift effect when drift ends
		if ( !isDrifting && _wasDrifting && Car.Drift.DriftEffect.IsValid() )
			Car.Drift.DriftEffect.Enabled = false;

		_wasDrifting = isDrifting;

		// Pitch nose-up proportional to hop height
		var hopPitchAmount = DriftHopHeight > 0f ? (_hopOffset / DriftHopHeight) * DriftHopPitchMax : 0f;

		// Nose-up while post-drift boost is active
		var boosting = Car.Movement.IsDriftBoosting;
		_boostPitch = MathX.Lerp( _boostPitch, boosting ? DriftBoostPitch : 0f, DriftBoostPitchLerpSpeed * timeDelta );

		var hopTilt = Rotation.From( -(hopPitchAmount + _boostPitch), 0f, 0f );

		Car.Renderer.GameObject.LocalRotation = localSlope * accelTilt * hopTilt;
		Car.Renderer.GameObject.LocalPosition = Car.Renderer.GameObject.LocalPosition.WithZ( _hopOffset );
	}
}