A component that spawns a burst of dirt effects for zombie riser spawns. It can clone an authored dust prefab around a point or fall back to spawning small cube clods that arc, land, shrink and self-destroy; the component cleans itself once all clods expire.
using Sandbox;
using System;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// Dirt kicked up by a zombie clawing out of the ground, as the original does
/// for riser spawns.
///
/// TWO IMPLEMENTATIONS, dust preferred, cubes as the fallback.
///
/// ⛔ THE DUST IS THE ENGINE'S OWN, NOT ONE I BUILT. `addons/base` ships an
/// authored dirt bullet-impact prefab — three layers (dust puff, additive haze
/// at an 85 degree cone, colliding debris), real sprite sheets, correct tints.
/// Cloning it takes one call. Building an equivalent `ParticleEffect` in C#
/// would mean constructing `ParticleFloat` and curve-range values whose exact
/// shape is not verifiable from outside the editor, and guessing those is how
/// four attempts were burned on a different bug this same session.
///
/// The cube fallback (`Model.Cube` + `ModelRenderer` + `Tint`, the pattern
/// PowerManager and DebrisManager already prove) stays because the prefab lives
/// in another addon and a missing asset must degrade, not disappear.
///
/// ⚠️ Self-destroying. Nothing owns or tracks these; the component removes its
/// own GameObject once every clod has expired, so a burst cannot leak.
/// </summary>
public sealed class SpawnDirt : Component
{
private sealed class Clod
{
public GameObject Go;
public Vector3 Velocity;
public float Life;
public float Age;
public float Size;
public bool Landed;
}
/// <summary>
/// Floor height for this burst — where the clods settle.
///
/// ⛔ WITHOUT THIS THEY FALL FOREVER. Nothing here collides, so a clod just
/// keeps accelerating: at 800 gravity a 1.6s clod is already ~800 units under
/// the map. That went unnoticed only because they were small and brief, and
/// tripling the duration is exactly what would have made it obvious.
/// </summary>
private float _groundZ;
private readonly List<Clod> _clods = new();
/// <summary>Units/sec pulling clods back down. Source gravity is 800.</summary>
[Property] public float Gravity { get; set; } = 800f;
/// <summary>How long the last clod can live, in seconds.</summary>
[Property] public float MaxLife { get; set; } = 4.8f;
/// <summary>Multiplier on clod size, for tuning by eye via nz_dirt_size.</summary>
public static float SizeScale { get; set; } = 1f;
/// <summary>
/// Log every burst. ⚠️ The point is not the numbers — it is that a line in
/// the console proves the CODE IS LIVE. "I see no dirt" has two completely
/// different causes (not running vs too small to notice) and they are
/// indistinguishable by looking.
/// </summary>
public static bool Verbose { get; set; } = true;
/// <summary>
/// s&box's own dirt bullet-impact effect, used as the dust.
///
/// ⛔ FOUND BY READING THE ENGINE, NOT BY GUESSING. The particle API is
/// `ParticleEffect` + `ParticleSpriteRenderer` + an emitter, and building one
/// in C# means constructing `ParticleFloat` / curve-range types whose exact
/// shape I could not verify. But the base addon SHIPS a dirt impact already
/// authored by the engine devs — three layers (a dust puff, an additive haze
/// with an 85 degree cone, and colliding debris chunks), correct tints, real
/// sprite sheets. Cloning it needs one call and no property guesses.
///
/// It also self-cleans: the prefab root carries `TemporaryEffect` with
/// `DestroyAfterSeconds`, so nothing here has to track what it spawned.
/// </summary>
public const string DustPrefab = "prefabs/nz_spawn_dust.prefab";
/// <summary>
/// ⛔ THE SIZES CAME OUT OF THE FILE, NOT OUT OF MY HEAD. `ParticleFloat`
/// serialises as `{"Type":"Range","Constants":"100,300,0,0"}` and a size curve
/// keeps its magnitude in `CurveA.rangey`, so the authored dirt impact could be
/// read and scaled directly. Building the equivalent in C# would have meant
/// guessing those shapes — which is what stopped this the first time round.
///
/// ⚠️ Scaled per layer, not uniformly: smoke x4.5 (the body of the cloud),
/// haze ring x3.6 and wider, debris specks x2.2. Cone angles opened from 5.5°
/// (a bullet jet) to 40-62° (something bursting out of the ground) and the
/// emitters widened to cover the hole rather than a point.
///
/// ⚠️ THE CANDIDATE LIST THAT USED TO LIVE HERE IS GONE ON PURPOSE. It was a
/// `static readonly string[]`, which looks like a constant and is not — the
/// array survived hotload with its old contents, so re-pointing it at this
/// asset changed nothing and the log kept naming the bullet effect. The paths
/// are now a local array built fresh inside CloneDust.
/// </summary>
/// <summary>The path that actually worked, once one has. Null until proven.</summary>
public static string ResolvedDustPrefab { get; private set; }
/// <summary>
/// Use the engine's dust prefab. Off falls back to the cubes.
///
/// ⛔ THE "BLACK ANGULAR SHAPES" FINDING WAS WRONG — CORRECTED 2026-08-16.
/// I recorded that the prefab renders as black shapes because its sprite sheet
/// would not resolve across addons, and disabled it on that basis. The black
/// shapes were **a tree in the map**. I attributed map geometry to my own
/// effect and switched off a working feature over it, then wrote the false
/// cause into this comment where it would have been believed later.
///
/// ⚠️ I never verified it. The claim came from a screenshot, in a session whose
/// own recorded lesson is not to diagnose from stills. The prefab resolves and
/// clones — both confirmed in the log — and there is no evidence it draws
/// wrongly.
/// </summary>
public static bool UseDustPrefab { get; set; } = true;
/// <summary>Puffs per requested clod. Tune by eye with nz_dirt_dust.</summary>
public static float DustDensity { get; set; } = 1f;
/// <summary>
/// Throw the cube clods as well as the dust.
///
/// ⚠️ OFF BY DEFAULT since the real cloud was confirmed working. They were
/// only ever the stand-in for a missing prefab, and they read as exactly that
/// — "big cubes", reported twice, with a screenshot.
///
/// ⚠️ Off does NOT mean gone: Burst still falls back to them when the dust
/// prefab produces nothing, so a broken asset degrades instead of vanishing.
/// `nz_dirt_clods 1` forces them back on.
/// </summary>
public static bool UseClods { get; set; } = false;
/// <summary>Hard cap on puffs per burst — each one is a full grave-scale cloud
/// of ~500 particles, so this is the number the 35-zombie target pays for.
/// Raise with nz_dirt_max when tuning by eye.</summary>
public static int MaxPuffs { get; set; } = 5;
/// <summary>
/// Throw a burst of dirt at a point on the ground.
///
/// ⚠️ `count` is per BURST, and a riser fires more than one — keep it low.
/// 35 zombies spawning at once with a fat burst each is exactly the kind of
/// thing the 35-zombie perf target exists to catch.
/// </summary>
public static SpawnDirt Burst( Scene scene, Vector3 position, int count = 14,
float power = 1f, bool announce = true )
{
// ⚠️ EVERY MACHINE BUILDS ITS OWN. The emerge runs on the host, so on every other screen
// a zombie rose out of undisturbed ground.
if ( announce && Networking.IsActive && Connection.Local is not null )
NZombies.NZNet.WorldFx( Connection.Local.Id.ToString(), "",
(int)NZombies.NZNet.FxKind.Dirt, position, System.Guid.Empty, count );
if ( !scene.IsValid() || count <= 0 ) return null;
// Real dust first. Its return says whether anything was actually made,
// which is what decides below whether the cubes are still needed.
bool dust = UseDustPrefab && BurstDust( scene, position, count, power );
// ⛔ CUBES ONLY AS A FALLBACK NOW — off unless the dust produced nothing.
//
// They were written when there was no particle effect, and they look it:
// tinted boxes that arc and land. Reported as "big cubes" twice, and once
// the real cloud existed they stopped being a feature and went back to
// being a stand-in.
//
// ⚠️ NOT A FLAT `return`. If the prefab ever fails to resolve — a rename,
// a broken compile, a stripped asset — the entrance would produce NOTHING
// and look like the effect was never written. Falling back keeps the
// original promise that a missing asset degrades rather than disappears,
// and `nz_dirt_clods 1` still forces them on for comparison.
if ( !UseClods && dust )
return null;
var go = scene.CreateObject();
go.Name = "Spawn Dirt";
go.WorldPosition = position;
// ⚠️ NotSaved, like the spectator camera and the debris props. Without it
// a burst that happens while the editor is open can be written into the
// scene file, and a map slowly fills with dead dirt.
go.Flags |= GameObjectFlags.NotSaved;
var dirt = go.Components.Create<SpawnDirt>();
dirt.Emit( count, power );
if ( Verbose )
Log.Info( $"[nz-dirt] burst x{count} at {position} "
+ $"(size x{SizeScale:0.00}, power {power:0.00})" );
return dirt;
}
/// <summary>
/// Clone the dust prefab, trying each candidate path until one resolves.
///
/// Once one works it is remembered, so this costs a single call from then on.
/// If none work it says so ONCE, with every path it tried — a silent failure
/// here is the whole reason the effect looked like it was never added.
/// </summary>
private static GameObject CloneDust( Vector3 at )
{
var tf = new Transform( at );
// ⛔ NO CACHING, AND NO STATIC LIST. Five separate statics in this file have
// come forward through a hotload and pinned a stale value tonight —
// `UseDustPrefab`, `ResolvedDustPrefab`, `DustPrefabCandidates`,
// `_preferredChecked`, and the duplicate ConCmd before them. Each time I
// fixed the one I could see and the next one bit within minutes, because
// they all share the same lifetime and I kept treating them as separate
// bugs.
//
// ⚠️ THE CACHE WAS NEVER WORTH IT. Resolving means "clone the first path
// that works" — and the clone is the object we wanted anyway, so a hit
// costs nothing to begin with. The cache only ever saved failed attempts,
// and it bought that with a value that outlives the asset it names.
//
// ⚠️ Candidates are a LOCAL array of consts and literals, built fresh every
// call. A `static readonly string[]` looks like a constant and is not: the
// array object survives the reload with its old contents, which is exactly
// how re-pointing it at the new prefab changed nothing.
var candidates = new[]
{
DustPrefab,
"assets/prefabs/nz_spawn_dust.prefab",
"prefabs/surface/dirt-bullet.prefab",
"surface/dirt-bullet.prefab",
};
foreach ( var path in candidates )
{
var go = TryClone( path, tf );
if ( !go.IsValid() ) continue;
// Report only when it CHANGES, so a burst does not spam, but a switch
// is impossible to miss.
if ( ResolvedDustPrefab != path )
{
Log.Info( $"[nz-dirt] dust prefab now '{path}'"
+ (string.IsNullOrEmpty( ResolvedDustPrefab )
? "" : $" (was '{ResolvedDustPrefab}')") );
ResolvedDustPrefab = path;
}
return go;
}
// ⚠️ Says it EVERY burst, not once. A once-only warning is another piece of
// surviving state, and this is the failure that matters most: it is the
// difference between "the dust looks wrong" and "there is no dust".
Log.Warning( "[nz-dirt] ⛔ NO DUST PREFAB RESOLVED. Tried: "
+ string.Join( " · ", candidates ) );
ResolvedDustPrefab = null;
return null;
}
/// <summary>Forget which prefab resolved, so the next burst re-checks from the
/// top of the list.</summary>
public static void ForgetResolvedPrefab()
{
ResolvedDustPrefab = null;
}
private static GameObject TryClone( string path, Transform tf )
{
try { return GameObject.Clone( path, tf ); }
catch ( System.Exception ) { return null; }
}
/// <summary>
/// Clone the engine's dirt impact a few times around the hole.
///
/// ⚠️ SEVERAL SMALL ONES, NOT ONE BIG ONE. The prefab is authored for a
/// BULLET, and there is no reliable way to scale a particle effect up from
/// here — GameObject scale does not drive particle size, and the size lives
/// in curve properties inside the effect. Spreading a handful of impacts
/// around the rim gives the volume a zombie-sized hole needs, and reads as
/// one cloud because they overlap.
///
/// Returns false if the prefab cannot be found, so the caller falls back to
/// the cubes rather than producing nothing.
/// </summary>
private static bool BurstDust( Scene scene, Vector3 position, int count, float power )
{
// ⛔ A FEW, BECAUSE EACH PUFF IS NOW A WHOLE CLOUD. While this cloned the
// BULLET prefab, volume could only come from count — the effect could not
// be scaled from C#, so the fix was to spam small ones. That is no longer
// true: nz_spawn_dust is authored at grave scale, so 2-3 is a burst and
// the old 6-20 would be a dust storm per zombie.
//
// ⚠️ THE PERF ARGUMENT FLIPPED WITH IT. Each clone is now ~500 particles
// across three layers rather than ~100, so the 35-zombie target cares much
// more about this number than it did. Kept low on purpose.
int want = (int)MathF.Round( count * DustDensity * 0.2f );
int puffs = Math.Clamp( want, 1, MaxPuffs );
int made = 0;
// ⚠️ SAY SO WHEN THE CLAMP BITES. density 4 and density 109 both produced
// five puffs and reported five, so the knob looked dead when it was simply
// saturated — "I never saw a difference" is exactly what a silent clamp
// feels like from outside.
if ( Verbose && want != puffs )
Log.Info( $"[nz-dirt] density wants {want} puffs, capped at {puffs}"
+ " (nz_dirt_max to raise the cap)" );
for ( int i = 0; i < puffs; i++ )
{
// Ring around the rim, not a point — a riser breaks a hole, not a
// pinprick. First one dead centre so a small burst still reads.
var at = position;
if ( i > 0 )
{
float ang = (i / (float)puffs) * MathF.Tau + Game.Random.Float( -0.4f, 0.4f );
float rad = Game.Random.Float( 6f, 18f ) * SizeScale;
at += new Vector3( MathF.Cos( ang ) * rad, MathF.Sin( ang ) * rad,
Game.Random.Float( 0f, 6f ) );
}
var go = CloneDust( at );
// ⛔ A NULL RETURN MUST BE LOUD. Clone returns null for a path it
// cannot resolve rather than throwing, so the first version fell
// back to cubes reporting NOTHING — indistinguishable from the dust
// simply looking bad, which is exactly how it was reported.
if ( !go.IsValid() ) return made > 0;
go.Name = "Spawn Dust";
go.Flags |= GameObjectFlags.NotSaved;
made++;
}
if ( Verbose )
Log.Info( $"[nz-dirt] {made} dust puff(s) at {position} "
+ $"(spread x{SizeScale:0.00}, from {ResolvedDustPrefab})" );
return made > 0;
}
/// <summary>
/// Tint range — earth.
///
/// ⚠️ NOT AS DARK AS REAL SOIL. The first version ran 0.18-0.42 brightness,
/// which is accurate and invisible: dark brown against a dark floor, on a
/// thing that lives under a second. Lifted so it reads.
/// </summary>
private static Color RandomEarth()
{
float v = Game.Random.Float( 0.34f, 0.66f );
return new Color( v, v * Game.Random.Float( 0.70f, 0.84f ),
v * Game.Random.Float( 0.42f, 0.58f ) );
}
public void Emit( int count, float power = 1f )
{
var cube = Model.Cube.Bounds.Size;
_groundZ = WorldPosition.z;
for ( int i = 0; i < count; i++ )
{
var go = Scene.CreateObject();
go.Name = "clod";
go.SetParent( GameObject );
go.Flags |= GameObjectFlags.NotSaved;
// Start slightly spread around the hole rather than all at one point,
// or the burst reads as a single object splitting.
var offset = Vector3.Random.WithZ( 0f ).Normal
* Game.Random.Float( 0f, 9f );
go.WorldPosition = WorldPosition + offset + Vector3.Up * 2f;
go.LocalRotation = Rotation.Random;
// ⚠️ SCALE THIS AGAINST THE ZOMBIE, NOT AGAINST "a clod of dirt".
// The first version used 0.9-2.8 units, which is realistic and
// useless — about 3% of a 72-unit body, so roughly a fingernail on
// screen. Nothing was wrong with the code; it was just too small to
// see, which is indistinguishable from not running.
float size = Game.Random.Float( 4.5f, 11f ) * SizeScale;
go.LocalScale = new Vector3( size / cube.x, size / cube.y, size / cube.z );
var r = go.Components.Create<ModelRenderer>();
r.Model = Model.Cube;
r.Tint = RandomEarth();
// Mostly up, some outward — a riser pushes earth aside as well as up.
var dir = (Vector3.Up * Game.Random.Float( 1.6f, 3.0f )
+ Vector3.Random.WithZ( 0f ).Normal * Game.Random.Float( 0.4f, 1.5f ))
.Normal;
_clods.Add( new Clod
{
Go = go,
Velocity = dir * Game.Random.Float( 70f, 190f ) * power,
Life = Game.Random.Float( MaxLife * 0.3f, MaxLife ),
Size = size,
} );
}
}
protected override void OnUpdate()
{
float dt = Time.Delta;
var cube = Model.Cube.Bounds.Size;
for ( int i = _clods.Count - 1; i >= 0; i-- )
{
var c = _clods[i];
if ( !c.Go.IsValid() )
{
_clods.RemoveAt( i );
continue;
}
c.Age += dt;
if ( c.Age >= c.Life )
{
c.Go.Destroy();
_clods.RemoveAt( i );
continue;
}
if ( !c.Landed )
{
c.Velocity += Vector3.Down * Gravity * dt;
var next = c.Go.WorldPosition + c.Velocity * dt;
float rest = _groundZ + c.Size * 0.35f;
if ( next.z <= rest && c.Velocity.z < 0f )
{
// Land: drop the bounce, keep a little slide so it does not
// stop dead, and stop tumbling.
c.Landed = true;
c.Velocity = c.Velocity.WithZ( 0f ) * 0.25f;
next = next.WithZ( rest );
}
c.Go.WorldPosition = next;
// Tumble, so they do not look like sliding boxes.
c.Go.LocalRotation *= Rotation.FromAxis( Vector3.Up, 320f * dt )
* Rotation.FromAxis( Vector3.Forward, 210f * dt );
}
else
{
// Scrub off the last of the slide so settled dirt sits still.
c.Velocity = c.Velocity.WithZ( 0f ) * MathF.Pow( 0.02f, dt );
c.Go.WorldPosition += c.Velocity * dt;
}
// ⚠️ SHRINK RATHER THAN FADE. Alpha on a ModelRenderer needs a
// translucent material to do anything; scale needs nothing and reads
// the same at this size.
//
// ⚠️ AND ONLY AT THE END. Shrinking across the whole life reads as
// the clod receding into the distance rather than settling — which
// matters much more now that they live 3x longer and land.
float left = 1f - (c.Age / c.Life);
float shrink = MathX.Remap( left, 0f, 0.35f, 0f, 1f, true );
float s = c.Size * shrink;
c.Go.LocalScale = new Vector3( s / cube.x, s / cube.y, s / cube.z );
}
// Nothing owns this object — clean up after the last clod.
if ( _clods.Count == 0 )
GameObject.Destroy();
}
}