Component implementing the Shrieker zombie enemy. Handles scream wind-up, line-of-sight checks, firing a SonicWave projectile that dazes targets, and a death pulse that kills other zombies in range and propels the player.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// THE SHRIEKER — BO1 Shangri-La's screamer, upstream's "sonic" zombie.
///
/// ⛔ IT DOES NOT HURT YOU AND THAT IS THE WHOLE ENEMY. Its scream costs no health: it BLINDS
/// everyone in range for three seconds, and it fires from 325 units, which is outside the distance
/// anything else in this game can reach you from. So it never kills you — it makes the horde that
/// is already on you unsurvivable, and the correct response is to deal with it before that horde
/// arrives rather than after.
///
/// ⚠️ IT WON'T SCREAM IN YOUR FACE EITHER. Upstream requires the target be further than 90 units,
/// so walking into it is how you shut it up. An enemy whose answer is "get closer to the thing
/// that is screaming at you" is a better decision than "shoot it", and it is the opposite of the
/// napalm zombie's, which is what makes them worth fielding together.
///
/// ⛔ AND ITS DEATH CLEARS THE ROOM — 250 units, every OTHER zombie killed outright, the player
/// shoved and view-punched. Upstream deals `v:Health() + 666` to guarantee it whatever the round.
/// That turns the Shrieker into a tool as well as a threat: a player who can hold their nerve and
/// kill it inside a crowd gets the crowd for free, which is a genuinely interesting thing to be
/// offered while blind.
///
/// ⚠️ ITS ASSETS ARE NAMED "sonic" AND THE ENEMY IS NAMED "Shrieker". The pack calls it sonic, its
/// own `ENT.PrintName` calls it Shrieker; clips and materials keep the pack's name, cues and code
/// keep ours, and `extract_sounds.py` is where the two are mapped.
///
/// ⚠️ IT SHARES ONE `.mdl` WITH THE NAPALM ZOMBIE AND THE FUEL JUNKIE — `moo_codz_t5_viet_special
/// _zombie.mdl`, three bosses as three skin families over two bodygroups. See `mdl_to_smd.py`
/// `--skin` / `--body`.
/// </summary>
public sealed class ShriekerZombie : Component
{
// ── the scream ───────────────────────────────────────────────────────────
/// <summary>
/// How far the scream reaches.
///
/// ⚠️ 650 — TWICE UPSTREAM'S 325, BY REQUEST, AND ONLY SURVIVABLE BECAUSE OF THE SIGHT CHECK.
/// At this range it out-reaches every other enemy in the game by a wide margin, so without
/// `CanSee` it would be an unavoidable tax from two rooms away through solid rock. The two
/// changes are one change: the range is what makes it frightening, the sight line is what makes
/// it fair.
/// </summary>
[Property] public float ScreamRange { get; set; } = 650f;
/// <summary>
/// It will not scream at a target closer than this.
///
/// ⛔ THE ENEMY'S ONLY COUNTERPLAY, AND IT IS UPSTREAM'S. Closing the distance stops the scream
/// outright, so the answer to a Shrieker is to walk at it — while the answer to everything else
/// in the room is to back away. Remove this and it becomes a thing that blinds you forever from
/// wherever it happens to be standing.
/// </summary>
[Property] public float ScreamMinRange { get; set; } = 90f;
/// <summary>Seconds between screams. Upstream's 7.</summary>
[Property] public float ScreamCooldown { get; set; } = 7f;
/// <summary>
/// Seconds before it can scream for the first time after spawning.
///
/// ⚠️ UPSTREAM'S `self.Cooldown = CurTime() + 7` AT SPAWN, kept for the reason the napalm's
/// `ArmDelay` exists: an enemy that fires the moment it arrives gives the player nothing to
/// react to, and a blind you could not have avoided reads as the game cheating.
/// </summary>
[Property] public float ArmDelay { get; set; } = 7f;
/// <summary>How long the scream animation runs before the blind lands.</summary>
[Property] public float ScreamWindUp { get; set; } = 0.9f;
/// <summary>
/// How long the daze lasts once a wave connects.
///
/// ⚠️ IT IS PASSED TO THE WAVE, WHICH OWNS THE EFFECT. The Shrieker decides WHEN to scream; what
/// a scream does to you belongs to `SonicWave` and `SonicDaze`, so anything else that ever
/// fires one gets the same result without copying a number.
/// </summary>
[Property] public float DazeSeconds { get; set; } = 4f;
// ── the death pulse ──────────────────────────────────────────────────────
/// <summary>Radius of the pulse it lets go when it dies.</summary>
[Property] public float PulseRadius { get; set; } = 250f;
/// <summary>
/// Damage the pulse deals to OTHER zombies. Deliberately absurd.
///
/// ⚠️ UPSTREAM USES `v:Health() + 666`, i.e. "whatever it takes". A fixed number would stop
/// clearing the room somewhere around round 30 and the Shrieker would quietly become a worse
/// enemy the longer you played — the kind of decay nobody notices happening.
///
/// ⛔ AND THE MILLION ITSELF DECAYED THAT WAY THE DAY THE CAP MOVED (2026-10-04): it cleared the room only
/// while walkers capped at a million, and they climb to 6,000,000 by round 100 now. So a walker
/// takes the larger of this and upstream's own `health + 666` (see the pulse); a boss still takes
/// this, as before.
/// </summary>
[Property] public float PulseDamage { get; set; } = 1_000_000f;
/// <summary>How hard the pulse shoves the player.</summary>
[Property] public float PulseShove { get; set; } = 420f;
[Property] public float ShakeStrength { get; set; } = 1.8f;
[Property] public float ShakeRange { get; set; } = 1400f;
static readonly string[] ScreamClips =
{
"nz_sonic_attack_01", "nz_sonic_attack_02", "nz_sonic_attack_03",
};
ZombieAI _ai;
TimeSince _alive;
TimeUntil _cooling;
TimeUntil _screamLands;
bool _screaming;
bool _died;
protected override void OnStart()
{
_ai = Components.Get<ZombieAI>( FindMode.EverythingInSelf );
_alive = 0f;
_cooling = ArmDelay;
}
protected override void OnUpdate()
{
if ( !_ai.IsValid() ) return;
if ( _ai.State == ZombieState.Dead )
{
// ⛔ ONCE, AND FROM A POLL. Every route to a dead zombie has to fire the pulse and
// `ZombieAI` exposes no single death event they all pass through — the same reason
// `NapalmZombie` watches the state rather than subscribing to a kill path.
if ( !_died )
{
_died = true;
Pulse();
}
return;
}
TickScream();
}
/// <summary>Wind up, then let the wave go when the clock says so.</summary>
void TickScream()
{
if ( _screaming )
{
// ⚠️ THE CLOCK LANDS IT, NOT THE ANIMATION. `PlaySpecial` returns false on a model
// missing the sequence, so waiting on the clip would mean a Shrieker that never screams
// again the day a clip name drifts — and the drift would be silent.
if ( _screamLands > 0f ) return;
_screaming = false;
_cooling = ScreamCooldown;
Land();
return;
}
if ( _alive < ArmDelay || _cooling > 0f ) return;
var target = _ai.Target;
if ( !target.IsValid() ) return;
var d = _ai.WorldPosition.Distance( target.WorldPosition );
// ⛔ BOTH BOUNDS. Too far is obvious; TOO CLOSE is the counterplay, and dropping it would
// turn walking at the thing from the answer into a mistake.
if ( d > ScreamRange || d < ScreamMinRange ) return;
// ⛔ IT WILL NOT SCREAM THROUGH A WALL, AND THE CHECK IS HERE RATHER THAN ON THE WAVE. The
// wave already stops at geometry, so a blocked shot would look the same to the player — but
// the Shrieker would have spent its seven-second cooldown on it. Checking before the wind-up
// means cover does not just absorb the scream, it stops the attack from happening, which is
// the difference between cover being a shield and cover being useless.
if ( !CanSee( target ) ) return;
Wind();
}
/// <summary>
/// Is there clear air between us and the target?
///
/// ⚠️ FROM THE HEAD TO THE CHEST, NOT ORIGIN TO ORIGIN. Both objects' origins are at the FEET,
/// so a floor-to-floor ray clips every step, kerb and doorframe between them and the Shrieker
/// would almost never fire indoors.
///
/// ⚠️ ZOMBIES AND PLAYERS DO NOT BLOCK IT. A horde between the two is the situation the scream
/// is FOR — being shut down by your own escort would make the enemy weakest exactly when it
/// should be worst. Only world geometry stops it.
/// </summary>
bool CanSee( GameObject target )
{
if ( !target.IsValid() ) return false;
var from = WorldPosition + Vector3.Up * 52f;
var to = target.WorldPosition + Vector3.Up * 40f;
var tr = Scene.Trace.Ray( from, to )
.WithoutTags( "player", "zombie", "trigger" )
.Run();
return !tr.Hit;
}
/// <summary>
/// Start a scream, target or no target.
///
/// ⚠️ PUBLIC SO `nz_shrieker_scream` CAN REACH IT, for the reason `NapalmZombie.Wind` is public:
/// in Creative nothing gives a zombie a target, so the signature behaviour of the enemy would
/// otherwise be unwatchable outside a real game — and a behaviour you can only see by playing
/// properly is one that ships unverified.
/// </summary>
public void Wind()
{
if ( _screaming ) return;
_screaming = true;
_screamLands = ScreamWindUp;
// ⚠️ HELD FOR THE WHOLE CLIP, NOT JUST THE WIND-UP, so it does not walk out of its own
// scream. The blind lands partway through, which is why the hold is longer than the wait.
_ai.PlaySpecial( Game.Random.FromArray( ScreamClips ), ScreamWindUp + 0.8f );
// ⛔ `PlayShared`, BECAUSE THE DECISION IS THE HOST'S. `TickScream` reads `_ai.Target` and a
// puppet never acquires one, so a client playing this locally would never play it at all.
NZSound.PlayShared( NZSound.ShriekerCharge, WorldPosition + Vector3.Up * 55f );
Log.Info( $"[nz-shrieker] winding up — wave leaves in {ScreamWindUp:0.##}s" );
}
/// <summary>
/// The scream: the noise, and a wave sent at whoever it was aimed at.
///
/// ⛔ IT FIRES A PROJECTILE RATHER THAN APPLYING AN EFFECT, and that is the difference between
/// a tax and a fight. The first version of this method dazed everyone within 325 units the
/// instant the clip finished — unavoidable by construction, because the only input it read was
/// where you already were. A wave that crosses the room in a third of a second can be stepped
/// out of or put a wall in front of.
///
/// ⚠️ AIMED AT WHERE THE TARGET IS WHEN IT LEAVES, NOT TRACKED. A homing wave would be the
/// instant version wearing a costume; letting it miss is what makes moving worth anything.
/// </summary>
void Land()
{
var scene = Scene;
if ( !scene.IsValid() ) return;
NZSound.PlayShared( NZSound.ShriekerScream, WorldPosition + Vector3.Up * 55f );
CameraShake.Punch( WorldPosition, ShakeStrength * 0.5f, ScreamRange );
var from = WorldPosition + Vector3.Up * 52f;
var target = _ai.Target;
// ⚠️ NO TARGET STILL FIRES, straight ahead. `nz_shrieker_scream` exists precisely so the
// behaviour can be watched in Creative where nothing has a target, and a version that
// silently did nothing there would be untestable exactly where testing is easiest.
var to = target.IsValid()
? target.WorldPosition + Vector3.Up * 36f
: from + _ai.WorldRotation.Forward * ScreamRange;
SonicWave.DazeSeconds = DazeSeconds;
SonicWave.Fire( from, to );
Log.Info( $"[nz-shrieker] SCREAM at {WorldPosition:0} — wave away"
+ $" toward {to:0}, {DazeSeconds:0.#}s daze if it connects" );
}
/// <summary>
/// The death pulse: every other zombie in range dies, the player is thrown.
/// </summary>
void Pulse()
{
var scene = Scene;
if ( !scene.IsValid() ) return;
var at = WorldPosition + Vector3.Up * 40f;
int killed = 0;
// ⛔ EVERY MACHINE RUNS THIS, AND EACH DOES ONLY ITS OWN PART (2026-10-05). `ZombieAI.DieAsPuppet` sets `State` to Dead on
// every client, so the death watch in `OnUpdate` fires here on each of them as well as on the host. The kills are the
// host's (a client's are only sent on to it, for zombies its own pulse already killed); the fireball and the sound are
// shared by the host's, so a client's were a second and third; and a body is thrown only by the machine that owns it,
// because a push on someone else's copy is overwritten by their next position and reads as a stutter on this screen.
var puppet = _ai.IsValid() && _ai.IsPuppet;
foreach ( var hp in puppet ? Enumerable.Empty<Health>() : scene.GetAllComponents<Health>().ToList() )
{
if ( !hp.IsValid() ) continue;
if ( hp.GameObject == GameObject ) continue;
if ( hp.WorldPosition.Distance( WorldPosition ) > PulseRadius ) continue;
// ⛔ ZOMBIES ONLY. Upstream's loop shoves everything and damages only `IsValidZombie`,
// and that split is the enemy: the pulse is a gift to the player, not a second attack.
// A version that hurt them would make killing a Shrieker in a crowd the wrong move,
// which is precisely the decision it exists to offer.
var z = hp.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;
// ⛔ BUT NOT BASALT'S BEAST IN ITS FIGHT: a million points of pulse would end a phase at a blow (`HexPlatforms.BossCap`)
if ( HexPlatforms.IsFightBoss( z ) ) continue;
// ⛔ A WALKER TAKES WHAT IT TAKES (2026-10-04): upstream's `health + 666`, never less than PulseDamage. The
// fixed million stopped clearing the room once walkers climbed past it, from round 60 on; a boss keeps it.
var damage = z.Variant?.IsBoss == true ? PulseDamage : MathF.Max( PulseDamage, hp.Current + 666f );
hp.OnDamage( new DamageInfo { Damage = damage, Position = hp.WorldPosition } );
killed++;
}
foreach ( var p in scene.GetAllComponents<NZPlayer>().ToList() )
{
if ( !p.IsValid() || !PlayerPresence.Mine( p.GameObject ) ) continue;
var d = p.WorldPosition.Distance( WorldPosition );
if ( d > PulseRadius ) continue;
// ⚠️ UP AND OUT, WITH THE UP FIXED. A pure radial shove does nothing to somebody
// standing on top of it — the direction is undefined at zero distance — and upstream
// adds `v:GetUp()*20` for the same reason.
var dir = (p.WorldPosition - WorldPosition).WithZ( 0f ).Normal;
var vel = dir * PulseShove + Vector3.Up * ( PulseShove * 0.6f );
// ⛔ THROUGH `PlayerController.Body`, AND LIFTED OFF THE FLOOR FIRST. `Placeable.Launch`
// already paid for this lesson: while the controller considers you grounded it cancels
// upward velocity and re-snaps you to the floor, so a shove applied to somebody standing
// still produces a small horizontal nudge and nothing else — which reads as the pulse
// not working rather than as a ground check winning. Neither clean route exists on this
// engine version (`Punch` is not a method, `IsOnGround` is read-only), so the position
// nudge is the mechanism.
var c = p.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants );
var body = c.IsValid() ? c.Body : null;
if ( !body.IsValid() ) continue;
p.WorldPosition += Vector3.Up * Placeable.GroundBreak;
body.Velocity += vel;
}
if ( !puppet )
{
BlastEffect.Spawn( at, PulseRadius );
NZSound.PlayShared( NZSound.ShriekerExplode, at );
}
CameraShake.Punch( WorldPosition, ShakeStrength, ShakeRange );
Log.Info( puppet
? $"[nz-shrieker] death pulse at {WorldPosition:0} — the host's; this machine threw only its own player"
: $"[nz-shrieker] death pulse at {WorldPosition:0} — {killed} zombie(s) killed within {PulseRadius:0}u" );
}
// ── commands ─────────────────────────────────────────────────────────────
/// <summary>`nz_shrieker [n]` — spawn Shriekers around you.</summary>
[ConCmd( "nz_shrieker" )]
public static void SpawnCmd( int count = 1 )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-shrieker] no active scene" ); return; }
var variant = SpecialEnemies.VariantFor( SpecialEnemies.Shrieker );
if ( variant is null )
{
Log.Warning( $"[nz-shrieker] '{SpecialEnemies.PathFor( SpecialEnemies.Shrieker )}'"
+ " did not load" );
return;
}
var p = NZPlayer.Local;
var origin = p.IsValid() ? p.WorldPosition : Vector3.Zero;
int made = 0;
for ( int i = 0; i < count.Clamp( 1, 16 ); i++ )
{
var angle = ( i / (float)MathF.Max( 1, count ) ) * MathF.PI * 2f;
var at = origin + new Vector3( MathF.Cos( angle ), MathF.Sin( angle ), 0f ) * 280f;
if ( ZombieCommands.SpawnAt( scene, at, variant ) is not null ) made++;
}
Log.Info( $"[nz-shrieker] spawned {made} — they scream from 325u and will not do it"
+ " inside 90u, so walk at them" );
}
/// <summary>`nz_shrieker_scream` — make every live Shrieker scream, no target needed.</summary>
[ConCmd( "nz_shrieker_scream" )]
public static void ScreamCmd()
{
var live = Game.ActiveScene?.GetAllComponents<ShriekerZombie>().ToList();
if ( live is null || live.Count == 0 ) { Log.Warning( "[nz-shrieker] none alive" ); return; }
foreach ( var z in live ) z.Wind();
Log.Info( $"[nz-shrieker] {live.Count} told to scream" );
}
/// <summary>`nz_shrieker_tune [range] [minrange] [cooldown] [blind] [pulse]` — 0 leaves alone.</summary>
[ConCmd( "nz_shrieker_tune" )]
public static void Tune( float range = 0f, float minrange = 0f, float cooldown = 0f,
float blind = 0f, float pulse = 0f )
{
var live = Game.ActiveScene?.GetAllComponents<ShriekerZombie>().ToList();
int n = 0;
foreach ( var z in live ?? Enumerable.Empty<ShriekerZombie>().ToList() )
{
if ( range > 0f ) z.ScreamRange = range;
if ( minrange > 0f ) z.ScreamMinRange = minrange;
if ( cooldown > 0f ) z.ScreamCooldown = cooldown;
if ( blind > 0f ) z.DazeSeconds = blind;
if ( pulse > 0f ) z.PulseRadius = pulse;
n++;
}
foreach ( var z in live ?? Enumerable.Empty<ShriekerZombie>().ToList() )
Log.Info( $"[nz-shrieker] scream {z.ScreamMinRange:0}–{z.ScreamRange:0}u"
+ $" every {z.ScreamCooldown:0.#}s daze {z.DazeSeconds:0.#}s"
+ $" pulse {z.PulseRadius:0}u" );
Log.Info( $"[nz-shrieker] retuned {n} live Shrieker(s); newly spawned ones use the"
+ " component's own defaults" );
}
}