A static utility implementing the "flechette" weapon node. It breaks a bullet impact into multiple short-range fragment traces that apply damage via Health.OnDamage, ignoring the body that caused the split and tagging shard damage as "flechette" (and optionally "nopay"). It also exposes Reach and a console command to inspect/change reach.
using Sandbox;
using System;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// FLECHETTE (`t5_flechette`) — a round that comes apart inside the first zombie it hits and
/// sprays the rest of the pack with fragments.
///
/// ⛔ IT IS A CROWD NODE AND IT IS SUPPOSED TO BE USELESS ALONE. Seven shards at a third of the
/// damage each is up to x2.31 in a packed group and EXACTLY NOTHING against one zombie in an open
/// room — every shard flies off and finds nothing. That shape is the whole design: the ceiling is
/// enormous, and the player only reaches it by standing somewhere frightening.
///
/// ⛔ THE SHARDS CANNOT SPLIT AGAIN, BY CONSTRUCTION RATHER THAN BY A FLAG. The split is fired
/// from `HitScan`, and a shard is not a `HitScan` bullet — it is a trace resolved in this file that
/// calls `Health.OnDamage` directly. There is no path from a shard back to the code that would
/// split it, so "seven, once" needs no depth counter and cannot be broken by a later edit that
/// forgets one.
///
/// ⚠️ ONCE PER PELLET, ON THE FIRST ZOMBIE — not once per body a penetrating round crosses. A
/// rifle round through five zombies splits at the first of them, for 7 shards, not 35. The caller
/// holds that latch (`split` in `HitScan.Shoot`, which is per pellet) for the same reason the
/// explosive node holds `blasted` beside it.
///
/// ⚠️ SO A SHOTGUN REALLY DOES GET ONE SPLIT PER PELLET, and that is the honest reading of "a
/// bullet splits into 7": a pellet is a bullet. It is also why <see cref="Reach"/> is short — see
/// its note; sixteen splits of seven short traces is a broadphase query each, not a map-length
/// sweep each.
///
/// ⚠️ ROUTED THROUGH `Health.OnDamage`, NOT `Health.Apply`, following `TechBlast.Detonate`'s note
/// verbatim: `Apply` is local-only and takes no weapon, so shard damage would neither reach the
/// host from a client nor attribute the kill to the gun that earned it. `OnDamage` is the one
/// chokepoint that relays, scales and attributes.
///
/// ⚠️ WHICH ALSO MEANS THE SHARDS OBEY `ShotPoints`. They are asked the same question every
/// pierced body is asked and carry the same `nopay` tag when refused, so a node that touches seven
/// extra zombies does not also become a points engine — the economy guard was built for exactly
/// this multiplication and it costs one call per shard to stay inside it.
/// </summary>
public static class Flechette
{
/// <summary>
/// How far a shard flies before it gives up, in units. 250.
///
/// ⛔ SHORT ON PURPOSE, AND FOR TWO SEPARATE REASONS. A fragment that travels the length of
/// the map is not a fragment, and — the one that decides the number — seven full-range sweeps
/// per pellet is the cost shape `Weapon.TraceRange` exists to stop. A 16-pellet shotgun asks
/// for 112 traces on one trigger pull; at 250 units each of them is a small local query that
/// broadphase culling answers almost for free, and at 999999 it would be 112 sweeps that
/// narrow-phase against every zombie in the level.
///
/// ⚠️ IT IS ALSO THE NODE'S REAL RANGE STAT. Zombies have to be within about six metres of
/// the one you shot for any of this to happen, which is what makes it a horde node rather than
/// a damage node.
/// </summary>
public static float Reach { get => _reach ?? 250f; set => _reach = value; }
static float? _reach;
/// <summary>How many draws to spend finding a uniform direction before settling. 8.</summary>
/// <remarks>
/// ⚠️ SAME REJECTION SAMPLER AS `HitScanBulletInfo.Deflect`, and deliberately not shared with
/// it: that one folds in a hemisphere test against a surface normal, which a shard has no use
/// for. Two short loops that agree is better than one parameterised loop that has to be read
/// twice to see which half applies.
/// </remarks>
const int DirectionTries = 8;
/// <summary>The tag every shard carries, so the damage can be told apart downstream.</summary>
public const string Tag = "flechette";
// ⚠️ ONE REUSED LIST RATHER THAN A NEW ONE PER SPLIT. A split runs to completion inside one
// pellet's resolution and never overlaps another, and this is the damage path — where
// INSTRUCTIONS.md already records an array-per-hit costing frames.
static readonly List<GameObject> _ignore = new();
/// <summary>
/// A bullet just hit a zombie. Break it up.
/// </summary>
/// <param name="splitOn">
/// The zombie that caused the split. ⚠️ THE SHARDS IGNORE IT, which is the requested rule and
/// is also the only thing standing between this node and seven free hits on a body that is
/// already being shot: every shard starts INSIDE that zombie's hitbox, so without the ignore
/// most of them would resolve against it a unit later and the "spray" would be a x3.31 single
/// target multiplier.
/// </param>
/// <param name="at">Where the bullet struck — the origin every shard flies from.</param>
/// <param name="damage">What the bullet dealt, before the share is taken.</param>
public static void Split( SWB.Base.Weapon weapon, GameObject splitOn, Vector3 at, float damage )
{
if ( !weapon.IsValid() || damage <= 0f ) return;
var shards = (int)TechEffects.Factor( weapon, "t5_flechette", 0f );
if ( shards < 1 ) return;
var share = TechEffects.Mag( weapon, "t5_flechette", "share", 0f );
if ( share <= 0f ) return;
var each = damage * share;
var attacker = weapon.Owner?.GameObject;
var root = ZombieAI.RootOf( splitOn );
_ignore.Clear();
if ( root.IsValid() ) _ignore.Add( root );
for ( var i = 0; i < shards; i++ )
{
var dir = Scatter();
var tr = weapon.TraceBullet( at, at + dir * Reach, extraIgnoreGOs: _ignore );
if ( !tr.Hit ) continue;
// ⛔ THE TRACE IS NOT TAG-FILTERED TO ZOMBIES, AND THAT IS WHAT MAKES WALLS STOP
// SHARDS. A `.WithTag("zombie")` sweep would be cheaper and would also spray the
// room on the other side of the wall you are standing behind — a node that shoots
// through geometry reads as a bug, and it would be one.
var hit = ZombieAI.RootOf( tr.GameObject );
if ( !hit.IsValid() ) continue;
var hp = hit.Components.Get<Health>( FindMode.EverythingInSelf );
if ( hp is null || hp.IsDead ) continue;
// ⚠️ ASKED EXACTLY ONCE PER SHARD, because it MUTATES — see `ShotPoints.Pays`. The
// answer rides the damage as a tag, the same carrier the bullet's own verdict uses.
// ⚠️ INSIDE ITS BULLET'S PELLET, so the gun's ammo-mod upgrades come with it (2026-10-05): a shard spends Midas III's
// wider cap and pays Scrapper III's salvage as any hit of that bullet does. Leech III has already healed for it.
var pays = ShotPoints.Pays( hit );
hp.OnDamage( new SWB.Shared.DamageInfo
{
Attacker = attacker,
Weapon = weapon.GameObject,
Damage = each,
Position = tr.HitPosition,
Origin = at,
// ⚠️ NO `head` TAG EVER, however the shard landed. A fragment is not aimed, so
// it earns neither the head damage product nor the 100-point award — the same
// ruling `BouncyRounds` makes, for the same reason. It also saves the per-shard
// `Hitbox.Tags.TryGetAll().ToArray()` that `HitScan` already flags as a
// gen0 pressure source, seven times per pellet.
Tags = pays ? [Tag] : [Tag, "nopay"],
} );
}
}
/// <summary>
/// Where one shard goes: a uniformly random direction over the whole sphere.
///
/// ⛔ THE WHOLE SPHERE, NOT A FORWARD CONE, AND IN THIS GAME THAT IS NOT A WASTE. The obvious
/// objection is that half the shards fly back past the shooter — true, and in nZombies the
/// space behind you is where the horde is. A forward cone would make the node strongest when
/// firing into an empty room and weakest when surrounded, which is backwards for a fragment
/// spray.
///
/// ⚠️ REJECTION-SAMPLED IN THE CUBE RATHER THAN `Vector3.Random`, for the reason
/// `GlobalHandling`'s spread note gives: whether that property returns a unit vector or a point
/// in a cube is engine behaviour this project has decided not to assume, and the difference is
/// between uniform and biased toward the eight corners.
/// </summary>
static Vector3 Scatter()
{
for ( var i = 0; i < DirectionTries; i++ )
{
var v = new Vector3(
Game.Random.Float( -1f, 1f ),
Game.Random.Float( -1f, 1f ),
Game.Random.Float( -1f, 1f ) );
// ⚠️ BOTH ENDS TESTED. Longer than 1 is outside the ball and biases toward the
// corners; near zero cannot be normalised at all.
var len = v.Length;
if ( len > 1f || len < 0.0001f ) continue;
return v / len;
}
// ⚠️ EIGHT CONSECUTIVE REJECTIONS IS A 2.7-IN-10000 EVENT (the ball fills 52% of the
// cube), and the cost of hitting it is one shard flying straight up. Not worth a ninth try.
return Vector3.Up;
}
/// <summary>
/// `nz_flechette [reach]` — how far a shard looks, and what the node is worth.
///
/// ⚠️ THE SHARD COUNT AND SHARE ARE NOT SETTABLE HERE. They belong to the catalogue, which
/// `nz_tech` prints and `nz_tech_amp` scales; a second place to set them is the drift this
/// project has already fixed twice.
/// </summary>
[ConCmd( "nz_flechette" )]
public static void Cmd( float reach = -1f )
{
if ( reach > 0f ) Reach = reach;
var node = WeaponTech.Find( "t5_flechette" );
var shards = node is null ? 0 : (int)node.Factor;
var share = WeaponTech.MagOf( "t5_flechette", "share", 0f );
Log.Info( $"[nz-flechette] {shards} shards at x{share:0.##} each, {Reach:0} units of reach" );
Log.Info( $"[nz-flechette] all seven landing is x{1f + shards * share:0.00} the bullet;"
+ " none landing is x1.00" );
Log.Info( "[nz-flechette] one split per PELLET, on the first zombie it hits, and the"
+ " shards ignore that zombie" );
}
}