Part of HexPlatforms game component handling the Bonfire step of a multi-step event. Manages host state for stage and pest counts, mirrors state to clients, detects zombie deaths on tile 1, builds a local visual bonfire (flames, sound, pit visual), provides console commands to drive and debug the bonfire, and coordinates transitions to Rings and Torch Carry steps.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// BASALT — STEP 2, BONFIRE (named by the user, 2026-09-26). It begins when Color Smash is done, with the offering in red
/// on tile 1's top. Kill a napalm zombie on top of tile 1 and the whole platform catches fire; then kill pests on the
/// burning platform until the offering turns green, <see cref="BonfireTarget"/> of them. Asked for as *"we need to kill a
/// napalm zombie on top of that platform. after doing so the whole platform lights on fire. then we need to kill pests on
/// top of the platform until the icon turns green, about 30 kills"*, and *"this fire does not hurt the player"*.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the STAGE and the PEST COUNT are HOST state, MIRRORED whole on every change (`NZNet.HexBonfire`) and to a joiner;
/// - the FIRE and the OFFERING'S COLOUR are LOCAL: every machine dresses tile 1 from what it was sent
/// (<see cref="BuildRewardTile"/>).
///
/// ⚠️ A DEATH COUNTS WHERE THE BODY FELL, as a soul box counts it. `ZombieAI.Die` — the one site every kill funnels through,
/// and only on the host — hands over the variant and the position, whatever did the killing. A napalm zombie cannot die
/// but by a player (its wind-up does not kill it, `NapalmZombie`), so "killed on top of it" is its death there.
///
/// ⚠️ THE FIRE HURTS NOBODY, as asked. It is the napalm's own flame and a napalm pit's glow, never fading, with no
/// `NapalmBlaze` under it. The napalm zombie's death still leaves its own 20-second pit on top, as it does anywhere.
///
/// ⚠️ IT DOES NOT RESET WITH THE ROUNDS. Only a new game starts it over, together with Color Smash (<see cref="SetDone"/>).
///
/// Its end begins the next step, the rings (`HexPlatforms.Rings.cs`).
///
/// ⛔ AND ITS FIRE IS WHERE STEP 4, TORCH CARRY, BEGINS (`HexPlatforms.Torch.cs`). Once Color Rings is done, a Shrieker
/// dying on tile 1's top puts the fire out (<see cref="BonfireOut"/>, the stage after done): the flames and the offering
/// go, and the cursed flame is left floating over the tile, to be picked up. The same message carries the stage, so it is
/// HOST state mirrored like the rest.
/// </summary>
public sealed partial class HexPlatforms
{
// ══ the stages ══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Bonfire's stages: waiting for the napalm zombie, burning (the pests), done (the offering green) — and out, Torch
/// Carry: a Shrieker died in the fire once Color Rings was done, and the cursed flame is left in its place.
/// </summary>
public const int BonfireWaiting = 0, BonfireLit = 1, BonfireDone = 2, BonfireOut = 3;
static int? _bonfireTarget;
/// <summary>How many pests must die on the burning platform before the offering turns green: 30, "about 30".</summary>
public static int BonfireTarget { get => Math.Max( 1, _bonfireTarget ?? 30 ); set => _bonfireTarget = value; }
/// <summary>
/// How far past a tile's edge a zombie may be and still be on top of it, and how far off its top, in units: a body on
/// tile 1 here, and a zombie an ammo mod goes off on, on a Color Smash tile, for the rings.
/// </summary>
const float BodyEdgeSlack = 24f, BodyHeightSlack = 48f;
/// <summary>HOST — where Bonfire stands, and how many pests have died on the burning platform.</summary>
int _bonfire, _pests;
/// <summary>MIRROR — the same, as this machine was told (`NZNet.HexBonfire`). Tile 1 is dressed from it.</summary>
int _bonfireShown, _pestsShown;
/// <summary>HOST — Bonfire as it stands, for `NZNet.PushState` to replay to a joiner.</summary>
public static (int Stage, int Pests) BonfireState => Instance.IsValid() ? (Instance._bonfire, Instance._pests) : (0, 0);
/// <summary>Tell everybody where Bonfire stands. HOST.</summary>
void SendBonfire() => NZNet.HexBonfire( _bonfire, _pests );
/// <summary>Where Bonfire stands. EVERY machine — `NZNet.HexBonfire`: tile 1 dressed to match.</summary>
public void ApplyBonfire( int stage, int pests )
{
_bonfireShown = stage;
_pestsShown = pests;
BuildRewardTile();
}
/// <summary>Bonfire back to its start. HOST — a new game, with Color Smash (<see cref="SetDone"/>).</summary>
void ResetBonfire()
{
// the rings and Torch Carry follow Bonfire, and go with it
ResetRings();
ResetFlame();
if ( _bonfire == BonfireWaiting && _pests == 0 ) return;
_bonfire = BonfireWaiting;
_pests = 0;
SendBonfire();
}
/// <summary>Where Bonfire stands, in words. HOST.</summary>
string BonfireStateText() => !_done ? "Bonfire: waiting for Color Smash" : _bonfire switch
{
BonfireWaiting => "Bonfire: waiting for a napalm zombie to die on tile 1",
BonfireLit => $"Bonfire: burning — {_pests} of {BonfireTarget} pests killed on it",
BonfireDone => $"Bonfire DONE — {_pests} pests killed on it; the offering is green"
+ ( _rings.Done ? " · Torch Carry: a Shrieker killed in the fire puts it out" : " · Torch Carry waits for Color Rings" ),
_ => "Bonfire OUT — a Shrieker died in it · " + TorchStateText(),
};
// ══ the deaths ══════════════════════════════════════════════════════════════════════════
/// <summary>
/// A zombie died here. HOST — `ZombieAI.Die`, beside the soul boxes' hook. A napalm zombie dying on tile 1 lights the
/// bonfire; a pest dying on the burning platform counts; and, once Color Rings is done, a Shrieker dying in the fire
/// puts it out. And once the twin shield is down, a Shrieker dying on the 1911 platform counts
/// (`HexPlatforms.Shriekers.cs`).
/// </summary>
public static void OnZombieKilled( ZombieVariant variant, Vector3 at )
{
if ( NZGame.IsClient || !OnBasalt ) return;
var m = Instance;
if ( !m.IsValid() ) return;
var kind = KindOf( variant );
m.BodyFell( kind, at );
m.ShriekerFell( kind, at );
}
/// <summary>
/// Which of the fire's specials a variant is — napalm, pest or Shrieker, by its id — or "" for any other. BY PATH, as
/// `AmbientSpecials.AliveOf` asks: the pest has no component of its own to look for.
/// </summary>
static string KindOf( ZombieVariant variant )
{
var path = variant?.ResourcePath;
if ( string.IsNullOrEmpty( path ) ) return "";
foreach ( var id in new[] { SpecialEnemies.Napalm, SpecialEnemies.Pest, SpecialEnemies.Shrieker } )
if ( path.EndsWith( SpecialEnemies.PathFor( id ), StringComparison.OrdinalIgnoreCase ) ) return id;
return "";
}
/// <summary>Is a body lying here on top of tile 1?</summary>
static bool OnTileOne( Vector3 at )
=> OutOf( at, RewardTile ) <= BodyEdgeSlack && MathF.Abs( at.z - RewardTile.Top ) <= BodyHeightSlack;
/// <summary>A body of this kind fell here, by Bonfire's rules and step 4's. HOST — apart from the hook, so the selftest can walk it.</summary>
void BodyFell( string kind, Vector3 at )
{
if ( !_done || !OnTileOne( at ) ) return;
// Torch Carry's start, once Bonfire is done: a Shrieker dying in the fire, with Color Rings done too, puts it out
// (`HexPlatforms.Torch.cs`) — and nothing dying in it after lights it again
if ( _bonfire >= BonfireDone )
{
if ( _bonfire == BonfireDone && kind == SpecialEnemies.Shrieker && _rings.Done ) PutOut();
return;
}
if ( _bonfire == BonfireWaiting )
{
if ( kind != SpecialEnemies.Napalm ) return;
_bonfire = BonfireLit;
SendBonfire();
Log.Info( "[nz-hex] 🔥 BONFIRE LIT — a napalm zombie died on tile 1, and the platform is burning."
+ $" Now {BonfireTarget} pests killed on it" );
return;
}
if ( kind != SpecialEnemies.Pest ) return;
_pests++;
if ( _pests >= BonfireTarget )
{
_bonfire = BonfireDone;
Fanfare( 2 );
Log.Info( $"[nz-hex] ✦ BONFIRE DONE — {_pests} pests killed on the burning platform. The offering turns green" );
}
else if ( _pests % 5 == 0 )
Log.Info( $"[nz-hex] Bonfire: {_pests} of {BonfireTarget} pests" );
SendBonfire();
// …and at its end the next step begins: the rings
if ( _bonfire >= BonfireDone ) StartRings();
}
// ══ the fire ════════════════════════════════════════════════════════════════════════════
//
// ⚠️ THE NAPALM'S OWN FLAME, CLONED ACROSS THE TOP, AND A NAPALM PIT'S GLOW. `napalm_flame.prefab` is what a burning body
// wears (`StatusEffects.ParticlesFor( "burn" )`) and is sized for one body, so a burning platform is many of them, the way
// `LavaFog` scatters its clouds. `PitVisual`'s fire style gives the glow, the smoke and the ring, told never to fade:
// its life is infinite, so `1 - age / life` stays 1.
const string FlamePrefab = "prefabs/particles/nz/napalm_flame.prefab";
const string FireLoop = "sounds/effects/fire/fire_burn_loop01.sound";
static int? _flames;
/// <summary>How many flames burn on tile 1's top: 19, one in the middle and two rings round it.</summary>
public static int Flames { get => Math.Clamp( _flames ?? 19, 1, 37 ); set => _flames = value; }
static float? _flameScale;
/// <summary>How much bigger each flame is than on a burning body.</summary>
public static float FlameScale { get => _flameScale ?? 2.5f; set => _flameScale = value; }
/// <summary>LOCAL — the bonfire over tile 1's top, and what it was built with.</summary>
GameObject _fireGo;
(int Flames, float Scale) _fireBuilt;
/// <summary>Once a flame would not clone, say so once and stop trying — a failure stays failed until a restart.</summary>
static bool _flameFailed;
/// <summary>The bonfire, if Bonfire is burning or done on this machine — not once Torch Carry has put it out. LOCAL.</summary>
void BuildBonfire()
{
var want = OnBasalt && _doneShown && _bonfireShown >= BonfireLit && _bonfireShown < BonfireOut;
if ( !want ) { ClearBonfire(); return; }
if ( _fireGo.IsValid() && _fireBuilt == (Flames, FlameScale) ) return;
ClearBonfire();
var top = new Vector3( RewardTile.X, RewardTile.Y, RewardTile.Top );
var root = Scene.CreateObject();
root.Name = $"Hex tile {RewardTile.Id} — the bonfire";
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 = top;
// the glow, the smoke and the ring of a napalm pit, never fading
PitVisual.Attach( root, Apothem, PitVisual.Style.Fire, float.PositiveInfinity );
foreach ( var p in FlamePoints( Flames ) )
{
var at = top + new Vector3( p.x, p.y, 2f );
GameObject f;
try { f = _flameFailed ? null : GameObject.Clone( FlamePrefab, new Transform( at ) ); }
catch ( Exception ) { f = null; }
if ( !f.IsValid() )
{
if ( !_flameFailed ) Log.Warning( $"[nz-hex] {FlamePrefab} would not clone — the bonfire has its glow and smoke, and no flames" );
_flameFailed = true;
break;
}
// ⛔ SetParent KEEPS THE WORLD TRANSFORM, so the place is set again after it (`DamageWallManager`'s note)
f.Flags |= GameObjectFlags.NotSaved;
f.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
f.SetParent( root );
f.WorldPosition = at;
f.WorldScale = FlameScale;
}
// its roar: the fire loop the lava walls burn with
var sound = ResourceLibrary.Get<SoundEvent>( FireLoop );
if ( sound is not null )
{
// ⚠️ OVERRIDES ARE OPT-IN ON THIS COMPONENT: a Volume or Distance without its `…Override` flag is ignored
var sp = root.Components.Create<SoundPointComponent>();
sp.SoundEvent = sound;
sp.PlayOnStart = true;
sp.SoundOverride = true;
sp.Volume = 1f;
sp.DistanceAttenuationOverride = true;
sp.DistanceAttenuation = true;
sp.Distance = 1400f;
}
_fireGo = root;
_fireBuilt = (Flames, FlameScale);
}
void ClearBonfire()
{
if ( _fireGo.IsValid() ) _fireGo.Destroy();
_fireGo = null;
}
/// <summary>
/// Where the flames stand on tile 1's top, nearest the middle first: a triangular grid 45u apart, kept 24u inside the
/// edge so none hangs over it.
/// </summary>
static IEnumerable<Vector2> FlamePoints( int count )
{
const float gap = 45f;
var pts = new List<Vector2>();
for ( var j = -4; j <= 4; j++ )
for ( var i = -4; i <= 4; i++ )
{
var p = new Vector2( (i + j * 0.5f) * gap, j * gap * 0.8660254f );
if ( OutOf( new Vector3( RewardTile.X + p.x, RewardTile.Y + p.y, 0f ), RewardTile ) <= -24f ) pts.Add( p );
}
return pts.OrderBy( p => p.Length ).Take( count );
}
// ══ commands ════════════════════════════════════════════════════════════════════════════
/// <summary>
/// `nz_hex_bonfire [light|pest|done|shrieker|out|reset] [n]` — HOST: where Bonfire stands. To test what follows it:
/// `light` lights it as a napalm zombie's death on tile 1 would, `pest [n]` counts n pests killed on it (one if none
/// given), `done` finishes it — and lights it again after Torch Carry, the cursed flame gone — `shrieker` is a
/// Shrieker's death in the fire, by Torch Carry's rule (Color Rings done first: `nz_hex_rings solve`), `out` puts it out
/// whatever the rings, and `reset` puts it back to waiting for the napalm zombie. It needs Color Smash done first
/// (`nz_hex_step done`).
/// </summary>
[ConCmd( "nz_hex_bonfire" )]
public static void BonfireCmd( string what = "", int n = 1 )
{
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; }
var one = new Vector3( RewardTile.X, RewardTile.Y, RewardTile.Top );
var cmd = what.Trim().ToLowerInvariant();
if ( cmd is "light" or "pest" or "done" or "shrieker" or "out" && !m._done )
{
Log.Warning( "[nz-hex] Color Smash is not done, and Bonfire begins after it — nz_hex_step done first" );
return;
}
switch ( cmd )
{
case "":
break;
case "light":
m.BodyFell( SpecialEnemies.Napalm, one );
break;
case "pest":
for ( var i = 0; i < Math.Max( 1, n ); i++ ) m.BodyFell( SpecialEnemies.Pest, one );
break;
case "done":
m.ResetFlame();
m._bonfire = BonfireDone;
m._pests = Math.Max( m._pests, BonfireTarget );
m.SendBonfire();
m.StartRings();
break;
case "shrieker":
if ( m._bonfire < BonfireDone )
{
Log.Warning( "[nz-hex] Bonfire is not done, so Torch Carry cannot begin yet — nz_hex_bonfire done first" );
return;
}
if ( !m._rings.Done )
Log.Warning( "[nz-hex] Color Rings is not done, so a Shrieker in the fire puts nothing out yet — nz_hex_rings solve first" );
m.BodyFell( SpecialEnemies.Shrieker, one );
break;
case "out":
if ( m._bonfire == BonfireOut ) { Log.Info( "[nz-hex] the bonfire is out already" ); break; }
m.PutOut( "hand" );
break;
case "reset":
m.ResetFlame();
m._bonfire = BonfireWaiting;
m._pests = 0;
m.SendBonfire();
m.ResetRings();
break;
default:
Log.Warning( "[nz-hex] nz_hex_bonfire light, pest [n], done, shrieker, out or reset — or nothing, to see where it stands" );
return;
}
Log.Info( $"[nz-hex] {m.BonfireStateText()}" );
}
/// <summary>
/// `nz_hex_bonfire_target [n]` — HOST: how many pests turn the offering green; bare, it prints it. Until a restart —
/// set the default in `BonfireTarget` once it is settled.
/// </summary>
[ConCmd( "nz_hex_bonfire_target" )]
public static void BonfireTargetCmd( int n = 0 )
{
if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] host only" ); return; }
if ( n > 0 ) BonfireTarget = n;
Log.Info( $"[nz-hex] Bonfire wants {BonfireTarget} pests killed on the burning platform" );
}
/// <summary>
/// `nz_hex_bonfire_flames [count] [scale]` — how many flames burn on tile 1 and how big, redrawn at once; bare, it
/// prints them. On this machine and until a restart: a fire is judged by looking at it, so settle on the numbers here,
/// then set them as the defaults in `Flames` and `FlameScale`.
/// </summary>
[ConCmd( "nz_hex_bonfire_flames" )]
public static void FlamesCmd( int count = 0, float scale = 0f )
{
if ( count > 0 ) Flames = count;
if ( scale > 0f ) FlameScale = scale;
if ( (count > 0 || scale > 0f) && Instance.IsValid() ) Instance.BuildRewardTile();
Log.Info( $"[nz-hex] the bonfire burns {Flames} flames, each {FlameScale:0.##}× a burning body's" );
}
}