A scene Component that spawns and manages small ember particle effects inside fog areas. It builds a ParticleEffect and ParticleSpriteRenderer, controls spawn rate based on config and fog weight, ensures embers are born inside fog, and exposes console commands to toggle, test, and tune the system.
using Sandbox;
using System.Linq;
namespace NZombies;
/// <summary>
/// EMBERS in the fog areas, rising among the ash: *"I'd like to see small realistic embers in the volcano fog too, not just ashes"*
/// (2026-09-28). A map asks for them with `Gameplay.FogEmbers` (basalt's 1; 0, the default, is none), so a cold fog elsewhere stays
/// ash alone.
///
/// ⛔ `AshParticles`' DESIGN, KEPT WHOLE — and its reason is the answer to "will it lag": the spawn box rides the camera and the
/// particles do not, so the cost is the same in a corridor as in a cavern, and never more than `nz_embers_max` live at once. The
/// fog's own weight where the camera is (`FogAreaManager.Weight`) sets the rate; out of fog it is zero and what is up simply
/// burns out, which is also the fade.
///
/// ⛔ AND EACH IS BORN INSIDE THE FOG, NEVER BESIDE IT (2026-09-28): *"embers should only exist in the fog"*. The box emitter took no
/// notice of a bank's edges, so near one, half of them rose in clear air. Now each is placed by hand (`Spawn`) at a spot in the box
/// that `EmberLook.BornInFog` keeps: inside a fog area, and far enough under its top not to climb out of it.
///
/// ⚠️ AN EMBER IS NOT A MOTE: SMALL, BRIGHT, RISING, SPARSE — and since 2026-09-28 (`EmberLook`) a light: a hot-cored sprite of
/// our own, additive, glowing to six times white at its hottest so the map's bloom catches it (the old tint never reached 1),
/// cooling from white-yellow to a dull red as it climbs, flickering in its own life, and weaving on the updraft rather than
/// sliding up in parallel lines. A few dozen in the air at once — embers are rare against the ash, which is what makes each one
/// noticed.
///
/// ⚠️ OVERDRAW IS STILL THE COST, as the ash's note says, and these are small: 240 at most, most of them far from the lens, and
/// faded out near it (`CameraFadeNear`).
/// </summary>
public sealed class FogEmbers : Component
{
public static FogEmbers Instance { get; private set; }
protected override void OnAwake() => Instance = this;
public static FogEmbers Ensure( Scene scene = null )
{
if ( Instance.IsValid() ) return Instance;
scene ??= Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "Fog Embers";
go.Flags |= GameObjectFlags.NotSaved;
return go.Components.Create<FogEmbers>();
}
/// <summary>`nz_embers 0` to switch them off without touching the fog or the ash.</summary>
public static bool On
{
get => _on ?? true;
set => _on = value;
}
static bool? _on;
/// <summary>Embers a second at full fog weight, before the map's own `FogEmbers` scale. Nullable-backed (INSTRUCTIONS §1).</summary>
public static float Rate
{
get => _rate ?? 36f;
set => _rate = value;
}
static float? _rate;
/// <summary>Ceiling on embers in the air — about the rate times their life, so it rarely binds.</summary>
public static int MaxParticles
{
get => _max ?? 240;
set => _max = value;
}
static int? _max;
/// <summary>How far the spawn box reaches from the camera — the ash's 700, so the two fill the same air.</summary>
public const float BoxSize = 700f;
ParticleEffect _fx;
float _forceUntil;
/// <summary>Embers a second now, after the fog and the map's scale.</summary>
public float AppliedRate { get; private set; }
/// <summary>How many are in the air, or -1 before the effect exists.</summary>
public int Live => _fx.IsValid() ? _fx.Particles.Count : -1;
/// <summary>
/// The look this effect was last set up in. ⚠️ BUMP IT WHEN THE LOOK BELOW CHANGES: the effect is built once and kept, so a running
/// game would go on drawing the old one. 2: the ember sprite of our own, and embers twice the size (2026-09-28). 3: no emitter,
/// each placed by hand in the fog (2026-09-28).
/// </summary>
const int Look = 3;
int _look;
bool Build()
{
if ( _fx.IsValid() && _look == Look ) return true;
if ( !_fx.IsValid() ) _fx = GameObject.Components.GetOrCreate<ParticleEffect>();
// ⛔ NO EMITTER (look 3): each ember is placed by hand where the fog is (`Spawn`) — and so no box emitter's default burst of
// 100 either, the reason the old one had to be created disabled
var renderer = GameObject.Components.GetOrCreate<ParticleSpriteRenderer>();
_look = Look;
_fx.MaxParticles = MaxParticles;
_fx.LocalSpace = 0f; // ⚠️ world space: you walk through them, they do not ride with you
_fx.Collision = false; // ⚠️ no traces, as the ash
_fx.Lifetime = EmberLook.Sizes( 2.6f, 5.4f );
// ⚠️ THE UPDRAFT, AND THE SPARK THAT THREW IT: a steady climb, a random kick the damping takes away, and each one's own
// sway (`EmberLook.Wander`) — the old ones rose a hundred units in parallel lines; these climb near two hundred, weaving
_fx.ConstantMovement = new Vector3( 4f, 2f, 34f );
_fx.StartVelocity = 22f;
_fx.Damping = 0.55f;
_fx.OnStep = EmberLook.Wander( 28f, 4f );
_fx.ApplyShape = true;
_fx.ApplyAlpha = true;
_fx.ApplyColor = true;
// ⛔ A LIGHT, NOT A SPECK (2026-09-28): white-yellow cooling to a dull red over its life, glowing to six times white at
// its hottest so the bloom (threshold 1) catches it, and flickering in its own life. ⚠️ THE TINT WHITE: it multiplies the
// gradient.
_fx.Tint = Color.White;
_fx.Gradient = EmberLook.Cooling;
_fx.Brightness = EmberLook.Glow( 6f );
// ⚠️ SIZES THAT READ — `ParticleEffect.Scale` units, as `AshParticles.Size`'s note measures them: the sprite's hot core is
// an eighth of it, so at 2.4-5.2 the core still covers a pixel or so across a room and the bloom spreads it into a glow
// (0.7-1.5, and then 1.1-2.6 with the engine's near-empty ember sprite, were nothing on screen)
_fx.Scale = EmberLook.Sizes( 2.4f, 5.2f );
// ⚠️ IN FAST, OUT SLOW: never popping in at full
_fx.Alpha = EmberLook.FadeInOut();
// ⚠️ ADDITIVE AND UNLIT: an ember is a light, so it adds rather than covers, and no dark room can turn it black. ⚠️ A LITTLE
// FOG ON IT (0.35): a far one sinks into the haze rather than hanging bright in it
renderer.Lighting = false;
renderer.FogStrength = 0.35f;
renderer.Additive = true;
renderer.Alignment = ParticleSpriteRenderer.BillboardAlignment.LookAtCamera;
renderer.CameraFadeNear = 30f;
var sprite = EmberLook.LoadSprite( "nz-embers" );
if ( sprite is null ) Log.Warning( $"[nz-embers] no sprite loaded — embers will simulate and draw nothing" );
else renderer.Sprite = sprite;
return true;
}
protected override void OnUpdate()
{
if ( !_emittersGone ) RetireEmitters();
var scale = ActiveConfig.Current?.Gameplay?.FogEmbers ?? 0f;
var forced = Time.Now < _forceUntil;
// ⚠️ NOTHING BUILT ON A MAP THAT ASKS FOR NONE — not a single component
if ( !forced && (scale <= 0f || !On) )
{
AppliedRate = 0f;
_owed = 0f;
return;
}
if ( !Build() ) return;
var fog = FogAreaManager.Instance;
var weight = fog.IsValid() ? fog.Weight.Clamp( 0f, 1f ) : 0f;
if ( forced ) { weight = 1f; if ( scale <= 0f ) scale = 1f; }
// ⚠️ THE BOX RIDES THE CAMERA, not the player — the ash's reason: the camera decides what is on screen. ⚠️ AND IT RIDES
// AHEAD OF IT (2026-09-28): centred on the lens, nine in ten embers were born behind or beside it, never seen
var cam = Scene.Camera;
if ( cam.IsValid() ) WorldPosition = cam.WorldPosition + cam.WorldRotation.Forward * (BoxSize * 0.3f) + Vector3.Down * 40f;
AppliedRate = Rate * scale * weight;
_fx.MaxParticles = MaxParticles;
Spawn( AppliedRate * Time.Delta, forced );
}
/// <summary>
/// How far an ember climbs in its life, near enough — none is born closer than this under the fog's top, so none rises out.
/// </summary>
const float Rise = 180f;
float _owed;
bool _emittersGone;
/// <summary>Spots tried and embers born since the last `nz_embers`, so the fog rule can be seen working.</summary>
int _tried, _born;
/// <summary>
/// ⛔ PLACED BY HAND, NOT BY A BOX EMITTER (look 3): <paramref name="count"/> more embers owed, each at a spot in the box the fog
/// keeps (`EmberLook.BornInFog`), tried up to six times — or anywhere while `nz_embers test` forces them.
/// ⚠️ `Emit` GIVES EACH ITS START VELOCITY, as the emitter did (the engine's own code), so they move exactly as before.
/// </summary>
void Spawn( float count, bool anywhere )
{
_owed = System.MathF.Min( _owed + count, 8f );
while ( _owed >= 1f )
{
_owed -= 1f;
for ( var t = 0; t < 6; t++ )
{
var at = WorldPosition + new Vector3( Game.Random.Float( -0.5f, 0.5f ) * BoxSize,
Game.Random.Float( -0.5f, 0.5f ) * BoxSize, Game.Random.Float( -0.5f, 0.5f ) * BoxSize * 0.6f );
_tried++;
if ( !anywhere && !EmberLook.BornInFog( at, Rise ) ) continue;
_born++;
if ( _fx.Emit( at, Game.Random.Float() ) is null ) return;
break;
}
}
}
/// <summary>The box emitter of an older look, taken off once — it would go on filling its box, fog or not.</summary>
void RetireEmitters()
{
_emittersGone = true;
foreach ( var e in GameObject.Components.GetAll<ParticleBoxEmitter>( FindMode.EverythingInSelf ).ToArray() ) e.Destroy();
}
/// <summary>
/// `nz_embers [0|1]` — the embers now: the rate, how many are up, the fog where the camera is and the map's scale, and how many
/// spots the fog kept. `nz_embers 0` switches them off (this session), `nz_embers 1` back on; `nz_embers test` forces them on for
/// eight seconds, anywhere, the fog rule too. `nz_embers fog 0` lets both kinds be born outside the fog again, `fog 1` not.
/// `nz_embers rate 16` and `nz_embers max 90` retune them.
/// </summary>
[ConCmd( "nz_embers" )]
public static void Cmd( string what = "", string value = "" )
{
var m = Ensure( Game.ActiveScene );
switch ( what )
{
case "0": On = false; break;
case "1": On = true; break;
case "test" when m.IsValid(): m._forceUntil = Time.Now + 8f; break;
case "rate" when float.TryParse( value, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var r ): Rate = System.MathF.Max( 0f, r ); break;
case "max" when int.TryParse( value, out var n ): MaxParticles = System.Math.Max( 0, n ); break;
case "fog" when value is "0" or "1": EmberLook.FogOnly = value == "1"; break;
}
var fog = FogAreaManager.Instance;
Log.Info( $"[nz-embers] {(On ? "ON" : "OFF")} · the map's scale {ActiveConfig.Current?.Gameplay?.FogEmbers ?? 0f:0.##}"
+ $" (Settings → Gameplay → Fog embers) · fog here {(fog.IsValid() ? fog.Weight : 0f):0.##}"
+ $" · {(m.IsValid() ? m.AppliedRate : 0f):0.#}/s of {Rate:0.#}, {(m.IsValid() ? m.Live : -1)} up of {MaxParticles}"
+ $" · born {(EmberLook.FogOnly ? "only in the fog" : "ANYWHERE (nz_embers fog 1)")}:"
+ $" {(m.IsValid() ? m._born : 0)} of {(m.IsValid() ? m._tried : 0)} spots since the last look"
+ (what == "test" ? " · forced on for 8 s, anywhere" : "") );
if ( m.IsValid() ) m._tried = m._born = 0;
}
}