EasterEgg/HexPlatforms.Lava.cs

HexPlatforms partial class implementing the "rising lava" Easter egg. It manages host state (phases: warning, rising, up, sinking, done), networking mirroring, local drawing of a lava slab and fog, camera quake, kill logic for local players when feet are below the shown lava level, console commands to control and inspect the system, and lifecycle tied to rounds.

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

namespace NZombies;

/// <summary>
/// BASALT — THE RISING LAVA (2026-09-26). The Mastermind solved, the lava rises: thirty seconds' warning, then it floods
/// everything below the height the user marked with a 1911 wall buy, and stays there while three more rounds are fought;
/// when the third ends it sinks back into its bed. Asked for as *"competing this step makes the lava rise up for 3 rounds,
/// up to the height of the 1911 wallbuy i just placed — the players get 30 seconds to get to safety and then they must
/// survive the 3 rounds, after that the lava lowers down again — this lava insta kills any player that touches it, but
/// does not affect zombies or their navigation"*.
///
/// ⛔ THE FLOOD IS ONE LEVEL OVER THE WHOLE MAP, NOT THE BED'S FOOTPRINT. Basalt's lava is one damage wall (the config's
/// wall drawn in `lava.vmat`, its top at 1120), and its footprint stops at x -4020, short of the lava room's west end,
/// which is open: a flood cut to it would stand as a wall of lava across that room. So everything below the top is lava —
/// the hex floor, the lava room, tile 1 — while the spawn room and the twin shield's room, at 1696, stay dry.
///
/// ⛔ IT KILLS, IT DOES NOT HURT. A player whose feet are below it goes down and bleeds out at once
/// (`NZPlayer.KillOutright`), and the bleedout's rules decide the rest: back next round while anybody is still up, the
/// run over when nobody is.
///
/// ⚠️ NOTHING SOLID: a drawn surface and a height test, no collider — so zombies walk under it and their navmesh never
/// sees it, as asked, for the reason the damage walls have no body (`DamageWallVolume`).
///
/// ⛔ AND THE MAP FILLS WITH BASALT'S FOG WHILE IT IS OUT OF ITS BED: *"all players must get the lava fog overlay and
/// particles during those 3 rounds"*. The fog areas the user drew give it where they stand — the haze, the ash on the
/// screen and the ash motes; for the rounds of the flood one of their look holds everyone, everywhere
/// (`FogAreaManager.Forced`).
///
/// ⛔ THE QUAKE IS WHAT RAISES IT, in the story as the user tells it: the Mastermind starts the teleporter's charge, and
/// the shaking brings the lava up. So the solve shakes every screen, violently, easing out over three seconds
/// (<see cref="StartQuake"/>) — with the beast's second roar since 2026-09-28 (`EggFanfare`), three seconds after the clicking.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the PHASE — warning, rising, up, sinking, survived — and the rounds are HOST state, MIRRORED (`NZNet.LavaState`) at
///   every change and to a joiner, with how far into the phase the host is;
/// - the SURFACE is LOCAL: every machine draws it at the height its phase gives, from when it was told;
/// - the KILL is the BODY OWNER'S: each machine tests its own player against its own surface, as every down happens where
///   the body is owned (`NZNet.HurtPlayer`).
/// </summary>
public sealed partial class HexPlatforms
{
	// ══ where and when ══════════════════════════════════════════════════════════════════════

	/// <summary>Its phases: not begun; the thirty seconds' warning; rising; up; sinking; survived, back in its bed.</summary>
	const int LavaNone = 0, LavaWarning = 1, LavaRising = 2, LavaUp = 3, LavaSinking = 4, LavaDone = 5;

	static float? _lavaTop;
	/// <summary>
	/// How high it rises: 1315.7, where the user's 1911 wall buy hung, on the hex floor's south wall at (-340.5, -964.8), on
	/// 2026-09-26. `nz_hex_lava_top` moves it, on this machine and until a restart.
	/// </summary>
	public static float LavaTop { get => _lavaTop ?? 1315.7f; set => _lavaTop = value; }

	/// <summary>The warning, the rise and the sinking, in seconds.</summary>
	const float LavaWarnSeconds = 30f, LavaRiseSeconds = 10f, LavaSinkSeconds = 10f;

	/// <summary>How many rounds after the one it rose in it stays up: it sinks as the third of them ends.</summary>
	const int LavaRounds = 3;

	/// <summary>
	/// Where the flood lies, flat: wider than the map on every side — the bed runs x -4020 to 3565 and y -2838 to 2375, the
	/// west rooms out past x -5400 — so nowhere below the top is dry for being outside it.
	/// </summary>
	static Vector2 LavaMin => new( -6000f, -3400f );
	static Vector2 LavaMax => new( 4200f, 3000f );

	/// <summary>
	/// The lava's own bed: the config's damage wall drawn in lava — its top the flood's floor, its material and tint the
	/// flood's look. Null if this map's config has none.
	/// </summary>
	static DamageWall LavaBed => ActiveConfig.Current?.DamageWalls?.FirstOrDefault( w => w is not null && w.VisibleInGame
		&& (w.Material ?? "").Contains( "lava", StringComparison.OrdinalIgnoreCase ) );

	/// <summary>Where the flood rises from and sinks back to: the bed's top, 1120 on basalt; 1120 too when there is no bed.</summary>
	static float LavaBedTop => LavaBed is DamageWall w ? w.Position.z + w.Size.z * 0.5f : 1120f;

	const string LavaMaterialFallback = "materials/nz/lava.vmat";

	/// <summary>How high the flood's fog reaches: over the upper floors at 1696, and the ceilings above them.</summary>
	const float LavaFogTop = 2200f;

	/// <summary>
	/// The fog the flood brings: the look of the fog areas the user drew on basalt — the first of them; all four are one look —
	/// holding everyone wherever they stand, up to <see cref="LavaFogTop"/>. The areas' own default look if the map has none.
	/// </summary>
	static FogArea LavaFog()
	{
		var look = ActiveConfig.Current?.Fog?.FirstOrDefault( a => a is not null ) ?? new FogArea();
		return new FogArea
		{
			Position = new Vector3( 0f, 0f, LavaFogTop * 0.5f ),
			Size = new Vector3( 1f, 1f, LavaFogTop ),
			Color = look.Color,
			Density = look.Density,
			Blend = look.Blend,
		};
	}

	// ══ the state ════════════════════════════════════════════════════════════════════════════

	/// <summary>HOST — the phase, how long it has run, and the round the lava was set rising in.</summary>
	int _lava;
	TimeSince _lavaSince;
	int _lavaRoseRound;

	/// <summary>MIRROR — the phase as this machine was told (`NZNet.LavaState`), and how long it has run here.</summary>
	int _lavaShown;
	TimeSince _lavaShownSince;

	/// <summary>LOCAL — the bed's top the surface rises from, read when the phase arrives; and the warning's last ten seconds said.</summary>
	float _lavaFrom = 1120f;
	bool _lavaTenSaid;

	/// <summary>HOST — the phase and how far into it, for `NZNet.PushState` to replay to a joiner.</summary>
	public static (int Phase, float Elapsed) LavaNow => Instance.IsValid() ? (Instance._lava, Instance._lavaSince) : (0, 0f);

	/// <summary>Has the lava been survived, as this machine was told? What the next step asks.</summary>
	public bool LavaDoneShown => _lavaShown == LavaDone;

	void SendLava() => NZNet.LavaState( _lava, _lavaSince, LavaTop );

	/// <summary>A new phase. HOST.</summary>
	void SetLava( int phase )
	{
		_lava = phase;
		_lavaSince = 0;
		SendLava();
	}

	/// <summary>
	/// The lava's phase, and how far into it. EVERY machine — `NZNet.LavaState`: the surface drawn to match, and the news
	/// on the banner when the phase is new.
	/// </summary>
	public void ApplyLava( int phase, float elapsed, float top = 0f )
	{
		// ⚠️ THE HOST'S HEIGHT, which decides where it kills: `nz_hex_lava_top` on the host reaches everyone (the co-op audit,
		// 2026-09-27 — it was each machine's own, and a player the host saw in the lava lived on their own screen)
		if ( NZGame.IsClient && top > 0f ) LavaTop = top;

		var was = _lavaShown;
		_lavaShown = phase;
		_lavaShownSince = elapsed;
		_lavaFrom = LavaBedTop;
		_lavaTenSaid = elapsed >= LavaWarnSeconds - 10f;

		if ( phase != was && !Testing )
		{
			var banner = phase switch
			{
				LavaWarning => $"THE LAVA RISES IN {LavaWarnSeconds:0} SECONDS",
				LavaRising => "THE LAVA RISES",
				LavaSinking => "THE LAVA SINKS",
				_ => "",
			};
			if ( banner != "" ) PowerupBannerState.Show( banner );
		}

		BuildLava();
	}

	/// <summary>
	/// How high the lava stands now, as this machine was told: in its bed until it rises, easing up to the top and later
	/// down again.
	/// </summary>
	float LavaHeightShown
	{
		get
		{
			static float Ease( float t ) { t = Math.Clamp( t, 0f, 1f ); return t * t * (3f - 2f * t); }

			return _lavaShown switch
			{
				LavaRising => _lavaFrom + (LavaTop - _lavaFrom) * Ease( _lavaShownSince / LavaRiseSeconds ),
				LavaUp => LavaTop,
				LavaSinking => LavaTop + (_lavaFrom - LavaTop) * Ease( _lavaShownSince / LavaSinkSeconds ),
				_ => _lavaFrom,
			};
		}
	}

	/// <summary>
	/// The risen lava's level as this machine was told, while it is out of its bed — rising, up or sinking — and null otherwise: where
	/// the lava's embers rise from then (`LavaEmbers`).
	/// </summary>
	public static float? RisenLavaTop => Instance.IsValid() && Instance._lavaShown is (LavaRising or LavaUp or LavaSinking)
		? Instance.LavaHeightShown : null;

	/// <summary>
	/// Would the lava, as this machine was told, take a body standing here? Out of its bed — rising, up or sinking — with
	/// the feet below its surface, anywhere in the map.
	/// </summary>
	bool LavaTakes( Vector3 feet )
	{
		if ( _lavaShown is not (LavaRising or LavaUp or LavaSinking) ) return false;

		var min = LavaMin;
		var max = LavaMax;
		return feet.z < LavaHeightShown && feet.x >= min.x && feet.x <= max.x && feet.y >= min.y && feet.y <= max.y;
	}

	// ══ the host's clock ═════════════════════════════════════════════════════════════════════

	/// <summary>The lava's warning: thirty seconds before it rises. HOST — the Mastermind solved.</summary>
	void StartLava()
	{
		if ( _lava != LavaNone ) return;

		_lavaRoseRound = RoundManager.Instance?.Round ?? 0;
		SetLava( LavaWarning );
		Cue( SmashCue );
		Log.Info( $"[nz-hex] 🌋 THE LAVA WILL RISE in {LavaWarnSeconds:0} seconds, to {LavaTop:0.#} — and sink when round"
			+ $" {_lavaRoseRound + LavaRounds} ends" );
	}

	/// <summary>The warning run out, the rise done, the sinking done. HOST — `OnUpdate`.</summary>
	void WatchLava()
	{
		switch ( _lava )
		{
			case LavaWarning when _lavaSince >= LavaWarnSeconds:
				SetLava( LavaRising );
				Cue( SmashCue );
				Log.Info( $"[nz-hex] 🌋 THE LAVA RISES — to {LavaTop:0.#}, killing whoever it reaches" );
				break;

			case LavaRising when _lavaSince >= LavaRiseSeconds:
				SetLava( LavaUp );
				break;

			case LavaSinking when _lavaSince >= LavaSinkSeconds:
				SetLava( LavaDone );
				Fanfare( 11 );
				Log.Info( "[nz-hex] ✦ THE RISING LAVA IS SURVIVED — back in its bed, and the junctions wake" );

				// …and the next step begins: the junctions (`HexPlatforms.Junctions.cs`)
				StartJunctions();
				break;
		}
	}

	/// <summary>A round was cleared: the third after the one it rose in sinks it. HOST — `RoundEnded`, with the round's number.</summary>
	void LavaRoundEnded( int round )
	{
		if ( _lava != LavaUp || round < _lavaRoseRound + LavaRounds ) return;

		SetLava( LavaSinking );
		Cue( SmashCue );
		Log.Info( $"[nz-hex] 🌋 round {round} is over — the lava sinks" );
	}

	/// <summary>Back to not begun, the lava in its bed. HOST — with the Mastermind, which it follows.</summary>
	void ResetLava()
	{
		// the junctions follow the lava, and go with it
		ResetJunctions();

		if ( _lava == LavaNone && _lavaRoseRound == 0 ) return;

		_lavaRoseRound = 0;
		SetLava( LavaNone );
	}

	// ══ the surface, and the kill ════════════════════════════════════════════════════════════

	/// <summary>LOCAL — the flood's surface: a slab of the bed's lava over the map, one unit tall, stretched up to the level.</summary>
	GameObject _lavaGo;

	/// <summary>
	/// How the flood is drawn: ⚠️ BUMP IT WHEN THAT CHANGES. 1: one slab over the map, 2026-09-26. A manager that has not
	/// built this layout draws it again — `OnUpdate` asks every frame — as the altar's does.
	/// </summary>
	const int LavaLayout = 1;

	/// <summary>LOCAL — the layout this manager last drew the flood in; 0 before it has.</summary>
	int _lavaLaid;

	/// <summary>Say once that the lava's material did not load, not at every rebuild.</summary>
	static bool _lavaMissing;

	/// <summary>The flood as this machine was told, on basalt: drawn while it is out of its bed, gone otherwise. LOCAL.</summary>
	void BuildLava()
	{
		_lavaLaid = LavaLayout;
		if ( !OnBasalt || !Scene.IsValid() || _lavaShown is not (LavaRising or LavaUp or LavaSinking) )
		{
			ClearLava();
			return;
		}

		if ( !_lavaGo.IsValid() ) _lavaGo = MakeLava();
		FogAreaManager.Forced ??= LavaFog();
		MoveLava();
	}

	/// <summary>
	/// The slab: `DebrisMesh`'s prism, as the bed is drawn — the same material and tint, and so the same texture, a repeat
	/// every 128 units — laid over the map, its bottom at the bed's top. <see cref="MoveLava"/> stretches it to the level.
	/// </summary>
	GameObject MakeLava()
	{
		var bed = LavaBed;
		var path = string.IsNullOrWhiteSpace( bed?.Material ) ? LavaMaterialFallback : bed.Material;
		var material = Material.Load( path );
		if ( material is null )
		{
			if ( !_lavaMissing ) Log.Warning( $"[nz-hex] {path} did not load — the risen lava kills, but is not drawn" );
			_lavaMissing = true;
			return null;
		}

		var min = LavaMin;
		var max = LavaMax;
		var half = (max - min) * 0.5f;
		var model = DebrisMesh.Build( new List<Vector2>
		{
			new( -half.x, -half.y ), new( half.x, -half.y ), new( half.x, half.y ), new( -half.x, half.y ),
		}, 1f, material );
		if ( model is null ) return null;

		var go = Scene.CreateObject();
		go.Name = "Basalt's risen lava";
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		go.Tags.Add( PanelTag );
		go.WorldPosition = new Vector3( (min.x + max.x) * 0.5f, (min.y + max.y) * 0.5f, _lavaFrom );

		var r = go.Components.Create<ModelRenderer>();
		r.Model = model;
		r.RenderType = ModelRenderer.ShadowRenderType.Off;
		r.Tint = bed?.Tint ?? Color.White;
		return go;
	}

	/// <summary>The slab stretched from the bed's top to the lava's level now — hidden while it is barely out of its bed. LOCAL.</summary>
	void MoveLava()
	{
		if ( !_lavaGo.IsValid() ) return;

		var depth = LavaHeightShown - _lavaFrom;
		var show = depth > 0.5f;
		if ( _lavaGo.Enabled != show ) _lavaGo.Enabled = show;
		if ( show ) _lavaGo.WorldScale = new Vector3( 1f, 1f, depth );
	}

	/// <summary>The flood gone, and its fog with it. LOCAL.</summary>
	void ClearLava()
	{
		if ( _lavaGo.IsValid() ) _lavaGo.Destroy();
		_lavaGo = null;
		FogAreaManager.Forced = null;
	}

	// ══ the quake ════════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// How long the Mastermind's end shaking lasts, in seconds, and what `nz_hex_quake` tries by default. ⚠️ The shaking itself is
	/// the second roar's now (`EggFanfare.ShakeFor`, 3 s there too).
	/// </summary>
	const float MastermindQuakeSeconds = 3f;

	static float? _quakePeak;
	/// <summary>
	/// How hard a quake starts, as the camera shake's trauma (0-1): 1, its most — *"violently"* — easing to nothing as it
	/// ends. `nz_hex_quake` tries it.
	/// </summary>
	public static float QuakePeak { get => _quakePeak ?? 1f; set => _quakePeak = value; }

	/// <summary>LOCAL — this machine's screen shaking: for how long, since when, and how hard it began.</summary>
	float _quakeFor, _quakeFrom;
	TimeSince _quakeSince;

	/// <summary>How hard this machine's quake shakes now (<see cref="StartQuake"/>), 0-1 — for the lights that flicker (`MapTremor`).</summary>
	public static float QuakeNow => Instance.IsValid() && Instance._quakeFor > 0f
		? Instance._quakeFrom * MathF.Max( 0f, 1f - Instance._quakeSince / Instance._quakeFor ) : 0f;

	/// <summary>
	/// Shake this machine's screen, from full down to nothing over these seconds. EVERY machine: `NZNet.HexQuake`, and the Easter
	/// egg's roars (`EggFanfare`), which also pass how hard it begins — <see cref="QuakePeak"/> when they do not.
	/// </summary>
	public void StartQuake( float seconds, float peak = -1f )
	{
		_quakeFor = MathF.Max( 0f, seconds );
		_quakeFrom = peak >= 0f ? Math.Clamp( peak, 0f, 1f ) : QuakePeak;
		_quakeSince = 0;
	}

	/// <summary>
	/// The quake, every frame it runs: the local player's camera shake held at least at its level now — `CameraShake`'s own
	/// trauma dies in a third of a second, so a long quake has to keep it topped up. LOCAL.
	/// </summary>
	void TickQuake()
	{
		if ( _quakeFor <= 0f ) return;

		var t = _quakeSince / _quakeFor;
		if ( t >= 1f ) { _quakeFor = 0f; return; }

		var me = NZPlayer.Local;
		if ( !me.IsValid() ) return;

		var shake = me.Components.GetOrCreate<CameraShake>();
		var want = _quakeFrom * (1f - t);
		if ( shake.Trauma < want ) shake.Add( want - shake.Trauma );
	}

	/// <summary>
	/// Every frame, on every machine: the surface to its level, the warning's last ten seconds said, and this machine's own
	/// player taken if the lava has reached them. LOCAL. ⚠️ NOT IN CREATIVE — a map being built is walked in noclip.
	/// </summary>
	void TickLava()
	{
		if ( _lavaShown is not (LavaWarning or LavaRising or LavaUp or LavaSinking) || !OnBasalt ) return;

		if ( _lavaShown == LavaWarning && !_lavaTenSaid && _lavaShownSince >= LavaWarnSeconds - 10f )
		{
			_lavaTenSaid = true;
			if ( !Testing ) PowerupBannerState.Show( "10 SECONDS" );
		}

		MoveLava();

		var me = NZPlayer.Local;
		if ( !me.IsValid() || me.IsOutOfRound || NZGame.IsCreative || !LavaTakes( me.WorldPosition ) ) return;

		if ( !me.IsDown ) Log.Info( $"[nz-hex] 🌋 the lava took {me.GameObject.Name} at {me.WorldPosition.z:0} — its level {LavaHeightShown:0}" );
		me.KillOutright();
	}

	// ══ in words ════════════════════════════════════════════════════════════════════════════

	/// <summary>Where it stands, in words. HOST.</summary>
	string LavaStateText() => _lava switch
	{
		LavaNone => "the rising lava: waiting for the Mastermind",
		LavaWarning => $"the rising lava: rises in {MathF.Max( 0f, LavaWarnSeconds - _lavaSince ):0} seconds, to {LavaTop:0.#}",
		LavaRising => $"the rising lava: rising — now at {LavaHeightShown:0}, to {LavaTop:0.#}",
		LavaUp => $"the rising lava: UP at {LavaTop:0.#} — it sinks when round {_lavaRoseRound + LavaRounds} ends",
		LavaSinking => $"the rising lava: sinking — now at {LavaHeightShown:0}",
		_ => "the rising lava: SURVIVED — back in its bed",
	};

	// ══ commands ════════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// `nz_hex_lava [start|now|sink|done|reset]` — HOST: where the rising lava stands. `start` begins its thirty seconds'
	/// warning, as the Mastermind's end does; `now` raises it at once, the warning skipped; `sink` sends it back down, as
	/// the third round's end does; `done` puts it back in its bed, survived, and wakes the junctions; `reset` makes it not
	/// begun, and them with it.
	/// </summary>
	[ConCmd( "nz_hex_lava" )]
	public static void LavaCmd( string what = "" )
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] host only" ); return; }

		var m = Ensure();
		if ( !m.IsValid() ) { Log.Warning( "[nz-hex] no game running — start one first" ); return; }

		switch ( what.Trim().ToLowerInvariant() )
		{
			case "":
				break;

			case "start":
				m.ResetLava();
				m.StartLava();
				break;

			case "now":
				if ( m._lava == LavaNone ) m.StartLava();
				m.SetLava( LavaRising );
				break;

			case "sink":
				if ( m._lava is LavaNone or LavaDone ) { Log.Warning( "[nz-hex] the lava is in its bed already" ); return; }
				m.SetLava( LavaSinking );
				break;

			case "done":
				m.SetLava( LavaDone );
				m.StartJunctions();
				break;

			case "reset":
				m.ResetLava();
				break;

			default:
				Log.Warning( "[nz-hex] nz_hex_lava start, now, sink, done or reset — or nothing, to see where it stands" );
				return;
		}

		Log.Info( $"[nz-hex] {m.LavaStateText()}" );
	}

	/// <summary>
	/// `nz_hex_quake [seconds] [peak]` — shake this machine's screen as the Mastermind's end does, three seconds if none given;
	/// a peak (0-1) sets how hard it starts, until a restart. This machine only.
	/// </summary>
	[ConCmd( "nz_hex_quake" )]
	public static void QuakeCmd( float seconds = 0f, float peak = -1f )
	{
		if ( peak >= 0f ) QuakePeak = Math.Clamp( peak, 0f, 1f );

		var m = Ensure();
		if ( !m.IsValid() ) { Log.Warning( "[nz-hex] no scene" ); return; }

		m.StartQuake( seconds > 0f ? seconds : MastermindQuakeSeconds );
		Log.Info( $"[nz-hex] the ground shakes: {m._quakeFor:0.#} seconds, from {QuakePeak:0.##}" );
	}

	/// <summary>
	/// `nz_hex_lava_top [height]` — how high the lava rises; bare, it prints it. On this machine and until a restart: set the
	/// default in `LavaTop` once it is settled.
	/// </summary>
	[ConCmd( "nz_hex_lava_top" )]
	public static void LavaTopCmd( float height = 0f )
	{
		if ( height != 0f && NZGame.IsClient ) { Log.Warning( "[nz-hex] the host sets the lava's height" ); return; }

		if ( height != 0f )
		{
			LavaTop = height;
			if ( Instance.IsValid() ) Instance.SendLava();
		}
		Log.Info( $"[nz-hex] the lava rises to {LavaTop:0.#}, from its bed at {LavaBedTop:0.#}" );
	}
}