Effects/LavaEmbers.cs

A scene component that spawns and manages lava ember particle effects near the camera, computing nearest lava surface positions, spawning steady embers and occasional bursts, and exposing console commands to toggle or test the effect.

File Access
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// EMBERS RISING OFF THE LAVA where the player is near it — the lava's own sparks, where the fog's embers are the air's: part of
/// *"i also love 7 — we already have something a bit like that but you can do better"* (2026-09-28). A map asks for them with
/// `Gameplay.LavaEmbers` (basalt's 1; 0, the default, is none).
///
/// ⛔ THE FOG EMBERS' DESIGN, LAID ON THE SURFACE: a thin slab lying on the lava where it is nearest the camera and moved with it,
/// its embers left in the world. Their rate falls away with the camera's distance from the lava, to nothing past
/// <see cref="Reach"/>, so a player far from it pays nothing.
/// ⛔ AND ONLY WHERE THE LAVA IS IN THE FOG (2026-09-28): *"embers should only exist in the fog"*. Each is placed by hand at a spot on
/// the slab that `EmberLook.BornInFog` keeps, and a burst only goes up where the fog above it is deep enough — lava in clear air
/// throws none. The bursts climb less than they did (slower, shorter-lived), so that they fit under the fog's top.
/// ⚠️ THE LAVA IS THE CONFIG'S: every damage wall drawn in game in a lava material, its top the surface, the nearest point of its
/// footprint the place; and while basalt's lava is out of its bed, its level everywhere (`HexPlatforms.RisenLavaTop`).
/// ⚠️ THEIR LOOK IS `EmberLook`'s: its hot-cored sprite, glowing past white for the bloom, cooling white-yellow to red, each
/// weaving on its own — hotter and quicker than the fog's, as they are fresh off the surface.
/// ⚠️ EACH MACHINE, ITS OWN, as every particle is.
/// </summary>
public sealed class LavaEmbers : Component
{
	public static LavaEmbers Instance { get; private set; }

	protected override void OnAwake() => Instance = this;
	protected override void OnDestroy() { if ( Instance == this ) Instance = null; }

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

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

		var go = scene.CreateObject();
		go.Name = "Lava Embers";
		go.Flags |= GameObjectFlags.NotSaved;
		return go.Components.Create<LavaEmbers>();
	}

	/// <summary>`nz_lava_embers 0` switches them off (this session).</summary>
	public static bool On
	{
		get => _on ?? true;
		set => _on = value;
	}

	static bool? _on;

	/// <summary>Embers a second with the camera at the lava's edge, before the map's own scale.</summary>
	public static float Rate
	{
		get => _rate ?? 80f;
		set => _rate = value;
	}

	static float? _rate;

	/// <summary>How far from the lava the camera still sees them rise, in units.</summary>
	public static float Reach
	{
		get => _reach ?? 2000f;
		set => _reach = value;
	}

	static float? _reach;

	/// <summary>Ceiling on embers in the air.</summary>
	public static int MaxParticles
	{
		get => _max ?? 340;
		set => _max = value;
	}

	static int? _max;

	/// <summary>Seconds between the lava's bursts of sparks, at the most and the least — each wait rolled afresh.</summary>
	public static Vector2 BurstEvery
	{
		get => _burstEvery ?? new Vector2( 0.6f, 2.2f );
		set => _burstEvery = value;
	}

	static Vector2? _burstEvery;

	/// <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, twice the size, and the bursts (2026-09-28). 3: no
	/// emitter, each placed by hand in the fog (2026-09-28).
	/// </summary>
	const int Look = 3;

	int _look;
	float _nextBurst;

	/// <summary>How wide a patch of the surface they rise from, centred where it is nearest the camera.</summary>
	public const float BoxSize = 800f;

	ParticleEffect _fx;
	float _forceUntil;

	/// <summary>Embers a second now, and how far the lava is.</summary>
	public float AppliedRate { get; private set; }
	public float Distance { get; private set; } = -1f;

	/// <summary>How many are in the air, or -1 before the effect exists.</summary>
	public int Live => _fx.IsValid() ? _fx.Particles.Count : -1;

	static GameplaySettings Cfg => ActiveConfig.Current?.Gameplay;

	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`, `Burst`)
		var renderer = GameObject.Components.GetOrCreate<ParticleSpriteRenderer>();
		_look = Look;

		_fx.MaxParticles = MaxParticles;
		_fx.LocalSpace = 0f;
		_fx.Collision = false;
		_fx.Lifetime = EmberLook.Sizes( 1.8f, 4.2f );

		// ⚠️ THROWN UP OFF THE SURFACE AND CARRIED BY ITS HEAT: a quick climb that the damping slows, and each its own weave
		_fx.ConstantMovement = new Vector3( 0f, 0f, 60f );
		_fx.StartVelocity = 30f;
		_fx.Damping = 0.4f;
		_fx.OnStep = EmberLook.Wander( 40f, 10f );

		_fx.ApplyShape = true;
		_fx.ApplyAlpha = true;
		_fx.ApplyColor = true;
		_fx.Tint = Color.White;
		_fx.Gradient = EmberLook.Cooling;
		_fx.Brightness = EmberLook.Glow( 7f );

		// ⚠️ SIZES THAT READ — the sprite's hot core is an eighth of it (see `FogEmbers`); 1-2.8 with the engine's near-empty ember
		// sprite was nothing on screen
		_fx.Scale = EmberLook.Sizes( 2.6f, 6f );
		_fx.Alpha = EmberLook.FadeInOut( 0.85f );

		renderer.Lighting = false;
		renderer.FogStrength = 0.25f;
		renderer.Additive = true;
		renderer.Alignment = ParticleSpriteRenderer.BillboardAlignment.LookAtCamera;
		renderer.CameraFadeNear = 30f;

		var sprite = EmberLook.LoadSprite( "nz-lava-embers" );
		if ( sprite is null ) Log.Warning( "[nz-lava-embers] no sprite loaded — the embers will simulate and draw nothing" );
		else renderer.Sprite = sprite;

		return true;
	}

	protected override void OnUpdate()
	{
		if ( !_emittersGone ) RetireEmitters();

		var scale = Cfg?.LavaEmbers ?? 0f;
		var forced = Time.Now < _forceUntil;
		if ( forced && scale <= 0f ) scale = 1f;

		// ⚠️ NOTHING BUILT ON A MAP THAT ASKS FOR NONE
		if ( scale <= 0f || !On )
		{
			AppliedRate = 0f;
			Distance = -1f;
			_owed = 0f;
			return;
		}

		if ( !Build() ) return;

		var cam = Scene.Camera;
		if ( !cam.IsValid() || !Nearest( cam.WorldPosition, out var at, out var dist ) )
		{
			AppliedRate = 0f;
			Distance = -1f;
			_owed = 0f;
			return;
		}

		WorldPosition = at + Vector3.Up * 6f;
		Distance = dist;

		var near = forced ? 1f : (1f - dist / MathF.Max( 1f, Reach )).Clamp( 0f, 1f );
		AppliedRate = Rate * scale * near * near;
		_fx.MaxParticles = MaxParticles;

		Spawn( AppliedRate * Time.Delta, forced );
		Burst( at, near * scale, forced );
	}

	/// <summary>How far a steady ember climbs in its life, near enough — none is born closer than this under the fog's top.</summary>
	const float Rise = 240f;

	/// <summary>How far a burst's sparks climb, about, now that they are slower and shorter-lived — the fog above a burst must be this deep.</summary>
	const float BurstRise = 330f;

	float _owed;
	bool _emittersGone;

	/// <summary>Spots tried and embers born since the last `nz_lava_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 on the slab the fog
	/// keeps (`EmberLook.BornInFog`), tried up to six times — or anywhere while `nz_lava_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 = 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( -8f, 8f ) );
				_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 slab, fog or not.</summary>
	void RetireEmitters()
	{
		_emittersGone = true;
		foreach ( var e in GameObject.Components.GetAll<ParticleBoxEmitter>( FindMode.EverythingInSelf ).ToArray() ) e.Destroy();
	}

	/// <summary>
	/// Now and then a burst: a spray of sparks thrown up from one spot on the surface near the camera, as a bubble breaking would
	/// throw them — much faster than the steady rise, the air slowing them as they cool. ⚠️ EMITTED WHERE CHOSEN (`ParticleEffect.Emit`),
	/// their speed set on the main thread as they are made: the step's own threads never touch it.
	/// ⛔ ONLY WHERE THE FOG ABOVE THE SPOT IS DEEP ENOUGH (`BurstRise`, 2026-09-28): a burst outside it is skipped, not moved, so in
	/// the fog they come no more often than before. ⚠️ AND LOWER THAN THEY WERE: 120-260 u/s (was 140-320), living 1.2-2 s (was the
	/// steady 1.8-4.2), so by the damping they climb some 300 units rather than up to about 900 — under basalt's fog tops, not out
	/// through them. The life is cut on the particle itself (`DeathTime`, from its own `BornTime`, as `Emit` sets it), so the
	/// cooling and the fade still run over the whole of it.
	/// </summary>
	void Burst( Vector3 at, float strength, bool anywhere )
	{
		if ( strength <= 0.05f || Time.Now < _nextBurst ) return;

		var every = BurstEvery;
		_nextBurst = Time.Now + Game.Random.Float( every.x, every.y ) / MathF.Max( 0.25f, strength );

		var spot = at + new Vector3( Game.Random.Float( -BoxSize, BoxSize ) * 0.4f, Game.Random.Float( -BoxSize, BoxSize ) * 0.4f, 4f );
		if ( !anywhere && !EmberLook.BornInFog( spot, BurstRise ) ) return;

		var count = (int)(Game.Random.Float( 8f, 22f ) * MathF.Min( 1f, strength + 0.3f ));
		for ( var k = 0; k < count; k++ )
		{
			var p = _fx.Emit( spot + new Vector3( Game.Random.Float( -18f, 18f ), Game.Random.Float( -18f, 18f ), 0f ), 0f );
			if ( p is null ) break;

			var dir = new Vector3( Game.Random.Float( -0.45f, 0.45f ), Game.Random.Float( -0.45f, 0.45f ), 1f ).Normal;
			p.Velocity = dir * Game.Random.Float( 120f, 260f );
			p.DeathTime = p.BornTime + Game.Random.Float( 1.2f, 2f );
		}
	}

	/// <summary>
	/// The lava's surface nearest this point: on each lava wall drawn in game, its top, at the nearest point of its footprint (or
	/// its box); and with basalt's lava out of its bed, its level straight below.
	/// </summary>
	static bool Nearest( Vector3 eye, out Vector3 at, out float dist )
	{
		at = default;
		dist = float.MaxValue;
		var found = false;

		var walls = ActiveConfig.Current?.DamageWalls;
		if ( walls is not null )
		{
			foreach ( var w in walls )
			{
				if ( w is null || !w.VisibleInGame || !(w.Material ?? "").Contains( "lava", StringComparison.OrdinalIgnoreCase ) ) continue;

				var top = w.Position.z + w.Size.z * 0.5f;
				var rot = Rotation.FromYaw( w.Yaw );
				var local = rot.Inverse * (eye - w.Position);
				var p = new Vector2( local.x, local.y );
				p = w.HasFootprint
					? NearestIn( w.Footprint, p )
					: new Vector2( Math.Clamp( p.x, -w.Size.x * 0.5f, w.Size.x * 0.5f ), Math.Clamp( p.y, -w.Size.y * 0.5f, w.Size.y * 0.5f ) );

				var world = w.Position + rot * new Vector3( p.x, p.y, 0f );
				var point = new Vector3( world.x, world.y, top );
				var d = point.Distance( eye );
				if ( d >= dist ) continue;

				dist = d;
				at = point;
				found = true;
			}
		}

		if ( HexPlatforms.RisenLavaTop is float flood && eye.z > flood && eye.z - flood < dist )
		{
			dist = eye.z - flood;
			at = eye.WithZ( flood );
			found = true;
		}

		return found;
	}

	/// <summary>The point itself inside the polygon; else the nearest point of its edges.</summary>
	static Vector2 NearestIn( List<Vector2> poly, Vector2 p )
	{
		if ( Inside( poly, p ) ) return p;

		var best = poly[0];
		var bestD = float.MaxValue;
		for ( var i = 0; i < poly.Count; i++ )
		{
			var a = poly[i];
			var ab = poly[(i + 1) % poly.Count] - a;
			var t = Math.Clamp( Vector2.Dot( p - a, ab ) / MathF.Max( 1e-4f, ab.LengthSquared ), 0f, 1f );
			var q = a + ab * t;
			var d = (q - p).LengthSquared;
			if ( d >= bestD ) continue;

			bestD = d;
			best = q;
		}

		return best;
	}

	static bool Inside( List<Vector2> poly, Vector2 p )
	{
		var inside = false;
		for ( int i = 0, j = poly.Count - 1; i < poly.Count; j = i++ )
			if ( (poly[i].y > p.y) != (poly[j].y > p.y)
				&& p.x < (poly[j].x - poly[i].x) * (p.y - poly[i].y) / (poly[j].y - poly[i].y) + poly[i].x )
				inside = !inside;
		return inside;
	}

	/// <summary>
	/// `nz_lava_embers [0|1|test]` — the lava's embers now: how far the nearest lava is, the rate, how many are up, and how many
	/// spots the fog kept. `0` switches them off and `1` back on (this session); `test` forces them on at full for eight seconds,
	/// wherever the lava is, fog or not. `nz_lava_embers fog 0` lets both kinds be born outside the fog again, `fog 1` not.
	/// </summary>
	[ConCmd( "nz_lava_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 "fog" when value is "0" or "1": EmberLook.FogOnly = value == "1"; break;
		}

		Log.Info( $"[nz-lava-embers] {(On ? "ON" : "OFF")} · the map's scale {Cfg?.LavaEmbers ?? 0f:0.##} (Settings → Gameplay → Lava embers)"
			+ $" · the lava {(m.IsValid() && m.Distance >= 0f ? $"{m.Distance:0}u off" : "nowhere near")}"
			+ $" · {(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_lava_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;
	}
}