A game component representing the Wustling special zombie. It handles its tuning, state, animations, charge behavior (tell, run, slam), weak spot damage scaling, stun shaking, and debugging console commands.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// THE WÜSTLING — CoD WWII's charging brute, ported 2026-10-07 from GMod nZombies' `nz_zombie_special_follower` (GhostlyMoo's port;
/// "Wustling" upstream, "follower" in its assets — the name of no behaviour). A SPECIAL, not a boss: he arrives during ordinary
/// rounds (`AmbientSpecials`) and the round waits for him (`IsBoss` false). Built on `BossBase` for its helpers alone (`Queue`,
/// `PlayShared`, `HurtPlayer`, `MaxHit`, `SwingDamage`, `SetPart`, `ScaleDamage`) — nothing in it assumes a boss.
///
/// • HE WALKS, one of two walks rolled per Wüstling (55.0 or 65.9 u/s, each its own tier), and swings for x1.3 a walker (upstream's
/// 65 against 50) — the standing set at a target all but still within <see cref="StandRange"/>, upstream's base split.
/// • SHOOT HIM AND HE MAY CHARGE: every hit has a <see cref="ChargeChance"/> chance once the cooldown is out (the first
/// <see cref="FirstCooldown"/> s after he arrives). Its clock is <see cref="MadMin"/>-<see cref="MadMax"/> s from that hit.
/// THE TELL: he flinches (`charge_react`, 2.23 s — a pain yelp, then at 1.77 s the roar), then RUNS at 274.6 u/s (`charge_run`)
/// for what is left of the clock (2-2.7 s).
/// • THE SLAM ENDS IT: `charge_attack` (2.9 s), a lunge of up to <see cref="SlamTravel"/> forward to its blow at 0.77 s — his
/// target, within <see cref="SlamReach"/>, takes x<see cref="HeavyWeight"/> of his swing (upstream's 95 against 65; never over
/// `BossBase.MaxHit`) and the hulk's thud; a punch, a jolt within 500 and dust at his feet for everyone. He slams the moment his
/// target is in sight within <see cref="ChargeReach"/> (no charge then for <see cref="HitCooldown"/> s), or where he stands when
/// the clock runs out (<see cref="StopCooldown"/> s) — each counted from the slam's start, upstream's.
/// ⚠️ NO KNOCKBACK: upstream has none for him (the base's shove is a ZCT personality's, and a special never gets one).
/// ⚠️ THE SLAM IS HIS, NOT `ZombieAI`'S SWING: upstream plays the one clip and blow at both ends, so here both are this move,
/// timed by its own frame 23 (the AI swings at 1.8x and hits 10% in). His swing is off while he charges (`MeleeDisabled`).
/// ⚠️ OFF THE GROUND (a drop, a climb) THE CHARGE ENDS — its clock runs out and he slams once he is down. Upstream's comment says
/// so; its line set the COOLDOWN (`ManIsMadCooldown`) where it meant the clock (`ManIsMadTime`), and never stopped anything.
/// ⚠️ A BOARDED WINDOW IN HIS WAY ENDS IT TOO, with no slam: he stops to tear it with his ordinary swings — the comment's "moving
/// into an object", which upstream never wrote.
/// ⚠️ NOT UPSTREAM'S 100 °/s TURN WHILE CHARGING: the agent here steers by its path, and the body's turn is the AI's (`MaxYawRate`
/// + 0.85 x speed — 233 °/s at the charge's speed even at 0), so he cannot be baited off a ledge.
/// • HIS WEAK SPOT: a hit within <see cref="WeakReach"/> of `tag_back_weakspot` (the QC's: 6.5 along `j_spine4`, his upper back)
/// takes x<see cref="WeakScale"/> and a wet impact; anything else x<see cref="BodyScale"/> — 16 walkers' health from the front.
/// • A STUN SHAKES HIM: while the `stun` status holds him (Dead Wire's stun lock, Elemental Pop, Concussion…) he plays his
/// `stunned_electrobolt` loop instead of standing still — upstream's `SparkySequences`, its idle under `PerformStun`.
/// ⚠️ His shock DEATH (upstream's electrobolt as `DMG_SHOCK`'s death clip) is not here: nothing in this game kills by type.
/// • PAIN: a hit voices it at most every <see cref="PainGap"/> s, and so does the flinch that starts his charge.
/// ⚠️ Upstream's hit pain needs the map's stumbling setting and a head or arm hit; the flinch's own `vox_follower_pain` is an
/// event of the clip that no code of upstream's plays.
/// • HE RISES out of the ground (`trav_riser`, 6.4 s; its dirt-crawl at 1.1 s), crosses climbs with his window vault
/// (`trav_win_v1`), and hits the floor a second into his death.
/// ⚠️ LURES STILL PULL HIM (Banana Colada's stand, the Re-Animator's human); upstream's specials ignore monkey bombs — no variant
/// here can say so.
///
/// ⚠️ HOST AUTHORITY, EVERY MACHINE DRAWS: clips through `PlayShared`, sounds `NZSound.PlayShared`, the jolt `NZNet.ShakeAt`, the dust
/// `ShockRing.FireShared`; his pace (walk v1 / v2 / the charge) travels as a part (`SetPart( PacePart )` → <see cref="ShowPart"/>),
/// the Panzerhund's way, so a watching machine's legs match his.
/// ⛔ NO HIT OF HIS DOWNS ANYONE FROM FULL HEALTH: the slam goes through `HurtPlayer`, his swing under `ZombieAI.MaxHitDamage`.
/// </summary>
public sealed class WustlingZombie : BossBase
{
protected override string Tag => "[nz-wustling]";
protected override float VoiceHeight => 66f;
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED, INSTRUCTIONS.md §1.
static float? _chance, _madMin, _madMax, _firstCd, _hitCd, _stopCd;
/// <summary>A hit's chance to start the charge once the cooldown is out: 0.5 (upstream's `math.random(100) <= 50`).</summary>
public static float ChargeChance { get => _chance ?? 0.5f; set => _chance = value; }
/// <summary>The charge's clock, from the hit that starts it, the tell included: 4.25-4.95 s, upstream's.</summary>
public static float MadMin { get => _madMin ?? 4.25f; set => _madMin = value; }
public static float MadMax { get => _madMax ?? 4.95f; set => _madMax = value; }
/// <summary>No charge for 3 s after he arrives; for 6 s after a slam that reached someone, 3 s after one the clock ran out on.</summary>
public static float FirstCooldown { get => _firstCd ?? 3f; set => _firstCd = value; }
public static float HitCooldown { get => _hitCd ?? 6f; set => _hitCd = value; }
public static float StopCooldown { get => _stopCd ?? 3f; set => _stopCd = value; }
static float? _chargeReach, _slamReach, _slamTravel, _heavy;
/// <summary>Charging, he slams at his target in sight this near: 95 (upstream's `AttackRange`, which starts his attack).</summary>
public static float ChargeReach { get => _chargeReach ?? 95f; set => _chargeReach = value; }
/// <summary>The slam's blow lands on his target within 95 of him (upstream's `DamageRange`) …</summary>
public static float SlamReach { get => _slamReach ?? 95f; set => _slamReach = value; }
/// <summary>… after a lunge of up to 163: `charge_attack`'s own root travel to its frame 23, which the port strips.</summary>
public static float SlamTravel { get => _slamTravel ?? 163f; set => _slamTravel = value; }
/// <summary>
/// The slam's blow against his swing: 1.46 (upstream's `HeavyAttackDamage` 95 against his `AttackDamage` 65) — 57 to round 6, 95
/// to 15, then `BossBase.MaxHit` (100) on every round after.
/// </summary>
public static float HeavyWeight { get => _heavy ?? 95f / 65f; set => _heavy = value; }
static float? _weak, _body, _weakReach, _painGap;
/// <summary>A hit within 10 of his weak spot takes x3, anything else x0.25 — upstream's table.</summary>
public static float WeakScale { get => _weak ?? 3f; set => _weak = value; }
public static float BodyScale { get => _body ?? 0.25f; set => _body = value; }
public static float WeakReach { get => _weakReach ?? 10f; set => _weakReach = value; }
/// <summary>A hit voices his pain at most this often: 8 s (the base's `LastStun + 8`); 0, never.</summary>
public static float PainGap { get => _painGap ?? 8f; set => _painGap = value; }
static float? _standRange, _standSpeed;
/// <summary>He plants for a standing swing at a target this near and this still: 80 (`AttackRange − 15`), 3.9 u/s (`velocity² < 15`).</summary>
public static float StandRange { get => _standRange ?? 80f; set => _standRange = value; }
public static float StandSpeed { get => _standSpeed ?? 3.9f; set => _standSpeed = value; }
static bool? _shockClips;
/// <summary>A stun shakes him in his electrobolt loop (upstream's `SparkySequences`); off, he stands rooted like a walker.</summary>
public static bool ShockClips { get => _shockClips ?? true; set => _shockClips = value; }
// his clips (30 fps) and their events — ⛔ `const`, never `static readonly` (INSTRUCTIONS.md §1)
const string ClipReact = "nz_base_follower_charge_react", ClipSlam = "nz_base_follower_charge_attack",
ClipShockA = "nz_base_follower_stunned_electrobolt_v1", ClipShockB = "nz_base_follower_stunned_electrobolt_v2",
ClipVault = "nz_base_follower_trav_win_v1", ClipStandA = "nz_base_follower_stand_attack_v1",
ClipStandB = "nz_base_follower_stand_attack_v2", ClipStandC = "nz_base_follower_stand_attack_v3";
const float ReactLen = 67f / 30f, ReactPain = 3f / 30f, ReactRoar = 53f / 30f, SlamLen = 87f / 30f, SlamWhoosh = 18f / 30f,
SlamHit = 23f / 30f, RiserCrawl = 33f / 30f, BodyfallAt = 30f / 30f;
/// <summary>His clips' own speeds — `wustling.zvar`'s tiers are picked by them.</summary>
const float WalkSlow = 55.0f, WalkFast = 65.9f, ChargeSpeed = 274.6f;
/// <summary>The electrobolt is held this long past the last frame he was stunned.</summary>
const float ShakeTail = 0.25f;
/// <summary>The lunge stops this far short of the player it is aimed at, and of a wall.</summary>
const float SlamGap = 30f;
/// <summary>Not a bodygroup: the pace the host tells every machine (0 walk v1, 1 walk v2, 2 the charge), taken by <see cref="ShowPart"/>.</summary>
const string PacePart = "wustling_pace";
/// <summary>`tag_back_weakspot`, from the QC: 6.5 along `j_spine4`'s Y — his upper back (measured: model (0, 7.7, 60.4); the rig faces −Y).</summary>
static Vector3 WeakOffset => new( 0f, 6.5f, 0f );
static Color Dust => new( 0.72f, 0.64f, 0.52f );
// ══ state ════════════════════════════════════════════════════════════════
bool _mad, _chargeDue, _shaking, _paceSent, _fell;
int _walkPace, _pace, _shownPace;
float _madUntil, _cooldownUntil, _painAt, _nextWet, _diedAt;
string _shakeClip;
List<string> _standing;
GameObject _tracked;
Vector3 _trackedAt;
float _targetSpeed = 999f;
enum Beat { Pain, Roar, Whoosh, Slam, Crawl }
public bool Charging => _mad;
/// <summary>Free to start something: walking, swinging or standing — not in a clip of his own, a spawn or a fall.</summary>
bool Free => Ai.State is ZombieState.Chasing or ZombieState.Attacking or ZombieState.Idle;
static float SpeedOf( int pace ) => pace switch { 1 => WalkFast, 2 => ChargeSpeed, _ => WalkSlow };
/// <summary>The standing swings (⚠️ LAZILY: a hotload into a live one finds this unset).</summary>
List<string> Standing => _standing ??= new List<string> { ClipStandA, ClipStandB, ClipStandC };
protected override void HostStart()
{
Ai.MaxHitDamage = MaxHit;
// ⛔ NONE OF THE WALKER'S CLIMB CLIPS ON HIS MODEL: his window vault (upstream's `JumpSequences`)
Ai.ClimbClipOverride = ClipVault;
// upstream rolls one walk clip of two; each is its own tier here
_walkPace = Game.Random.Int( 0, 1 );
_pace = _walkPace;
SetSpeed( SpeedOf( _pace ) );
_cooldownUntil = Time.Now + FirstCooldown;
// the riser's dirt-crawl, its frame 33 (`CoDZ_Zombie.Ground_ClimbOut`) — its frame 0 is the variant's SpawnSound
if ( Ai.State == ZombieState.Spawning ) Queue( RiserCrawl, (int)Beat.Crawl );
Say( $"spawned — {Hp?.Max ?? 0f:0} hp (x{BodyScale:0.##}, x{WeakScale:0.##} on his back) · walks {SpeedOf( _pace ):0.#} u/s"
+ $" · charges on {ChargeChance * 100f:0}% of hits · hitboxes {Ai.HitboxCount}" );
}
/// <summary>
/// ⚠️ A WATCHING MACHINE'S LEGS: the host's `SetSpeed` does not travel (the Panzerhund's note), so its puppet re-picks its clip from
/// the pace it was told (<see cref="ShowPart"/>). Every machine.
/// </summary>
protected override void EveryFrame()
{
if ( !Ai.IsPuppet ) return;
var speed = MathF.Max( 1f, SpeedOf( _shownPace ) * Size );
if ( MathF.Abs( Ai.SpeedOverride - speed ) <= 0.01f ) return;
Ai.SpeedOverride = speed;
Ai.RepickAnimations();
}
protected override void HostFrame()
{
Ai.MaxHitDamage = MaxHit;
// ⚠️ EVERY FRAME, the Napalm Zombie's way: his swing is off exactly while he charges — the slam is his
Ai.MeleeDisabled = _mad;
if ( Ai.State == ZombieState.Spawning ) { _chargeDue = false; return; }
// ⚠️ THE PACE ONCE MORE AS HE STANDS UP: a watching machine may not have had him yet when he spawned
if ( !_paceSent )
{
_paceSent = true;
SetPart( PacePart, _pace );
}
TickShock();
if ( _chargeDue )
{
_chargeDue = false;
if ( !_mad && Free ) StartCharge();
}
if ( _mad ) TickCharge();
PickAttackSet();
}
/// <summary>
/// His table: x<see cref="WeakScale"/> on the weak spot, x<see cref="BodyScale"/> anywhere else — and the hit that may voice his
/// pain or start his charge. THE HOST, once a hit.
/// </summary>
protected override float ScaleDamage( float amount, in DamageInfo damage )
{
var weak = OnWeakSpot( damage.Position );
// the only sign you found it: a wet hit (upstream's `WeakImpactSounds`, the Margwa's), throttled per bullet
if ( weak && Time.Now >= _nextWet )
{
_nextWet = Time.Now + 0.07f;
NZSound.PlayShared( "nz.margwa.weakspot", damage.Position );
}
if ( PainGap > 0f && Time.Now >= _painAt + PainGap && Ai.State is not (ZombieState.Spawning or ZombieState.Dead) ) Pain();
// ⚠️ ASKED FOR HERE, STARTED IN `HostFrame` — outside the damage call (the Director's way)
if ( !_mad && Time.Now >= _cooldownUntil && Game.Random.Float( 0f, 1f ) < ChargeChance ) _chargeDue = true;
return weak ? WeakScale : BodyScale;
}
/// <summary>A watching machine told his pace (`NZNet.BossPart`); the host's own echo is harmless (it never reads it).</summary>
public override bool ShowPart( string group, int choice )
{
if ( group != PacePart ) return false;
_shownPace = choice;
return true;
}
protected override void OnBeat( int beat )
{
switch ( (Beat)beat )
{
case Beat.Pain:
// the flinch's own `vox_follower_pain` (frame 3) — unless the hit that started it just voiced one
if ( Time.Now >= _painAt + 1f ) Pain();
break;
case Beat.Roar:
// `vox_follower_charge` (frame 53): THE TELL, half a second before he runs
NZSound.PlayShared( "nz.wustling.charge", Voice );
break;
case Beat.Whoosh:
// `melee_whoosh` (frame 18): the base's heavy swipe
NZSound.PlayShared( "nz.zaballa.whoosh", Voice );
break;
case Beat.Slam:
SlamBlow();
break;
case Beat.Crawl:
NZSound.PlayShared( "nz.tesla.climbout", WorldPosition + Vector3.Up * 20f );
break;
}
}
protected override void Died( bool host )
{
_diedAt = Time.Now;
if ( !host ) return;
_mad = false;
Ai.MeleeDisabled = false;
Ai.AttackOverride = null;
Say( "killed" );
}
/// <summary>The thud a second into either death clip (the base's `bodyfall_heavy`, frame 30/31). THE HOST.</summary>
protected override void DeadFrame()
{
if ( _fell || !IsHost || Time.Now < _diedAt + BodyfallAt ) return;
_fell = true;
NZSound.PlayShared( "nz.megaton.bodyfall", WorldPosition );
}
// ══ the weak spot ════════════════════════════════════════════════════════
/// <summary>Where `tag_back_weakspot` is now: on `j_spine4`, so it follows his lean; without the bone, behind his middle.</summary>
Vector3 WeakSpot => Body.IsValid() && Body.TryGetBoneTransform( "j_spine4", out var spine )
? spine.PointToWorld( WeakOffset )
: WorldPosition + (Vector3.Up * 60f - Facing * 7.7f) * Size;
/// <summary>Upstream's test: the hit's position within <see cref="WeakReach"/> of the spot (a blast's centre is not on it).</summary>
bool OnWeakSpot( Vector3 at ) => !at.IsNearlyZero() && at.Distance( WeakSpot ) <= WeakReach * Size;
void Pain()
{
_painAt = Time.Now;
NZSound.PlayShared( "nz.wustling.pain", Voice );
}
// ══ the charge ═══════════════════════════════════════════════════════════
/// <summary>The charge: the flinch and the roar (the tell), then the run until the clock — the slam ends it. THE HOST.</summary>
void StartCharge()
{
if ( !PlayShared( ClipReact, ReactLen ) ) return;
_mad = true;
_madUntil = Time.Now + Game.Random.Float( MadMin, MathF.Max( MadMin, MadMax ) );
Ai.MeleeDisabled = true;
ApplyPace();
Queue( ReactPain, (int)Beat.Pain );
Queue( ReactRoar, (int)Beat.Roar );
Say( $"CHARGING — runs at {ChargeSpeed:0.#} u/s once he has roared; slams in {_madUntil - Time.Now:0.##}s, or at his target"
+ $" within {ChargeReach:0}" );
}
/// <summary>
/// The charge, every frame of it: off the ground the clock runs out (he slams once down); a board in his way ends it; his target
/// in sight within <see cref="ChargeReach"/> takes the slam now; at the clock's end, the slam where he stands. THE HOST.
/// </summary>
void TickCharge()
{
var airborne = Ai.OnLink || Ai.CrossingLink || Ai.Arcing;
// ⚠️ UPSTREAM'S INTENT, NOT ITS LINE: `ManIsMadCooldown = CurTime() - 999` was meant for the clock
if ( airborne && _madUntil > Time.Now )
{
_madUntil = Time.Now;
Say( "off the ground — the charge ends as he lands" );
}
// his swing is off, so this is a board or a wall he stopped to tear
if ( Ai.State == ZombieState.Attacking )
{
Calm( StopCooldown, "a barricade in his way" );
return;
}
if ( airborne || Ai.State is not (ZombieState.Chasing or ZombieState.Idle) ) return;
var p = TargetPlayer;
if ( IsUp( p ) && WorldPosition.Distance( p.WorldPosition ) <= ChargeReach * Size && CanSee( p.GameObject ) )
{
Slam( true );
return;
}
if ( Time.Now >= _madUntil ) Slam( false );
}
/// <summary>The charge over without a slam. THE HOST.</summary>
void Calm( float cooldown, string why )
{
_mad = false;
_cooldownUntil = Time.Now + cooldown;
Ai.MeleeDisabled = false;
ApplyPace();
Say( $"charge over ({why}) — walking at {SpeedOf( _pace ):0.#} u/s" );
}
/// <summary>
/// His speed for what he is doing — the charge, or his walk — and every machine told on a change. ⚠️ MID-SPECIAL ONLY THE NUMBER
/// (Krasny's rule): a re-pick pushes the agent's speed back where the special roots it; the special's end re-picks. THE HOST.
/// </summary>
void ApplyPace()
{
var pace = _mad ? 2 : _walkPace;
var speed = SpeedOf( pace );
if ( Ai.State is ZombieState.Special or ZombieState.Spawning ) Ai.SpeedOverride = MathF.Max( 1f, speed * Size );
else SetSpeed( speed );
if ( pace == _pace ) return;
_pace = pace;
SetPart( PacePart, pace );
}
/// <summary>
/// The slam, both of the charge's ends (upstream plays the one clip and blow for both): facing his target when he reached them,
/// the lunge to the blow at frame 23, his walk again after. THE HOST.
/// </summary>
void Slam( bool reached )
{
var p = TargetPlayer;
if ( reached && IsUp( p ) ) Face( p.WorldPosition );
_mad = false;
_cooldownUntil = Time.Now + (reached ? HitCooldown : StopCooldown);
Ai.MeleeDisabled = false;
var played = PlayShared( ClipSlam, SlamLen );
ApplyPace();
if ( !played ) return;
// ⚠️ THE LUNGE: the clip's 163 to its blow is root motion the port strips — an arc along his facing, short of a wall and of the
// player ahead (upstream's body stopped against them)
var dir = Facing;
var reach = SlamTravel * Size;
if ( IsUp( p ) )
{
var to = (p.WorldPosition - WorldPosition).WithZ( 0 );
if ( to.Length > 1f && Vector3.Dot( to.Normal, dir ) > 0.7f ) reach = MathF.Min( reach, to.Length - SlamGap * Size );
}
if ( reach > 16f )
{
var from = WorldPosition + Vector3.Up * 40f * Size;
var wall = Scene.Trace.Ray( from, from + dir * reach ).IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "zombie", "player", "trigger", "ragdoll" ).Run();
if ( wall.Hit ) reach = MathF.Min( reach, wall.Distance - SlamGap * Size );
if ( reach > 16f ) Ai.BeginArc( WorldPosition + dir * reach, SlamHit, 0f );
}
Queue( SlamWhoosh, (int)Beat.Whoosh );
Queue( SlamHit, (int)Beat.Slam );
Say( reached
? $"SLAM at {(p.IsValid() ? p.GameObject.Name : "nobody")} — no charge for {HitCooldown:0.#}s"
: $"the clock ran out — SLAM where he stands, no charge for {StopCooldown:0.#}s" );
}
/// <summary>
/// The slam's blow (`melee_heavy` + `evt_follower_slam`, frame 23): the vox, the punch, the jolt within 500, the dust — and his
/// target, if within <see cref="SlamReach"/>, takes x<see cref="HeavyWeight"/> of his swing (never over `MaxHit`) and the hulk's
/// thud. Upstream's `DoAttackDamage`: his target alone. THE HOST.
/// </summary>
void SlamBlow()
{
var at = WorldPosition;
NZSound.PlayShared( "nz.wustling.melee", Voice );
NZSound.PlayShared( "nz.wustling.slam", at + Vector3.Up * 20f );
NZNet.ShakeAt( at, 0.5f, 500f );
ShockRing.FireShared( at + Vector3.Up * 4f, 110f * Size, Dust );
var p = TargetPlayer;
if ( !IsUp( p ) || p.WorldPosition.Distance( at ) > SlamReach * Size ) return;
if ( HurtPlayer( p, SwingDamage( HeavyWeight ), source: "wustling slam" ) )
NZSound.PlayShared( "nz.wustling.impact", p.WorldPosition + Vector3.Up * 40f );
}
// ══ the stun and the standing swing ══════════════════════════════════════
/// <summary>
/// A stun shakes him in his electrobolt loop, held while the `stun` status lasts and <see cref="ShakeTail"/> after (upstream's
/// `SparkySequences`). THE HOST.
/// </summary>
void TickShock()
{
var stunned = ShockClips && StatusEffects.Has( GameObject, "stun" );
if ( _shaking )
{
if ( stunned && Ai.State == ZombieState.Special && Ai.CurrentClip == _shakeClip ) Ai.HoldSpecialFor( ShakeTail );
else _shaking = false;
return;
}
if ( !stunned || !Free ) return;
var clip = Game.Random.Int( 0, 1 ) == 0 ? ClipShockA : ClipShockB;
if ( !PlayShared( clip, ShakeTail ) ) return;
_shaking = true;
_shakeClip = clip;
}
/// <summary>
/// Which swings his next attack draws from — upstream's base `Attack`: the standing set at a target all but still within
/// <see cref="StandRange"/>, else the tier's moving ones (none while he charges). The AI reads it as each swing starts. THE HOST.
/// </summary>
void PickAttackSet()
{
var t = Ai.Target;
if ( !t.IsValid() )
{
_tracked = null;
_targetSpeed = 999f;
}
else if ( t != _tracked )
{
_tracked = t;
_trackedAt = t.WorldPosition;
_targetSpeed = 999f;
}
else
{
var dt = MathF.Max( 0.001f, Time.Delta );
var v = (t.WorldPosition - _trackedAt).WithZ( 0 ).Length / dt;
_trackedAt = t.WorldPosition;
if ( v <= 1200f ) _targetSpeed += (v - _targetSpeed) * (1f - MathF.Exp( -10f * dt )); // past 1200: a teleport, not a run
}
var stand = !_mad && t.IsValid() && _targetSpeed < StandSpeed && WorldPosition.Distance( t.WorldPosition ) <= StandRange * Size;
var want = stand ? Standing : null;
if ( !ReferenceEquals( Ai.AttackOverride, want ) ) Ai.AttackOverride = want;
}
// ══ diagnostics ══════════════════════════════════════════════════════════
static IEnumerable<WustlingZombie> All
=> Game.ActiveScene?.GetAllComponents<WustlingZombie>() ?? Enumerable.Empty<WustlingZombie>();
/// <summary>"walk v1", "walk v2" or "CHARGING (Ns on the clock)" — a watching machine knows only the pace the host told it.</summary>
string PaceReport
=> IsHost
? (_mad ? $"CHARGING ({MathF.Max( 0f, _madUntil - Time.Now ):0.#}s on the clock)" : _walkPace == 0 ? "walk v1" : "walk v2")
: (_shownPace == 2 ? "CHARGING (the host's)" : _shownPace == 1 ? "walk v2" : "walk v1");
/// <summary>`nz_wustling` — every Wüstling alive, and the tuning.</summary>
[ConCmd( "nz_wustling" )]
public static void Report()
{
Log.Info( $"[nz-wustling] charge on {ChargeChance * 100f:0}% of hits, clock {MadMin:0.##}-{MadMax:0.##}s · cooldowns {FirstCooldown:0.#}s"
+ $" (arrival) / {HitCooldown:0.#}s (reached) / {StopCooldown:0.#}s (clock) · slams at {ChargeReach:0} in sight, lunge"
+ $" {SlamTravel:0}, blow within {SlamReach:0} x{HeavyWeight:0.###} a swing · hit cap {MaxHit:0}" );
Log.Info( $"[nz-wustling] weak spot x{WeakScale:0.##} within {WeakReach:0.#}, else x{BodyScale:0.##} · pain every {PainGap:0.#}s"
+ $" · stun shake {(ShockClips ? "on" : "off")} · standing swing within {StandRange:0} under {StandSpeed:0.#} u/s" );
var list = All.ToList();
Log.Info( $"[nz-wustling] {list.Count} alive" );
foreach ( var w in list )
{
if ( !w.Ai.IsValid() ) continue;
// ⚠️ THE WEAK SPOT'S SANITY: it must sit ~6.5 BEHIND his spine — a negative number means the bone's frame came out
// mirrored and `WeakOffset` needs its sign flipped
var spine = w.Body.IsValid() && w.Body.TryGetBoneTransform( "j_spine4", out var s ) ? s.Position : w.WorldPosition;
var behind = -Vector3.Dot( w.WeakSpot - spine, w.Facing );
Log.Info( $"[nz-wustling] hp {w.Hp?.Current ?? 0f:0}/{w.Hp?.Max ?? 0f:0} · state {w.Ai.State} · speed {w.Ai.MoveSpeed:0}"
+ $" · {w.PaceReport} · {(w._mad ? "" : $"next charge in {MathF.Max( 0f, w._cooldownUntil - Time.Now ):0.#}s · ")}"
+ $"weak spot {behind:0.#} behind his spine{(w._shaking ? " · SHAKING (stunned)" : "")}"
+ $"{(w.Ai.AttackOverride is not null ? " · standing swing" : "")}" );
}
}
/// <summary>`nz_wustling_charge` — every Wüstling charges now (the tell, the run, the slam), whatever the cooldown and the dice.</summary>
[ConCmd( "nz_wustling_charge" )]
public static void ChargeCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-wustling] the host decides" ); return; }
foreach ( var w in All.Where( w => w.Ai.IsValid() && !w.Dead && !w._mad ).ToList() )
{
if ( w.Free ) w.StartCharge();
else Log.Info( $"[nz-wustling] busy ({w.Ai.State}) — try again" );
}
}
/// <summary>`nz_wustling_slam` — every Wüstling slams now where he stands (the charge's end), charging or not.</summary>
[ConCmd( "nz_wustling_slam" )]
public static void SlamCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-wustling] the host decides" ); return; }
foreach ( var w in All.Where( w => w.Ai.IsValid() && !w.Dead && w.Free ).ToList() ) w.Slam( false );
}
/// <summary>`nz_wustling_set <key> <value>` — retune him live (`shock` 0 / 1).</summary>
[ConCmd( "nz_wustling_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "chance": ChargeChance = value; break;
case "madmin": MadMin = value; break;
case "madmax": MadMax = value; break;
case "firstcd": FirstCooldown = value; break;
case "hitcd": HitCooldown = value; break;
case "stopcd": StopCooldown = value; break;
case "reach": ChargeReach = value; break;
case "slamreach": SlamReach = value; break;
case "travel": SlamTravel = value; break;
case "heavy": HeavyWeight = value; break;
case "weak": WeakScale = value; break;
case "body": BodyScale = value; break;
case "weakreach": WeakReach = value; break;
case "pain": PainGap = value; break;
case "shock": ShockClips = value != 0f; break;
case "standrange": StandRange = value; break;
case "standspeed": StandSpeed = value; break;
default:
Log.Info( "[nz-wustling] nz_wustling_set <chance|madmin|madmax|firstcd|hitcd|stopcd|reach|slamreach|travel|heavy|weak|body"
+ "|weakreach|pain|shock|standrange|standspeed> <value>" );
return;
}
Log.Info( $"[nz-wustling] {key} = {value:0.###}" );
Report();
}
}