HexPlatforms partial class implementing the altar defense step for a game mode called BASALT. It manages host and mirrored state for a timed defense where waves of special zombies spawn targeting the altar, tracks hits, spawns/despawns wave and shriekers, updates visuals (altar glow and zombie eye color), provides console commands to control/tune the defense, and syncs state via NZNet.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// BASALT — STEP 5, THE ALTAR, ITS SECOND PHASE: THE ALTAR DEFENSE (named by the user, 2026-09-26). Setting the cursed flame
/// on the altar wakes a wave: for a minute zombies without end — at the top speed tier — pour in from the spawns nearest
/// the altar, with a Shrieker now and then, and they go for the ALTAR, not the players. Ten hits on it fail the defense:
/// the wave is gone, and so is the flame, until the next round brings it back over tile 1 to be carried here again. A
/// minute held: the flame burns light blue, and can be taken up again. While it runs the round cannot end, every walker's
/// eyes burn purple instead of blue, and zombies pay little — a kill 10, a hit 1. Asked for as *"when we place the flame
/// there, an unlimited amount of
/// really fast zombies will spawn for 2 minutes, the round never ends during this time — these zombies will not target
/// the players, instead they will target the altar, if the altar takes 10 hits the step is failed, the extra zombies
/// despawn and i need to go pick up the flame from its spawn location in the next round and try again — if the players
/// manage to protect the altar for 2 minutes the flame becomes light blue and the player can pick it up again — during
/// this infinite wave of zombies, shriekers also spawn occasionally — also during this step all zombies get purple eyes
/// instead of blue eyes"*; then *"reduce the time to 1 minute instead of 2 — make it so during this 1 minute, zombie kills
/// award only 10 points, and shots award only 1 point"*.
///
/// ⛔ IT PAYS LITTLE, AND FLAT (<see cref="DefensePoints"/>): every zombie's kill 10 and every hit 1 while it runs — the
/// round's own zombies as well as the wave's — after Double Points, the kill bonuses and the bounty, not before: "only"
/// is what is paid. A wave without end must not be a points farm. The augments that pay a kill on their own — Deadshot's
/// Trophy, Vigor's Spoils — pay nothing while it runs, for the same reason.
///
/// ⛔ THE WAVE: walkers flagged `ZombieAI.AltarWave`, spawned at the spawns nearest the altar that are open this round.
/// - Their only target is the altar (`ZombieAI.GetTargetables`), and their swing lands on it, not on anyone
/// (`ZombieAI.DoAttackDamage` → <see cref="AltarHit"/>). They spawn at the top speed tier, as Misery's do.
/// - "Unlimited" is a stream, not a crowd: one every <see cref="WaveEvery"/> seconds while fewer than
/// <see cref="WaveMaxAlive"/> of them live — each one killed is replaced — for as long as it runs.
/// - ⚠️ ITS SHRIEKERS HUNT THE PLAYERS, AS EVERY SHRIEKER DOES — A CHOICE. A Shrieker's scream kills the zombies round it
/// and throws the players back (`ShriekerZombie.Pulse`): sent at the altar it would scream there and clear the wave for
/// the defenders; after them, it throws them off the altar. One every <see cref="ShriekerEvery"/> seconds, two at most.
/// - ⚠️ WHEN IT ENDS, EITHER WAY, EVERY ONE OF THE WAVE DESPAWNS — the Shriekers too. Asked for on a failure; on a win it
/// is a choice: the wave was the altar's, and it has nothing left to go for.
/// - The round's own zombies go on as ever, and hunt the players.
///
/// ⛔ THE ROUND IS HELD OPEN WHILE IT RUNS (<see cref="HoldsRound"/>, `RoundManager`'s round clear), however few are left.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the DEFENSE — running, held or not begun — its clock and the altar's HITS are HOST state; the defense and the hits
/// are MIRRORED (`NZNet.AltarDefenseState`) on every change and to a joiner, and each machine dresses from them: the
/// engravings' glow, dimming as the altar is hit; the flame's colour; the walkers' purple eyes (`ZombieEyes`);
/// - the WAVE is the host's: its zombies are spawned there and reach everyone as zombies do (`ZombieCommands.SpawnAt`),
/// with their flag in the spawn, so a puppet sprints as its original does.
/// </summary>
public sealed partial class HexPlatforms
{
// ══ the state ════════════════════════════════════════════════════════════════════════════
/// <summary>Where the altar's defense stands: not begun — or failed — running, or held, the flame light blue.</summary>
public enum Defense { None, Running, Won }
/// <summary>HOST — the defense, and how many hits the altar has taken in it.</summary>
Defense _defense;
int _altarHits;
/// <summary>MIRROR — the same, as this machine was told (`NZNet.AltarDefenseState`).</summary>
Defense _defenseShown;
int _altarHitsShown;
/// <summary>HOST — the defense and the hits, for `NZNet.PushState` to replay to a joiner.</summary>
public static int DefenseState => Instance.IsValid() ? (int)Instance._defense : 0;
public static int AltarHitsState => Instance.IsValid() ? Instance._altarHits : 0;
/// <summary>
/// HOST — does the altar's defense hold the round open? `RoundManager` asks before it clears one: *"the round never ends
/// during this time"*.
/// </summary>
public static bool HoldsRound => Instance.IsValid() && Instance._defense == Defense.Running;
/// <summary>Is the flame light blue — the altar held? HOST, and as this machine was told.</summary>
bool FlameBlue => _defense == Defense.Won;
bool FlameBlueShown => _defenseShown == Defense.Won;
/// <summary>HOST — the altar, as its wave's target, while the defense runs; null otherwise. `ZombieAI.GetTargetables` asks.</summary>
public static GameObject AltarTarget
{
get
{
var m = Instance;
return m.IsValid() && m._defense == Defense.Running && m._altarGo.IsValid() ? m._altarGo : null;
}
}
/// <summary>Is this the altar? `ZombieAI.DoAttackDamage` asks, to hand it the swing.</summary>
public static bool IsAltar( GameObject go ) => go.IsValid() && Instance.IsValid() && go == Instance._altarGo;
// ══ tuning ═══════════════════════════════════════════════════════════════════════════════
static float? _defenseSeconds, _waveEvery, _shriekerEvery;
static int? _altarHitsToFail, _waveMaxAlive, _waveSpawns;
/// <summary>How long the altar must be held, in seconds: a minute — two, until the user asked for one.</summary>
public static float DefenseSeconds { get => Math.Clamp( _defenseSeconds ?? 60f, 5f, 1800f ); set => _defenseSeconds = value; }
/// <summary>How many hits fail it: 10.</summary>
public static int AltarHitsToFail { get => Math.Clamp( _altarHitsToFail ?? 10, 1, 1000 ); set => _altarHitsToFail = value; }
/// <summary>How often a zombie of the wave comes, in seconds, while there is room: 0.6.</summary>
public static float WaveEvery { get => Math.Clamp( _waveEvery ?? 0.6f, 0.05f, 30f ); set => _waveEvery = value; }
/// <summary>
/// How many of the wave's walkers may be alive at once: 16 — the stream's crowd. ⚠️ THEY COUNT TOWARD THE ROUND'S OWN
/// CAP ON THE LIVING, as every zombie does, so while they are many the round's own come slower.
/// </summary>
public static int WaveMaxAlive { get => Math.Clamp( _waveMaxAlive ?? 16, 1, 100 ); set => _waveMaxAlive = value; }
/// <summary>How many of the open zombie spawns nearest the altar the wave comes from: 6.</summary>
public static int WaveSpawns { get => Math.Clamp( _waveSpawns ?? 6, 1, 64 ); set => _waveSpawns = value; }
/// <summary>How often a Shrieker comes with the wave, in seconds: 25 — twice in its minute — and at most <see cref="WaveShriekers"/> alive.</summary>
public static float ShriekerEvery { get => Math.Clamp( _shriekerEvery ?? 25f, 3f, 600f ); set => _shriekerEvery = value; }
const int WaveShriekers = 2;
static int? _defenseKillPoints, _defenseHitPoints;
/// <summary>What a zombie's kill pays while the defense runs: 10, flat.</summary>
public static int DefenseKillPoints { get => Math.Clamp( _defenseKillPoints ?? 10, 0, 10000 ); set => _defenseKillPoints = value; }
/// <summary>What a hit on a zombie pays while the defense runs: 1, flat.</summary>
public static int DefenseHitPoints { get => Math.Clamp( _defenseHitPoints ?? 1, 0, 10000 ); set => _defenseHitPoints = value; }
/// <summary>
/// Does the altar's defense run, so zombies pay little? EVERY machine, from the mirrored defense — the host's mirror is its
/// own state the moment it is sent — because the augments that pay a kill on their own run on the KILLER's machine
/// (`AugmentEffects.OnZombieKilled` relays), where the host's state is not.
/// </summary>
public static bool DefensePoints => Instance.IsValid() && Instance._defenseShown == Defense.Running;
/// <summary>Is the altar's defense running, as this machine was told? `EggMusic` plays the challenge's track while it does.</summary>
public static bool DefenseMusic => Instance.IsValid() && OnBasalt && Instance._defenseShown == Defense.Running;
// ══ the wave ═════════════════════════════════════════════════════════════════════════════
/// <summary>HOST — the clock, and when the next of the wave and the next Shrieker come.</summary>
TimeUntil _defenseLeft, _waveNext, _shriekerNext;
/// <summary>HOST — the wave's living: its walkers and its Shriekers, to despawn when it ends.</summary>
List<ZombieAI> _wave;
/// <summary>HOST — said once that the wave had nowhere to come from.</summary>
bool _waveNowhere;
/// <summary>
/// The flame is on the altar: the defense begins. HOST — `PlaceFlame`, the purple flame set down. The clock starts, the
/// wave comes at once, the first Shrieker at <see cref="ShriekerEvery"/>.
/// </summary>
void StartDefense()
{
EndWave();
_defense = Defense.Running;
_altarHits = 0;
_defenseLeft = DefenseSeconds;
_waveNext = 0f;
_shriekerNext = ShriekerEvery;
_waveNowhere = false;
SendDefense();
Log.Info( $"[nz-hex] ⚔ THE ALTAR DEFENSE — hold the altar for {DefenseSeconds:0}s: a wave without end comes for it, and"
+ $" {AltarHitsToFail} hits fail it. The round is held open, and a kill pays {DefenseKillPoints}, a hit {DefenseHitPoints}" );
}
/// <summary>
/// HOST — from `OnUpdate`, every frame, while the defense runs: the clock, and the wave kept coming. A game that has
/// ended, or a flame no longer on the altar, ends it quietly.
/// </summary>
void WatchDefense()
{
if ( _defense != Defense.Running ) return;
if ( !OnBasalt || !_flamePlaced || RoundManager.Instance is { State: RoundState.GameOver } )
{
EndDefense( Defense.None );
return;
}
_wave ??= new();
_wave.RemoveAll( z => !z.IsValid() || z.State == ZombieState.Dead );
if ( _defenseLeft <= 0f )
{
WinDefense();
return;
}
if ( _waveNext <= 0f )
{
if ( _wave.Count( z => z.AltarWave ) < WaveMaxAlive ) SpawnWave( shrieker: false );
_waveNext = WaveEvery;
}
if ( _shriekerNext <= 0f )
{
if ( _wave.Count( z => !z.AltarWave ) < WaveShriekers ) SpawnWave( shrieker: true );
_shriekerNext = ShriekerEvery;
}
}
/// <summary>
/// One of the wave, at one of the <see cref="WaveSpawns"/> open zombie spawns nearest the altar, at random: a walker for
/// the altar, or a Shrieker for the players. HOST.
/// </summary>
void SpawnWave( bool shrieker )
{
var round = Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
var near = RoundManager.EligibleSpawnsFor( round )
.OrderBy( s => s.Position.Distance( AltarAt ) )
.Take( WaveSpawns )
.ToList();
if ( near.Count == 0 )
{
if ( !_waveNowhere ) Log.Warning( "[nz-hex] ⚔ the altar's wave has nowhere to come from: no zombie spawn is open this round (nz_links)" );
_waveNowhere = true;
return;
}
var variant = shrieker ? SpecialEnemies.VariantFor( SpecialEnemies.Shrieker ) : null;
if ( shrieker && variant is null ) return;
var point = near[Game.Random.Next( near.Count )];
var z = ZombieCommands.SpawnAt( Scene, point.Position, variant, altarWave: !shrieker );
if ( z is null ) return;
z.WorldRotation = point.Rotation;
(_wave ??= new()).Add( z );
}
/// <summary>
/// Every one of the wave gone — despawned, not killed: no points, no drops. HOST. Any a hotload left untracked are known
/// by their flag.
/// </summary>
void EndWave()
{
var gone = new HashSet<ZombieAI>();
if ( _wave is not null ) gone.UnionWith( _wave.Where( z => z.IsValid() ) );
gone.UnionWith( ZombieAI.All.Where( z => z.IsValid() && z.AltarWave ) );
_wave?.Clear();
foreach ( var z in gone )
if ( z.GameObject.IsValid() ) z.GameObject.Destroy();
if ( gone.Count > 0 ) Log.Info( $"[nz-hex] ⚔ the altar's wave despawned — {gone.Count} zombie(s)" );
}
/// <summary>
/// A zombie of the wave landed a swing on the altar. HOST — `ZombieAI.DoAttackDamage`, which has played its impact already.
/// </summary>
public static void AltarHit()
{
if ( NZGame.IsClient ) return;
var m = Instance;
if ( m.IsValid() ) m.TakeAltarHit();
}
/// <summary>The rule for a hit. HOST — apart from the hook, so the selftest can walk it. The tenth fails the defense.</summary>
void TakeAltarHit()
{
if ( _defense != Defense.Running ) return;
_altarHits++;
SendDefense();
Log.Info( $"[nz-hex] ⚔ the altar is hit — {_altarHits} of {AltarHitsToFail}" );
if ( _altarHits >= AltarHitsToFail ) FailDefense();
}
/// <summary>
/// A minute held: the flame burns light blue on the altar, and can be taken up again. HOST. The wave despawns, and the
/// step-done clicking plays.
/// </summary>
void WinDefense()
{
EndWave();
_defense = Defense.Won;
SendDefense();
if ( !Testing ) DoneCueLater();
Fanfare( 7 );
Log.Info( $"[nz-hex] ✦ THE ALTAR HELD — {DefenseSeconds:0}s, {_altarHits} of {AltarHitsToFail} hits taken. The cursed flame burns"
+ " light blue, and can be taken up again" );
}
/// <summary>
/// Ten hits: the defense fails. HOST. The wave despawns, the flame goes from the altar and from everywhere — the next
/// round brings it back over tile 1, where it first floated (<see cref="ReturnFlame"/>) — and the power-down sounds at
/// the altar. The shield stays open: the flame is only to be carried here again.
/// </summary>
void FailDefense()
{
EndWave();
var hits = _altarHits;
_defense = Defense.None;
_altarHits = 0;
_flamePlaced = false;
_flameLost = true;
SendFlame();
SendDefense();
Cue( FailCue, AltarTop );
Log.Info( $"[nz-hex] ⚔ THE ALTAR FELL — {hits} hits. The wave is gone, and the cursed flame with it: the next round brings it"
+ " back over tile 1, to carry here again" );
}
/// <summary>The defense over without a winner — a new game, a flame taken off by hand, a game ended. HOST. Quiet.</summary>
void EndDefense( Defense to )
{
if ( _defense == to && _altarHits == 0 && (_wave is null || _wave.Count == 0) && !ZombieAI.All.Any( z => z.IsValid() && z.AltarWave ) )
return;
EndWave();
_defense = to;
_altarHits = 0;
SendDefense();
}
void SendDefense() => NZNet.AltarDefenseState( (int)_defense, _altarHits );
/// <summary>
/// The defense and the hits. EVERY machine — `NZNet.AltarDefenseState`: the walkers' eyes purple while it runs, the
/// engravings in the flame's colour, dimming with every hit, and the flame purple or light blue.
/// </summary>
public void ApplyDefense( int state, int hits )
{
_defenseShown = (Defense)Math.Clamp( state, 0, 2 );
_altarHitsShown = Math.Max( 0, hits );
ZombieEyes.SetDefense( OnBasalt && _defenseShown == Defense.Running );
DressAltar();
BuildRewardTile();
}
// ══ the altar's glow ═════════════════════════════════════════════════════════════════════
/// <summary>The light blue the flame burns once the altar is held — its glow, its light and the engravings'.</summary>
static Color FlameBlueHue => new( 0.35f, 0.72f, 1f );
static float? _altarGlowDrive;
/// <summary>How hard the engravings are driven in the flame's colour: 2.4, the tiles' lights' order.</summary>
public static float AltarGlowDrive { get => Math.Clamp( _altarGlowDrive ?? 2.4f, 0.1f, 20f ); set => _altarGlowDrive = value; }
/// <summary>`nz_hex_altar_glow`'s copies of the map's light, by colour: a tinted copy each, never white001 itself.</summary>
static Dictionary<string, Material> _glows;
/// <summary>
/// A copy of white001 in this hue at this drive — ⚠️ A COPY, as `MaterialFor` makes: tinting white001 would turn every
/// light strip in the map. The tint is written on every call, so a dimming or a retune takes at once.
/// </summary>
static Material GlowMaterial( string key, Color hue, float drive )
{
var white = Material.Load( LightMaterial );
if ( white is null ) return null;
_glows ??= new();
if ( !_glows.TryGetValue( key, out var m ) || m is null )
_glows[key] = m = white.CreateCopy( $"nz_hex_altar_{key}" );
m.Set( "g_vColorTint", new Vector3( hue.r * drive, hue.g * drive, hue.b * drive ) );
return m;
}
/// <summary>
/// The engravings as the flame on the altar makes them: in its colour while it burns there — purple, fading toward a
/// third with every hit the altar takes while defended; light blue once held — and the map's white otherwise. Asked for
/// as *"when i place the orb there, make the engravings shine in the same color as the flame"*. LOCAL — the pillar keeps
/// its stone: only the engravings, its children, are overridden.
/// </summary>
void DressAltar()
{
if ( !_altarGo.IsValid() ) return;
Material glow = null;
if ( _flamePlacedShown )
{
var blue = FlameBlueShown;
var worn = _defenseShown == Defense.Running
? 1f - 0.65f * Math.Clamp( _altarHitsShown / (float)AltarHitsToFail, 0f, 1f )
: 1f;
glow = GlowMaterial( blue ? "blue" : "purple", blue ? FlameBlueHue : CursedPurple, AltarGlowDrive * worn );
}
foreach ( var r in _altarGo.Components.GetAll<ModelRenderer>( FindMode.EverythingInDescendants ) )
r.MaterialOverride = glow;
}
// ══ commands ════════════════════════════════════════════════════════════════════════════
/// <summary>
/// `nz_hex_defense [start|win|fail|stop|hit]` — HOST: the altar's defense as it stands. `start` sets the cursed flame on the
/// altar by hand and begins it; `win` ends it held, as its minute would; `fail` ends it fallen, as ten hits would; `stop`
/// ends it quietly, the flame still purple on the altar; `hit` is one hit on the altar, as a zombie's swing is.
/// </summary>
[ConCmd( "nz_hex_defense" )]
public static void DefenseCmd( 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":
if ( m._bonfire != BonfireOut ) { Log.Warning( "[nz-hex] the fire is not out, so there is no cursed flame — nz_hex_skipto torch first" ); return; }
m._flameCarrier = "";
m._flameLost = false;
m._flamePlaced = true;
m.SendFlame();
m.StartDefense();
break;
case "win":
if ( m._defense != Defense.Running ) { Log.Warning( "[nz-hex] the defense is not running — nz_hex_defense start" ); return; }
m.WinDefense();
break;
case "fail":
if ( m._defense != Defense.Running ) { Log.Warning( "[nz-hex] the defense is not running — nz_hex_defense start" ); return; }
m._altarHits = AltarHitsToFail - 1;
m.TakeAltarHit();
break;
case "stop":
m.EndDefense( Defense.None );
break;
case "hit":
m.TakeAltarHit();
break;
default:
Log.Warning( "[nz-hex] nz_hex_defense start, win, fail, stop or hit — or nothing, to see where it stands" );
return;
}
var alive = m._wave?.Count( z => z.IsValid() && z.State != ZombieState.Dead ) ?? 0;
Log.Info( m._defense switch
{
Defense.Running => $"[nz-hex] ⚔ the altar's defense: {(float)m._defenseLeft:0}s left, {m._altarHits} of {AltarHitsToFail} hits,"
+ $" {alive} of the wave alive · the round is held open",
Defense.Won => "[nz-hex] the altar is held — the cursed flame burns light blue" + ( m._flamePlaced ? " on it" : ", carried" ),
_ => "[nz-hex] the altar's defense: not running" + ( m._flamePlaced ? " (the flame is on the altar)" : "" ),
} );
}
/// <summary>
/// `nz_hex_defense_tune [seconds] [hits] [every] [maxAlive] [spawns] [shriekerEvery]` — HOST: the defense's numbers: how
/// long it must be held, how many hits fail it, how often the wave comes and how many of it may live, from how many of
/// the spawns nearest the altar, and how often a Shrieker. Bare, it prints them. Until a restart.
/// </summary>
[ConCmd( "nz_hex_defense_tune" )]
public static void DefenseTuneCmd( float seconds = 0f, int hits = 0, float every = 0f, int maxAlive = 0, int spawns = 0, float shriekerEvery = 0f )
{
if ( seconds > 0f ) DefenseSeconds = seconds;
if ( hits > 0 ) AltarHitsToFail = hits;
if ( every > 0f ) WaveEvery = every;
if ( maxAlive > 0 ) WaveMaxAlive = maxAlive;
if ( spawns > 0 ) WaveSpawns = spawns;
if ( shriekerEvery > 0f ) ShriekerEvery = shriekerEvery;
Log.Info( $"[nz-hex] ⚔ the altar's defense: hold it {DefenseSeconds:0}s, {AltarHitsToFail} hits fail it; the wave comes every"
+ $" {WaveEvery:0.##}s while under {WaveMaxAlive} live, from the {WaveSpawns} open spawns nearest the altar; a Shrieker every"
+ $" {ShriekerEvery:0}s, {WaveShriekers} at most" );
}
/// <summary>
/// `nz_hex_defense_points [kill] [hit]` — what a zombie's kill and a hit pay while the altar's defense runs: 10 and 1.
/// Bare, it prints them. On each machine it is read on — the host's for the award, the killer's for the augments' — until
/// a restart.
/// </summary>
[ConCmd( "nz_hex_defense_points" )]
public static void DefensePointsCmd( int kill = -1, int hit = -1 )
{
if ( kill >= 0 ) DefenseKillPoints = kill;
if ( hit >= 0 ) DefenseHitPoints = hit;
Log.Info( $"[nz-hex] ⚔ while the altar's defense runs, a zombie's kill pays {DefenseKillPoints} and a hit {DefenseHitPoints},"
+ " flat — no Double Points, no kill bonuses" + ( DefensePoints ? " — running now" : "" ) );
}
/// <summary>
/// `nz_hex_altar_glow [drive]` — how hard the engravings shine in the flame's colour: 2.4. On this machine, until a
/// restart.
/// </summary>
[ConCmd( "nz_hex_altar_glow" )]
public static void AltarGlowCmd( float drive = 0f )
{
if ( drive > 0f ) AltarGlowDrive = drive;
var m = Instance;
if ( m.IsValid() ) m.DressAltar();
Log.Info( $"[nz-hex] the altar's engravings shine at {AltarGlowDrive:0.##} in the flame's colour while it burns there" );
}
}