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.
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 | <min> <max> | feel <level> [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.##}" );
}
}