Zombies/ShriekerZombie.cs

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.

File AccessNetworking
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" );
	}
}