A Boss AI component for the Zaballa boss in a nzombies game mode. Controls tuning constants, audio/visual effects, mask-breaking, teleport-mine sequence, area pulse attack, attack speed/frenzy scaling, and console commands for diagnostics and live retuning.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Zaballa the Deceiver — MWZ's masked demon, ported 2026-10-07 from GMod nZombies' `nz_zombie_boss_trickster` (GhostlyMoo's
/// port), the third batch. Spec: `Sbox nzombies/Docs/boss_specs/zaballa.md`.
///
/// • HE WALKS (77.8 u/s, the variant's one tier in play) humming and crackling, a blue aura on his robe and fire in his eyes.
/// • THE MINE TELEPORT, every <see cref="MineGapMin"/>-<see cref="MineGapMax"/> s (the first 2.75-5.95 s after he arrives) when
/// his target is within <see cref="MineRange"/>: into a portal (`disappear_01`), then three evil-energy mines at his target's
/// feet <see cref="MineGap"/> s apart (`BossOrb`: a <see cref="MineRadius"/> burst after <see cref="MineFuse"/> s, x<see
/// cref="MineWeight"/> of his swipe at the middle, a daze), and on the third he steps out of a portal behind them (`appear_01`).
/// ⚠️ UNTOUCHABLE WHILE HIDDEN — upstream's intent; its own code cleared the protection a tick after setting it, and bullets
/// at the empty spot still hurt him.
/// • THREE MASKS: at 75% health the right one breaks, at 25% the left one, each with a blast at his feet and, unless he is busy,
/// a stagger (`stun_blend`, 5.7 s) that ends in the mine teleport. The middle one breaks when he dies.
/// • THE PULSE, every <see cref="PulseGapMin"/>-<see cref="PulseGapMax"/> s when his target is in sight within <see
/// cref="PulseRange"/>: 1.43 s of warning (its reach drawn on the floor), then a burst round him — x<see cref="PulseWeight"/>
/// of his swipe at his middle, falling off to the rim, and everyone in it dazed. ⚠️ Upstream's damage only reached 50 of its
/// 127 (the mine's falloff, copied); here it falls off over the whole reach it warns of.
/// • HIS SWIPE (the variant's attacks, 75 against a walker's 50, x<see cref="SwipeScale"/>) within 77.
/// • HE TAKES x<see cref="Resist"/> FROM EVERYTHING.
/// • ⚠️ THE USER'S REVIEW (2026-10-07): every attack harder (<see cref="SwipeScale"/>, the mines' and the pulse's weights, their
/// edges no longer fading to nothing), and A FRENZY PER BROKEN MASK — gaps x<see cref="FrenzyGap"/>, x<see cref="FrenzyRate"/>
/// as fast, each step (`nz_zaballa_mask` to try it).
///
/// ⚠️ HOST AUTHORITY, EVERY MACHINE DRAWS: the masks are `NZNet.BossPart`, the vanishing `NZNet.BossHidden`, the mines
/// network-spawned `BossOrb`s, the pulse `NZNet.BossBurstFx` (whose daze each machine puts on its own player).
/// ⛔ NO HIT OF HIS DOWNS ANYONE FROM FULL HEALTH (`BossBase.MaxHit`), Oberon's rule — the mines and the pulse included.
/// </summary>
public sealed class ZaballaBoss : BossBase
{
protected override string Tag => "[nz-zaballa]";
protected override float VoiceHeight => 66f;
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED, INSTRUCTIONS.md §1.
static float? _mineRange, _mineGapMin, _mineGapMax, _mineWeight, _mineRadius, _mineFuse, _mineGap, _daze;
/// <summary>He teleports at a target within 750 (upstream's `TargetInRange(750)`, no sight needed), every 10.75-18.95 s.</summary>
public static float MineRange { get => _mineRange ?? 750f; set => _mineRange = value; }
public static float MineGapMin { get => _mineGapMin ?? 10.75f; set => _mineGapMin = value; }
public static float MineGapMax { get => _mineGapMax ?? 18.95f; set => _mineGapMax = value; }
/// <summary>A mine at its middle against his swipe: 1.6 (upstream's 90 against 75 was 1.2; raised by the user's review,
/// 2026-10-07 — see <see cref="SwipeScale"/>).</summary>
public static float MineWeight { get => _mineWeight ?? 1.6f; set => _mineWeight = value; }
/// <summary>A mine's reach (50), its fuse (1.15 s) and the gap between his three (0.75 s), upstream's.</summary>
public static float MineRadius { get => _mineRadius ?? 50f; set => _mineRadius = value; }
public static float MineFuse { get => _mineFuse ?? 1.15f; set => _mineFuse = value; }
public static float MineGap { get => _mineGap ?? 0.75f; set => _mineGap = value; }
/// <summary>How long a mine or the pulse dazes you: 0.75 s (upstream's `NZSonicBlind(0.75)`).</summary>
public static float Daze { get => _daze ?? 0.75f; set => _daze = value; }
static float? _pulseRange, _pulseGapMin, _pulseGapMax, _pulseWeight, _resist, _vox;
/// <summary>He pulses at a target in sight within 127 (his reach + 50), every 5.95-9.99 s.</summary>
public static float PulseRange { get => _pulseRange ?? 127f; set => _pulseRange = value; }
public static float PulseGapMin { get => _pulseGapMin ?? 5.95f; set => _pulseGapMin = value; }
public static float PulseGapMax { get => _pulseGapMax ?? 9.99f; set => _pulseGapMax = value; }
/// <summary>The pulse at his middle against his swipe: 1.6 (upstream's 90 against 75 was 1.2; raised by the user's review,
/// 2026-10-07 — see <see cref="SwipeScale"/>).</summary>
public static float PulseWeight { get => _pulseWeight ?? 1.6f; set => _pulseWeight = value; }
/// <summary>What he takes from anything: 0.75, upstream's.</summary>
public static float Resist { get => _resist ?? 0.75f; set => _resist = value; }
/// <summary>The chance of a line with a teleport, a reappearance or a broken mask: 0.24 (upstream's `math.random(100) < 25`).</summary>
public static float VoxChance { get => _vox ?? 0.24f; set => _vox = value; }
static float? _swipe, _mineFloor, _pulseFloor, _frenzyGap, _frenzyRate;
/// <summary>
/// HARDER HITS (2026-10-07, the user's review: *"zaballa, each attack should deal more damage"*): his swipe x1.35 on top of his
/// variant's 1.5 (`ZombieAI.SwingScale`: 75 → 100 at round 12), the mines and the pulse 1.6 of his swipe at the middle (was
/// 1.2), and neither fades to nothing at its edge any more — a mine's rim deals <see cref="MineFloor"/> of its middle (was
/// none), the pulse's <see cref="PulseFloor"/> (was 0.1). ⚠️ `BossBase.MaxHit` STILL CAPS EVERY HIT AT 100 (the no-insta-kill
/// rule): from round 16 his swipe was already there, so the later rounds feel the change in the falloff.
/// </summary>
public static float SwipeScale { get => _swipe ?? 1.35f; set => _swipe = value; }
public static float MineFloor { get => _mineFloor ?? 0.35f; set => _mineFloor = value; }
public static float PulseFloor { get => _pulseFloor ?? 0.4f; set => _pulseFloor = value; }
/// <summary>
/// THE FRENZY, A STEP PER BROKEN MASK (2026-10-07, the user: *"for each mask missing it should become far more agressive with
/// the attacks, they become more frequent and faster"*). Each mask gone multiplies the gaps between his attacks by
/// <see cref="FrenzyGap"/> (0.6 — the teleport's, the pulse's and the rest between swipes) and their speed by
/// <see cref="FrenzyRate"/> (1.3 — the swipe, the pulse and its warning, the way out, the mines' spacing and fuses, the way
/// back in). One mask gone: gaps x0.6, x1.3 as fast; two gone: x0.36, x1.69.
/// ⚠️ THE STAGGER A BREAKING MASK CAUSES KEEPS ITS LENGTH: it is the opening the break earns, not an attack.
/// </summary>
public static float FrenzyGap { get => _frenzyGap ?? 0.6f; set => _frenzyGap = value; }
public static float FrenzyRate { get => _frenzyRate ?? 1.3f; set => _frenzyRate = value; }
// his clips (30 fps) and their events — ⛔ `const`, never `static readonly` (INSTRUCTIONS.md §1)
const string ClipOut = "nz_base_deceiver_disappear_01", ClipStun = "nz_base_deceiver_stun_blend",
ClipIn = "nz_base_deceiver_appear_01", ClipPulse = "nz_base_deceiver_special_attack_01", ClipIdle = "nz_base_deceiver_idle";
const float OutLen = 52f / 30f, OutPortal = 28f / 30f, StunLen = 172f / 30f, StunPortal = 149f / 30f,
InLen = 87f / 30f, PulseLen = 96f / 30f, PulseCharge = 20f / 30f, PulseBlast = 63f / 30f;
/// <summary>"Behind the player": a spot on the navmesh this near and this far from them, behind where they look.</summary>
const float BehindNear = 25f, BehindFar = 115f;
static Color Red => new( 1f, 0.12f, 0.08f );
static Color Aura => new( 0.15f, 0.65f, 1f );
// ══ state ════════════════════════════════════════════════════════════════
bool _rightMask = true, _leftMask = true, _hidden, _away;
int _masks = 3, _mine;
float _nextMine, _nextPulse;
GameObject _victim;
Vector3 _vanishedAt;
SoundHandle _hum;
float _nextHumTry;
float _actRate, _pulseRate, _baseSwing, _baseRest;
enum Beat { PortalOut, Vanish, Mine, Reappear, Visible, PulseCharge, PulseBlast }
public bool Hidden => _hidden;
public int Masks => _masks;
/// <summary>Masks gone while he lives: 0, 1 or 2 (the middle one goes as he dies).</summary>
int Broken => Math.Clamp( 3 - _masks, 0, 2 );
/// <summary>How much faster his attacks run now, and what their gaps are multiplied by — <see cref="FrenzyRate"/> and
/// <see cref="FrenzyGap"/>, once per broken mask.</summary>
float Faster => MathF.Pow( MathF.Max( 0.1f, FrenzyRate ), Broken );
float Sooner => MathF.Pow( Math.Clamp( FrenzyGap, 0.05f, 1f ), Broken );
/// <summary>The teleport's and the pulse's speed, fixed as each starts (never 0: a hotload into a live one finds these unset).</summary>
float ActRate => _actRate > 0.01f ? _actRate : 1f;
float PulseRate => _pulseRate > 0.01f ? _pulseRate : 1f;
protected override void BossStart()
{
// ⚠️ EVERY MACHINE: the aura on his robe (upstream's `zmb_trickster_aura`, blue arcs and embers) and the fire in his eyes
// (`zmb_zct_fire_red` on `eyes`)
var aura = Components.Create<BossGlow>();
aura.Bone = "j_spineupper";
aura.Colour = Aura;
aura.Radius = 150f;
aura.Brightness = 1.6f;
aura.SparkEvery = 0.12f;
aura.SparkReach = 22f;
aura.SparkColour = new Color( 0.5f, 0.9f, 1f );
var eyes = Components.Create<BossGlow>();
eyes.Bone = "j_head";
eyes.Offset = new Vector3( 3.6f, 2.67f, 0f );
eyes.Colour = Red;
eyes.Radius = 60f;
eyes.Brightness = 2.2f;
}
protected override void HostStart()
{
Ai.MaxHitDamage = MaxHit;
// ⛔ NONE OF THE WALKER'S CLIMB CLIPS ON HIS MODEL: his own mantle (upstream's barricade jump, type 0)
Ai.ClimbClipOverride = "nz_base_deceiver_mantle_over_36";
_nextMine = Time.Now + Game.Random.Float( 2.75f, 5.95f );
_nextPulse = Time.Now + Game.Random.Float( PulseGapMin, PulseGapMax );
// he climbs out of a portal (`nz_base_deceiver_spawn`, its `deceiver_portal` at frame 1)
Portal( WorldPosition );
Say( $"spawned — {Hp?.Max ?? 0f:0} hp (x{Resist:0.##} from everything) · mines within {MineRange:0} every {MineGapMin:0.#}-{MineGapMax:0.#}s"
+ $" · pulse within {PulseRange:0} every {PulseGapMin:0.#}-{PulseGapMax:0.#}s · hitboxes {Ai.HitboxCount}" );
}
protected override void EveryFrame()
{
// his hum, looped while you are near enough to hear it (the Sawrunner's saw), and quiet while he is gone
if ( Time.Now < _nextHumTry ) { if ( _hum.IsValid() ) _hum.Position = Voice; return; }
_nextHumTry = Time.Now + 1f;
if ( _hum.IsValid() && _hum.IsPlaying ) return;
if ( NZSound.Ear.Distance( WorldPosition ) > 1400f ) return;
_hum = NZSound.Play( "nz.zaballa.amb", Voice );
}
protected override void HostFrame()
{
Ai.MaxHitDamage = MaxHit;
TickSwipe();
if ( Ai.State == ZombieState.Spawning ) return;
TickMasks();
if ( _away || Ai.State != ZombieState.Chasing ) return;
var p = TargetPlayer;
if ( !IsUp( p ) ) return;
var d = WorldPosition.Distance( p.WorldPosition );
// ⚠️ THE TELEPORT FIRST, as upstream's `AI()` asks it first
if ( Time.Now >= _nextMine && d <= MineRange * Size )
{
if ( Game.Random.Float( 0f, 1f ) < VoxChance ) NZSound.PlayShared( "nz.zaballa.taunt", Voice );
BeginTeleport( false );
return;
}
if ( Time.Now >= _nextPulse && d <= PulseRange * Size && CanSee( p.GameObject ) ) StartPulse();
}
protected override void Died( bool host )
{
if ( _hum.IsValid() ) _hum.Stop();
if ( !host ) return;
// the middle mask comes off as he goes (upstream's `BreakMask(3)`)
SetHidden( false );
SetPart( "mmask", 1 );
MaskBlast();
Say( "killed" );
}
protected override void OnDestroy()
{
if ( _hum.IsValid() ) _hum.Stop();
}
protected override float ScaleDamage( float amount, in DamageInfo damage ) => Resist;
protected override void OnBeat( int beat )
{
switch ( (Beat)beat )
{
case Beat.PortalOut:
Portal( WorldPosition );
break;
case Beat.Vanish:
// gone: hidden on every machine, untouchable, and the first mine at his target's feet
_hidden = true;
_vanishedAt = WorldPosition;
SetHidden( true );
if ( Hp.IsValid() ) Hp.Invulnerable = true;
SwapClip( ClipIdle );
_mine = 0;
LayMine();
Queue( MineGap / ActRate, (int)Beat.Mine );
Queue( MineGap * 2f / ActRate, (int)Beat.Reappear );
break;
case Beat.Mine:
LayMine();
break;
case Beat.Reappear:
LayMine();
Reappear();
break;
case Beat.Visible:
_away = false;
break;
case Beat.PulseCharge:
// `deceiver_attack_charge`: the tell, 1.43 s before it goes off — its reach drawn on the floor
NZSound.PlayShared( "nz.zaballa.charge", Voice );
NZNet.BossTelegraph( WorldPosition, PulseRange * Size, (PulseBlast - PulseCharge) / PulseRate, 1f, 0.15f, 0.35f );
BossFx.BurstShared( Voice, 40f * Size, new Color( 0.7f, 0.3f, 1f ), 14 );
break;
case Beat.PulseBlast:
Pulse();
break;
}
}
// ══ the masks ════════════════════════════════════════════════════════════
/// <summary>The right mask at 75%, the left at 25% — each a blast and, unless he is busy, a stagger into the teleport. THE HOST.</summary>
void TickMasks()
{
if ( !Hp.IsValid() || Hp.Max <= 1f ) return;
var share = Hp.Current / Hp.Max;
if ( _rightMask && share <= 0.75f ) BreakMask( "rmask" );
else if ( _leftMask && share <= 0.25f ) BreakMask( "lmask" );
}
void BreakMask( string group )
{
if ( group == "rmask" ) _rightMask = false;
else _leftMask = false;
_masks--;
SetPart( group, 1 );
MaskBlast();
if ( Game.Random.Float( 0f, 1f ) < VoxChance )
NZSound.PlayShared( _masks == 2 ? "nz.zaballa.mask1" : "nz.zaballa.mask2", Voice );
Say( $"{group} broken — {_masks} mask(s) left" );
// ⚠️ ONLY IF HE IS FREE: mid-pulse or mid-teleport the mask still breaks, without the stagger (upstream's)
if ( !_away && Ai.State is ZombieState.Chasing or ZombieState.Attacking or ZombieState.Idle ) BeginTeleport( true );
}
/// <summary>
/// His swipe now: <see cref="SwipeScale"/> harder always, and per broken mask <see cref="FrenzyRate"/> faster with
/// <see cref="FrenzyGap"/> of the rest between swings — on the authored values, kept the first time this runs (⚠️ LAZILY: a
/// hotload into a live Zaballa finds them unset, before anything here has changed them). THE HOST, every frame.
/// </summary>
void TickSwipe()
{
if ( _baseSwing <= 0f )
{
_baseSwing = Ai.AttackSpeed;
_baseRest = Ai.AttackCooldownPlaceholder;
}
Ai.SwingScale = SwipeScale;
Ai.AttackSpeed = _baseSwing * Faster;
Ai.AttackCooldownPlaceholder = _baseRest * Sooner;
}
/// <summary>`zmb_disciple_blast` at his feet and the crack of the mask.</summary>
void MaskBlast()
{
BossFx.BurstShared( WorldPosition + Vector3.Up * 6f, 90f * Size, Red, 10 );
NZSound.PlayShared( "nz.zaballa.maskbreak", Voice );
}
// ══ the mine teleport ════════════════════════════════════════════════════
/// <summary>Into a portal — the plain way out, or the stagger after a mask — then the mines and out behind his target. THE HOST.</summary>
void BeginTeleport( bool stun )
{
// ⚠️ THE FRENZY'S SPEED FOR THE WHOLE ACT, fixed as it starts — all but a stagger's own clip, which keeps its length
var rate = Faster;
var outRate = stun ? 1f : rate;
var outLen = (stun ? StunLen : OutLen) / outRate;
// ⚠️ THE WHOLE ACT IS ONE HOLD: out, three mines, in — the appearance re-arms it for its own length
if ( !PlayShared( stun ? ClipStun : ClipOut, outLen + MineGap / rate * 2f + 0.5f, outRate ) ) return;
_actRate = rate;
_away = true;
_victim = Ai.Target;
_nextMine = Time.Now + Game.Random.Float( MineGapMin, MineGapMax ) * Sooner;
if ( stun ) NZSound.PlayShared( "nz.zaballa.taunt", Voice );
Queue( (stun ? StunPortal : OutPortal) / outRate, (int)Beat.PortalOut );
Queue( outLen, (int)Beat.Vanish );
Say( stun ? "staggered — teleporting" : "teleporting" );
}
/// <summary>One mine at his target's feet (where he vanished, with no target), 25 up — its fuse the frenzy's. THE HOST.</summary>
void LayMine()
{
_mine++;
var at = (_victim.IsValid() ? _victim.WorldPosition : _vanishedAt) + Vector3.Up * 25f;
BossOrb.Lay( Scene, at, Red, MineFuse / ActRate, GameObject, SwingDamage( MineWeight ), MineRadius * Size, Daze, "nz.zaballa.blast",
look: 1.2f, floor: Math.Clamp( MineFloor, 0f, 1f ) );
NZSound.PlayShared( "nz.zaballa.charge", at );
}
/// <summary>Out of a portal behind his target, facing them (`appear_01`), visible and touchable again. THE HOST.</summary>
void Reappear()
{
var target = _victim.IsValid() ? _victim : Ai.Target;
var spot = target.IsValid() ? BehindSpot( target ) : _vanishedAt;
Warp( spot );
if ( target.IsValid() ) Face( target.WorldPosition );
_hidden = false;
SetHidden( false );
if ( Hp.IsValid() ) Hp.Invulnerable = false;
// ⚠️ THE RATE ON THE HOST'S RENDERER TOO: `SwapClip` only relays it, and a stagger's act started at 1
SwapClip( ClipIn, ActRate );
if ( Body.IsValid() ) Body.PlaybackRate = ActRate;
Ai.HoldSpecialFor( InLen / ActRate );
Queue( InLen / ActRate, (int)Beat.Visible );
Portal( spot );
if ( Game.Random.Float( 0f, 1f ) < VoxChance ) NZSound.PlayShared( "nz.zaballa.laugh", Voice );
}
/// <summary>
/// Upstream's `FindSpotBehindPlayer`: random points on the navmesh within <see cref="BehindFar"/> of them, kept when they lie
/// behind where the player looks; the nearest wins. None found, the spot on the mesh at their own feet.
/// </summary>
Vector3 BehindSpot( GameObject target )
{
var centre = target.WorldPosition;
var p = target.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
var look = p.IsValid() ? p.EyeAngles.ToRotation().Forward.WithZ( 0 ).Normal : Vector3.Zero;
var nav = Scene.NavMesh;
Vector3? best = null;
var bestD = float.MaxValue;
if ( nav is not null )
for ( int i = 0; i < 25; i++ )
{
var q = nav.GetRandomPoint( centre, BehindFar );
if ( !q.HasValue ) continue;
var off = (q.Value - centre).WithZ( 0 );
var d = off.Length;
if ( d < BehindNear || d > BehindFar || MathF.Abs( q.Value.z - centre.z ) > 64f ) continue;
if ( look.Length > 0.5f && look.Dot( off.Normal ) >= 0f ) continue;
if ( d >= bestD ) continue;
bestD = d;
best = q;
}
return best ?? ZombieAI.NavGround( Scene, centre );
}
/// <summary>His red portal, on every machine: a flash, a ring on the floor, and its sound.</summary>
void Portal( Vector3 at )
{
BossFx.BurstShared( at + Vector3.Up * 4f, 80f * Size, Red, 12 );
NZSound.PlayShared( "nz.zaballa.portal", at + Vector3.Up * 40f );
}
// ══ the pulse ════════════════════════════════════════════════════════════
/// <summary>The pulse — its clip, its warning and its gap the frenzy's, fixed as it starts. THE HOST.</summary>
void StartPulse()
{
var rate = Faster;
_nextPulse = Time.Now + Game.Random.Float( PulseGapMin, PulseGapMax ) * Sooner;
if ( !PlayShared( ClipPulse, PulseLen / rate, rate ) ) return;
_pulseRate = rate;
Queue( PulseCharge / rate, (int)Beat.PulseCharge );
Queue( PulseBlast / rate, (int)Beat.PulseBlast );
}
/// <summary>
/// `deceiver_attack`: x<see cref="PulseWeight"/> of his swipe at his middle, falling off to <see cref="PulseRange"/>, everyone
/// in it dazed (each machine its own player, `BossFx.Burst`). THE HOST.
/// </summary>
void Pulse()
{
var at = WorldPosition + Vector3.Up * 40f * Size;
var radius = PulseRange * Size;
var damage = SwingDamage( PulseWeight );
foreach ( var p in PlayersWithin( at, radius ).ToList() )
{
var d = (p.WorldPosition + Vector3.Up * 40f).Distance( at );
HurtPlayer( p, damage * Math.Clamp( 1f - d / radius, Math.Clamp( PulseFloor, 0f, 1f ), 1f ), blast: true, at: at, source: "zaballa pulse" );
}
BossFx.BurstShared( at, radius, Red, 16, Daze );
NZSound.PlayShared( "nz.zaballa.blast", at );
NZNet.ShakeAt( WorldPosition, 0.45f, 600f );
}
// ══ diagnostics ══════════════════════════════════════════════════════════
static IEnumerable<ZaballaBoss> All
=> Game.ActiveScene?.GetAllComponents<ZaballaBoss>() ?? Enumerable.Empty<ZaballaBoss>();
/// <summary>`nz_zaballa` — every Zaballa alive, and the tuning.</summary>
[ConCmd( "nz_zaballa" )]
public static void Report()
{
Log.Info( $"[nz-zaballa] mines: within {MineRange:0} every {MineGapMin:0.#}-{MineGapMax:0.#}s, {MineRadius:0}u x{MineWeight:0.##} after {MineFuse:0.##}s,"
+ $" {MineGap:0.##}s apart · pulse: within {PulseRange:0} every {PulseGapMin:0.#}-{PulseGapMax:0.#}s x{PulseWeight:0.##} · daze {Daze:0.##}s"
+ $" · x{Resist:0.##} from everything · hit cap {MaxHit:0}" );
Log.Info( $"[nz-zaballa] swipe x{SwipeScale:0.##} · edges: mines {MineFloor:0.##}, pulse {PulseFloor:0.##} of the middle"
+ $" · per broken mask: gaps x{FrenzyGap:0.##}, x{FrenzyRate:0.##} as fast" );
var list = All.ToList();
Log.Info( $"[nz-zaballa] {list.Count} alive" );
foreach ( var z in list )
Log.Info( $"[nz-zaballa] hp {z.Hp?.Current ?? 0f:0}/{z.Hp?.Max ?? 0f:0} · state {z.Ai?.State} · masks {z._masks}"
+ $" (x{z.Faster:0.##} as fast, gaps x{z.Sooner:0.##}; swing {z.Ai?.AttackSpeed ?? 0f:0.##}, rest {z.Ai?.AttackCooldownPlaceholder ?? 0f:0.##}s)"
+ $"{(z._hidden ? " · HIDDEN" : z._away ? " · teleporting" : "")} · next teleport in {MathF.Max( 0f, z._nextMine - Time.Now ):0.#}s"
+ $" · next pulse in {MathF.Max( 0f, z._nextPulse - Time.Now ):0.#}s" );
}
/// <summary>`nz_zaballa_teleport` — every Zaballa teleports now; `nz_zaballa_pulse` — every one pulses now.</summary>
[ConCmd( "nz_zaballa_teleport" )]
public static void TeleportCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-zaballa] the host decides" ); return; }
foreach ( var z in All.Where( z => z.Ai.IsValid() && !z.Dead && !z._away ).ToList() ) z.BeginTeleport( false );
}
[ConCmd( "nz_zaballa_pulse" )]
public static void PulseCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-zaballa] the host decides" ); return; }
foreach ( var z in All.Where( z => z.Ai.IsValid() && !z.Dead && !z._away ).ToList() ) z.StartPulse();
}
/// <summary>`nz_zaballa_mask` — every Zaballa loses his next mask now (the right, then the left), whatever his health: the
/// blast, the stagger and a step of the frenzy.</summary>
[ConCmd( "nz_zaballa_mask" )]
public static void MaskCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-zaballa] the host decides" ); return; }
foreach ( var z in All.Where( z => z.Ai.IsValid() && !z.Dead ).ToList() )
{
if ( z._rightMask ) z.BreakMask( "rmask" );
else if ( z._leftMask ) z.BreakMask( "lmask" );
else Log.Info( "[nz-zaballa] no mask left to break but the last (it goes as he dies)" );
}
}
/// <summary>`nz_zaballa_set <key> <value>` — retune him live.</summary>
[ConCmd( "nz_zaballa_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "range": MineRange = value; break;
case "gapmin": MineGapMin = value; break;
case "gapmax": MineGapMax = value; break;
case "mine": MineWeight = value; break;
case "mineradius": MineRadius = value; break;
case "fuse": MineFuse = value; break;
case "minegap": MineGap = value; break;
case "daze": Daze = value; break;
case "pulserange": PulseRange = value; break;
case "pulse": PulseWeight = value; break;
case "resist": Resist = value; break;
case "vox": VoxChance = value; break;
case "swipe": SwipeScale = value; break;
case "minefloor": MineFloor = value; break;
case "pulsefloor": PulseFloor = value; break;
case "frenzygap": FrenzyGap = value; break;
case "frenzyrate": FrenzyRate = value; break;
default:
Log.Info( "[nz-zaballa] nz_zaballa_set <range|gapmin|gapmax|mine|mineradius|fuse|minegap|daze|pulserange|pulse|resist|vox"
+ "|swipe|minefloor|pulsefloor|frenzygap|frenzyrate> <value>" );
return;
}
Log.Info( $"[nz-zaballa] {key} = {value:0.###}" );
Report();
}
}