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