Effects/AshParticles.cs

Component that spawns and manages ash particle effects around the camera. It ensures a singleton manager, builds/configures a ParticleEffect with a ParticleBoxEmitter and sprite renderer, updates emitter rate, size and effect properties from console variables and fog weight, and exposes console commands to force/test and report status.

File AccessNetworking
using Sandbox;

namespace NZombies;

/// <summary>
/// Real ash drifting in the air around the player while they stand in a fog area.
///
/// ⛔ THE EMITTER FOLLOWS THE CAMERA; THE PARTICLES DO NOT. This is the whole design, and it is what
/// makes the cost independent of the zone. Filling a drawn volume with particles would cost whatever
/// that volume happens to be — a corridor is cheap and a cavern is not, and the mapper would be
/// choosing a framerate every time they drew an area. Instead a small box rides the camera and
/// spawns into it, while `LocalSpace = 0` leaves every particle standing still in the world once
/// born. You walk THROUGH them, they do not travel with you, and there are never more than
/// `nz_ash_max` of them no matter how big the area is.
///
/// ⚠️ SO THE ZONE ONLY DECIDES THE RATE, NOT THE COUNT. Out of fog the rate is zero and the existing
/// particles live out their lifetime and are gone — which is also the fade-out, for free.
///
/// ⚠️ OVERDRAW IS THE COST HERE, NOT THE PARTICLE COUNT. These are alpha-blended sprites; two
/// hundred small ones are cheap and a dozen big ones near the lens are not. Hence a small `Scale`
/// and `CameraFadeNear`, which fades out anything close enough to cover the screen.
///
///     # MAPPORT: ash overlay
/// </summary>
public sealed class AshParticles : Component
{
	public static AshParticles Instance { get; private set; }

	protected override void OnAwake() => Instance = this;

	public static AshParticles Ensure( Scene scene = null )
	{
		if ( Instance.IsValid() ) return Instance;

		scene ??= Game.ActiveScene;
		if ( !scene.IsValid() ) return null;

		var go = scene.CreateObject();
		go.Name = "Ash Drift";
		go.Flags |= GameObjectFlags.NotSaved;
		return go.Components.Create<AshParticles>();
	}

	public const string SpritePath = "sprites/nz/nz_ash_mote.sprite";

	/// <summary>`nz_ash_particles 0` to rule them out without touching the fog.</summary>
	// ⚠️ `new` BECAUSE THIS DELIBERATELY HIDES `Component.Enabled`, AND THE COMPILER WAS RIGHT TO
	// ASK. Every bare `Enabled` inside this class means THE CONVAR — which is what the call sites
	// want — but a future `Enabled = false` written to disable the component would instead switch
	// the feature off globally for everyone. The keyword states the intent; if that trap ever bites,
	// rename this rather than removing the keyword.
	[ConVar( "nz_ash_particles" )] public static new bool Enabled { get; set; } = true;

	/// <summary>
	/// Ceiling on live particles.
	///
	/// ⛔ AND IT IS THE BINDING LIMIT, NOT THE RATE — WHICH IS WHY BOTH HAD TO MOVE. Steady state is
	/// `Rate × Life`, so 45/s over a 7s life asks for 315 and a cap of 250 silently threw the rest
	/// away. Raising the rate alone would have changed nothing at all: the emitter would spawn
	/// faster and the cap would drop more.
	///
	/// ⚠️ 110/s × 7s = 770, against a 700 cap — deliberately just over, so the air stays full rather
	/// than thinning whenever a burst of particles happens to expire together.
	/// </summary>
	/// <remarks>
	/// ⛔ A CHANGED DEFAULT DOES NOT REACH A RUNNING EDITOR, AND THE REASON IS THE CONVAR SYSTEM —
	/// NOT THE HOTLOAD-STATIC TRAP IT LOOKS LIKE. Raising this from 250 to 700 left the game
	/// reporting `rate 45/s of 45, live 117/250` while the source said otherwise, which is the exact
	/// signature of a static whose initialiser did not re-run. It is not that. Once `nz_ash_max` has
	/// been REGISTERED as a convar, the convar system owns the value and restores it over whatever
	/// the property's default says.
	///
	/// ⚠️ THE TELL WAS IN THE SAME LOG LINE: `size 3.4` read correctly while rate and max were
	/// stale. `nz_ash_size` was a NEW convar that had never been registered, so it took the
	/// property's default; the other two already existed at their old values. Nullable-backing and a
	/// version suffix were both applied here before that was understood — they are harmless and they
	/// were not the fix.
	///
	/// So: a fresh install gets these numbers. A running editor needs `nz_ash_max 700` typed once,
	/// and any value the player has set stays set, which is what a convar is supposed to do.
	/// </remarks>
	[ConVar( "nz_ash_max" )]
	public static int MaxParticles
	{
		get => _maxV2 ??= 700;
		set => _maxV2 = value;
	}

	static int? _maxV2;

	/// <summary>Particles per second at full fog weight. Nullable-backed, see MaxParticles.</summary>
	[ConVar( "nz_ash_rate" )]
	public static float Rate
	{
		get => _rateV2 ??= 110f;
		set => _rateV2 = value;
	}

	static float? _rateV2;

	/// <summary>
	/// How far the spawn box reaches from the camera, in units.
	///
	/// ⚠️ IT HAS TO OUTRUN THE PLAYER. A sprinting player crosses ~320 units a second, so a box
	/// much tighter than this empties out ahead of them and refills behind — ash that only exists
	/// where you have already been.
	/// </summary>
	[ConVar( "nz_ash_box" )]
	public static float BoxSize
	{
		get => _boxV2 ??= 700f;
		set => _boxV2 = value;
	}

	static float? _boxV2;

	/// <summary>How long each mote lives. Also the fade-out when you leave a zone.</summary>
	[ConVar( "nz_ash_life" )]
	public static float Life
	{
		get => _lifeV2 ??= 7f;
		set => _lifeV2 = value;
	}

	static float? _lifeV2;

	/// <summary>
	/// How big each mote is, in `ParticleEffect.Scale` units.
	///
	/// ⛔ NOT A 0-1 FRACTION, and assuming it was is what made the whole system invisible. See the
	/// note in Build(). Anything below about 1 is smaller than a pixel at playable distances.
	/// </summary>
	[ConVar( "nz_ash_size" )] public static float Size
	{
		get => _sizeV2 ??= 3.4f;
		set => _sizeV2 = value;
	}

	static float? _sizeV2;

	/// <summary>Each mote's own size, 0.7 to 1.35 of <see cref="Size"/>, from its seed.</summary>
	static ParticleFloat SizeSpread => EmberLook.Sizes( Size * 0.7f, Size * 1.35f );

	/// <summary>The mote colour. Warm grey ash rather than white snow.</summary>
	public static Color Tint
	{
		get => _tint ??= new Color( 0.78f, 0.70f, 0.60f, 1f );
		set => _tint = value;
	}

	static Color? _tint;

	ParticleEffect _fx;
	ParticleBoxEmitter _emitter;
	ParticleSpriteRenderer _renderer;

	/// <summary>Last rate applied, for the status command.</summary>
	public float AppliedRate { get; private set; }

	/// <summary>How many are alive right now, or -1 if there is no effect.</summary>
	public int Live => _fx.IsValid() ? _fx.Particles.Count : -1;

	bool Build()
	{
		if ( _fx.IsValid() ) return true;

		_fx = GameObject.Components.GetOrCreate<ParticleEffect>();
		// ⛔ CREATED DISABLED, CONFIGURED, THEN TURNED ON — AND THAT ORDER IS NOT COSMETIC.
		// `ParticleBoxEmitter.Burst` defaults to 100 and the burst fires from the component's own
		// enable, which happens BEFORE the next line can set it to 0. Measured: a hundred motes in
		// the air with the rate still reading `0/s`, appearing as a puff of ash every time a map
		// loaded, in the one state where there should be none at all.
		_emitter = GameObject.Components.Create<ParticleBoxEmitter>( false );
		_renderer = GameObject.Components.GetOrCreate<ParticleSpriteRenderer>();

		_fx.MaxParticles = MaxParticles;
		_fx.Lifetime = Life;

		// ⛔ WORLD SPACE, AND `LocalSpace` IS HOW YOU SAY SO. At 1 every mote would be glued to the
		// emitter, riding along with the camera — and the entire point, moving through them, would be
		// gone. This one number separates "ash in the air" from "ash on the visor".
		//
		// ⚠️ NOT `ParticleEffect.Space`, WHICH COMPILES AND IS OBSOLETE — the compiler says "use
		// LocalSpace instead". Same value, and the deprecated one is the sort that disappears in an
		// engine update.
		_fx.LocalSpace = 0f;

		// ⚠️ DRIFT, NOT GRAVITY. Ash is light enough that it hangs and slides rather than falls, so
		// the downward component is small and there is a sideways one. `ConstantMovement` ignores
		// drag and collisions, which is right for something this light and saves the simulation.
		_fx.ConstantMovement = new Vector3( 6f, 3f, -11f );

		// A little spread so they are not a marching grid.
		_fx.StartVelocity = 5f;
		_fx.Damping = 0.35f;

		_fx.ApplyShape = true;

		// ⛔ 3.4, AND IT WAS 0.55 — WHICH IS WHY THEY LOOKED LIKE AN OVERLAY AND NOTHING ELSE.
		// `ParticleEffect.Scale` is not a 0-1 fraction; `VultureStink` already records that it runs
		// at roughly 2.5x a Source PCF radius. At 0.55 each mote was SUB-UNIT — they simulated
		// perfectly, the counts were right, the status command reported healthy, and on screen they
		// were one or two pixels that read as compression noise. Reported as *"the ashes are an
		// overlay at the moment"*: the real particles were there and invisible, so the only thing
		// visible was the screen layer.
		//
		// ⚠️ MEASURED AGAINST A HUMAN FIGURE at 200-900 units, not chosen. 4 was slightly too much
		// and 0.55 was nothing. ⚠️ AND NOW A SPREAD ABOUT IT (2026-09-28): 0.7 to 1.35 of it, each mote its own, so the air is
		// not a field of identical dots.
		_fx.Scale = SizeSpread;
		_fx.InitialScale = 1f;

		_fx.ApplyAlpha = true;
		_fx.ApplyColor = true;

		// ⚠️ IN AND OUT, NOT POPPING (2026-09-28): `ApplyAlpha` was on with no `Alpha`, so every mote appeared and vanished at full
		_fx.Alpha = EmberLook.FadeInOut( 0.85f );

		// ⚠️ AND EACH SWAYS ON ITS OWN, gently — the whole air no longer sliding in one direction in parallel lines
		_fx.OnStep = EmberLook.Wander( 9f );

		// ⚠️ WARM GREY, NOT WHITE. White motes on a pale background are snow; the warm tint is what
		// makes them read as ash. On a dark map a LIGHT particle is still correct — soot is dark in
		// the hand and bright in the air, because what you see is the light it catches.
		_fx.Tint = Tint;

		// ⛔ NO COLLISION. Two hundred colliding particles is two hundred traces a frame, to stop ash
		// passing through a wall you cannot see it against anyway.
		_fx.Collision = false;

		_emitter.Size = BoxSize;
		_emitter.Burst = 0;
		_emitter.Rate = 0f;
		_emitter.Loop = true;
		_emitter.Duration = 1f;

		// Safe to run now that Burst is zero.
		_emitter.Enabled = true;

		// ⚠️ NOT LIT AND NOT FOGGED. `Lighting` would make each mote shade against the room, which on
		// a dark map turns them black; `FogStrength` at 1 would then have the gradient fog wash them
		// out exactly where the fog is thickest — the one place they are meant to be visible.
		_renderer.Lighting = false;
		_renderer.FogStrength = 0f;
		_renderer.Additive = false;
		_renderer.Alignment = ParticleSpriteRenderer.BillboardAlignment.LookAtCamera;
		_renderer.SortMode = ParticleSpriteRenderer.ParticleSortMode.ByDistance;

		// ⚠️ FADES OUT WHAT IS ON THE LENS. A mote a few units from the camera covers a quarter of
		// the screen and reads as a smudge rather than as a particle, and it is also the worst case
		// for overdraw. Both problems have the same fix.
		_renderer.CameraFadeNear = 40f;

		var sprite = ResourceLibrary.Get<Sprite>( SpritePath );

		if ( sprite is null )
		{
			// ⛔ SAYS SO LOUDLY. A null sprite renders NOTHING while the effect happily simulates
			// hundreds of particles — the emitter reports healthy, the count is right, and the
			// screen is empty. That is the hardest possible version of this bug to find.
			Log.Warning( $"[nz-ash] '{SpritePath}' did not load — particles will simulate and draw"
				+ " NOTHING. Check the .sprite compiled." );
		}
		else
		{
			_renderer.Sprite = sprite;
		}

		return true;
	}

	protected override void OnUpdate()
	{
		if ( !Build() ) return;

		var cam = Scene.Camera;
		var fog = FogAreaManager.Instance;

		var weight = Enabled && fog.IsValid() ? fog.Weight.Clamp( 0f, 1f ) : 0f;

		// `nz_ash_test` overrides the fog for a few seconds.
		if ( Time.Now < _forceUntil ) weight = 1f;

		// ⚠️ THE BOX RIDES THE CAMERA EVERY FRAME, not the player. The camera trails the body and
		// can be a long way from it while spectating or downed — and it is the camera that decides
		// what is on screen. `nz_corner_at` records the same distinction costing time elsewhere.
		if ( cam.IsValid() )
			WorldPosition = cam.WorldPosition;

		AppliedRate = Rate * weight;

		if ( _emitter.IsValid() ) _emitter.Rate = AppliedRate;

		// Live-tunable without a rebuild.
		if ( _fx.IsValid() )
		{
			_fx.MaxParticles = MaxParticles;
			_fx.Lifetime = Life;
			_fx.Scale = SizeSpread;
			_fx.Tint = Tint;
		}

		if ( _emitter.IsValid() ) _emitter.Size = BoxSize;
	}

	/// <summary>
	/// `nz_ash_test [seconds]` — force them on here, regardless of fog.
	///
	/// ⛔ BECAUSE "ARE THEY EVEN THERE" WAS UNANSWERABLE WITHOUT ONE. Seeing the particles required
	/// standing inside a drawn fog area, and both test maps drop the player out of the world within
	/// seconds — so the only way to check was to author a zone, respawn into it and hope. That is
	/// also why they stayed invisible at a sub-unit scale for a whole revision: nothing made the
	/// question cheap to ask.
	/// </summary>
	[ConCmd( "nz_ash_test" )]
	public static void Test( float seconds = 8f )
	{
		var m = Ensure( Game.ActiveScene );
		if ( !m.IsValid() ) { Log.Warning( "[nz-ash] no manager" ); return; }

		m._forceUntil = Time.Now + seconds.Clamp( 0.5f, 120f );

		Log.Info( $"[nz-ash] forced on for {seconds:0.#}s at {Rate:0.#}/s"
			+ $"  size {Size:0.##}  box {BoxSize:0}u  — look around, they are in WORLD space" );
    }

	float _forceUntil;

	/// <summary>
	/// `nz_ash_particles_status` — what is in the air and why.
	///
	/// ⛔ IT NAMES THE FOG WEIGHT, THE RATE AND THE SPRITE SEPARATELY, because "no ash" has four
	/// causes that look identical from inside the game: switched off, not in a fog area, an emitter
	/// producing nothing, or a sprite that failed to load so hundreds of invisible particles are
	/// simulating perfectly. `VultureStink` learned the same lesson and its diagnostic says so.
	/// </summary>
	[ConCmd( "nz_ash_particles_status" )]
	public static void Status()
	{
		var m = Ensure( Game.ActiveScene );
		if ( !m.IsValid() ) { Log.Warning( "[nz-ash] no manager" ); return; }

		var fog = FogAreaManager.Instance;
		var w = fog.IsValid() ? fog.Weight : 0f;

		Log.Info( $"[nz-ash] particles {( Enabled ? "on" : "OFF (nz_ash_particles 1)" )}"
			+ $"   fog weight {w:0.###}   rate {m.AppliedRate:0.#}/s of {Rate:0.#}"
			+ $"   live {m.Live}/{MaxParticles}   size {Size:0.##}   box {BoxSize:0}u"
			+ $"   life {Life:0.#}s"
			+ ( Time.Now < m._forceUntil ? "   (FORCED, nz_ash_test)" : "" ) );

		// ⚠️ THE SIZE IS CALLED OUT BECAUSE IT IS THE ONE THAT HID EVERYTHING. A sub-unit scale
		// reports as a perfectly healthy system: right counts, right rate, nothing on screen.
		if ( Size < 1f )
			Log.Warning( $"[nz-ash] ⚠ size {Size:0.##} is SUB-UNIT — they will be a pixel or less."
				+ " nz_ash_sizeV2 3.4" );

		var sprite = ResourceLibrary.Get<Sprite>( SpritePath );

		if ( sprite is null )
			Log.Warning( $"[nz-ash] ⚠ '{SpritePath}' DID NOT LOAD — they simulate and draw nothing" );

		if ( w <= 0.001f )
			Log.Info( "[nz-ash] fog weight is zero — you are not inside a fog area (nz_fog_report)" );
	}
}