Static PrismaChain utility for the NZombies game that implements the Prisma weapons resonance chain mechanic. It applies and propagates a timed "resonance" status to zombies, handles bursts on death that re-infect nearby zombies with the remaining time and carried damage, exposes tuning properties and console commands for debugging and testing.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// The Prisma's resonance chain — one ten-second fuse, handed from corpse to corpse.
///
/// A round does its damage and leaves the `resonance` status behind. If the victim dies while it
/// is still burning, the body bursts and **whatever is left of the ten seconds** jumps to every
/// zombie nearby. Those carry the same remainder, and pass on whatever is left of it when they
/// die. The chain ends when the clock does, not when it runs out of victims.
///
/// ⛔ ONE DEADLINE, NOT A DURATION PER ZOMBIE, AND EVERYTHING DEPENDS ON THAT. If each spread
/// handed on a fresh ten seconds the chain would be unkillable: one shot into a horde would
/// re-seed itself faster than it expired and never stop. What is passed on is the REMAINDER, so
/// the whole lineage shares a single wall-clock budget — the design as asked for: *"so over these
/// 10 seconds when a zombie dies with this effect it spreads it in a radius until the 10 seconds
/// are gone"*.
///
/// ⚠️ THE REMAINDER IS NOT DIVIDED AMONG THE NEW VICTIMS. Each one gets the full remaining time.
/// That is what makes it a wonder weapon rather than a damage-over-time round: the chain grows
/// in WIDTH while shrinking in TIME, so a shot into a crowd is spectacular and a shot into an
/// empty room is one dead zombie.
///
/// ⚠️ AND IT CANNOT RUN AWAY. Every link shares the deadline, so the worst case is bounded by ten
/// seconds of spreading no matter how many zombies are in the room — there is no arrangement of
/// bodies that extends it.
///
/// ⚠️ RE-INFECTION KEEPS THE LONGER FUSE, which falls out of `StatusEffects` refreshing with a
/// `MathF.Max` on `Until`. Two chains crossing cannot shorten each other, and a zombie caught by
/// the same burst twice is not counted twice.
/// </summary>
public static class PrismaChain
{
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does
// not re-run. INSTRUCTIONS.md §1.
static string _weapon;
/// <summary>Which weapon carries the chain, by `ClassName`.</summary>
public static string Weapon { get => _weapon ?? "nz_prisma"; set => _weapon = value; }
static float? _seconds;
/// <summary>How long one fuse lasts, from the shot that started it. 10s.</summary>
public static float Seconds { get => _seconds ?? 10f; set => _seconds = value; }
static float? _radius;
/// <summary>How far a burst reaches when a carrier dies. 260u.</summary>
public static float Radius { get => _radius ?? 260f; set => _radius = value; }
static int? _maxPerBurst;
/// <summary>
/// How many zombies one burst may infect. 12.
/// </summary>
///
/// ⚠️ A BUDGET, NOT A BALANCE KNOB. The chain is already bounded in time; this bounds the
/// per-frame COST of a burst going off in the middle of a forty-zombie horde, where the
/// radius query and twelve status applications all land on one tick.
public static int MaxPerBurst { get => _maxPerBurst ?? 12; set => _maxPerBurst = value; }
static float? _minPass;
/// <summary>
/// Below this much time left, a death does not bother bursting. 0.35s.
/// </summary>
///
/// ⛔ WITHOUT IT THE CHAIN ENDS IN A FLURRY OF EMPTY EXPLOSIONS. The last fraction of a second
/// infects zombies that die of nothing, each throwing its own burst — a lot of noise and light
/// for damage too small to kill anything.
public static float MinPass { get => _minPass ?? 0.35f; set => _minPass = value; }
static float? _specialEvery;
/// <summary>
/// How often the fuse ticks on a variant with a `ResonanceShare` — the napalm, the shrieker,
/// Brutus and Oberon. 1s.
/// </summary>
///
/// ⚠️ THE SHARE IS DATA, THE CLOCK IS NOT. How hard each special burns is its `.zvar`'s business
/// (`ZombieVariant.ResonanceShare`, 10% on all four); how often is the weapon's, and "once per
/// second" was the ask.
public static float SpecialEvery { get => _specialEvery ?? 1f; set => _specialEvery = value; }
/// <summary>The burst's colour — whatever blue the weapon's effects are drawn in.</summary>
///
/// ⛔ IT DEFERS TO `PrismaFx.Tint` RATHER THAN HOLDING ITS OWN COPY. It used to carry the same
/// measured figure written out a second time, which meant retuning the blue moved the muzzle
/// and the tracer and left every chain burst the old colour — a difference you only notice in
/// the one situation the weapon exists for, a burst going off in a crowd.
///
/// ⚠️ SO `nz_prisma_fx_blue` MOVES THIS TOO, which is the point: one weapon, one blue.
public static Color Colour => PrismaFx.Tint;
// ══ the fuse ═════════════════════════════════════════════════════════════
/// <summary>
/// A round from the chain weapon hit a zombie. Light the fuse.
/// </summary>
///
/// ⚠️ IT DOES NOT ADD DAMAGE. The shot's own damage has already been applied by the time this
/// runs; the status is the whole of what this contributes, and the weapon's punch is tuned on
/// the weapon where the rest of its numbers live.
public static void OnHit( in TechEffects.TechRef tech, GameObject zombie, float damage )
{
if ( !zombie.IsValid() ) return;
if ( !Fired( tech ) ) return;
Infect( zombie, tech.Player?.GameObject, MathF.Max( 0.1f, Seconds ), damage );
}
/// <summary>
/// Light, or refresh, the fuse on one zombie — and carry the weapon's damage with it.
/// </summary>
///
/// ⛔ THE DAMAGE TRAVELS WITH THE FUSE, NOT JUST THE CLOCK. A special caught by a burst was never
/// shot, so "10% of the weapon's damage" has to be the damage of the shot that STARTED the chain.
/// Every link carries it forward, the way it carries what is left of the ten seconds.
///
/// ⚠️ A VARIANT WITH A `ResonanceShare` TICKS THAT SHARE OF IT ONCE A SECOND instead of the rule's
/// 9% of max health every quarter second — asked for as *"the wonder weapon DoT should not affect
/// napalms, shriekers, brutus and oberon the same way, instead it deals 10% of the weapon's damage
/// once per second"*. Everything else about the fuse is the same: it glows, it spreads, it bursts.
///
/// ⚠️ NO DAMAGE KNOWN MEANS NO TICK ON A SPECIAL, not the proportional one. Falling back to 9% of
/// max health is exactly the behaviour this replaces; zero is the safe way to be wrong.
static void Infect( GameObject zombie, GameObject source, float seconds, float damage )
{
var ai = zombie.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
var share = ai?.Variant?.ResonanceShare;
// ⛔ BASALT'S BEAST IN ITS FIGHT TAKES NOTHING FROM THE FUSE ITSELF — *"the 10s debuff deals no damage to it, however
// while it has that debuff it takes full damage"*: the fuse burns on him as on anything, glowing and spreading, and
// ticks nothing; what it does is let every other hit land whole (`HexPlatforms.BossCap`)
if ( HexPlatforms.IsFightBoss( ai ) ) share = 0f;
// ⛔ AND ANY OBERON, IN THE FIGHT OR OUT OF IT (2026-10-07, the user: *"make it so the prisma is unable to damage the boss oberon, only applying the debuff that does not damage but allows it to get more damaged"*): the fuse burns on him and
// deals him nothing (`Spares`)
if ( ai.IsValid() && Spares( ai.GameObject ) ) share = 0f;
if ( share is float s )
StatusEffects.Apply( zombie, "resonance", source, seconds: seconds,
tickDamage: MathF.Max( 0f, s ) * MathF.Max( 0f, damage ),
tickEvery: MathF.Max( 0.05f, SpecialEvery ), carry: damage );
else
StatusEffects.Apply( zombie, "resonance", source, seconds: seconds, carry: damage );
}
/// <summary>
/// A zombie died. If it was carrying a fuse with time on it, burst and hand that time on.
/// </summary>
///
/// ⛔ CALLED FROM `ZombieAI.Die`, BEFORE THE STATUS IS TORN DOWN. Reading the remainder after
/// the component has been cleaned up gives zero, and the chain would stop at the first link
/// while looking exactly like a chain that was working.
///
/// ⚠️ THE VICTIM IS EXCLUDED BY NAME, not by "it is dying" — a corpse still answers
/// `IsValid` on the frame it dies, and re-infecting it would put a fuse on something that can
/// never burst again and silently swallow the rest of the chain.
public static void OnDied( GameObject zombie )
{
if ( !zombie.IsValid() ) return;
var left = StatusEffects.Remaining( zombie, "resonance" );
if ( left <= MinPass || left == float.MaxValue ) return;
// ⚠️ READ NOW, WITH THE REMAINDER: after the status is torn down both answers are 0.
Burst( zombie.WorldPosition, left, zombie, StatusEffects.CarriedBy( zombie, "resonance" ) );
}
/// <summary>Throw the light and infect what is standing in it.</summary>
static void Burst( Vector3 at, float left, GameObject skip, float damage )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
// ⚠️ THE RING IS DRAWN AT THE RADIUS THAT ACTUALLY INFECTS, so what you see is what was
// caught — the same rule the leap's shockwave and the hole's vortex follow.
ShockRing.FireShared( at, Radius, Colour );
NZSound.PlayShared( "nz.pop.thunderwall.shoot", at );
var caught = ZombieAI.All
.Where( z => z.IsValid() && z.GameObject.IsValid() )
.Where( z => z.GameObject != skip )
.Where( z => z.State != ZombieState.Dead )
.Where( z => at.Distance( z.WorldPosition ) <= Radius )
.OrderBy( z => at.Distance( z.WorldPosition ) )
.Take( Math.Max( 1, MaxPerBurst ) )
.ToList();
foreach ( var z in caught )
Infect( z.GameObject, null, left, damage );
if ( ChainDebug )
Log.Info( $"[nz-prisma] burst at {at} — {left:0.00}s left, caught {caught.Count}" );
}
/// <summary>
/// Did this shot come from the chain weapon.
/// </summary>
///
/// ⛔ OFF THE TECH REF, WHICH COSTS NOTHING, AND THAT MATTERS BECAUSE THIS RUNS ON EVERY
/// BULLET IN THE GAME. `Health.OnDamage` already resolves `FiredBy( damage )` once per hit;
/// reading the prefab path off it is a string compare, where resolving the player and then
/// their weapon would be two `Components.Get` with descendant search per pellet of every
/// shotgun in the pack.
///
/// ⚠️ THE PREFAB PATH, NOT `tech.Weapon`. That field is the live component and weapons do
/// not replicate — it is null on the machine that is not holding the gun, which is exactly
/// the machine the host resolves damage on. The path is a string and travels.
public static bool Fired( in TechEffects.TechRef tech )
=> !string.IsNullOrEmpty( tech.Prefab )
&& tech.Prefab.Contains( Weapon, StringComparison.OrdinalIgnoreCase );
/// <summary>
/// Does this weapon deal the same damage wherever it lands.
/// </summary>
///
/// ⛔ IT SUPPRESSES THE GAME'S HEADSHOT BONUS AND THE PART MULTIPLIER BOTH. `HeadMultiplier`
/// and `LimbMultiplier` on the ShootInfo are already 1, but those are only the WEAPON's own
/// scaling — `Health` applies a base x2.5 for a headshot on top, plus Death Perception,
/// Deadshot, Deadeye and `t2_headshot`, none of which the prefab can switch off. A flat
/// weapon has to be flat against all of them.
///
/// ⚠️ IT REUSES THE `t4_bodyshot` PATH rather than adding a branch, which is the same move
/// `ImmuneToHeadshotBonus` made: that node already means "no headshot bonus", so the concept
/// and its consequences are established.
public static bool FlatDamage( in TechEffects.TechRef tech ) => Fired( tech );
/// <summary>
/// IS THIS ONE THE PRISMA NEVER HURTS? Oberon (2026-10-07, the user: *"make it so the prisma is unable to damage the boss oberon, only applying the debuff that does not damage but allows it to get more damaged"*).
/// Its round lights the fuse on him and deals him nothing — no hit, no ammo mod, no Napalm ignite off it, no fuse tick
/// (`Health.OnDamage`, `Infect`) — and the fuse is what lets every OTHER gun's hit land whole in basalt's fight
/// (`HexPlatforms.BossCap`: a tenth of a hit without it).
/// </summary>
public static bool Spares( GameObject victim )
=> victim.IsValid() && victim.Components.Get<OberonBoss>( FindMode.EverythingInSelfAndAncestors ).IsValid();
// ══ diagnostics ══════════════════════════════════════════════════════════
static bool? _debug;
/// <summary>`nz_prisma_debug 1` — one line per burst.</summary>
public static bool ChainDebug { get => _debug ?? false; set => _debug = value; }
/// <summary>`nz_prisma` — what the chain is set to, and how many fuses are burning.</summary>
[ConCmd( "nz_prisma" )]
public static void Report()
{
var lit = ZombieAI.All
.Count( z => z.IsValid() && z.GameObject.IsValid()
&& StatusEffects.Has( z.GameObject, "resonance" ) );
Log.Info( $"[nz-prisma] chain on '{Weapon}' · {Seconds:0.#}s fuse"
+ $" · {Radius:0}u burst · up to {MaxPerBurst} per burst"
+ $" · stops under {MinPass:0.##}s" );
Log.Info( $"[nz-prisma] {lit} zombie(s) burning right now" );
// ⚠️ READ OFF THE LOADED VARIANTS, not restated here: the share is each `.zvar`'s own.
var specials = SpecialEnemies.Names
.Select( n => (n, v: SpecialEnemies.VariantFor( n )) )
.Where( x => x.v?.ResonanceShare is float )
.Select( x => $"{x.n} {x.v.ResonanceShare.Value * 100f:0.#}%" )
.ToArray();
Log.Info( specials.Length == 0
? "[nz-prisma] no variant has a ResonanceShare — every zombie burns the ordinary fuse"
: $"[nz-prisma] on {string.Join( " · ", specials )} of the weapon's damage"
+ $" every {SpecialEvery:0.##}s instead" );
}
/// <summary>
/// `nz_prisma_set <key> <value>` — retune the chain.
/// </summary>
[ConCmd( "nz_prisma_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "seconds": Seconds = value; break;
case "radius": Radius = value; break;
case "maxperburst": MaxPerBurst = (int)value; break;
case "minpass": MinPass = value; break;
case "specialevery": SpecialEvery = value; break;
case "debug": ChainDebug = value > 0.5f; break;
default:
Log.Info( "[nz-prisma] nz_prisma_set <seconds|radius|maxperburst|minpass|specialevery|debug> <v>" );
return;
}
Log.Info( $"[nz-prisma] {key} = {value:0.###}" );
Report();
}
/// <summary>
/// `nz_prisma_test [seconds]` — light a fuse on the nearest zombie, without firing.
/// </summary>
///
/// ⚠️ IT EXISTS BECAUSE THE CHAIN IS ONLY INTERESTING IN A CROWD, and getting a crowd, a full
/// magazine and a clear view at once is most of the work of testing it.
///
/// ⚠️ `damage` IS WHAT A SPECIAL'S TICK IS A SHARE OF — 4000 is the unpacked Prisma's figure;
/// pass a packed one (10800 for MK1) to see a packed burn on a Brutus.
[ConCmd( "nz_prisma_test" )]
public static void TestCmd( float seconds = -1f, float damage = 4000f )
{
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz-prisma] no player" ); return; }
var z = ZombieAI.All
.Where( q => q.IsValid() && q.GameObject.IsValid() && q.State != ZombieState.Dead )
.OrderBy( q => p.WorldPosition.Distance( q.WorldPosition ) )
.FirstOrDefault();
if ( z is null ) { Log.Warning( "[nz-prisma] no zombie to light" ); return; }
var life = seconds > 0f ? seconds : Seconds;
Infect( z.GameObject, p.GameObject, life, damage );
Log.Info( $"[nz-prisma] lit {z.GameObject.Name} for {life:0.##}s, carrying {damage:0} damage"
+ $" at {p.WorldPosition.Distance( z.WorldPosition ):0}u" );
}
}