Code/TrenchFire.cs

An editor/game component that simulates decorative machine-gun tracer fire for a trench map. It spawns pooled tracer effects, brief muzzle flash lights, plays a distant shot sound, traces ground to spawn dust puff particles, and exposes editable handles and timing/visual properties.

File AccessNetworking
using Sandbox;
using System;

/// <summary>
/// Decorative machine-gun fire for the trench map: bursts of glowing tracers fly from a random
/// point on the Start line to a random point on the End line, with a muzzle flash where they leave
/// and a dust puff where they land, and a distant, softened shot sound for every round. Purely a
/// show: nothing is traced against players or balls, nothing takes damage, nothing is networked.
/// Every machine runs its own with its own random timing.
///
/// The two lines are each two child objects (Start A/B, End A/B) that you move with the normal
/// move gizmo; they are created the first time the component is seen in the editor if missing.
/// The lines and the handles are drawn as gizmos. The component's own Enabled checkbox turns the
/// gun off. Small pool of tracers and flash lights, nothing allocated per shot but the engine's
/// own sound handle and the surface's impact particle.
/// </summary>
[Title( "Trench Fire" ), Category( "Golf" ), Icon( "whatshot" )]
public sealed class TrenchFire : Component
{
	/// <summary>One end of the line the tracers leave from.</summary>
	[Property, Group( "Lines" ), Title( "Start A" )]
	public GameObject StartA { get; set; }

	/// <summary>The other end of the line the tracers leave from.</summary>
	[Property, Group( "Lines" ), Title( "Start B" )]
	public GameObject StartB { get; set; }

	/// <summary>One end of the line the tracers land on.</summary>
	[Property, Group( "Lines" ), Title( "End A" )]
	public GameObject EndA { get; set; }

	/// <summary>The other end of the line the tracers land on.</summary>
	[Property, Group( "Lines" ), Title( "End B" )]
	public GameObject EndB { get; set; }

	/// <summary>Random scatter around the landing point, in units.</summary>
	[Property, Group( "Lines" ), Range( 0f, 300f )]
	public float Spread { get; set; } = 40f;

	/// <summary>Bursts per minute.</summary>
	[Property, Group( "Timing" ), Range( 0.1f, 60f ), Title( "Rate (bursts per minute)" )]
	public float Rate { get; set; } = 6f;

	/// <summary>Fewest shots in a burst.</summary>
	[Property, Group( "Timing" ), Range( 1, 60 ), Title( "Burst Length Min" )]
	public int BurstMin { get; set; } = 8;

	/// <summary>Most shots in a burst.</summary>
	[Property, Group( "Timing" ), Range( 1, 60 ), Title( "Burst Length Max" )]
	public int BurstMax { get; set; } = 15;

	/// <summary>Shots per second inside a burst.</summary>
	[Property, Group( "Timing" ), Range( 1f, 30f )]
	public float ShotsPerSecond { get; set; } = 12f;

	/// <summary>How much the pause between bursts varies, 0 for a metronome, 1 for anything from no pause to double.</summary>
	[Property, Group( "Timing" ), Range( 0f, 1f )]
	public float PauseVariance { get; set; } = 0.5f;

	/// <summary>How fast a tracer flies, units per second.</summary>
	[Property, Group( "Tracer" ), Range( 500f, 20000f )]
	public float TracerSpeed { get; set; } = 6000f;

	/// <summary>Colour of the tracer streak, the flash and its light.</summary>
	[Property, Group( "Tracer" )]
	public Color TracerColour { get; set; } = new Color( 1f, 0.55f, 0.15f );

	/// <summary>Length of the streak, in units.</summary>
	[Property, Group( "Tracer" ), Range( 10f, 1000f )]
	public float TracerLength { get; set; } = 160f;

	/// <summary>Width of the streak at its bright end, in units.</summary>
	[Property, Group( "Tracer" ), Range( 0.5f, 10f )]
	public float TracerWidth { get; set; } = 2.5f;

	/// <summary>How much brighter than the colour the streak glows.</summary>
	[Property, Group( "Tracer" ), Range( 1f, 8f )]
	public float Glow { get; set; } = 3f;

	/// <summary>A short flash of light at the start point for every shot.</summary>
	[Property, Group( "Effects" )]
	public bool MuzzleFlash { get; set; } = true;

	/// <summary>The surface's bullet impact particle where a tracer lands.</summary>
	[Property, Group( "Effects" )]
	public bool DustPuff { get; set; } = true;

	/// <summary>The distant shot, played at the start point for every round. Empty uses sounds/trench_fire.</summary>
	[Property, Group( "Sound" )]
	public SoundEvent ShotSound { get; set; }

	/// <summary>Volume of the shots, 0 to 1.</summary>
	[Property, Group( "Sound" ), Range( 0f, 1f )]
	public float Volume { get; set; } = 0.35f;

	const string DefaultSound = "sounds/trench_fire.sound";
	const float FlashSeconds = 0.05f;
	const float FlashRadius = 160f;

	struct Landing
	{
		public float At;        // Time.Now the tracer arrives; 0 for none pending
		public Vector3 Position;
		public Vector3 Normal;
		public Surface Surface;
	}

	TracerEffect[] _tracers = Array.Empty<TracerEffect>();
	Landing[] _landings = Array.Empty<Landing>();
	PointLight[] _flashes = Array.Empty<PointLight>();
	float[] _flashOff = Array.Empty<float>();
	int _nextTracer;
	int _nextFlash;
	Surface _defaultSurface;
	Color _builtColour;
	float _builtWidth;

	float _nextBurstAt;
	float _nextShotAt;
	int _shotsLeft;

	protected override void OnStart()
	{
		EnsureHandles();
		ShotSound ??= ResourceLibrary.Get<SoundEvent>( DefaultSound );
		_defaultSurface = Surface.FindByName( "default" );
		BuildPool();
		_nextBurstAt = Time.Now + NextPause() * Game.Random.Float( 0f, 1f );
	}

	protected override void OnDisabled()
	{
		_shotsLeft = 0;
		for ( var i = 0; i < _landings.Length; i++ ) _landings[i].At = 0f;
		foreach ( var flash in _flashes ) if ( flash.IsValid() ) flash.Enabled = false;
	}

	protected override void OnUpdate()
	{
		if ( Scene.IsEditor ) return;
		if ( _tracers.Length == 0 ) return;

		var now = Time.Now;

		// Flashes go out, landings puff.
		for ( var i = 0; i < _flashes.Length; i++ )
		{
			if ( _flashOff[i] > 0f && now >= _flashOff[i] )
			{
				_flashOff[i] = 0f;
				if ( _flashes[i].IsValid() ) _flashes[i].Enabled = false;
			}
		}

		for ( var i = 0; i < _landings.Length; i++ )
		{
			if ( _landings[i].At <= 0f || now < _landings[i].At ) continue;
			_landings[i].At = 0f;
			if ( DustPuff )
				BaseCombatWeapon.ImpactPrefab( null, _landings[i].Surface, _landings[i].Position, _landings[i].Normal );
		}

		// Bursts.
		if ( _shotsLeft <= 0 )
		{
			if ( now < _nextBurstAt ) return;
			_shotsLeft = Game.Random.Int( Math.Min( BurstMin, BurstMax ), Math.Max( BurstMin, BurstMax ) );
			_nextShotAt = now;
			_nextBurstAt = now + NextPause();
		}

		if ( now < _nextShotAt ) return;
		_nextShotAt = now + 1f / Math.Max( 1f, ShotsPerSecond );
		_shotsLeft--;
		Fire();
	}

	float NextPause()
	{
		var mean = 60f / Math.Max( 0.1f, Rate );
		return mean * (1f + Game.Random.Float( -PauseVariance, PauseVariance ));
	}

	void Fire()
	{
		if ( !StartA.IsValid() || !StartB.IsValid() || !EndA.IsValid() || !EndB.IsValid() ) return;

		var start = Vector3.Lerp( StartA.WorldPosition, StartB.WorldPosition, Game.Random.Float( 0f, 1f ) );
		var end = Vector3.Lerp( EndA.WorldPosition, EndB.WorldPosition, Game.Random.Float( 0f, 1f ) )
			+ Vector3.Random * Spread;

		// Where it lands: the ground near the end point, for the puff's surface and facing.
		var landing = new Landing { Position = end, Normal = Vector3.Up, Surface = _defaultSurface };
		var trace = Scene.Trace.Ray( end + Vector3.Up * 48f, end + Vector3.Down * 96f ).WithoutTags( "player", "golfball" ).Run();
		if ( trace.Hit )
		{
			landing.Position = trace.HitPosition;
			landing.Normal = trace.Normal;
			landing.Surface = trace.Surface ?? _defaultSurface;
		}

		var distance = start.Distance( end );
		landing.At = Time.Now + distance / Math.Max( 1f, TracerSpeed );

		// Tracer: the pool's oldest one restarts from the new start point.
		var i = _nextTracer;
		_nextTracer = (_nextTracer + 1) % _tracers.Length;
		var tracer = _tracers[i];
		if ( tracer.IsValid() )
		{
			if ( _builtColour != TracerColour || _builtWidth != TracerWidth ) BuildLook();
			tracer.Enabled = false;
			tracer.WorldPosition = start;
			tracer.EndPoint = end;
			tracer.DistancePerSecond = TracerSpeed;
			tracer.Length = TracerLength;
			tracer.Enabled = true;
		}
		_landings[i] = landing;

		// Muzzle flash.
		if ( MuzzleFlash && _flashes.Length > 0 )
		{
			var f = _nextFlash;
			_nextFlash = (_nextFlash + 1) % _flashes.Length;
			var flash = _flashes[f];
			if ( flash.IsValid() )
			{
				flash.WorldPosition = start + (end - start).Normal * 12f;
				flash.LightColor = TracerColour * Glow;
				flash.Radius = FlashRadius;
				flash.Enabled = true;
				_flashOff[f] = Time.Now + FlashSeconds;
			}
		}

		// The distant shot.
		if ( ShotSound is not null && Volume > 0f )
		{
			var handle = Sound.Play( ShotSound, start );
			if ( handle.IsValid() ) handle.Volume = Volume;
		}
	}

	/// <summary>
	/// Enough tracers to cover the longest flight at the burst rate, and a couple of flash lights.
	/// Pool objects are hidden children that are never saved or networked.
	/// </summary>
	void BuildPool()
	{
		var longest = 0f;
		if ( StartA.IsValid() && StartB.IsValid() && EndA.IsValid() && EndB.IsValid() )
		{
			longest = Math.Max( Math.Max( StartA.WorldPosition.Distance( EndA.WorldPosition ), StartA.WorldPosition.Distance( EndB.WorldPosition ) ),
				Math.Max( StartB.WorldPosition.Distance( EndA.WorldPosition ), StartB.WorldPosition.Distance( EndB.WorldPosition ) ) );
		}
		var flight = (longest + Spread + TracerLength) / Math.Max( 1f, TracerSpeed );
		var count = Math.Clamp( (int)MathF.Ceiling( flight * ShotsPerSecond ) + 2, 4, 32 );

		_tracers = new TracerEffect[count];
		_landings = new Landing[count];
		for ( var i = 0; i < count; i++ )
		{
			var go = new GameObject( GameObject, false, $"tracer {i}" );
			go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked | GameObjectFlags.Hidden;
			var tracer = go.AddComponent<TracerEffect>( false );
			tracer.StartDistance = 0f;
			tracer.Opaque = false;
			tracer.CastShadows = false;
			tracer.EnableLight = false;
			_tracers[i] = tracer;
			go.Enabled = true;
		}
		BuildLook();

		_flashes = new PointLight[2];
		_flashOff = new float[2];
		for ( var i = 0; i < _flashes.Length; i++ )
		{
			var go = new GameObject( GameObject, false, $"flash {i}" );
			go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked | GameObjectFlags.Hidden;
			var light = go.AddComponent<PointLight>( false );
			light.Shadows = false;
			light.Radius = FlashRadius;
			light.Attenuation = 2f;
			_flashes[i] = light;
			go.Enabled = true;
		}
	}

	void BuildLook()
	{
		_builtColour = TracerColour;
		_builtWidth = TracerWidth;
		var head = TracerColour * Glow;
		head.a = 1f;
		var colour = new Gradient( new Gradient.ColorFrame( 0f, head ), new Gradient.ColorFrame( 0.35f, TracerColour ), new Gradient.ColorFrame( 1f, TracerColour.WithAlpha( 0f ) ) );
		var width = new Curve( new Curve.Frame( 0f, TracerWidth ), new Curve.Frame( 1f, 0f ) );
		foreach ( var tracer in _tracers )
		{
			if ( !tracer.IsValid() ) continue;
			tracer.LineColor = colour;
			tracer.LineWidth = width;
		}
	}

	/// <summary>
	/// The four handle objects: found by name under this object, or made with default spots.
	/// </summary>
	void EnsureHandles()
	{
		StartA = Handle( StartA, "Start A", new Vector3( 0f, -150f, 40f ) );
		StartB = Handle( StartB, "Start B", new Vector3( 0f, 150f, 40f ) );
		EndA = Handle( EndA, "End A", new Vector3( 1200f, -250f, 0f ) );
		EndB = Handle( EndB, "End B", new Vector3( 1200f, 250f, 0f ) );
	}

	GameObject Handle( GameObject current, string name, Vector3 localPosition )
	{
		if ( current.IsValid() ) return current;

		foreach ( var child in GameObject.Children )
			if ( child.IsValid() && child.Name == name ) return child;

		var go = new GameObject( GameObject, true, name );
		go.LocalPosition = localPosition;
		return go;
	}

	protected override void DrawGizmos()
	{
		EnsureHandles();
		if ( !StartA.IsValid() || !StartB.IsValid() || !EndA.IsValid() || !EndB.IsValid() ) return;

		var alpha = Gizmo.IsSelected ? 1f : (Gizmo.IsHovered ? 0.6f : 0.3f);
		var local = WorldTransform;
		var sa = local.PointToLocal( StartA.WorldPosition );
		var sb = local.PointToLocal( StartB.WorldPosition );
		var ea = local.PointToLocal( EndA.WorldPosition );
		var eb = local.PointToLocal( EndB.WorldPosition );

		Gizmo.Draw.LineThickness = Gizmo.IsSelected ? 3f : 2f;
		Gizmo.Draw.Color = TracerColour.WithAlpha( alpha );
		Gizmo.Draw.Line( sa, sb );
		Gizmo.Draw.LineSphere( new Sphere( sa, 8f ), 8 );
		Gizmo.Draw.LineSphere( new Sphere( sb, 8f ), 8 );

		Gizmo.Draw.Color = Color.Red.WithAlpha( alpha );
		Gizmo.Draw.Line( ea, eb );
		Gizmo.Draw.LineSphere( new Sphere( ea, 8f ), 8 );
		Gizmo.Draw.LineSphere( new Sphere( eb, 8f ), 8 );

		Gizmo.Draw.LineThickness = 1f;
		Gizmo.Draw.Color = Color.White.WithAlpha( alpha * 0.7f );
		Gizmo.Draw.Arrow( (sa + sb) * 0.5f, (ea + eb) * 0.5f, 24f, 8f );
	}
}