Effects/BombShell.cs

A visual effect component that represents a thrown bomb shell: it draws a falling projectile (mesh or fallback line), a trailing streak, an optional point light, and a target marker ring on the ground, and spawns local blast/visuals when it lands. It provides static tunables, spawn helpers (local and broadcast), debug console commands, and manages per-shell lifetime and drawing.

NetworkingFile Access
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// A thrown bomb: a glowing shell lobbed on an arc, a target ring where it will land, and a
/// detonation when it gets there.
///
/// ⛔ IT IS PURELY A PICTURE. Nothing here damages anything, deliberately: the shell exists on
/// every machine so that everyone can see the barrage, and a copy per machine that dealt damage
/// would hurt each player once per client. Whoever threw it keeps the damage on its own side and
/// schedules it for the same moment — see `OberonBoss.BombWave`.
///
/// ⚠️ THE TARGET RING IS THE POINT OF THE WHOLE CLASS, not the shell. Before this, Oberon's
/// barrage was three waves of invisible instant blasts: damage arrived out of a clear sky with
/// nothing to react to, and the only cue that anything had happened was the camera shaking. A
/// bomb you can watch fall onto a marked circle is the same attack made fair.
///
/// ⚠️ AND IT IS LINES AND A LIGHT, NOT A MODEL. `proj_drg_bomb` is an entity from a base addon the
/// workshop item does not ship — the pack carries `bomb_oberon.vmt` and no mesh to put it on. So
/// the shell is drawn the way every other effect in this project is drawn: a thick two-point
/// `LineRenderer` for the body, a thin fading one for the trail, and a small light.
/// </summary>
public sealed class BombShell : Component
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does
	// not re-run. INSTRUCTIONS.md §1.

	static string _modelPath;
	/// <summary>
	/// The shell's mesh. The addon's own bomb.
	/// </summary>
	///
	/// ✅ `proj_drg_bomb.lua` NAMES IT: `ENT.Models = {"models/misc/zbs_bossl_big02_bomb.mdl"}`.
	/// It was missed on the first pass because the workshop item's file INDEX is truncated when
	/// listed, so a search for a bomb mesh came back with only the `bomb_oberon` material and the
	/// conclusion "the pack ships no model". It ships both, in the same archive as Oberon himself.
	///
	/// ⚠️ A SPIKED METAL BALL ABOUT 46 UNITS ACROSS, one material, 200 triangles. Ported
	/// through the STATIC path (`Tools/qc_to_vmdl.py`, SMD → OBJ) rather than the animated one:
	/// its only sequence is an idle, and the tumble is applied here instead.
	public static string ModelPath
	{
		get => _modelPath ?? "models/zombies/bomb_oberon.vmdl";
		set => _modelPath = value;
	}

	static float? _modelScale;
	/// <summary>Scale of that mesh. 1 — the ball is already about 46 units across.</summary>
	public static float ModelScale { get => _modelScale ?? 1f; set => _modelScale = value; }

	static float? _bodyWidth;
	/// <summary>
	/// Thickness of the DRAWN shell — the fallback used when the mesh will not load. 9.
	/// </summary>
	///
	/// ⚠️ IT IS STILL HERE BECAUSE `Model.Load` CAN RETURN NULL and a barrage of fifteen
	/// invisible bombs is the exact bug this whole class was written to remove. A thick two-point
	/// line along the direction of travel reads as a glowing slug — not the real thing, but never
	/// nothing.
	public static float BodyWidth { get => _bodyWidth ?? 9f; set => _bodyWidth = value; }

	static float? _bodyLength;
	/// <summary>How long that segment is, in units. 22.</summary>
	public static float BodyLength { get => _bodyLength ?? 22f; set => _bodyLength = value; }

	static int? _trail;
	/// <summary>How many past positions the trail keeps. 16.</summary>
	public static int Trail { get => _trail ?? 16; set => _trail = value; }

	static float? _trailWidth;
	/// <summary>Thickness of the trail. 3.5.</summary>
	public static float TrailWidth { get => _trailWidth ?? 3.5f; set => _trailWidth = value; }

	static int? _markerSegments;
	/// <summary>Points in the target ring. 28.</summary>
	public static int MarkerSegments
	{
		get => _markerSegments ?? 28;
		set => _markerSegments = value;
	}

	static bool? _marker;
	/// <summary>Draw the ring where it will land. On.</summary>
	///
	/// ⛔ TURNING THIS OFF MAKES THE ATTACK UNFAIR, not merely plainer. The ring is drawn at the
	/// blast radius, so it is the only thing telling a player which patch of floor is about to
	/// become lethal. It is a switch because every look in this project has one, not because off
	/// is a reasonable setting.
	public static bool Marker { get => _marker ?? true; set => _marker = value; }

	static bool? _light;
	/// <summary>A light on each shell. On.</summary>
	///
	/// ⚠️ SMALL RADIUS, AND HERE IS THE SWITCH IF FIFTEEN AT ONCE EVER COSTS ANYTHING. Map lights
	/// are the ones this project has measured as expensive (71 of them cost 3.5× the frame time);
	/// these are 120-unit lights alive for under two seconds. Turn it off before anything else if
	/// a barrage ever stutters.
	public static bool Light { get => _light ?? true; set => _light = value; }

	static float? _brightness;
	/// <summary>How hard that light burns. 4.</summary>
	public static float Brightness { get => _brightness ?? 4f; set => _brightness = value; }

	static float? _lightRadius;
	/// <summary>How far it reaches. 120.</summary>
	public static float LightRadius { get => _lightRadius ?? 120f; set => _lightRadius = value; }

	static int? _maxLights;
	/// <summary>
	/// How many shells may carry a light at the same time. 12.
	/// </summary>
	///
	/// ⛔ A HARD CAP, BECAUSE THE WAVES NOW OVERLAP. Ten bombs a wave (twenty-five until 2026-09-27) with a 2.2s flight against
	/// waves 1.8s apart means some twenty shells can be in the air together, forty-odd at twenty-five — and forty
	/// dynamic lights is the one cost this project has actually measured as expensive (71 map
	/// lights cost 3.5× the frame time). The shells past the cap keep their mesh and their trail
	/// and simply do not light the world.
	///
	/// ⚠️ FIRST COME, FIRST LIT, which is not the cleverest rule but is the only one that costs
	/// nothing to evaluate. Set it to 0 to turn lights off entirely.
	public static int MaxLights { get => _maxLights ?? 12; set => _maxLights = value; }

	/// <summary>How many are lit right now.</summary>
	///
	/// ⚠️ A STATIC COUNTER SURVIVES A HOTLOAD WHILE THE SHELLS DO NOT, so it is floored at
	/// zero on the way down rather than trusted. A drift of a few would only mean a few unlit
	/// bombs in one barrage.
	static int _lit;

	static float? _spin;
	/// <summary>How fast the shell tumbles, in turns per second. 2.2.</summary>
	public static float Spin { get => _spin ?? 2.2f; set => _spin = value; }

	/// <summary>The authored shell colour — the same red-hot as Oberon's landing wave.</summary>
	///
	/// ⚠️ AN EXPRESSION, NOT A `static readonly` FIELD, so a hotload cannot freeze it.
	public static Color DefaultColour => new Color( 1f, 0.42f, 0.12f );

	static Color? _colour;
	public static Color Colour { get => _colour ?? DefaultColour; set => _colour = value; }

	const float Lift = 2f;

	// ══ per-shell state ══════════════════════════════════════════════════════

	/// <summary>Where it is thrown from, and where it comes down.</summary>
	public Vector3 From { get; set; }
	public Vector3 To { get; set; }

	/// <summary>How long it waits before it is thrown, and how long it is in the air.</summary>
	public float Delay { get; set; }
	public float Flight { get; set; } = 1.8f;

	/// <summary>How high the arc peaks above the straight line between the ends.</summary>
	public float Apex { get; set; } = 420f;

	/// <summary>Blast radius, which is also the radius the target ring is drawn at.</summary>
	public float Radius { get; set; } = 220f;

	/// <summary>Shell colour.</summary>
	public Color Tint { get; set; } = DefaultColour;

	float _born;
	bool _gone;
	bool _marked;

	LineRenderer _body, _trailLine, _markerLine;
	PointLight _pointLight;

	GameObject _modelGo;

	readonly List<Vector3> _bodyBuf = new( 2 );
	readonly List<Vector3> _trailBuf = new();
	readonly List<Vector3> _markerBuf = new();

	Vector3 _last;

	// ══ throwing ═════════════════════════════════════════════════════════════

	/// <summary>
	/// Throw one, here only.
	/// </summary>
	///
	/// ⚠️ THE LANDING POINT IS SNAPPED TO THE FLOOR BY THE CALLER, NOT HERE, because the caller is
	/// the one that had to pick a reachable patch of ground in the first place — and the same
	/// point has to reach every machine identically or the marker and the crater disagree.
	public static BombShell Throw( Vector3 from, Vector3 to, float delay, float flight,
		float apex, float radius, Color? colour = null )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return null;

		var go = scene.CreateObject();
		go.Name = "nz_bomb_shell";
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		go.WorldPosition = from;

		var b = go.Components.Create<BombShell>();

		b.From = from;
		b.To = to;
		b.Delay = MathF.Max( 0f, delay );
		b.Flight = MathF.Max( 0.15f, flight );
		b.Apex = MathF.Max( 0f, apex );
		b.Radius = MathF.Max( 16f, radius );
		b.Tint = colour ?? Colour;

		return b;
	}

	/// <summary>
	/// Throw one here AND on every other machine.
	/// </summary>
	///
	/// ⛔ A BOSS IS SIMULATED ON THE HOST ALONE. ALL VISUAL EFFECTS ARE GLOBAL — and a barrage
	/// nobody but the host can see is the worst case of that rule being broken, because the
	/// damage lands on everyone regardless.
	///
	/// ⚠️ EVERY NUMBER IS SENT, none re-rolled. The scatter is random, so a receiver that picked
	/// its own would draw fifteen shells landing somewhere other than where the damage goes.
	public static BombShell ThrowShared( Vector3 from, Vector3 to, float delay, float flight,
		float apex, float radius, Color? colour = null )
	{
		if ( Networking.IsActive && NZGame.IsHost )
		{
			var c = colour ?? Colour;
			NZNet.BombShellFx( from, to, delay, flight, apex, radius, c.r, c.g, c.b );
		}

		return Throw( from, to, delay, flight, apex, radius, colour );
	}

	// ══ building ═════════════════════════════════════════════════════════════

	protected override void OnStart()
	{
		_born = Time.Now;
		_last = From;

		// ⛔ THE MESH IS A CHILD OBJECT, NOT A COMPONENT ON THIS ONE, so it can tumble without
		// touching anything else. The trail and the marker are `LineRenderer`s in WORLD-space
		// points — rotating the object they hang off would not move them, but scaling it would,
		// and a shell at any scale other than 1 would drag its own trail out of shape.
		var model = string.IsNullOrWhiteSpace( ModelPath ) ? null : Model.Load( ModelPath );

		if ( model is not null )
		{
			_modelGo = Scene.CreateObject();
			_modelGo.Name = "nz_bomb_mesh";
			_modelGo.Flags |= GameObjectFlags.NotSaved;
			_modelGo.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
			_modelGo.SetParent( GameObject );
			_modelGo.LocalPosition = Vector3.Zero;
			_modelGo.LocalScale = MathF.Max( 0.05f, ModelScale );

			_modelGo.Components.Create<ModelRenderer>().Model = model;
		}
		else
		{
			// ⚠️ NEVER NOTHING. See `BodyWidth`.
			Log.Warning( $"[nz-fx] bomb mesh '{ModelPath}' did not load"
				+ " — the shells fall back to a drawn body" );

			_body = MakeLine( BodyWidth );
			_body.Color = Flat( Tint );
		}

		_trailLine = MakeLine( TrailWidth );

		// ⛔ THE MARKER IS *NOT* TRACED HERE. A whole wave is created on one frame, and tracing
		// twenty-eight floor points for each of fifteen shells at once is 420 traces in a single
		// frame — a visible hitch, three times per barrage. Each shell traces its own when it is
		// thrown instead, which spreads them across the 0.45s stagger at about one a frame.
		if ( Marker )
			_markerLine = MakeLine( TrailWidth * 0.9f );

		if ( Light && _lit < MaxLights )
		{
			_lit++;

			var go = Scene.CreateObject();
			go.Name = "nz_bomb_light";
			go.Flags |= GameObjectFlags.NotSaved;
			go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
			go.SetParent( GameObject );
			go.LocalPosition = Vector3.Zero;

			_pointLight = go.Components.Create<PointLight>();
			_pointLight.LightColor = Tint * MathF.Max( 0f, Brightness );
			_pointLight.Radius = MathF.Max( 16f, LightRadius );
			_pointLight.Shadows = false;
		}

		// ⚠️ HIDDEN UNTIL IT IS THROWN. The shell is created for the whole wave at once so the
		// stagger needs no timers, which means the ones still waiting must draw nothing.
		Show( false );
	}

	static Gradient Flat( Color c ) => new Gradient( new Gradient.ColorFrame( 0f, c ) );

	LineRenderer MakeLine( float width )
	{
		var line = Components.Create<LineRenderer>();

		line.UseVectorPoints = true;
		line.Additive = true;
		line.Lighting = false;
		line.CastShadows = false;
		line.Color = Flat( Tint );
		line.Width = MathF.Max( 0.05f, width );

		return line;
	}

	/// <summary>
	/// Trace the target ring once, at spawn.
	/// </summary>
	///
	/// ⚠️ ONCE IS ENOUGH BECAUSE THE TARGET DOES NOT MOVE — the same reason `Vortex` samples its
	/// floor once and `ShockRing` cannot. Twenty-eight traces per shell at spawn, none after.
	void BuildMarker()
	{
		if ( !_markerLine.IsValid() ) return;

		var n = Math.Max( 8, MarkerSegments );
		_markerBuf.Clear();

		for ( var i = 0; i <= n; i++ )
		{
			var a = i / (float)n * MathF.PI * 2f;
			var p = To + new Vector3( MathF.Cos( a ), MathF.Sin( a ), 0f ) * Radius;

			_markerBuf.Add( PitVisual.GroundAt( p ) + Vector3.Up * Lift );
		}

		_markerLine.VectorPoints = _markerBuf;
	}

	void Show( bool on )
	{
		if ( _modelGo.IsValid() ) _modelGo.Enabled = on;
		if ( _body.IsValid() ) _body.Enabled = on;
		if ( _trailLine.IsValid() ) _trailLine.Enabled = on;
		if ( _pointLight.IsValid() ) _pointLight.Enabled = on;
	}

	// ══ flying ═══════════════════════════════════════════════════════════════

	protected override void OnUpdate()
	{
		if ( _gone ) return;

		var age = Time.Now - _born;

		// ── still in his hand ─────────────────────────────────────────────
		if ( age < Delay ) return;

		Show( true );

		// ⚠️ THE RING GOES DOWN THE MOMENT THE BOMB LEAVES HIS HAND, which is the whole warning:
		// nearly two seconds of flight with the landing already marked. Traced once, here, for the
		// reason given in `OnStart`.
		if ( !_marked )
		{
			_marked = true;
			BuildMarker();
		}

		var t = MathX.Clamp( (age - Delay) / Flight, 0f, 1f );

		// ⚠️ A PLAIN PARABOLA — `4t(1-t)` peaks at exactly 1 in the middle, so `Apex` is the height
		// above the chord and not a number that needs explaining.
		var at = Vector3.Lerp( From, To, t ) + Vector3.Up * (Apex * 4f * t * (1f - t));

		WorldPosition = at;

		// ⚠️ THE MARKER BRIGHTENS AS IT CLOSES, so several overlapping rings still read as "this
		// one lands next". Cubed, so the tell is late and sharp rather than a slow glow.
		if ( _markerLine.IsValid() )
			_markerLine.Color = Flat( Tint.WithAlpha( 0.12f + 0.88f * (t * t * t) ) );

		DrawBody( at, age );
		DrawTrail( at );

		if ( t >= 1f ) Detonate();
	}

	/// <summary>The shell itself: the mesh tumbling, or the drawn fallback.</summary>
	///
	/// ⚠️ IT TUMBLES ON ALL THREE AXES AT UNRELATED RATES, which is both what the original does
	/// (`phys:SetAngleVelocity( Vector(1300,1300,1300) )`) and what stops it looking like a ball
	/// spinning on one axis. Rates that share a factor come back into phase and read as a wobble.
	void DrawBody( Vector3 at, float age )
	{
		if ( _modelGo.IsValid() )
		{
			_modelGo.WorldRotation = Rotation.From(
				age * Spin * 360f,
				age * Spin * 297f,
				age * Spin * 211f );

			return;
		}

		if ( !_body.IsValid() ) return;

		var dir = (at - _last).Normal;
		if ( dir.LengthSquared < 0.01f ) dir = Vector3.Forward;

		// A vector perpendicular to travel, rotated about it over time.
		var side = Vector3.Cross( dir, Vector3.Up );
		if ( side.LengthSquared < 0.01f ) side = Vector3.Forward;
		side = side.Normal;

		var spun = Rotation.FromAxis( dir, age * Spin * 360f ) * side;
		var half = spun * (BodyLength * 0.5f);

		_bodyBuf.Clear();
		_bodyBuf.Add( at - half );
		_bodyBuf.Add( at + half );

		_body.VectorPoints = _bodyBuf;
	}

	/// <summary>The streak behind it, oldest point transparent.</summary>
	void DrawTrail( Vector3 at )
	{
		if ( !_trailLine.IsValid() ) return;

		_trailBuf.Add( at );

		var keep = Math.Max( 2, Trail );
		while ( _trailBuf.Count > keep ) _trailBuf.RemoveAt( 0 );

		_last = at;

		if ( _trailBuf.Count < 2 ) return;

		_trailLine.VectorPoints = _trailBuf;

		// ⚠️ FRAME 0 IS THE OLDEST POINT, because that is the order the list is in.
		_trailLine.Color = new Gradient(
			new Gradient.ColorFrame( 0f, Tint.WithAlpha( 0f ) ),
			new Gradient.ColorFrame( 1f, Tint.WithAlpha( 0.9f ) ) );
	}

	/// <summary>
	/// It lands.
	/// </summary>
	///
	/// ⚠️ `announce: false` ON THE BLAST. `BlastEffect.Spawn` relays itself through `WorldFx` by
	/// default, and this shell already exists on every machine — so the default would put one
	/// explosion here and a second one, from every other machine's relay, on top of it.
	///
	/// ⚠️ THE SOUND IS `NZSound.Play`, NOT `PlayShared`, for exactly the same reason.
	void Detonate()
	{
		_gone = true;

		BlastEffect.Spawn( To, Radius, announce: false );
		ShockRing.Fire( To, Radius, Tint );
		NZSound.Play( "nz.oberon.attack", To );

		GameObject.Destroy();
	}

	protected override void OnDestroy()
	{
		if ( _pointLight.IsValid() )
		{
			_lit = Math.Max( 0, _lit - 1 );

			if ( _pointLight.GameObject.IsValid() )
				_pointLight.GameObject.Destroy();
		}

		if ( _modelGo.IsValid() ) _modelGo.Destroy();
	}

	// ══ diagnostics ══════════════════════════════════════════════════════════

	/// <summary>`nz_bomb` — the resolved look and how many are in the air.</summary>
	[ConCmd( "nz_bomb" )]
	public static void Report()
	{
		var live = Game.ActiveScene?.GetAllComponents<BombShell>().Count() ?? 0;

		var model = string.IsNullOrWhiteSpace( ModelPath ) ? null : Model.Load( ModelPath );

		Log.Info( $"[nz-fx] BOMB SHELL · {live} in the air · spin {Spin:0.##}/s"
			+ $" · trail {Trail}pt × {TrailWidth:0.#}" );

		Log.Info( $"[nz-fx]   mesh {(model is null ? $"'{ModelPath}' MISSING — drawing a {BodyLength:0}u line instead" : $"{ModelPath} ×{ModelScale:0.##}")}" );

		Log.Info( $"[nz-fx]   marker {(Marker ? $"on, {MarkerSegments} segment(s)" : "OFF — the attack is unfair without it")}"
			+ $" · light {(Light ? $"{_lit}/{MaxLights} lit ×{Brightness:0.#} @ {LightRadius:0}u" : "off")}" );

		Log.Info( $"[nz-fx]   colour {Colour.r:0.##},{Colour.g:0.##},{Colour.b:0.##}" );
	}

	/// <summary>
	/// `nz_bomb_test [count] [spread] [radius]` — throw a wave over your own head.
	/// </summary>
	///
	/// ⚠️ IT EXISTS BECAUSE THE REAL TRIGGER IS ONE ATTACK IN THREE, three waves into an
	/// eleven-second clip. Tuning an arc that way costs a minute a look.
	[ConCmd( "nz_bomb_test" )]
	public static void TestCmd( int count = 15, float spread = 700f, float radius = 220f )
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-fx] no player" ); return; }

		var from = p.WorldPosition + Vector3.Up * 220f;

		for ( var i = 0; i < Math.Max( 1, count ); i++ )
		{
			var a = Game.Random.Float( 0f, MathF.Tau );
			var r = Game.Random.Float( spread * 0.25f, spread );
			var to = PitVisual.GroundAt(
				p.WorldPosition + new Vector3( MathF.Cos( a ) * r, MathF.Sin( a ) * r, 0f ) );

			// ⚠️ `Throw`, NOT `ThrowShared`. A test is for whoever typed the command.
			Throw( from, to, 0.03f * i, Game.Random.Float( 1.55f, 2.05f ), 420f, radius );
		}

		Log.Info( $"[nz-fx] test barrage — {count} shell(s) over {spread:0}u" );
	}

	/// <summary>`nz_bomb_set &lt;key&gt; &lt;value&gt;` — retune the shell.</summary>
	[ConCmd( "nz_bomb_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "modelscale": ModelScale = value; break;
			case "bodywidth": BodyWidth = value; break;
			case "bodylength": BodyLength = value; break;
			case "trail": Trail = (int)value; break;
			case "trailwidth": TrailWidth = value; break;
			case "markersegments": MarkerSegments = (int)value; break;
			case "marker": Marker = value > 0.5f; break;
			case "light": Light = value > 0.5f; break;
			case "brightness": Brightness = value; break;
			case "lightradius": LightRadius = value; break;
			case "maxlights": MaxLights = (int)value; break;
			case "spin": Spin = value; break;

			default:
				Log.Info( "[nz-fx] nz_bomb_set <modelscale|bodywidth|bodylength|trail|trailwidth"
					+ "|markersegments|marker|light|brightness|lightradius|maxlights|spin> <value>" );
				return;
		}

		Log.Info( $"[nz-fx] bomb {key} = {value:0.###}" );
		Report();
	}

	/// <summary>`nz_bomb_colour &lt;r&gt; &lt;g&gt; &lt;b&gt;` — recolour the shells, then throw a wave.</summary>
	[ConCmd( "nz_bomb_colour" )]
	public static void ColourCmd( float r = -1f, float g = 0f, float b = 0f )
	{
		if ( r >= 0f ) Colour = new Color( r, g, b );

		Report();
		TestCmd();
	}
}