Effects/LavaHaze.cs

Component that creates and manages decorative heat-haze slabs over lava damage walls. It builds per-pool model slabs using a material, places them above each lava wall from the current map config, and exposes console commands to toggle haze and switch lava material between living and classic looks.

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

namespace NZombies;

/// <summary>
/// HEAT HAZE OVER THE LAVA — the air above every pool wavering, what lies beyond it swimming (2026-09-28, the living lava: *"i love
/// the living lava, we already have some form of that but you could make it realistic and professional looking"*). A map asks for
/// it with `Gameplay.LavaHaze` (basalt's 1; 0, the default, is none).
///
/// ⛔ A SLAB OVER EACH POOL, AS THE OLD LAVA FOG WAS: every damage wall drawn in game in a lava material gets a prism extruded from
/// its own footprint (`DebrisMesh`, the builder that draws the lava), from the lava's surface up <see cref="Height"/> units, in
/// `materials/nz/heathaze.vmat` (`nz_heathaze.shader`), which draws the frame behind it again, bent.
/// ⚠️ ITS SURFACE AND HEIGHT ARE THE RENDERER'S, not the material's (`SceneObject.Attributes`), as the fog sheets' are: one material
/// serves pools at different heights.
/// ⚠️ LOCAL, BUILT BESIDE THE WALLS AS A CONFIG IS SHOWN (`NZGame.ShowConfig`), and nothing sent.
///
/// ⚠️ AND `nz_lava_look` CHANGES THE LAVA ITSELF: basalt's walls on the living shader (`materials/nz/lava_living.vmat`) or back on
/// the classic (`materials/nz/lava.vmat`), in the config in memory — `nz_save` keeps it.
/// </summary>
public sealed class LavaHaze : Component
{
	public static LavaHaze Instance { get; private set; }

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

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

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

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

	public const string MaterialPath = "materials/nz/heathaze.vmat";
	public const string LivingLava = "materials/nz/lava_living.vmat";
	public const string ClassicLava = "materials/nz/lava.vmat";

	/// <summary>How high above the lava the air wavers, in units.</summary>
	public static float Height
	{
		get => _height ?? 140f;
		set => _height = value;
	}

	static float? _height;

	/// <summary>`nz_lava_haze 0` takes it away (this session).</summary>
	public static bool On
	{
		get => _on ?? true;
		set => _on = value;
	}

	static bool? _on;

	readonly List<GameObject> _slabs = new();

	/// <summary>How many slabs stand.</summary>
	public int Slabs => _slabs.Count( s => s.IsValid() );

	static bool IsLava( DamageWall w ) => w is not null && w.VisibleInGame
		&& (w.Material ?? "").Contains( "lava", StringComparison.OrdinalIgnoreCase );

	/// <summary>A slab over every lava wall drawn in game, where the map asks for haze; none otherwise.</summary>
	public void Rebuild()
	{
		foreach ( var s in _slabs ) if ( s.IsValid() ) s.Destroy();
		_slabs.Clear();

		var cfg = ActiveConfig.Current;
		if ( !On || (cfg?.Gameplay?.LavaHaze ?? 0f) <= 0f || cfg.DamageWalls is null ) return;

		var material = Material.Load( MaterialPath );
		if ( material is null ) { Log.Warning( $"[nz-lava] {MaterialPath} did not load — no heat haze" ); return; }

		foreach ( var w in cfg.DamageWalls.Where( IsLava ) )
		{
			var footprint = w.HasFootprint
				? w.Footprint
				: new List<Vector2>
				{
					new( -w.Size.x * 0.5f, -w.Size.y * 0.5f ), new( w.Size.x * 0.5f, -w.Size.y * 0.5f ),
					new( w.Size.x * 0.5f, w.Size.y * 0.5f ), new( -w.Size.x * 0.5f, w.Size.y * 0.5f ),
				};

			var model = DebrisMesh.Build( footprint, Height, material );
			if ( model is null ) continue;

			var top = w.Position.z + w.Size.z * 0.5f;
			var go = Scene.CreateObject();
			go.Name = "Lava haze";
			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 = w.Position.WithZ( top );
			go.WorldRotation = Rotation.FromYaw( w.Yaw );

			var r = go.Components.Create<ModelRenderer>();
			r.Model = model;
			r.RenderType = ModelRenderer.ShadowRenderType.Off;
			if ( r.SceneObject.IsValid() )
			{
				r.SceneObject.Attributes.Set( "g_flHazeBase", top );
				r.SceneObject.Attributes.Set( "g_flHazeHeight", Height );
			}

			_slabs.Add( go );
		}

		if ( _slabs.Count > 0 ) Log.Info( $"[nz-lava] heat haze over {_slabs.Count} lava pool(s), {Height:0}u deep" );
	}

	/// <summary>
	/// `nz_lava_haze [0|1]` — the heat haze over the lava: `0` takes it away and `1` brings it back (this session). Bare: how many
	/// pools it stands over.
	/// </summary>
	[ConCmd( "nz_lava_haze" )]
	public static void HazeCmd( string what = "" )
	{
		if ( what == "0" ) On = false;
		if ( what == "1" ) On = true;

		var m = Ensure();
		if ( !m.IsValid() ) { Log.Warning( "[nz-lava] no scene" ); return; }

		m.Rebuild();
		Log.Info( $"[nz-lava] heat haze {(On ? "on" : "OFF")} · over {m.Slabs} pool(s) · the map's scale"
			+ $" {ActiveConfig.Current?.Gameplay?.LavaHaze ?? 0f:0.##} (Settings → Gameplay → Lava haze)" );
	}

	/// <summary>
	/// `nz_lava_look [living|classic]` — the lava's own look: every lava wall drawn in game on the living lava
	/// (`lava_living.vmat`: plates, flow, heat, hot spots) or the classic (`lava.vmat`), in the config in memory — `nz_save` keeps
	/// it. The walls are built again at once; the rising lava takes it the next time it rises. Bare: what each wall wears.
	/// </summary>
	[ConCmd( "nz_lava_look" )]
	public static void LookCmd( string look = "" )
	{
		var walls = ActiveConfig.Current?.DamageWalls;
		if ( walls is null ) { Log.Warning( "[nz-lava] no config" ); return; }

		var want = look.Trim().ToLowerInvariant() switch
		{
			"living" or "new" => LivingLava,
			"classic" or "old" => ClassicLava,
			_ => null,
		};

		var n = 0;
		for ( var i = 0; i < walls.Count; i++ )
		{
			var w = walls[i];
			if ( !IsLava( w ) ) continue;

			if ( want is not null && w.Material != want )
			{
				w.Material = want;
				n++;
			}

			Log.Info( $"[nz-lava] damage wall #{i}: {w.Material}" );
		}

		if ( want is null )
		{
			Log.Info( "[nz-lava] nz_lava_look living | classic" );
			return;
		}

		if ( n > 0 )
		{
			ActiveConfig.NotifyChanged();
			DamageWallManager.Ensure( Game.ActiveScene )?.Rebuild();
			Ensure()?.Rebuild();
		}

		Log.Info( $"[nz-lava] the lava is {(want == LivingLava ? "LIVING" : "CLASSIC")} now ({n} wall(s) changed) · `nz_save` keeps it" );
	}
}