Player/Car/Gameplay/CarCollision.cs

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.

NetworkingFile Access
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();
		}
	}
}