Visual and gameplay effects for the Avogadro boss and its interactions. Defines static helpers to play flashes, shock rings, jump telegraphs and bolt landings, plus component classes that render timed point lights, sparks, crackle on a skinned model, player shock state (blocking shooting), an AoE electric field that ticks damage, and a jump mark.
using Sandbox;
using System;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// Avogadro's look, on EVERY machine (2026-10-06): the electricity crawling over his body, the flash when he phases out
/// and back in, his shockwave's ring and throw, his bolt's landing and the patch it leaves, his jump, and a player's shock.
/// The host decides; `NZNet.AvogadroPhase`, `AvogadroShock`, `AvogadroBoltLand` and `AvogadroJump` carry it, and each machine
/// draws its own copy here.
///
/// ⚠️ UPSTREAM'S PARTICLES ARE GMOD'S (`avo_glow`, `hcea_shield_impact`, `bo3_astronaut_pulse`, `bo3_mangler_*`), WHICH THIS
/// ENGINE CANNOT LOAD. The same reads are built from this project's own primitives: jagged additive lines (`LightningArc`'s
/// recipe) and point lights in his blue, the `$emissiveBlendTint` of his own body material ([3 7 10] over its brightest).
/// </summary>
public static class AvogadroFx
{
/// <summary>His blue: the body material's own tint, [3 7 10] over 10.</summary>
public static Color Blue => new( 0.3f, 0.7f, 1f );
/// <summary>
/// Phase him out (teleporting) or back in, on THIS machine: the body hidden or shown, a flash where he stands, and his
/// crackle switched to the bright ball the upstream glow becomes while the model is invisible (`NZNet.AvogadroPhase`).
///
/// ⛔ `RenderType`, NOT `Enabled` (`MargwaFx.SetHidden`'s rule: a disabled renderer throws from its sequence accessor).
/// </summary>
public static void SetPhased( GameObject body, bool hidden )
{
if ( !body.IsValid() ) return;
MargwaFx.SetHidden( body, hidden );
var crackle = body.Components.Get<AvogadroCrackle>( FindMode.EverythingInSelfAndDescendants );
if ( crackle.IsValid() ) crackle.Phased = hidden;
Flash( body.WorldPosition + Vector3.Up * 40f * MathF.Max( 0.1f, body.WorldScale.x ), 1f );
}
/// <summary>
/// His shockwave on THIS machine: the ring in his blue, a hard shake, and THIS machine's own player thrown back and up
/// if it stands inside — upstream's `SetVelocity(dir * 255 + up * 255)` and `ViewPunch(-25, …)`. The damage is the
/// host's (`AvogadroBoss.Shockwave`).
///
/// ⚠️ THE THROW IS THE OWNER'S, as Oberon's pull is (`BossPull`): a body another machine simulates is written back within a
/// frame, so only the machine that moves this player can move it.
/// </summary>
public static void LandShock( Vector3 at, float radius, float push )
{
ShockRing.Fire( at, radius, Blue );
CameraShake.Punch( at, 0.75f, radius * 3f );
Flash( at, 1.6f );
var me = NZPlayer.Local;
if ( !me.IsValid() || me.IsDown || me.IsOutOfRound ) return;
if ( me.WorldPosition.Distance( at ) > radius ) return;
var c = me.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants );
var body = c.IsValid() ? c.Body : null;
if ( !body.IsValid() ) return;
var away = (me.WorldPosition - at).WithZ( 0f );
away = away.Length > 1f ? away.Normal : Vector3.Forward;
// ⚠️ LIFTED OFF THE FLOOR FIRST, or a grounded controller cancels the upward speed and re-snaps (`Placeable.Launch`)
me.WorldPosition += Vector3.Up * Placeable.GroundBreak;
body.Velocity += away * push + Vector3.Up * push;
ShoveGuard.Of( me )?.NoteLaunch();
}
/// <summary>
/// His bolt landing on THIS machine, since the "more dangerous" pass (2026-10-06): the flash; THIS machine's own player, if
/// inside the blast, dazed (upstream's `NZSonicBlind(2)`) AND SHOCKED — unable to shoot (`PlayerShock`; the user: *"when
/// shocked, we can make the player unable to shoot"*); and an electric patch left on the floor under it (`AvogadroField`)
/// that shocks whoever stands in it. The damage is the host's (`AvogadroBolt.End`, and the host's copy of the patch).
/// </summary>
public static void LandBoltShock( Vector3 at, float radius, float daze, float shock, float fieldSeconds, float fieldRadius,
float fieldDps )
{
Flash( at, 1.2f );
if ( fieldSeconds > 0f && fieldRadius > 0f ) AvogadroField.Lay( at, fieldRadius, fieldSeconds, fieldDps );
var me = NZPlayer.Local;
if ( !me.IsValid() || me.IsDown || me.IsOutOfRound ) return;
if ( (me.WorldPosition + Vector3.Up * 40f).Distance( at ) > radius + 16f ) return;
if ( daze > 0f ) SonicDaze.Apply( me, daze );
if ( shock > 0f ) PlayerShock.Apply( me, shock );
}
/// <summary>
/// His lightning jump on THIS machine (the user: *"teleport around to appear near any player"*): bolts from where he stood to
/// where he will stand, and a warning on that spot for <paramref name="warn"/> seconds — a ring filling in his blue and sparks
/// gathering — so the player he is coming for gets a moment to turn round.
/// </summary>
public static void Jump( Vector3 from, Vector3 to, float warn )
{
for ( int i = 0; i < 3; i++ )
LightningArc.Hang( from + Vector3.Up * Game.Random.Float( 20f, 70f ), to + Vector3.Up * Game.Random.Float( 20f, 70f ),
null, null, Blue );
Flash( from + Vector3.Up * 40f, 1f );
PulseTelegraph.Fire( to, 70f, warn, Blue );
AvogadroJumpMark.Place( to, warn );
}
/// <summary>A burst of electricity at a point on THIS machine: a light that dies in a quarter second and a few sparks.</summary>
public static void Flash( Vector3 at, float size )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
var go = new GameObject( true, "nz_avo_flash" );
go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand lit
go.WorldPosition = at;
var light = go.Components.Create<PointLight>();
light.LightColor = Blue * 6f;
light.Radius = 260f * size;
light.Shadows = false;
go.Components.Create<AvogadroFlash>().Light = light;
for ( int i = 0; i < 5; i++ )
{
var dir = Vector3.Random.Normal;
AvogadroSpark.Between( at, at + dir * Game.Random.Float( 24f, 48f ) * size, Game.Random.Float( 0.12f, 0.25f ), 0.2f );
}
}
}
/// <summary>A flash light fading out over a quarter second, then gone.</summary>
public sealed class AvogadroFlash : Component
{
public PointLight Light { get; set; }
float _born = -1f;
Color _from;
protected override void OnUpdate()
{
if ( _born < 0f )
{
_born = Time.Now;
_from = Light.IsValid() ? Light.LightColor : Color.Black;
}
var t = (Time.Now - _born) / 0.25f;
if ( t >= 1f || !Light.IsValid() )
{
GameObject.Destroy();
return;
}
Light.LightColor = _from * (1f - t) * (1f - t);
}
}
/// <summary>
/// One short jagged spark, re-kinked thirty times a second until its life is out — `LightningArc`'s recipe with a life of
/// its own (that one's is a global 0.9 s, built for Dead Wire's hops). It runs between two bones of a body (following them
/// as he moves), or between two points.
/// </summary>
public sealed class AvogadroSpark : Component
{
SkinnedModelRenderer _body;
string _boneA, _boneB;
Vector3 _a, _b;
float _dies, _nextKink, _jitter;
LineRenderer _line;
/// <summary>A spark between two points, for <paramref name="life"/> seconds.</summary>
public static AvogadroSpark Between( Vector3 a, Vector3 b, float life, float width, Color? colour = null )
=> Make( null, null, null, a, b, life, width, 5f, colour );
/// <summary>A spark between two bones of a body, following them, for <paramref name="life"/> seconds.</summary>
public static AvogadroSpark Along( SkinnedModelRenderer body, string boneA, string boneB, float life, float width, float jitter )
=> Make( body, boneA, boneB, default, default, life, width, jitter );
static AvogadroSpark Make( SkinnedModelRenderer body, string boneA, string boneB, Vector3 a, Vector3 b, float life,
float width, float jitter, Color? colour = null )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var go = new GameObject( true, "nz_avo_spark" );
go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
go.NetworkMode = NetworkMode.Never;
go.WorldPosition = a;
var s = go.Components.Create<AvogadroSpark>();
s._body = body;
s._boneA = boneA;
s._boneB = boneB;
s._a = a;
s._b = b;
s._dies = Time.Now + MathF.Max( 0.03f, life );
s._jitter = jitter;
var line = go.Components.Create<LineRenderer>();
line.UseVectorPoints = true;
line.VectorPoints = new List<Vector3>();
// ⚠️ ADDITIVE AND UNLIT (`LightningArc`'s reason): lit, a spark goes grey in shadow, where it should be brightest
line.Additive = true;
line.Lighting = false;
line.CastShadows = false;
line.Color = colour ?? new Color( 0.62f, 0.85f, 1f );
line.Width = MathF.Max( 0.03f, width );
s._line = line;
if ( !s.Ends( out var p, out var q ) ) { go.Destroy(); return null; }
s.Rebuild( p, q );
return s;
}
bool Ends( out Vector3 a, out Vector3 b )
{
a = _a;
b = _b;
if ( _boneA is null ) return true;
if ( !_body.IsValid() ) return false;
if ( !_body.TryGetBoneTransform( _boneA, out var ta ) || !_body.TryGetBoneTransform( _boneB, out var tb ) ) return false;
a = ta.Position;
b = tb.Position;
return true;
}
void Rebuild( Vector3 a, Vector3 b )
{
var dir = b - a;
var len = dir.Length;
if ( len < 0.1f ) { _line.VectorPoints = new List<Vector3> { a, b }; return; }
dir /= len;
// ⛔ TWO PERPENDICULARS, AND NOT FROM `Up` ALONE (`LightningArc.Rebuild`): a vertical spark would collapse to a line
var reference = MathF.Abs( dir.z ) > 0.9f ? Vector3.Forward : Vector3.Up;
var side = dir.Cross( reference ).Normal;
var up = dir.Cross( side ).Normal;
var n = Math.Clamp( (int)(len / 6f), 3, 10 );
var pts = new List<Vector3>( n + 1 );
for ( int i = 0; i <= n; i++ )
{
var t = i / (float)n;
var bell = MathF.Sin( t * MathF.PI );
pts.Add( a + (b - a) * t + (side * Game.Random.Float( -1f, 1f ) + up * Game.Random.Float( -1f, 1f )) * _jitter * bell );
}
_line.VectorPoints = pts;
}
protected override void OnUpdate()
{
if ( Time.Now >= _dies || !_line.IsValid() || !Ends( out var a, out var b ) )
{
GameObject.Destroy();
return;
}
if ( Time.Now < _nextKink ) return;
_nextKink = Time.Now + 1f / 30f;
Rebuild( a, b );
}
}
/// <summary>
/// The electricity on his body, on EVERY machine — upstream's `avo_glow`, which his client re-attaches whenever it lapses:
/// a blue light at his chest that never sits still, and sparks running along his limbs. While he is phased out (a teleport)
/// the light burns brighter and the sparks keep running over the hidden skeleton, so what you see crossing the floor is a
/// ball of lightning — which is what upstream's glow on an invisible model looked like.
/// </summary>
public sealed class AvogadroCrackle : Component
{
/// <summary>Teleporting: the model hidden, the light up.</summary>
public bool Phased { get; set; }
/// <summary>Dying: the light and the sparks go out over his exit.</summary>
public bool Fading { get; set; }
SkinnedModelRenderer _body;
PointLight _light;
GameObject _lightGo;
float _nextSpark, _flicker, _nextFlicker, _fade = 1f;
/// <summary>Bone pairs a spark can run along: the limbs and the spine, segment by segment.</summary>
static (string A, string B) Pair( int i ) => i switch
{
0 => ("j_head", "j_neck"),
1 => ("j_neck", "j_spineupper"),
2 => ("j_spineupper", "j_spinelower"),
3 => ("j_spinelower", "j_mainroot"),
4 => ("j_spineupper", "j_shoulder_le"),
5 => ("j_shoulder_le", "j_elbow_le"),
6 => ("j_elbow_le", "j_wrist_le"),
7 => ("j_spineupper", "j_shoulder_ri"),
8 => ("j_shoulder_ri", "j_elbow_ri"),
9 => ("j_elbow_ri", "j_wrist_ri"),
10 => ("j_mainroot", "j_hip_le"),
11 => ("j_hip_le", "j_knee_le"),
12 => ("j_knee_le", "j_ankle_le"),
13 => ("j_mainroot", "j_hip_ri"),
14 => ("j_hip_ri", "j_knee_ri"),
15 => ("j_knee_ri", "j_ankle_ri"),
16 => ("j_wrist_le", "j_hip_le"),
_ => ("j_head", "j_shoulder_ri"),
};
const int Pairs = 18;
protected override void OnStart()
{
_body = Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );
_lightGo = new GameObject { Parent = GameObject, Name = "nz_avo_glow" };
_lightGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
_light = _lightGo.Components.Create<PointLight>();
_light.LightColor = AvogadroFx.Blue;
_light.Radius = 170f;
_light.Shadows = false;
}
protected override void OnUpdate()
{
if ( !_light.IsValid() ) return;
var now = Time.Now;
if ( Fading ) _fade = MathF.Max( 0f, _fade - Time.Delta / 3.5f );
var size = MathF.Max( 0.1f, WorldScale.x );
var chest = _body.IsValid() && _body.TryGetBoneTransform( "j_spineupper", out var t )
? t.Position
: WorldPosition + Vector3.Up * 50f * size;
_lightGo.WorldPosition = chest;
if ( now >= _nextFlicker )
{
_nextFlicker = now + Game.Random.Float( 0.04f, 0.11f );
_flicker = Game.Random.Float( 0.55f, 1.15f );
}
var strength = (Phased ? 4.5f : 1.8f) * _flicker * _fade;
_light.LightColor = AvogadroFx.Blue * strength;
_light.Radius = (Phased ? 230f : 170f) * size;
if ( _fade <= 0.02f || !_body.IsValid() || now < _nextSpark ) return;
_nextSpark = now + (Phased ? Game.Random.Float( 0.02f, 0.05f ) : Game.Random.Float( 0.07f, 0.22f ));
var (a, b) = Pair( Game.Random.Int( 0, Pairs - 1 ) );
AvogadroSpark.Along( _body, a, b, Game.Random.Float( 0.08f, 0.2f ), Phased ? 0.18f : 0.1f, 2.5f * size );
}
protected override void OnDestroy()
{
if ( _lightGo.IsValid() ) _lightGo.Destroy();
}
}
/// <summary>
/// SHOCKED (2026-10-06, the user: *"when shocked, we can make the player unable to shoot"*): this machine's own player cannot
/// shoot until <see cref="Until"/>. Read by `Weapon.CanShoot`, the gate every shot passes on the shooter's own machine — so it
/// is applied there too, never by the host (Avogadro's bolt, `AvogadroFx.LandBoltShock`; his patch, `AvogadroField`).
///
/// ⚠️ THE KNIFE STILL WORKS, and that is the counter: a knife hit stuns him (`AvogadroBoss.ScaleFor`). While the shock lasts the
/// screen crackles (Elemental Pop's electric overlay, `CherryShockState`), and a pull of the trigger only sparks.
/// </summary>
public sealed class PlayerShock : Component
{
public float Until { get; set; }
float _nextOverlay, _nextClick;
/// <summary>Shock THIS machine's player for <paramref name="seconds"/> (the longer of this and what is left).</summary>
public static void Apply( NZPlayer player, float seconds )
{
if ( !player.IsValid() || player.IsProxy || seconds <= 0f ) return;
var s = player.Components.GetOrCreate<PlayerShock>();
s.Until = MathF.Max( s.Until, Time.Now + seconds );
}
/// <summary>Is this player shocked right now — no shooting.</summary>
public static bool Blocks( NZPlayer player )
{
if ( !player.IsValid() ) return false;
var s = player.Components.Get<PlayerShock>( FindMode.EverythingInSelf );
return s.IsValid() && Time.Now < s.Until;
}
protected override void OnUpdate()
{
var now = Time.Now;
if ( now >= Until ) { Destroy(); return; }
// the screen crackles for as long as it lasts: Elemental Pop's burst, played back to back
if ( now >= _nextOverlay )
{
_nextOverlay = now + CherryShockState.Duration;
CherryShockState.Fire();
}
// a pull of the trigger: a spark, not a shot
if ( now >= _nextClick && Input.Pressed( "attack1" ) )
{
_nextClick = now + 0.35f;
NZSound.Play( "nz.avo.hit", WorldPosition + Vector3.Up * 50f );
}
}
/// <summary>`nz_shock [seconds]` — shock yourself, to try it without an Avogadro.</summary>
[ConCmd( "nz_shock" )]
public static void ShockCmd( float seconds = 2f )
{
var me = NZPlayer.Local;
if ( !me.IsValid() ) { Log.Warning( "[nz-avo] no player" ); return; }
Apply( me, seconds );
Log.Info( $"[nz-avo] shocked for {seconds:0.#}s — no shooting (the knife still works)" );
}
}
/// <summary>
/// The electric patch Avogadro's bolt leaves on the floor (2026-10-06), on EVERY machine: for a few seconds anyone standing in
/// it is shocked (no shooting, `PlayerShock`) and cannot regenerate — both on their own machine, which owns their gun and their
/// regen (`MargwaBurn`'s reason) — and the host's copy deals the damage, as a tick, to every player inside.
/// </summary>
public sealed class AvogadroField : Component
{
public float Radius { get; set; } = 70f;
public float Until { get; set; }
/// <summary>Damage a second to whoever stands in it. Only the host's copy deals it.</summary>
public float Dps { get; set; }
float _nextTick, _nextSpark;
PointLight _light;
/// <summary>Lay one on the floor under <paramref name="at"/>, on THIS machine — none if there is no floor within reach.</summary>
public static void Lay( Vector3 at, float radius, float seconds, float dps )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() || seconds <= 0f ) return;
var tr = scene.Trace.Ray( at + Vector3.Up * 8f, at + Vector3.Down * 220f ).WithoutTags( "zombie", "player", "trigger" ).Run();
if ( !tr.Hit ) return;
var go = new GameObject( true, "nz_avo_field" );
go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot
go.WorldPosition = tr.HitPosition;
// ⚠️ A PIT VISUAL DOES NOT REMOVE ITSELF (`MargwaFx.DrawPortal`'s note): this component destroys the object at the end
PitVisual.Attach( go, radius, PitVisual.Style.Ice, seconds );
var f = go.Components.Create<AvogadroField>();
f.Radius = radius;
f.Until = Time.Now + seconds;
f.Dps = dps;
var lgo = new GameObject { Parent = go, Name = "light" };
lgo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
lgo.LocalPosition = Vector3.Up * 24f;
f._light = lgo.Components.Create<PointLight>();
f._light.LightColor = AvogadroFx.Blue * 2f;
f._light.Radius = radius * 2.5f;
f._light.Shadows = false;
}
bool Inside( Vector3 p ) => (p - WorldPosition).WithZ( 0 ).Length <= Radius && MathF.Abs( p.z - WorldPosition.z ) < 64f;
protected override void OnUpdate()
{
var now = Time.Now;
if ( now >= Until ) { GameObject.Destroy(); return; }
// ── the look: sparks running over the floor, a light that will not sit still ──
if ( _light.IsValid() ) _light.LightColor = AvogadroFx.Blue * Game.Random.Float( 1.2f, 2.6f );
if ( now >= _nextSpark )
{
_nextSpark = now + 0.04f;
var a = Game.Random.Float( 0f, MathF.Tau );
var r = Game.Random.Float( 0f, Radius );
var p = WorldPosition + new Vector3( MathF.Cos( a ), MathF.Sin( a ), 0f ) * r + Vector3.Up * 3f;
var b = Game.Random.Float( 0f, MathF.Tau );
AvogadroSpark.Between( p, p + new Vector3( MathF.Cos( b ), MathF.Sin( b ), 0f ) * Game.Random.Float( 10f, 26f )
+ Vector3.Up * Game.Random.Float( 2f, 14f ), Game.Random.Float( 0.05f, 0.12f ), 0.1f );
}
// ── THIS machine's own player standing in it: shocked, and no regen while inside (and a moment after) ──
var me = NZPlayer.Local;
if ( me.IsValid() && !me.IsDown && Inside( me.WorldPosition ) )
{
PlayerShock.Apply( me, 0.6f );
var hp = me.Hp;
if ( hp.IsValid() && hp.NoRegenUntil < now + 0.6f ) hp.NoRegenUntil = now + 0.6f;
}
// ── the host deals the damage, every 0.25 s, to every player inside ──
if ( NZGame.IsClient || Dps <= 0f || now < _nextTick ) return;
_nextTick = now + 0.25f;
foreach ( var p in Scene.GetAllComponents<NZPlayer>() )
{
if ( !p.IsValid() || (p.IsDown || p.DownedNet) || !Inside( p.WorldPosition ) ) continue;
var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
if ( !hp.IsValid() || hp.IsDead ) continue;
hp.Apply( MathF.Min( Dps * 0.25f, AvogadroBoss.MaxHit ), false, null, tick: true, source: "avogadro field" );
}
}
}
/// <summary>Where Avogadro is about to land (his jump), on THIS machine: sparks gathering and a light swelling until he arrives.</summary>
public sealed class AvogadroJumpMark : Component
{
float _born, _until, _nextSpark;
PointLight _light;
public static void Place( Vector3 at, float seconds )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
var go = new GameObject( true, "nz_avo_jumpmark" );
go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
go.NetworkMode = NetworkMode.Never;
go.WorldPosition = PitVisual.GroundAt( at );
var m = go.Components.Create<AvogadroJumpMark>();
m._born = Time.Now;
m._until = Time.Now + MathF.Max( 0.1f, seconds );
m._light = go.Components.Create<PointLight>();
m._light.LightColor = AvogadroFx.Blue;
m._light.Radius = 200f;
m._light.Shadows = false;
}
protected override void OnUpdate()
{
var now = Time.Now;
if ( now >= _until ) { GameObject.Destroy(); return; }
var t = (now - _born) / MathF.Max( 0.01f, _until - _born );
if ( _light.IsValid() ) _light.LightColor = AvogadroFx.Blue * (1f + 5f * t) * Game.Random.Float( 0.8f, 1.1f );
if ( now < _nextSpark ) return;
_nextSpark = now + 0.03f;
// a column of sparks gathering where he will stand
var a = Game.Random.Float( 0f, MathF.Tau );
var p = WorldPosition + new Vector3( MathF.Cos( a ), MathF.Sin( a ), 0f ) * Game.Random.Float( 4f, 30f );
AvogadroSpark.Between( p, p + Vector3.Up * Game.Random.Float( 30f, 90f ), Game.Random.Float( 0.05f, 0.12f ), 0.12f );
}
}