UI/AmbientTremor.cs

Ambient tremor manager for the NZombies game. Hosts a periodic randomized timer (5–15 minutes by config) that, when due, signals all clients (via NZNet.TremorNow) to play a softer camera shake, a reduced rumble sound, and spawn ceiling dust locally. Provides tunable backing fields, host Tick scheduling, a PlayHere routine for local effects, and a console command to inspect/control behavior.

NetworkingFile Access
using Sandbox;
using System;
using System.Globalization;

namespace NZombies;

/// <summary>
/// THE MAP'S OWN TREMORS — every 5 to 15 minutes of a game, rolled again each time, the ground shakes and the ceiling sheds its
/// dust, much softer than the spawn-in's: *"for flavor i want a random timer from 5-15 minutes that once its over shakes the map
/// again with dust from the cieling, but shakes a lot softer than this spawn one — every time the timer ends its random again
/// between 5-15 minutes"* (2026-09-27).
///
/// ⛔ THE HOST KEEPS THE CLOCK AND EVERY MACHINE FEELS IT AT ONCE. The host rolls the wait and, when it runs out, tells everyone
/// (`NZNet.TremorNow`); each machine then shakes its own player's view, plays the rumble and sheds the dust over its own
/// player (<see cref="PlayHere"/>) — the spawn-in's own parts (`CameraShake.Rumble`, `SpawnTremor.Cue`, `CeilingDust`), turned
/// down.
///
/// ⚠️ ONLY WHILE A GAME IS ON. The lobby, creative and the game over screen stop the clock, and a new game rolls it afresh. And
/// HELD THROUGH BASALT'S BOSS FIGHT, which has shaking enough of its own: one that comes due then waits until the fight is over.
///
/// ⚠️ THE TUNABLES ARE NULLABLE-BACKED, so a changed default reaches a running editor (INSTRUCTIONS §1, #9).
/// </summary>
public static class AmbientTremor
{
	/// <summary>How hard it shakes the view, as trauma — "a lot softer" than the spawn-in's 0.08: under a degree of roll.</summary>
	public static float Level
	{
		get => _level ?? 0.025f;
		set => _level = value;
	}

	static float? _level;

	/// <summary>How long it lasts, easing in and out.</summary>
	public static float Seconds
	{
		get => _seconds ?? 4f;
		set => _seconds = value;
	}

	static float? _seconds;

	/// <summary>How loud its rumble is, against the spawn-in's.</summary>
	public static float SoundScale
	{
		get => _soundScale ?? 0.45f;
		set => _soundScale = value;
	}

	static float? _soundScale;

	/// <summary>How much dust it shakes down, against the spawn-in's.</summary>
	public static float DustScale
	{
		get => _dustScale ?? 0.5f;
		set => _dustScale = value;
	}

	static float? _dustScale;

	/// <summary>When the next one is due, in game time. HOST — and 0 while no game is on, so the next game rolls it afresh.</summary>
	static float _due;

	static GameplaySettings Cfg => ActiveConfig.Gameplay;

	/// <summary>A wait between the map's two bounds, in seconds — rolled again after every tremor.</summary>
	static float Roll()
	{
		var lo = MathF.Max( 0.1f, MathF.Min( Cfg.TremorMinMinutes, Cfg.TremorMaxMinutes ) );
		var hi = MathF.Max( lo, MathF.Max( Cfg.TremorMinMinutes, Cfg.TremorMaxMinutes ) );
		return Game.Random.Float( lo, hi ) * 60f;
	}

	/// <summary>HOST, every frame (`RoundManager.OnUpdate`): the clock, and the tremor for everyone when it runs out.</summary>
	public static void Tick( RoundManager rm )
	{
		if ( NZGame.IsClient ) return;

		var playing = rm.IsValid() && rm.State is RoundState.Prep or RoundState.Active;
		if ( !playing || !Cfg.Tremors )
		{
			_due = 0f;
			return;
		}

		if ( _due <= 0f )
		{
			_due = Time.Now + Roll();
			Log.Info( $"[nz-tremor] the map's tremors: the first in {(_due - Time.Now) / 60f:0.0} min" );
			return;
		}

		if ( Time.Now < _due || HexPlatforms.FreezesRound ) return;

		_due = Time.Now + Roll();
		Log.Info( $"[nz-tremor] the ground shakes — the next in {(_due - Time.Now) / 60f:0.0} min" );
		NZNet.TremorNow();
	}

	/// <summary>
	/// EVERY MACHINE — `NZNet.TremorNow`: the soft shake, the rumble turned down, and a little dust, over this machine's own
	/// player.
	/// </summary>
	public static void PlayHere()
	{
		CameraShake.Rumble( Level, Seconds );

		var h = NZSound.PlayUi( SpawnTremor.Cue );
		if ( h.IsValid() ) h.Volume *= SoundScale;

		CeilingDust.Shed( Seconds, DustScale );
	}

	static bool Number( string s, out float v ) => float.TryParse( s, NumberStyles.Float, CultureInfo.InvariantCulture, out v );

	/// <summary>
	/// `nz_tremor_ambient [now | on | off | &lt;min&gt; &lt;max&gt; | feel &lt;level&gt; [seconds]]` — the map's tremors. Bare, when the
	/// next is due. `now` shakes everyone at once (HOST). `on` / `off`. Two numbers are the wait's bounds, in minutes (the
	/// config in memory — `nz_save` keeps them). `feel` plays one here, retuned (for this session).
	/// </summary>
	[ConCmd( "nz_tremor_ambient" )]
	public static void Cmd( params string[] args )
	{
		var g = Cfg;
		var a = args is { Length: > 0 } ? args[0].Trim().ToLowerInvariant() : "";

		switch ( a )
		{
			case "":
				break;

			case "now":
				if ( NZGame.IsClient ) { Log.Warning( "[nz-tremor] the host keeps the clock — run it on the host" ); return; }
				if ( _due > 0f ) _due = Time.Now + Roll();
				NZNet.TremorNow();
				Log.Info( "[nz-tremor] the ground shakes, for everyone" );
				break;

			case "on":
			case "1":
				g.Tremors = true;
				break;

			case "off":
			case "0":
				g.Tremors = false;
				_due = 0f;
				break;

			case "feel":
				if ( args.Length > 1 && Number( args[1], out var level ) ) Level = level;
				if ( args.Length > 2 && Number( args[2], out var seconds ) ) Seconds = seconds;
				PlayHere();
				break;

			default:
				if ( args.Length >= 2 && Number( args[0], out var lo ) && Number( args[1], out var hi ) )
				{
					g.TremorMinMinutes = lo;
					g.TremorMaxMinutes = hi;
					_due = 0f;
				}
				else
				{
					Log.Info( "[nz-tremor] nz_tremor_ambient [now | on | off | <min> <max> | feel <level> [seconds]]" );
					return;
				}
				break;
		}

		Log.Info( $"[nz-tremor] {(g.Tremors ? "on" : "OFF")} · every {g.TremorMinMinutes:0.#}–{g.TremorMaxMinutes:0.#} min, rolled"
			+ $" again each time · {(_due > 0f ? $"the next in {(_due - Time.Now) / 60f:0.0} min" : "no clock running (no game on)")}"
			+ $" · shake {Level:0.000} for {Seconds:0.#}s, sound x{SoundScale:0.##}, dust x{DustScale:0.##}" );
	}
}