Component controlling the Margwa boss AI and visuals. Manages heads/mouths, attacks (slam, pulse, teleport, fire line, shadow skulls, scream), head popping, sounds, lights, bone overrides, networking of mouth/head events, and designer-tunable parameters with console commands.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// The Margwa — BO3's three-headed boss, ported 2026-10-06 from GMod nZombies' `nz_zombie_boss_margwa` (GhostlyMoo's port of
/// Shadows of Evil / Revelations). The user picked it first of the enemies chosen on 2026-10-05; its four other entries are
/// the same body in another skin (`margwa_zod`, `margwa_genesis`, `margwa_fire`, `margwa_shadow`).
///
/// | | an OPEN mouth, hit within <see cref="MouthReach"/> of its head | everywhere else |
/// |---|---|---|
/// | **damage** | **×0.75** | **×0.01** |
///
/// ⛔ THOSE TWO NUMBERS ARE UPSTREAM'S (`OnInjured`: `ScaleDamage(0.75)` / `ScaleDamage(0.01)`) AND THEY ARE THE FIGHT. The
/// body is armour; the only way in is a mouth, and only one opens at a time: every 3.5-4.15 s (less a second per head lost)
/// one of the THREE heads, at random, opens for 1.5 s — a head already gone wastes its turn, exactly as upstream rolls
/// `table.Random(self.HeadTbl)` over all three.
///
/// ⛔ A HEAD COMES OFF ONLY AT A THRESHOLD, AND ONLY THROUGH ITS OWN OPEN MOUTH: the first once health is at 65% or less, the
/// second at 35% (`healththreshold1/2`). Each pays the shooter 500, makes him wait 3 s before another can go
/// (`IFrames`), plays the stagger for that head, and from the first one on he CHARGES (upstream's `SetRunSpeed(71)` the tick
/// after any head is lost). The third head is the kill itself.
///
/// ⚠️ THE MOUTH IS A JAW, TURNED HERE, NOT A CLIP. Upstream's mouth clips are delta layers that move exactly one bone each
/// (that head's lower jaw, 53-62°, measured from the decompiled SMDs), and s&box plays one sequence; so the jaw is turned by
/// a bone override on top of whatever is playing (<see cref="OnPreRender"/>), and a light in the element's colour sits in the
/// open mouth — upstream's `zmb_mimic_mouth` glow. `nz_margwa_jaw 0` turns the override off and leaves the light.
///
/// ⚠️ THE SKIN DECIDES WHAT ELSE HE DOES (`MaterialGroup`; the user's picks, 2026-10-06):
/// • skin 0, Shadows of Evil — TELEPORTS: far from his target he goes through a portal and climbs out beside it.
/// • skin 1, Revelations — an AREA PULSE: close to his target he rears up, an amber ring shows its reach, and the ground
/// erupts round him — damage inside, every screen shaking (the user's pick, Oberon's pulse as the model). And he is the
/// QUICK, ANGRY one (the user: *"the genesis one must be a bit faster and more agressive than the others"*): 20% faster on
/// his feet, 50% faster swings, his pulse and slam on short clocks (<see cref="GenesisPace"/>). And from further off he
/// SCREAMS THE SHRIEKER'S SCREAM (the user: *"must also be able to use the shrieker scream that stuns the player"*): every
/// mouth wide — and open to a shot while it lasts — then `SonicWave`, the Shrieker's own dazing wave.
/// • skin 2, Fire — lays a LINE of fire along the floor in front of him that burns for seconds (`MargwaFireLine`; it
/// replaced upstream's rolling fire wave on request).
/// • skin 3, Shadow — opens a portal and sends four homing skulls (`nz_ent_proj_chomper`, `MargwaProjectile`).
///
/// ⚠️ HOST AUTHORITY, EVERY MACHINE DRAWS: the host decides mouths, heads and attacks; `NZNet.MargwaMouth` /
/// `NZNet.MargwaHeadGone` tell everyone, and each machine turns its own jaw, lights its own mouth and switches its own head
/// bodygroup — a renderer property does not replicate after the spawn.
///
/// ⛔ NO ATTACK OF HIS DOWNS ANYONE FROM FULL HEALTH (<see cref="HitCap"/>), Oberon's rule — *"we cant have any attack insta
/// kill like that"* (2026-09-27). The swipe is capped through `ZombieAI.MaxHitDamage`, the slam and the projectiles here.
/// </summary>
public sealed class MargwaBoss : Component
{
public enum Element { Normal, Fire, Shadow }
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED, INSTRUCTIONS.md §1: a static's value survives a hotload, its initialiser does not re-run.
static float? _mouthScale;
/// <summary>Damage an open mouth takes. 0.75, upstream's.</summary>
public static float MouthScale { get => _mouthScale ?? 0.75f; set => _mouthScale = value; }
static float? _bodyScale;
/// <summary>Damage everything else takes — the body, a closed mouth, a blast. 0.01, upstream's.</summary>
public static float BodyScale { get => _bodyScale ?? 0.01f; set => _bodyScale = value; }
static float? _mouthReach;
/// <summary>How near a hit must land to the open head's bone to count, at model scale 1. 34 (upstream 35 middle, 33 sides).</summary>
public static float MouthReach { get => _mouthReach ?? 34f; set => _mouthReach = value; }
static float? _openSeconds;
/// <summary>How long a mouth stays open. 1.5 s, upstream's `OpenMouthTime`.</summary>
public static float OpenSeconds { get => _openSeconds ?? 1.5f; set => _openSeconds = value; }
static float? _gapMin, _gapMax;
/// <summary>Seconds between mouth rolls, less one per head lost. 3.5-4.15, upstream's `NextMouthOpen`.</summary>
public static float GapMin { get => _gapMin ?? 3.5f; set => _gapMin = value; }
public static float GapMax { get => _gapMax ?? 4.15f; set => _gapMax = value; }
static float? _popFirstAt, _popSecondAt;
/// <summary>Health share at or under which a head can come off: 0.65 for the first, 0.35 for the second.</summary>
public static float PopFirstAt { get => _popFirstAt ?? 0.65f; set => _popFirstAt = value; }
public static float PopSecondAt { get => _popSecondAt ?? 0.35f; set => _popSecondAt = value; }
static int? _popPoints;
/// <summary>Points for taking a head off. 500, upstream's `GivePoints(500)`.</summary>
public static int PopPoints { get => _popPoints ?? 500; set => _popPoints = value; }
static float? _popGrace;
/// <summary>Seconds after spawning or losing a head before another can go. 3, upstream's `IFrames`.</summary>
public static float PopGrace { get => _popGrace ?? 3f; set => _popGrace = value; }
static float? _chargeSpeed;
/// <summary>
/// His ABSOLUTE speed once a head is gone: 195 u/s, `nz_ai_margwa_run_charge`'s own ground speed (442 u over 69 frames).
/// Before that he walks at the variant's `FixedSpeed`, 88 — the walk clip's 88.7.
/// </summary>
public static float ChargeSpeed { get => _chargeSpeed ?? 195f; set => _chargeSpeed = value; }
static float? _slamRange, _slamRadius, _slamWeight, _slamGapMin, _slamGapMax;
/// <summary>How near the target must be for the ground slam (`nz_ai_margwa_smash_attack`, upstream's stand attack).</summary>
public static float SlamRange { get => _slamRange ?? 125f; set => _slamRange = value; }
/// <summary>How far the slam reaches, from where the arms come down.</summary>
public static float SlamRadius { get => _slamRadius ?? 170f; set => _slamRadius = value; }
/// <summary>The slam against one swipe. 1.25 (upstream's heavy hit is 3× a swipe; the cap would eat most of that anyway).</summary>
public static float SlamWeight { get => _slamWeight ?? 1.25f; set => _slamWeight = value; }
public static float SlamGapMin { get => _slamGapMin ?? 8f; set => _slamGapMin = value; }
public static float SlamGapMax { get => _slamGapMax ?? 12f; set => _slamGapMax = value; }
static float? _elementRange;
/// <summary>How near the target must be for a Fire or Shadow attack. 750, upstream's `TargetInRange(750)`.</summary>
public static float ElementRange { get => _elementRange ?? 750f; set => _elementRange = value; }
static float? _hitCap;
/// <summary>The most one of his hits takes, as a share of the match's base health. Two-thirds, Oberon's.</summary>
public static float HitCap { get => _hitCap ?? (2f / 3f); set => _hitCap = value; }
static float? _teleportFrom, _teleportGapMin, _teleportGapMax, _teleportNear, _teleportFar;
/// <summary>
/// The Shadows of Evil Margwa goes through a portal when his target is at least this far. 900 — about five seconds of his
/// charge, ten of his walk. Not scaled by his size: it is a distance across the map, not about his body.
/// </summary>
public static float TeleportFrom { get => _teleportFrom ?? 900f; set => _teleportFrom = value; }
/// <summary>Seconds between portals: 14-22.</summary>
public static float TeleportGapMin { get => _teleportGapMin ?? 14f; set => _teleportGapMin = value; }
public static float TeleportGapMax { get => _teleportGapMax ?? 22f; set => _teleportGapMax = value; }
/// <summary>Where he comes out: 160-360 units from his target, on the navmesh, on its floor.</summary>
public static float TeleportNear { get => _teleportNear ?? 160f; set => _teleportNear = value; }
public static float TeleportFar { get => _teleportFar ?? 360f; set => _teleportFar = value; }
static float? _pulseRadius, _pulseWeight, _pulseEdge, _pulseGapMin, _pulseGapMax;
/// <summary>The Revelations Margwa's pulse: how far it reaches, at size 1. 360 — 450 at his 1.25, near Oberon's 500.</summary>
public static float PulseRadius { get => _pulseRadius ?? 360f; set => _pulseRadius = value; }
/// <summary>Its damage at his feet, against one swipe. 1.2.</summary>
public static float PulseWeight { get => _pulseWeight ?? 1.2f; set => _pulseWeight = value; }
/// <summary>
/// The share of that at the rim. 0.5 — the ring on the floor promises a hit anywhere inside it, so the edge must still hurt;
/// a linear falloff to nothing would make the outer third a lie.
/// </summary>
public static float PulseEdge { get => _pulseEdge ?? 0.5f; set => _pulseEdge = value; }
/// <summary>Seconds between pulses: 4.5-7 (10-15 until he was made the aggressive one; 6-9 until "more frequently").</summary>
public static float PulseGapMin { get => _pulseGapMin ?? 4.5f; set => _pulseGapMin = value; }
public static float PulseGapMax { get => _pulseGapMax ?? 7f; set => _pulseGapMax = value; }
static float? _screamRange, _screamMinRange, _screamGapMin, _screamGapMax, _screamWindUp, _screamHold;
/// <summary>
/// The Revelations Margwa's scream reaches this far: 650, the Shrieker's (`ShriekerZombie.ScreamRange`). He screams only at a
/// target he can see — the Shrieker's rule, what makes the range fair.
/// </summary>
public static float ScreamRange { get => _screamRange ?? 650f; set => _screamRange = value; }
/// <summary>He will not scream closer than this: 90, the Shrieker's — walking at him shuts him up (and brings the pulse).</summary>
public static float ScreamMinRange { get => _screamMinRange ?? 90f; set => _screamMinRange = value; }
/// <summary>Seconds between screams: 8-12 (the Shrieker's 7, for a boss with two other moves).</summary>
public static float ScreamGapMin { get => _screamGapMin ?? 8f; set => _screamGapMin = value; }
public static float ScreamGapMax { get => _screamGapMax ?? 12f; set => _screamGapMax = value; }
/// <summary>How long he winds up before the wave leaves: 0.9 s, the Shrieker's; he holds the scream 0.8 s after.</summary>
public static float ScreamWindUp { get => _screamWindUp ?? 0.9f; set => _screamWindUp = value; }
public static float ScreamHold { get => _screamHold ?? 0.8f; set => _screamHold = value; }
static float? _genesisPace, _genesisSwing, _genesisSlamGapMin, _genesisSlamGapMax;
/// <summary>
/// The Revelations Margwa's feet against the others': 1.2 — a walk of 106 for their 88, a charge of 234 for their 195. Both
/// stay inside their animation tiers (walk under 130, charge over 190), so his legs simply cycle a little faster.
/// </summary>
public static float GenesisPace { get => _genesisPace ?? 1.2f; set => _genesisPace = value; }
/// <summary>
/// His swings against the others': 1.5 — `ZombieAI.AttackSpeed`, the windup and the recovery both, so he swings again half as
/// soon. 1.25 until the user asked for him to *"attack more frequently"* (2026-10-06).
/// </summary>
public static float GenesisSwing { get => _genesisSwing ?? 1.5f; set => _genesisSwing = value; }
/// <summary>Seconds between his slams: 4-6, where the others wait 8-12 (5-8 before "more frequently").</summary>
public static float GenesisSlamGapMin { get => _genesisSlamGapMin ?? 4f; set => _genesisSlamGapMin = value; }
public static float GenesisSlamGapMax { get => _genesisSlamGapMax ?? 6f; set => _genesisSlamGapMax = value; }
static bool? _jawBones;
/// <summary>Turn the jaw bone of the open mouth (`nz_margwa_jaw`). On.</summary>
public static bool JawBones { get => _jawBones ?? true; set => _jawBones = value; }
static Angles? _jawMid, _jawLeft, _jawRight;
/// <summary>
/// Each lower jaw's open turn, in its own frame: upstream's mouth clips at their widest, SMD Euler (x, y, z) read as
/// (pitch y, yaw z, roll x) — Source's own order, so `Rotation.From` builds the same matrix.
/// </summary>
public static Angles JawMid { get => _jawMid ?? new Angles( 53.44f, -4.22f, -3.33f ); set => _jawMid = value; }
public static Angles JawLeft { get => _jawLeft ?? new Angles( 62.39f, 0.26f, 3.32f ); set => _jawLeft = value; }
public static Angles JawRight { get => _jawRight ?? new Angles( 62.09f, 2.96f, 4.70f ); set => _jawRight = value; }
// ══ the heads ════════════════════════════════════════════════════════════
//
// ⛔ SWITCHES, NOT `static readonly` ARRAYS: an array built in an initialiser cannot be corrected in a live session (§1).
// Head 0 is the middle, 1 the left, 2 the right — upstream's `HeadTbl` 1/2/3.
public static string HeadBone( int h ) => h switch { 0 => "j_head", 1 => "j_head_le", _ => "j_head_ri" };
static string JawBone( int h ) => h switch { 0 => "j_jaw_lower_1", 1 => "j_jaw_lower_1_le", _ => "j_jaw_lower_1_ri" };
/// <summary>The lower jaw's one child (the inner lip), which must turn with it or tear off.</summary>
static string JawChild( int h ) => h switch { 0 => "j_jaw_lower_upper_1", 1 => "j_jaw_lower_upper_1_le", _ => "j_jaw_lower_upper_1_ri" };
/// <summary>The bodygroup per head — `margwa.vmdl`'s `midhead`/`lefthead`/`righthead`, choice 1 the stump.</summary>
static string Group( int h ) => h switch { 0 => "midhead", 1 => "lefthead", _ => "righthead" };
static string ShotClip( int h ) => h switch
{
0 => "nz_ai_margwa_shot_middle_head",
1 => "nz_ai_margwa_shot_left_head",
_ => "nz_ai_margwa_shot_right_head",
};
static Angles JawOpen( int h ) => h switch { 0 => JawMid, 1 => JawLeft, _ => JawRight };
static string HeadName( int h ) => h switch { 0 => "middle", 1 => "left", _ => "right" };
/// <summary>The mouth glow per element — upstream's `PostDraw` colours.</summary>
public static Color GlowFor( Element e ) => e switch
{
Element.Fire => new Color( 1f, 0.235f, 0f ),
Element.Shadow => new Color( 0.12f, 0.55f, 1f ),
_ => new Color( 1f, 0.82f, 0f ),
};
// ══ state ════════════════════════════════════════════════════════════════
public Element Kind { get; private set; }
/// <summary>Does he go through portals — the Shadows of Evil Margwa (skin 0).</summary>
public bool Teleports { get; private set; }
/// <summary>Does he pulse — the Revelations Margwa (skin 1).</summary>
public bool Pulses { get; private set; }
/// <summary>His pace against the others': <see cref="GenesisPace"/> for the Revelations Margwa, 1 for the rest.</summary>
float Pace => Pulses ? MathF.Max( 0.1f, GenesisPace ) : 1f;
readonly bool[] _alive = { true, true, true };
readonly float[] _jaw = new float[3];
/// <summary>The head whose mouth is open, or -1. The host's decision, mirrored on every machine.</summary>
public int OpenHead { get; private set; } = -1;
float _openUntil;
float _nextMouth;
float _noPopUntil;
float _nextSlam;
float _nextElement;
float _nextWeakPing;
float _nextTeleport;
float _nextPulse;
float _nextScream;
float _allOpenUntil;
bool _paceApplied;
/// <summary>`ShowMouth`'s head for EVERY mouth at once — the scream. -1 closes them all.</summary>
const int AllMouths = 3;
bool _vanished;
bool _shielded;
bool _deadHandled;
bool _overriding;
ZombieAI _ai;
Health _hp;
SkinnedModelRenderer _body;
PointLight _mouthLight;
GameObject _mouthLightGo;
/// <summary>A timed beat inside one of his moves — data, not a lambda, so a hotload mid-move cannot strand a closure.</summary>
enum Beat { SlamHit, FireTell, FireThrow, ShadowTell, ShadowPortal, ShadowSkull, PortalOut, Vanish, Arrive, PulseHit, ScreamLand }
readonly List<(float At, Beat What, int N)> _pending = new();
public int HeadsLost => _alive.Count( a => !a );
public bool HeadAlive( int h ) => h >= 0 && h < 3 && _alive[h];
/// <summary>The most one hit of his takes now (<see cref="HitCap"/> of the match's base health).</summary>
public static float MaxHit => MathF.Max( 1f, Difficulty.MaxHealth * Math.Clamp( HitCap, 0.1f, 0.95f ) );
/// <summary>Which way his BODY faces — the object's rotation carries the rig's yaw correction (`ZombieAI.ModelTurn`).</summary>
Vector3 Facing => _ai.IsValid() ? (WorldRotation * _ai.ModelTurn.Inverse).Forward.WithZ( 0 ).Normal : WorldRotation.Forward;
/// <summary>
/// His size against the authored model: the variant's `ModelScale` (1.25 since 2026-10-06, the user: *"increase their size by
/// 25%"*), which `ZombieAI.ApplyModelScale` puts on the object. Every distance of his own here is authored at size 1 and
/// multiplied by this, as the capsule, the swipe's reach and <see cref="MouthReach"/> are.
/// </summary>
float Size => MathF.Max( 0.1f, WorldScale.x );
/// <summary>Where his voice comes from: head height.</summary>
Vector3 Voice => WorldPosition + Vector3.Up * 90f * Size;
protected override void OnStart()
{
_ai = Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
_hp = Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
_body = Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );
// ⚠️ THE ELEMENT FROM THE SKIN THE HOST ROLLED: `ZombieAI.ApplySkin` writes `skinN` on the renderer, which travels in
// the network spawn, so every machine reads the same answer here.
var group = _body.IsValid() ? _body.MaterialGroup ?? "" : "";
Kind = group == "skin2" ? Element.Fire : group == "skin3" ? Element.Shadow : Element.Normal;
// ⚠️ SKIN 0 IS THE ONE WITH NO GROUP: `ApplySkin` leaves the renderer alone for skin 0, so "not skin1-3" is Shadows of Evil
Teleports = Kind == Element.Normal && group != "skin1";
Pulses = group == "skin1";
var now = Time.Now;
_nextMouth = now + Game.Random.Float( GapMin, 7.5f ); // upstream's first roll: 3.5-7.5
_noPopUntil = now + PopGrace;
_nextElement = now + 5f; // upstream's `ElementalAttackCooldown = CurTime() + 5`
_nextSlam = now + Game.Random.Float( 4f, 8f );
_nextTeleport = now + 6f;
_nextPulse = now + (Pulses ? 3f : 5f);
_nextScream = now + 7f; // the Shrieker's `ArmDelay`: never a scream the moment he arrives
// ⚠️ THE REVELATIONS MARGWA'S PACE: a quicker swing now, and an absolute walk (`SpeedOverride`, as a lost head sets his
// charge) that `TickPace` hands to the animation once he has climbed out — `RepickAnimations` also pushes the agent's
// speed, which must not move a body still in its entrance.
if ( Pulses && _ai.IsValid() && !NZGame.IsClient )
{
_ai.AttackSpeed *= MathF.Max( 0.1f, GenesisSwing );
_ai.SpeedOverride = MathF.Max( 1f, (_ai.Variant?.FixedSpeed ?? 88f) * Pace );
}
if ( _ai.IsValid() )
{
_ai.MaxHitDamage = MaxHit;
// ⛔ HE HAS NONE OF THE WALKER'S CLIMB CLIPS, and an unknown sequence is the bind pose rather than an error (Oberon's
// note). The 48-unit mantle is his: in place, since `margwa_port.py` strips its travel.
_ai.ClimbClipOverride = "nz_ai_margwa_mantle_over_48";
}
EnsureMouthLight();
if ( !NZGame.IsClient )
Log.Info( $"[nz-margwa] spawned — {Kind}, {_hp?.Max ?? 0f:0} hp · mouth x{MouthScale:0.##}, body x{BodyScale:0.###}"
+ $" · heads come off at {PopFirstAt * 100f:0}% / {PopSecondAt * 100f:0}% · hitboxes {_ai?.HitboxCount ?? 0}" );
}
protected override void OnUpdate()
{
if ( !_ai.IsValid() ) return;
if ( _ai.State == ZombieState.Dead )
{
OnDeath();
return;
}
// ── the host decides ──
if ( !_ai.IsPuppet )
{
_ai.MaxHitDamage = MaxHit;
TickShield();
TickPace();
TickMouths();
TickPending();
TickMoves();
}
// ── every machine draws ──
if ( OpenHead >= 0 && Time.Now >= _openUntil ) OpenHead = -1;
float step = Time.Delta / 0.18f;
for ( int h = 0; h < 3; h++ )
{
var want = _alive[h] && (h == OpenHead || Time.Now < _allOpenUntil) ? 1f : 0f;
_jaw[h] = want > _jaw[h] ? MathF.Min( want, _jaw[h] + step ) : MathF.Max( want, _jaw[h] - step );
}
TickMouthLight();
}
// ══ spawn ════════════════════════════════════════════════════════════════
/// <summary>The Revelations Margwa's walk, handed to the animation once he is out of his entrance (`RepickAnimations`).</summary>
void TickPace()
{
if ( _paceApplied || !Pulses ) return;
if ( _ai.State is not (ZombieState.Chasing or ZombieState.Attacking) ) return;
_paceApplied = true;
_ai.RepickAnimations();
}
/// <summary>
/// Untouchable while he climbs out — upstream's `SetInvulnerable(true)` for the length of `nz_ai_margwa_spawn`.
/// </summary>
void TickShield()
{
var spawning = _ai.State == ZombieState.Spawning;
if ( spawning == _shielded || !_hp.IsValid() ) return;
_shielded = spawning;
_hp.Invulnerable = spawning;
// ⚠️ THE ARRIVAL IS A BOSS BEAT, heard wherever you stand: upstream plays `spawn_2d` through every client, beside the
// positional roar the variant's `SpawnSound` already gives.
if ( spawning ) NZSound.PlayShared( "nz.margwa.spawn2d" );
else NZSound.PlayShared( "nz.margwa.teleport", WorldPosition + Vector3.Up * 50f );
}
// ══ the mouths ═══════════════════════════════════════════════════════════
void TickMouths()
{
if ( Time.Now < _nextMouth ) return;
// ⚠️ ALL THREE HEADS ROLL, DEAD ONES INCLUDED, as upstream's `table.Random(self.HeadTbl)` does: a lost head wastes its
// turn. Rolling only the living ones would leave the last head open nearly all the time, since the gap also shrinks by
// a second per head lost.
var h = Game.Random.Int( 0, 2 );
_nextMouth = Time.Now + MathF.Max( 0.5f, Game.Random.Float( GapMin, GapMax ) - HeadsLost );
if ( _alive[h] ) OpenMouth( h, OpenSeconds );
}
/// <summary>Open one mouth (or close them all with -1) on every machine. THE HOST.</summary>
void OpenMouth( int h, float seconds )
{
ShowMouth( h, seconds );
if ( Networking.IsActive ) NZNet.MargwaMouth( GameObject.Id, h, seconds );
}
/// <summary>This machine's half of a mouth opening or closing (`NZNet.MargwaMouth`).</summary>
public void ShowMouth( int h, float seconds )
{
// ⚠️ 3 IS EVERY MOUTH AT ONCE (the Revelations Margwa's scream), beside whichever single one is open; -1 closes them all
if ( h == AllMouths )
{
_allOpenUntil = Time.Now + MathF.Max( 0f, seconds );
return;
}
if ( h < 0 ) _allOpenUntil = 0f;
OpenHead = h >= 0 && h < 3 && _alive[h] ? h : -1;
_openUntil = Time.Now + MathF.Max( 0f, seconds );
}
void EnsureMouthLight()
{
if ( _mouthLight.IsValid() ) return;
_mouthLightGo = new GameObject { Parent = GameObject, Name = "nz_margwa_mouth" };
_mouthLightGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
_mouthLight = _mouthLightGo.Components.Create<PointLight>();
_mouthLight.LightColor = Color.Black;
_mouthLight.Radius = 110f;
_mouthLight.Shadows = false;
}
void TickMouthLight()
{
if ( !_mouthLight.IsValid() ) return;
int h = -1;
float w = 0f;
for ( int i = 0; i < 3; i++ )
if ( _jaw[i] > w ) { w = _jaw[i]; h = i; }
if ( h < 0 || w <= 0.01f || !_body.IsValid() || !_body.TryGetBoneTransform( HeadBone( h ), out var head ) )
{
_mouthLight.LightColor = Color.Black;
return;
}
// in front of the head, where the mouth opens — upstream's `*mouth_fx_tag` sits 9 units ahead of the head bone
_mouthLightGo.WorldPosition = head.Position + Facing * 10f * Size;
var c = GlowFor( Kind );
_mouthLight.LightColor = c * (3.2f * w);
_mouthLight.Radius = 110f * Size;
}
/// <summary>Turn the open jaw — after this frame's animation, on top of it.</summary>
protected override void OnPreRender()
{
if ( !_body.IsValid() || _body.Model is null ) return;
var sm = _body.SceneModel;
if ( !sm.IsValid() ) return;
var active = JawBones && _jaw.Any( j => j > 0.001f );
if ( !active )
{
if ( _overriding ) { sm.ClearBoneOverrides(); _overriding = false; }
return;
}
sm.ClearBoneOverrides();
_overriding = true;
var bones = _body.Model.Bones;
var root = sm.Transform;
for ( int h = 0; h < 3; h++ )
{
if ( _jaw[h] <= 0.001f || !_alive[h] ) continue;
var jb = bones.GetBone( JawBone( h ) );
if ( jb is null || !_body.TryGetBoneTransformAnimation( jb, out var jawWorld ) ) continue;
// ⚠️ MODEL SPACE: `SetBoneOverride` takes a transform relative to the scene model.
var jaw = root.ToLocal( jawWorld );
var open = Rotation.Slerp( Rotation.Identity, Rotation.From( JawOpen( h ) ), Ease( _jaw[h] ) );
var turned = jaw.WithRotation( jaw.Rotation * open );
sm.SetBoneOverride( jb.Index, turned );
// ⛔ AND ITS CHILD WITH IT: an override is the bone's FINAL transform, so the inner lip would stay where the
// animation left it and tear away from the jaw it hangs off.
var kb = bones.GetBone( JawChild( h ) );
if ( kb is not null && _body.TryGetBoneTransformAnimation( kb, out var kidWorld ) )
sm.SetBoneOverride( kb.Index, turned.ToWorld( jaw.ToLocal( root.ToLocal( kidWorld ) ) ) );
}
}
static float Ease( float x ) => x * x * (3f - 2f * x);
// ══ the damage table ═════════════════════════════════════════════════════
/// <summary>
/// The multiplier for one hit, and the head it takes off when it is the one. THE HOST (`Health.OnDamage`).
///
/// ⛔ IT MUTATES, SO EXACTLY ONCE PER HIT, the helmet's rule: a head comes off in here.
/// </summary>
public float ScaleFor( Vector3 hitPos, GameObject attacker )
{
if ( _deadHandled ) return 1f;
if ( _shielded ) return 0f;
var h = MouthHit( hitPos );
if ( h < 0 ) return MathF.Max( 0f, BodyScale );
// ⛔ THE ONLY SIGN YOU FOUND IT: a wet hit, throttled per bullet (upstream plays `WeakImpactSounds` on both ends)
if ( Time.Now >= _nextWeakPing )
{
_nextWeakPing = Time.Now + 0.07f;
NZSound.PlayShared( "nz.margwa.weakspot", hitPos );
}
TryPop( h, attacker );
return MathF.Max( 0f, MouthScale );
}
/// <summary>The open head this hit landed on, or -1.</summary>
int MouthHit( Vector3 hitPos )
{
if ( hitPos.IsNearlyZero() || !_body.IsValid() ) return -1;
var reach = MouthReach * Size;
// ⚠️ WHILE HE SCREAMS EVERY MOUTH IS OPEN, AND OPEN IS OPEN: the live head nearest the hit, within reach. Jaws drawn wide
// that a bullet could not hurt would read as broken — and it is the price of the stun, a window to answer it in.
if ( Time.Now < _allOpenUntil )
{
int best = -1;
var bestD = reach;
for ( int i = 0; i < 3; i++ )
{
if ( !_alive[i] || !_body.TryGetBoneTransform( HeadBone( i ), out var hd ) ) continue;
var d = hitPos.Distance( hd.Position );
if ( d <= bestD ) { bestD = d; best = i; }
}
if ( best >= 0 ) return best;
}
var h = OpenHead;
if ( h < 0 || !_alive[h] ) return -1;
if ( !_body.TryGetBoneTransform( HeadBone( h ), out var head ) ) return -1;
return hitPos.Distance( head.Position ) <= reach ? h : -1;
}
void TryPop( int h, GameObject attacker )
{
if ( Time.Now < _noPopUntil || !_hp.IsValid() || _hp.Max <= 0f ) return;
var share = _hp.Current / _hp.Max;
var lost = HeadsLost;
var ready = (lost == 0 && share <= PopFirstAt) || (lost == 1 && share <= PopSecondAt);
if ( !ready ) return;
PopHead( h, attacker, react: true );
}
/// <summary>Take a head off. THE HOST.</summary>
void PopHead( int h, GameObject attacker, bool react )
{
if ( !_alive[h] ) return;
_noPopUntil = Time.Now + PopGrace;
OpenMouth( -1, 0f );
SetHeadGone( h );
if ( Networking.IsActive ) NZNet.MargwaHeadGone( GameObject.Id, h );
var at = _body.IsValid() && _body.TryGetBoneTransform( HeadBone( h ), out var head ) ? head.Position : WorldPosition + Vector3.Up * 90f * Size;
NZSound.PlayShared( "nz.margwa.headpop", at );
NZSound.PlayShared( "nz.margwa.pain", at );
// ⚠️ THE PAY GOES THROUGH `AddPoints`, which relays to a client's own machine (`NZNet.AwardPoints`)
var shooter = attacker.IsValid() ? attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;
if ( react && shooter.IsValid() && PopPoints > 0 ) shooter.AddPoints( PopPoints );
// ⛔ FROM THE FIRST HEAD ON HE CHARGES, AT AN ABSOLUTE SPEED (`SpeedOverride`, the helmet's way) — and `RepickAnimations`
// or nothing reads it.
if ( HeadsLost >= 1 && _ai.IsValid() )
{
_ai.SpeedOverride = MathF.Max( 1f, ChargeSpeed * Pace );
_ai.RepickAnimations();
}
if ( react && HeadsLost <= 2 && _ai.IsValid() )
_ai.PlaySpecial( ShotClip( h ), 101f / 30f );
Log.Info( $"[nz-margwa] {HeadName( h ).ToUpperInvariant()} HEAD OFF — {HeadsLost}/3 · {_hp?.Current ?? 0f:0}/{_hp?.Max ?? 0f:0} hp"
+ $" · {(shooter.IsValid() ? $"+{PopPoints} to {shooter.GameObject.Name}" : "no shooter")}"
+ $" · speed {(_ai?.SpeedOverride ?? 0f):0}" );
}
/// <summary>This machine's half of a head coming off (`NZNet.MargwaHeadGone`): the stump, and the jaw let go.</summary>
public void SetHeadGone( int h )
{
if ( h < 0 || h > 2 ) return;
_alive[h] = false;
_jaw[h] = 0f;
if ( OpenHead == h ) OpenHead = -1;
if ( _body.IsValid() ) _body.SetBodyGroup( Group( h ), 1 );
}
void OnDeath()
{
if ( _deadHandled ) return;
_deadHandled = true;
_pending.Clear();
OpenHead = -1;
for ( int i = 0; i < 3; i++ ) _jaw[i] = 0f;
if ( _mouthLight.IsValid() ) _mouthLight.LightColor = Color.Black;
var sm = _body.IsValid() ? _body.SceneModel : null;
if ( sm.IsValid() ) sm.ClearBoneOverrides();
_overriding = false;
// ⚠️ NEVER A HIDDEN CORPSE: killed between the portals (he is untouchable there, but a nuke or a command is not), each
// machine shows its own body again
_vanished = false;
MargwaFx.SetHidden( GameObject, false );
// ⚠️ THE LAST HEAD GOES WITH HIM, as upstream's `PerformDeath` pops the first one still on. Each machine does its own,
// from the same state: the heads that are gone already arrived over the network.
for ( int i = 0; i < 3; i++ )
{
if ( !_alive[i] ) continue;
SetHeadGone( i );
if ( !NZGame.IsClient && _body.IsValid() && _body.TryGetBoneTransform( HeadBone( i ), out var head ) )
NZSound.PlayShared( "nz.margwa.headpop", head.Position );
break;
}
}
// ══ his moves ════════════════════════════════════════════════════════════
void TickMoves()
{
if ( _ai.State is not (ZombieState.Chasing or ZombieState.Attacking) ) return;
var target = _ai.Target;
if ( !target.IsValid() ) return;
var dist = WorldPosition.Distance( target.WorldPosition );
var now = Time.Now;
// ── the Shadows of Evil Margwa's portal: far from his target, he goes through one and comes out beside it ──
if ( Teleports && now >= _nextTeleport && dist >= TeleportFrom && StartTeleport() ) return;
// ── Fire / Shadow ──
if ( Kind != Element.Normal && now >= _nextElement && dist <= ElementRange && CanSee( target ) )
{
Face( target.WorldPosition );
if ( Kind == Element.Fire && _ai.PlaySpecial( "nz_ai_margwa_fire_attack", 63f / 30f ) )
{
_nextElement = now + Game.Random.Float( 10f, 16f );
Queue( 0.05f, Beat.FireTell );
Queue( 33f / 30f, Beat.FireThrow );
return;
}
if ( Kind == Element.Shadow && _ai.PlaySpecial( "nz_ai_margwa_elec_attack", 96f / 30f ) )
{
_nextElement = now + Game.Random.Float( 12f, 18f );
Queue( 0.05f, Beat.ShadowTell );
Queue( 48f / 30f, Beat.ShadowPortal );
// four skulls, 0.35 s apart, upstream's `timer.Simple(i * 0.35)`
for ( int i = 1; i <= 4; i++ ) Queue( 48f / 30f + i * 0.35f, Beat.ShadowSkull, i );
return;
}
}
// ── the Revelations Margwa's pulse: close enough, he rears up and the ground erupts round him ──
// ⚠️ FROM THE RING'S FULL REACH (80% until he was made the aggressive one): he goes for it the moment you are inside it
if ( Pulses && now >= _nextPulse && dist <= PulseRadius * Size && StartPulse() ) return;
// ── the Revelations Margwa's scream: further off and in sight, the Shrieker's dazing wave ──
if ( Pulses && now >= _nextScream && dist >= ScreamMinRange && dist <= ScreamRange && CanSee( target ) && StartScream() )
return;
// ── the ground slam ──
if ( now >= _nextSlam && dist <= SlamRange * Size )
{
Face( target.WorldPosition );
if ( _ai.PlaySpecial( "nz_ai_margwa_smash_attack", 56f / 30f ) )
{
_nextSlam = now + (Pulses
? Game.Random.Float( GenesisSlamGapMin, GenesisSlamGapMax )
: Game.Random.Float( SlamGapMin, SlamGapMax ));
NZSound.PlayShared( "nz.margwa.attack", Voice );
Queue( 27f / 30f, Beat.SlamHit ); // `melee_heavy` + `slam` at frame 27
}
}
}
void Queue( float after, Beat what, int n = 0 ) => _pending.Add( (Time.Now + after, what, n) );
void TickPending()
{
for ( int i = _pending.Count - 1; i >= 0; i-- )
{
var p = _pending[i];
if ( Time.Now < p.At ) continue;
_pending.RemoveAt( i );
Run( p.What, p.N );
}
}
void Run( Beat what, int n )
{
var target = _ai.Target;
var front = WorldPosition + Facing * 100f * Size;
switch ( what )
{
case Beat.SlamHit:
{
var at = WorldPosition + Facing * 45f * Size;
NZSound.PlayShared( "nz.margwa.slam", at );
NZSound.PlayShared( "nz.margwa.slam.far", at );
NZNet.ShakeAt( at, 0.45f, 2000f );
Blast( at, SlamRadius * Size, SlamWeight );
break;
}
case Beat.FireTell:
NZSound.PlayShared( "nz.margwa.fire.start", Voice );
NZSound.PlayShared( "nz.margwa.warn", Voice );
break;
case Beat.FireThrow:
{
// ⛔ A LINE OF FIRE SINCE 2026-10-06, NOT UPSTREAM'S ROLLING WAVE — the user: *"it makes an entire line in front of it
// a fire zone"*. From his feet, aimed at his target at the moment his arms come down (`fire_slam`, frame 33).
var dir = target.IsValid() ? (target.WorldPosition - WorldPosition).WithZ( 0 ) : Facing;
dir = dir.Length > 0.01f ? dir.Normal : Facing;
CastFireLine( WorldPosition + dir * 50f * Size, dir );
break;
}
case Beat.ScreamLand:
{
// the Shrieker's own `Land`: its scream, a shake (on every machine here — the Shrieker's shakes the host alone), and
// `SonicWave`, which every machine builds and which dazes the player it reaches on that player's machine
NZSound.PlayShared( NZSound.ShriekerScream, Voice );
NZSound.PlayShared( "nz.margwa.attack", Voice );
NZNet.ShakeAt( WorldPosition, 0.6f, ScreamRange );
var from = Voice;
var to = target.IsValid() ? target.WorldPosition + Vector3.Up * 36f : from + Facing * ScreamRange;
SonicWave.Fire( from, to );
break;
}
case Beat.PulseHit:
{
var at = WorldPosition;
var radius = PulseRadius * Size;
NZNet.MargwaPulse( at, radius );
NZSound.PlayShared( "nz.margwa.slam", at );
NZSound.PlayShared( "nz.margwa.slam.far", at );
Pulse( at, radius );
break;
}
case Beat.PortalOut:
// `teleport_portal`, frame 13: the portal opens under him
NZNet.MargwaPortal( WorldPosition, 70f * Size, 1.6f );
NZSound.PlayShared( "nz.margwa.warp", WorldPosition + Vector3.Up * 40f );
break;
case Beat.Vanish:
{
// ⚠️ NOWHERE TO COME OUT (no navmesh near his target), AND HE STAYS: the clip ends where he stands
var spot = target.IsValid() ? TeleportSpot( target ) : null;
if ( spot is null ) break;
NZNet.MargwaVanish( GameObject.Id, true );
_vanished = true;
if ( _hp.IsValid() ) _hp.Invulnerable = true;
WarpTo( spot.Value );
if ( target.IsValid() ) Face( target.WorldPosition );
NZNet.MargwaPortal( spot.Value, 70f * Size, 2.2f );
NZSound.PlayShared( "nz.margwa.teleport", spot.Value + Vector3.Up * 40f );
break;
}
case Beat.Arrive:
if ( !_vanished ) break;
_vanished = false;
NZNet.MargwaVanish( GameObject.Id, false );
if ( _hp.IsValid() ) _hp.Invulnerable = false;
if ( target.IsValid() ) Face( target.WorldPosition );
// he climbs out with his own entrance, as he first came in
_ai.PlaySpecial( "nz_ai_margwa_spawn", 54f / 30f );
break;
case Beat.ShadowTell:
NZSound.PlayShared( "nz.margwa.shadow.start", Voice );
NZSound.PlayShared( "nz.margwa.warn", Voice );
break;
case Beat.ShadowPortal:
NZSound.PlayShared( "nz.margwa.shadow.portal", front );
NZSound.PlayShared( "nz.margwa.slam", WorldPosition );
NZNet.ShakeAt( WorldPosition, 0.35f, 1800f );
break;
case Beat.ShadowSkull:
{
// ⚠️ OUT OF THE PORTAL, 100 AHEAD AND 50 UP, upstream's `direction + Vector3(0,0,50)`; even a skull launched after
// he died still flies, as upstream's timers do
var from = front + Vector3.Up * 50f * Size + Facing.Cross( Vector3.Up ) * ((n - 2.5f) * 12f * Size);
var aim = target.IsValid() ? (target.WorldPosition + Vector3.Up * 50f - from).Normal : Facing;
MargwaProjectile.Launch( Scene, MargwaProjectile.Kinds.Skull, from, aim, target, GameObject );
break;
}
}
}
/// <summary>What one of his swipes deals on this round — the fire line's and the slam's yardstick.</summary>
float SwipeDamage()
{
int round = Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
return ZombieStats.AttackDamageForRound( round ) * (_ai?.Variant?.DamageMultiplier ?? 2f) * Difficulty.ZombieDamage;
}
/// <summary>Light the line of fire, here (the copy that burns) and on every other machine (copies that only draw). THE HOST.</summary>
void CastFireLine( Vector3 start, Vector3 dir )
{
MargwaFireLine.Draw( Scene, start, dir, Size, true, SwipeDamage(), GameObject );
if ( Networking.IsActive ) NZNet.MargwaFireLine( start, dir, Size );
NZSound.PlayShared( "nz.margwa.slam", WorldPosition );
NZSound.PlayShared( "nz.margwa.fire.impact", start );
NZSound.PlayShared( "nz.margwa.fire.whoosh", start + dir * 300f * Size );
NZNet.ShakeAt( WorldPosition, 0.4f, 1800f );
}
/// <summary>
/// Scream: he stops, every mouth opens, and after the Shrieker's wind-up the wave leaves (`ScreamLand`). THE HOST.
/// </summary>
bool StartScream()
{
var target = _ai.Target;
if ( !target.IsValid() ) return false;
_nextScream = Time.Now + Game.Random.Float( ScreamGapMin, ScreamGapMax );
Face( target.WorldPosition );
if ( !_ai.PlaySpecial( "nz_ai_margwa_idle_01", ScreamWindUp + ScreamHold ) ) return false;
OpenMouth( AllMouths, ScreamWindUp + ScreamHold );
NZSound.PlayShared( NZSound.ShriekerCharge, Voice );
NZSound.PlayShared( "nz.margwa.warn", Voice );
Queue( ScreamWindUp, Beat.ScreamLand );
return true;
}
/// <summary>
/// Rear up for the pulse: `nz_ai_margwa_elec_attack`, whose two-handed slam (`elec_slam`, frame 48) is the release — 1.6 s of
/// warning, the ring on the floor at the full reach meanwhile. THE HOST.
/// </summary>
bool StartPulse()
{
var target = _ai.Target;
if ( !target.IsValid() ) return false;
_nextPulse = Time.Now + Game.Random.Float( PulseGapMin, PulseGapMax );
Face( target.WorldPosition );
if ( !_ai.PlaySpecial( "nz_ai_margwa_elec_attack", 96f / 30f ) ) return false;
const float charge = 48f / 30f;
NZNet.MargwaPulseCharge( WorldPosition, PulseRadius * Size, charge );
NZSound.PlayShared( "nz.margwa.warn", Voice );
Queue( charge, Beat.PulseHit );
return true;
}
/// <summary>
/// The pulse's damage: everyone inside the circle on his floor, in his line of sight, <see cref="PulseWeight"/> of a swipe at
/// his feet down to <see cref="PulseEdge"/> of that at the rim, under his cap, as an area hit with his name on it. THE HOST.
/// </summary>
void Pulse( Vector3 at, float radius )
{
var swipe = SwipeDamage();
foreach ( var p in Scene.GetAllComponents<NZPlayer>().ToList() )
{
if ( !p.IsValid() || (p.IsDown || p.DownedNet) ) continue;
var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
if ( !hp.IsValid() || hp.IsDead ) continue;
// ⚠️ HIS FLOOR, NOT THE ONE ABOVE OR BELOW: the ring is drawn on the ground he stands on
var flat = (p.WorldPosition - at).WithZ( 0 ).Length;
if ( flat > radius || MathF.Abs( p.WorldPosition.z - at.z ) > 96f ) continue;
var to = p.WorldPosition + Vector3.Up * 32f;
var tr = Scene.Trace.Ray( at + Vector3.Up * 32f, to ).IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "zombie", "player", "trigger" ).Run();
if ( tr.Hit && tr.HitPosition.Distance( to ) > 24f ) continue;
var t = Math.Clamp( flat / MathF.Max( 1f, radius ), 0f, 1f );
var share = 1f - (1f - PulseEdge) * t;
hp.Apply( MathF.Min( swipe * PulseWeight * share, MaxHit ), false, GameObject, blast: true, blastAt: at );
}
}
/// <summary>Go into the portal: `nz_ai_margwa_teleport_out`, then out beside his target. THE HOST.</summary>
bool StartTeleport()
{
var target = _ai.Target;
if ( !target.IsValid() ) return false;
_nextTeleport = Time.Now + Game.Random.Float( TeleportGapMin, TeleportGapMax );
Face( target.WorldPosition );
if ( !_ai.PlaySpecial( "nz_ai_margwa_teleport_out", 41f / 30f ) ) return false;
Queue( 13f / 30f, Beat.PortalOut ); // `teleport_portal`
Queue( 36f / 30f, Beat.Vanish ); // `teleport` is frame 38: gone just before it
Queue( 36f / 30f + 0.35f, Beat.Arrive ); // after the clip, so the entrance can play
return true;
}
/// <summary>Where to come out: on the navmesh, 160-360 from the target, on its floor; else a ring point snapped to the mesh.</summary>
Vector3? TeleportSpot( GameObject target )
{
var center = target.WorldPosition;
var nav = Scene.NavMesh;
if ( nav is not null )
for ( int i = 0; i < 12; i++ )
{
var p = nav.GetRandomPoint( center, TeleportFar );
if ( !p.HasValue ) continue;
var flat = (p.Value - center).WithZ( 0 ).Length;
if ( flat < TeleportNear || flat > TeleportFar || MathF.Abs( p.Value.z - center.z ) > 64f ) continue;
return p.Value;
}
var a = Game.Random.Float( 0f, MathF.PI * 2f );
var g = ZombieAI.NavGround( Scene, center + new Vector3( MathF.Cos( a ), MathF.Sin( a ), 0f ) * ((TeleportNear + TeleportFar) * 0.5f) );
return (g - center).WithZ( 0 ).Length >= TeleportNear * 0.5f && MathF.Abs( g.z - center.z ) <= 64f ? g : null;
}
/// <summary>Put him somewhere else, the agent with him, as a snap on every machine.</summary>
void WarpTo( Vector3 to )
{
WorldPosition = to;
var agent = Components.Get<NavMeshAgent>( FindMode.EverythingInSelfAndDescendants );
if ( agent.IsValid() ) agent.SetAgentPosition( to );
// ⛔ A SNAP, NOT A SLIDE: without this every other machine interpolates the body across the map
GameObject.Network.ClearInterpolation();
}
/// <summary>Turn to face a point — his own facing, the rig's yaw correction included (`ZombieAI.FaceMovement`'s rule).</summary>
void Face( Vector3 at )
{
var dir = (at - WorldPosition).WithZ( 0 );
if ( dir.Length < 1f || !_ai.IsValid() ) return;
WorldRotation = Rotation.LookAt( dir.Normal, Vector3.Up ) * _ai.ModelTurn;
}
bool CanSee( GameObject target )
{
var from = WorldPosition + Vector3.Up * 80f * Size;
var to = target.WorldPosition + Vector3.Up * 50f;
var tr = Scene.Trace.Ray( from, to ).IgnoreGameObjectHierarchy( GameObject ).WithoutTags( "zombie", "trigger" ).Run();
return !tr.Hit || tr.HitPosition.Distance( to ) < 40f;
}
/// <summary>
/// Damage every player in reach, with line of sight and linear falloff, as an area hit with his name on it — Oberon's
/// `Blast`, the same 24 units of trace slack and the same cap.
/// </summary>
public void Blast( Vector3 at, float radius, float weight ) => BlastFrom( Scene, GameObject, _ai, at, radius, weight );
public static void BlastFrom( Scene scene, GameObject source, ZombieAI ai, Vector3 at, float radius, float weight )
{
if ( !scene.IsValid() || radius <= 0f ) return;
int round = Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
var damage = ZombieStats.AttackDamageForRound( round )
* (ai?.Variant?.DamageMultiplier ?? 2f)
* weight
* Difficulty.ZombieDamage;
foreach ( var p in scene.GetAllComponents<NZPlayer>().ToList() )
{
if ( !p.IsValid() ) continue;
var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
if ( !hp.IsValid() ) continue;
var to = p.WorldPosition + Vector3.Up * 32f;
var dist = at.Distance( to );
if ( dist > radius ) continue;
var tr = scene.Trace.Ray( at, to ).IgnoreGameObjectHierarchy( source ).Run();
if ( tr.Hit && tr.HitPosition.Distance( to ) > 24f ) continue;
var falloff = 1f - MathX.Clamp( dist / MathF.Max( radius, 1f ), 0f, 1f );
hp.Apply( MathF.Min( damage * falloff, MaxHit ), false, source, blast: true, blastAt: at );
}
}
/// <summary>The scale for a hit on this object: the Margwa's table, or 1 when it is not one (`Health.OnDamage`).</summary>
public static float ScaleOn( GameObject victim, Vector3 hitPos, GameObject attacker )
{
if ( !victim.IsValid() ) return 1f;
var m = victim.Components.Get<MargwaBoss>( FindMode.EverythingInSelfAndAncestors );
return m.IsValid() ? m.ScaleFor( hitPos, attacker ) : 1f;
}
// ══ diagnostics ══════════════════════════════════════════════════════════
static IEnumerable<MargwaBoss> All
=> Game.ActiveScene?.GetAllComponents<MargwaBoss>() ?? Enumerable.Empty<MargwaBoss>();
/// <summary>`nz_margwa` — every Margwa alive and the table.</summary>
[ConCmd( "nz_margwa" )]
public static void Report()
{
Log.Info( $"[nz-margwa] table — open mouth x{MouthScale:0.##}, else x{BodyScale:0.###} · reach {MouthReach:0}u"
+ $" · mouth {OpenSeconds:0.##}s every {GapMin:0.##}-{GapMax:0.##}s less 1 per head lost"
+ $" · heads at {PopFirstAt * 100f:0}%/{PopSecondAt * 100f:0}% (+{PopPoints}) · charge {ChargeSpeed:0} u/s"
+ $" · slam {SlamRange:0}u/{SlamRadius:0}u x{SlamWeight:0.##} (at size 1) · hit cap {MaxHit:0} · jaw {(JawBones ? "on" : "off")}" );
var list = All.ToList();
Log.Info( $"[nz-margwa] {list.Count} alive" );
foreach ( var m in list )
{
var hp = m._hp;
Log.Info( $"[nz-margwa] {m.Kind}{(m.Teleports ? " (teleports)" : m.Pulses ? $" (pulses + screams, pace x{m.Pace:0.##})" : "")} · size x{m.Size:0.##} · hp {hp?.Current ?? 0f:0}/{hp?.Max ?? 0f:0}"
+ $" · heads {(m._alive[0] ? "M" : "-")}{(m._alive[1] ? "L" : "-")}{(m._alive[2] ? "R" : "-")}"
+ $" · open {(m.OpenHead >= 0 ? HeadName( m.OpenHead ) : "none")} · state {m._ai?.State}"
+ $" · speed {m._ai?.MoveSpeed ?? 0f:0} u/s · hitboxes {m._ai?.HitboxCount ?? 0}"
+ ((m._ai?.HitboxCount ?? 0) == 0 ? " ⛔ NO HITBOXES: only the capsule can be hit, and no mouth" : "") );
}
}
/// <summary>`nz_margwa_open <0|1|2> [seconds]` — open a mouth on every Margwa (0 middle, 1 left, 2 right).</summary>
[ConCmd( "nz_margwa_open" )]
public static void OpenCmd( int head = 0, float seconds = 5f )
{
if ( NZGame.IsClient ) { Log.Info( "[nz-margwa] the host decides the mouths" ); return; }
foreach ( var m in All ) m.OpenMouth( head, seconds );
Log.Info( $"[nz-margwa] {HeadName( head )} mouth open for {seconds:0.#}s" );
}
/// <summary>`nz_margwa_pop <0|1|2>` — take a head off every Margwa, as a shot would (no points).</summary>
[ConCmd( "nz_margwa_pop" )]
public static void PopCmd( int head = 0 )
{
if ( NZGame.IsClient ) { Log.Info( "[nz-margwa] the host decides the heads" ); return; }
foreach ( var m in All ) m.PopHead( Math.Clamp( head, 0, 2 ), null, react: true );
}
/// <summary>`nz_margwa_scream` — every Revelations Margwa screams the Shrieker's scream at its target now.</summary>
[ConCmd( "nz_margwa_scream" )]
public static void ScreamCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-margwa] the host decides the screams" ); return; }
var n = 0;
foreach ( var m in All )
if ( m.Pulses && m._ai.IsValid() && m.StartScream() ) n++;
Log.Info( $"[nz-margwa] {n} Margwa(s) screaming · {ScreamMinRange:0}-{ScreamRange:0}u, in sight · {SonicWave.DazeSeconds:0.#}s daze"
+ $" · every {ScreamGapMin:0}-{ScreamGapMax:0}s (Revelations only)" );
}
/// <summary>`nz_margwa_pulse` — every Revelations Margwa rears up and pulses now.</summary>
[ConCmd( "nz_margwa_pulse" )]
public static void PulseCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-margwa] the host decides the pulses" ); return; }
var n = 0;
foreach ( var m in All )
if ( m.Pulses && m._ai.IsValid() && m.StartPulse() ) n++;
Log.Info( $"[nz-margwa] {n} Margwa(s) pulsing · {PulseRadius:0}u at size 1 · x{PulseWeight:0.##} of a swipe at the middle,"
+ $" x{PulseWeight * PulseEdge:0.##} at the rim · every {PulseGapMin:0}-{PulseGapMax:0}s (Revelations only)" );
}
/// <summary>`nz_margwa_teleport` — every Shadows of Evil Margwa goes through its portal now, to beside its target.</summary>
[ConCmd( "nz_margwa_teleport" )]
public static void TeleportCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-margwa] the host decides the portals" ); return; }
var n = 0;
foreach ( var m in All )
if ( m.Teleports && m._ai.IsValid() && m.StartTeleport() ) n++;
Log.Info( $"[nz-margwa] {n} Margwa(s) into the portal · comes out {TeleportNear:0}-{TeleportFar:0}u from its target"
+ $" · on its own past {TeleportFrom:0}u, every {TeleportGapMin:0}-{TeleportGapMax:0}s (Shadows of Evil only)" );
}
/// <summary>`nz_margwa_fire` — the nearest Margwa lays its line of fire at you now, whatever his skin.</summary>
[ConCmd( "nz_margwa_fire" )]
public static void FireCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-margwa] the host lights it" ); return; }
var me = NZPlayer.Local;
var m = All.Where( b => b._ai.IsValid() )
.OrderBy( b => me.IsValid() ? b.WorldPosition.Distance( me.WorldPosition ) : 0f ).FirstOrDefault();
if ( !m.IsValid() || !me.IsValid() ) { Log.Info( "[nz-margwa] no Margwa, or no player of yours" ); return; }
var dir = (me.WorldPosition - m.WorldPosition).WithZ( 0 );
dir = dir.Length > 0.01f ? dir.Normal : m.Facing;
m.CastFireLine( m.WorldPosition + dir * 50f * m.Size, dir );
Log.Info( $"[nz-margwa] fire line: {MargwaFireLine.Length * m.Size:0}u long, {MargwaFireLine.Width * m.Size:0}u wide,"
+ $" {MargwaFireLine.Seconds:0.#}s, {MargwaFireLine.Dps:0.##} of a swipe a second" );
}
/// <summary>`nz_margwa_jaw <0|1>` — turn the open jaw's bone, or leave only the light.</summary>
[ConCmd( "nz_margwa_jaw" )]
public static void JawCmd( int on = 1 )
{
JawBones = on != 0;
Log.Info( $"[nz-margwa] jaw bones {(JawBones ? "ON" : "OFF — the mouth light alone shows an open mouth")}" );
}
/// <summary>`nz_margwa_jaw_angles <0|1|2> <pitch> <yaw> <roll>` — retune one jaw's open turn live.</summary>
[ConCmd( "nz_margwa_jaw_angles" )]
public static void JawAnglesCmd( int head = 0, float pitch = 0f, float yaw = 0f, float roll = 0f )
{
var a = new Angles( pitch, yaw, roll );
if ( head == 0 ) JawMid = a; else if ( head == 1 ) JawLeft = a; else JawRight = a;
Log.Info( $"[nz-margwa] {HeadName( head )} jaw opens by {a}" );
}
/// <summary>`nz_margwa_set <key> <value>` — retune the table live.</summary>
[ConCmd( "nz_margwa_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "mouth": MouthScale = value; break;
case "body": BodyScale = value; break;
case "reach": MouthReach = value; break;
case "open": OpenSeconds = value; break;
case "gapmin": GapMin = value; break;
case "gapmax": GapMax = value; break;
case "first": PopFirstAt = value; break;
case "second": PopSecondAt = value; break;
case "points": PopPoints = (int)value; break;
case "charge": ChargeSpeed = value; break;
case "slam": SlamWeight = value; break;
case "slamrange": SlamRange = value; break;
case "slamradius": SlamRadius = value; break;
case "element": ElementRange = value; break;
case "hitcap": HitCap = value; break;
default:
Log.Info( "[nz-margwa] nz_margwa_set <mouth|body|reach|open|gapmin|gapmax|first|second|points|charge|slam|slamrange"
+ "|slamradius|element|hitcap> <value>" );
Log.Info( "[nz-margwa] upstream: mouth 0.75, body 0.01, reach 33-35, open 1.5, gap 3.5-4.15, heads at 0.65 / 0.35, 500 points" );
return;
}
Log.Info( $"[nz-margwa] {key} = {value:0.###}" );
Report();
}
}