Component controlling the Oberon boss variant, implementing its two phases, special attacks (leap, hole, bomb barrage, pulse), effects, timing, and tuning console commands. It drives animation-tied cues, handles spawning effect objects, damage blasts with falloff and LOS, bomb scheduling, pull/pulse network events, and phase/idle logic.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// OBERON — the giant's own moveset, on top of the ordinary swing the variant already gives him.
///
/// ⛔ THE VARIANT CAN ONLY EXPRESS "SWING AT WHAT IS IN FRONT OF YOU". `.zvar` holds movement,
/// attack and death sequence lists and a damage multiplier, which is every enemy in this game
/// except the ones that have a component — Brutus has his helmet, the napalm zombie its eruption,
/// the shrieker its scream. Oberon's three big attacks are timed events inside long clips, and
/// there is nowhere in a data asset to say "pull everyone in for five seconds, THEN hit them".
///
/// ⚠️ SO THE MELEE STAYS WHERE IT IS. `zbs_attack1` / `zbs_attack2` remain the variant's
/// `AttackSequences` and are driven by `ZombieAI`'s own swing with its own range check and
/// barricade handling. This component owns only what that cannot do, and never competes for the
/// same moment — it will not start anything while the AI is mid-swing.
///
/// ⚠️ EVERY TIMING BELOW IS THE ORIGINAL'S, CONVERTED. `npc_drg_csnz_oberon` hangs its damage off
/// `SequenceEvent( clip, { cycle }, fn )` — a fraction of the clip, not a delay — so each move here
/// carries the cycle and the clip's real duration (frames ÷ fps, measured from the decompiled SMDs)
/// and fires the event when the two say to. Copying the delays as seconds would have silently
/// re-timed every hit the moment a clip's length was re-read.
/// </summary>
public sealed class OberonBoss : Component
{
// ── the two phases ───────────────────────────────────────────────────────
/// <summary>
/// Health fraction at which he takes up the knife.
/// </summary>
///
/// ⚠️ THE ORIGINAL SAYS `self:Health() < 17500` AGAINST A `SpawnHealth` OF 35000, which is a
/// half and is written here as one — a literal 17500 would silently stop being the halfway point
/// the moment his health came from a round curve instead of a constant.
[Property, Range( 0.05f, 0.95f )] public float PhaseTwoAt { get; set; } = 0.5f;
/// <summary>Seconds between big attacks — the original's `bot_skill_delay`.</summary>
///
/// ⚠️ HALVED FROM THE ORIGINAL'S 3. The clips are the real floor on how often he can do
/// anything — the leap is 5s, the barrage 7s, the hole 7.5s — so the gap between them is the
/// only part of the cadence that is ours to spend.
[Property] public float SkillCooldown { get; set; } = 1.5f;
/// <summary>
/// Relative odds of each special. Leap-heavy, because the leap is how he applies pressure.
/// </summary>
///
/// ⚠️ WEIGHTS, NOT A 1-IN-3 ROLL. The three used to come up equally, which on clips this
/// long meant a leap about every fifteen seconds — far too rare for the move that closes
/// distance. At 4:1:1 it is two thirds of everything he does.
///
/// ⚠️ AND THE CLIP LENGTHS STILL DOMINATE. Even at this weighting a leap lands roughly
/// every eight seconds, because the leap itself takes five of them. Shortening that gap
/// further means cutting the recovery out of the clip, not changing these numbers.
[Property] public float LeapWeight { get; set; } = 4f;
[Property] public float HoleWeight { get; set; } = 1f;
[Property] public float BombWeight { get; set; } = 1f;
/// <summary>
/// The pulse's odds: none unless something asks for it — basalt's boss fight gives it from its second phase
/// (`HexPlatforms.SetMoves`), asked for as *"a pulse around it that has the effect of the shrieker, stunning the players"*.
/// </summary>
[Property] public float PulseWeight { get; set; } = 0f;
/// <summary>
/// The pulse: how far it reaches; how long its daze lasts — the Shrieker's own four seconds (`SonicDaze`); how long it
/// charges, its warning drawn on the floor meanwhile (`PulseTelegraph`); and how long he stands after it.
/// `nz_oberon_pulse` sets them.
/// </summary>
[Property] public float PulseRadius { get; set; } = 500f;
[Property] public float PulseDaze { get; set; } = 4f;
[Property] public float PulseCharge { get; set; } = 1.2f;
[Property] public float PulseRecover { get; set; } = 0.5f;
/// <summary>
/// What he plays while the pulse charges. ⚠️ A STAND-IN: his set has no clip for it, so he stands and gathers, in his idle.
/// `nz_oberon_pulse 0 0 0 <clip>` tries another of his twenty; `nz_oberon_clip <clip>` plays one to look at.
/// </summary>
[Property] public string PulseClip { get; set; } = "zbs_idle1";
/// <summary>
/// While true he starts no special of his own — basalt's boss fight holds him through his coming and its phase changes,
/// where it moves him itself (<see cref="Dive"/>). His swipe is the AI's, and goes on.
/// </summary>
public bool Held { get; set; }
/// <summary>
/// He will not leap at somebody already within this much of him.
/// </summary>
///
/// ⛔ A LEAP IS A GAP-CLOSER, AND ONE THROWN AT SOMEBODY ALREADY IN REACH IS A HOP BACKWARDS.
/// `BeginArc` moves him toward the target but never past them, so at close range the whole
/// five-second move resolves to almost no travel — five seconds of pressure spent standing
/// still. Inside this range the weight drops to zero and he swipes or throws instead.
[Property] public float LeapMinRange { get; set; } = 600f;
/// <summary>
/// How far the leap carries him, and how high it arcs.
/// </summary>
///
/// ⛔ WITHOUT THIS HE CANNOT TRAVEL AT ALL, and that is a property of the model, not a choice.
/// `zbs_attack3` has the whole jump baked into its BONES — the root rises 163 units and comes
/// back down — and `oberon.vmdl` declares no motion extraction, so the engine plays that as a
/// pose. The mesh lifts; the GameObject never moves. Anything that wants him to cross ground
/// has to move the object itself.
///
/// ⚠️ THE ORIGINAL THROWS ITSELF WITH `SetVelocity( forward*500 + up*750 )` — ballistic under
/// GMod's own gravity, landing wherever it lands. A nav agent cannot be shoved: it comes down
/// off the mesh and stands still for good. So the distance is authored and the landing is
/// snapped to the navmesh, which is the same leap with a floor under it.
///
/// ⚠️ CAPPED, NOT MEASURED FROM THE TARGET. He starts this from up to 5000 units away; a leap
/// that closed the whole gap would be a teleport.
///
/// ⛔ BOTH ARE QUOTED AT `ModelScale` 1 AND MULTIPLIED BY HIS ACTUAL SCALE AT THE LAUNCH.
/// Everything else that describes his size already scales itself — `ApplyModelScale` carries
/// `BodyHeight`, `HitRadius` and `AttackRange`, and the effect models are children of a scaled
/// object — so a leap quoted in raw world units is the one measurement that would silently
/// stay the old size when he grows. That is exactly the shape of the `GroundSpeed` trap in
/// INSTRUCTIONS.md: a number coupled to the model with nothing in the code saying so.
[Property] public float LeapDistance { get; set; } = 840f;
/// <summary>Peak height of the arc, above the straight line between the ends. At scale 1.</summary>
[Property] public float LeapHeight { get; set; } = 260f;
/// <summary>
/// Colour of the shockwave his landing throws out.
/// </summary>
///
/// ⚠️ IT IS THUNDERWALL'S RING, RECOLOURED — `ShockRing` was written as a general primitive
/// for exactly this, and its default pale blue is Thunderwall's own. A red-hot one reads as
/// mass hitting the floor rather than as electricity, and matches the red on his mask.
[Property] public Color LandingRing { get; set; } = DefaultRing;
/// <summary>The authored ring colour, for the console command's fallback.</summary>
///
/// ⚠️ AN EXPRESSION, NOT A `static readonly` FIELD. A static's initialiser does not re-run
/// on a hotload (INSTRUCTIONS.md §1), so a field would be a value frozen at whatever it held
/// when the session started.
static Color DefaultRing => new Color( 1f, 0.35f, 0.12f );
/// <summary>How far past the damage radius the ring is drawn. 1 = exactly on it.</summary>
///
/// ⛔ ONE, AND IT SHOULD STAY ONE. The ring is the only thing that makes a 600-unit blast
/// legible, and a ring drawn wider than the damage is a promise the hit does not keep —
/// `ShockRing`'s own note calls this out: what you see is what was hit.
[Property] public float LandingRingScale { get; set; } = 1f;
/// <summary>
/// The most one hit of his takes, as a share of a player's BASE health (`PlayerSettings.MaxHealth`, basalt's 150):
/// two-thirds — 100. *"we cant have any attack insta kill like that"* (2026-09-27): no swipe, leap, black hole or bomb takes
/// a player down from full health, whatever the round; two in a row can. His swipe used to kill outright.
/// </summary>
///
/// ⛔ OF THE BASE HEALTH, NOT THE VICTIM'S OWN MAXIMUM, AND ON PURPOSE TWICE OVER. The host sizes his hits, and its copy of a
/// client has never heard of their Juggernog (`Health.Apply`'s relay note) — a cap off that copy's maximum would be one
/// number for the host's player and another for everyone else. And against the base, Juggernog still buys what it should:
/// a third hit, not a bigger one.
///
/// ⚠️ THE SWIPE IS THE VARIANT'S, NOT THIS COMPONENT'S — `zbs_attack1`/`zbs_attack2` come from `oberon.zvar` and are swung
/// by `ZombieAI` — so it is capped there (`ZombieAI.MaxHitDamage`); his blasts are capped here, in <see cref="Blast"/>.
[Property, Range( 0.1f, 0.95f )] public float HitCap { get; set; } = DefaultHitCap;
/// <summary>Two-thirds.</summary>
public const float DefaultHitCap = 2f / 3f;
/// <summary>The most one hit of his takes now, in health: <see cref="HitCap"/> of the base health — under 1, so never all of it.</summary>
/// <remarks>⚠️ OF THE MATCH'S MAX HEALTH (the lobby's Difficulty, 2026-10-05), so on any difficulty none downs anyone from full.</remarks>
public float MaxHit => MathF.Max( 1f, Difficulty.MaxHealth * Math.Clamp( HitCap, 0.1f, 0.95f ) );
/// <summary>How far away he will still open with one.</summary>
///
/// ⚠️ THE ORIGINAL'S `RangeAttackRange` IS 5000, which is most of a map. Kept, because every one
/// of these moves closes the distance or reaches across it — a boss that only does this in your
/// face is a boss you walk away from.
[Property] public float SpecialRange { get; set; } = 5000f;
// ── damage ───────────────────────────────────────────────────────────────
/// <summary>
/// What each move hits for, as a MULTIPLE of his ordinary swing.
/// </summary>
///
/// ⛔ NOT THE LUA'S NUMBERS. It authors 285 / 360 / 452 against players at a tenth — 28.5, 36,
/// 45.2 — which are flat and would stop meaning anything by round 20, because damage here comes
/// from `ZombieStats.AttackDamageForRound` and the variant's multiplier. What IS portable is the
/// RATIO between his moves: 1 : 1.26 : 1.59. Those are kept and the round curve carries the rest,
/// so he scales with the game instead of against it.
[Property] public float HoleDamage { get; set; } = 1.26f;
[Property] public float LeapDamage { get; set; } = 1.59f;
/// ⚠️ 1.8, UP FROM 0.55, AND THIS IS THE LARGEST SINGLE NUMBER ON THE BOSS. A weight is
/// multiplied by the round's attack damage AND by the variant's `DamageMultiplier` of 3, so
/// 1.8 is **5.4× a round's attack per bomb** — against the leap's 4.8× and the hole's 3.8×,
/// except that there are thirty bombs and one leap. Anyone standing in two craters is
/// almost certainly dead. That is what was asked for; `nz_oberon_barrage` dials it live.
[Property] public float BombDamage { get; set; } = 1.8f;
/// <summary>Phase two hits harder — 410/430/560 against 285/360/452 upstream, so about ×1.3.</summary>
[Property] public float PhaseTwoDamageScale { get; set; } = 1.3f;
// ── the hole's pull ──────────────────────────────────────────────────────
/// <summary>How hard the hole drags a player in, per tick.</summary>
[Property] public float PullSpeed { get; set; } = 260f;
/// <summary>How far the pull reaches.</summary>
[Property] public float PullRadius { get; set; } = 900f;
/// <summary>Ticks per second while the hole is open. The original runs a 0.1s timer.</summary>
[Property] public float PullRate { get; set; } = 10f;
// ── the bomb barrage ─────────────────────────────────────────────────────
/// <summary>Bombs per wave, and how far out they scatter.</summary>
///
/// ⛔ TEN — 40% OF THE 25 IT WAS — by the user's word after playing the fight: *"there are too many bombs, reduce them to
/// 40% on the bomb attack"* (2026-09-27). Three waves of ten, thirty in all; one wave covers some 45% of the disc it lands
/// in, where 25 covered all of it and more, so there is floor to stand on between the craters. `nz_oberon_barrage [count]`
/// retunes it live.
///
/// ⚠️ IT WAS FIFTEEN, THE ORIGINAL'S OWN COUNT, and five before that while each one was an invisible instant blast — fifteen
/// simultaneous explosions with no cause on screen would have been noise. Thrown objects with a marked landing point are
/// what made a number this size a barrage.
[Property] public int BombsPerWave { get; set; } = 10;
/// <summary>Seconds between one bomb leaving his hand and the next. 0.03.</summary>
///
/// ⚠️ THE ORIGINAL'S OWN `self:Timer(0.03*i, ...)`. It is short enough that the wave reads
/// as one throw and long enough that the shells leave as a stream rather than a clump.
[Property] public float BombStagger { get; set; } = 0.03f;
/// <summary>How long a bomb is in the air, before its ±15% jitter. 1.8s.</summary>
///
/// ⚠️ AUTHORED, NOT BALLISTIC, AND THE ORIGINAL AGREES WITHIN A QUARTER SECOND. It throws
/// with `up * rand(600,700)` under GMod's gravity of 600, which is about 2.2s of flight; an
/// arc that is authored end to end lands exactly where the marker says instead of wherever
/// physics puts it, which is what lets the ring be a promise.
[Property] public float BombFlight { get; set; } = 2.2f;
/// <summary>How high the arc peaks above the line from his hand to the floor. 420.</summary>
[Property] public float BombApex { get; set; } = 800f;
/// <summary>
/// How far he leans forward to throw, in degrees.
/// </summary>
///
/// ✅ NINETY, AND THE CLIP AGREES. Measured off the source SMD with `Tools/measure_anim.py`:
/// the torso lean runs **34.4° at rest → ~115° through the throw → 34.4°**, so the authored
/// movement is about **+80°** from his standing hunch. Ninety is within a few degrees of what
/// the animator drew, which is why it is the number here rather than a smaller one.
///
/// ⚠️ THIS IS THE BODY, NOT THE CLIP. Oberon's clips came through the port with their root
/// pitch flattened, so the pose he plays is upright however far the source leaned. Rather than
/// re-export twenty clips to recover one attack's lean, the body is leaned under the animation
/// — which is what "adjust the boss's position" asks for and costs one rotation.
[Property] public float BombTilt { get; set; } = 90f;
/// <summary>Cycles of the clip at which he goes down and comes back up. 0.17 and 0.88.</summary>
///
/// ⚠️ BOTH READ OFF THE SOURCE ANIMATION, not chosen. Frame 14 of 167 is still near upright
/// (10.6°), frame 28 is already down (120.8°), it holds past frame 139, and frame 153 is on
/// its way back (83.6°). 28/167 and 147/167 are 0.17 and 0.88.
[Property] public float BombTiltDown { get; set; } = 0.17f;
[Property] public float BombTiltUp { get; set; } = 0.88f;
/// <summary>
/// How long BEFORE the animation goes down that he starts leaning. 1 second.
/// </summary>
///
/// ✅ ASKED FOR AS *"make it apply 1 second sooner"*, and it is in SECONDS rather than in
/// cycles for exactly that reason: a second is what was asked for, and a cycle is not a fixed
/// amount of time. `BombTiltDown` stays at the measured 0.17 — which is where the clip really
/// goes down — and this is the deliberate lead on top of it.
///
/// ⛔ CONVERTED WITH THE CLIP'S REAL LENGTH, NOT A CONSTANT. Every cue on this boss was
/// briefly half a second out because it was timed against `frames ÷ authored fps` while the
/// DMX exported at 24 — see the 16:04 entry. A lead baked in as "subtract 0.14 cycles" would
/// walk straight back into that the day a clip is re-exported at a different rate.
///
/// ⚠️ IT MOVES THE ONSET ONLY. He therefore HOLDS the lean a second longer, which is right:
/// the throw itself has not moved, and coming up early would look like he had finished.
[Property] public float BombTiltLead { get; set; } = 1.0f;
[Property] public float BombSpread { get; set; } = 1800f;
[Property] public float BombRadius { get; set; } = 380f;
/// <summary>
/// What the knife walk's travel comes to against the walk the tier was written for.
/// </summary>
///
/// ⛔ MEASURED, AND IT IS NOT 1. `knife_walk` covers 749.7u in 3.333s = 224.9 u/s where
/// `zbs_walk` covers 963.4u in 4.0s = 240.8. The tier's `GroundSpeed` describes the latter, and
/// the animation rate is `velocity / clipGroundSpeed` — so playing the knife walk against the
/// bare-handed number cycles his legs 7% slow for the ground he covers. `ExtraSpeedMultiplier`
/// is the one field that feeds that division from outside the asset.
[Property] public float KnifeWalkSpeedScale { get; set; } = 224.9f / 240.8f;
/// <summary>How long an idle is held before he reconsiders.</summary>
///
/// ⛔ SHORTER THAN THE CLIP, ON PURPOSE. `PlaySpecial` zeroes his agent speed for the whole
/// hold, so a full 5.25s idle is 5.25 seconds of a boss standing still while somebody shoots
/// him. Two seconds shows the animation and hands control back before that matters.
[Property] public float IdleHold { get; set; } = 2f;
/// <summary>
/// How far up his own effect models sit, in model space.
/// </summary>
///
/// ⛔ IT IS THE FOOT DROP, AND IT IS NOT A COINCIDENCE. His mesh was lifted 61.6 when `--stand`
/// put his feet on Z=0, so his object origin is now his feet — while `ef_hole` and the claws are
/// authored around where his origin USED to be. One offset on the child object restores the
/// relationship the two were drawn with, and costs nothing at runtime.
///
/// ⚠️ NOT BAKED INTO THE EFFECT MESHES. Four models lifted by four different amounts is four
/// chances to be wrong; one number beside the reason is one.
[Property] public float FxLift { get; set; } = 61.6f;
/// <summary>
/// The scale his body is actually drawn at, for the measurements quoted at scale 1.
/// </summary>
///
/// ⚠️ READ FROM THE AI, NOT FROM THE VARIANT. `ZombieAI.ModelScale` is what
/// `ApplyModelScale` LAST APPLIED, so it follows `nz_zscale` and the tuner panel; the variant
/// is only what the asset asked for, and the two differ the moment anybody tries a size.
///
/// ⚠️ FLOORED, because a zero would collapse every distance derived from it to nothing and
/// the symptom would be a leap that does not move.
float BodyScale => MathF.Max( 0.05f, _ai.IsValid() ? _ai.ModelScale : 1f );
/// <summary>Which phase he is in. 1 is bare-handed, 2 is the knife.</summary>
public int Phase { get; private set; } = 1;
/// <summary>What he is doing right now, for `nz_oberon`.</summary>
public string Status { get; private set; } = "idle";
// ── the moveset ──────────────────────────────────────────────────────────
enum Kind { Leap, Hole, Bomb, Pulse }
/// <summary>
/// One big attack: a clip, its real length, and the cycles its events sit at.
/// </summary>
///
/// ⚠️ `Seconds` IS MEASURED, NOT GUESSED — frames ÷ fps from the decompiled SMDs, and the fps
/// varies per clip in this model (the bomb runs at 15, the run at 50). `PlaySpecial` plays at
/// rate 1, so cycle × Seconds is when an event actually lands on screen.
sealed class Move
{
public Kind Kind;
public string Clip;
public float Seconds;
public float HitAt;
public float Damage;
public float Radius;
/// <summary>Hole only: when the pull starts and stops.</summary>
public float PullFrom;
/// <summary>Bomb only: the three waves.</summary>
public float[] Waves;
/// <summary>Leap only: when he leaves the ground.</summary>
public float LaunchAt;
/// <summary>
/// Leap only: where to, if not at his target; and whether it is a dive — landing exactly there, off the navmesh, with
/// no blow (<see cref="Dive"/>).
/// </summary>
public Vector3? To;
public bool Dive;
/// <summary>
/// The cycle at which he is released back to the AI. 1 = the whole clip.
/// </summary>
///
/// ⛔ IT CUTS THE HOLD AS WELL AS THE MOVE, AND IT HAS TO CUT BOTH. `PlaySpecial` freezes
/// the agent until its hold expires, so ending the Move early while the hold ran on would
/// leave him standing exactly as long and simply stop this component watching. The two are
/// set from the same number.
public float Ends = 1f;
}
static Move Leap( string clip ) => new()
{
Kind = Kind.Leap, Clip = clip,
// 121 frames @ 30fps
Seconds = 4.0f, LaunchAt = 16f / 120f, HitAt = 63f / 120f, Radius = 600f,
// ⛔ CUT AT 0.78, WHICH IS THE RECOVERY. He lands at 0.525 and the remaining two and a
// half seconds are the animator standing him back up — during which `PlaySpecial` has his
// agent pinned at zero speed, so a boss meant to APPLY PRESSURE spends half of his main
// attack as a stationary target. Releasing him at 0.78 keeps the part of the recovery that
// reads as landing and hands him back while he is still moving.
//
// ⚠️ THE CLIP IS NOT TRUNCATED, THE HOLD IS. `TickSpecial` re-picks the walk clip on the
// way out and the anim files carry a 0.2s fade, so it blends rather than snaps.
Ends = 0.78f,
};
static Move Hole( string clip ) => new()
{
Kind = Kind.Hole, Clip = clip,
// 181 frames @ 30fps
Seconds = 6.0f, PullFrom = 5f / 180f, HitAt = 150f / 180f, Radius = 1000f,
};
static Move Bomb( string clip ) => new()
{
Kind = Kind.Bomb, Clip = clip,
// 168 frames @ 15fps — eleven seconds, and that is the animation, not a choice
Seconds = 11.13f, HitAt = -1f, Radius = 0f,
Waves = new[] { 45f / 167f, 89f / 167f, 134f / 167f },
};
/// <summary>
/// The pulse: the charge, then the release. ⚠️ TIMED IN SECONDS, NOT OFF THE CLIP — the clip is a stand-in of any length
/// (<see cref="PulseClip"/>) — so the hold is the charge and the recovery, and the release lands at the charge's end.
/// </summary>
static Move PulseMove( string clip, float charge, float recover ) => new()
{
Kind = Kind.Pulse, Clip = clip,
Seconds = MathF.Max( 0.2f, charge + recover ),
HitAt = MathF.Max( 0.05f, charge ) / MathF.Max( 0.2f, charge + recover ),
Radius = 0f,
};
ZombieAI _ai;
Health _hp;
Move _move;
float _startedAt;
bool _hit;
int _wave;
bool _launched;
/// <summary>Set by `nz_oberon_move`: the next special to run instead of a rolled one.</summary>
Kind? _forced;
/// <summary>What he did last, so the barrage cannot run twice running.</summary>
///
/// ⚠️ IT SURVIVES THE MOVE ENDING, obviously, but NOT a re-spawn — a fresh boss may open
/// with a barrage. That is correct: the rule is about repetition inside one fight.
Kind? _lastKind;
/// <summary>
/// A barrage blast waiting for its bomb to come down.
/// </summary>
///
/// ⛔ THE DAMAGE STAYS HERE AND THE PICTURE GOES OVER THE WIRE, WHICH IS THE WHOLE SPLIT.
/// A `BombShell` exists on every machine so everyone can watch the barrage, so a shell that
/// dealt its own damage would hurt each player once per client. The shell is inert; this list
/// is the host's own copy of when each one arrives.
///
/// ⚠️ IT OUTLIVES THE MOVE, ON PURPOSE. The last wave is thrown at cycle 134/167 and the
/// flight is nearly two seconds, so bombs are still falling after the clip has ended and
/// `_move` is null. That is also why the damage is CAPTURED at the throw rather than read from
/// `_move` at the landing — by then there is nothing to read.
readonly List<(float At, Vector3 Where, float Damage)> _pending = new();
TimeUntil _cooldown;
TimeUntil _idle;
// ── effects ──────────────────────────────────────────────────────────────
readonly List<(GameObject Go, TimeUntil Until)> _fx = new();
string _lastClip = "";
string _clawModel;
string _clawClip;
TimeUntil _clawAt;
protected override void OnStart()
{
_ai = Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
_hp = Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
// ⚠️ CAPPED FROM HIS FIRST FRAME — `OnUpdate` keeps it so after (`HitCap`)
if ( _ai.IsValid() ) _ai.MaxHitDamage = MaxHit;
// ⚠️ THE FIRST ONE IS NOT FREE. Without this he opens with a six-second hole the instant he
// is spawned, before anybody has seen him stand up.
_cooldown = SkillCooldown;
// ⛔ HE HAS NONE OF THE WALKER'S CLIMB CLIPS. `WalkerTraverse.ClimbUp` names
// `nz_base_zombie_jump_up_*`, and an unknown sequence is the bind pose rather than an error
// — so without this he crosses every nav link frozen. `zbs_jump` is 15 frames and is the
// only clip in his set the lua never calls; this is what it is for.
_ai.ClimbClipOverride = "zbs_jump";
Log.Info( $"[nz-oberon] awake — {_hp?.Max ?? 0f:0} hp, phase two at {PhaseTwoAt * 100f:0}%" );
}
protected override void OnUpdate()
{
if ( !_ai.IsValid() ) return;
// ⛔ ABOVE THE DEATH CHECK, AND THAT IS DELIBERATE. A bomb already in the air is a thing
// in the world, not a thing he is doing — killing him between the throw and the landing
// should not disarm it, and the shells keep falling and exploding either way. Below the
// check they would draw their craters and do nothing.
TickPending();
if ( _ai.State == ZombieState.Dead )
{
_move = null;
Status = "dead";
// ⚠️ A CORPSE CARRIES NO SLASH TRAIL. These are children of a body that stays in the
// scene, so without this the last claw hangs off him for as long as he lies there.
foreach ( var f in _fx )
if ( f.Go.IsValid() ) f.Go.Destroy();
_fx.Clear();
// ⚠️ A CORPSE DOES NOT KEEP THROWING. Dying mid-barrage would otherwise leave the
// lean set, and the death clip would play on a body tipped ninety degrees forward.
Unlean();
return;
}
// ⚠️ PUSHED EVERY FRAME, NOT ONLY IN `OnStart`. The cap is editable live from the inspector and from
// `nz_oberon_hitcap`, and a one-time write would silently ignore both. It is one float assignment.
_ai.MaxHitDamage = MaxHit;
TickPhase();
TickFx();
TickClaw();
if ( _move is not null ) { TickMove(); return; }
TryStart();
TickIdle();
}
// ── phase ────────────────────────────────────────────────────────────────
/// <summary>
/// Take up the knife at half health, once.
/// </summary>
///
/// ⚠️ IT INTERRUPTS NOTHING. The original drops whatever it is doing, kills the hole and plays
/// the transition; here the switch waits for the current move to finish, because `PlaySpecial`
/// refuses while another special is running and a transition that silently did not play would
/// leave him bare-handed with a knife moveset.
void TickPhase()
{
if ( Phase != 1 || _move is not null ) return;
if ( !_hp.IsValid() || _hp.Max <= 0f ) return;
if ( _hp.Current > _hp.Max * PhaseTwoAt ) return;
// 261 frames @ 30fps
if ( !_ai.PlaySpecial( "scene_knife", 8.67f ) ) return;
Phase = 2;
Status = "taking the knife";
_cooldown = SkillCooldown;
// ⛔ PER-INSTANCE OVERRIDES, NOT AN EDIT TO THE `.zvar`. The variant is a shared asset that
// is written to disk; changing it here would give every other Oberon the knife and persist
// it. See `ZombieAI.MovementOverride`.
//
// ⚠️ AND `RepickAnimations()` OR NOTHING READS THEM. The clip set and the speed derivation
// are both computed inside `PickAnimations`, so a caller that sets a field and walks away
// keeps the old gait until some unrelated tier change happens to re-pick — which may be
// never.
_ai.MovementOverride = new List<string> { "knife_walk" };
_ai.AttackOverride = new List<string> { "knife_attack1", "knife_attack2" };
_ai.ExtraSpeedMultiplier = KnifeWalkSpeedScale;
_ai.RepickAnimations();
NZSound.PlayShared( "nz.oberon.close", WorldPosition );
CameraShake.Punch( WorldPosition, 0.5f, 2500f );
Log.Info( $"[nz-oberon] phase two — {_hp.Current:0}/{_hp.Max:0} hp" );
}
/// <summary>
/// Spawn one of his effect models as a child, playing its own single clip.
/// </summary>
///
/// ⚠️ A CHILD OBJECT, NOT A BONE MERGE. GMod bone-merges these, but their bones are their own —
/// `ef_k_03`, `Bone_Root` — and none of them exist on Oberon, so the merge there is really just
/// parenting with extra steps. Parenting is what it reproduces.
///
/// ⚠️ AND IT INHERITS HIS SCALE FOR FREE, which matters: he renders at ×0.5, and an effect at
/// full size would be twice the boss it is drawn on.
void Fx( string model, string clip, float life )
{
var m = Model.Load( $"models/zombies/{model}.vmdl" );
if ( m is null )
{
Log.Warning( $"[nz-oberon] no effect model '{model}' — is it compiled?" );
return;
}
var go = Scene.CreateObject();
go.Name = $"oberon {model}";
go.Flags |= GameObjectFlags.NotSaved;
go.SetParent( GameObject );
// ⚠️ SET AFTER PARENTING. `SetParent` preserves the world transform by default, so a local
// offset written before it is immediately undone.
go.LocalPosition = Vector3.Up * FxLift;
go.LocalRotation = Rotation.Identity;
go.LocalScale = 1f;
var r = go.Components.Create<SkinnedModelRenderer>();
r.Model = m;
r.Sequence.Name = clip;
r.Sequence.Time = 0f;
_fx.Add( (go, life) );
}
/// <summary>Throw away the effects whose time is up.</summary>
void TickFx()
{
for ( int i = _fx.Count - 1; i >= 0; i-- )
{
if ( !_fx[i].Until ) continue;
if ( _fx[i].Go.IsValid() ) _fx[i].Go.Destroy();
_fx.RemoveAt( i );
}
}
/// <summary>
/// The claw trail on a knife swing — the one effect whose attack this component does not own.
/// </summary>
///
/// ⛔ IT READS WHICH SWING IS ACTUALLY PLAYING. `knife_attack1` and `knife_attack2` are picked
/// at random by `ZombieAI` out of the override list, and they carry their claws at different
/// cycles — 21/55 against 8/45. Alternating the effects would put the right trail on the wrong
/// swing half the time, which is exactly the kind of wrong that looks like a timing bug.
void TickClaw()
{
var clip = _ai.CurrentClip ?? "";
if ( clip != _lastClip )
{
_lastClip = clip;
// 56 frames @ 30 = 1.83s, claw at 21/55. 46 @ 30 = 1.50s, claw at 8/45.
if ( clip == "knife_attack1" )
{
_clawModel = "ef_knife1"; _clawClip = "knife_attack1";
_clawAt = 1.83f * (21f / 55f);
}
else if ( clip == "knife_attack2" )
{
_clawModel = "ef_knife2"; _clawClip = "knife_attack2";
_clawAt = 1.50f * (8f / 45f);
}
}
if ( _clawModel is null || !_clawAt ) return;
Fx( _clawModel, _clawClip, 1f );
_clawModel = null;
}
/// <summary>
/// Stand and breathe when there is nobody to chase.
/// </summary>
///
/// ⛔ ONLY WITH NO TARGET AT ALL. `PlaySpecial` zeroes his agent speed for the duration, so an
/// idle played while a player is in the room is a boss who stops chasing to pose. With no
/// target he is standing still anyway, and this is the difference between standing and being
/// frozen mid-stride.
///
/// ⚠️ THE VARIANT HAS NO IDLE FIELD, which is why this is here rather than in the asset: a
/// stationary zombie otherwise holds its movement clip at the 0.05 rate floor, because the
/// playback rate is `velocity / groundSpeed` and velocity is zero.
void TickIdle()
{
if ( _move is not null || !_idle ) return;
if ( _ai.Target.IsValid() ) return;
if ( _ai.State is not (ZombieState.Idle or ZombieState.Chasing) ) return;
var clip = Phase == 2 ? "knife_idle" : "zbs_idle1";
if ( _ai.PlaySpecial( clip, IdleHold ) )
{
_idle = IdleHold + 0.15f;
Status = Phase == 1 ? "idle" : "idle (knife)";
}
}
// ── choosing ─────────────────────────────────────────────────────────────
void TryStart()
{
Status = Phase == 1 ? "idle" : "idle (knife)";
if ( Held ) return;
if ( !_cooldown ) return;
// ⛔ NEVER OVER THE TOP OF THE AI'S OWN SWING. `PlaySpecial` would refuse anyway while the
// state is Special, but Attacking is a state it does NOT refuse — and stealing a swing
// halfway through is how a hit that was already paid for goes missing.
if ( _ai.State is not (ZombieState.Chasing or ZombieState.Idle) ) return;
var target = _ai.Target;
if ( !target.IsValid() ) return;
if ( WorldPosition.Distance( target.WorldPosition ) > SpecialRange ) return;
var knife = Phase == 2;
// ⚠️ `nz_oberon_move` FORCES THE NEXT ONE, AND THAT IS WHY IT EXISTS. The three are rolled
// uniformly and a move plus its cooldown is about ten seconds, so waiting on a particular one
// is a dice roll that can easily run a minute — a leap fix was reported broken against a
// session in which the roll came up barrage twice and the leap never ran at all.
//
// ⚠️ ONE SHOT. It is cleared as it is read, so a forced move does not latch the boss into
// repeating it and the fight goes back to rolling by itself.
var pick = _forced.HasValue ? (int)_forced.Value : RollKind( target );
_forced = null;
// ⚠️ NOTHING ALLOWED — every weight at nothing, or the one there is too near, as basalt's first phase has the leap alone:
// no special, and the swipe goes on
if ( pick < 0 ) { _cooldown = SkillCooldown; return; }
var move = pick switch
{
0 => Leap( knife ? "knife_attack3" : "zbs_attack3" ),
1 => Hole( knife ? "knife_attack_hole" : "zbs_attack_hole" ),
3 => PulseMove( PulseClip, PulseCharge, PulseRecover ),
// ⚠️ TWO TAKES OF THE BARRAGE IN PHASE ONE. `zbs_attack_bomb_2` is the same length as
// `zbs_attack_bomb` and the lua never calls it — an alternate the pack shipped and its
// own entity forgot. Eleven seconds is a long time to watch the same clip, so it is
// rolled between them. Phase two has only the one.
_ => Bomb( knife
? "knife_attack_bomb"
: (Game.Random.Int( 0, 1 ) == 0 ? "zbs_attack_bomb" : "zbs_attack_bomb_2") ),
};
move.Damage = move.Kind switch
{
Kind.Leap => LeapDamage,
Kind.Hole => HoleDamage,
Kind.Pulse => 0f,
_ => BombDamage,
};
if ( !_ai.PlaySpecial( move.Clip, move.Seconds ) ) return;
// ⛔ RE-TIME THE WHOLE MOVE FROM THE CLIP THE ENGINE IS ACTUALLY PLAYING, NOT FROM `Seconds`.
// Every cue in a Move is a CYCLE — a fraction of the clip — so the seconds it is multiplied
// by has to be the length the clip really has, or a correctly-placed cue lands at the wrong
// moment of an animation that itself looks perfectly fine.
//
// ⛔ AND THEY DID DISAGREE, ON EVERY CLIP THIS MODEL HAS. `Seconds` was measured as frames ÷
// the fps the ORIGINAL was authored at (30 for the leap, 15 for the barrage). The DMX export
// never set Blender's scene fps, so all twenty clips left at its default of 24 — and the
// vmdl says `framerate = -1`, meaning "use the file's". The leap is 121 frames: 4.03s by the
// authored rate, 5.04s as it actually plays. The landing cue at cycle 0.525 therefore fired
// at 2.12s against a touchdown at 2.65s, and the damage and the sound arrived half a second
// before he reached the floor. Reported exactly that way.
//
// ⚠️ THE HOLD IS RE-ARMED WITH IT TOO. `PlaySpecial` was told 4s for a 5s clip, so the last
// second of the leap — the whole recovery — was being cut off by the return to walking.
//
// ⚠️ THE AUTHORED NUMBER SURVIVES AS THE FALLBACK, for the frame where the renderer has not
// caught up: a length of zero puts every cycle at infinity and the move never fires at all.
var authored = move.Seconds;
var real = _ai.CurrentClipDuration;
if ( real > 0.05f && move.Kind != Kind.Pulse )
{
move.Seconds = real;
// ⚠️ `Ends` — see the field. For everything but the leap this is 1 and the hold is
// the whole clip, exactly as before.
_ai.HoldSpecialFor( real * MathX.Clamp( move.Ends, 0.1f, 1f ) );
}
_move = move;
_lastKind = move.Kind;
_startedAt = Time.Now;
_hit = false;
_launched = false;
_wave = 0;
Status = $"{move.Kind.ToString().ToLowerInvariant()} ({move.Clip})";
// ⚠️ ONE LINE PER SPECIAL, AND THEY ARE 3 SECONDS APART AT BEST. "The effect lands but there
// is no animation" is not a question this code can answer from outside — `PlaySpecial`
// returning true means the sequence was found and set, so if that is logged and nothing
// moves on screen, the fault is downstream of here rather than in the choosing.
Log.Info( $"[nz-oberon] {move.Kind} '{move.Clip}' {move.Seconds:0.00}s (phase {Phase})"
+ (MathF.Abs( real - authored ) > 0.02f ? $" [authored {authored:0.00}s]" : "") );
if ( move.Kind == Kind.Hole )
{
NZSound.PlayShared( "nz.oberon.close", WorldPosition );
// ⚠️ 4.9s IS THE ORIGINAL'S OWN TIMER, against a six-second attack — the hole closes
// while he is still finishing the swing, which is what it does in GMod.
Fx( "ef_hole", "idle1", 4.9f );
// ⛔ DRAWN AT `PullRadius`, WHICH MAKES IT A TELEGRAPH RATHER THAN DECORATION. The
// rim is exactly the line `TickPull` tests against, so standing outside the ring is
// standing outside the attack — and a vortex drawn generously would be the attack
// lying about its reach. `ef_hole` is the prop at his feet; this is the reach.
//
// ⚠️ IT LASTS THE PULL, NOT THE CLIP. From the cue the drag starts to the cue it
// lands, off `move.Seconds` — which by here is the clip's REAL length, so this follows
// the same re-timing every other cue in the move does.
//
// ⚠️ AND IT DOES NOT FOLLOW HIM, because he does not move: `PlaySpecial` zeroes his
// agent for the duration. That is what lets the vortex measure the floor once at spawn
// instead of every frame — see the note on `Vortex._height`.
Vortex.SpawnShared( WorldPosition, PullRadius,
(move.HitAt - move.PullFrom) * move.Seconds );
// ⛔ AND THE PULL IS FELT ON EACH MACHINE BY ITS OWN PLAYER (`BossPull`): the host dragging everyone from here moved
// its own player and nobody else's — a body another machine simulates is written back within a frame
NZNet.OberonPull( WorldPosition, PullRadius, PullSpeed, move.PullFrom * move.Seconds,
(move.HitAt - move.PullFrom) * move.Seconds );
}
// ⛔ THE PULSE'S WARNING: its reach on the floor, brightening as it charges, a ring closing in on him — every machine
// draws its own
if ( move.Kind == Kind.Pulse )
{
NZSound.PlayShared( "nz.oberon.close", WorldPosition );
NZNet.OberonPulseCharge( WorldPosition, PulseRadius, move.HitAt * move.Seconds );
}
}
// ── running one ──────────────────────────────────────────────────────────
void TickMove()
{
var m = _move;
var cycle = m.Seconds <= 0f ? 1f : (Time.Now - _startedAt) / m.Seconds;
if ( m.Kind == Kind.Leap && !_launched && cycle >= m.LaunchAt )
{
_launched = true;
// ⛔ THE ARC RUNS FROM THE LAUNCH CUE TO THE LANDING HIT, not for the whole clip — the
// authored cycles are 16/120 and 63/120. He is in the air for that window and stood on
// the floor for the wind-up and the recovery, which is what the animation draws.
//
// ⚠️ OFF `m.Seconds`, WHICH IS THE CLIP'S REAL LENGTH, so the arc ends on the same frame
// the landing hit fires whatever the export did to the frame rate.
var airborne = (m.HitAt - m.LaunchAt) * m.Seconds;
var to = WorldPosition;
if ( m.To is Vector3 sent )
{
to = sent;
}
else if ( _ai.Target.IsValid() )
{
var course = (_ai.Target.WorldPosition - WorldPosition).WithZ( 0f );
// ⚠️ TOWARD THE TARGET BUT NO FURTHER THAN THE AUTHORED DISTANCE, and never past
// them: a leap that overshoots puts a boss the size of a house on the far side of
// the person it was jumping at.
var reach = MathF.Min( course.Length, LeapDistance * BodyScale );
to = WorldPosition + course.Normal * reach;
}
else
{
to = WorldPosition + _ai.WorldRotation.Forward.WithZ( 0f ).Normal * LeapDistance * BodyScale;
}
var began = _ai.BeginArc( to, airborne, LeapHeight * BodyScale, snapLanding: !m.Dive );
// ⛔ THIS LINE IS THE POINT OF THE WHOLE EXERCISE. "It stays on the ground" has at least
// four causes that are identical from outside — the cue never fired, `BeginArc` refused,
// the arc ran for no time, or the landing snapped to where he already stood — and a
// fifth that is not a leap at all: the barrage, which he performs on all fours without
// moving. Two rounds of this were spent on evidence that could not tell them apart.
Log.Info( $"[nz-oberon] leap launch{(began ? "" : " REFUSED")}"
+ $" · airborne {airborne:0.00}s · lift {LeapHeight:0}"
+ $" · travel {WorldPosition.Distance( to ):0}u" );
if ( !NZGame.IsClient ) NZNet.ShakeAt( WorldPosition, 0.3f, 1800f );
}
// ⛔ THE LEAN IS DRIVEN EVERY FRAME, NOT SET ONCE. It is a ramp read off the cycle, and
// `FaceMovement` rebuilds the rotation from it each frame — so a single write at the wave
// cue would be overwritten before it was ever drawn.
if ( m.Kind == Kind.Bomb )
_ai.LeanPitch = BombTilt * TiltAt( cycle, m.Seconds );
if ( m.Kind == Kind.Pulse )
{
if ( !_hit && cycle >= m.HitAt )
{
_hit = true;
FirePulse();
}
}
// ⛔ A DIVE ENDS A MOMENT BEFORE ITS ARC DOES, UNDER THE LAVA: gone before the arc hands him back to an agent that would
// set him on the navmesh again (`EndDive`)
else if ( m.Dive )
{
if ( !_hit && cycle >= m.HitAt - 0.03f )
{
_hit = true;
EndDive();
return;
}
}
else if ( m.Kind == Kind.Bomb && m.Waves is not null )
{
while ( _wave < m.Waves.Length && cycle >= m.Waves[_wave] )
{
BombWave();
_wave++;
}
}
// ⚠️ `!Arcing` IS PART OF THE LEAP'S CUE, NOT A SAFETY CHECK. The cycle says when the clip
// draws the touchdown; the arc is what carries the body down, and the two are advanced by
// different components on the same frame — so on the frame the cycle crosses the cue the
// body may still be a step above the floor. For every other move `Arcing` is already false
// and this reads as the cycle alone.
else if ( !_hit && m.HitAt > 0f && cycle >= m.HitAt && !_ai.Arcing )
{
_hit = true;
Strike( m );
// ⚠️ THE THIRD CLAW GOES WITH THE KNIFE HOLE AND NOTHING ELSE — the lua hangs
// `CreateClaw3` off `knife_attack_hole` at the same cycle as the hit.
if ( Phase == 2 && m.Kind == Kind.Hole )
Fx( "ef_knife3", "knife_attack3", 1f );
}
if ( cycle >= MathX.Clamp( m.Ends, 0.1f, 1f ) )
{
Unlean();
_move = null;
_cooldown = SkillCooldown;
Status = Phase == 1 ? "idle" : "idle (knife)";
}
}
/// <summary>
/// The pulse lands: every machine sees it go out, and dazes its own player inside its reach (`NZNet.OberonPulse` →
/// <see cref="LandPulse"/>) — the Shrieker's daze, and no damage, as the Shrieker's scream has none. HOST.
/// </summary>
void FirePulse()
{
NZSound.PlayShared( "nz.oberon.attack", WorldPosition );
// ⚠️ NO SHAKE OF ITS OWN HERE: `NZNet.OberonPulse` lands on every machine, the host too, and shakes each one there
// (`LandPulse`) — this one made the host's shake twice (the co-op audit, 2026-09-27).
NZNet.OberonPulse( WorldPosition, PulseRadius, PulseDaze );
}
/// <summary>
/// Dive: his own leap, `zbs_attack3`, to exactly there — off the navmesh, with no blow where he lands: into the lava, as
/// basalt's boss fight has him at each phase change. Refused while he is doing anything else; the fight asks again. HOST.
/// </summary>
public bool Dive( Vector3 into )
{
if ( !_ai.IsValid() || _move is not null || Dived ) return false;
if ( _ai.State is not (ZombieState.Chasing or ZombieState.Idle) ) return false;
var move = Leap( "zbs_attack3" );
move.To = into;
move.Dive = true;
move.Ends = 1f;
if ( !_ai.PlaySpecial( move.Clip, move.Seconds ) ) return false;
var real = _ai.CurrentClipDuration;
if ( real > 0.05f )
{
move.Seconds = real;
_ai.HoldSpecialFor( real );
}
_move = move;
_lastKind = Kind.Leap;
_startedAt = Time.Now;
_hit = false;
_launched = false;
_wave = 0;
Status = "diving into the lava";
NZSound.PlayShared( "nz.oberon.close", WorldPosition );
Log.Info( $"[nz-oberon] dive into {into:0} — {move.Seconds:0.00}s" );
return true;
}
/// <summary>Has the dive gone under, and where: basalt's fight despawns him and splashes there.</summary>
public bool Dived { get; private set; }
public Vector3 DivedAt { get; private set; }
/// <summary>Under the lava: out of sight at once — switched off, his AI with him — for the fight to take away.</summary>
void EndDive()
{
Dived = true;
DivedAt = WorldPosition;
Unlean();
_move = null;
GameObject.Enabled = false;
Log.Info( $"[nz-oberon] under the lava at {DivedAt:0}" );
}
/// <summary>The pulse's warning, drawn here. EVERY machine — `NZNet.OberonPulseCharge`.</summary>
public static void ShowPulseCharge( Vector3 at, float radius, float seconds ) => PulseTelegraph.Fire( at, radius, seconds );
/// <summary>
/// The pulse gone off. EVERY machine — `NZNet.OberonPulse`: the ring going out, the ground's thump, and this machine's own
/// player dazed if they stand inside it — the daze is movement, and movement is the owner's (`SonicDaze`).
/// </summary>
public static void LandPulse( Vector3 at, float radius, float daze )
{
ShockRing.Fire( at, radius, PulseTelegraph.Colour );
CameraShake.Punch( at, 0.5f, radius * 3f );
var me = NZPlayer.Local;
if ( !me.IsValid() || me.IsOutOfRound || me.WorldPosition.Distance( at ) > radius ) return;
SonicDaze.Apply( me, daze );
}
/// <summary>The hole's pull, felt by this machine's own player. EVERY machine — `NZNet.OberonPull`.</summary>
public static void FeelPull( Vector3 at, float radius, float speed, float delay, float seconds )
=> BossPull.Apply( NZPlayer.Local, at, radius, speed, delay, seconds );
/// <summary>One wave of the barrage — several blasts scattered around him.</summary>
///
/// ⚠️ NOT PROJECTILES. The original spawns fifteen `proj_drg_bomb` per wave on a 0.03s stagger,
/// flung at randomised velocities; there is no ported equivalent, and inventing a projectile to
/// get a barrage is a much larger change than the barrage is. Blasts land in a ring instead —
/// same shape of threat, none of the travel time, and the clip is what sells it either way.
void BombWave()
{
NZSound.PlayShared( "nz.oberon.attack", WorldPosition );
// ⚠️ THROWN FROM HIS UPPER BODY, off `BodyHeight` — which `ApplyModelScale` already
// carries — so the shells leave his hands rather than his feet at any size.
var from = WorldPosition + Vector3.Up * (_ai.BodyHeight * 0.8f);
// ⛔ CAPTURED NOW, NOT READ AT THE LANDING. `_move` is null by the time the last wave's
// bombs arrive; see `_pending`.
var damage = _move.Damage;
for ( int i = 0; i < Math.Max( 1, BombsPerWave ); i++ )
{
var a = Game.Random.Float( 0f, MathF.Tau );
var r = Game.Random.Float( BombSpread * 0.25f, BombSpread );
// ⚠️ THE LANDING POINT IS SNAPPED TO THE FLOOR HERE, ONCE, and then sent verbatim to
// every machine. The marker ring, the crater and the damage all read this one vector,
// so there is nothing for them to disagree about.
var to = PitVisual.GroundAt(
WorldPosition + new Vector3( MathF.Cos( a ) * r, MathF.Sin( a ) * r, 0f ) );
var delay = MathF.Max( 0f, BombStagger ) * i;
// ⛔ THE ARC SCALES WITH THE THROW, OR THE FAR ONES READ AS TRACER FIRE. `BombFlight`
// and `BombApex` describe a bomb going the WHOLE distance; at 1800 units of spread a
// near one covering 450 of them on the same timing is a flat horizontal streak. Both
// are eased down for short throws so every bomb is recognisably lobbed.
var f = MathX.Clamp( to.Distance( WorldPosition )
/ MathF.Max( 1f, BombSpread ), 0f, 1f );
var flight = MathF.Max( 0.15f, BombFlight )
* MathX.Lerp( 0.6f, 1f, f ) * Game.Random.Float( 0.85f, 1.15f );
var apex = BombApex * MathX.Lerp( 0.55f, 1f, f );
BombShell.ThrowShared( from, to, delay, flight, apex, BombRadius );
// ⚠️ 24 UNITS UP, the height the blast used to be centred at — a blast centred on
// the floor itself loses its line-of-sight trace to anything standing on that floor.
_pending.Add( (Time.Now + delay + flight, to + Vector3.Up * 24f, damage) );
}
if ( !NZGame.IsClient ) NZNet.ShakeAt( WorldPosition, 0.35f, 2200f );
}
/// <summary>
/// How far through the lean he is at a given cycle: 0 upright, 1 fully down.
/// </summary>
///
/// ⚠️ TWO SMOOTHSTEPS SUBTRACTED, not one ramp with a hold. Down minus up gives the hold
/// for free and cannot produce a value outside 0..1, which a hand-written three-phase ramp
/// does the first time the two cycles are set the wrong way round.
float TiltAt( float cycle, float seconds )
{
const float ramp = 0.08f;
static float Smooth( float x )
{
x = MathX.Clamp( x, 0f, 1f );
return x * x * (3f - 2f * x);
}
// ⚠️ THE LEAD IS SECONDS, SO IT HAS TO BE DIVIDED BY THE CLIP'S REAL LENGTH TO BECOME A
// CYCLE. At the bomb's actual 6.96s, one second is 0.144 of it — so the lean starts at
// cycle 0.026 and is fully down by 0.11, about three quarters of a second in.
//
// ⚠️ A LEAD LONG ENOUGH TO PUSH THE START BELOW ZERO IS HARMLESS: `Smooth` clamps, so he
// simply begins the move already leaning rather than popping.
var lead = seconds > 0.01f ? MathF.Max( 0f, BombTiltLead ) / seconds : 0f;
return Smooth( (cycle - (BombTiltDown - lead)) / ramp )
- Smooth( (cycle - BombTiltUp) / ramp );
}
/// <summary>
/// Put him upright, now, rather than next frame.
/// </summary>
///
/// ⛔ THE ROTATION IS WRITTEN HERE AND NOT LEFT TO `FaceMovement`. That method returns early
/// when there is nothing to face — the target died during the barrage is the ordinary case —
/// and a lean cleared without also fixing the transform would leave him lying on his face for
/// the rest of the round.
void Unlean()
{
if ( !_ai.IsValid() || _ai.LeanPitch == 0f ) return;
_ai.LeanPitch = 0f;
_ai.WorldRotation = Rotation.FromYaw( _ai.WorldRotation.Angles().yaw ) * _ai.ModelTurn;
}
/// <summary>Blast anything standing where a bomb has just landed.</summary>
///
/// ⚠️ BACKWARDS, so removing the one that fired does not skip the next.
/// <summary>
/// Which special to run: weighted, never the barrage twice, never a leap into someone's face.
/// </summary>
///
/// ⚠️ A WEIGHT OF ZERO IS A REAL OUTCOME HERE, and two of the three can hit it at once —
/// a barrage that just ran, at point-blank range. The total is checked rather than assumed,
/// and nothing left is -1: no special. It fell back to the hole, which basalt's boss fight gives
/// him only in its last phase.
int RollKind( GameObject target )
{
var leap = MathF.Max( 0f, LeapWeight );
var hole = MathF.Max( 0f, HoleWeight );
var bomb = MathF.Max( 0f, BombWeight );
var pulse = MathF.Max( 0f, PulseWeight );
// ⛔ NEVER TWICE RUNNING. Seven seconds of standing still throwing bombs is the longest
// thing he does, and back to back it is fifteen seconds in which he never closes.
if ( _lastKind == Kind.Bomb ) bomb = 0f;
// ⛔ AND NEVER AT SOMEBODY ALREADY ON TOP OF HIM. See `LeapMinRange`.
if ( target.IsValid()
&& WorldPosition.Distance( target.WorldPosition ) < LeapMinRange ) leap = 0f;
// ⛔ AND NO PULSE AT SOMEBODY OUT OF ITS REACH: a daze that lands on nobody is a second and a half of standing still
if ( target.IsValid()
&& WorldPosition.Distance( target.WorldPosition ) > PulseRadius ) pulse = 0f;
var total = leap + hole + bomb + pulse;
if ( total <= 0f ) return -1;
var roll = Game.Random.Float( 0f, total );
if ( roll < leap ) return (int)Kind.Leap;
if ( roll < leap + hole ) return (int)Kind.Hole;
if ( roll < leap + hole + bomb ) return (int)Kind.Bomb;
return (int)Kind.Pulse;
}
void TickPending()
{
for ( var i = _pending.Count - 1; i >= 0; i-- )
{
if ( Time.Now < _pending[i].At ) continue;
Blast( _pending[i].Where, BombRadius, _pending[i].Damage );
_pending.RemoveAt( i );
}
}
/// <summary>The landing hit of a leap, or the hole going off.</summary>
void Strike( Move m )
{
var at = WorldPosition + Vector3.Up * 32f;
NZSound.PlayShared( "nz.oberon.attack", WorldPosition );
Blast( at, m.Radius, m.Damage );
// The original shakes at 125 over 1000 units for both; the hole reaches further, so it
// shakes further.
if ( !NZGame.IsClient ) NZNet.ShakeAt( WorldPosition, 0.6f, m.Kind == Kind.Hole ? 3000f : 2200f );
// ⛔ THE LEAP ONLY. This fires at the landing cue for the hole as well, and a hole that
// PULLS everything inward while throwing a wave outward reads as the opposite of itself.
//
// ⚠️ DRAWN AT THE DAMAGE RADIUS, so what you see is what was hit — `ShockRing`'s own
// reason for existing. ⚠️ AT HIS FEET, not the +32 the blast is centred on: the ring
// snaps itself to the floor and a centre inside his chest only makes the trace longer.
if ( m.Kind == Kind.Leap )
ShockRing.FireShared( WorldPosition, m.Radius * LandingRingScale, LandingRing );
}
/// <summary>
/// Damage everything of ours inside a radius, with line of sight and linear falloff.
/// </summary>
///
/// ⚠️ THE SAME SHAPE AS THE NAPALM ZOMBIE'S BLAST, INCLUDING THE 24 UNITS OF TRACE SLACK. A ray
/// aimed at a player's centre stops on their CAPSULE about 16 units short, so the obvious
/// "did the ray arrive" test throws the hit away against a target standing in the open.
void Blast( Vector3 at, float radius, float weight )
{
var scene = Scene;
if ( !scene.IsValid() || radius <= 0f ) return;
int round = RoundManager.Instance?.Round ?? 1;
if ( round < 1 ) round = 1;
// ⚠️ AND THE MATCH'S ZOMBIE DAMAGE (the lobby's Difficulty, 2026-10-05), under `MaxHit` as ever
var damage = ZombieStats.AttackDamageForRound( round )
* (_ai.Variant?.DamageMultiplier ?? 1f)
* weight
* (Phase == 2 ? PhaseTwoDamageScale : 1f)
* 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 ).IgnoreGameObject( GameObject ).Run();
if ( tr.Hit && tr.HitPosition.Distance( to ) > 24f ) continue;
var falloff = 1f - MathX.Clamp( dist / MathF.Max( radius, 1f ), 0f, 1f );
// ⛔ NEVER MORE THAN `MaxHit`: no blast of his takes a player down from full health (`HitCap`).
//
// ⛔ AND WITH HIS NAME ON IT, AS AN AREA HIT (`Health.Apply`'s `blast`): armor spends on it as on his swipe — *"armor
// needs to react to the other attacks too"* (2026-09-27) — and Victorious Tortoise judges it by where it went off
// (`blastAt`, *"fix that too"*), while what answers a claw does not fire, so Retaliate cannot stun him with his own
// bombs. Through `OnDamage` it came with no attacker, and armor, which answers only an enemy's hit, let it through.
hp.Apply( MathF.Min( damage * falloff, MaxHit ), false, GameObject, blast: true, blastAt: at );
}
}
/// <summary>
/// `nz_oberon_hitcap [share]` — the most one hit of his takes, as a share of a player's base health (0.1-0.95), and what
/// each attack does at this round under it: the swipe, and the leap, the black hole and one bomb at their middles — less
/// further out. Bare, it reports. A share set here holds for the Oberons in the scene; a new one starts at two-thirds.
/// ⚠️ It replaces `nz_oberon_swipe`, whose one job — to make his swipe kill outright, or not — is gone.
/// </summary>
[ConCmd( "nz_oberon_hitcap" )]
public static void HitCapCmd( float share = -1f )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }
var all = scene.GetAllComponents<OberonBoss>().ToList();
if ( share > 0f )
foreach ( var o in all ) o.HitCap = Math.Clamp( share, 0.1f, 0.95f );
var b = all.FirstOrDefault();
var baseHealth = ActiveConfig.Player.MaxHealth;
var cap = b is not null ? b.MaxHit : baseHealth * DefaultHitCap;
Log.Info( $"[nz-oberon] the most one hit of his takes: {cap:0} — {(b?.HitCap ?? DefaultHitCap) * 100f:0}% of a player's"
+ $" {baseHealth:0} base health, so none downs anyone from full" );
if ( b is null || !b._ai.IsValid() ) { Log.Info( "[nz-oberon] none in the scene for the rest — nz_spawn oberon" ); return; }
int round = Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
var swing = ZombieStats.AttackDamageForRound( round ) * (b._ai.Variant?.DamageMultiplier ?? 1f);
string Hit( float weight ) => $"{MathF.Min( swing * weight, cap ):0}";
var trigger = b._ai.AttackRange + b._ai.AttackRangePadding;
Log.Info( $"[nz-oberon] round {round}: swipe {Hit( 1f )} · leap {Hit( b.LeapDamage )} · black hole {Hit( b.HoleDamage )}"
+ $" · bomb {Hit( b.BombDamage )} — the three at their middles, less further out · the pulse none" );
Log.Info( $"[nz-oberon] his swipe starts within {trigger:0}u and lands within {trigger * b._ai.ScaledAttackReach:0}u" );
}
/// <summary>
/// `nz_oberon_odds [leap] [hole] [bomb] [cooldown]` — how often he picks each special.
/// </summary>
///
/// ⚠️ IT PRINTS THE RESULTING SECONDS-PER-LEAP, which is the thing being tuned and is not
/// readable off the weights: the clips are 5 to 7.5 seconds long, so they dominate the cadence
/// far more than the odds do.
[ConCmd( "nz_oberon_odds" )]
public static void OddsCmd( float leap = -1f, float hole = -1f,
float bomb = -1f, float cooldown = -1f )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }
var all = scene.GetAllComponents<OberonBoss>().ToList();
if ( all.Count == 0 ) { Log.Info( "[nz-oberon] none in the scene" ); return; }
foreach ( var o in all )
{
if ( leap >= 0f ) o.LeapWeight = leap;
if ( hole >= 0f ) o.HoleWeight = hole;
if ( bomb >= 0f ) o.BombWeight = bomb;
if ( cooldown >= 0f ) o.SkillCooldown = cooldown;
}
var b = all[0];
var total = MathF.Max( 0.001f, b.LeapWeight + b.HoleWeight + b.BombWeight );
Log.Info( $"[nz-oberon] odds leap {b.LeapWeight / total * 100f:0}%"
+ $" · hole {b.HoleWeight / total * 100f:0}%"
+ $" · bomb {b.BombWeight / total * 100f:0}%"
+ $" · cooldown {b.SkillCooldown:0.##}s" );
// ⚠️ HOW LONG EACH ONE HOLDS HIM, WHICH FOR THE LEAP IS NOT ITS CLIP LENGTH: 5.04s of
// animation cut to 0.78 by `Move.Ends`. Bomb 6.96 and hole 7.54 run whole.
var avg = (b.LeapWeight * 3.93f + b.HoleWeight * 7.54f + b.BombWeight * 6.96f) / total
+ b.SkillCooldown;
Log.Info( $"[nz-oberon] a move every {avg:0.#}s ·"
+ $" a leap every {avg / MathF.Max( 0.01f, b.LeapWeight / total ):0.#}s" );
}
/// <summary>
/// `nz_oberon_barrage [count] [spread] [radius] [damage]` — retune the whole attack.
/// </summary>
///
/// ⚠️ FOUR NUMBERS IN ONE COMMAND BECAUSE THEY ARE ONE DECISION. Raising the count without
/// the spread packs the same floor tighter; raising the spread without the crater leaves gaps
/// wide enough to stand in. Anything omitted is left alone.
///
/// ⚠️ DAMAGE IS A WEIGHT, NOT A NUMBER OF HIT POINTS. It multiplies the round's attack
/// damage and the variant's ×3, so the printed figure is what a bomb is worth in rounds, not
/// what it takes off.
[ConCmd( "nz_oberon_barrage" )]
public static void BarrageCmd( float count = -1f, float spread = -1f,
float radius = -1f, float damage = -1f )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }
var all = scene.GetAllComponents<OberonBoss>().ToList();
if ( all.Count == 0 ) { Log.Info( "[nz-oberon] none in the scene" ); return; }
foreach ( var o in all )
{
if ( count > 0f ) o.BombsPerWave = (int)count;
if ( spread > 0f ) o.BombSpread = spread;
if ( radius > 0f ) o.BombRadius = radius;
if ( damage > 0f ) o.BombDamage = damage;
}
var b = all[0];
Log.Info( $"[nz-oberon] barrage {b.BombsPerWave} × 3 waves"
+ $" · spread {b.BombSpread:0}u · crater {b.BombRadius:0}u"
+ $" · weight {b.BombDamage:0.##}" );
// ⚠️ THE COVERAGE IS PRINTED BECAUSE IT IS THE THING BEING TUNED and it is not obvious
// from the four inputs. Over 1 rather than capped: above 1 the craters overlap, which is
// the point at which the floor stops having safe gaps in it.
var area = MathF.PI * b.BombSpread * b.BombSpread;
var cover = b.BombsPerWave * MathF.PI * b.BombRadius * b.BombRadius / MathF.Max( 1f, area );
Log.Info( $"[nz-oberon] one wave covers {cover * 100f:0}% of the disc"
+ $" · {b.BombDamage * (b._ai.Variant?.DamageMultiplier ?? 1f):0.##}×"
+ " a round's attack per bomb" );
}
/// <summary>
/// `nz_oberon_tilt [degrees] [lead]` — how far he leans to throw the barrage.
/// </summary>
///
/// ⚠️ IT TAKES EFFECT MID-THROW. The lean is recomputed from the cycle every frame, so a
/// value set while the barrage is running is visible on the next one — which is the only
/// practical way to judge an angle on an eleven-second attack.
///
/// ⚠️ WITH NO ARGUMENT IT ONLY REPORTS. Negative values lean him backwards, which is worth
/// trying once if forward turns out to be the wrong sign on this rig.
[ConCmd( "nz_oberon_tilt" )]
public static void TiltCmd( float degrees = float.NaN, float lead = float.NaN )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }
var all = scene.GetAllComponents<OberonBoss>().ToList();
if ( all.Count == 0 ) { Log.Info( "[nz-oberon] none in the scene" ); return; }
foreach ( var o in all )
{
if ( !float.IsNaN( degrees ) ) o.BombTilt = degrees;
if ( !float.IsNaN( lead ) ) o.BombTiltLead = lead;
}
foreach ( var o in all )
Log.Info( $"[nz-oberon] barrage lean {o.BombTilt:0.#}°"
+ $" · {o.BombTiltLead:0.##}s early"
+ $" · down {o.BombTiltDown:0.##} up {o.BombTiltUp:0.##} (cycles)"
+ $" · now {(o._ai.IsValid() ? o._ai.LeanPitch : 0f):0.#}°" );
}
/// <summary>
/// `nz_oberon_ring [r] [g] [b]` — recolour the landing shockwave, and fire one to look at.
/// </summary>
///
/// ⚠️ IT FIRES A PREVIEW BECAUSE COLOUR CANNOT BE JUDGED FROM A NUMBER, and the real trigger
/// is the back half of a five-second leap that has to be rolled for first. Values are 0–1.
///
/// ⚠️ WITH NO ARGUMENTS IT ONLY REPORTS AND PREVIEWS, so it is safe to type to see what is
/// currently set rather than only to change it.
[ConCmd( "nz_oberon_ring" )]
public static void RingCmd( float r = -1f, float g = 0f, float b = 0f )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }
var all = scene.GetAllComponents<OberonBoss>().ToList();
var colour = all.Count > 0 ? all[0].LandingRing : DefaultRing;
if ( r >= 0f )
{
colour = new Color( r, g, b );
foreach ( var o in all ) o.LandingRing = colour;
}
Log.Info( $"[nz-oberon] landing ring {colour.r:0.##},{colour.g:0.##},{colour.b:0.##}"
+ $" on {all.Count} boss(es){(r >= 0f ? " — set" : "")}" );
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Info( "[nz-oberon] no local player to preview at" ); return; }
// ⚠️ `Fire`, NOT `FireShared`. A preview is for the person typing the command; announcing
// it would put a ring under everybody else's feet for no reason.
ShockRing.Fire( p.WorldPosition, 600f, colour );
Log.Info( "[nz-oberon] preview ring at your feet" );
}
/// <summary>
/// `nz_oberon_move leap|hole|bomb|pulse` — force the next special instead of waiting on the roll.
/// </summary>
///
/// ⚠️ IT CLEARS THE COOLDOWN TOO, so the forced move starts on the next opportunity rather than
/// up to three seconds later. It does NOT interrupt one already running: stealing a special
/// halfway through leaves its effects half-applied, and the thing being tested is the whole move.
[ConCmd( "nz_oberon_move" )]
public static void ForceMove( string which )
{
if ( !Enum.TryParse<Kind>( which, ignoreCase: true, out var kind ) )
{
Log.Warning( $"[nz-oberon] '{which}' is not a move — leap, hole, bomb or pulse" );
return;
}
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }
var all = scene.GetAllComponents<OberonBoss>().ToList();
if ( all.Count == 0 ) { Log.Info( "[nz-oberon] none in the scene" ); return; }
foreach ( var o in all )
{
o._forced = kind;
o._cooldown = 0f;
}
Log.Info( $"[nz-oberon] next move forced to {kind} on {all.Count}" );
}
/// <summary>
/// `nz_oberon_pulse [radius] [daze] [charge] [clip]` — the pulse's reach, its daze and its charge, in units and seconds, and
/// what he plays for it; 0 or "" leaves one alone. It fires none — `nz_oberon_move pulse` does.
/// </summary>
[ConCmd( "nz_oberon_pulse" )]
public static void PulseCmd( float radius = 0f, float daze = 0f, float charge = 0f, string clip = "" )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }
var all = scene.GetAllComponents<OberonBoss>().ToList();
if ( all.Count == 0 ) { Log.Info( "[nz-oberon] none in the scene" ); return; }
foreach ( var o in all )
{
if ( radius > 0f ) o.PulseRadius = radius;
if ( daze > 0f ) o.PulseDaze = daze;
if ( charge > 0f ) o.PulseCharge = charge;
if ( !string.IsNullOrWhiteSpace( clip ) ) o.PulseClip = clip.Trim();
}
var b = all[0];
Log.Info( $"[nz-oberon] pulse {b.PulseRadius:0}u · daze {b.PulseDaze:0.#}s · charge {b.PulseCharge:0.##}s"
+ $" · recover {b.PulseRecover:0.##}s · clip '{b.PulseClip}' · weight {b.PulseWeight:0.##}" );
}
/// <summary>
/// `nz_oberon_clip <clip> [seconds]` — play one of his clips on every Oberon, held this long, to look at it: for
/// choosing the pulse's. Refused while he is in a move of his own.
/// </summary>
[ConCmd( "nz_oberon_clip" )]
public static void ClipCmd( string clip = "", float seconds = 3f )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }
if ( string.IsNullOrWhiteSpace( clip ) ) { Log.Warning( "[nz-oberon] nz_oberon_clip <clip> [seconds] — zbs_idle1, scene_appear, zbs_attack2 …" ); return; }
foreach ( var o in scene.GetAllComponents<OberonBoss>().ToList() )
{
var played = o._move is null && o._ai.IsValid() && o._ai.PlaySpecial( clip.Trim(), MathF.Max( 0.2f, seconds ) );
Log.Info( played
? $"[nz-oberon] playing '{clip.Trim()}' — the clip is {o._ai.CurrentClipDuration:0.00}s, held {seconds:0.#}s"
: $"[nz-oberon] '{clip.Trim()}' not played — he is busy, or has no such clip" );
}
}
/// <summary>
/// `nz_oberon` — what every Oberon in the scene is doing.
/// </summary>
///
/// ⚠️ IT REPORTS RATHER THAN SPAWNS. `nz_boss_spawn oberon` already exists; a second command
/// that also spawned one would be two answers to "how do I get a boss".
[ConCmd( "nz_oberon" )]
public static void Report()
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }
var all = scene.GetAllComponents<OberonBoss>().ToList();
if ( all.Count == 0 ) { Log.Info( "[nz-oberon] none in the scene" ); return; }
foreach ( var o in all )
{
var hp = o.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
Log.Info( $"[nz-oberon] phase {o.Phase} · {o.Status}"
+ $" · {(hp.IsValid() ? $"{hp.Current:0}/{hp.Max:0} hp" : "no health")}"
+ $" · cooldown {(float)o._cooldown:0.0}s" );
}
}
}