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.
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();
}