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).
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"/> > 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"/> > 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;
}
}