EasterEgg/HexPlatforms.ArenaHazards.cs

Client-side manager for the Basalt boss arena hazards. Draws the shrunk inner floor, crack glow, rising hex platforms, rising lava flood and the post-fight green orb, updates their visuals each frame and applies periodic damage to the local player when standing in the flood.

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

namespace NZombies;

/// <summary>
/// BASALT — THE BOSS ARENA'S CHANGES (2026-09-27): what the boss fight does to the arena, phase by phase
/// (`HexPlatforms.BossFight.cs`), each machine drawing its own from the fight as the host told it.
///
/// ⛔ PHASE 2 — *"the floor becomes a bit smaller on the sides, reducing the arena size"*: the upper floor's edge glows red-hot
/// for four seconds, then sinks into the lava over three; what stays is the shrunk floor, 127u from every wall where the upper
/// floor was 72u — 55u off every side, a sixth of it gone (`Tools/basalt_arena_floor.py --gap 127`, generated into
/// `HexPlatforms.ArenaShrunkOutline.cs`). ⚠️ IT WAS 222u — 150u off, 41% of the floor — until the user had it cut by 60%:
/// *"reduce the amount of the floor that is reduced by 60%"* (2026-09-27); 127u takes 40% of what 222u took. ⚠️ THE SHRUNK FLOOR IS A PRISM OF ITS OWN, half a unit over the old top, laid as the
/// warning begins: the band round it is the old floor showing, and glowing, and then the old floor sinks whole — under the
/// lava and into the raised floor, out of sight — while the shrunk one stays.
///
/// ⛔ PHASE 3 — *"a few hexagonal platforms rise up just a little bit from the ground, in some places"*, and then *"the hex
/// platforms should all be really close to each other, and form a big ring around the arena"* (2026-09-27): eleven hexes the
/// size of the hex tiles upstairs (<see cref="Apothem"/>, some 250u across), a ring of them round the arena — each beside two
/// others with 8u between, the tiles' own seam upstairs, so the ring walks as one — rising 16u out of the floor over three
/// seconds, grinding as the blue altar does; a step up, so anyone walks onto them. The biggest ring of them the shrunk floor
/// holds, found by search (`Tools/basalt_hex_ring.py`): its nearest corner 16u inside the floor's edge, and he comes up in its
/// middle. ⚠️ IT WAS A FLOWER OF SEVEN, 72u — a jump — apart, until the user's word.
///
/// ⛔ PHASE 4 — *"lava rises to only leave the hexagonal platforms as safe zones"*: a warning, then the flood rises from the
/// gap's lava over ten seconds and stops 4u under the platforms' tops. It burns a player whose feet are under it as the gap's
/// lava does — the config's damage wall there, 10 every 0.2s: *"the lava in the boss arena should not insta kill the player,
/// instead it deals 10 damage every 0.2s"* (2026-09-27; it killed outright before) — and not zombies, who wade on after them.
///
/// ⛔ AND THE KILL: the flood goes back into its bed over five seconds, and a green orb floats over his spawn — *"a floating
/// green orb spawns in the boss spawn point"* — to be taken. Once the Easter egg is done the arena is as it was.
///
/// ⚠️ LOCAL, FROM THE MIRROR, EVERY FRAME (<see cref="TickHazards"/>): the fight's stage, phase and clock say how far each
/// change has gone, so a joiner or a hotload draws the arena where the host's is. The navmesh over them is the host's, built
/// again as each change ends (`RunChange`).
/// </summary>
public sealed partial class HexPlatforms
{
	/// <summary>Eased, slow at both ends.</summary>
	static float EaseBoth( float t )
	{
		t = Math.Clamp( t, 0f, 1f );
		return t * t * (3f - 2f * t);
	}

	/// <summary>
	/// How the arena's changes are drawn: ⚠️ BUMP IT WHEN THAT CHANGES. 1: the shrunk floor, the seven platforms, the flood and
	/// the core, 2026-09-27; 2: and the crack's line of fire round the shrunk floor; 3: the platforms a ring of eleven; 4: the
	/// shrunk floor 20u tall, with the upper floor. A manager that has not built this layout builds them again — `OnUpdate`
	/// asks every frame.
	/// </summary>
	const int HazardsLayout = 4;

	/// <summary>LOCAL — the layout this manager last built them in; 0 before it has.</summary>
	int _hazardsLaid;

	/// <summary>LOCAL — which of this stage's banners have been shown, a bit each; a new stage clears them.</summary>
	int _hazardSaid;

	// ══ phase 2: the floor shrunk ═══════════════════════════════════════════════════════════

	/// <summary>How far the shrunk floor stands over the old one's top: half a unit, so the two never share a surface and flicker.</summary>
	const float ShrunkLift = 0.5f;

	/// <summary>How far the old floor sinks: its whole height and 40 more — under the lava and into the raised floor.</summary>
	static float SinkDepth => ArenaStageHeight + 40f;

	/// <summary>How far through the edge's sinking the fight is: 0 until phase 2's change reaches it, 1 once it has sunk.</summary>
	static float SinkT( int stage, int phase, float since )
	{
		if ( stage == FightNone || phase < 2 ) return 0f;
		if ( phase == 2 && stage == FightChange ) return Math.Clamp( (since - ShrinkSinkAt) / ShrinkSinkSeconds, 0f, 1f );
		return 1f;
	}

	/// <summary>Does the shrunk floor stand: from the edge's warning on?</summary>
	static bool ShrunkFloorUp( int stage, int phase, float since )
		=> stage != FightNone && (phase > 2 || (phase == 2 && (stage != FightChange || since >= ShrinkWarnAt)));

	/// <summary>Is the edge red-hot: its warning, and its sinking?</summary>
	static bool EdgeGlowing( int stage, int phase, float since )
		=> stage == FightChange && phase == 2 && since >= ShrinkWarnAt && since < ShrinkSinkAt + ShrinkSinkSeconds;

	/// <summary>LOCAL — the shrunk floor: its prism and its collider.</summary>
	GameObject _arenaInnerGo;

	const string ArenaInnerName = "Basalt boss arena's shrunk floor";

	/// <summary>The shrunk floor: the upper floor's own concrete, on the world's 128u grid, standing half a unit over it. LOCAL.</summary>
	GameObject MakeShrunkFloor()
	{
		var material = Material.Load( ArenaFloorMaterial );
		if ( material is null ) return null;

		var origin = ArenaStageOrigin;
		var model = DebrisMesh.Build( ArenaShrunkOutline.Select( p => p - origin ).ToList(), ArenaStageHeight + ShrunkLift, material );
		if ( model is null ) { Log.Warning( "[nz-hex] the shrunk floor would not extrude — its outline crosses itself?" ); return null; }

		var go = Scene.CreateObject();
		go.Name = ArenaInnerName;
		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( origin.x, origin.y, ArenaFloorTop );
		go.WorldRotation = Rotation.Identity;
		go.Components.Create<ModelRenderer>().Model = model;
		go.Components.Create<ModelCollider>().Model = model;
		return go;
	}

	// ══ phase 3: the platforms ══════════════════════════════════════════════════════════════

	/// <summary>How many platforms: eleven, a ring round the arena.</summary>
	const int FightHexes = 11;

	/// <summary>The ring's middle — the average of its eleven: the grinding's, and the core's should there be no boss spawn. His spawn is 66u from it.</summary>
	static Vector2 FightHexMiddle => new( -3047.45f, -464.53f );

	/// <summary>The seam between two platforms: 8u, the hex tiles' own upstairs — close enough that the ring walks as one.</summary>
	const float FightHexSeam = 8f;

	/// <summary>From one platform's middle to its neighbour's: two tiles' flats and the seam — 257u.</summary>
	static float FightHexSpacing => Apothem * 2f + FightHexSeam;

	/// <summary>How far they rise out of the floor, and how deep they reach under it, in units.</summary>
	const float FightHexRise = 16f, FightHexDepth = 32f;

	/// <summary>
	/// The honeycomb the ring is cut from: its cell (0, 0)'s middle, as the search found it — the biggest ring the shrunk floor
	/// holds, the best centred on it (`Tools/basalt_hex_ring.py`, 2026-09-27). Cell (i, j) is i across and j rows up, each row
	/// half a cell east of the one under it; corners north and south, as the tiles' are.
	/// </summary>
	static Vector2 FightHexOrigin => new( -2790.45f, -383.59f );

	/// <summary>
	/// The ring's cells in order round it, counter-clockwise from the west — each beside the one before, the last beside the
	/// first. ⚠️ A SWITCH, NOT A STATIC ARRAY: a static survives a hotload holding its old values (INSTRUCTIONS.md §1).
	/// </summary>
	static (int I, int J) FightHexCell( int k ) => k switch
	{
		0 => (-2, -1),
		1 => (-1, -2),
		2 => (0, -2),
		3 => (1, -2),
		4 => (1, -1),
		5 => (1, 0),
		6 => (0, 1),
		7 => (-1, 1),
		8 => (-2, 1),
		9 => (-3, 1),
		_ => (-3, 0),
	};

	/// <summary>A platform's middle: its cell of the honeycomb.</summary>
	static Vector2 FightHexCentre( int k )
	{
		var (i, j) = FightHexCell( Math.Clamp( k, 0, FightHexes - 1 ) );
		return FightHexOrigin + new Vector2( FightHexSpacing * (i + j * 0.5f), FightHexSpacing * MathF.Sqrt( 3f ) * 0.5f * j );
	}

	/// <summary>The platform nearest a point: where a change's Max Ammo drops, by the players.</summary>
	static int NearestFightHex( Vector2 to )
		=> Enumerable.Range( 0, FightHexes ).OrderBy( k => (FightHexCentre( k ) - to).LengthSquared ).First();

	/// <summary>How far risen they are: 0 until phase 3's change raises them, 1 once they stand.</summary>
	static float HexT( int stage, int phase, float since )
	{
		if ( stage == FightNone || phase < 3 ) return 0f;
		if ( phase == 3 && stage == FightChange ) return Math.Clamp( (since - HexRiseAt) / HexRiseSeconds, 0f, 1f );
		return 1f;
	}

	/// <summary>The shrunk floor's top, where the platforms rise from.</summary>
	static float ShrunkFloorTop => ArenaFloorTop + ArenaStageHeight + ShrunkLift;

	/// <summary>A platform's bottom, this far risen: its top a unit under the floor to start, 16u over it at the end.</summary>
	static float HexBottom( float rise ) => ShrunkFloorTop - 1f + (FightHexRise + 1f) * EaseBoth( rise ) - FightHexDepth;

	/// <summary>LOCAL — the eleven platforms.</summary>
	GameObject[] _fightHexGo;

	const string FightHexName = "Basalt boss arena's platform";

	/// <summary>The eleven: hex prisms the hex tiles' size, corners north and south as theirs are, in the stones' concrete, solid. LOCAL.</summary>
	void MakeHexes( float rise, bool grind )
	{
		ClearHexes();

		var material = Material.Load( StoneMaterial );
		if ( material is null ) return;

		float a = Apothem, r = Apothem * 2f / MathF.Sqrt( 3f );
		var model = DebrisMesh.Build( new List<Vector2>
		{
			new( 0f, r ), new( -a, r * 0.5f ), new( -a, -r * 0.5f ), new( 0f, -r ), new( a, -r * 0.5f ), new( a, r * 0.5f ),
		}, FightHexDepth, material );
		if ( model is null ) return;

		_fightHexGo = new GameObject[FightHexes];
		for ( var k = 0; k < FightHexes; k++ )
		{
			var c = FightHexCentre( k );
			var go = Scene.CreateObject();
			go.Name = $"{FightHexName} {k + 1}";
			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( c.x, c.y, HexBottom( rise ) );
			go.WorldRotation = Rotation.Identity;
			go.Components.Create<ModelRenderer>().Model = model;
			go.Components.Create<ModelCollider>().Model = model;
			_fightHexGo[k] = go;
		}

		if ( grind && !Testing ) NZSound.Play( BlueAltarCue, new Vector3( FightHexMiddle.x, FightHexMiddle.y, ShrunkFloorTop ) );
	}

	void ClearHexes()
	{
		if ( _fightHexGo is not null )
			foreach ( var go in _fightHexGo )
				if ( go.IsValid() ) go.Destroy();
		_fightHexGo = null;
	}

	// ══ phase 4: the flood ══════════════════════════════════════════════════════════════════

	/// <summary>Where the flood rises from: the gap's lava, 16u under the upper floor's top — basalt's damage wall 2.</summary>
	static float FloodBed => ArenaFloorTop + ArenaStageHeight - 16f;

	/// <summary>Where it stops: 4u under the platforms' tops, 12u over the floor.</summary>
	static float FloodTop => ShrunkFloorTop + FightHexRise - 4f;

	/// <summary>LOCAL — how far risen the flood is, 0-1, moved each frame toward what the fight says; and its surface.</summary>
	float _floodT;
	GameObject _floodGo;

	/// <summary>The flood's level now, as this machine draws it.</summary>
	float FloodLevel => FloodBed + (FloodTop - FloodBed) * EaseBoth( _floodT );

	/// <summary>
	/// The boss arena's lava, the config's: the damage wall lying in the arena — basalt's wall 2, the gap's — whose damage and
	/// interval the flood burns with too, so the arena's lava is one setting: 10 every 0.2s. Null if the map has none; the flood
	/// then burns 10 every 0.2s all the same.
	/// </summary>
	static DamageWall ArenaLavaWall => ActiveConfig.Current?.DamageWalls?.FirstOrDefault( w => w is not null && InBossArena( w.Position ) );

	/// <summary>The arena's lava's top — the gap's surface, -552 — or nowhere, with no lava wall. Below it the wall burns, not the flood.</summary>
	static float ArenaLavaTop => ArenaLavaWall is DamageWall w ? w.Position.z + w.Size.z * 0.5f : float.MinValue;

	/// <summary>LOCAL — since this machine's player last took the flood's tick; and whether they stand in it.</summary>
	TimeSince _floodTick;
	bool _inFlood;

	/// <summary>
	/// The flood's surface: a slab of the lava bed's own lava over the whole arena, one unit tall, stretched to its level — as
	/// the risen lava upstairs is (`MakeLava`). No collider. LOCAL.
	/// </summary>
	GameObject MakeFlood()
	{
		var bed = LavaBed;
		var material = Material.Load( string.IsNullOrWhiteSpace( bed?.Material ) ? LavaMaterialFallback : bed.Material );
		if ( material is null ) return null;

		Vector2 lo = ArenaCeilingMin, hi = ArenaCeilingMax;
		var half = (hi - lo) * 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 = FloodName;
		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( (lo.x + hi.x) * 0.5f, (lo.y + hi.y) * 0.5f, FloodBed );

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

	// ══ the orb ═════════════════════════════════════════════════════════════════════════════

	/// <summary>Where the core hangs, should there be no boss spawn: over the ring's middle, 70u over where a platform's top would be.</summary>
	static Vector3 CoreRest => new( FightHexMiddle.x, FightHexMiddle.y, ShrunkFloorTop + FightHexRise + 70f );

	/// <summary>
	/// Where the green orb floats: 70u over the fight's boss spawn — the user's, flag 200 — the first of them; over the middle
	/// platform if there is none. From the config, so every machine puts it in the same place.
	/// </summary>
	static Vector3 OrbAt => ActiveConfig.Current?.BossSpawns?.FirstOrDefault( s => s is not null && s.Link == FightSpawnFlag ) is SpawnPoint s
		? s.Position + Vector3.Up * 70f
		: CoreRest;

	/// <summary>Its green.</summary>
	static Color OrbGreen => new( 0.3f, 1f, 0.35f );

	/// <summary>How near a player's feet must be to take it, and how near its middle a look must pass, in units.</summary>
	const float CoreReach = 150f, CoreAimRadius = 36f;

	/// <summary>LOCAL — the orb.</summary>
	GameObject _coreGo;

	/// <summary>Where the orb is now: over the spawn, rising and falling a little.</summary>
	Vector3 CoreNow => OrbAt + Vector3.Up * (MathF.Sin( Time.Now * 1.9f ) * 5f);

	/// <summary>
	/// The orb: the powerups' soft flare in green, twice — a wide halo and a bright heart — a green light round it, and the
	/// powerups' hum. LOCAL.
	/// </summary>
	GameObject MakeCore()
	{
		var root = Scene.CreateObject();
		root.Name = CoreName;
		root.Flags |= GameObjectFlags.NotSaved;
		root.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		root.Tags.Add( PanelTag );
		root.WorldPosition = CoreNow;

		var flare = ResourceLibrary.Get<Sprite>( Powerup.GlowSprite );
		foreach ( var (name, size, colour) in new[] { ("halo", 110f, OrbGreen), ("heart", 44f, new Color( 0.85f, 1f, 0.85f )) } )
		{
			var go = Scene.CreateObject();
			go.Name = name;
			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.SetParent( root, false );
			go.LocalPosition = Vector3.Zero;
			var glow = go.Components.Create<SpriteRenderer>();
			if ( flare is not null ) glow.Sprite = flare;
			glow.Size = new Vector2( size, size );
			glow.Color = colour;
			glow.Additive = true;
			glow.Lighting = false;
			glow.Shadows = false;
			glow.DepthFeather = 8f;
		}

		var lamp = Scene.CreateObject();
		lamp.Name = "light";
		lamp.Flags |= GameObjectFlags.NotSaved;
		lamp.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		lamp.SetParent( root, false );
		lamp.LocalPosition = Vector3.Zero;
		var light = lamp.Components.Create<PointLight>();
		light.LightColor = OrbGreen * 3f;
		light.Radius = 600f;
		light.Shadows = false;

		var sound = ResourceLibrary.Get<SoundEvent>( "sounds/nz/nz.powerup.loop.sound" );
		if ( sound is not null )
		{
			var sp = root.Components.Create<SoundPointComponent>();
			sp.SoundEvent = sound;
			sp.PlayOnStart = true;
			sp.SoundOverride = true;
			sp.Volume = 0.6f;
			sp.DistanceAttenuationOverride = true;
			sp.DistanceAttenuation = true;
			sp.Distance = 1000f;
		}
		return root;
	}

	/// <summary>
	/// Is this player looking at the core, up and near enough to take it? LOCAL — the prompt's test and the use key's, one
	/// question for both (`UsePrompt.ForCore`, `NZPlayer.TickUse`).
	/// </summary>
	public static bool CoreAimed( NZPlayer player )
	{
		var m = Instance;
		if ( !m.IsValid() || !OnBasalt || m._fightShown != FightWon || !m._coreGo.IsValid() || CannotCarry( player ) ) return false;

		var at = m.CoreNow;
		if ( player.WorldPosition.Distance( at ) > CoreReach ) return false;

		var cam = m.Scene.Camera;
		return cam.IsValid() && RayMeetsSphere( cam.WorldPosition, cam.WorldRotation.Forward, at, CoreAimRadius );
	}

	// ══ every frame ═════════════════════════════════════════════════════════════════════════

	/// <summary>A banner, once a stage, when its moment has come. LOCAL.</summary>
	void Say( int bit, bool when, string text )
	{
		if ( !when || (_hazardSaid & (1 << bit)) != 0 ) return;
		_hazardSaid |= 1 << bit;
		if ( !Testing ) PowerupBannerState.Show( text );
	}

	/// <summary>A number with its thousands grouped by commas, whatever this machine's culture: the banner's "+50,000".</summary>
	static string Grouped( int n ) => n.ToString( "N0", System.Globalization.CultureInfo.InvariantCulture );

	/// <summary>Everything the fight put in the arena gone, and the upper floor back as it was. LOCAL.</summary>
	void ClearHazards()
	{
		if ( _arenaInnerGo.IsValid() ) _arenaInnerGo.Destroy();
		_arenaInnerGo = null;
		ClearHexes();
		if ( _floodGo.IsValid() ) _floodGo.Destroy();
		_floodGo = null;
		_floodT = 0f;
		if ( _coreGo.IsValid() ) _coreGo.Destroy();
		_coreGo = null;
		if ( _crackGo.IsValid() ) _crackGo.Destroy();
		_crackGo = null;

		if ( _arenaStageGo.IsValid() )
		{
			_arenaStageGo.Enabled = true;
			var o = ArenaStageOrigin;
			_arenaStageGo.WorldPosition = new Vector3( o.x, o.y, ArenaFloorTop );
			var r = _arenaStageGo.Components.Get<ModelRenderer>( FindMode.EverythingInSelf );
			if ( r.IsValid() ) r.Tint = Color.White;
		}

		if ( !Scene.IsValid() ) return;
		foreach ( var old in Scene.GetAllObjects( false ).Where( x => x.Tags.Has( PanelTag )
			&& (x.Name == ArenaInnerName || x.Name.StartsWith( FightHexName ) || x.Name == FloodName || x.Name == CoreName
				|| x.Name == CrackName) ).ToList() )
			old.Destroy();
	}

	const string FloodName = "Basalt boss arena's flood", CoreName = "Basalt's beast — the green orb", CrackName = "Basalt boss arena's crack";

	/// <summary>LOCAL — phase 2's warning: the crack round the shrunk floor, glowing until the edge has sunk.</summary>
	GameObject _crackGo;

	/// <summary>The crack: a line of fire along the shrunk floor's edge, a unit over its top — additive and unlit, so it shows in the dark. LOCAL.</summary>
	GameObject MakeCrack()
	{
		var go = Scene.CreateObject();
		go.Name = CrackName;
		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 );

		var line = go.Components.Create<LineRenderer>();
		line.UseVectorPoints = true;
		line.Additive = true;
		line.Lighting = false;
		line.CastShadows = false;

		var z = ShrunkFloorTop + 1f;
		var pts = ArenaShrunkOutline.Select( p => new Vector3( p.x, p.y, z ) ).ToList();
		pts.Add( pts[0] );
		line.VectorPoints = pts;
		line.Color = new Color( 1f, 0.45f, 0.1f );
		line.Width = 5f;
		return go;
	}

	/// <summary>
	/// Built again from nothing, at a new layout. LOCAL — `OnUpdate`, which calls <see cref="TickHazards"/> right after it, so
	/// what the fight needs stands again in the same frame: a floor gone for a frame would drop whoever stood on it.
	/// </summary>
	void BuildHazards()
	{
		_hazardsLaid = HazardsLayout;
		ClearHazards();
	}

	/// <summary>
	/// The arena as the fight says it is now, every frame: the shrunk floor standing and the old one glowing and sinking; the
	/// platforms rising; the flood rising, and burning this machine's own player where it reaches their feet; the core drifting
	/// to where it hangs. And each change's banner at its moment. LOCAL — `OnUpdate`. ⚠️ THE FLOOD'S BURN IS NOT IN CREATIVE, as
	/// the rising lava's kill is not — a map being built is walked in noclip.
	/// </summary>
	void TickHazards()
	{
		if ( !OnBasalt ) return;

		var stage = _fightShown;

		// ⛔ DONE IS THE ARENA AS IT WAS: everyone home, nothing of the fight left in it — and the news a moment on, a line at a
		// time, since the banner holds one; each in a second of its own, so a joiner, told long after, hears none of it: the
		// gifts were not theirs
		if ( stage == FightDone && _fightShownSince < 14f )
		{
			var news = (float)_fightShownSince;
			Say( 3, news >= 3.5f && news < 4.5f, $"+{Grouped( EggPoints )} POINTS · +{Grouped( EggSalvage )} SALVAGE" );
			Say( 4, news >= 6.5f && news < 7.5f, $"PACK-A-PUNCH UP TO MK{PapSettings.MaxTiers}" );
			Say( 5, news >= 9.5f && news < 10.5f, $"RARITY UP TO {Rarity.NameFor( Rarity.GodlyTier ).ToUpper()}" );
			Say( 6, news >= 12.5f && news < 13.5f, "NO LIMIT ON AUGMENTS" );
		}

		if ( stage is FightNone or FightDone )
		{
			if ( _arenaInnerGo.IsValid() || _fightHexGo is not null || _floodGo.IsValid() || _coreGo.IsValid() || _crackGo.IsValid() )
				ClearHazards();
			return;
		}

		var phase = _fightPhaseShown;
		var since = (float)_fightShownSince;

		// phase 2: the shrunk floor up; the old one red-hot and sinking under it, then gone
		if ( ShrunkFloorUp( stage, phase, since ) && !_arenaInnerGo.IsValid() ) _arenaInnerGo = MakeShrunkFloor();
		if ( _arenaStageGo.IsValid() )
		{
			var sink = SinkT( stage, phase, since );
			var sunk = sink >= 1f;
			if ( _arenaStageGo.Enabled == sunk ) _arenaStageGo.Enabled = !sunk;
			var o = ArenaStageOrigin;
			if ( !sunk ) _arenaStageGo.WorldPosition = new Vector3( o.x, o.y, ArenaFloorTop - SinkDepth * EaseBoth( sink ) );

			var r = _arenaStageGo.Components.Get<ModelRenderer>( FindMode.EverythingInSelf );
			if ( r.IsValid() )
			{
				var hot = EdgeGlowing( stage, phase, since ) ? 0.5f + 0.5f * MathF.Sin( since * MathF.PI * 4f ) : 0f;
				r.Tint = new Color( 1f, 1f - 0.7f * hot, 1f - 0.9f * hot );
			}
		}

		// …and the crack where it will break, a line of fire round the shrunk floor, pulsing: ⚠️ THE TINT ALONE DOES NOT SHOW —
		// it multiplies a floor this dark, and the first try's screenshots showed nothing of it (2026-09-27)
		var glowing = EdgeGlowing( stage, phase, since );
		if ( glowing && !_crackGo.IsValid() ) _crackGo = MakeCrack();
		if ( !glowing && _crackGo.IsValid() ) { _crackGo.Destroy(); _crackGo = null; }
		if ( _crackGo.IsValid() && _crackGo.Components.Get<LineRenderer>() is LineRenderer crack )
		{
			var beat = 0.5f + 0.5f * MathF.Sin( since * MathF.PI * 4f );
			crack.Color = new Color( 1f, 0.45f, 0.1f ).WithAlpha( 0.45f + 0.55f * beat );
			crack.Width = 3f + 5f * beat;
		}

		Say( 0, stage == FightChange && phase == 2 && since >= ShrinkWarnAt, "THE FLOOR SINKS — GET OFF THE EDGE" );
		Say( 1, stage == FightChange && phase == 3 && since >= HexRiseAt - 1f, "THE PLATFORMS RISE" );
		Say( 2, stage == FightChange && phase == 4 && since >= FloodWarnAt, "THE LAVA RISES — GET ON THE PLATFORMS" );

		// phase 3: the platforms
		var rise = HexT( stage, phase, since );
		if ( rise > 0f && (_fightHexGo is null || _fightHexGo.Any( g => !g.IsValid() )) )
			MakeHexes( rise, grind: stage == FightChange && phase == 3 && rise < 0.5f );
		if ( _fightHexGo is not null )
		{
			var bottom = HexBottom( rise );
			foreach ( var go in _fightHexGo )
				if ( go.IsValid() ) go.WorldPosition = go.WorldPosition.WithZ( bottom );
		}

		// phase 4: the flood, toward what the fight says — and after the kill, back into its bed from wherever it stood
		if ( stage is FightWon or FightReturn )
			_floodT = MathF.Max( 0f, _floodT - Time.Delta / FloodRecedeSeconds );
		else
			_floodT = phase < 4 ? 0f
				: stage == FightChange ? Math.Clamp( (since - FloodRiseAt) / FloodRiseSeconds, 0f, 1f )
				: 1f;

		if ( _floodT > 0.001f && !_floodGo.IsValid() ) _floodGo = MakeFlood();
		if ( _floodGo.IsValid() )
		{
			var depth = FloodLevel - FloodBed;
			var show = depth > 0.5f;
			if ( _floodGo.Enabled != show ) _floodGo.Enabled = show;
			if ( show ) _floodGo.WorldScale = new Vector3( 1f, 1f, depth );
		}

		// ⛔ THE FLOOD BURNS, IT DOES NOT KILL — *"the lava in the boss arena should not insta kill the player, instead it deals
		// 10 damage every 0.2s"* (2026-09-27): a tick while this machine's own player stands in it, the arena's lava wall's damage
		// every its interval (`ArenaLavaWall`), as a tick (`Health.Apply`'s `tick`); the downed left alone, as a damage wall
		// leaves them.
		var me = NZPlayer.Local;
		//
		// ⚠️ NOT BELOW THE GAP'S LAVA TOP (`ArenaLavaTop`): there the gap's own damage wall burns them already, and the two
		// together would be 20 every 0.2s.
		var inFlood = stage is FightPhase or FightChange && _floodT > 0.001f && me.IsValid() && !me.IsOutOfRound && !me.IsDown
			&& !NZGame.IsCreative && !Testing && InBossArena( me.WorldPosition ) && me.WorldPosition.z < FloodLevel
			&& me.WorldPosition.z >= ArenaLavaTop;
		if ( inFlood )
		{
			var lava = ArenaLavaWall;
			float burn = lava?.Damage ?? 10f, every = MathF.Max( 0.05f, lava?.Interval ?? 0.2f );
			if ( !_inFlood )
				Log.Info( $"[nz-hex] 🌋 {me.GameObject.Name} is in the arena's flood at {me.WorldPosition.z:0} — its level {FloodLevel:0}:"
					+ $" {burn:0.#} every {every:0.##}s" );
			if ( _floodTick >= every )
			{
				_floodTick = 0f;
				me.Hp?.Apply( burn, tick: true );
			}
		}
		_inFlood = inFlood;

		// the kill: the core, drifting to where it hangs; gone once taken
		if ( stage == FightWon && !_coreGo.IsValid() ) _coreGo = MakeCore();
		if ( stage != FightWon && _coreGo.IsValid() ) { _coreGo.Destroy(); _coreGo = null; }
		if ( _coreGo.IsValid() ) _coreGo.WorldPosition = CoreNow;
	}
}