Rounds/SpecialFog.cs

A Scene Component that implements the special round fog effect. It creates or reuses a GradientFog component, linearly fades its alpha over time, and enables/disables it based on a target set by SetSpecial or the console command nz_fog.

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

namespace NZombies;

/// <summary>
/// SPECIAL ROUND FOG — the mist that rolls in for a dog round.
///
/// Ported from the original's `round/cl_fog.lua`, which is small enough to be
/// quoted in full by its numbers. With no `edit_color` entity on the map it uses
/// these defaults, and the ONLY difference between a normal round and a special
/// one is the density:
///
///     normal   start 50  end 1000  density 0.0  colour (0.4, 0.7, 0.8)
///     special  start 50  end 1000  density 0.9  colour (0.4, 0.7, 0.8)
///
/// ⚠️ SO THE FOG IS NOT "BLUER ON A DOG ROUND", IT IS THE SAME COLOUR TURNED UP
/// FROM NOTHING. Normal rounds have no fog at all — the colour is only ever seen
/// on a special round. Anyone tuning this should move the density, not the hue.
///
/// ⚠️ `fadetime = 5` — a five second LINEAR fade, via
/// `math.Approach(fade, 1, FrameTime()/fadetime)`. Not an ease. The mist visibly
/// rolling in is the tell that a dog round has begun, and it is meant to land at
/// about the same time as the first hound.
/// </summary>
public sealed class SpecialFog : Component
{
	/// <summary>Seconds to fade fully in or out. The original's `fadetime`.</summary>
	public static float FadeTime { get; set; } = 5f;

	/// <summary>Density at full strength — the original's special `fogdensity`.
	/// Carried as the fog COLOUR'S ALPHA in s&box, which has no separate density
	/// field on GradientFog.</summary>
	public static float Density { get; set; } = 0.9f;

	/// <summary>`fogcolor` — Vector(0.4, 0.7, 0.8), a pale blue-grey.</summary>
	public static Color Tint { get; set; } = new( 0.4f, 0.7f, 0.8f );

	public static float StartDistance { get; set; } = 50f;
	public static float EndDistance { get; set; } = 1000f;

	/// <summary>How far up the fog reaches.
	///
	/// ⚠️ NOT FROM THE ORIGINAL — Source's fog is pure distance fog with no
	/// vertical term, while s&box's GradientFog always has one. Set high enough
	/// that the vertical falloff never becomes the visible edge, which is what
	/// makes it behave like the flat fog being ported.</summary>
	public static float Height { get; set; } = 4096f;

	/// <summary>Where the fade is going: 1 while a special round is on.</summary>
	float _target;

	/// <summary>Where it is now. Drives the alpha.</summary>
	float _current;

	GradientFog _fog;

	/// <summary>Roll the fog in or out. Idempotent — calling it with the value it
	/// already has does nothing, so it is safe to call every round.</summary>
	public void SetSpecial( bool on ) => _target = on ? 1f : 0f;

	/// <summary>True once the fog has fully arrived.</summary>
	public bool FullyIn => _current >= 0.999f;

	protected override void OnUpdate()
	{
		// ⛔ GetOrCreate FROM OnUpdate, NOT OnStart. A component created in
		// OnStart is destroyed by a hotload and never comes back, so the fog
		// would silently stop working after any code edit — the exact failure
		// PowerupMusic had. This is idempotent and self-healing.
		_fog ??= Components.GetOrCreate<GradientFog>();
		if ( !_fog.IsValid() ) return;

		// Linear, matching math.Approach — see the note at the top.
		_current = _current.Approach( _target, Time.Delta / MathF.Max( 0.01f, FadeTime ) );

		// ⚠️ DISABLED AT ZERO rather than left running at alpha 0. A fog that is
		// merely transparent still costs a full-screen pass every frame, and a
		// map with its own atmosphere should get its look back untouched between
		// special rounds rather than sharing the field with ours.
		if ( _current <= 0.001f )
		{
			_fog.Enabled = false;
			return;
		}

		_fog.Enabled = true;
		_fog.StartDistance = StartDistance;
		_fog.EndDistance = EndDistance;
		_fog.Height = Height;
		_fog.Color = Tint.WithAlpha( Density * _current );
	}

	/// <summary>Report and drive the fog: `nz_fog [0|1]`.</summary>
	[ConCmd( "nz_fog" )]
	public static void Cmd( string on = "" )
	{
		var fog = Game.ActiveScene?.GetAllComponents<SpecialFog>().FirstOrDefault();
		if ( !fog.IsValid() )
		{
			Log.Warning( "[nz-fog] no SpecialFog — it is created by the round manager "
				+ "(nz_round_start)" );
			return;
		}

		if ( !string.IsNullOrWhiteSpace( on ) )
			fog.SetSpecial( on is "1" or "true" or "on" );

		Log.Info( $"[nz-fog] {fog._current:0.00} -> {fog._target:0} · "
			+ $"density {Density:0.##}  tint {Tint}  {StartDistance:0}-{EndDistance:0}  "
			+ $"fade {FadeTime:0.#}s  renderer {(fog._fog.IsValid() && fog._fog.Enabled ? "on" : "off")}" );
	}
}