Effects/PitVisual.cs

A game-component that renders decorative pit visuals (glow light, optional gas cloud, edge ring and inward-moving drag rings) for multiple pit styles (Fallout, Fire, Slow, Tortoise, Tar, Ice). It traces to the ground, spawns/clones a particle prefab for gas, constructs LineRenderer rings, animates flicker and fade over the pit lifetime, and exposes console commands to test and tune global parameters.

File AccessExternal Download
🌐 prefabs/particles/nz/vulture_stink.prefab
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// The look of a pit on the ground — a glow on the floor, an optional cloud, a ring marking its
/// edge, and optional rings crawling outward inside it.
///
/// ⛔ THIS WAS `FalloutVisual`, AND THE RENAME IS THE POINT. It was written for Radioactive Decay,
/// and then the fire pit and the slow pit both turned out to want the same three layers in different
/// colours. Three copies of a floor glow would have been three places to fix the day one of them
/// looked wrong. One component taking a <see cref="Style"/> is the whole idea:
///
///   • `Fallout` — radioactive green, gas, no drag rings   (Radioactive Decay, 140u / 4s)
///   • `Fire`    — upstream's napalm orange, faster gas    (Napalm Nectar M3, 200u / 8s)
///   • `Slow`    — Timeslip blue, no gas, three drag rings (Timeslip M3, 280u / 10s)
///   • `Tar`     — near-black, PAINTED not lit: low fumes, a dark edge, two slow ripples (Tar Pit, 140u / 8s)
///   • `Ice`     — light blue, a steady ring over a faint cold glow                  (Ice Wall, 150u / 5s)
///
/// ⛔ NONE OF THE THREE IS A PORT OF A PARTICLE, BECAUSE NEITHER PIT HAS ONE TO PORT.
///
///   • Fallout's `perks_aat_fallout.pcf` is Source 1 binary DMX against s&amp;box's component
///     particles — different architecture, no importer.
///   • Fire's `zmb_firepit` is the same problem, and deliberately unused; the LIGHT beside it is
///     ported exactly (see <see cref="Look"/>'s Fire values) because a light is just numbers.
///   • Slow has NOTHING. `nz_augment_zone` takes an optional `effect` string and the Timeslip caller
///     never passes one; the entity is `SetNoDraw(true)` with an empty `Draw()`. Its
///     `Color(120, 180, 255)` is the only art direction that exists upstream, and we honour it.
///
/// So each layer is built from something this project has already proven —
///
///   • the glow is a `PointLight`, the way `StatusEffects.Present` lights a burning zombie
///   • the cloud clones `vulture_stink.prefab`, an existing working gas cloud, and recolours it
///   • every ring is a `LineRenderer`, the way `LightningArc` draws a bolt
///
/// Nothing here needs a texture that does not already exist, which is the whole reason all three can
/// ship before any source particle is chased down.
///
/// ⛔ IT TRACES TO THE FLOOR AND THAT IS THE OTHER POINT OF THE FILE. A pit spawns at
/// `zombie.WorldPosition` or at a hit position, which is at floor level on flat ground and wrong
/// everywhere else: on a slope, on stairs, mid-vault, or on a zombie killed in the air. A glow that
/// hovers is the single most obvious way for this to look broken, so the visual finds the ground
/// itself rather than trusting the position it was handed.
/// </summary>
public sealed class PitVisual : Component
{
	/// <summary>Which pit this is. Picks a <see cref="Look"/>; changes nothing else.</summary>
	public enum Style
	{
		/// <summary>Radioactive Decay's fallout patch.</summary>
		Fallout,
		/// <summary>Napalm Nectar M3's burning ground.</summary>
		Fire,
		/// <summary>Timeslip M3's slow zone.</summary>
		Slow,
		/// <summary>Victorious Tortoise's planted ring.</summary>
		Tortoise,
		/// <summary>The Tar Pit ammo mod's pool (2026-10-04).</summary>
		Tar,
		/// <summary>The Ice Wall ammo mod's circle (2026-10-04).</summary>
		Ice,
	}

	/// <summary>
	/// Everything that differs between the three pits.
	///
	/// ⚠️ WHAT SEPARATES THEM IS MOSTLY MOTION, NOT STRUCTURE. All three are a glow and a ring; the
	/// fire pit reads as fire because its glow jitters on two beating sines and its cloud climbs
	/// twice as fast, and the slow pit reads as slow because its glow breathes on one long sine and
	/// its rings crawl. Swapping only the colours would have given three green pits in hats.
	/// </summary>
	public sealed class Look
	{
		/// <summary>The one colour every layer shares.</summary>
		public Color Colour = Color.White;

		/// <summary>Light radius as a share of the pit radius.</summary>
		public float LightScale = 1.35f;

		/// <summary>Light brightness.</summary>
		public float Brightness = 1.6f;

		/// <summary>Primary flicker rate, in Hz.</summary>
		public float FlickerA = 11f;

		/// <summary>
		/// Second flicker rate, multiplied against the first. 0 for a single clean sine.
		///
		/// ⚠️ TWO SINES MULTIPLIED IS WHAT MAKES FIRE LOOK LIKE FIRE. One sine is a pulse — regular,
		/// and regular reads as a prop. Two incommensurate rates beat against each other and never
		/// visibly repeat, which is the difference between a flickering fire and a throbbing lamp.
		/// </summary>
		public float FlickerB;

		/// <summary>How deep the flicker cuts, 0-1.</summary>
		public float FlickerDepth = 0.14f;

		/// <summary>Spawn the cloud.</summary>
		public bool Gas = true;

		/// <summary>Cloud emitter radius as a share of the pit radius.</summary>
		public float GasScale = 0.72f;

		/// <summary>How far above the floor the cloud is spawned.</summary>
		public float GasLift = 6f;

		/// <summary>Rise force on the cloud. 0 keeps the prefab's own (70).</summary>
		public float GasForce;

		/// <summary>Cloud emission rate. 0 keeps the prefab's own (10).</summary>
		public float GasRate;

		/// <summary>Draw the ring at the pit's edge.</summary>
		public bool Ring = true;

		/// <summary>How many rings crawl outward inside the pit. 0 for none.</summary>
		public int DragRings;

		/// <summary>How many full sweeps a drag ring makes per second.</summary>
		public float DragHz = 0.13f;

		/// <summary>
		/// Draw the rings as light (added to what is behind them) rather than paint. On.
		///
		/// ⛔ OFF FOR A DARK STYLE, OR IT IS INVISIBLE (2026-10-04, Tar). Additive black adds nothing, so a near-black ring
		/// drawn additively is no ring at all. The same goes for the glow: a style with `Brightness` 0 gets no light.
		/// </summary>
		public bool Additive = true;

		/// <summary>The edge ring's width (a `LineRenderer` curve, tuned by eye — see `MakeLine`). 0.7.</summary>
		public float RingWidth = 0.7f;
	}

	// ══ per-style looks ══════════════════════════════════════════════════════

	/// <summary>
	/// The numbers for one style.
	///
	/// ⛔ BUILT FRESH FROM LITERALS ON EVERY READ, NOT CACHED IN A STATIC DICTIONARY. A static's
	/// VALUE survives a hotload but its initialiser does not re-run (INSTRUCTIONS.md §1), so a
	/// cached table of looks would keep serving the numbers from before an edit and every retune
	/// here would appear to do nothing. It allocates a small object per pit spawn, which is nothing
	/// next to cloning a particle prefab in the same method.
	/// </summary>
	public static Look LookFor( Style style ) => style switch
	{
		// ⛔ UPSTREAM'S LIGHT, EXACTLY, AND IT IS THE ONE THING HERE THAT IS A REAL PORT. All three
		// GMod entities that make a fire pit — the napalm zombie, the hellhound and the glowstick —
		// create a `DynamicLight` at RGB 235,75,15 with brightness 3 and size 400, recreated every
		// frame with a one-second die time. 235/255 = 0.922, 75/255 = 0.294, 15/255 = 0.059, and
		// size 400 against our 200u radius is a LightScale of 2. Do not "tidy" these.
		Style.Fire => new Look
		{
			Colour = new Color( 0.922f, 0.294f, 0.059f ),
			Brightness = 3f,
			LightScale = 2f,

			FlickerA = 9.3f,
			FlickerB = 4.1f,
			FlickerDepth = 0.22f,

			Gas = true,
			GasScale = 0.6f,
			GasLift = 8f,

			// ⚠️ TWICE THE PREFAB'S RISE AND NEARLY TWICE ITS RATE. `vulture_stink` is a lazy stink
			// cloud at ForceScale 70 / Rate 10; fire climbs, and it climbs thickly. This is the
			// other half of "same layers, different temperament".
			GasForce = 140f,
			GasRate = 18f,

			Ring = true,
			DragRings = 0,
		},

		// ⚠️ THE COLOUR IS UPSTREAM'S AND NOTHING ELSE IS. `Color(120, 180, 255)` is the whole of
		// what GMod says about how a slow zone looks, because its entity does not draw. Everything
		// below is a proposal, reviewed as an animated preview before it was written.
		//
		// ⚠️ NO CLOUD, BY REQUEST. A drifting layer was previewed and cut — the drag rings turned out
		// to carry "time is thick here" better than motes did, and a cloud on top of them read as
		// two effects in one hole.
		Style.Slow => new Look
		{
			Colour = new Color( 0.471f, 0.706f, 1f ),
			Brightness = 2.2f,
			LightScale = 1.3f,

			FlickerA = 0.9f,
			FlickerB = 0f,
			FlickerDepth = 0.22f,

			Gas = false,

			Ring = true,
			DragRings = 3,
			DragHz = 0.13f,
		},

		// ⛔ A RING AND ALMOST NOTHING ELSE. Tortoise's ring was `models/dev/box.vmdl` scaled flat —
		// which is a SQUARE, because a box is a box however thin you make it. It is the one pit that
		// is genuinely only its outline: the ground inside it is a place to stand, not a hazard, so
		// a bright floor glow would read as damage.
		//
		// ⚠️ THE GREEN IS THE PLACEHOLDER'S OWN TINT, kept so the fix changes the SHAPE and nothing
		// else. `Color( 0.45f, 0.85f, 0.55f )` is what the box was tinted.
		//
		// ⚠️ NO GAS AND NO DRAG RINGS. Both say "this area is doing something to you over time",
		// which is the fire pit and the slow pit. This one is a line on the floor saying "inside
		// here you are tougher", and it holds still for the same reason.
		Style.Tortoise => new Look
		{
			Colour = new Color( 0.45f, 0.85f, 0.55f ),

			// ⚠️ DIMMER AND TIGHTER THAN ANY HAZARD PIT. A player STANDS in this one, often for a
			// while, so the glow has to survive being looked at rather than announce itself.
			Brightness = 1.1f,
			LightScale = 0.9f,

			// ⚠️ ONE SLOW SINE, SHALLOW. Fire jitters on two rates; a defensive ring that flickered
			// would read as unstable, which is the opposite of what it grants.
			FlickerA = 1.4f,
			FlickerB = 0f,
			FlickerDepth = 0.08f,

			Gas = false,

			Ring = true,
			DragRings = 0,
		},

		// ⛔ TAR (2026-10-04, the user: *"make it spawn a circle like in radioactive decay, but make it tar colored"*). The
		// fallout's three layers, but tar cannot be drawn with light: every glow and ring here is additive, and black added
		// to anything is nothing. So it is PAINTED — the rings opaque (`Additive` off), the glow gone (`Brightness` 0) —
		// and the cloud, which `vulture_stink` already blends normally rather than additively, carries the dark.
		//
		// ⚠️ THE FUMES HUG THE FLOOR: a fifth of the prefab's rise and a little more of it, so the cloud reads as a pool
		// giving off heavy smoke rather than gas escaping upward. Two drag rings crawling slowly are the surface moving.
		Style.Tar => new Look
		{
			Colour = new Color( 0.075f, 0.055f, 0.04f ),
			Brightness = 0f,

			Gas = true,
			GasScale = 0.78f,
			GasLift = 3f,
			GasForce = 14f,
			GasRate = 14f,

			Ring = true,
			DragRings = 2,
			DragHz = 0.07f,

			Additive = false,
		},

		// ⚠️ ICE (2026-10-04, the user: *"ice wall, pretty simple visually / just make a light blue circle"*). The ring IS the
		// wall — the line a held zombie cannot cross — so it is the bright part: steady, wider than the other styles' edges,
		// light blue. Under it only a faint cold glow, barely breathing. No cloud and no ripples: ice holds still.
		Style.Ice => new Look
		{
			Colour = new Color( 0.62f, 0.9f, 1f ),
			Brightness = 1.1f,
			LightScale = 1.1f,

			FlickerA = 0.6f,
			FlickerB = 0f,
			FlickerDepth = 0.05f,

			Gas = false,

			Ring = true,
			RingWidth = 1.2f,
			DragRings = 0,
		},

		// The fallout patch, unchanged from when this file was `FalloutVisual` and served only it.
		_ => new Look
		{
			// ⚠️ THE SAME HUE AS THE `radiation` STATUS'S LIGHT, deliberately. The pit and the
			// zombies it has dosed should read as one system; two greens would read as two effects.
			Colour = new Color( 0.75f, 1f, 0.15f ),
			Brightness = 1.6f,
			LightScale = 1.35f,

			FlickerA = 11f,
			FlickerB = 0f,
			FlickerDepth = 0.14f,

			Gas = true,
			GasScale = 0.72f,
			GasLift = 6f,

			Ring = true,
			DragRings = 0,
		},
	};

	// ══ global tuning ════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not
	// re-run. INSTRUCTIONS.md §1.
	//
	// ⚠️ THESE ARE MULTIPLIERS OVER EVERY STYLE, NOT PER-PIT VALUES. The per-pit numbers live in
	// `LookFor` because they are art direction; these exist so `nz_pit_fx_set` can dim or widen all
	// three at once while eyeballing them in a dark map, without editing three sets of literals.

	static float? _brightnessScale;
	/// <summary>Multiplies every style's brightness. 1.</summary>
	public static float BrightnessScale
	{
		get => _brightnessScale ?? 1f;
		set => _brightnessScale = value;
	}

	static float? _radiusScale;
	/// <summary>Multiplies every style's light radius. 1.</summary>
	public static float RadiusScale { get => _radiusScale ?? 1f; set => _radiusScale = value; }

	static bool? _rings;
	/// <summary>Master switch for every ring, edge and drag. On.</summary>
	public static bool Rings { get => _rings ?? true; set => _rings = value; }

	static bool? _gas;
	/// <summary>Master switch for the cloud on the styles that have one. On.</summary>
	public static bool Gas { get => _gas ?? true; set => _gas = value; }

	static int? _ringSegments;
	/// <summary>How many points the edge ring is drawn with. 48.</summary>
	public static int RingSegments { get => _ringSegments ?? 48; set => _ringSegments = value; }

	static int? _dragSegments;
	/// <summary>
	/// How many points each drag ring is drawn with. 20.
	///
	/// ⚠️ FEWER THAN THE EDGE RING ON PURPOSE. These are retraced repeatedly while the edge ring is
	/// traced once, so their segment count is a running cost rather than a one-off — and they sit
	/// inside the pit where a slightly polygonal circle does not read.
	/// </summary>
	public static int DragSegments { get => _dragSegments ?? 20; set => _dragSegments = value; }

	static float? _maxStep;
	/// <summary>
	/// How far a ring point may sit above or below the pit's own floor before the trace is thrown
	/// away and the centre's height used instead. 48u.
	///
	/// ⛔ THIS EXISTS BECAUSE THE RINGS CLIMBED WALLS, AND IT LOOKED BROKEN. `GroundAt` traces from
	/// 72u above each point; where that point is inside a shipping container the trace lands on the
	/// container's ROOF, so the ring shot vertically up the side of it and back down. Tracing every
	/// point is still right — that is what makes the ring follow stairs and slopes — but a "floor"
	/// a hundred units above the pit is not the pit's floor.
	///
	/// ⚠️ REJECTED POINTS FALL BACK TO THE CENTRE'S HEIGHT, NOT TO THE CLAMP. Clamping to ±48 would
	/// still draw a visible lip around every wall; flattening puts the point inside the geometry,
	/// where the wall occludes the line and nothing is drawn at all — which is what should happen.
	///
	/// ⚠️ 48u IS ABOVE A STAIR RISE AND BELOW A CRATE. Slopes, ramps and stairwells stay traced
	/// point by point; only genuine walls and ledges get flattened.
	/// </summary>
	public static float MaxStep { get => _maxStep ?? 48f; set => _maxStep = value; }

	static float? _dragRebuildHz;
	/// <summary>
	/// How often the drag rings are re-traced, in Hz. 20.
	///
	/// ⛔ NOT PER FRAME, AND THIS IS THE ONE PERFORMANCE DECISION IN THE FILE. A drag ring changes
	/// radius continuously, so unlike the edge ring it cannot trace once at spawn. Three rings at 20
	/// segments retraced every frame is 60 traces per frame for the pit's whole ten seconds. At 20 Hz
	/// it is 1200 traces a second instead of 3600, and at DragHz 0.13 a ring takes nearly eight
	/// seconds to cross the pit — so a 20 Hz radius update is not something an eye can catch.
	///
	/// ⚠️ FRAME-RATE INDEPENDENT FOR THE SAME REASON `LightningArc.RebuildHz` IS. Rebuilding on a
	/// clock rather than per frame means the effect looks identical at 30fps and at 200fps.
	/// </summary>
	public static float DragRebuildHz
	{
		get => _dragRebuildHz ?? 20f;
		set => _dragRebuildHz = value;
	}

	/// <summary>The gas cloud borrowed from Vulture Aid.</summary>
	public const string GasPrefab = "prefabs/particles/nz/vulture_stink.prefab";

	// ══ live state ═══════════════════════════════════════════════════════════

	/// <summary>Pit radius, set by the caller.</summary>
	public float Radius { get; set; } = 140f;

	/// <summary>
	/// How long the pit lives, set by the caller. Drives the fade.
	///
	/// ⛔ PASSED IN RATHER THAN SNIFFED OUT. This used to read `RadioactiveDecay.Lifetime` off a
	/// sibling component, which worked only because there was exactly one kind of pit. The fire pit's
	/// life is `FireAugments.PitSeconds` and the slow pit's is `TimeAugments.PitSeconds`, so the
	/// visual would have needed to know all three — a component that knows every caller is the thing
	/// this file was generalised to stop being.
	/// </summary>
	public float Life { get; set; } = 4f;

	/// <summary>Which pit this is.</summary>
	public Style Kind { get; set; } = Style.Fallout;

	Look _look;
	PointLight _light;
	LineRenderer _ringLine;
	readonly List<LineRenderer> _dragLines = new();
	GameObject _gasGo;

	float _born;
	float _nextDrag;

	/// <summary>
	/// Find the floor under a point.
	///
	/// ⛔ TRACED DOWNWARD FROM ABOVE THE POINT, NOT FROM IT. Starting the trace at the spawn
	/// position means starting it possibly *inside* the floor — a trace that begins solid returns
	/// its own start, so the effect would sit exactly where it already was and the trace would look
	/// like it worked. Lifting the start clear is what makes the result meaningful.
	///
	/// ⚠️ IT IGNORES ZOMBIES AND THE PLAYER. Without that the trace lands on the corpse that made
	/// the pit, which on a ragdoll is a surface at knee height that then moves.
	///
	/// ⚠️ AND IT FALLS BACK TO THE ORIGINAL POINT rather than to zero. A miss means there is no
	/// floor within reach — off a ledge, or a map hole — and dropping the effect to the world origin
	/// would put it somewhere absurd instead of merely somewhere imperfect.
	/// </summary>
	public static Vector3 GroundAt( Vector3 at )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return at;

		var tr = scene.Trace
			.Ray( at + Vector3.Up * 72f, at + Vector3.Down * 256f )
			.WithoutTags( "player", "zombie", "corpse", "ragdoll" )
			.Run();

		return tr.Hit ? tr.HitPosition : at;
	}

	/// <summary>
	/// Hang the visual on an object, at floor level.
	///
	/// ⛔ IT MOVES THE OBJECT IT IS GIVEN, AND EVERY CALLER DEPENDS ON THAT. All three pits are
	/// radius checks against `WorldPosition` — Radioactive Decay doses a sphere, Napalm Nectar
	/// ignites one, and `TimeAugments.Pits()` measures distance to every object named `nz_time_pit`.
	/// Snapping the object rather than just the glow keeps the volume and the ring the player sees in
	/// agreement; two positions would mean zombies affected outside the ring they can see.
	///
	/// ⚠️ SO IT MUST BE CALLED BEFORE THE FIRST TICK OF WHATEVER THE PIT DOES, not after. Radioactive
	/// Decay documents this at its call site because doing it the other way round irradiates a sphere
	/// centred where the ring is not.
	/// </summary>
	public static PitVisual Attach( GameObject go, float radius, Style style, float life )
	{
		if ( !go.IsValid() ) return null;

		go.WorldPosition = GroundAt( go.WorldPosition );

		var v = go.Components.Create<PitVisual>();

		v.Radius = MathF.Max( 1f, radius );
		v.Life = MathF.Max( 0.1f, life );
		v.Kind = style;

		return v;
	}

	protected override void OnStart()
	{
		_born = Time.Now;
		_look = LookFor( Kind );

		var colour = _look.Colour;

		// ── the floor glow ───────────────────────────────────────────────
		//
		// ⚠️ LIFTED A LITTLE OFF THE GROUND. A point light exactly on a surface lights almost
		// nothing — half its sphere is inside the floor. A few units up is what makes the ground
		// itself bright, which is the whole effect.
		var lightGo = new GameObject
		{
			Parent = GameObject,
			Name = "nz_pit_glow",
			LocalPosition = Vector3.Up * 10f,
		};

		// ⚠️ NO LIGHT AT ALL FOR A STYLE WITHOUT ONE (Tar) — a black light is no light, and an object for it is waste.
		if ( _look.Brightness > 0f )
		{
			_light = lightGo.Components.Create<PointLight>();
			_light.LightColor = colour * MathF.Max( 0f, _look.Brightness * BrightnessScale );
			_light.Radius = LightRadius();
		}

		// ── the edge ring ────────────────────────────────────────────────
		if ( Rings && _look.Ring )
		{
			_ringLine = MakeLine( colour, _look.RingWidth );
			BuildEdgeRing();
		}

		// ── the drag rings ───────────────────────────────────────────────
		if ( Rings )
		{
			for ( var i = 0; i < _look.DragRings; i++ )
				_dragLines.Add( MakeLine( colour, 0.45f ) );
		}

		// ── the cloud ────────────────────────────────────────────────────
		//
		// ⚠️ AN EXISTING PREFAB, RECOLOURED, NOT A NEW ONE. `vulture_stink` is already a working
		// drifting cloud with a sprite, an emitter and a rise force. Authoring a second gas cloud
		// to sit beside it would be two things to keep in step for no gain.
		if ( Gas && _look.Gas ) SpawnGas();
	}

	float LightRadius() => Radius * MathF.Max( 0.1f, _look.LightScale * RadiusScale );

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

		line.UseVectorPoints = true;

		// ⚠️ THE STYLE'S CHOICE: light for the bright pits, paint for the dark one (`Look.Additive`).
		line.Additive = _look?.Additive ?? true;
		line.Lighting = false;
		line.CastShadows = false;
		line.Color = colour;

		// ⛔ `Width` IS A CURVE AND ITS UNITS ARE NOT WORLD UNITS. A float assigns through an
		// implicit conversion, but the number that looks right is roughly a tenth of what a world
		// measurement would suggest — 1.4 on `LightningArc` drew a ribbon before it was cut to 0.22.
		//
		// ⚠️ SO THE CALLERS' 0.7 AND 0.45 WERE CHOSEN BY LOOKING, NOT DERIVED. This shipped at 1.6
		// and 1.0, from before anything had been seen in game, and at those widths both rings read as
		// glowing TUBES lying on the floor rather than as lines drawn on it.
		line.Width = width;

		return line;
	}

	/// <summary>
	/// Lay a circle of points on the floor.
	///
	/// ⛔ EACH POINT IS TRACED TO THE GROUND SEPARATELY. A single flat circle at the centre's height
	/// cuts into a slope on one side and floats on the other, which is exactly the "not on the
	/// floor" failure this class exists to avoid.
	///
	/// ⚠️ AND EACH IS LIFTED 2 UNITS. Dead on the surface, a line z-fights with the floor and
	/// flickers; 2u reads as painted on without hovering.
	///
	/// ⚠️ THE LIST IS CLOSED — the last point repeats the first — because `LineRenderer` draws a
	/// polyline, not a loop. Without it every ring here would be a circle with a bite out of it, the
	/// same way `ShockRing` has to close its own.
	///
	/// ⚠️ AND A TRACE THAT LANDS MORE THAN `MaxStep` FROM THE PIT'S FLOOR IS DISCARDED. See that
	/// property — without it the ring walks up the side of anything it meets.
	/// </summary>
	List<Vector3> CircleOn( Vector3 at, float radius, int segments )
	{
		var n = Math.Max( 6, segments );
		var pts = new List<Vector3>( n + 1 );
		var step = MathF.Max( 0f, MaxStep );

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

			var g = GroundAt( p );

			if ( MathF.Abs( g.z - at.z ) > step ) g.z = at.z;

			pts.Add( g + Vector3.Up * 2f );
		}

		return pts;
	}

	/// <summary>
	/// Trace the edge ring once, at spawn.
	///
	/// ⚠️ FORTY-EIGHT TRACES ONCE IS CHEAP. This is not a per-frame cost, which is the only reason
	/// the segment count can be this high — see `DragRebuildHz` for the ring that does pay over time.
	/// </summary>
	void BuildEdgeRing()
	{
		if ( !_ringLine.IsValid() ) return;

		_ringLine.VectorPoints = CircleOn( WorldPosition, Radius, RingSegments );
	}

	void SpawnGas()
	{
		var file = ResourceLibrary.Get<PrefabFile>( GasPrefab );

		if ( file is null )
		{
			Log.Warning( $"[nz-fx] pit gas prefab '{GasPrefab}' not found"
				+ " — the pit keeps its glow and rings" );
			return;
		}

		_gasGo = SceneUtility.GetPrefabScene( file ).Clone( new CloneConfig
		{
			Transform = new Transform( WorldPosition + Vector3.Up * _look.GasLift ),
			StartEnabled = false,
			Name = $"nz_pit_gas_{Kind}".ToLowerInvariant(),
		} );

		// ⚠️ RECOLOURED WHILE DISABLED, THEN ENABLED — the same order `ColourTracer` and
		// `BlastEffect` both use. A particle effect that starts enabled has already emitted its
		// first particles by the time the tint lands, so the head of the cloud would flash Vulture
		// Aid's colour before turning.
		//
		// ⛔ BOTH `Tint` AND `Gradient`, BECAUSE `Tint` MULTIPLIES THE GRADIENT. Setting only the
		// tint on a prefab whose gradient is already coloured gives the product of two colours,
		// which is nobody's intended hue. The gradient has to be REPLACED.
		foreach ( var fx in _gasGo.Components
			.GetAll<ParticleEffect>( FindMode.EverythingInSelfAndDescendants ) )
		{
			fx.Tint = _look.Colour;
			fx.Gradient = _look.Colour;

			// ⚠️ ZERO MEANS "KEEP THE PREFAB'S OWN". Only the fire pit overrides this, and writing
			// 70 back in for the other styles would be a second author for a number that already
			// lives in the prefab (§3) — it would silently stop tracking an edit to it.
			if ( _look.GasForce > 0f ) fx.ForceScale = _look.GasForce;
		}

		// ⚠️ THE CLOUD IS WIDENED TO THE PIT, not left at Vulture Aid's size. Its emitter is a 6u
		// sphere feeding particles that grow to ~190 — sized for a cloud around a player, not a
		// patch of ground two hundred units across.
		foreach ( var em in _gasGo.Components
			.GetAll<ParticleSphereEmitter>( FindMode.EverythingInSelfAndDescendants ) )
		{
			em.Radius = Radius * _look.GasScale;

			if ( _look.GasRate > 0f ) em.Rate = _look.GasRate;
		}

		_gasGo.Enabled = true;
	}

	/// <summary>
	/// Fade the glow out over the pit's life, and crawl the drag rings.
	///
	/// ⚠️ THE FADE IS DRIVEN FROM HERE RATHER THAN FROM THE PIT, so the visual can be attached to
	/// anything with a lifetime without that thing knowing how it looks.
	/// </summary>
	protected override void OnUpdate()
	{
		if ( _look is null ) return;

		var age = Time.Now - _born;

		// ⚠️ A FLICKER ON TOP OF THE FADE. A steady light reads as a prop; the status lights in this
		// project all flicker for the same reason. `FlickerB` at 0 collapses this to one sine.
		var wave = _look.FlickerB > 0f
			? MathF.Sin( Time.Now * _look.FlickerA ) * MathF.Sin( Time.Now * _look.FlickerB )
			: MathF.Sin( Time.Now * _look.FlickerA );

		var flick = 1f - _look.FlickerDepth + _look.FlickerDepth * wave;
		var left = Life <= 0f ? 1f : Math.Clamp( 1f - age / Life, 0f, 1f );

		// ⚠️ EASED, NOT LINEAR. A linear fade visibly steps out at the end; squaring keeps it bright
		// while it matters and drops it away quickly.
		var k = left * left;

		if ( _light.IsValid() )
		{
			_light.LightColor = _look.Colour
				* MathF.Max( 0f, _look.Brightness * BrightnessScale ) * k * flick;

			_light.Radius = LightRadius() * (0.75f + 0.25f * k);
		}

		if ( _ringLine.IsValid() )
			_ringLine.Color = _look.Colour.WithAlpha( k );

		TickDragRings( k );
	}

	/// <summary>
	/// Crawl the inner rings outward, evenly spaced and fading as they go.
	///
	/// ⚠️ EVENLY PHASED BY INDEX so they never bunch up: ring i sits at `(age * DragHz + i / count)`
	/// of the way out. With three rings that is a new ring leaving the centre every time the one
	/// ahead is a third of the way across, which is what reads as a continuous outward drift rather
	/// than as three separate rings.
	/// </summary>
	void TickDragRings( float k )
	{
		if ( _dragLines.Count == 0 ) return;
		if ( Time.Now < _nextDrag ) return;

		_nextDrag = Time.Now + 1f / MathF.Max( 1f, DragRebuildHz );

		var at = WorldPosition;
		var age = Time.Now - _born;

		for ( var i = 0; i < _dragLines.Count; i++ )
		{
			var line = _dragLines[i];
			if ( !line.IsValid() ) continue;

			var p = (age * _look.DragHz + i / (float)_dragLines.Count) % 1f;

			// ⚠️ A RING AT THE VERY CENTRE IS A DOT, so the smallest radius is clamped up a little.
			// Without it each ring visibly pops into existence as a bright point.
			line.VectorPoints = CircleOn( at, MathF.Max( 8f, Radius * p ), DragSegments );

			// ⚠️ FADING WITH DISTANCE *AND* WITH THE PIT'S OWN FADE — `1 - p` for the crawl, `k` for
			// the life. A ring that stayed bright to the edge would compete with the edge ring it is
			// about to arrive at.
			line.Color = _look.Colour.WithAlpha( (1f - p) * 0.6f * k );
		}
	}

	protected override void OnDestroy()
	{
		// ⚠️ THE GAS IS NOT A CHILD, so it does not die with this object. It was cloned into the
		// scene at world position rather than parented, because parenting it would make the cloud
		// inherit any movement of the pit — and a patch of ground that slides is worse than one
		// that has to be cleaned up by hand.
		if ( _gasGo.IsValid() ) _gasGo.Destroy();
	}

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

	/// <summary>`nz_pit_fx` — every style's resolved look, and whether the gas prefab resolves.</summary>
	[ConCmd( "nz_pit_fx" )]
	public static void Report()
	{
		var live = Game.ActiveScene?.GetAllComponents<PitVisual>().ToList()
			?? new List<PitVisual>();

		var file = ResourceLibrary.Get<PrefabFile>( GasPrefab );

		Log.Info( $"[nz-fx] PIT VISUAL · {live.Count} live"
			+ $" · global bright x{BrightnessScale:0.##} radius x{RadiusScale:0.##}"
			+ $" · rings {(Rings ? "on" : "OFF")} · gas {(Gas ? "on" : "OFF")}"
			+ $" · edge {RingSegments} seg · drag {DragSegments} seg at {DragRebuildHz:0}Hz" );

		Log.Info( $"[nz-fx]   gas prefab {(file is null ? "MISSING" : "ok")} ({GasPrefab})" );

		foreach ( var style in Enum.GetValues<Style>() )
		{
			var L = LookFor( style );

			Log.Info( $"[nz-fx]   {style,-8} {L.Colour}"
				+ $" · bright {L.Brightness:0.##} at {L.LightScale:0.##}x radius"
				+ $" · flicker {L.FlickerA:0.#}Hz{(L.FlickerB > 0f ? $" x {L.FlickerB:0.#}Hz" : "")}"
				+ $" d{L.FlickerDepth:0.##}"
				+ $" · gas {(L.Gas ? $"{L.GasScale:0.##}x" : "off")}"
				+ $"{(L.GasForce > 0f ? $" force {L.GasForce:0}" : "")}"
				+ $"{(L.GasRate > 0f ? $" rate {L.GasRate:0}" : "")}"
				+ $" · ring {(L.Ring ? "on" : "off")}"
				+ $" · drag {(L.DragRings > 0 ? $"{L.DragRings} at {L.DragHz:0.##}Hz" : "none")}" );
		}

		foreach ( var v in live )
			Log.Info( $"[nz-fx]   live {v.Kind} · {v.Radius:0}u · {v.Life:0.#}s"
				+ $" · {Time.Now - v._born:0.#}s old · at {v.WorldPosition}" );

		// ⚠️ THE TRACE IS EXERCISED HERE, because "the glow floats" and "the trace missed" look
		// identical in game and only one of them is this file's fault.
		var p = NZPlayer.Local;

		if ( p.IsValid() )
		{
			var from = p.WorldPosition + Vector3.Up * 40f;
			var ground = GroundAt( from );

			Log.Info( $"[nz-fx]   floor trace from {from} → {ground}"
				+ $" ({(ground == from ? "MISSED — no floor found" : $"{from.z - ground.z:0.#}u down")})" );
		}
	}

	static float? _testDistance;
	/// <summary>
	/// How far ahead of you `nz_pit_fx_test` drops its pit. 450u.
	///
	/// ⛔ AHEAD OF YOU, NOT AT YOUR FEET, AND THE FIRST VERSION GOT THIS WRONG. Spawning at the
	/// player put the camera INSIDE a 400-unit light at brightness 3, which blew the whole frame out
	/// and made the composition unjudgeable — while telling me nothing, because a pit spawns on a
	/// zombie you shot and is therefore almost never underfoot. 450u is far enough to frame a 280u
	/// pit whole.
	/// </summary>
	public static float TestDistance
	{
		get => _testDistance ?? 450f;
		set => _testDistance = value;
	}

	/// <summary>
	/// `nz_pit_fx_test &lt;fallout|fire|slow&gt; [radius] [seconds]` — spawn a visual with no pit
	/// under it, on the floor ahead of you.
	///
	/// ⛔ THE VISUAL ONLY. There is no dosing, no ignition and no slow — this exists to look at the
	/// three styles without needing the perk, the augment and a zombie to kill in the right spot. A
	/// pit that LOOKS right and does nothing is exactly what this should spawn.
	///
	/// ⚠️ GIVE IT SEVERAL SECONDS BEFORE JUDGING THE CLOUD. `vulture_stink`'s alpha is a curve that
	/// starts at 0, peaks at 0.4 half way through a particle's life and returns to 0, and its emitter
	/// runs at a rate rather than a burst. Screenshotting the first frame after spawn shows an empty
	/// pit and looks exactly like the gas layer being broken — which is what it looked like here.
	/// </summary>
	[ConCmd( "nz_pit_fx_test" )]
	public static void TestCmd( string style = "fallout", float radius = 0f, float seconds = 0f )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		var p = NZPlayer.Local;

		if ( !p.IsValid() )
		{
			Log.Info( "[nz-fx] no player to spawn at" );
			return;
		}

		// ⚠️ THE DEFAULT RADIUS AND LIFE ARE EACH STYLE'S REAL ONES, read from the augment and the
		// mod rather than repeated here, so this command cannot show a size the game never spawns.
		var (kind, r, s) = style.ToLowerInvariant() switch
		{
			"fire" => (Style.Fire, FireAugments.PitRadius, FireAugments.PitSeconds),
			"slow" => (Style.Slow, TimeAugments.PitRadius, TimeAugments.PitSeconds),
			"tortoise" => (Style.Tortoise, TortoiseAugments.RingRadius, 8f),
			"fallout" => (Style.Fallout, RadioactiveDecay.Radius, RadioactiveDecay.Lifetime),
			"tar" => (Style.Tar, TarPit.Radius, TarPit.Lifetime),
			"ice" => (Style.Ice, IceWall.Radius, IceWall.Lifetime),
			_ => (Style.Fallout, 0f, 0f),
		};

		if ( r <= 0f )
		{
			Log.Info( "[nz-fx] nz_pit_fx_test <fallout|fire|slow|tortoise|tar|ice> [radius] [seconds]" );
			return;
		}

		if ( radius > 0f ) r = radius;
		if ( seconds > 0f ) s = seconds;

		// ⚠️ FLATTENED WITH `WithZ( 0 )`, so looking up at the sky still drops the pit on the floor
		// in front of you rather than launching it. `Attach`'s trace only reaches 256u down, so a
		// point thrown into the air would miss the floor entirely and the pit would hang there.
		var fwd = p.EyeAngles.Forward.WithZ( 0f ).Normal;

		var go = scene.CreateObject();

		go.Name = $"nz_pit_fx_test_{kind}".ToLowerInvariant();
		go.WorldPosition = p.WorldPosition + fwd * MathF.Max( 0f, TestDistance );
		go.NetworkMode = NetworkMode.Never;

		Attach( go, r, kind, s );

		SWB.Shared.GameObjectExtensions.DestroyAsync( go, MathF.Max( 0.1f, s ) );

		Log.Info( $"[nz-fx] test {kind} pit at {go.WorldPosition}"
			+ $" · {r:0}u for {s:0.#}s · visual only, it does nothing" );
	}

	/// <summary>`nz_pit_fx_set &lt;key&gt; &lt;value&gt;` — retune the look across every style.</summary>
	[ConCmd( "nz_pit_fx_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "bright": BrightnessScale = value; break;
			case "scale": RadiusScale = value; break;
			case "rings": Rings = value > 0.5f; break;
			case "gas": Gas = value > 0.5f; break;
			case "segments": RingSegments = (int)value; break;
			case "dragsegments": DragSegments = (int)value; break;
			case "draghz": DragRebuildHz = value; break;
			case "testdist": TestDistance = value; break;
			case "maxstep": MaxStep = value; break;

			// ⚠️ THE PER-STYLE COLOURS AND FLICKER RATES ARE NOT RETUNABLE HERE, on purpose. They are
			// art direction sitting in `LookFor` as literals — one of them is a straight port of
			// upstream's light — and a console override would make the next person reading those
			// numbers unable to trust them.
			default:
				Log.Info( "[nz-fx] nz_pit_fx_set <bright|scale|rings|gas|segments"
					+ "|dragsegments|draghz|testdist|maxstep> <value>" );
				Log.Info( "[nz-fx]   per-style colour/flicker: literals in PitVisual.LookFor" );
				return;
		}

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