Perforator weapon node and a component that applies delayed "bleed" damage to zombies. Perforator.Absorb splits a bullet's damage into an immediate tick and a queued per-tick pool stored on the victim's Perforation component, which drains over time and applies unpaid damage to the host-side Health component.
using Sandbox;
using System;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// PERFORATOR (`t5_perforator`) — the round does not hit, it bleeds. Four times the damage,
/// paid out over a second, and every shot adds another second on top of the last.
///
/// ⛔ x4 IS NOT A x4 DAMAGE NODE, AND THE DIFFERENCE IS THE ENTIRE DESIGN. Damage delivered a
/// second late is damage the zombie gets to keep walking on: a shot that would have stopped
/// something at arm's length now stops it a step and a half further in. Against a sprinter in a
/// corridor that is the difference between a kill and a hit, and it is why the multiplier can be
/// this large without the node simply being the best in its tier.
///
/// ⚠️ IT ALSO WASTES EVERY POINT OF OVERKILL, TWICE OVER. A pool of 4x on a zombie that needed
/// 1.2x is 2.8x that drains into a corpse — and unlike an instant hit, the player cannot see that
/// it happened and re-aim. The node rewards firing exactly enough into exactly one thing, which is
/// the opposite of what tier 5's crowd nodes reward.
///
/// ⚠️ STACKING IS ADDITIVE AND PER SHOT, so sustained fire converges on 4x the weapon's DPS with
/// a one-second lag rather than compounding into anything. That is the honest reading of "stacking
/// with each shot" and it is also the only reading that does not turn a 900rpm SMG into an
/// exponential.
///
/// ⛔ THE FIRST TICK LANDS INSTANTLY, WHICH IS NOT A COMPROMISE — IT IS THE POINTS ECONOMY. Every
/// hit in this game pays a per-hit drip through `ZombieAI.OnHurt`, fired by `Health.OnDamaged`;
/// a bullet that applied NOTHING at the moment it struck would fire nothing, and the player would
/// shoot a horde for no points at all. One tenth arriving immediately through the normal path
/// pays exactly once, flinches once, and leaves the other nine tenths to this component — which
/// pays for none of them, for the same reason: one trigger pull, one award.
///
/// ⚠️ ONE `Apply` PER TICK NO MATTER HOW MANY STACKS. The queue is a list of (per-tick, ticks
/// remaining) entries and the tick sums them, so a 900rpm weapon holding fifteen overlapping
/// stacks still costs ten damage calls a second on that zombie, not a hundred and fifty.
///
/// ⚠️ HOST-SIDE BY CONSTRUCTION. It is created from `Health.OnDamage` below the client relay, so
/// it only ever exists on the machine that owns the zombie — the same place the damage it replaces
/// would have landed. Nothing here is networked because nothing here needs to be: the health it
/// drains already replicates.
/// </summary>
public static class Perforator
{
/// <summary>How long one shot's pool takes to drain, in seconds. 1.</summary>
public static float Seconds { get => _seconds ?? 1f; set => _seconds = value; }
static float? _seconds;
/// <summary>
/// How many payments one shot is split into. 10.
///
/// ⛔ TEN, BECAUSE THE FIRST ONE IS THE HIT. At 10 ticks a second the instant tenth that pays
/// the points drip is a tenth of the pool — small enough that the node still reads as "nothing
/// happened yet", large enough to flinch the zombie and register as a hit. At 2 ticks the
/// "instant" half would make it an ordinary x2 weapon with a tail; at 60 the drip-paying first
/// tick would be invisible and so would the hit.
/// </summary>
public static int Ticks { get => _ticks ?? 10; set => _ticks = value; }
static int? _ticks;
/// <summary>
/// How many separate shots one zombie may be bleeding from. 64.
///
/// ⚠️ A BOUND, NOT A BALANCE LEVER, and it is unreachable in play: sixty-four overlapping
/// stacks needs 3840rpm sustained into one body. It exists because the list is walked every
/// tick and an unbounded list on the damage path is how a frame budget disappears.
/// </summary>
public const int MaxStacks = 64;
/// <summary>
/// A perforating round just struck. Returns what the bullet should deal RIGHT NOW; the rest is
/// queued on the victim.
/// </summary>
/// <param name="amount">The bullet's fully scaled damage, as it would have been dealt.</param>
/// <param name="factor">The node's multiplier — 4.</param>
public static float Absorb( Health hp, float amount, float factor, GameObject attacker )
{
if ( !hp.IsValid() || amount <= 0f || factor <= 0f ) return amount;
var ticks = Math.Max( 1, Ticks );
var perTick = amount * factor / ticks;
// ⚠️ ONE TICK STAYS BEHIND. See the header — this is the payment that keeps the hit a hit.
if ( ticks == 1 ) return perTick;
var bleed = hp.Components.GetOrCreate<Perforation>();
if ( bleed.IsValid() )
bleed.Add( perTick, ticks - 1, attacker, Seconds / ticks );
return perTick;
}
/// <summary>
/// `nz_perforator [seconds] [ticks]` — how the pool drains, and what it is worth.
/// </summary>
[ConCmd( "nz_perforator" )]
public static void Cmd( float seconds = -1f, int ticks = -1 )
{
if ( seconds > 0f ) Seconds = seconds;
if ( ticks > 0 ) Ticks = ticks;
var node = WeaponTech.Find( "t5_perforator" );
var factor = node?.Factor ?? 0f;
Log.Info( $"[nz-perforator] x{factor:0.##} total over {Seconds:0.##}s in {Ticks} ticks" );
Log.Info( $"[nz-perforator] the hit itself deals x{factor / Math.Max( 1, Ticks ):0.###};"
+ " the other x" + $"{factor * (Ticks - 1) / Math.Max( 1, Ticks ):0.##} bleeds out" );
Log.Info( "[nz-perforator] stacks add, so sustained fire is x" + $"{factor:0.##}"
+ " the DPS with a one-second lag" );
}
}
/// <summary>
/// One zombie's outstanding bleed. Created on demand by <see cref="Perforator.Absorb"/>.
/// </summary>
public sealed class Perforation : Component
{
/// <summary>One shot's remaining payments.</summary>
struct Bleed
{
public float PerTick;
public int Left;
}
readonly List<Bleed> _stacks = new();
/// <summary>
/// Who fired. ⚠️ KEPT SO THE KILL IS ATTRIBUTED, and overwritten by the most recent shooter
/// rather than tracked per stack: `Health.LastAttacker` is a single field, kill points pay one
/// player, and "whoever shot it last" is both the closest answer available and the one every
/// other damage source in this game already gives.
/// </summary>
GameObject _from;
float _interval = 0.1f;
float _since;
/// <summary>The stacks on this zombie right now, for `nz_perforator`.</summary>
public int Count => _stacks.Count;
public void Add( float perTick, int ticks, GameObject from, float interval )
{
if ( perTick <= 0f || ticks < 1 ) return;
if ( _stacks.Count >= Perforator.MaxStacks ) return;
if ( from.IsValid() ) _from = from;
if ( interval > 0f ) _interval = interval;
_stacks.Add( new Bleed { PerTick = perTick, Left = ticks } );
Enabled = true;
}
protected override void OnUpdate()
{
if ( _stacks.Count == 0 )
{
// ⚠️ THE COMPONENT SLEEPS RATHER THAN BEING DESTROYED. A zombie that has been shot
// once will be shot again, and `GetOrCreate` on the next bullet is cheaper than a
// destroy-and-recreate per magazine.
Enabled = false;
return;
}
_since += Time.Delta;
if ( _since < _interval ) return;
// ⚠️ ONE INTERVAL PER FRAME AT MOST, NOT A CATCH-UP LOOP. A hitch must not dump four
// ticks of a pool into one frame — the whole node is that the damage arrives LATE, and a
// stall that made it arrive all at once would hand the player back exactly what they paid
// for. The pool is not lost, only stretched.
_since = 0f;
var hp = Components.Get<Health>( FindMode.EverythingInSelf );
if ( !hp.IsValid() || hp.IsDead )
{
_stacks.Clear();
Enabled = false;
return;
}
var total = 0f;
// ⚠️ WALKED DOWNWARD SO A REMOVAL DOES NOT SKIP THE NEXT ENTRY. Nothing outside this
// method touches the list, so this is the ordinary reason rather than the mid-iteration
// death hazard `BouncyRounds.Nearest` guards against.
for ( var i = _stacks.Count - 1; i >= 0; i-- )
{
var s = _stacks[i];
total += s.PerTick;
if ( --s.Left <= 0 ) _stacks.RemoveAt( i );
else _stacks[i] = s;
}
if ( total <= 0f ) return;
// ⛔ UNPAID, AND THAT IS THE POINT OF THE METHOD. The trigger pull that started this bleed
// has already been paid for by its instant tenth; paying again on every tick would turn a
// damage node into a points engine worth ten times an ordinary hit.
//
// ⛔ AND STRAIGHT TO `Apply`, NOT THROUGH `OnDamage`, for the reason the status ticks in
// `StatusEffects` record: `OnDamage` is the path that APPLIES statuses and scales damage by
// the attacker's perks, so re-entering it would re-scale a figure that is already scaled —
// and, here, would perforate the bleed itself, forever.
hp.ApplyUnpaid( total, _from );
}
}