A game AI component for the "Sizzler" special zombie. Controls spawn effects, ignition behavior (trail, sprint choice), attack selection, death blast damage and visuals, per-machine trail/fire effects, and debug console commands to report and retune parameters.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// The Sizzler — CoD WWII's fire runner, ported 2026-10-07 from GMod nZombies' `nz_zombie_special_siz` (GhostlyMoo's), the user's
/// pick of three WWII enemies. A SPECIAL, NOT A BOSS: it comes during an ordinary round beside the walkers (`AmbientSpecials`) or
/// makes up a special round, and the round waits for it (`sizzler.zvar`, `IsBoss` false). Spec: `Docs/boss_specs/sizzler.md`.
///
/// • IT CLIMBS OUT AND WALKS (`nz_s2_core_walk_v10-12`, 46 u/s), SILENT (upstream's walk voice is `mute_00.wav`), with half a
/// walker's health (upstream's `GetZombieHealth() * 0.5`, the variant's 0.5). Every step burns, walking or lit: the napalm step
/// (the variant's) and under it a fire layer with embers at the foot (upstream's `SWTFootstepsSounds` and `bo3_napalm_fs`), on
/// every machine. ⚠️ Its WWII arrival is a burst of embers, the gib, and Dead Wire's zap for HL2's weld (in no archive).
/// • IT IGNITES, ONCE (upstream's `AI`): at its target in sight within <see cref="IgniteRange"/> (950, origin to origin), or
/// <see cref="EnrageMin"/>-<see cref="EnrageMax"/> s (2-7) after it arrives — whichever comes first, and only with a target.
/// It stops for a cry (`nz_stn_idle_react_f_v3/v4`, 1.4/1.57 s; the `convert` scream on its `generic_taunt` frame, 0.4 s in),
/// a plasma trail streams from its chest (upstream's `SpriteTrail` on `chest_fx_tag`, (255, 45, 0), 0.75 s — here
/// `BossShotLook`, unlit, a flame at its heart), and it SPRINTS FOR GOOD, shrieking: one of upstream's eight sprints — the slow
/// four (245.9 u/s) or the fast four (321.4), a coin flip (<see cref="FastChance"/>).
/// ⚠️ Upstream's third cry, `nz_l4d_violentalert_f`, is an L4D clip left off the model; the cries' 14-30 u of creep is root
/// motion the port strips.
/// • ITS SWING, a walker's 50 (`DamageMultiplier` 1), and uncapped like a walker's: upstream's super-sprint lunges, or the six
/// standing swings at a target all but still within <see cref="StandRange"/> (upstream's base `Attack`; `TeslaBoss`'s copy).
/// • IT BLOWS UP WHEN IT DIES — lit or not, however it dies (shot, knifed, nuked, still climbing out), but never when it is only
/// removed (upstream's `PostDeath` → `Explode(25, true)`). From its middle, every player up within <see cref="BlastRadius"/>
/// (200) in the open takes <see cref="BlastWeight"/> of its swing at the middle (upstream's 25 of 50), falling to nothing at the
/// rim, ⛔ never more than `BossBase.MaxHit`; every other zombie in it takes <see cref="ZombieBlast"/> (25, flat, upstream's).
/// A fireball (`BlastEffect`), HL2's explosion, a shake out to 400 (upstream's).
/// ⚠️ UPSTREAM'S BLAST REACHED ITS OWN TARGET ONLY: it named that player the attacker, so for them it was their own blast (PhD
/// Flopper spared them) and for everyone else friendly fire (refused). Here, anyone up, PhD or not — the Panzerhund's death blast.
/// ⚠️ ITS KILLS PAY NOBODY: upstream credited them to that player; here a blast with no owner, as the Napalm Zombie's.
/// • ITS WINDOWS AND LEDGES: its own traverse at its pace — `nz_base_zombie_walk_win_trav_m_01` walking, `run_` lit slow, `sprint_`
/// lit fast — through `ZombieAI.VaultClipOverride` (added with it: the window vault read only the walker's mantles) and
/// `ClimbClipOverride`. ⚠️ Upstream used the sprint one at every pace.
/// • LEFT OUT: the electrocution set (`nz_death_elec_1-4`, the electrobolt stuns, the `trans_shriek` voice — this game has no
/// electric damage to route them by, `WalkerDeaths.Electric`), the Turned sprints (no Turned here), the stand idles (the AI has
/// no idle clip), and the map option that lets its blast set walkers alight (upstream's `burningspread`).
///
/// ⚠️ A SPECIAL ON `BossBase` FOR ITS HELPERS — beats, shared clips, parts, the capped hit, `Say`. Nothing in it is a boss's: no bar,
/// no music, no round rule; `Health` asks it for a damage scale and gets 1.
/// ⚠️ HOST AUTHORITY, EVERY MACHINE DRAWS: the ignite travels as a part (`SetPart( LitPart )` → <see cref="ShowPart"/>): every machine
/// lights its own trail, and a watching machine's puppet takes the sprint's legs (the host's speed does not travel — the Panzerhund's
/// note). The blast is the host's; its fireball, sound and shake are shared. The fire steps are each machine's, off the clip it shows.
/// </summary>
public sealed class SizzlerZombie : BossBase
{
protected override string Tag => "[nz-siz]";
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED, INSTRUCTIONS.md §1.
static float? _range, _enrageMin, _enrageMax, _fastChance, _standRange, _standSpeed;
/// <summary>It ignites at its target in sight this near: 950, origin to origin (upstream's `TargetInRange(950)`).</summary>
public static float IgniteRange { get => _range ?? 950f; set => _range = value; }
/// <summary>Or this long after it arrives, target in sight or not: 2-7 s (upstream's `CurTime() + math.random(2,7)`).</summary>
public static float EnrageMin { get => _enrageMin ?? 2f; set => _enrageMin = value; }
public static float EnrageMax { get => _enrageMax ?? 7f; set => _enrageMax = value; }
/// <summary>The chance it sprints with the fast four: 0.5 (upstream picks one of its eight sprints, four of each).</summary>
public static float FastChance { get => _fastChance ?? 0.5f; set => _fastChance = value; }
/// <summary>It plants for a standing swing at a target this near and this still: 65, and 3.9 u/s (upstream's `speed² < 15`).</summary>
public static float StandRange { get => _standRange ?? 65f; set => _standRange = value; }
public static float StandSpeed { get => _standSpeed ?? 3.9f; set => _standSpeed = value; }
static float? _blastRadius, _blastWeight, _zombieBlast;
/// <summary>Its death blast's reach from its middle: 200 (upstream's `FindInSphere( WorldSpaceCenter(), 200 )`).</summary>
public static float BlastRadius { get => _blastRadius ?? 200f; set => _blastRadius = value; }
/// <summary>
/// What the blast takes from a player at its middle, against its swing: 0.5 — upstream's 25 against 50, linear to nothing at the
/// rim. 15 at round 6, 25 at 12, 37.5 at 20, 45 at 31; ⛔ never more than `MaxHit`.
/// </summary>
public static float BlastWeight { get => _blastWeight ?? 0.5f; set => _blastWeight = value; }
/// <summary>What the blast takes from another zombie at its middle: 25, flat, whatever the round (upstream's `Explode(25)`). 0, none.</summary>
public static float ZombieBlast { get => _zombieBlast ?? 25f; set => _zombieBlast = value; }
static float? _trailWidth, _trailSeconds, _core;
/// <summary>
/// Its trail, lit: 24 wide at its chest, thinning over 0.75 s of its path (upstream's plasma trail, 45 → 20 over 0.75 s — a soft
/// texture; a solid line at 45 is a wall). A flame 10 across at its heart. Each machine reads these when it lights.
/// </summary>
public static float TrailWidth { get => _trailWidth ?? 24f; set => _trailWidth = value; }
public static float TrailSeconds { get => _trailSeconds ?? 0.75f; set => _trailSeconds = value; }
public static float CoreSize { get => _core ?? 10f; set => _core = value; }
// its clips (30 fps) — ⛔ `const`, never `static readonly` (INSTRUCTIONS.md §1)
const string ClipCry3 = "nz_stn_idle_react_f_v3", ClipCry4 = "nz_stn_idle_react_f_v4",
ClipTravWalk = "nz_base_zombie_walk_win_trav_m_01", ClipTravRun = "nz_base_zombie_run_win_trav_m_01",
ClipTravSprint = "nz_base_zombie_sprint_win_trav_m_01";
const float Cry3Len = 42f / 30f, Cry4Len = 47f / 30f, CryVoxAt = 12f / 30f;
// the standing swings (upstream's `StandAttackSequences`); the moving ones are the variant's
const string StandL1 = "nz_iw7_cp_zom_stand_attack_l_01", StandL2 = "nz_iw7_cp_zom_stand_attack_l_02",
StandR1 = "nz_iw7_cp_zom_stand_attack_r_01", StandR2 = "nz_iw7_cp_zom_stand_attack_r_02",
Stand2h1 = "nz_zom_core_stand_attack_2h_01", Stand2h2 = "nz_zom_core_stand_attack_2h_02";
/// <summary>Its clips' own speeds — `sizzler.zvar`'s tiers are picked by them (walk 0, slow sprint 71, fast sprint 280).</summary>
const float WalkSpeed = 46.0f, SlowSprint = 245.9f, FastSprint = 321.4f;
/// <summary>Its middle, where the blast goes off: upstream's `WorldSpaceCenter`, half its 72-tall box.</summary>
const float Middle = 36f;
/// <summary>Upstream's `util.ScreenShake( 20, 255, 1.5, 400 )` — the Panzerhund's strength for the same call, upstream's reach.</summary>
const float ShakeStrength = 0.7f, ShakeRange = 400f;
/// <summary>How often it looks for its target (upstream asks once a pass of its AI), and how near embers are worth drawing.</summary>
const float LookEvery = 0.2f, EmberEar = 1500f;
/// <summary>Not a bodygroup: lit or not, and which sprint (0 walking, 1 the slow four, 2 the fast four), taken by <see cref="ShowPart"/>.</summary>
const string LitPart = "siz_lit";
const string FlameSprite = "sprites/nz/napalm_flame.sprite";
// upstream's trail colour (255, 45, 0); its steps' embers a little yellower
static Color Plasma => new( 1f, 0.176f, 0f );
static Color Ember => new( 1f, 0.45f, 0.1f );
// ══ state ════════════════════════════════════════════════════════════════
//
// ⚠️ A HOTLOAD INTO A LIVE ONE LEAVES NEW FIELDS AT 0/false/null: unlit, its timer due — it lights at its next look, as upstream's
// would have by then.
bool _lit, _fast;
float _igniteAt, _nextLook;
List<string> _standing;
// its target's ground speed, measured frame to frame (`TeslaBoss`'s); 999 until measured, so a new target never reads as still
GameObject _tracked;
Vector3 _trackedAt;
float _targetSpeed = 999f;
// every machine: the trail's anchor, and the last clip/time its fire steps were read at
GameObject _fire;
string _stepClip;
float _stepT;
enum Beat { Cry }
public bool Lit => _lit;
/// <summary>Its pace: its walk, or the sprint it rolled.</summary>
float Pace => !_lit ? WalkSpeed : _fast ? FastSprint : SlowSprint;
protected override void HostStart()
{
// ⛔ NONE OF THE WALKER'S MANTLES ON ITS MODEL: its own traverse at its pace, through a window and over a ledge alike
SetTraverse();
_igniteAt = Time.Now + Game.Random.Float( EnrageMin, MathF.Max( EnrageMin, EnrageMax ) );
// upstream's WWII arrival (`OnSpawn`): sparks and embers, the gib, the weld — HL2's `ambient/energy/weld1/2` is in no archive,
// Dead Wire's zap stands in (the Tesla Zombie's stand-in for HL2's zaps)
var at = WorldPosition;
BossFx.BurstShared( at + Vector3.Up * 12f * Size, 56f * Size, Ember, 10 );
NZSound.PlayShared( "nz.sizzler.gib", at + Vector3.Up * 30f * Size );
NZSound.PlayShared( "nz.pop.deadwire.shock", at + Vector3.Up * 30f * Size );
Say( $"spawned — {Hp?.Max ?? 0f:0} hp · walks until its target is in sight within {IgniteRange:0}, or for {_igniteAt - Time.Now:0.#}s"
+ $" · blast {BlastRadius:0}u · hitboxes {Ai.HitboxCount}" );
}
/// <summary>Its look, on every machine: a watching machine's legs, the trail, the fire under its steps.</summary>
protected override void EveryFrame()
{
TickPuppetPace();
TickTrail();
TickFireSteps();
}
protected override void HostFrame()
{
if ( Ai.State == ZombieState.Spawning ) return;
TrackTarget();
PickAttackSet();
if ( !_lit ) TickIgnite();
}
protected override void OnBeat( int beat )
{
// the cry's `generic_taunt` frame: upstream's `TauntSounds`
if ( beat == (int)Beat.Cry ) NZSound.PlayShared( "nz.sizzler.convert", Voice );
}
protected override void Died( bool host )
{
Douse();
if ( host ) Blast( "died" );
}
/// <summary>
/// A watching machine told it lit, and with which sprint (`NZNet.BossPart`): its trail, and its legs (<see cref="EveryFrame"/>).
/// ⚠️ THE HOST'S OWN ECHO LIGHTS THE HOST'S TRAIL; its `_lit` and `_fast` are what it sent.
/// </summary>
public override bool ShowPart( string group, int choice )
{
if ( group != LitPart ) return false;
if ( Ai.IsValid() && Ai.IsPuppet )
{
_lit = choice != 0;
_fast = choice == 2;
}
if ( _lit ) Light();
else Douse();
return true;
}
// ══ the host ═════════════════════════════════════════════════════════════
/// <summary>Carried by something the AI drives — a nav link, a window (held at it, or climbing through), an arc (the Meúchler's test).</summary>
bool Busy => Ai.CrossingLink || Ai.OnLink || Ai.ParkedAt is not null || Ai.Arcing
|| (Ai.CurrentClip ?? "").Contains( "_win_trav_" );
/// <summary>Free to light: walking at its target or idle, and not carried by anything.</summary>
bool Free => (Ai.State is ZombieState.Chasing or ZombieState.Idle) && !Busy;
/// <summary>
/// Upstream's `AI`: with a target, light once its time is up, or once that target is in its sight within <see cref="IgniteRange"/>
/// (a target that is not a player is never "out of sight", upstream's `IsAttackBlocked`). THE HOST.
/// </summary>
void TickIgnite()
{
if ( !Free || Time.Now < _nextLook ) return;
_nextLook = Time.Now + LookEvery;
// ⚠️ NO TARGET, NO FIRE — NOT EVEN THE TIMER'S (upstream's `if IsValid(tar)` holds both)
var t = Ai.Target;
if ( !t.IsValid() ) return;
if ( Time.Now >= _igniteAt )
{
Ignite( "its time was up" );
return;
}
var d = WorldPosition.Distance( t.WorldPosition );
if ( d >= IgniteRange * Size ) return;
var player = t.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
if ( player.IsValid() && !CanSee( t ) ) return;
Ignite( $"its target in sight, {d:0} away" );
}
/// <summary>
/// The ignite: the cry (its scream 0.4 s in), the trail on every machine, the sprint for good. False when it cannot now — lit
/// already, dead, still climbing out, or held in another move. THE HOST.
/// </summary>
bool Ignite( string why )
{
if ( _lit || !Ai.IsValid() || Ai.State is ZombieState.Dead or ZombieState.Spawning or ZombieState.Special ) return false;
_lit = true;
_fast = Game.Random.Float( 0f, 1f ) < FastChance;
// ⚠️ THE CRY FIRST, THEN THE SPEED, AND NO RE-PICK BETWEEN (the Krasny's rule): a re-pick mid-special hands the agent its speed
// back where the special roots it at 0, and the cry would slide. The special's own end re-picks from `SpeedOverride`
// (`ZombieAI.TickSpecial`). A cry that cannot play (no clip) re-picks now.
var four = Game.Random.Int( 0, 1 ) == 1;
if ( PlayShared( four ? ClipCry4 : ClipCry3, four ? Cry4Len : Cry3Len ) ) Ai.SpeedOverride = MathF.Max( 1f, Pace * Size );
else SetSpeed( Pace );
Queue( CryVoxAt, (int)Beat.Cry );
SetPart( LitPart, _fast ? 2 : 1 );
SetTraverse();
Say( $"IGNITED ({why}) — sprinting at {Pace:0} u/s ({(_fast ? "the fast four" : "the slow four")})" );
return true;
}
/// <summary>Its traverse for a window or a ledge, at its pace: walk, run (the slow sprint), sprint (the fast). THE HOST.</summary>
void SetTraverse()
{
var clip = !_lit ? ClipTravWalk : _fast ? ClipTravSprint : ClipTravRun;
Ai.ClimbClipOverride = clip;
Ai.VaultClipOverride = clip;
}
/// <summary>Its target's ground speed, frame to frame, smoothed — what decides a standing swing (`TeslaBoss`'s). THE HOST.</summary>
void TrackTarget()
{
var t = Ai.Target;
if ( !t.IsValid() ) { _tracked = null; _targetSpeed = 999f; return; }
var at = t.WorldPosition;
if ( t != _tracked )
{
_tracked = t;
_trackedAt = at;
_targetSpeed = 999f;
return;
}
var dt = MathF.Max( 0.001f, Time.Delta );
var v = (at - _trackedAt).WithZ( 0 ).Length / dt;
_trackedAt = at;
if ( v > 1200f ) return; // a teleport or a respawn, not a run
_targetSpeed += (v - _targetSpeed) * (1f - MathF.Exp( -10f * dt ));
}
/// <summary>
/// Which swings its next attack draws from — upstream's base `Attack` (M L4586): the standing set at a target all but still within
/// <see cref="StandRange"/>, else the variant's lunges. The AI reads it as each swing starts. THE HOST.
/// </summary>
void PickAttackSet()
{
var t = Ai.Target;
var stand = t.IsValid() && _targetSpeed < StandSpeed && WorldPosition.Distance( t.WorldPosition ) <= StandRange * Size;
Ai.AttackOverride = stand ? (_standing ??= new List<string> { StandL1, StandL2, StandR1, StandR2, Stand2h1, Stand2h2 }) : null;
}
/// <summary>
/// The death blast (upstream's `Explode(25)`): players up in the open within <see cref="BlastRadius"/> of its middle take
/// <see cref="BlastWeight"/> of its swing, falling to nothing at the rim, capped at `MaxHit` (`HurtPlayer`); every other zombie
/// there <see cref="ZombieBlast"/>, the same falloff, from nobody. The fireball, the bang and the shake on every machine. THE HOST.
/// </summary>
void Blast( string why )
{
var at = WorldPosition + Vector3.Up * Middle * Size;
var radius = BlastRadius * Size;
var swing = SwingDamage( BlastWeight );
int players = 0, zombies = 0;
foreach ( var p in PlayersWithin( at, radius ).ToList() )
{
var chest = p.WorldPosition + Vector3.Up * 40f;
if ( !InTheOpen( at, chest ) ) continue;
var share = 1f - Math.Clamp( chest.Distance( at ) / radius, 0f, 1f );
if ( share > 0f && HurtPlayer( p, swing * share, blast: true, at: at, source: "sizzler blast" ) ) players++;
}
// ⚠️ THE OTHER ZOMBIES, upstream's too (its loop takes every nextbot): flat, so it matters early and not late — and another
// Sizzler it finishes off blows up in turn, as upstream's did
if ( ZombieBlast > 0f )
{
foreach ( var z in ZombieAI.All.ToList() )
{
if ( !z.IsValid() || z == Ai || z.State == ZombieState.Dead ) continue;
var middle = z.WorldPosition + Vector3.Up * Middle * MathF.Max( 0.1f, z.WorldScale.x );
var d = middle.Distance( at );
if ( d > radius || !InTheOpen( at, middle ) ) continue;
var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
if ( !hp.IsValid() || hp.IsDead ) continue;
hp.OnDamage( new DamageInfo { Damage = ZombieBlast * (1f - d / radius), Position = middle } );
zombies++;
}
}
BlastEffect.Spawn( at, radius );
NZSound.PlayShared( "nz.sizzler.explode", at );
NZNet.ShakeAt( at, ShakeStrength, ShakeRange * Size );
Say( $"BLAST ({why}) — {players} player(s) hurt (up to {MathF.Min( swing, MaxHit ):0} each), {zombies} zombie(s)"
+ $" (up to {ZombieBlast:0}) within {radius:0}" );
}
/// <summary>Nothing of the world between (upstream's `if tr1.HitWorld then continue`); bodies and triggers do not count.</summary>
bool InTheOpen( Vector3 from, Vector3 to )
{
var tr = Scene.Trace.Ray( from, to ).IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "zombie", "player", "trigger", "ragdoll" ).Run();
return !tr.Hit || tr.HitPosition.Distance( to ) < 24f;
}
// ══ every machine ════════════════════════════════════════════════════════
/// <summary>
/// ⚠️ A WATCHING MACHINE'S LEGS: the host's speed does not travel, so its puppet re-picks its clip from the pace it was told
/// (<see cref="ShowPart"/>) — the walk tier, or its sprint's (the Panzerhund's way).
/// </summary>
void TickPuppetPace()
{
if ( !Ai.IsPuppet ) return;
var speed = MathF.Max( 1f, Pace * Size );
if ( MathF.Abs( Ai.SpeedOverride - speed ) <= 0.01f ) return;
Ai.SpeedOverride = speed;
Ai.RepickAnimations();
}
/// <summary>Its trail follows its chest while it is lit — lit again if a hotload or a late message lost it.</summary>
void TickTrail()
{
if ( !_lit ) return;
if ( !_fire.IsValid() ) Light();
if ( _fire.IsValid() ) _fire.WorldPosition = Chest;
}
/// <summary>
/// Its trail on THIS machine: an anchor at its chest that only follows it (never networked), with `BossShotLook`'s head and trail
/// on it — a flame at its heart and upstream's orange-red streak. Unlit: nothing lights the room (the tracers' rule).
/// </summary>
void Light()
{
if ( _fire.IsValid() || !Body.IsValid() ) return;
_fire = new GameObject { Parent = GameObject, Name = "nz_siz_fire" };
_fire.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
_fire.NetworkMode = NetworkMode.Never;
_fire.WorldPosition = Chest;
BossShotLook.On( _fire, Plasma, CoreSize, TrailWidth, TrailSeconds, 0f, 0f, 2.5f, FlameSprite );
}
void Douse()
{
if ( _fire.IsValid() ) _fire.Destroy();
_fire = null;
}
/// <summary>Upstream's `chest_fx_tag` (attachment 9): `j_spineupper`.</summary>
Vector3 Chest => Body.IsValid() && Body.TryGetBoneTransform( "j_spineupper", out var t )
? t.Position
: WorldPosition + Vector3.Up * 50f * Size;
/// <summary>
/// The fire under its steps: each step of the clip it shows (`WalkerFootsteps`, the same table and times `ZombieAI` steps by —
/// it plays the napalm step), the fire layer at the planted foot and, near enough to see, embers (upstream's `bo3_napalm_fs`).
/// </summary>
void TickFireSteps()
{
if ( !Body.IsValid() ) return;
var seq = Body.Sequence;
if ( seq is null ) return;
var clip = seq.Name;
var t = seq.TimeNormalized;
if ( clip != _stepClip )
{
_stepClip = clip;
_stepT = t;
return;
}
if ( string.IsNullOrEmpty( clip ) || !WalkerFootsteps.Has( clip ) ) { _stepT = t; return; }
// a loop wraps past 1 back to 0: two spans, as `ZombieAI.TickFootsteps` reads them
var steps = WalkerFootsteps.For( clip );
var n = t >= _stepT ? Crossed( steps, _stepT, t ) : Crossed( steps, _stepT, 1f ) + Crossed( steps, -0.001f, t );
_stepT = t;
if ( n > 0 ) FireStep();
}
static int Crossed( WalkerFootsteps.Step[] steps, float from, float to )
{
var n = 0;
foreach ( var s in steps )
if ( s.Time > from && s.Time <= to ) n++;
return n;
}
void FireStep()
{
var foot = Foot;
NZSound.PlayAmbient( "nz.sizzler.step.fire", foot, SoundGate.Step );
if ( NZSound.Ear.Distance( foot ) <= EmberEar ) BossFx.Sparks( foot + Vector3.Up * 3f, Ember, 2, 10f * Size );
}
/// <summary>The planted foot — the lower of its two (upstream's `lfoot_fx_tag` / `rfoot_fx_tag`, `j_ball_le` / `j_ball_ri`).</summary>
Vector3 Foot
{
get
{
var left = Body.TryGetBoneTransform( "j_ball_le", out var l ) ? l.Position : WorldPosition;
var right = Body.TryGetBoneTransform( "j_ball_ri", out var r ) ? r.Position : WorldPosition;
return left.z <= right.z ? left : right;
}
}
// ══ diagnostics ══════════════════════════════════════════════════════════
static IEnumerable<SizzlerZombie> All
=> Game.ActiveScene?.GetAllComponents<SizzlerZombie>() ?? Enumerable.Empty<SizzlerZombie>();
/// <summary>`nz_siz` — every Sizzler alive, and the tuning.</summary>
[ConCmd( "nz_siz" )]
public static void Report()
{
Log.Info( $"[nz-siz] ignites at its target in sight within {IgniteRange:0}, or {EnrageMin:0.#}-{EnrageMax:0.#}s after it arrives"
+ $" · then sprints {SlowSprint:0} or {FastSprint:0} u/s ({FastChance * 100f:0}% the fast) · standing swing within"
+ $" {StandRange:0} under {StandSpeed:0.#} u/s" );
Log.Info( $"[nz-siz] death blast {BlastRadius:0}u: x{BlastWeight:0.##} of its swing to players at the middle (cap {MaxHit:0}),"
+ $" {ZombieBlast:0} to zombies · trail {TrailWidth:0}u x {TrailSeconds:0.##}s, flame {CoreSize:0}" );
var list = All.ToList();
Log.Info( $"[nz-siz] {list.Count} alive" );
foreach ( var s in list )
{
var target = s.Ai.IsValid() ? s.Ai.Target : null;
Log.Info( $"[nz-siz] hp {s.Hp?.Current ?? 0f:0}/{s.Hp?.Max ?? 0f:0} · state {s.Ai?.State} · speed {s.Ai?.MoveSpeed ?? 0f:0}"
+ $" · {(s._lit ? $"LIT ({(s._fast ? "fast" : "slow")})" : s.IsHost ? $"walking (lights in {MathF.Max( 0f, s._igniteAt - Time.Now ):0.#}s)" : "walking")}"
+ $" · target {(target.IsValid() ? $"{target.Name} {target.WorldPosition.Distance( s.WorldPosition ):0} away" : "none")}"
+ $"{(s.Ai?.AttackOverride is not null ? " (standing swing)" : "")}"
+ $"{(s.Ai.IsValid() && !s.Dead ? $" · blast now {MathF.Min( s.SwingDamage( BlastWeight ), MaxHit ):0}" : "")}" );
}
}
/// <summary>`nz_siz_ignite` — every Sizzler not yet lit ignites now, target or not (one still climbing out cannot).</summary>
[ConCmd( "nz_siz_ignite" )]
public static void IgniteCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-siz] the host decides" ); return; }
var n = 0;
foreach ( var s in All.Where( s => s.Ai.IsValid() && !s.Dead && !s._lit ).ToList() )
if ( s.Ignite( "nz_siz_ignite" ) ) n++;
Log.Info( $"[nz-siz] {n} ignited · one climbing out or mid-move cannot (run it again)" );
}
/// <summary>`nz_siz_blast` — every Sizzler's death blast where it stands, now; it lives on (to see and feel the blast).</summary>
[ConCmd( "nz_siz_blast" )]
public static void BlastCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-siz] the host decides" ); return; }
var n = 0;
foreach ( var s in All.Where( s => s.Ai.IsValid() && !s.Dead ).ToList() )
{
s.Blast( "nz_siz_blast" );
n++;
}
Log.Info( $"[nz-siz] {n} blast(s)" );
}
/// <summary>`nz_siz_set <key> <value>` — retune it live. The trail's keys reach the next one to light.</summary>
[ConCmd( "nz_siz_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "range": IgniteRange = value; break;
case "enragemin": EnrageMin = value; break;
case "enragemax": EnrageMax = value; break;
case "fast": FastChance = value; break;
case "standrange": StandRange = value; break;
case "standspeed": StandSpeed = value; break;
case "radius": BlastRadius = value; break;
case "weight": BlastWeight = value; break;
case "zombies": ZombieBlast = value; break;
case "trailwidth": TrailWidth = value; break;
case "trailseconds": TrailSeconds = value; break;
case "flame": CoreSize = value; break;
default:
Log.Info( "[nz-siz] nz_siz_set <range|enragemin|enragemax|fast|standrange|standspeed|radius|weight|zombies|trailwidth"
+ "|trailseconds|flame> <value>" );
return;
}
Log.Info( $"[nz-siz] {key} = {value:0.###}" );
Report();
}
}