Effects/LavaFog.cs

A scene component that spawns and manages lava fog particle prefabs over lava damage walls. It clones a shared prefab, recolours and adjusts emitters, scatters a deterministic set of emitters per damage wall, and exposes console vars and commands to control/count/rebuild the effect.

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

namespace NZombies;

/// <summary>
/// Low lava cloud — the Radioactive Decay pit's gas, recoloured and scattered across the lava.
///
/// ⛔ THE FOURTH APPROACH, AND THE FIRST ONE BUILT ON SOMETHING THAT ALREADY WORKED. The three
/// before it each failed for a different structural reason, all recorded in the CHANGELOG:
/// `VolumetricFogVolume` renders nothing on the scene path; `GradientFog` is one global fog and
/// cannot show a layer from outside; a custom shader on a slab needed stacked sheets to have any
/// volume at all and still read as flat. This one clones `vulture_stink.prefab` — the gas cloud
/// `PitVisual` already uses for Radioactive Decay — and recolours it.
///
/// ⛔ AND THE PREFAB IS WHY IT LOOKS LIKE GAS, IN THREE NUMBERS THE EARLIER PARTICLE ATTEMPT HAD
/// WRONG. Reading them was worth more than every hour spent tuning noise:
///
///   • `Scale` is a RANGE OVER LIFE, 65 → 189 — each puff GROWS as it ages. The earlier attempt
///     used a fixed 3.4, which is why it was *"just big orange particles"*: a sprite that never
///     changes size is a sprite, and one that swells is billowing gas.
///   • `Alpha` is a CURVE over life, 0 → 1 → 0, peaking at 0.4. Fading in as well as out is what
///     stops each puff appearing; a constant alpha pops on at full strength.
///   • `MaxParticles` is TWENTY. A few enormous soft puffs, not hundreds of small ones — the
///     opposite of the instinct, and the reason a 250-particle field read as confetti.
///
/// ⚠️ SO THIS FILE IS MOSTLY PLACEMENT. The look belongs to the prefab, which means an edit to it
/// improves the pit gas and the lava together rather than one drifting from the other.
///
///     # MAPPORT: lava fog
/// </summary>
public sealed class LavaFog : Component
{
	public static LavaFog Instance { get; private set; }

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

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

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

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

	/// <summary>The same cloud `PitVisual` uses. Shared on purpose — see the class note.</summary>
	public const string GasPrefab = "prefabs/particles/nz/vulture_stink.prefab";

	/// <summary>
	/// ⛔ OFF BY DEFAULT — THE LAVA BUBBLES REPLACED IT. The recoloured decay gas was the closest
	/// of four attempts at atmosphere ABOVE the lava, and it still was not right; bubbling the
	/// surface itself turned out to be the thing that was actually wanted. Parked rather than
	/// deleted: `nz_lavafog 1` then `nz_lavafog_rebuild` brings it straight back.
	/// </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_lavafog" )] public static new bool Enabled { get; set; } = false;

	/// <summary>
	/// How many cloud emitters are spread over each lava pool.
	///
	/// ⛔ THIS IS THE COST. Each one is the prefab's own 20 particles, so the real particle count is
	/// this times twenty — 14 emitters is 280 large soft sprites, and they are large, which means
	/// overdraw rather than simulation is the bill. Drop this first if the lava room gets heavy.
	/// </summary>
	[ConVar( "nz_lavafog_count" )] public static int Count { get; set; } = 20;

	/// <summary>
	/// Emitter sphere radius, in units.
	///
	/// ⚠️ THE PREFAB'S OWN IS 6 — SIZED FOR A CLOUD AROUND ONE PLAYER. `PitVisual` widens it to the
	/// pit; a lava pool is far larger again, so each emitter covers a patch rather than a point and
	/// the patches overlap into a continuous bank.
	/// </summary>
	[ConVar( "nz_lavafog_spread" )] public static float Spread { get; set; } = 340f;

	/// <summary>How fast the cloud climbs. The prefab's own is 70; lava heat should be gentler than
	/// napalm's 140 and lazier than the pit's.</summary>
	[ConVar( "nz_lavafog_rise" )] public static float Rise { get; set; } = 20f;

	/// <summary>
	/// Emission rate per emitter. The prefab's own is 10.
	///
	/// ⚠️ RAISING THIS PAST WHAT FILLS THE POOL DOES NOTHING. The prefab caps at `MaxParticles` 20
	/// and each puff lives 1-2s, so somewhere around 12/s the emitter is already saturated — more
	/// coverage has to come from `nz_lavafog_count`, not from here.
	/// </summary>
	[ConVar( "nz_lavafog_rate" )] public static float Rate { get; set; } = 12f;

	/// <summary>How far above the lava surface the emitters sit.</summary>
	[ConVar( "nz_lavafog_lift" )] public static float Lift { get; set; } = 10f;

	/// <summary>Molten orange. Replaces the prefab's radioactive green outright — see Build.</summary>
	public static Color Tint
	{
		get => _tint ??= new Color( 1f, 0.34f, 0.06f, 1f );
		set => _tint = value;
	}

	static Color? _tint;

	readonly List<GameObject> _built = new();

	public int Built => _built.Count;

	public void Rebuild()
	{
		foreach ( var g in _built ) g?.Destroy();
		_built.Clear();

		var walls = ActiveConfig.Current?.DamageWalls;
		if ( !Enabled || walls is null ) return;

		var file = ResourceLibrary.Get<PrefabFile>( GasPrefab );

		if ( file is null )
		{
			Log.Warning( $"[nz-lavafog] gas prefab '{GasPrefab}' not found — no cloud" );
			return;
		}

		var skipped = 0;

		for ( int i = 0; i < walls.Count; i++ )
		{
			var w = walls[i];

			// ⛔ VISIBLE WALLS ONLY. A damage wall doubles as the generic invisible killbox, and a
			// glowing cloud in mid-air over nothing you can see is not atmosphere.
			if ( !w.VisibleInGame ) { skipped++; continue; }

			Build( i, w, file );
		}

		Log.Info( $"[nz-lavafog] {Built} emitter(s) across {walls.Count - skipped} pool(s)"
			+ $"  ({Count} each, spread {Spread:0}u)"
			+ ( skipped > 0 ? $"  [{skipped} wall(s) skipped]" : "" ) );
	}

	void Build( int index, DamageWall w, PrefabFile file )
	{
		// The lava surface: the volume's origin is its middle, so the top face is half its height up.
		var surface = w.Position.z + ( w.Size.z * 0.5f ) + Lift;

		var half = new Vector2( w.Size.x * 0.5f, w.Size.y * 0.5f );

		// ⚠️ A DETERMINISTIC SCATTER, NOT `Game.Random`. The same map must lay its clouds out the
		// same way every load — otherwise a mapper tuning the count is also reshuffling the
		// placement and cannot tell which change did what.
		var rand = new Random( 0x1A7A + index );

		for ( int n = 0; n < Count.Clamp( 1, 128 ); n++ )
		{
			var local = new Vector3(
				( (float)rand.NextDouble() * 2f - 1f ) * half.x * 0.85f,
				( (float)rand.NextDouble() * 2f - 1f ) * half.y * 0.85f,
				0f );

			var at = w.Position.WithZ( surface ) + w.Rotation * local;

			// ⚠️ CLONED DISABLED, RECOLOURED, THEN ENABLED — the order `PitVisual`, `ColourTracer`
			// and `BlastEffect` all use. A particle effect that starts enabled has already emitted
			// its first puffs by the time the tint lands, so the head of the cloud flashes Vulture
			// Aid's green before turning orange.
			var go = SceneUtility.GetPrefabScene( file ).Clone( new CloneConfig
			{
				Transform = new Transform( at ),
				StartEnabled = false,
				Name = $"nz_lavafog_{index}_{n}",
			} );

			go.Flags |= GameObjectFlags.NotSaved;
			go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
			go.SetParent( GameObject );
			go.WorldPosition = at;

			foreach ( var fx in go.Components
				.GetAll<ParticleEffect>( FindMode.EverythingInSelfAndDescendants ) )
			{
				// ⛔ BOTH `Tint` AND `Gradient`. `Tint` MULTIPLIES the gradient, and this prefab's
				// gradient is already a saturated green — setting only the tint gives the product of
				// green and orange, which is a muddy yellow nobody chose. The gradient has to be
				// replaced outright. `PitVisual` carries the same note.
				fx.Tint = Tint;
				fx.Gradient = Tint;

				if ( Rise > 0f ) fx.ForceScale = Rise;
			}

			foreach ( var em in go.Components
				.GetAll<ParticleSphereEmitter>( FindMode.EverythingInSelfAndDescendants ) )
			{
				em.Radius = Spread;
				if ( Rate > 0f ) em.Rate = Rate;
			}

			go.Enabled = true;
			_built.Add( go );
		}
	}

	/// <summary>
	/// `nz_lavafog_status` — whether there is cloud, and if not, why.
	///
	/// ⛔ THE CAUSES ARE SEPARATE LINES BECAUSE THEY LOOK IDENTICAL IN GAME: switched off, no damage
	/// walls on this map, walls that are not `VisibleInGame`, or a missing gas prefab. Only the
	/// first is guessable.
	/// </summary>
	[ConCmd( "nz_lavafog_status" )]
	public static void Status()
	{
		var m = Ensure( Game.ActiveScene );
		if ( !m.IsValid() ) { Log.Warning( "[nz-lavafog] no manager" ); return; }

		var walls = ActiveConfig.Current?.DamageWalls;
		var n = walls?.Count ?? 0;
		var vis = 0;

		if ( walls is not null )
			foreach ( var w in walls ) if ( w.VisibleInGame ) vis++;

		Log.Info( $"[nz-lavafog] {( Enabled ? "on" : "OFF (nz_lavafog 1)" )}"
			+ $"   {m.Built} emitter(s)   {n} damage wall(s), {vis} visible"
			+ $"   {Count} per pool, spread {Spread:0}u, rate {Rate:0.#}, rise {Rise:0}"
			+ $"   ~{m.Built * 20} particles" );

		if ( ResourceLibrary.Get<PrefabFile>( GasPrefab ) is null )
			Log.Warning( $"[nz-lavafog] ⚠ gas prefab '{GasPrefab}' MISSING — no cloud at all" );

		if ( n == 0 )
			Log.Info( "[nz-lavafog] no damage walls on this map — there is nothing to lie on" );
		else if ( vis == 0 )
			Log.Warning( "[nz-lavafog] every damage wall is invisible — skipped (nz_dmgwall_show 1)" );
	}

	/// <summary>`nz_lavafog_rebuild` — apply changed settings without reloading the map.</summary>
	[ConCmd( "nz_lavafog_rebuild" )]
	public static void RebuildCmd() => Ensure( Game.ActiveScene )?.Rebuild();
}