CarCollision component for a player car. Handles scrape state, spawn/stop of scrape VFX and SFX, wall bounce and car-to-car knockback logic, cooldowns and networked RPCs to sync effects and apply mirrored impulses.
using Machines.Events;
namespace Machines.Player;
/// <summary>
/// Kinematic collision logic for the car. Handles scraping, wall bounces, and car-to-car knockback.
/// </summary>
public sealed class CarCollision : Component
{
[RequireComponent]
public Car Car { get; private set; }
/// <summary>
/// Looping prefab spawned while scraping (parented to the car). Destroyed when it ends.
/// </summary>
[Property]
public GameObject ScrapeEffectPrefab { get; set; }
/// <summary>
/// Sound event played (looping) while scraping.
/// </summary>
[Property]
public string ScrapeSound { get; set; } = "";
/// <summary>
/// One-shot prefab spawned at the contact point when two cars collide.
/// </summary>
[Property]
public GameObject ImpactEffectPrefab { get; set; }
/// <summary>
/// Sound event played at the contact point when two cars collide.
/// </summary>
[Property]
public string ImpactSound { get; set; } = "";
/// <summary>
/// Tangential (sliding) speed above which a contact counts as scraping.
/// </summary>
[Property]
public float ScrapeSpeedThreshold { get; set; } = 80f;
/// <summary>
/// Below this speed, throttling into a wall counts as a scraping head-on press.
/// </summary>
[Property]
public float WallSpeedThreshold { get; set; } = 60f;
/// <summary>
/// Scrape state lingers this long after the last qualifying contact (bridges intermittent callbacks).
/// </summary>
[Property]
public float ScrapeGrace { get; set; } = 0.12f;
/// <summary>
/// How much of the into-wall speed is bounced back when hitting a wall (0 = none, 1 = full reflect).
/// </summary>
[Property]
public float WallBounciness { get; set; } = 0.5f;
/// <summary>
/// Minimum into-wall speed required to trigger a bounce (avoids jitter on gentle contact).
/// </summary>
[Property]
public float WallBounceMinSpeed { get; set; } = 100f;
/// <summary>
/// Fraction of speed into the wall required to trigger a rebound (1 = head-on, 0 = parallel); lower = more grinds.
/// </summary>
[Property]
public float WallBounceMinDot { get; set; } = 0.8f;
/// <summary>
/// Min closing speed between cars to trigger knockback.
/// </summary>
[Property]
public float CarImpactMinSpeed { get; set; } = 80f;
/// <summary>
/// Base push-apart speed applied to each car on a car-to-car hit.
/// </summary>
[Property]
public float KnockbackImpulse { get; set; } = 350f;
/// <summary>
/// How long grip-to-facing is suppressed after a knockback so the shove reads.
/// </summary>
[Property]
public float KnockbackGripSuppress { get; set; } = 0.3f;
/// <summary>
/// Minimum time between car-to-car knockback impulses (debounce repeated contacts).
/// </summary>
[Property]
public float ImpactCooldown { get; set; } = 0.25f;
/// <summary>
/// True while scraping a wall or car, synced for remote visuals.
/// </summary>
[Sync]
public bool IsScraping { get; private set; }
private GameObject _activeScrapeEffect;
private SoundHandle _scrapeSound;
private float _nextImpactTime;
private float _nextBounceTime;
private float _nextPropImpactTime;
// Velocity before the physics step; callbacks fire after the solver cancels into-surface speed.
private Vector3 _preStepVelocity;
// Latest qualifying scrape contact, consumed by OnFixedUpdate.
private float _scrapeContactTime = -100f;
private CarImpactKind _scrapeKind;
private GameObject _scrapeOther;
private Vector3 _scrapePoint;
private Vector3 _scrapeNormal;
/// <summary>
/// Whether this machine simulates the car (and should run collision logic).
/// </summary>
private bool IsAuthority => Car.IsValid() && Car.IsAuthority;
protected override void OnFixedUpdate()
{
if ( !IsAuthority )
return;
var scraping = Time.Now - _scrapeContactTime < ScrapeGrace;
if ( scraping && !IsScraping )
{
IsScraping = true;
StartScrape();
Scene.RunEvent<ICarImpactListener>( x => x.OnCarImpact( new CarImpact
{
Kind = _scrapeKind,
Car = Car,
OtherCar = _scrapeOther?.GetComponentInParent<Car>(),
Other = _scrapeOther,
Point = _scrapePoint,
Normal = _scrapeNormal,
Speed = MathF.Abs( Car.Movement.CurrentSpeed )
} ) );
}
else if ( !scraping && IsScraping )
{
IsScraping = false;
StopScrape();
}
}
/// <summary>
/// Called by <see cref="CarMovement"/> before each sweep to record pre-impact velocity.
/// </summary>
public void StampPreImpactVelocity( Vector3 velocity )
{
_preStepVelocity = velocity;
}
/// <summary>
/// Called by the mover on a car hit: apply knockback and refresh scrape state.
/// </summary>
public void NotifyCarHit( Car otherCar, Vector3 point, Vector3 normal )
{
if ( !IsAuthority || !otherCar.IsValid() || otherCar == Car )
return;
ApplyCarKnockback( otherCar, point, normal );
RegisterContact( point, normal, otherCar.GameObject, otherCar );
}
/// <summary>
/// Called by the mover on a wall hit: apply bounce and refresh scrape state.
/// </summary>
public void NotifyWallHit( Vector3 point, Vector3 normal, GameObject other )
{
if ( !IsAuthority )
return;
ApplyWallBounce( point, normal, other );
RegisterContact( point, normal, other, null );
}
/// <summary>
/// Called by the mover on a prop shove; fires a debounced impact event for camera shake / haptics.
/// </summary>
public void NotifyPropHit( Vector3 point, Vector3 normal, float speed )
{
if ( !IsAuthority || Time.Now < _nextPropImpactTime )
return;
_nextPropImpactTime = Time.Now + ImpactCooldown;
Scene.RunEvent<ICarImpactListener>( x => x.OnCarImpact( new CarImpact
{
Kind = CarImpactKind.Prop,
Car = Car,
Other = null,
Point = point,
Normal = normal,
Speed = speed
} ) );
}
/// <summary>
/// If the contact qualifies as a scrape (sliding or head-on wall press), refresh the scrape timer.
/// </summary>
private void RegisterContact( Vector3 point, Vector3 normal, GameObject other, Car otherCar )
{
if ( !Car.Movement.IsValid() )
return;
var isCar = otherCar.IsValid() && otherCar != Car;
// Walls only; cars qualify regardless of normal.
if ( !isCar && normal.z >= 0.5f )
return;
var vel = Car.Movement.Velocity;
var tangentialSpeed = (vel - normal * Vector3.Dot( vel, normal )).WithZ( 0f ).Length;
var sliding = tangentialSpeed >= ScrapeSpeedThreshold;
var pressedHeadOn = !isCar
&& Car.Input.IsValid() && Car.Input.Current.Throttle > 0.1f
&& MathF.Abs( Car.Movement.CurrentSpeed ) < WallSpeedThreshold;
if ( !sliding && !pressedHeadOn )
return;
_scrapeContactTime = Time.Now;
_scrapeKind = isCar ? CarImpactKind.Car : CarImpactKind.Wall;
_scrapeOther = other;
_scrapePoint = point;
_scrapeNormal = normal;
}
private void ApplyCarKnockback( Car otherCar, Vector3 point, Vector3 normal )
{
if ( Time.Now < _nextImpactTime )
return;
// Horizontal push-apart; each car computes its own "away" for symmetric separation.
var away = (Car.WorldPosition - otherCar.WorldPosition).WithZ( 0f ).Normal;
// Use pre-impact velocity; the solver has already cancelled the current closing speed.
var relVel = (_preStepVelocity - otherCar.Movement.Velocity).WithZ( 0f );
var closing = MathF.Max( 0f, Vector3.Dot( relVel, -away ) );
// Only react above a real closing speed.
if ( closing < CarImpactMinSpeed )
return;
_nextImpactTime = Time.Now + ImpactCooldown;
var magnitude = MathF.Min( KnockbackImpulse + closing * 0.5f, KnockbackImpulse * 2.5f );
// Split knockback by fault: each car bounces in proportion to the other's closing speed.
var myInto = MathF.Max( 0f, Vector3.Dot( _preStepVelocity.WithZ( 0f ), -away ) );
var otherInto = MathF.Max( 0f, Vector3.Dot( otherCar.Movement.Velocity.WithZ( 0f ), away ) );
var totalInto = myInto + otherInto;
var myShare = totalInto > 1f ? MathF.Min( 1f, otherInto / totalInto * 2f ) : 1f;
var otherShare = totalInto > 1f ? MathF.Min( 1f, myInto / totalInto * 2f ) : 1f;
// Grip suppression scales with shove magnitude.
Car.Movement.ApplyImpulse( away * magnitude * myShare, KnockbackGripSuppress * myShare );
// Tell the other car's owner to apply the mirrored impulse.
if ( otherCar.Collision.IsValid() )
otherCar.Collision.ReceiveKnockback( -away * magnitude * otherShare, KnockbackGripSuppress * otherShare );
// Lower-slot car spawns FX and fires the event so a hit produces one burst.
if ( Car.Slot < otherCar.Slot )
{
SpawnImpact( point );
Scene.RunEvent<ICarImpactListener>( x => x.OnCarImpact( new CarImpact
{
Kind = CarImpactKind.Car,
Car = Car,
OtherCar = otherCar,
Other = otherCar.GameObject,
Point = point,
Normal = normal,
Speed = closing
} ) );
}
}
private void ApplyWallBounce( Vector3 point, Vector3 normal, GameObject other )
{
if ( !Car.Movement.IsValid() || Time.Now < _nextBounceTime )
return;
// Only bounce off wall-like surfaces.
if ( normal.z >= 0.5f )
return;
// Outward normal pointing toward the car, flattened to horizontal.
var outward = normal.WithZ( 0f ).Normal;
if ( Vector3.Dot( outward, Car.WorldPosition - point ) < 0f )
outward = -outward;
// Use pre-impact velocity; slide has already cancelled into-wall speed.
var into = MathF.Max( 0f, -Vector3.Dot( _preStepVelocity, outward ) );
if ( into < WallBounceMinSpeed )
return;
// Only rebound on near-head-on hits; glancing contact grinds along the wall.
var approachSpeed = _preStepVelocity.WithZ( 0f ).Length;
if ( approachSpeed > 1f && (into / approachSpeed) < WallBounceMinDot )
return;
_nextBounceTime = Time.Now + ImpactCooldown;
Car.Movement.ApplyImpulse( outward * into * WallBounciness, KnockbackGripSuppress );
SpawnImpact( point );
Scene.RunEvent<ICarImpactListener>( x => x.OnCarImpact( new CarImpact
{
Kind = CarImpactKind.Wall,
Car = Car,
Other = other,
Point = point,
Normal = normal,
Speed = into
} ) );
}
/// <summary>
/// RPC: apply a knockback impulse sent by the other car's owner. Gated by IsAuthority and cooldown.
/// </summary>
[Rpc.Broadcast]
public void ReceiveKnockback( Vector3 impulse, float gripSuppressSeconds )
{
if ( !IsAuthority )
return;
if ( Time.Now < _nextImpactTime )
return;
_nextImpactTime = Time.Now + ImpactCooldown;
Car.Movement.ApplyImpulse( impulse, gripSuppressSeconds );
}
[Rpc.Broadcast( NetFlags.OwnerOnly )]
private void StartScrape()
{
if ( ScrapeEffectPrefab.IsValid() )
{
_activeScrapeEffect = ScrapeEffectPrefab.Clone( new CloneConfig
{
Parent = GameObject,
StartEnabled = true,
Transform = new Transform( Vector3.Zero )
} );
}
if ( !string.IsNullOrEmpty( ScrapeSound ) )
_scrapeSound = Sound.Play( ScrapeSound, WorldPosition );
}
[Rpc.Broadcast( NetFlags.OwnerOnly )]
private void StopScrape()
{
if ( _activeScrapeEffect.IsValid() )
{
_activeScrapeEffect.Destroy();
_activeScrapeEffect = null;
}
if ( _scrapeSound is not null )
{
_scrapeSound.Stop();
_scrapeSound = null;
}
}
[Rpc.Broadcast( NetFlags.OwnerOnly )]
private void SpawnImpact( Vector3 point )
{
if ( ImpactEffectPrefab.IsValid() )
{
ImpactEffectPrefab.Clone( new CloneConfig
{
Transform = new Transform( point ),
StartEnabled = true
} );
}
if ( !string.IsNullOrEmpty( ImpactSound ) )
Sound.Play( ImpactSound, point );
}
protected override void OnDisabled()
{
if ( IsScraping )
{
IsScraping = false;
StopScrape();
}
}
}