Player/Items/MineHazard.cs

A networked mine item component. It can be activated by a car to place a mine behind the vehicle, aligns to ground, spawns on the network, expires after a lifetime, and triggers a spinout and explosion FX when another car (not the owner during self-immunity) touches it, awarding score to the dropper.

NetworkingFile Access
using Machines.Player;
using Machines.UI;

namespace Machines.Items;

/// <summary>
/// Dropped mine that spins out cars; dropper has brief self-immunity.
/// </summary>
public sealed class MineHazard : Component, Component.ITriggerListener, IPickupItem, IMinimapBlip
{
	Color IMinimapBlip.BlipColor => new( 1f, 0.4f, 0.15f );
	string IMinimapBlip.BlipClass => "item mine";
	int IMinimapBlip.BlipPriority => 1;

	/// <summary>
	/// Distance behind the dropper to place the mine.
	/// </summary>
	[Property]
	public float SpawnBack { get; set; } = 70f;

	/// <summary>
	/// Height above the drop point for the downward ground trace.
	/// </summary>
	[Property]
	public float TraceUp { get; set; } = 128f;

	/// <summary>
	/// Height above the surface the mine rests at.
	/// </summary>
	[Property]
	public float SurfaceOffset { get; set; } = 4f;

	/// <summary>
	/// Self-immunity window after dropping (seconds).
	/// </summary>
	[Property]
	public float SelfImmunity { get; set; } = 1.5f;

	/// <summary>
	/// Seconds before the mine self-destructs if untouched.
	/// </summary>
	[Property]
	public float Lifetime { get; set; } = 30f;

	/// <summary>
	/// Seconds the victim spins out for.
	/// </summary>
	[Property]
	public float SpinDuration { get; set; } = 1.5f;

	/// <summary>
	/// FX spawned when the mine goes off.
	/// </summary>
	[Property]
	public GameObject ExplosionEffect { get; set; }

	private Car _owner;
	private float _droppedAt;
	private float _expireAt;
	private bool _triggered;

	public bool Activate( Car owner )
	{
		// Can't deploy mid-air.
		if ( !owner.Movement.IsValid() || !owner.Movement.IsGrounded )
			return false;

		_owner = owner;
		_droppedAt = Time.Now;

		// Drop behind car and trace down to find the surface normal.
		var rot = Rotation.FromYaw( owner.Movement.Yaw );
		var dropPoint = owner.WorldPosition - rot.Forward * SpawnBack;
		var traceStart = dropPoint + Vector3.Up * TraceUp;

		// Exclude players/cars; don't filter by tag since the track isn't reliably tagged "world".
		var tr = Scene.Trace
			.Ray( traceStart, traceStart + Vector3.Down * (TraceUp * 4f) )
			.WithoutTags( "player", "car", "trigger" )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		if ( tr.Hit )
		{
			// Align to the surface: up = hit normal, forward = car heading projected onto surface.
			var forwardOnSurface = rot.Forward - tr.Normal * Vector3.Dot( rot.Forward, tr.Normal );
			if ( forwardOnSurface.IsNearlyZero() )
				forwardOnSurface = rot.Right;

			WorldPosition = tr.HitPosition + tr.Normal * SurfaceOffset;
			WorldRotation = Rotation.LookAt( forwardOnSurface.Normal, tr.Normal );
		}
		else
		{
			WorldPosition = dropPoint + Vector3.Up * SurfaceOffset;
			WorldRotation = rot;
		}

		GameObject.NetworkSpawn();
		return true;
	}

	private bool IsAuthority => !Networking.IsActive || GameObject.Network.IsOwner;

	protected override void OnStart()
	{
		_expireAt = Time.Now + Lifetime;
	}

	protected override void OnFixedUpdate()
	{
		if ( IsAuthority && Time.Now >= _expireAt )
			GameObject.Destroy();
	}

	public void OnTriggerEnter( Collider other )
	{
		if ( !IsAuthority || _triggered )
			return;

		var car = other.GameObject.GetComponentInParent<Car>();
		if ( !car.IsValid() )
			return;

		// Self-immunity window so the dropper doesn't immediately trigger it.
		if ( car == _owner && Time.Now - _droppedAt < SelfImmunity )
			return;

		_triggered = true;
		car.Spinout?.Spin( SpinDuration, (car.WorldPosition - WorldPosition).WithZ( 0f ) );

		// Credit the dropper, but not for tripping their own mine.
		if ( car != _owner )
		{
			_owner?.Score?.RpcAdd( "Mine Hit", 50 );
			_owner?.Score?.RpcRecordHit( "mine-hits" );
		}

		SpawnExplosionFx( WorldPosition );
		GameObject.Destroy();
	}

	public void OnTriggerExit( Collider other ) { }

	[Rpc.Broadcast]
	private void SpawnExplosionFx( Vector3 point )
	{
		if ( ExplosionEffect.IsValid() )
		{
			ExplosionEffect.Clone( new CloneConfig
			{
				Transform = new Transform( point ),
				StartEnabled = true
			} );
		}
	}
}