EasterEgg/HexPlatforms.Bonfire.cs

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.

NetworkingFile Access
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" );
	}
}