Effects/MapTremor.cs

Map tremor and lighting effects. Contains MapTremor which exposes a global Level based on camera rumble, platform quakes, and a local test flicker command, TremorFlicker which computes per-light dropout timing and depth based on tremor intensity, and PowerWave which simulates a power-on wave that warms/stutters lights across the map.

Process ExecutionFile Access
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// HOW HARD THE GROUND SHAKES, as this machine feels it — for the lights that flicker when the map shakes: *"when the map shakes
/// the strips flicker asyncronously — when i say strips i mean any light from the hexagons"* (2026-09-28).
///
/// ⛔ TREMORS ONLY, NEVER A PUNCH. Two things shake the ground, and every tremor goes through one of them:
/// - the held rumble (`CameraShake.Rumble`): the spawn-in, the power, the map's own tremors and the Easter egg's roars;
/// - a quake (`HexPlatforms.StartQuake`): the boss fight's, and the roars' violent ones.
/// A gun's kick, a blast or a heavy footstep punches the view (`CameraShake.Punch`) and flickers nothing.
///
/// ⚠️ EACH MACHINE, ITS OWN. Every tremor reaches every machine already, so nothing here is sent.
/// </summary>
public static class MapTremor
{
	/// <summary>The ground's shaking now, as trauma (0-1): the held rumble or the quake, whichever is the stronger.</summary>
	public static float Level => MathF.Max( MathF.Max( CameraShake.RumbleNow, HexPlatforms.QuakeNow ), TestLevel );

	static float _testLevel, _testUntil;

	/// <summary>`nz_flicker`'s pretend shaking, for its seconds: the lights flicker with no tremor at all.</summary>
	static float TestLevel => RealTime.Now < _testUntil ? _testLevel : 0f;

	/// <summary>
	/// `nz_flicker [level] [seconds]` — make the lights flicker as if the ground shook this hard (0.025 is the map's own tremor,
	/// 0.1 the power's, 0.3 the third roar's, 1 a quake), for these seconds, with no shaking of the view. On this machine only.
	/// </summary>
	[ConCmd( "nz_flicker" )]
	public static void Cmd( float level = 0.2f, float seconds = 4f )
	{
		_testLevel = Math.Clamp( level, 0f, 1f );
		_testUntil = RealTime.Now + MathF.Max( 0f, seconds );
		Log.Info( $"[nz-light] flickering as if the ground shook at {_testLevel:0.###} for {seconds:0.#}s · strips: "
			+ $"{StripLights.Instance?.Fixtures ?? 0} fixture(s) · lights: {MapLightManager.Instance?.Live.Count ?? 0}"
			+ $" · the map's flicker x{ActiveConfig.Current?.Gameplay?.LightFlicker ?? 0f:0.##}" );
	}
}

/// <summary>
/// ONE LIGHT'S FLICKER WHILE THE GROUND SHAKES: dropouts on its own random clock — more of them, and longer, the harder it shakes
/// — and now and then a stutter, two in quick succession. Each light has one, so no two flicker together.
///
/// ⚠️ LOCAL AND COSMETIC: two machines' lights need not flicker alike (INSTRUCTIONS: "effects are LOCAL; the event is HOST").
/// </summary>
public sealed class TremorFlicker
{
	float _next, _until, _second, _depth;

	/// <summary>Dropouts a second at this shaking: one every three seconds at the map's own tremor, fourteen at a quake.</summary>
	static float Rate( float level ) => 0.3f + 14f * level;

	/// <summary>How lit it is now, 0-1 — 1 with the ground still, or between dropouts.</summary>
	public float Lit( float level )
	{
		var now = RealTime.Now;
		if ( level < 0.005f )
		{
			_next = 0f;
			return 1f;
		}

		// ⚠️ THE FIRST DROPOUT ON ITS OWN CLOCK TOO, or every light would drop the moment the shaking began
		if ( _next <= 0f ) _next = now + Wait( level );

		if ( now >= _next && now >= _until )
		{
			_until = now + Game.Random.Float( 0.04f, 0.12f ) * (1f + 1.5f * level);
			_depth = Game.Random.Float( 0f, 0.25f );
			_second = Game.Random.Float( 0f, 1f ) < 0.35f ? _until + Game.Random.Float( 0.03f, 0.07f ) : 0f;
			_next = _until + Wait( level );
		}

		if ( now < _until ) return _depth;
		if ( _second > 0f && now >= _second && now < _second + 0.05f ) return _depth;
		return 1f;
	}

	static float Wait( float level ) => -MathF.Log( MathF.Max( 1e-4f, 1f - Game.Random.Float( 0f, 1f ) ) ) / Rate( level );
}

/// <summary>
/// THE POWER COMING ON, ACROSS THE MAP: the lights that wait for it come on in a wave from the switch, each one stuttering as it
/// warms, like a tube starting — not all at once. Read by the strips (`StripLights`) and the placed lights (`MapLightManager`).
///
/// ⚠️ ONLY WHEN THE POWER IS SEEN TO COME ON. A machine that finds it on already (a joiner, a map with no switch) has every
/// light lit, with no wave. ⚠️ ONLY IN A GAME: in Creative and the lobby a light that waits for the power is lit, so a map can
/// be worked on with its lights.
/// </summary>
public static class PowerWave
{
	/// <summary>How fast the wave runs, in units a second: basalt's far end, 10,000 units off, lights about four seconds on.</summary>
	public static float Speed
	{
		get => _speed ?? 2600f;
		set => _speed = value;
	}

	static float? _speed;

	/// <summary>How long a light stutters as it comes on, in seconds.</summary>
	const float WarmUp = 0.55f;

	static bool _seen, _wasOn, _live;
	static float _start;
	static Vector3 _origin;

	/// <summary>Are the lights that wait for the power to be dark now? In a game, with the power off.</summary>
	public static bool Dark => InGame && !Power.IsOn;

	static bool InGame => NZGame.Mode is GameMode.Survival or GameMode.Spectator;

	/// <summary>Every frame, from each reader: notice the power coming on (the wave) or going off (a new game).</summary>
	public static void Sync()
	{
		var on = !Dark;
		if ( _seen && on == _wasOn ) return;

		if ( on && _seen ) Begin();
		else _live = false;

		_seen = true;
		_wasOn = on;
	}

	/// <summary>The wave, from now: from the power switches' middle, or from this machine's player on a map with none.</summary>
	public static void Begin()
	{
		var switches = ActiveConfig.Current?.PowerSwitches;
		_origin = switches is { Count: > 0 }
			? switches.Aggregate( Vector3.Zero, ( s, p ) => s + p.Position ) / switches.Count
			: NZPlayer.Local?.WorldPosition ?? Vector3.Zero;
		_start = RealTime.Now;
		_live = true;
	}

	/// <summary>
	/// How lit a light here is as the wave passes, 0-1: dark before it arrives, a stutter as it warms, then lit. `seed` is the
	/// light's own, so its arrival and its stutter are its own.
	/// </summary>
	public static float Lit( Vector3 at, int seed )
	{
		if ( !_live ) return 1f;

		var h = Hash( seed );
		var t = RealTime.Now - _start - (at.Distance( _origin ) / MathF.Max( 1f, Speed ) + 0.35f * h);
		if ( t < 0f ) return 0f;
		if ( t >= WarmUp ) return 1f;

		// ⚠️ A TUBE STARTING: a blink, dark, a blink, dark, then on dim and rising — its times stretched or squeezed by its seed
		var s = 0.7f + 0.6f * Hash( seed * 7 + 3 );
		var k = t / s;
		if ( k < 0.06f ) return 0.9f;
		if ( k < 0.17f ) return 0f;
		if ( k < 0.23f ) return 0.7f;
		if ( k < 0.36f ) return 0f;
		return MathF.Min( 1f, 0.45f + 0.55f * (k - 0.36f) / 0.64f );
	}

	/// <summary>A fixed 0-1 from a seed.</summary>
	static float Hash( int seed )
	{
		unchecked
		{
			var x = (uint)seed * 2654435761u;
			x ^= x >> 13;
			x *= 1274126177u;
			return (x & 0xFFFFFF) / (float)0x1000000;
		}
	}

	/// <summary>`nz_strips wave`: the power coming on again, to see it.</summary>
	public static void Replay() => Begin();
}