Utility class defining visual and motion properties for ember and ash particle effects. It provides sprite path, helper methods to build curves/gradients/sizes, a per-particle deterministic wander function, a simple hash, sprite loading with fallback, and fog-based logic to permit particle spawning only inside fog areas.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// HOW BASALT'S EMBERS LOOK AND MOVE — shared by the embers in the fog (`FogEmbers`) and those rising off the lava (`LavaEmbers`),
/// and the ash's sway (`AshParticles`): *"i also love 7 — we already have something a bit like that but you can do better"*
/// (2026-09-28, the dust, ash and embers).
///
/// ⛔ AN EMBER IS A LIGHT: it GLOWS PAST WHITE (`Brightness`, peaking at several times 1) so the map's bloom — threshold 1 —
/// catches it, where the old 0.55 never reached it; it COOLS as it rises, white-yellow out of the fire to a dull red
/// (`Gradient` over its life); and it FLICKERS, catching and fading along its life, each on its own because no two are the
/// same age.
/// ⚠️ AND IT WANDERS: a sway of its own, from the particle's own randoms — not the whole air sliding in parallel lines.
/// ⛔ AND IT LIVES IN THE FOG, ONLY THERE (2026-09-28): both kinds are born only inside a fog area, far enough under its top not to
/// climb out of it (`BornInFog`).
/// </summary>
public static class EmberLook
{
/// <summary>
/// An ember: a hot core in a soft falloff, white, to be tinted (`Tools/basalt_ember_sprite.py` → `particles/nz/ember/nz_ember.png`).
/// ⛔ NOT THE ENGINE'S `textures/particles/flame/ember` (the first try, 2026-09-28): 32 x 32 and nearly all empty, one orange speck a
/// few texels wide — under a pixel at an ember's size, so the embers were not there at all.
/// </summary>
public const string SpritePath = "sprites/nz/nz_ember.sprite";
/// <summary>
/// A curve through these points, over a particle's life (0-1), straight between them, its values scaled to <paramref name="top"/>.
/// ⚠️ THE RANGES SET, NOT LEFT TO THE STRUCT'S DEFAULT: a curve's value and time ranges remap what it gives.
/// </summary>
public static Curve Linear( float top, params (float T, float V)[] keys )
{
var frames = keys.Select( k => new Curve.Frame { Time = k.T, Value = k.V, Mode = Curve.HandleMode.Linear } ).ToArray();
return new Curve( frames ) { TimeRange = new Vector2( 0f, 1f ), ValueRange = new Vector2( 0f, top ) };
}
/// <summary>A value along a particle's life.</summary>
public static ParticleFloat OverLife( Curve c ) => new()
{
Type = ParticleFloat.ValueType.Curve,
Evaluation = ParticleFloat.EvaluationType.Life,
CurveA = c,
};
/// <summary>
/// An ember's glow: bright out of the fire, then catching and fading as it cools — a flicker in its own life, so no two glow
/// together. Its peak times white.
/// </summary>
public static ParticleFloat Glow( float peak ) => OverLife( Linear( peak,
(0f, 0.1f), (0.04f, 1f), (0.13f, 0.6f), (0.22f, 0.85f), (0.34f, 0.45f), (0.47f, 0.66f), (0.62f, 0.3f), (0.78f, 0.38f), (1f, 0f) ) );
/// <summary>In fast and out slow — never popping in or out at full.</summary>
public static ParticleFloat FadeInOut( float hold = 0.9f ) => OverLife( Linear( 1f, (0f, 0f), (0.06f, 1f), (0.7f, hold), (1f, 0f) ) );
/// <summary>Cooling as it rises: white-yellow out of the fire, orange, deep orange, a dull red at the end.</summary>
public static ParticleGradient Cooling => new()
{
Type = ParticleGradient.ValueType.Gradient,
Evaluation = ParticleGradient.EvaluationType.Life,
GradientA = new Gradient(
new Gradient.ColorFrame( 0f, new Color( 1f, 0.9f, 0.62f ) ),
new Gradient.ColorFrame( 0.18f, new Color( 1f, 0.58f, 0.18f ) ),
new Gradient.ColorFrame( 0.55f, new Color( 0.95f, 0.3f, 0.06f ) ),
new Gradient.ColorFrame( 1f, new Color( 0.45f, 0.07f, 0.02f ) ) ),
};
/// <summary>A size per particle, from its seed.</summary>
public static ParticleFloat Sizes( float min, float max ) => new()
{
Type = ParticleFloat.ValueType.Range,
Evaluation = ParticleFloat.EvaluationType.Seed,
ConstantA = min,
ConstantB = max,
};
/// <summary>
/// A drift of its own for each particle: a slow sway, each at its own speeds and phase, from where and when it was born; and
/// <paramref name="lift"/> a second, rising.
/// ⛔ IT RUNS ON THE ENGINE'S WORKER THREADS (`ParticleEffect` steps its particles in a `Parallel.For`): pure arithmetic on the
/// particle handed to it, nothing shared, nothing random. ⚠️ NOT `Particle.Random01…`, which are obsolete, nor `Particle.Rand`,
/// which goes through `Game.Random`: the particle's birth place and time, hashed, are its own and are only read.
/// </summary>
public static Action<Particle, float> Wander( float strength, float lift = 0f ) => ( p, dt ) =>
{
var s = p.StartPosition.x * 0.1307f + p.StartPosition.y * 0.7131f + p.StartPosition.z * 0.3719f + p.BornTime * 13.13f;
var a = p.Age;
var ph = Hash( s ) * 6.2832f;
var x = MathF.Sin( a * (1.3f + 1.4f * Hash( s + 1.7f )) + ph );
var y = MathF.Cos( a * (1.1f + 1.6f * Hash( s + 3.1f )) + ph * 1.7f );
var z = MathF.Sin( a * (2.3f + 1.2f * Hash( s + 5.3f )) + ph * 2.3f );
p.Velocity += new Vector3( x * strength, y * strength, z * strength * 0.5f + lift ) * dt;
};
/// <summary>A fixed 0-1 from any number — the old shader hash, in arithmetic only.</summary>
static float Hash( float x )
{
var h = MathF.Sin( x * 12.9898f ) * 43758.5453f;
return h - MathF.Floor( h );
}
/// <summary>The sprite, or the ash's mote when it does not load — a warning either way, as a null sprite draws nothing.</summary>
public static Sprite LoadSprite( string tag )
{
var sprite = ResourceLibrary.Get<Sprite>( SpritePath );
if ( sprite is not null ) return sprite;
Log.Warning( $"[{tag}] '{SpritePath}' did not load — using the ash's mote" );
return ResourceLibrary.Get<Sprite>( AshParticles.SpritePath );
}
// ── where they may be ────────────────────────────────────────────────────────────────────────
/// <summary>`nz_embers fog 0` lets both kinds be born anywhere again, as before 2026-09-28 (this session).</summary>
public static bool FogOnly
{
get => _fogOnly ?? true;
set => _fogOnly = value;
}
static bool? _fogOnly;
/// <summary>
/// How deep in the drawn fog a point is, 0-1: the strongest area's own weight there (`FogAreaManager.WeightAt` — feathered at the
/// sides and the top, hard at the floor), or 1 anywhere while an area is forced on, as basalt's risen lava forces one. 0 with the
/// fog zones switched off (`nz_fogzone 0`): no fog, no embers.
/// </summary>
public static float FogAt( Vector3 at )
{
if ( !FogAreaManager.Enabled ) return 0f;
if ( FogAreaManager.Forced is not null ) return 1f;
var list = ActiveConfig.Current?.Fog;
if ( list is null ) return 0f;
var best = 0f;
foreach ( var a in list )
if ( a is not null ) best = MathF.Max( best, FogAreaManager.WeightAt( a, at ) );
return best;
}
/// <summary>
/// ⛔ EMBERS LIVE IN THE FOG AND ONLY THERE (2026-09-28): *"embers should only exist in the fog"*. May one be born at
/// <paramref name="at"/>? Only inside a fog area, and only as far under its top as it will climb (<paramref name="rise"/>), so none
/// rises out of it — kept by chance by the fog's weight at both ends, so they thin out into a bank's feathered edges.
/// ⚠️ THE MAIN THREAD ONLY: it reads the map's config and rolls `Game.Random` — never from a particle's step.
/// </summary>
public static bool BornInFog( Vector3 at, float rise )
{
if ( !FogOnly ) return true;
var w = MathF.Min( FogAt( at ), FogAt( at + Vector3.Up * rise ) );
return w > 0f && Game.Random.Float() < w;
}
}