Player/Items/MineHazard.cs

A component implementing a dropped mine item for cars. It spawns behind the dropper, aligns to surface, gives brief self-immunity to the dropper, detonates on car trigger to spin the victim, awards score to the mine owner, spawns an explosion effect and destroys the mine after a lifetime.

Networking
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; }

	/// <summary>
	/// The car that dropped the mine, synced so the victim's machine can credit the hit (works for bot mines too).
	/// </summary>
	[Sync]
	public Car Owner { get; set; }

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

	// True on the machine that owns the mine (dropper, or host for bot mines); also true single-player.
	private bool OwnsMine => !Networking.IsActive || GameObject.Network.IsOwner;

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

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

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

		var car = other.GameObject.GetComponentInParent<Car>();
		// Detect on the machine simulating the touching car, so the victim detonates it instantly.
		if ( !car.IsValid() || !car.IsAuthority )
			return;

		// Self-immunity window so the dropper doesn't immediately trigger it (only hits on the dropper's machine).
		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" );
		}

		Detonate();
	}

	public void OnTriggerExit( Collider other ) { }

	[Rpc.Broadcast]
	private void Detonate()
	{
		_triggered = true;

		if ( ExplosionEffect.IsValid() )
		{
			ExplosionEffect.Clone( new CloneConfig
			{
				Transform = new Transform( WorldPosition ),
				StartEnabled = true
			} );
		}

		// Vanish instantly on every machine; the owner does the authoritative destroy.
		foreach ( var col in GameObject.Components.GetAll<Collider>( FindMode.EverythingInSelfAndDescendants ) )
			col.Enabled = false;
		foreach ( var mr in GameObject.Components.GetAll<ModelRenderer>( FindMode.EverythingInSelfAndDescendants ) )
			mr.Enabled = false;

		if ( OwnsMine )
			GameObject.Destroy();
	}
}