A game weapon effect component that implements a Gravity Well ability. It opens a visual black hole, pulls nearby zombies into a knot, optionally deals periodic damage (Spaghettify) and a final implosion damage (Collapse) based on the shooter's upgrades, and handles drawing the effect on all clients.
using Sandbox;
using System;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// Gravity Well — a black hole opens at the zombie you hit and drags every zombie near it into a knot for two seconds.
///
/// | | value |
/// |---|---|
/// | proc | **12%** per hit, **40s** cooldown (10s until the user's nerf, 2026-10-06; no tier touches it) |
/// | reach | **300u** around the zombie hit — I, Strong Pull: **450u** |
/// | pull | into a knot **40u** across the middle, in **0.7s**; held there, unable to swing, until it closes |
/// | life | **2s** — a zombie that walks in while it is open is caught too; II, Event Horizon: **4s** |
/// | damage | **none** — it sets them up for a blast. III, Collapse: **500%** of your damage to each one held, as it closes; IV, Supernova: **1000%** |
/// | while held | **nothing** — V, Spaghettify: **100%** of your damage to each one held every **0.5s** (8 times in II's 4s, 800%) |
///
/// ⚠️ THE USER (2026-10-04): *"use the oberon gravity attack, we made an asset for it with an animation, just make it smaller
/// to fit the radius and use that animation"*. So the look IS Oberon's black hole: his effect model `ef_hole` playing its
/// own `idle1` clip, and his floor vortex (`Vortex`) drawn at the reach — both scaled from his 900u pull down to this
/// 300u one. His clip is sped up to play whole inside the two seconds rather than being cut off half way through.
///
/// ⛔ THE HOST PULLS; EVERY MACHINE DRAWS. Zombies think on the host, so a client's proc is sent there
/// (`NZNet.GravityWellAsk`), and the host tells everyone to draw it (`NZNet.GravityWellFx`) — one message for the hole and
/// the vortex together. Each pulled zombie's pose reaches the watching machines through `ZombieAI.PlayPratfall`'s relay.
///
/// ⚠️ NOT EVERY ZOMBIE COMES: a boss doesn't, nor one at or in a window or on a link, nor one already down — the guards are
/// `ZombieAI.Pull`'s, the same as Shockwave's.
///
/// ⚠️ THE UPGRADES (2026-10-05, `AmmoModUpgrades`; the user, 01:14: *"great!"*). The levels are the SHOOTER'S, read on the host
/// that opens the well: its own player's, or a client's from `Rpc.CallerId` (`NZNet.GravityWellAsk`; levels sync,
/// `NZPlayer.AmmoUpgradeNet`). One helper per number (`RadiusFor`, `SecondsFor`, `CollapseFor`). The reach and the life travel
/// in `NZNet.GravityWellFx`, so every machine draws the bigger hole for longer and fits Oberon's clip to the life it is handed
/// (`GravityHoleClip`) — Event Horizon's 4s too, the doc's building note.
///
/// ⛔ COLLAPSE'S FIGURE IS THE SHOOTER'S, ITS DAMAGE THE HOST'S, as Shockwave's Seismic Slam. The weapon damage is read on the
/// shooter's machine AT THE PROC (`AmmoMods.WeaponDamage`, snapshotted as Radioactive Decay's dose is) and rides the ask; the
/// host, whose knot it is, deals 500% of it ONCE to each zombie it still holds as the well closes. A blast, not a bullet
/// (`TechBlast.BlastTag`). ⚠️ A client's carries none of their own perks' terms and shows them no numbers (`Shockwave`).
///
/// ⚠️ TIERS IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"; the user, 23:49: *"Ok that's great"*). IV, Supernova: Collapse
/// deals 1000% (500%) — `SupernovaShare`, through `CollapseFor`. V, Spaghettify: every zombie held in the knot takes 100% of your
/// damage every 0.5s for the whole hold (`SpaghettifyFor`, `Shred`) — with II's 4s, eight times, 800%. The 40s cooldown stays
/// untouched at every tier.
///
/// ⛔ SPAGHETTIFY IS COLLAPSE'S KIND: the shooter's figure, snapshotted at the proc and carried by the ask, dealt by the host whose
/// knot it is, credited to the shooter, a blast, and rolling no mod (`AmmoMods.WithoutProcs`) — eight rounds of hits on a held crowd
/// past a cut cooldown would open the next well on the knot (`Collapse`'s note). Its shreds are counted from the opening and the
/// last is paid on the closing frame, before Collapse, so a late frame owes a shred rather than skipping it.
/// </summary>
public sealed class GravityWell : Component
{
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED GETTERS — INSTRUCTIONS.md §1.
static float? _radius;
/// <summary>How far the well reaches. 300u.</summary>
public static float Radius { get => _radius ?? 300f; set => _radius = value; }
static float? _seconds;
/// <summary>How long it stays open. 2s.</summary>
public static float Seconds { get => _seconds ?? 2f; set => _seconds = value; }
static float? _knot;
/// <summary>How far from the middle a pulled zombie ends up. 40u — a tight knot, not a pile on one point.</summary>
public static float Knot { get => _knot ?? 40f; set => _knot = value; }
static float? _pullIn;
/// <summary>How long the drag into the knot takes. 0.7s, accelerating, as falling in does.</summary>
public static float PullIn { get => _pullIn ?? 0.7f; set => _pullIn = value; }
static float? _holeScale;
/// <summary>
/// A multiplier on the hole model's size. 1 — the size Oberon draws it at for his 900u pull, scaled by this well's
/// reach against his (<see cref="OberonReach"/>).
/// </summary>
public static float HoleScale { get => _holeScale ?? 1f; set => _holeScale = value; }
// ══ the upgrades (2026-10-05) ════════════════════════════════════════════
//
// ⛔ EACH UPGRADED NUMBER IS ITS OWN TUNABLE BESIDE THE BASE ONE, AND ONE HELPER PICKS BETWEEN THEM (§3). Level 0 reads
// exactly the numbers above.
static float? _strongRadius;
/// <summary>I, STRONG PULL: how far the well reaches. 450u (300).</summary>
public static float StrongRadius { get => _strongRadius ?? 450f; set => _strongRadius = value; }
static float? _horizonSeconds;
/// <summary>II, EVENT HORIZON: how long it holds the knot. 4s (2s).</summary>
public static float HorizonSeconds { get => _horizonSeconds ?? 4f; set => _horizonSeconds = value; }
static float? _collapseShare;
/// <summary>III, COLLAPSE: what each zombie held takes as the well closes, as a share of your weapon's damage. 5 — 500%.</summary>
public static float CollapseShare { get => _collapseShare ?? 5f; set => _collapseShare = value; }
static float? _collapseShake;
/// <summary>III: the implosion's tremor at the well, before distance takes it down. 0.4 trauma, Shockwave's.</summary>
public static float CollapseShake { get => _collapseShake ?? 0.4f; set => _collapseShake = value; }
static float? _collapseShakeRange;
/// <summary>III: how far away the implosion is still felt. 1500u.</summary>
public static float CollapseShakeRange { get => _collapseShakeRange ?? 1500f; set => _collapseShakeRange = value; }
// ── tiers IV and V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V") ──
//
// ⚠️ THE SAME SHAPE: each its own tunable beside the one it replaces, and the helpers below ask the HIGHEST level first.
static float? _supernovaShare;
/// <summary>IV, SUPERNOVA: what Collapse deals each zombie held, as a share of your weapon's damage. 10 — 1000% (III's 500%).</summary>
public static float SupernovaShare { get => _supernovaShare ?? 10f; set => _supernovaShare = value; }
static float? _spaghettifyShare;
/// <summary>V, SPAGHETTIFY: what each zombie held takes every <see cref="SpaghettifyEvery"/>, as a share of your weapon's damage. 1 — 100%.</summary>
public static float SpaghettifyShare { get => _spaghettifyShare ?? 1f; set => _spaghettifyShare = value; }
static float? _spaghettifyEvery;
/// <summary>V, SPAGHETTIFY: how often it shreds the knot. 0.5s — eight times in Event Horizon's 4s. Never under 0.05s.</summary>
public static float SpaghettifyEvery { get => _spaghettifyEvery ?? 0.5f; set => _spaghettifyEvery = value; }
/// <summary>The mod's id, for its upgrade levels.</summary>
const string ModId = "gravitywell";
/// <summary>III: the implosion's cue — the boom Oberon's own hole goes off with (`OberonBoss.Strike`).</summary>
const string CollapseCue = "nz.oberon.attack";
/// <summary>How far this player's well reaches: Strong Pull's reach at level I.</summary>
public static float RadiusFor( NZPlayer owner )
=> MathF.Max( 16f, AmmoModUpgrades.Has( owner, ModId, 1 ) ? StrongRadius : Radius );
/// <summary>How long this player's well stays open: Event Horizon's life at level II.</summary>
public static float SecondsFor( NZPlayer owner )
=> MathF.Max( 0.3f, AmmoModUpgrades.Has( owner, ModId, 2 ) ? HorizonSeconds : Seconds );
/// <summary>
/// What Collapse deals each zombie held, from the shooter's weapon damage: Supernova's share at level IV, Collapse's at III,
/// 0 below it.
/// </summary>
public static float CollapseFor( NZPlayer owner, float weaponDamage )
=> MathF.Max( 0f, weaponDamage )
* (AmmoModUpgrades.Has( owner, ModId, 4 ) ? MathF.Max( 0f, SupernovaShare )
: AmmoModUpgrades.Has( owner, ModId, 3 ) ? MathF.Max( 0f, CollapseShare ) : 0f);
/// <summary>What Spaghettify deals each zombie held at every shred, from the shooter's weapon damage. 0 below level V.</summary>
public static float SpaghettifyFor( NZPlayer owner, float weaponDamage )
=> AmmoModUpgrades.Has( owner, ModId, 5 ) ? MathF.Max( 0f, SpaghettifyShare ) * MathF.Max( 0f, weaponDamage ) : 0f;
/// <summary>
/// Oberon's pull reach, which his `ef_hole` is drawn against (`OberonBoss.PullRadius`'s default). The model is scaled by
/// the well's reach over this (<see cref="Radius"/>, or Strong Pull's), so the hole and its reach keep his proportions.
/// </summary>
const float OberonReach = 900f;
/// <summary>How far up his effect models sit at scale 1 (`OberonBoss.FxLift`): authored round his old origin.</summary>
const float OberonLift = 61.6f;
const string HoleModel = "models/zombies/ef_hole.vmdl";
const string HoleClip = "idle1";
/// <summary>His hole's cue — the one he plays when it opens.</summary>
const string Cue = "nz.oberon.close";
/// <summary>How often the open well looks for zombies to catch. 10 Hz.</summary>
const float TickEvery = 0.1f;
// ══ live state (the host's well) ═════════════════════════════════════════
TimeUntil _closes;
TimeUntil _nextTick;
readonly HashSet<ZombieAI> _held = new();
/// <summary>
/// This well's reach: its owner's, Strong Pull's at level I. ⚠️ NULLABLE, as a field added to a live component must be (§1):
/// a well that outlives a hotload reads the base reach, not 0.
/// </summary>
float? _reach;
/// <summary>Whose well it is: the implosion's credit.</summary>
NZPlayer _owner;
/// <summary>III: what each zombie held takes as it closes (`Collapse`). Null or 0 — below level III — for nothing.</summary>
float? _collapse;
/// <summary>
/// V: what each zombie held takes at every shred (`Shred`), and how often — the owner's, read when it opens. Null or 0 —
/// below level V — for nothing. ⚠️ NULLABLE (§1), as `_reach`: a well alive across a hotload shreds nothing.
/// </summary>
float? _spaghettify;
float? _shredEvery;
/// <summary>V: the well's whole life, how long it has been open, and the shreds dealt so far — the clock `Shred` counts by.</summary>
float? _life;
TimeSince _opened;
int _shreds;
/// <summary>V: how many hits its shreds have landed in all, for the line it closes with.</summary>
int _shredHits;
/// <summary>How many zombies this well caught.</summary>
public int Caught => _held.Count;
// ══ the effect ═══════════════════════════════════════════════════════════
/// <summary>The mod went off on <paramref name="zombie"/>. THE SHOOTER'S MACHINE: opened here on the host, asked of it otherwise.</summary>
public static void Fire( NZPlayer player, GameObject zombie )
{
if ( !player.IsValid() || !zombie.IsValid() ) return;
var at = zombie.WorldPosition;
// ⚠️ THE SHOOTER'S WEAPON DAMAGE GOES WITH IT, SNAPSHOTTED NOW (2026-10-05, Collapse): this is the one machine that can
// read it, and the implosion comes seconds later, perhaps after a swap. Whether it is used is the host's call, by the level.
var damage = AmmoMods.WeaponDamage( player );
if ( Networking.IsActive && !NZGame.IsHost )
{
NZNet.GravityWellAsk( at, damage );
return;
}
Open( at, player, damage );
}
/// <summary>Open a well at <paramref name="at"/>. THE HOST, or solo.</summary>
/// <param name="owner">
/// Whose well: the host's own player, or the client who asked (`NZNet.GravityWellAsk`). Its levels, and the implosion's credit.
/// </param>
/// <param name="damage">The shooter's weapon damage at the proc, read on its machine (`AmmoMods.WeaponDamage`): Collapse's base.</param>
public static void Open( Vector3 at, NZPlayer owner = null, float damage = 0f )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
var ground = PitVisual.GroundAt( at );
var seconds = SecondsFor( owner );
var reach = RadiusFor( owner );
// ⚠️ EVERYBODY DRAWS IT, THIS MACHINE INCLUDED — a broadcast runs here too, and solo it simply runs. At the OWNER'S reach
// and life, which travel in the message: no other machine has to read the levels.
NZNet.GravityWellFx( ground, reach, seconds );
NZSound.PlayShared( Cue, ground );
var go = scene.CreateObject();
go.Name = "nz_gravity_well";
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THE HOST'S OWN BOOKKEEPING — every machine draws its own (§39)
go.WorldPosition = ground;
var well = go.Components.Create<GravityWell>();
well._closes = seconds;
well._reach = reach;
well._owner = owner;
well._collapse = CollapseFor( owner, damage );
// ⚠️ SPAGHETTIFY (V, 2026-10-06): the same snapshot as Collapse's, and its clock starts with the well's.
well._spaghettify = SpaghettifyFor( owner, damage );
well._shredEvery = MathF.Max( 0.05f, SpaghettifyEvery );
well._life = seconds;
well._opened = 0f;
SWB.Shared.GameObjectExtensions.DestroyAsync( go, seconds + 0.1f );
well.Tick();
var level = AmmoModUpgrades.Level( owner, ModId );
Log.Info( $"[nz-ammo] GRAVITY WELL — {reach:0}u for {seconds:0.#}s · caught {well.Caught} on opening"
+ (level > 0 ? $" · level {HudTheme.ToRoman( level )}" : "")
+ (well._collapse > 0f ? $" · collapses for {(well._collapse ?? 0f):0} each" : "")
+ (well._spaghettify > 0f
? $" · shreds for {(well._spaghettify ?? 0f):0} each every {(well._shredEvery ?? 0f):0.##}s" : "") );
}
/// <summary>Catch every zombie in reach that is not held yet. HOST.</summary>
void Tick()
{
var left = (float)_closes;
// ⚠️ NOT IN ITS LAST MOMENT: a zombie caught a frame before the well shuts would be grabbed and dropped at once.
if ( left < 0.25f ) return;
var reach = MathF.Max( 16f, _reach ?? Radius );
var at = WorldPosition;
foreach ( var z in ZombieAI.All )
{
if ( !z.IsValid() || z.State == ZombieState.Dead || _held.Contains( z ) ) continue;
if ( at.Distance( z.WorldPosition ) > reach ) continue;
// ⚠️ EACH IS HELD UNTIL THE WELL CLOSES — its remaining life, not a fresh two seconds.
if ( z.Pull( at, MathF.Max( 0f, Knot ), left, MathF.Min( MathF.Max( 0.05f, PullIn ), left * 0.6f ) ) )
_held.Add( z );
}
}
protected override void OnUpdate()
{
if ( _closes )
{
// ⚠️ COLLAPSE (III) GOES OFF AS IT CLOSES, while the knot is still this well's to name — and SPAGHETTIFY'S LAST SHRED
// (V) JUST BEFORE IT: with II's 4s the eighth falls due on this very frame.
Shred( closing: true );
Collapse();
GameObject.Destroy();
return;
}
Shred();
if ( _nextTick )
{
_nextTick = TickEvery;
Tick();
}
}
/// <summary>
/// SPAGHETTIFY (V): every shred owed by now — one each `_shredEvery` since the well opened — deals `_spaghettify` to each
/// zombie it still holds, credited to its owner. HOST. <paramref name="closing"/> is the well's last frame: it pays every shred
/// the life holds (eight in 4s) and says what they came to, once — the figure is spent there, as Collapse's is.
/// </summary>
void Shred( bool closing = false )
{
var damage = _spaghettify ?? 0f;
if ( damage <= 0f ) return;
var every = MathF.Max( 0.05f, _shredEvery ?? SpaghettifyEvery );
// ⚠️ COUNTED FROM THE OPENING, NOT A TIMER RE-ARMED AS IT FIRES: that runs up to a frame late every shred, and seven frames
// late would push the eighth past the close. A late frame pays the shred it owes instead.
var all = (int)MathF.Floor( MathF.Max( 0f, _life ?? 0f ) / every + 0.001f );
var owed = closing ? all : Math.Min( all, (int)MathF.Floor( (float)_opened / every + 0.001f ) );
var rounds = owed - _shreds;
if ( rounds > 0 )
{
// ⚠️ CLAIMED BEFORE THE DAMAGE, as a cooldown is stamped before its effect: a throw inside cannot have them paid twice.
_shreds = owed;
var at = WorldPosition;
var reach = MathF.Max( 16f, _reach ?? Radius );
var hits = 0;
// ⛔ ITS HITS ROLL NOTHING, for Collapse's reason (below): eight rounds of hits on a held crowd, up to 4s after the proc.
AmmoMods.WithoutProcs( _owner, () =>
{
for ( var i = 0; i < rounds; i++ )
{
foreach ( var z in _held )
{
if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;
// ⚠️ STILL IN IT, as Collapse asks: one the game moved away (relocated as stuck) is let off.
if ( at.Distance( z.WorldPosition ) > reach ) continue;
var hp = z.Components.Get<Health>( FindMode.EverythingInSelf );
if ( !hp.IsValid() || hp.IsDead ) continue;
// ⚠️ NO WEAPON, as Collapse has none: a mod's damage must not pick up the gun's tech tree (`TechEffects.Of`).
hp.OnDamage( new SWB.Shared.DamageInfo
{
Attacker = _owner.IsValid() ? _owner.GameObject : null,
Damage = damage,
Position = z.WorldPosition + Vector3.Up * (z.BodyHeight * 0.5f),
Origin = at,
Tags = [TechBlast.BlastTag],
} );
hits++;
}
}
} );
_shredHits += hits;
}
if ( !closing ) return;
_spaghettify = null;
Log.Info( $"[nz-ammo] GRAVITY WELL — Spaghettify: {_shreds} shred(s) of {damage:0}, {_shredHits} hit(s) in all" );
}
/// <summary>
/// COLLAPSE (III): the well implodes as it closes, and each zombie it still holds takes `_collapse`, credited to its owner.
/// HOST, once: the figure is spent on the first call.
/// </summary>
void Collapse()
{
var damage = _collapse ?? 0f;
_collapse = null;
if ( damage <= 0f ) return;
var at = WorldPosition;
var reach = MathF.Max( 16f, _reach ?? Radius );
// ⚠️ THE BOOM AND THE TREMOR FIRST, on every machine, the order Thunderwall keeps: a death the damage causes cannot cancel
// them. And no outward ring, as Oberon's own hole has none: a pull throwing a wave out reads as its opposite
// (`OberonBoss.Strike`).
NZSound.PlayShared( CollapseCue, at );
NZNet.ShakeAt( at, MathF.Max( 0f, CollapseShake ), MathF.Max( 0f, CollapseShakeRange ) );
var hit = 0;
// ⛔ ITS HITS ROLL NOTHING (2026-10-05, the review): Collapse lands up to 4 s after the proc, past a cut 10 s cooldown
// (Catalyst ×0.5 with Rapid Discharge or Time Warp is 4 s), and eight held zombies at 17%+ each would all but surely open
// a new level-III well on the knot — whose own Collapse would open the next, for as long as anything lived. The host's
// own well only: a client's body on the host holds no gun (`AmmoMods.WithoutProcs`).
//
// ⚠️ STILL NEEDED AT 40 s (2026-10-06, for Spaghettify too): fully cut, 40 s is about 11 s, past the 4 s hold — but the gun in
// HAND may not be the one cooling (a second gun carrying Gravity Well keeps its own clock), and a well an Elemental Pop surge
// opened stamped nothing at all (`AmmoMods.FireExternal`).
AmmoMods.WithoutProcs( _owner, () =>
{
foreach ( var z in _held )
{
if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;
// ⚠️ STILL IN IT, not merely caught once: one the game moved away (relocated as stuck) is let off.
if ( at.Distance( z.WorldPosition ) > reach ) continue;
var hp = z.Components.Get<Health>( FindMode.EverythingInSelf );
if ( !hp.IsValid() || hp.IsDead ) continue;
// ⚠️ NO WEAPON, as Shockwave's slam has none: a mod's blast must not pick up the gun's tech tree (`TechEffects.Of`).
hp.OnDamage( new SWB.Shared.DamageInfo
{
Attacker = _owner.IsValid() ? _owner.GameObject : null,
Damage = damage,
Position = z.WorldPosition + Vector3.Up * (z.BodyHeight * 0.5f),
Origin = at,
Tags = [TechBlast.BlastTag],
} );
hit++;
}
} );
Log.Info( $"[nz-ammo] GRAVITY WELL — Collapse: {damage:0} to {hit} zombie(s) in the knot" );
}
// ══ the look — every machine ═════════════════════════════════════════════
/// <summary>
/// Draw a well on THIS machine: Oberon's hole at the middle, playing its clip, and his vortex out to the reach.
/// `NZNet.GravityWellFx` calls it everywhere.
/// </summary>
public static void Draw( Vector3 at, float radius, float seconds )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
seconds = MathF.Max( 0.3f, seconds );
// ⚠️ HIS VORTEX, IN HIS COLOURS, AT THIS REACH — the rim is exactly the line the pull tests, as on him.
Vortex.Spawn( at, radius, seconds );
var model = Model.Load( HoleModel );
if ( model is null || model.IsError )
{
if ( !_holeWarned )
{
_holeWarned = true;
Log.Warning( $"[nz-ammo] gravity well: no '{HoleModel}' — the vortex shows, the hole does not" );
}
return;
}
// ⚠️ HIS SIZE, SHRUNK TO THIS REACH — and the lift his effects need, shrunk with it (`OberonBoss.FxLift`).
var scale = radius / OberonReach * MathF.Max( 0.01f, HoleScale );
var go = scene.CreateObject();
go.Name = "nz_gravity_hole";
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN (§39)
go.WorldPosition = at + Vector3.Up * (OberonLift * scale);
go.WorldRotation = Rotation.FromYaw( Game.Random.Float( 0f, 360f ) );
go.WorldScale = scale;
var r = go.Components.Create<SkinnedModelRenderer>();
r.Model = model;
r.Sequence.Name = HoleClip;
r.Sequence.Time = 0f;
// ⚠️ THE WHOLE CLIP INSIDE THE WELL'S LIFE: his hole runs it over nearly five seconds, and cut at two it would vanish
// mid-swirl. Fitted once the renderer knows the clip's length (`GravityHoleClip`) — on the frame it is made it may not.
// Event Horizon's 4s (2026-10-05) is fitted the same way: the life arrives here in the message.
go.Components.Create<GravityHoleClip>().Life = seconds;
SWB.Shared.GameObjectExtensions.DestroyAsync( go, seconds );
_lastScale = scale;
_lastLife = seconds;
}
/// <summary>The hole's clip length as last read, for the report.</summary>
internal static float LastClip { get => _lastClip; set => _lastClip = value; }
static bool _holeWarned;
static float _lastClip, _lastScale;
/// <summary>The life the last hole here was drawn for, for the report: Event Horizon makes it the owner's, not `Seconds`.</summary>
static float? _lastLife;
// ══ diagnostics ══════════════════════════════════════════════════════════
/// <summary>`nz_gravitywell` — the resolved numbers, the upgrades' and yours, and the hole as last drawn here.</summary>
[ConCmd( "nz_gravitywell" )]
public static void Report()
{
var mod = AmmoMods.Find( ModId );
var model = Model.Load( HoleModel );
Log.Info( $"[nz-ammo] GRAVITY WELL · {(mod?.Chance ?? 0f) * 100f:0.#}% per hit, {mod?.Cooldown ?? 0f:0.#}s cooldown"
+ $" · {Radius:0}u for {Seconds:0.#}s · knot {Knot:0}u in {PullIn:0.##}s · deals NO damage below III" );
var me = NZPlayer.Local;
var level = AmmoModUpgrades.Level( me, ModId );
var weapon = AmmoMods.WeaponDamage( me );
// ⚠️ IV AND V (2026-10-06): Supernova's share, and Spaghettify's with its beat.
Log.Info( $"[nz-ammo] upgrades · I {StrongRadius:0}u · II {HorizonSeconds:0.#}s · III {CollapseShare * 100f:0}% of your"
+ $" damage to each one held, as it closes · IV {SupernovaShare * 100f:0}% · V {SpaghettifyShare * 100f:0}% to each one"
+ $" held every {SpaghettifyEvery:0.##}s" );
Log.Info( $"[nz-ammo] you: {(level == 0 ? "0" : HudTheme.ToRoman( level ))} → {RadiusFor( me ):0}u for {SecondsFor( me ):0.#}s,"
+ $" collapse {CollapseFor( me, weapon ):0}, spaghettify {SpaghettifyFor( me, weapon ):0} every {SpaghettifyEvery:0.##}s" );
Log.Info( $"[nz-ammo] hole {(model is null || model.IsError ? "MISSING" : $"ok, bounds {model.Bounds.Size}")}"
+ $" · drawn at ×{Radius / OberonReach * HoleScale:0.###} (his ×1 at {OberonReach:0}u, multiplier {HoleScale:0.##})"
+ $" · last drawn here ×{_lastScale:0.###}, its {_lastClip:0.##}s clip played in {(_lastLife ?? Seconds):0.#}s" );
}
/// <summary>
/// `nz_gravitywell_set <key> <value>` — retune one number live. IV and V (2026-10-06): `supernova`, `spaghettify` and
/// `spaghettifyevery`; a well already open keeps the numbers it opened with.
/// </summary>
[ConCmd( "nz_gravitywell_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "radius": Radius = value; break;
case "seconds": Seconds = value; break;
case "knot": Knot = value; break;
case "pullin": PullIn = value; break;
case "hole": HoleScale = value; break;
case "strong": StrongRadius = value; break;
case "horizon": HorizonSeconds = value; break;
case "collapse": CollapseShare = value; break;
case "collapseshake": CollapseShake = value; break;
case "collapserange": CollapseShakeRange = value; break;
case "supernova": SupernovaShare = value; break;
case "spaghettify": SpaghettifyShare = value; break;
case "spaghettifyevery": SpaghettifyEvery = value; break;
default:
Log.Info( "[nz-ammo] nz_gravitywell_set <radius|seconds|knot|pullin|hole|strong|horizon|collapse|collapseshake"
+ "|collapserange|supernova|spaghettify|spaghettifyevery> <value>" );
return;
}
Report();
}
}
/// <summary>
/// Plays the gravity well's hole clip whole inside the well's life: the rate is set the first frame the renderer reports the
/// clip's length, from the time that is left. A clip that never reports one plays at its own speed.
/// </summary>
public sealed class GravityHoleClip : Component
{
/// <summary>How long the hole lives, from when it was made.</summary>
public float Life { get; set; } = 2f;
float _born;
bool _done;
protected override void OnStart() => _born = Time.Now;
protected override void OnUpdate()
{
if ( _done ) return;
var r = Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelf );
if ( !r.IsValid() ) return;
var length = r.Sequence.Duration;
var age = Time.Now - _born;
if ( length <= 0.05f )
{
// ⚠️ GIVEN HALF A SECOND TO REPORT, then left at its own speed rather than asked forever.
if ( age > 0.5f ) _done = true;
return;
}
var left = MathF.Max( 0.1f, Life - age );
var played = Math.Clamp( r.Sequence.Time, 0f, length );
r.PlaybackRate = MathF.Max( 0.25f, (length - played) / left );
GravityWell.LastClip = length;
_done = true;
}
}