Zombies/BossKit.cs

Visual and gameplay effects for boss monsters and related components. Provides localized (this machine) and shared burst, flash, sparks, daze, devour, hide/show, and parts operations, plus three component types: BossOrb (timed explosive orb the host spawns and bursts), BossGlow (bone-attached light and sparks), and BossFlame (nozzle flame jet with host-driven damage and sound).

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

namespace NZombies;

/// <summary>
/// Effects the third batch of bosses shares (2026-10-06), each drawn on THIS machine — the `NZNet.Boss*` messages put them
/// on every machine: a burst (a ring on the floor, a flash, sparks flung out, and this machine's own player dazed if inside),
/// a fading flash, and sparks.
/// </summary>
public static class BossFx
{
	/// <summary>
	/// A burst at <paramref name="at"/> on THIS machine: a ring out to <paramref name="radius"/>, a flash, <paramref name="sparks"/>
	/// sparks flung out, and — if <paramref name="daze"/> &gt; 0 — this machine's own player dazed when inside (upstream's
	/// `NZSonicBlind`, the Shrieker's daze here: only the owner can put it on its own movement).
	/// </summary>
	public static void Burst( Vector3 at, float radius, Color colour, int sparks = 6, float daze = 0f )
	{
		ShockRing.Fire( at, MathF.Max( 16f, radius ), colour );
		Flash( at + Vector3.Up * 8f, colour, MathF.Max( 120f, radius * 2f ) );
		Sparks( at + Vector3.Up * 12f, colour, sparks, MathF.Max( 24f, radius * 0.6f ) );

		if ( daze <= 0f ) return;
		var me = NZPlayer.Local;
		if ( !me.IsValid() || me.IsDown || me.IsOutOfRound ) return;
		if ( (me.WorldPosition + Vector3.Up * 40f).Distance( at ) > radius ) return;
		SonicDaze.Apply( me, daze );
	}

	/// <summary>
	/// <see cref="Burst"/> on EVERY machine (`NZNet.BossBurstFx`). THE HOST — for a cause only the host simulates (a mine, a
	/// pulse); solo it simply draws here.
	/// </summary>
	public static void BurstShared( Vector3 at, float radius, Color colour, int sparks = 6, float daze = 0f )
		=> NZNet.BossBurstFx( at, radius, colour.r, colour.g, colour.b, sparks, daze );

	/// <summary>
	/// One player a boss's attack reached, on THEIR machine only (`NZNet.BossDaze`, 2026-10-07 — the Tesla Zombie's scream reaches
	/// only those in his sight): dazed for <paramref name="daze"/> s (the Shrieker's daze, upstream's `NZSonicBlind`) and, with
	/// <paramref name="shock"/> &gt; 0, shocked — no shooting (Avogadro's `PlayerShock`). The host picks who; <see cref="Burst"/>'s
	/// own daze takes everyone inside its ring.
	/// </summary>
	public static void Daze( Guid body, float daze, float shock )
	{
		var me = NZPlayer.Local;
		if ( !me.IsValid() || me.GameObject.Id != body || me.IsDown || me.IsOutOfRound ) return;
		if ( daze > 0f ) SonicDaze.Apply( me, daze );
		if ( shock > 0f ) PlayerShock.Apply( me, shock );
	}

	/// <summary>A light at <paramref name="at"/> that dies away in a quarter second, on THIS machine.</summary>
	public static void Flash( Vector3 at, Color colour, float radius )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		var go = new GameObject( true, "nz_boss_flash" );
		go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		go.NetworkMode = NetworkMode.Never;
		go.WorldPosition = at;

		var light = go.Components.Create<PointLight>();
		light.LightColor = colour * 6f;
		light.Radius = radius;
		light.Shadows = false;
		go.Components.Create<AvogadroFlash>().Light = light;
	}

	/// <summary>A boss's bodygroup on THIS machine (`NZNet.BossPart`): a mask off, a plate gone — or a part the boss draws itself (`BossBase.ShowPart`).</summary>
	public static void SetPart( Guid id, string group, int choice )
	{
		var go = Game.ActiveScene?.Directory.FindByGuid( id );
		if ( !go.IsValid() ) return;
		var boss = go.Components.Get<BossBase>( FindMode.EverythingInSelfAndDescendants );
		if ( boss.IsValid() && boss.ShowPart( group, choice ) ) return;
		var r = go.Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );
		if ( r.IsValid() ) r.SetBodyGroup( group, choice );
	}

	/// <summary>
	/// A boss ate THIS machine's player where they lay (2026-10-07, the Thrasher's `nz_thrasher_eat`): still down and in the
	/// round, they bleed out at once (`NZPlayer.KillOutright`, basalt's lava's death) — out until the next round while anybody is
	/// still up, the run over when nobody is. ⚠️ ON THE VICTIM'S MACHINE: a down and its bleedout are the owner's; a revive that
	/// got there first wins.
	/// </summary>
	public static void Devour( Guid body )
	{
		var me = NZPlayer.Local;
		if ( !me.IsValid() || me.GameObject.Id != body || !me.IsDown || me.IsOutOfRound ) return;
		Log.Info( "[nz] eaten where you lay — bled out" );
		me.KillOutright();
	}

	/// <summary>
	/// A boss hidden or shown on THIS machine (`NZNet.BossHidden`): the body down to its shadow (`MargwaFx.SetHidden` — ⛔ never
	/// `Enabled`, whose sequence accessor throws) and its glows out.
	/// </summary>
	public static void SetHidden( Guid id, bool hidden )
	{
		var go = Game.ActiveScene?.Directory.FindByGuid( id );
		if ( !go.IsValid() ) return;
		MargwaFx.SetHidden( go, hidden );
		foreach ( var g in go.Components.GetAll<BossGlow>( FindMode.EverythingInSelfAndDescendants ) ) g.Lit = !hidden;
	}

	/// <summary><paramref name="count"/> short sparks flung out from <paramref name="at"/>, up to <paramref name="reach"/> long, on THIS machine.</summary>
	public static void Sparks( Vector3 at, Color colour, int count, float reach )
	{
		for ( int i = 0; i < count; i++ )
		{
			var dir = Vector3.Random.Normal;
			AvogadroSpark.Between( at, at + dir * Game.Random.Float( 0.4f, 1f ) * reach, Game.Random.Float( 0.08f, 0.22f ), 0.18f,
				colour );
		}
	}
}

/// <summary>
/// A timed orb of a boss (2026-10-07) — Zaballa's mines: it sits where it was laid, glowing and crackling in its colour, and
/// when its fuse is out it bursts: every player up within its radius hurt (more at the middle, never more than
/// `BossBase.MaxHit`) and dazed, the burst on every machine (`BossFx.BurstShared`).
///
/// ⚠️ THE HOST LAYS IT AND BURSTS IT; EVERYONE ELSE WATCHES. `Lay` network-spawns it after its look is set (`[Property]`, so the
/// spawn carries it), and the look is built on every machine.
/// </summary>
public sealed class BossOrb : Component
{
	[Property] public Color Colour { get; set; } = Color.Red;
	[Property] public float Fuse { get; set; } = 1f;
	/// <summary>How big it looks: 1 is a fist-sized orb.</summary>
	[Property] public float Look { get; set; } = 1f;

	// ── the host's ──
	public GameObject Owner { get; set; }
	public float Damage { get; set; }
	public float Radius { get; set; } = 50f;
	public float Daze { get; set; }
	public float Floor { get; set; }
	public string Sound { get; set; }

	float _born;
	float _nextSpark;
	bool _done;
	PointLight _light;
	GameObject _lightGo;

	/// <summary>Lay one at <paramref name="at"/>. THE HOST; a client only watches.</summary>
	public static BossOrb Lay( Scene scene, Vector3 at, Color colour, float fuse, GameObject owner, float damage, float radius, float daze,
		string sound, float look = 1f, float floor = 0f )
	{
		if ( !scene.IsValid() || NZGame.IsClient ) return null;

		var go = new GameObject( true, "nz_boss_orb" );
		go.Flags |= GameObjectFlags.NotSaved;
		go.WorldPosition = at;

		var o = go.Components.Create<BossOrb>();
		o.Colour = colour;
		o.Fuse = MathF.Max( 0.05f, fuse );
		o.Look = look;
		o.Owner = owner;
		o.Damage = damage;
		o.Radius = radius;
		o.Daze = daze;
		o.Floor = floor;
		o.Sound = sound;

		go.NetworkSpawn();
		return o;
	}

	protected override void OnStart()
	{
		_born = Time.Now;
		_lightGo = new GameObject { Parent = GameObject, Name = "light" };
		_lightGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		_light = _lightGo.Components.Create<PointLight>();
		_light.LightColor = Colour * 3f;
		_light.Radius = 110f * Look;
		_light.Shadows = false;
	}

	protected override void OnUpdate()
	{
		var t = Math.Clamp( (Time.Now - _born) / MathF.Max( 0.05f, Fuse ), 0f, 1f );

		// ── every machine: a light that pulses faster as the fuse runs out, and sparks round it ──
		if ( _light.IsValid() )
			_light.LightColor = Colour * (2.5f + 2f * t) * (0.75f + 0.25f * MathF.Sin( Time.Now * (12f + 30f * t) ));
		if ( Time.Now >= _nextSpark )
		{
			_nextSpark = Time.Now + 0.04f;
			var c = WorldPosition;
			AvogadroSpark.Between( c + Vector3.Random * 3f * Look, c + Vector3.Random.Normal * Game.Random.Float( 8f, 16f ) * Look,
				Game.Random.Float( 0.05f, 0.12f ), 0.14f, Colour );
		}

		if ( _done || NZGame.IsClient || t < 1f ) return;
		Burst();
	}

	void Burst()
	{
		_done = true;
		var at = WorldPosition;
		foreach ( var p in Scene.GetAllComponents<NZPlayer>().ToList() )
		{
			if ( !BossBase.IsUp( p ) ) continue;
			var d = (p.WorldPosition + Vector3.Up * 40f).Distance( at );
			if ( d > Radius ) continue;
			var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
			if ( !hp.IsValid() || hp.IsDead ) continue;
			var share = Math.Clamp( 1f - d / MathF.Max( 1f, Radius ), Floor, 1f );
			if ( share <= 0f ) continue;
			hp.Apply( MathF.Min( Damage * share, BossBase.MaxHit ), false, Owner.IsValid() ? Owner : GameObject, blast: true, blastAt: at );
		}

		BossFx.BurstShared( at, Radius, Colour, 10, Daze );
		if ( !string.IsNullOrEmpty( Sound ) ) NZSound.PlayShared( Sound, at );
		NZNet.ShakeAt( at, 0.35f, 400f );
		GameObject.Destroy();
	}

	protected override void OnDestroy()
	{
		if ( _lightGo.IsValid() ) _lightGo.Destroy();
	}
}

/// <summary>
/// A light that lives on a bone of a boss, on EVERY machine — an aura: a light that never quite sits still, and now and then
/// a spark off the body. Its boss turns it off while it is hidden (a teleport) and it goes out with the boss's death.
/// </summary>
public sealed class BossGlow : Component
{
	/// <summary>The bone it sits on, and an offset in that bone's frame; no bone, the boss's middle.</summary>
	public string Bone { get; set; }
	public Vector3 Offset { get; set; }

	public Color Colour { get; set; } = Color.White;
	public float Radius { get; set; } = 160f;
	public float Brightness { get; set; } = 2f;

	/// <summary>Seconds between sparks off the body (0, none), how long they reach, and their colour.</summary>
	public float SparkEvery { get; set; }
	public float SparkReach { get; set; } = 18f;
	public Color SparkColour { get; set; } = Color.White;

	/// <summary>Lit — its boss puts it out while hidden.</summary>
	public bool Lit { get; set; } = true;

	SkinnedModelRenderer _body;
	ZombieAI _ai;
	PointLight _light;
	GameObject _lightGo;
	float _nextSpark, _seed;

	protected override void OnStart()
	{
		_body = Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );
		_ai = Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
		_seed = Game.Random.Float( 0f, 100f );

		_lightGo = new GameObject { Parent = GameObject, Name = "nz_boss_glow" };
		_lightGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		_light = _lightGo.Components.Create<PointLight>();
		_light.LightColor = Color.Black;
		_light.Radius = Radius;
		_light.Shadows = false;
	}

	/// <summary>Where it sits now.</summary>
	public Vector3 Point
	{
		get
		{
			if ( !string.IsNullOrEmpty( Bone ) && _body.IsValid() && _body.TryGetBoneTransform( Bone, out var t ) )
				return t.PointToWorld( Offset );
			return WorldPosition + Vector3.Up * 45f * MathF.Max( 0.1f, WorldScale.x );
		}
	}

	protected override void OnUpdate()
	{
		if ( !_light.IsValid() ) return;
		var dead = _ai.IsValid() && _ai.State == ZombieState.Dead;
		if ( !Lit || dead )
		{
			_light.LightColor = Color.Black;
			return;
		}

		var at = Point;
		_lightGo.WorldPosition = at;
		var t = Time.Now + _seed;
		_light.Radius = Radius;
		_light.LightColor = Colour * Brightness * (0.8f + 0.12f * MathF.Sin( t * 13f ) + 0.08f * MathF.Sin( t * 37f ));

		if ( SparkEvery <= 0f || Time.Now < _nextSpark ) return;
		_nextSpark = Time.Now + SparkEvery * Game.Random.Float( 0.6f, 1.4f );
		var from = at + Vector3.Random * 8f;
		AvogadroSpark.Between( from, from + Vector3.Random.Normal * SparkReach * Game.Random.Float( 0.5f, 1f ),
			Game.Random.Float( 0.06f, 0.14f ), 0.12f, SparkColour );
	}

	protected override void OnDestroy()
	{
		if ( _lightGo.IsValid() ) _lightGo.Destroy();
	}
}

/// <summary>
/// A flamethrower on a boss, on EVERY machine (Brenner's, the Krasny Soldat's, the Panzerhund's breath — 2026-10-06): the
/// jet thrown from a nozzle on a bone, its light, and its sounds (the Panzer's mechz flame, which all three use upstream:
/// start, a loop that follows the nozzle, end). The host turns it on and off (`NZNet.BossFlame`); the host's damage is
/// <see cref="Burn"/>, along the same aim every machine draws (`PanzerLook.FlameDir`, the Panzer's fix: at the nearest
/// player in front, never parallel to his facing from a forearm off to one side).
/// </summary>
public sealed class BossFlame : Component
{
	/// <summary>The nozzle: a bone, and an offset in its frame (the model's QC attachment).</summary>
	public string Bone { get; set; }
	public Vector3 Offset { get; set; }

	/// <summary>How far the jet reaches, at size 1.</summary>
	public float Reach { get; set; } = 325f;

	/// <summary>On, on this machine — set by `NZNet.BossFlame` from the host.</summary>
	public bool On { get; private set; }

	SkinnedModelRenderer _body;
	ZombieAI _ai;
	PointLight _light;
	GameObject _lightGo;
	SoundHandle _loop;
	float _nextPuff;

	float Size => MathF.Max( 0.1f, WorldScale.x );

	protected override void OnStart()
	{
		_body = Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );
		_ai = Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );

		_lightGo = new GameObject { Parent = GameObject, Name = "nz_boss_flame" };
		_lightGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		_light = _lightGo.Components.Create<PointLight>();
		_light.LightColor = Color.Black;
		_light.Radius = 300f;
		_light.Shadows = false;
	}

	/// <summary>Where the jet leaves the body now.</summary>
	public Vector3 Nozzle
	{
		get
		{
			if ( !string.IsNullOrEmpty( Bone ) && _body.IsValid() && _body.TryGetBoneTransform( Bone, out var t ) )
				return t.PointToWorld( Offset );
			return WorldPosition + Vector3.Up * 50f * Size;
		}
	}

	Vector3 Facing => _ai.IsValid() ? (WorldRotation * _ai.ModelTurn.Inverse).Forward.WithZ( 0 ).Normal : WorldRotation.Forward;

	/// <summary>Which way the jet goes from <see cref="Nozzle"/> — the same answer on every machine.</summary>
	public Vector3 Aim => PanzerLook.FlameDir( GameObject, Nozzle, Facing, Reach * Size );

	/// <summary>On or off, on THIS machine (`NZNet.BossFlame`): the sounds start or stop with it.</summary>
	public void Set( bool on )
	{
		if ( On == on ) return;
		On = on;

		if ( on )
		{
			NZSound.Play( "nz.panzer.flame.start", Nozzle );
			if ( _loop.IsValid() ) _loop.Stop();
			_loop = NZSound.Play( "nz.panzer.flame.loop", Nozzle );
			return;
		}

		if ( _loop.IsValid() ) _loop.Stop();
		NZSound.Play( "nz.panzer.flame.end", Nozzle );
		if ( _light.IsValid() ) _light.LightColor = Color.Black;
	}

	/// <summary>The jet on or off for the boss <paramref name="id"/> on THIS machine (`NZNet.BossFlame`).</summary>
	public static void SetOn( Guid id, bool on )
	{
		var go = Game.ActiveScene?.Directory.FindByGuid( id );
		var f = go.IsValid() ? go.Components.Get<BossFlame>( FindMode.EverythingInSelfAndDescendants ) : null;
		if ( f.IsValid() ) f.Set( on );
	}

	protected override void OnUpdate()
	{
		if ( !On ) return;
		if ( _ai.IsValid() && _ai.State == ZombieState.Dead ) { Set( false ); return; }

		var nozzle = Nozzle;
		var dir = Aim;
		if ( _loop.IsValid() ) _loop.Position = nozzle;
		if ( _light.IsValid() )
		{
			_lightGo.WorldPosition = nozzle + dir * 120f;
			_light.LightColor = PanzerFx.FireColour * (2.4f + 0.6f * MathF.Sin( Time.Now * 31f ));
		}

		if ( Time.Now < _nextPuff ) return;
		_nextPuff = Time.Now + 0.05f;
		PanzerFlamePuff.Throw( nozzle, dir );
	}

	protected override void OnDestroy()
	{
		if ( _loop.IsValid() ) _loop.Stop();
		if ( _lightGo.IsValid() ) _lightGo.Destroy();
	}

	/// <summary>
	/// One tick of flame from <paramref name="boss"/>: every player up in the box along <paramref name="dir"/> from
	/// <paramref name="nozzle"/> (<paramref name="reach"/> long, <paramref name="halfWidth"/> to each side, 60 up and down), in its
	/// line — walls stop it, where upstream's hull ignored the world — takes <paramref name="damage"/> (never more than
	/// <see cref="BossBase.MaxHit"/>) and the fire on their screen. At most <paramref name="most"/> of them, nearest first
	/// (upstream's hull trace hit only the first). Returns how many it burned. THE HOST.
	///
	/// ⚠️ AND IT MAY SET THEM ALIGHT (2026-10-07, Brenner's — the user: *"should also burn the player like the panzer fire, dealing
	/// damage over time"*): with <paramref name="burnSeconds"/> and <paramref name="burnDps"/> over 0, each one it reaches starts or
	/// renews the Fire Margwa's burn on their own machine (`NZNet.MargwaBurn`): renewed by every tick, never stacked, no regen while
	/// it lasts, never the last point. 0 (the default), none — the Krasny Soldat's and the Panzerhund's flames are unchanged.
	/// </summary>
	public static int Burn( GameObject boss, Vector3 nozzle, Vector3 dir, float reach, float halfWidth, float damage, string source,
		int most = 1, float burnSeconds = 0f, float burnDps = 0f )
	{
		if ( !boss.IsValid() || damage <= 0f ) return 0;

		var hits = new List<(NZPlayer P, float Along)>();
		foreach ( var p in boss.Scene.GetAllComponents<NZPlayer>() )
		{
			if ( !BossBase.IsUp( p ) ) continue;
			var chest = p.WorldPosition + Vector3.Up * 40f;
			var v = chest - nozzle;
			var along = v.Dot( dir );
			if ( along < 0f || along > reach ) continue;
			var side = (v - dir * along).WithZ( 0 ).Length;
			if ( side > halfWidth + 16f || MathF.Abs( v.z ) > 60f ) continue;

			var tr = boss.Scene.Trace.Ray( nozzle, chest ).IgnoreGameObjectHierarchy( boss )
				.WithoutTags( "zombie", "player", "trigger" ).Run();
			if ( tr.Hit && tr.HitPosition.Distance( chest ) > 24f ) continue;
			hits.Add( (p, along) );
		}

		var n = 0;
		foreach ( var (p, _) in hits.OrderBy( h => h.Along ).Take( Math.Max( 1, most ) ) )
		{
			var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
			if ( !hp.IsValid() || hp.IsDead ) continue;
			hp.Apply( MathF.Min( damage, BossBase.MaxHit ), false, boss, tick: true, source: source );
			NZNet.PanzerScorch( p.GameObject.Id );
			if ( burnSeconds > 0f && burnDps > 0f ) NZNet.MargwaBurn( p.GameObject.Id, burnSeconds, burnDps );
			n++;
		}
		return n;
	}
}