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.
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&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 <fallout|fire|slow> [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 <key> <value>` — 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();
}
}