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.
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 );
}
}