Static SilkShot ammo mod logic for NZombies. Decides how many nearby zombies to web when a player hits one, applies web and optional snared status, handles upgrade variants (I–V) for count, radius, duration and brood spreading on death, and provides a console command to tune values.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// Silk Shot — webs the zombie you hit and the three nearest it.
///
/// | | value |
/// |---|---|
/// | proc | **20%** per hit, **6s** cooldown |
/// | catch | the zombie hit, and the **3** nearest it within **250u**; the **6** nearest at I Wider Web, the **12** nearest within **400u** at IV Silk Storm |
/// | web | **4s**: rooted, and it cannot attack; **5s** at II Strong Silk |
/// | damage | **none**; at III Snared Prey a zombie in one of these webs takes **+50%** from everyone |
/// | V Brood | a zombie in one of these webs that dies webs the **3** nearest unwebbed zombies within **140u**, and they pass it on in turn |
///
/// ⚠️ IT IS WIDOW'S WINE'S WEB, NOT A LOOKALIKE. The `web` status already roots (`SpeedScale` 0), disarms
/// (`StatusEffects.Disarms`) and wears the strands (`WebStrands`), and it relays to every machine — so this file only
/// decides who is caught and for how long. A second web status would be a second answer to "is this zombie webbed".
///
/// ⚠️ 4s IS PASSED PER APPLICATION, NOT WRITTEN ON THE RULE. The rule's own 10s is Widow's Wine's, and its augments
/// scale it; retuning the shared rule for this mod would retune the perk. A zombie already webbed for longer keeps the
/// longer web (`StatusEffects.Add` takes the later expiry).
///
/// ⚠️ THE USER'S NUMBERS (2026-10-04): *"silk shot should have a 6s cooldown and web for 2s"*, then, after playing it,
/// *"make the duration of the webs 4s instead of 2s / however make sure the webs do disapear after the 4s"*. The strands
/// had been outliving the web: `StatusEffects.Clear` never switched them off. Fixed the same day, so they go with it.
///
/// ⚠️ ITS THREE UPGRADES (2026-10-05, `AMMO_MODS.md` "Upgrades"; the user, 2026-10-04 21:43: *"ok good, i like it, next
/// one"*): I Wider Web, the hit and the 6 nearest (`WideExtra`); II Strong Silk, 5s webs (`StrongSeconds`); III Snared Prey,
/// +50% damage from everyone on a zombie in one of THESE webs (`snared`, a companion beside each). Each is the SHOOTER'S
/// level, read in <see cref="Fire"/>.
///
/// ⚠️ TIERS IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"; the user, 22:40: *"make V / when killed, a webbed zombie webs up
/// to 3 zombies in a 140u radius"*). IV Silk Storm, the hit and the 12 nearest within 400u (`StormExtra`, `StormRadius`): the
/// SHOOTER'S level, read in <see cref="Fire"/> as I–III are. V Brood is the web OWNER'S, read on the HOST at the death
/// (<see cref="OnWebbedDeath"/>, from `AmmoModDeaths`), whoever made the kill: the 3 nearest not already webbed, within 140u
/// (`BroodCount`, `BroodRadius`), webbed exactly as the owner's own shot webs (`Web`), so each passes it on when it dies. It needs
/// a death each time, so it never runs by itself; while webbed zombies keep dying the crowd stays webbed, past the "loose between
/// webs" II keeps (`StrongSeconds`) — that is the capstone.
/// </summary>
public static class SilkShot
{
// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not re-run.
// INSTRUCTIONS.md §1.
static float? _seconds;
/// <summary>How long the web holds. 4s.</summary>
public static float Seconds { get => _seconds ?? 4f; set => _seconds = value; }
static int? _extra;
/// <summary>How many zombies besides the one hit are caught. 3.</summary>
public static int Extra { get => _extra ?? 3; set => _extra = value; }
static float? _radius;
/// <summary>
/// How far from the zombie hit the others may stand. 250u.
///
/// ⚠️ A REACH ON "THE THREE NEAREST", which the design leaves open: without one, a lone zombie in an empty room would
/// web three more on the far side of the map.
/// </summary>
public static float Radius { get => _radius ?? 250f; set => _radius = value; }
// ── the upgrades (2026-10-05) ────────────────────────────────────────────
//
// ⚠️ EACH UPGRADED VALUE IS ITS OWN TUNABLE BESIDE THE BASE ONE, and the `…For` below alone choose between them (§3).
// Snared Prey's +50% is a status rule's (`StatusEffects`, `snared`).
/// <summary>The mod's id, for its upgrade level (`AmmoModUpgrades.Level`).</summary>
const string ModId = "silkshot";
static int? _wideExtra;
/// <summary>How many zombies besides the one hit are caught with I, Wider Web. 6 (3). The reach stays <see cref="Radius"/>.</summary>
public static int WideExtra { get => _wideExtra ?? 6; set => _wideExtra = value; }
static float? _strongSeconds;
/// <summary>
/// How long the web holds with II, Strong Silk. 5s (4).
///
/// ⛔ STILL UNDER THE 6s COOLDOWN (`AMMO_MODS.md`), as Cryofreeze's freeze is under its own: a webbed crowd gets loose
/// between webs. Move one, check the other.
/// </summary>
public static float StrongSeconds { get => _strongSeconds ?? 5f; set => _strongSeconds = value; }
// ── tiers IV and V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V") ─────────
//
// ⚠️ EACH ITS OWN TUNABLE BESIDE THE ONE IT REPLACES (§3), NULLABLE-BACKED (§1). The `…For` below ask the higher level first,
// since a level-IV owner has I too.
static int? _stormExtra;
/// <summary>How many zombies besides the one hit are caught with IV, Silk Storm. 12 (I's 6).</summary>
public static int StormExtra { get => _stormExtra ?? 12; set => _stormExtra = value; }
static float? _stormRadius;
/// <summary>How far from the zombie hit the others may stand with IV, Silk Storm. 400u (<see cref="Radius"/>, 250).</summary>
public static float StormRadius { get => _stormRadius ?? 400f; set => _stormRadius = value; }
static int? _broodCount;
/// <summary>
/// How many a zombie dying in one of these webs webs with V, Brood. 3 — the user's *"up to 3"*: fewer when fewer unwebbed
/// zombies stand in reach.
/// </summary>
public static int BroodCount { get => _broodCount ?? 3; set => _broodCount = value; }
static float? _broodRadius;
/// <summary>How far from the dying zombie Brood's webs reach. 140u, the user's.</summary>
public static float BroodRadius { get => _broodRadius ?? 140f; set => _broodRadius = value; }
/// <summary>
/// How many this player's web catches besides the hit: <see cref="StormExtra"/> from level IV, <see cref="WideExtra"/> from I,
/// <see cref="Extra"/> below.
/// </summary>
public static int ExtraFor( NZPlayer player )
=> AmmoModUpgrades.Has( player, ModId, 4 ) ? StormExtra
: AmmoModUpgrades.Has( player, ModId, 1 ) ? WideExtra
: Extra;
/// <summary>How far from the hit this player's web reaches: <see cref="StormRadius"/> from level IV, <see cref="Radius"/> below it.</summary>
public static float RadiusFor( NZPlayer player ) => AmmoModUpgrades.Has( player, ModId, 4 ) ? StormRadius : Radius;
/// <summary>How long this player's webs hold: <see cref="StrongSeconds"/> from level II, <see cref="Seconds"/> below it.</summary>
public static float SecondsFor( NZPlayer player ) => AmmoModUpgrades.Has( player, ModId, 2 ) ? StrongSeconds : Seconds;
/// <summary>How many a zombie dying in this owner's web webs: <see cref="BroodCount"/> at level V, none below it.</summary>
public static int BroodFor( NZPlayer player ) => AmmoModUpgrades.Has( player, ModId, 5 ) ? BroodCount : 0;
/// <summary>III's companion beside each of these webs: its +50% damage taken is on the rule (`StatusEffects`).</summary>
public const string Snared = "snared";
/// <summary>Web the zombie that was hit and the nearest few around it. The shooter's machine; the web relays.</summary>
public static void Fire( NZPlayer player, GameObject zombie )
{
if ( !player.IsValid() || !zombie.IsValid() ) return;
var at = zombie.WorldPosition;
// ⚠️ THE SHOOTER'S LEVEL, READ HERE (2026-10-05). Only the mod webs through this file — Widow's Wine calls
// `StatusEffects.Apply` itself — so the perk's webs never pick the upgrades up. IV moves the reach with the count
// (2026-10-06): Silk Storm's 12 nearest are looked for within 400u, not 250u.
var reach = MathF.Max( 0f, RadiusFor( player ) );
var extra = Math.Max( 0, ExtraFor( player ) );
var seconds = MathF.Max( 0.1f, SecondsFor( player ) );
var snare = AmmoModUpgrades.Has( player, ModId, 3 );
// ⚠️ THE ZOMBIE HIT FIRST, then the nearest — it is always caught, wherever the others stand.
var caught = ZombieAI.All
.Where( z => z.IsValid() && z.GameObject.IsValid() && z.GameObject != zombie && z.State != ZombieState.Dead )
.Select( z => (Ai: z, Dist: at.Distance( z.WorldPosition )) )
.Where( x => x.Dist <= reach )
.OrderBy( x => x.Dist )
.Take( extra )
.Select( x => x.Ai.GameObject )
.Prepend( zombie )
.ToList();
foreach ( var go in caught )
Web( player, go, seconds, snare );
// ⚠️ ONE CUE, AT THE HIT — a web landing on four zombies must not sound four times.
Sound.Play( NZSound.SlideSquish, at + Vector3.Up * 40f );
Log.Info( $"[nz-ammo] SILK SHOT — {caught.Count} zombie(s) webbed for {seconds:0.#}s (the hit + {extra} within {reach:0}u)"
+ (snare ? $" · snared: +{(SnareTaken() - 1f) * 100f:0}% damage taken" : "") );
}
/// <summary>
/// One zombie into one of this player's webs for <paramref name="seconds"/>, Snared Prey beside it when <paramref name="snare"/>.
///
/// ⛔ EVERY SILK SHOT WEB IS MADE HERE (2026-10-06, §3): the shot's (<see cref="Fire"/>, the shooter's machine) and Brood's
/// (<see cref="OnWebbedDeath"/>, the host). So a web passed on at a death is exactly the web the owner's own shot makes, and
/// carries the `snared` that lets it be passed on again.
/// </summary>
static void Web( NZPlayer player, GameObject go, float seconds, bool snare )
{
// ⚠️ FROM A CLIENT, A WEB ALREADY THERE IS REFRESHED HERE ONLY (2026-10-05, the review): `StatusEffects.Apply` relays a
// NEW status, so the host keeps that web's old end while a new `snared` reaches it whole — +50% on a zombie already
// walking free there. On the host (or solo) the refresh is the real one.
var fromClient = Networking.IsActive && !NZGame.IsHost;
var found = StatusEffects.Remaining( go, "web" );
StatusEffects.Apply( go, "web", player.GameObject, seconds: seconds );
// ⚠️ SNARED PREY RIDES BESIDE THIS WEB FOR THIS WEB'S TIME (2026-10-05), not the web's whole life: on a zombie Widow's
// Wine has webbed for longer, the +50% still ends when Silk Shot's web would have (`StatusEffects.Unseen`). From a
// client, never past the web it found (above).
if ( snare )
StatusEffects.Apply( go, Snared, player.GameObject,
seconds: fromClient && found > 0f ? MathF.Min( seconds, found ) : seconds );
}
/// <summary>What a zombie in a level-III web takes, read back from the `snared` rule as the logs print it. ×1.5.</summary>
static float SnareTaken() => StatusEffects.Rules.TryGetValue( Snared, out var r ) ? r.Vulnerability : 1f;
/// <summary>
/// V, BROOD (2026-10-06): a zombie died — if it wore one of these webs, it webs up to 3 more. THE HOST (or solo), from
/// `AmmoModDeaths.Died`, before the corpse's statuses are cleared, so the web and its owner (`StatusEffects.SourceOf`) can
/// still be read off it.
///
/// ⛔ A SILK SHOT WEB, NOT WIDOW'S WINE'S, AND `snared` IS HOW THE HOST TELLS THEM APART. Both are the one `web` status and both
/// name the player as its source, so the web alone cannot say which of the two made it. `snared` rides beside Silk Shot's webs
/// and nothing else (`Web`, from III), never outlasts the web it came with on the host, travels like any new status and names
/// the shooter — and V has III, so every web a level-V owner makes carries it. So its source IS the web's owner, and a zombie
/// Widow's Wine still holds after Silk Shot's web would have ended wears no `snared` and passes nothing on. No record of our
/// own and no message: a client's webs reach the host as statuses, and statuses are all the host ever hears of them.
///
/// ⚠️ THE WEB OWNER'S LEVEL, WHOEVER MADE THE KILL — a grenade, a bleed, a teammate (`AMMO_MODS.md`). Where two players' Silk
/// Shots overlap on one zombie, the `snared` the host heard last names the owner (`SourceOf`).
///
/// ⚠️ THE 3 NEAREST NOT ALREADY WEBBED, by anything (Widow's Wine too): a death never shortens, lengthens or re-owns a web that
/// is there, and the brood spreads outward instead of re-webbing its own. Bosses too: Silk Shot roots them.
///
/// ⚠️ NOTHING HERE DEALS DAMAGE, so it cannot re-enter itself; each link waits for a death.
/// </summary>
public static void OnWebbedDeath( ZombieAI zombie )
{
if ( !zombie.IsValid() || !zombie.GameObject.IsValid() ) return;
var body = zombie.GameObject;
if ( !StatusEffects.Has( body, "web" ) || !StatusEffects.Has( body, Snared ) ) return;
var source = StatusEffects.SourceOf( body, Snared );
var owner = source.IsValid() ? source.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;
var count = Math.Max( 0, BroodFor( owner ) );
if ( count <= 0 ) return;
var at = body.WorldPosition;
var reach = MathF.Max( 0f, BroodRadius );
// ⚠️ THE CORPSE IS LEFT OUT BY NAME, as `PrismaChain.Burst` leaves it: `Die` has already set it dead, but it still answers
// `IsValid` on this frame.
var caught = ZombieAI.All
.Where( z => z.IsValid() && z.GameObject.IsValid() && z.GameObject != body && z.State != ZombieState.Dead )
.Where( z => !StatusEffects.Has( z.GameObject, "web" ) )
.Select( z => (Ai: z, Dist: at.Distance( z.WorldPosition )) )
.Where( x => x.Dist <= reach )
.OrderBy( x => x.Dist )
.Take( count )
.Select( x => x.Ai.GameObject )
.ToList();
if ( caught.Count == 0 ) return;
// ⚠️ THE OWNER'S WEB, AS THEIR SHOT WOULD MAKE IT: II's 5s and III's Snared Prey (which V always has), through `Web`.
var seconds = MathF.Max( 0.1f, SecondsFor( owner ) );
var snare = AmmoModUpgrades.Has( owner, ModId, 3 );
foreach ( var go in caught )
Web( owner, go, seconds, snare );
// ⚠️ THE SQUISH, AT THE CORPSE, FROM THE HOST TO EVERY MACHINE (`NZSound.PlayShared`): a death happens on the host alone, and
// the owner may be a client. Once a frame, so a blast through a webbed crowd squishes once, as `Fire` does for one web.
if ( _broodCueAt != Time.Now )
{
_broodCueAt = Time.Now;
NZSound.PlayShared( NZSound.SlideSquish, at + Vector3.Up * 40f );
}
Log.Info( $"[nz-ammo] SILK SHOT V BROOD — {body.Name} died webbed: {caught.Count} more webbed for {seconds:0.#}s"
+ $" (the nearest unwebbed within {reach:0}u)" + (snare ? ", snared" : "") );
}
/// <summary>
/// When Brood last squished. RUNTIME STATE, NOT A TUNABLE (§1 is about tunables): a timestamp for the once-a-frame rule,
/// harmless if a hotload keeps it.
/// </summary>
static float _broodCueAt = -1f;
/// <summary>`nz_silkshot [seconds] [extra] [radius]` — the resolved numbers, and retune them live.</summary>
[ConCmd( "nz_silkshot" )]
public static void Cmd( float seconds = -1f, int extra = -1, float radius = -1f )
{
if ( seconds > 0f ) Seconds = seconds;
if ( extra >= 0 ) Extra = extra;
if ( radius > 0f ) Radius = radius;
var mod = AmmoMods.Find( "silkshot" );
Log.Info( $"[nz-ammo] SILK SHOT · {(mod?.Chance ?? 0f) * 100f:0.#}% per hit, {mod?.Cooldown ?? 0f:0.#}s cooldown"
+ $" · webs the hit + {Extra} within {Radius:0}u for {Seconds:0.#}s · deals NO damage" );
// ⚠️ THE UPGRADES THIS FILE READS (2026-10-05; IV and V 2026-10-06), and where your own level puts them.
var me = NZPlayer.Local;
Log.Info( $"[nz-ammo] upgrades: I the hit + {WideExtra} · II {StrongSeconds:0.#}s · III +{(SnareTaken() - 1f) * 100f:0}% while webbed"
+ $" · IV the hit + {StormExtra} within {StormRadius:0}u"
+ $" · V a zombie dying in one of these webs webs the {BroodCount} nearest unwebbed within {BroodRadius:0}u (host)"
+ (me.IsValid()
? $" · yours at level {AmmoModUpgrades.Level( me, ModId )}: the hit + {ExtraFor( me )} within {RadiusFor( me ):0}u"
+ $" for {SecondsFor( me ):0.#}s"
+ (AmmoModUpgrades.Has( me, ModId, 3 ) ? ", snared" : "")
+ (BroodFor( me ) > 0 ? $", brood {BroodFor( me )}" : "")
: "") );
}
}