Effects/FogAreaManager.cs

Manager component that controls scene fog areas. It finds the strongest FogArea around the local player, cross-fades a global GradientFog component (color, height, distances) and optionally builds stacked translucent sheet geometry so authored fog volumes are visible from outside.

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

namespace NZombies;

/// <summary>
/// Cross-fades the scene's fog to whatever area the listener is standing in.
///
/// ⛔ THERE IS ONE FOG IN THE SCENE, NOT ONE PER AREA, AND THAT IS THE ENGINE'S DOING.
/// `VolumetricFogVolume` is the component built to hang fog inside a box, and on the scene path it
/// renders NOTHING — tested with Bounds set and Strength at 25, with and without a
/// `VolumetricFogController`, and the frame came back pixel-identical. Its own controller explains
/// it: the thing exists "to fetch the baked fog texture FROM THE MAP FILE". Volumetric fog joins
/// lightmaps and shadow baking on the list of map-compile features this project cannot reach.
///
/// `GradientFog` does work, and it is global. So the areas vote on what the one global fog looks
/// like, weighted by how deep inside you are — and that alone was invisible from outside, which is
/// the limitation this file carried from the day it was written.
///
/// ⛔ SO EACH AREA ALSO GETS GEOMETRY NOW, AND THAT IS WHAT MAKES IT VISIBLE FROM OUTSIDE. Stacked
/// flat sheets across the drawn footprint, shaded by `lavafog.shader` — the same layered trick the
/// lava fog proved out, for the same reason: a hollow box only has surfaces at its edges, so a
/// volume has to be SAMPLED at many heights rather than enclosed once.
///
/// ⚠️ THE TWO HALVES DO DIFFERENT JOBS AND BOTH ARE NEEDED. The sheets are the bank you see across
/// the room; the GradientFog is what closes in once you are inside it, which no amount of geometry
/// gives you because you cannot draw sheets between the camera and its own near plane. Screen-space
/// for being in it, geometry for looking at it — the same split the ash settled into.
///
/// ⚠️ SO THE COST IS FLAT, WHICH IS THE POINT. One component, a handful of floats lerped ten times
/// a second, and a distance check per area. Twenty fog areas cost the same to render as one, and
/// none of them cost anything when you are not in them. On a map that has to stay smooth, that is
/// the whole reason this shape was chosen rather than worked around.
///
///     # MAPPORT: fog placeable
/// </summary>
public sealed class FogAreaManager : Component
{
	public static FogAreaManager Instance { get; private set; }

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

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

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

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

	/// <summary>
	/// `nz_fogzone 0` to rule the drawn zones out without clearing anything.
	///
	/// ⛔ NOT `nz_fog`, WHICH WAS TAKEN AND SILENTLY LOST. `SpecialFog` — the round manager's fog —
	/// already registers `nz_fog` as a ConCmd, so this ConVar never registered at all: the engine
	/// logged `Convar nz_fog already exists - not overwriting` once at startup and then every
	/// `nz_fog 0` went to the OTHER system, which answered about SpecialFog. The switch did nothing
	/// and said something plausible while doing it.
	///
	/// ⚠️ THE WARNING IS ONE LINE AT LOAD, AMONG HUNDREDS. Console noise at startup is where a
	/// collision like this hides — it was on screen for weeks before a command that happened to
	/// answer wrongly gave it away.
	/// </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_fogzone" )] public static new bool Enabled { get; set; } = true;

	/// <summary>`nz_fog_visible 0` drops the geometry and leaves only the blended GradientFog.</summary>
	[ConVar( "nz_fog_visible" )] public static bool Visible { get; set; } = true;

	/// <summary>
	/// Sheets per area.
	///
	/// ⛔ THE RESOLUTION OF THE VOLUME AND THE COST. Each sheet is a full-area translucent pass, so
	/// overdraw is this times the zone's screen coverage times the number of zones you can see at
	/// once — and unlike the lava, a map can have many zones. Ten reads as continuous; drop it first
	/// if a foggy room gets heavy.
	/// </summary>
	[ConVar( "nz_fog_layers" )] public static int Layers { get; set; } = 10;

	/// <summary>
	/// How thick each sheet is drawn.
	///
	/// ⚠️ MUCH LOWER THAN IT LOOKS, BECAUSE TEN OF THEM ACCUMULATE. The drawn zones already carry
	/// their own `Density`; this scales it per sheet so the stack lands somewhere sane rather than
	/// ten times too solid.
	/// </summary>
	[ConVar( "nz_fog_sheet" )] public static float SheetDensity { get; set; } = 0.11f;

	/// <summary>
	/// Seconds the fog holds at full strength after you leave a zone, before it starts fading.
	///
	/// ⛔ ON THE WAY OUT ONLY — ENTERING IS STILL IMMEDIATE. A delay in both directions is just a
	/// slower blend, and it would mean walking into a bank and standing in clear air for two seconds
	/// while the haze catches up. Air you have just left staying thick is how fog behaves; air you
	/// have just entered staying clear is not.
	///
	/// ⚠️ IT HOLDS, THEN FADES — it does not slow the fade down. After the hold the area's own
	/// `Blend` runs exactly as before, so the character of the fade is unchanged and only its start
	/// is late.
	/// </summary>
	[ConVar( "nz_fog_hold" )] public static float Hold { get; set; } = 2f;

	/// <summary>
	/// While you are inside ANY zone, the fog never thins — it only holds or gets thicker.
	///
	/// ⛔ BECAUSE A ZONE'S EDGE WEIGHS ALMOST NOTHING, AND THAT IS WHAT MADE IT CLEAR. The weight is
	/// a smoothstep from the boundary inward, so the moment you cross into a new zone its target is
	/// near ZERO — lower than the fog you are already standing in. Walking from one bank straight
	/// into another therefore asked the fog to fade, and the two-second hold only postponed it:
	/// stand near the edge of the second zone and it clears while you are inside fog.
	///
	/// ⚠️ THE TRADE IS THAT A DENSE ZONE DOES NOT THIN OUT INTO A LIGHT ONE UNTIL YOU LEAVE BOTH.
	/// That is the behaviour that was asked for — fog that persists while you are in fog — and it is
	/// the right call for walking a foggy map, but it does mean the zones' densities stop being a
	/// gradient you can walk down. `nz_fog_keep 0` restores the strict per-zone behaviour.
	/// </summary>
	[ConVar( "nz_fog_keep" )] public static bool KeepWhileInside { get; set; } = true;

	TimeSince _sinceFalling;

	public const string SheetMaterial = "materials/nz/lavafog.vmat";

	readonly List<GameObject> _sheets = new();

	/// <summary>How many sheet objects are standing.</summary>
	public int SheetCount => _sheets.Count;

	GradientFog _fog;

	/// <summary>What the blend is currently at — kept so a report can print it.</summary>
	public float Weight { get; private set; }

	/// <summary>
	/// An area that holds the listener wherever they stand, or null: its look at full weight, over every drawn area — and
	/// with it the ash overlay and the ash motes, which both follow <see cref="Weight"/>. Set by basalt's rising lava for
	/// the rounds it is out of its bed (`HexPlatforms.Lava.cs`). LOCAL, like the fog itself.
	/// </summary>
	public static FogArea Forced { get; set; }

	/// <summary>
	/// True while the fog is being held after leaving a zone.
	///
	/// ⚠️ WORTH REPORTING, because during the hold the fog and the weight disagree with where you
	/// are standing — which is the intended behaviour and looks exactly like the blend being stuck.
	/// </summary>
	public bool Holding { get; private set; }
	public Color Tint { get; private set; }
	public float EndDistance { get; private set; }
	public string Inside { get; private set; } = "-";

	TimeSince _sinceTick;

	/// <summary>
	/// The fog component, created next to the manager.
	///
	/// ⚠️ NOT ON THE CAMERA. `NZPostProcess` attaches tonemapping and bloom to `Scene.Camera`
	/// because those are camera effects; `GradientFog` is a world effect and survives the camera
	/// being replaced — which happens on every respawn and every map load.
	/// </summary>
	GradientFog Fog()
	{
		if ( _fog.IsValid() ) return _fog;

		_fog = GameObject.Components.GetOrCreate<GradientFog>();
		return _fog;
	}

	protected override void OnUpdate()
	{
		// Ten times a second. The blend below is time-based, not tick-based, so a coarse tick
		// changes nothing about how smooth the fade looks.
		if ( _sinceTick < 0.1f ) return;

		var dt = (float)_sinceTick;
		_sinceTick = 0f;

		Tick( dt );
	}

	void Tick( float dt )
	{
		var fog = Fog();
		if ( !fog.IsValid() ) return;

		var list = ActiveConfig.Current?.Fog;
		var p = NZPlayer.Local;

		FogArea best = null;
		var bestWeight = 0f;

		if ( Enabled && list is { Count: > 0 } && p.IsValid() )
		{
			var ear = p.WorldPosition;

			foreach ( var a in list )
			{
				var w = WeightAt( a, ear );

				// ⚠️ THE STRONGEST AREA WINS RATHER THAN THE SUM. Two overlapping patches of soot
				// should not be twice as thick as either — that is how a quiet overlap becomes an
				// opaque wall exactly where two authored areas meet, which is the seam a mapper is
				// least likely to look at and most likely to walk through.
				if ( w <= bestWeight ) continue;

				bestWeight = w;
				best = a;
			}
		}

		// ⛔ A FORCED AREA WINS OUTRIGHT, WHEREVER THE LISTENER STANDS — above the loop's result, at full weight
		if ( Forced is not null && Enabled )
		{
			best = Forced;
			bestWeight = 1f;
		}

		// The target: the winning area's look at its weight, or clear air at zero.
		var targetWeight = best is null ? 0f : bestWeight * best.Density.Clamp( 0f, 1f );
		var targetTint = best is null ? Tint : ( Color.Parse( best.Color ) ?? Color.Black );
		// ⛔ THE TOP OF THE DRAWN PRISM, IN WORLD Z. `GradientFog.Height` is the world altitude at
		// which the haze has thinned away, not a thickness above wherever the volume sits — so this
		// has to be an absolute, and an area drawn on a floor at z≈1200 has to say 1200-and-up. An
		// earlier version passed an authored thickness straight through, which on Basalt described a
		// fog column ending a thousand units UNDERGROUND: nothing rendered, nothing errored, and
		// every number in the config read correctly.
		//
		// ⚠️ Now it comes from the shape the mapper actually drew, so there is no second number to
		// keep in step with the volume.
		var targetHeight = best is null
			? 1200f
			: best.Position.z + ( best.Size.z * 0.5f );

		// ⛔ DENSITY IS EXPRESSED AS A DISTANCE, BECAUSE THAT IS WHAT THE COMPONENT TAKES.
		// `GradientFog` has no opacity — it has a start and an end distance, and "thicker" means
		// "the end is nearer".
		//
		// ⛔ AND THE RELATIONSHIP IS INVERSE, NOT LINEAR. The first version mapped 0..1 onto
		// 4000..300 units, which put the default density of 0.18 at an end distance of 3334 — and
		// MEASURED on a row of figures at 200/500/900/1600 units, 3334 is invisible and so is 2150.
		// Fog only began to read at about 900. A linear map spends almost its whole range in the
		// region where nothing is visible, so every density below 0.7 looked identical and the
		// setting felt broken rather than subtle.
		//
		// 160/d is the inverse curve that actually spreads the control out:
		//     0.05 -> 3200u (a suggestion)   0.18 -> 900u (the default, light but present)
		//     0.50 -> 320u  (heavy)          1.00 -> 160u (a wall)
		var targetEnd = targetWeight <= 0.001f
			? 20000f
			: ( 160f / targetWeight ).Clamp( 120f, 20000f );

		// ⛔ THE HOLD PINS THE TARGET, IT DOES NOT SKIP THE UPDATE. Skipping would freeze the TINT and
		// the end distance too, so a zone left while its colour was still crossfading would sit
		// half-way between two colours for the whole hold. Pinning only the weight lets everything
		// else keep resolving.
		var falling = targetWeight < Weight;

		// ⚠️ INSIDE ANY ZONE AT ALL, however weakly. `bestWeight` is the raw containment weight
		// before density is applied, so this is "is the player within some zone's bounds" rather
		// than "is the fog thick" — which is the question that matters here.
		var inside = bestWeight > 0f;

		if ( falling && ( ( KeepWhileInside && inside ) || _sinceFalling < Hold ) )
		{
			targetWeight = Weight;
			Holding = true;
		}
		else
		{
			Holding = false;

			// ⛔ THE TIMER RESETS WHILE INSIDE A ZONE TOO, NOT ONLY WHEN RISING. Otherwise stepping
			// out of a second zone would carry over however much of the hold had already elapsed
			// crossing the first one, and the two-second delay would be whatever was left of it.
			if ( !falling || inside ) _sinceFalling = 0f;
		}

		// Blend time comes from the area being entered, or from the one being left.
		var blend = MathF.Max( 0.05f, best?.Blend ?? 0.75f );
		var t = ( dt / blend ).Clamp( 0f, 1f );

		Weight = Weight.LerpTo( targetWeight, t );
		Tint = Color.Lerp( Tint, targetTint, t );
		EndDistance = EndDistance.LerpTo( targetEnd, t );
		Inside = best is null
			? "-"
			: $"{best.Color} d{best.Density:0.##} {best.Footprint.Count}-sided {best.Size.z:0} tall";

		fog.Color = Tint;
		fog.Height = targetHeight;

		// ⚠️ FLAT 1, AND 2.5 WAS TRIED AND MADE IT WORSE. Pooling the haze toward the floor sounds
		// truer — soot settles — but on a GradientFog whose Height is already the top of a drawn
		// volume, a steep exponent just thins the whole column and the areas read as weaker rather
		// than as lower. Reported plainly: "the fog that existed became worse".
		fog.VerticalFalloffExponent = 1f;
		fog.StartDistance = 0f;
		fog.EndDistance = EndDistance;
		fog.FalloffExponent = 1.6f;

		// ⚠️ DISABLED RATHER THAN SET TO NOTHING WHEN THERE IS NO FOG. A GradientFog with a huge end
		// distance is still a fullscreen pass doing arithmetic that resolves to "unchanged"; a map
		// with no fog areas should pay nothing at all for the feature.
		fog.Enabled = Weight > 0.002f;
	}

	/// <summary>
	/// How much say this area has at a point — 1 well inside it, falling to 0 at the boundary.
	///
	/// ⛔ MEASURED INWARD FROM EVERY FACE, INCLUDING THE CAPS. The nearest boundary might be a wall
	/// of the polygon, the top of the prism or its floor, and only the smallest of those describes
	/// how deep inside you actually are. Using the polygon alone would snap the haze on and off as
	/// you step over the top edge of an area drawn around a walkway.
	///
	/// ⚠️ SMOOTHSTEP, NOT LINEAR. A linear ramp has a visible corner at both ends, and on a fog
	/// colour that reads as the fog "starting" at a particular step rather than gathering.
	/// </summary>
	public static float WeightAt( FogArea a, Vector3 point )
	{
		var local = a.Rotation.Inverse * ( point - a.Position );
		var half = a.Size.z * 0.5f;

		// Outside the prism vertically.
		if ( local.z < -half || local.z > half ) return 0f;

		// ⛔ THE TOP FEATHERS AND THE FLOOR DOES NOT, AND FEATHERING BOTH WAS WRONG. An area is drawn
		// standing ON a floor, so its lower cap sits at the surface the player walks on — and fading
		// from it meant the haze was at its THINNEST exactly at eye height. Measured: an area 440
		// tall drawn at a spawn gave a weight of 0.104 to a player standing in the middle of it,
		// which reads as the feature barely working.
		//
		// ⚠️ The floor is a hard boundary because it is a floor; there is no "just below" to fade
		// into. The top still fades, because ash genuinely does thin out as it rises.
		var dz = half - local.z;

		var xy = new Vector2( local.x, local.y );
		float dEdge;

		if ( a.HasFootprint )
		{
			if ( !InPolygon( xy, a.Footprint ) ) return 0f;
			dEdge = DistanceToEdge( xy, a.Footprint );
		}
		else
		{
			// ⚠️ A DRAWN AREA ALWAYS HAS A FOOTPRINT; THIS IS FOR ONE BUILT BY HAND. `nz_fog_set`
			// can only reach an existing entry, but a config edited by hand can carry a Size with
			// no outline, and falling through to "no fog" would look like the area being ignored.
			var hx = ( a.Size.x * 0.5f ) - MathF.Abs( local.x );
			var hy = ( a.Size.y * 0.5f ) - MathF.Abs( local.y );

			if ( hx <= 0f || hy <= 0f ) return 0f;
			dEdge = MathF.Min( hx, hy );
		}

		var feather = MathF.Max( 1f, a.Feather );
		var x = ( MathF.Min( dEdge, dz ) / feather ).Clamp( 0f, 1f );

		return x * x * ( 3f - 2f * x );
	}

	/// <summary>
	/// Crossing-number point-in-polygon.
	///
	/// ⚠️ WORKS FOR CONCAVE SHAPES, which matters: the block builder explicitly allows a non-convex
	/// footprint, so a convex-only test would quietly mis-handle exactly the shapes this is for.
	/// The `(a.y > y) != (b.y > y)` form counts each edge once at a shared vertex, so a point level
	/// with a corner is not double-counted and reported outside. Same test as
	/// `DamageWallVolume.InPolygon` — deliberately, because a fog area and a damage wall are drawn
	/// by the same tool and must agree about what "inside" means.
	/// </summary>
	static bool InPolygon( Vector2 pt, IReadOnlyList<Vector2> poly )
	{
		if ( poly is null || poly.Count < 3 ) return false;

		var inside = false;

		for ( int i = 0, j = poly.Count - 1; i < poly.Count; j = i++ )
		{
			Vector2 a = poly[i], b = poly[j];

			if ( ( a.y > pt.y ) != ( b.y > pt.y )
				&& pt.x < ( b.x - a.x ) * ( pt.y - a.y ) / ( b.y - a.y ) + a.x )
			{
				inside = !inside;
			}
		}

		return inside;
	}

	/// <summary>Shortest distance from an interior point to the outline.</summary>
	static float DistanceToEdge( Vector2 pt, IReadOnlyList<Vector2> poly )
	{
		var best = float.MaxValue;

		for ( int i = 0; i < poly.Count; i++ )
		{
			var a = poly[i];
			var b = poly[( i + 1 ) % poly.Count];

			var ab = b - a;
			var len2 = ab.LengthSquared;

			// A degenerate edge — two corners clicked in the same place — would divide by zero.
			var t = len2 <= 0.0001f ? 0f : ( ( pt - a ).Dot( ab ) / len2 ).Clamp( 0f, 1f );

			best = MathF.Min( best, pt.Distance( a + ab * t ) );
		}

		return best;
	}



	/// <summary>Drop the blend so a rebuild does not fade from the old map's fog.</summary>
	public void Rebuild()
	{
		Weight = 0f;
		EndDistance = 20000f;

		if ( _fog.IsValid() ) _fog.Enabled = false;

		BuildSheets();

		var n = ActiveConfig.Current?.Fog?.Count ?? 0;

		Log.Info( $"[nz-fog] {n} fog area(s), {SheetCount} sheet(s)"
			+ ( n == 0 ? "" : $" — visible from outside, {Layers} per area" ) );
	}

	/// <summary>
	/// The geometry that makes a zone visible from across the room.
	///
	/// ⚠️ ONE MESH PER AREA, REUSED BY ITS SHEETS. `DebrisMesh.Build` triangulates the footprint,
	/// which is real work on a many-sided outline — building it ten times per zone would be ten
	/// times the cost for identical vertices.
	/// </summary>
	void BuildSheets()
	{
		foreach ( var g in _sheets ) g?.Destroy();
		_sheets.Clear();

		var list = ActiveConfig.Current?.Fog;
		if ( !Enabled || !Visible || list is null || list.Count == 0 ) return;

		var mat = Material.Load( SheetMaterial );

		if ( mat is null )
		{
			Log.Warning( $"[nz-fog] '{SheetMaterial}' did not load — zones will only show from inside" );
			return;
		}

		var n = Layers.Clamp( 1, 48 );

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

			if ( !a.HasFootprint || a.Density <= 0.001f ) continue;

			var model = DebrisMesh.Build( a.Footprint, 2f, mat );
			if ( model is null ) continue;

			// The prism runs from z=0 up and the area's origin is its middle — the convention the
			// wall tools set, and the one `WeightAt` already assumes.
			var baseZ = a.Position.z - ( a.Size.z * 0.5f );
			var tint = Color.Parse( a.Color ) ?? Color.White;

			var root = Scene.CreateObject();
			root.Name = $"Fog Sheets {i}";
			root.Flags |= GameObjectFlags.NotSaved;
			root.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
			root.SetParent( GameObject );
			root.WorldPosition = a.Position.WithZ( baseZ );
			root.WorldRotation = a.Rotation;

			for ( int s = 0; s < n; s++ )
			{
				var frac = n == 1 ? 0f : (float)s / ( n - 1 );

				var sheet = Scene.CreateObject();
				sheet.Name = $"sheet {s}";
				sheet.Flags |= GameObjectFlags.NotSaved;
				sheet.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
				sheet.SetParent( root );
				sheet.LocalPosition = Vector3.Up * ( frac * a.Size.z );
				sheet.LocalRotation = Rotation.Identity;

				var mr = sheet.Components.Create<ModelRenderer>();
				mr.Model = model;

				// ⛔ PER-RENDERER, BECAUSE ONE MATERIAL SERVES EVERY ZONE ON THE MAP. A material
				// parameter would give them all whichever zone's colour and height were written
				// last — and zones differ in both by design.
				if ( mr.SceneObject.IsValid() )
				{
					mr.SceneObject.Attributes.Set( "g_flLavaFogBase", baseZ );
					mr.SceneObject.Attributes.Set( "g_flLavaFogHeight", a.Size.z );
					mr.SceneObject.Attributes.Set( "g_vLavaFogTint", new Vector3( tint.r, tint.g, tint.b ) );
					mr.SceneObject.Attributes.Set( "g_flLavaFogDensity",
						( a.Density * SheetDensity ).Clamp( 0f, 1f ) );
				}
			}

			_sheets.Add( root );
		}
	}

	protected override void OnDestroy()
	{
		// ⚠️ The sheets are children of this object and go with it, but the list must not survive
		// into a new manager holding destroyed handles.
		_sheets.Clear();

		if ( _fog.IsValid() ) _fog.Enabled = false;
		if ( Instance == this ) Instance = null;
	}
}