Zombies/SpawnDirt.cs

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.

NetworkingFile Access
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();
	}
}