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.
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;
}
}