Static utility managing bullet impact decals and packed-round burns. Loads decal prefabs, spawns/clones impact prefabs on walls and bodies, applies tint/scale/lifetime, manages capped queues for wall holes and per-body burns, and provides console commands to tune and test behavior. Also contains small Component classes that animate tint/flare fade and track per-body burn queues.
using Sandbox;
using SWB.Shared; // GameObjectExtensions.DestroyAsync
using System.Linq;
namespace NZombies;
/// <summary>
/// Bullet holes on walls.
///
/// ⛔ THE ENGINE SPLITS IMPACT PARTICLES AND THE HOLE INTO TWO PREFABS, AND ONLY THE
/// FIRST WAS EVER SPAWNED. `Surface.PrefabCollection.BulletImpact` is
/// `prefabs/surface/default-bullet.prefab` — a TemporaryEffect plus smokering, smoke
/// and fleks, all particle systems, **no decal**. The hole is
/// `prefabs/surface/default-bullet-decal.prefab`, referenced by nothing in this
/// codebase. `CreateBulletImpact` cloned the particle prefab, named the clone
/// "bullet_decal", stripped its emitters for performance and left an empty object.
///
/// ⚠️ I PREVIOUSLY REPORTED DECALS AS WORKING. `nz_impact_test` printed "decal
/// spawned 'bullet_decal'" and I took that as proof — it proved a GameObject was
/// created, which is not the same claim as a mark appearing on the wall. Checking
/// the prefab's CONTENTS is what settled it.
///
/// ⛔ AND THE CAP IN THIS FILE WAS A SECOND CLAIM OF THE SAME SHAPE. A comment on the
/// `AddDecal` call below said *"Through the same pooled queue as everything else, so
/// `MaxDecals` (30) bounds holes as well"*. It did not. `WeaponParticleManager` is a
/// scene component and it is **in no scene in this project** — grep the .scene files, there
/// is not one — so `Instance` is null, `Instance?.AddDecal( go )` is a null-conditional
/// no-op, and holes were bounded only by `Lifetime` × fire rate. At 60s and 600 RPM that is
/// six hundred of them.
///
/// ⚠️ THE `?.` IS WHY IT WAS INVISIBLE. A null-conditional call on a missing singleton is
/// not a safe fallback, it is a silently skipped feature — the same trap INSTRUCTIONS.md
/// §13 records for `Instance?.Foo()` on a lazily-created singleton, where the same file
/// carried a doc comment warning about it and four call sites doing it anyway.
///
/// ⛔ SO THE QUEUE LIVES HERE NOW. The alternative — creating the missing
/// `WeaponParticleManager` at runtime the way `WunderfizzMenu.EnsureHost` does — would also
/// have switched on capping for EJECT particles (`MaxEject` 180) and for the impact clones,
/// neither of which was asked for and both of which have been running uncapped long enough
/// that turning a limiter on is a behaviour change, not a fix.
/// </summary>
public static class BulletDecals
{
/// <summary>The engine's bullet hole.</summary>
public const string DecalPrefab = "prefabs/surface/default-bullet-decal.prefab";
/// <summary>Master switch.</summary>
public static bool Enabled { get; set; } = true;
/// <summary>How long a hole stays, seconds. 60 chosen in play — long enough that a
/// room you have fought through still shows it.</summary>
public static float Lifetime { get; set; } = 60f;
/// <summary>Hole size multiplier. 1.5 chosen in play; the stock decal is small for
/// a first-person view.</summary>
public static float Scale { get; set; } = 1.5f;
/// <summary>
/// How many holes may exist at once. The oldest is destroyed to make room.
///
/// ⚠️ A HARD CEILING ON TOP OF `Lifetime`, not a replacement for it. The lifetime is
/// what makes a room you fought through still show it; this is what stops a long
/// firefight from being unbounded. Whichever comes first wins.
/// </summary>
public static int MaxHoles { get; set; } = 30;
/// <summary>
/// Live holes, oldest first.
///
/// ⛔ A PROPERTY OVER A LAZY FIELD, NOT A `static readonly` INITIALISER.
/// INSTRUCTIONS.md §1 — hotload migrates static collections, so a queue built in an
/// initialiser survives a code edit holding references to objects that no longer
/// exist, and a newly added one arrives null. This rebuilds if it has to.
/// </summary>
static System.Collections.Generic.Queue<GameObject> _holes;
static System.Collections.Generic.Queue<GameObject> Holes
=> _holes ??= new();
static PrefabFile _prefab;
static bool _looked;
static PrefabFile _papPrefab;
static bool _papLooked;
/// <summary>
/// The PACKED impact — a laser burn instead of a hole.
///
/// ⛔ ONE DECAL, TINTED, NOT FIVE. `decals/nz/pap_burn.decal`'s colour texture is authored
/// NEUTRAL and the tier colour arrives as `Decal.ColorTint`, exactly the way the muzzle flash
/// tints one flash effect rather than shipping five. A sixth tier costs nothing.
///
/// ⚠️ IT REPLACES THE HOLE, IT DOES NOT LAYER OVER IT. `MaxHoles` is 30 and covers every
/// decal in the scene, so stacking two per impact would halve how far back the wall
/// remembers being shot — and "instead of a hole" is the brief.
/// </summary>
public const string PapDecalPrefab = "prefabs/surface/pap-bullet-decal.prefab";
/// <summary>Packed impacts use the burn. `nz_decal_pap 0` falls back to plain holes.</summary>
public static bool PapEnabled { get; set; } = true;
// ── the hot half ─────────────────────────────────────────────────────────
// ⛔ NO LIGHT. A first pass made the heat a PointLight and that was wrong: the user asked
// for the MARK to be bright and to dim, not for packed rounds to light the room. A light
// at every impact also means an automatic weapon strobing the walls, which is a different
// effect entirely from a glowing scar.
//
// ⚠️ SO THE FADE IS THE DECAL'S OWN TINT, ANIMATED. `Decal.ColorTint` is writable at
// runtime, so the burn starts at full tier colour and is driven down to a dim stain over
// `CoolTime`. Nothing is emitted, nothing else in the scene changes brightness, and the
// mark that is left is the one that was already approved.
//
// ⚠️ THE SCORCH SURVIVES THIS UNTOUCHED, which is why it can be one tint over the whole
// decal. The halo's texel values are ~0.035 — multiplying near-black by anything is still
// near-black — so the tint animation is visible almost entirely in the bright centre,
// which is exactly the part that was meant to cool.
/// <summary>
/// Fade the burn's colour after impact. **OFF** — `nz_decal_glow 1` turns it on.
///
/// ⛔ OFF BECAUSE IT LOST A COMPARISON, NOT BECAUSE IT IS UNFINISHED. Two attempts were made
/// at cooling the mark — a PointLight at the impact, then this animated tint — and the user
/// preferred the static burn to both: *"the first version looked better."* The code stays so
/// the decision can be revisited with one command instead of rebuilt from the changelog.
/// </summary>
public static bool GlowEnabled { get; set; } = false;
/// <summary>Tint multiplier the instant it lands. 1 is the tier colour at full strength;
/// above 1 pushes the centre toward white-hot before it settles.</summary>
public static float HotTint { get; set; } = 1.6f;
/// <summary>Tint multiplier once cool. Not 0 — the burn keeps a trace of what made it,
/// which is the whole point of colouring it per tier.</summary>
public static float ColdTint { get; set; } = 0.18f;
/// <summary>Seconds from hot to cold.</summary>
public static float CoolTime { get; set; } = 1.1f;
/// <summary>
/// Drive one burn's tint from hot to cold, then stop.
///
/// ⚠️ IT DISABLES ITSELF RATHER THAN DESTROYING ANYTHING. `PapMuzzleFlash.Fade` destroys its
/// object because a muzzle flash IS the light; here the object is the decal and it has to
/// outlive the cooling by the full `Lifetime` — 60 seconds of scorch after one second of
/// heat. Killing it on cool would delete the mark the moment it finished forming.
/// </summary>
public sealed class BurnCool : Component
{
[Property] public Decal Target { get; set; }
[Property] public Color Base { get; set; } = Color.White;
[Property] public float Life { get; set; } = 1.1f;
[Property] public float Hot { get; set; } = 1.6f;
[Property] public float Cold { get; set; } = 0.18f;
TimeSince _since;
protected override void OnStart() => _since = 0f;
protected override void OnUpdate()
{
if ( !Target.IsValid() ) { Enabled = false; return; }
// ⚠️ No `System` in this file's usings, and it does not need one for a guard.
float t = MathX.Clamp( _since / (Life > 0.001f ? Life : 0.001f), 0f, 1f );
// ⚠️ EASED, NOT LINEAR. Cooling is fast at first and then lingers; a straight ramp
// reads as a dimmer switch being turned rather than as heat leaving metal.
float k = MathX.Lerp( Hot, Cold, t * t );
Target.ColorTint = Base * k;
if ( t >= 1f ) Enabled = false;
}
}
/// <summary>How much bigger a burn is than a bullet hole. A laser leaves a wider mark.</summary>
public static float PapScale { get; set; } = 1.62f; // 1.35 +20%, 2026-09-14
/// <summary>
/// Colour intensity of the burn. Multiplies the tier tint before it reaches the decal.
///
/// ⛔ ABOVE 1 ON PURPOSE, AND IT IS SATURATION RATHER THAN BRIGHTNESS THAT IT BUYS. The burn
/// texture is authored NEUTRAL — a texel is 0.82 grey in all three channels — so at tint x1 a
/// green tier lands as (0.20, 0.82, 0.20): a mid green, which is what "muted" looked like on
/// a white pillar. At x2 the same texel is (0.39, 1.64, 0.39); the green channel clips to 1
/// while red and blue stay at 0.39, and clipping ONE channel is precisely what makes a colour
/// read as vivid.
///
/// ⚠️ RAISING `CORE` IN THE GENERATOR WOULD NOT HAVE DONE THIS. That lifts all three channels
/// together, so the mark gets paler rather than greener — it walks toward white, which is the
/// opposite of intensity. The texture's approved shape is untouched by this knob.
///
/// ⚠️ THE SCORCH IS UNAFFECTED, same reason one tint over the whole decal works at all: its
/// texels sit near 0.035, and 0.035 x 2 is still black.
/// </summary>
public static float PapTint { get; set; } = 3f;
// ══ the energy burn — the Prisma's hole ════════════════════════
//
// ⚠️ THE PACK-A-PUNCH DECAL, BORROWED WHOLE. It is already authored NEUTRAL so the colour
// can arrive as `Decal.ColorTint`, which is exactly what a second coloured burn needs — there
// was nothing to author and nothing to duplicate.
static bool? _energyOn;
/// <summary>Does the energy weapon leave its own burn. On.</summary>
public static bool EnergyEnabled { get => _energyOn ?? true; set => _energyOn = value; }
static float? _energyScale;
/// <summary>Size on top of the pap burn's. 2 — twice as big, as asked for.</summary>
///
/// ⚠️ IT MULTIPLIES `PapScale`, IT DOES NOT REPLACE IT. "Twice the size" means twice the
/// thing it was compared against, so the final figure is `Scale × PapScale × this` and it
/// stays twice the pap burn even if that one is retuned.
public static float EnergyScale { get => _energyScale ?? 2f; set => _energyScale = value; }
static Color? _energyBurn;
/// <summary>
/// The burn's colour. A light blue.
/// </summary>
///
/// ⛔ ITS OWN FIGURE, NOT `PrismaFx.Tint`, AND FOR A MEASURABLE REASON. The muzzle's azure is
/// (0.16, 0.48, 1.00) — right for an additive line on a dark screen, too dark and too saturated
/// for a mark left ON a lit wall, where it reads as a navy smudge rather than as something
/// that burned. This sits between that and `PrismaFx.Core`: same family, lifted until it reads
/// as light blue against plaster.
public static Color EnergyBurn
{
get => _energyBurn ?? new Color( 0.35f, 0.66f, 1.00f );
set => _energyBurn = value;
}
static float? _energyTint;
/// <summary>
/// Colour intensity of the energy burn. 1.5.
/// </summary>
///
/// ⛔ DELIBERATELY WELL UNDER `PapTint`'s 3, BECAUSE 3 WOULD MAKE IT WHITE. Anything over 1
/// blooms rather than clips, so at ×3 all three channels are over the line and the hue is
/// gone — the same trap the muzzle flash fell into when the pale measured accent read as
/// white. At ×1.5 only BLUE goes over: (0.53, 0.99, 1.50). The glow is blue, the core burns
/// out light, and the hole still reads as its colour.
public static float EnergyTint { get => _energyTint ?? 1.5f; set => _energyTint = value; }
// ⚠️ 3 IS ABOUT WHERE THIS STOPS PAYING. At x2 only the tier's dominant channel clipped, which
// is what made the colour read as vivid. By x3 the SECOND channel starts clipping too — a
// green tier's 0.24 red reaches 0.72 of the way to white — so past here the mark gets paler
// rather than more saturated, and the knob starts undoing itself.
static PrefabFile PapPrefab
{
get
{
if ( _papLooked ) return _papPrefab;
_papLooked = true;
_papPrefab = ResourceLibrary.Get<PrefabFile>( PapDecalPrefab );
if ( _papPrefab is null )
Log.Warning( $"[nz] pap decal '{PapDecalPrefab}' not found — packed shots fall back"
+ " to plain holes" );
return _papPrefab;
}
}
/// <summary>
/// The decal prefab, loaded once.
///
/// ⚠️ CACHES THE FAILURE TOO — a missing prefab looked up per bullet would spam
/// `ERROR_FILEOPEN` at fire rate.
/// </summary>
static PrefabFile Prefab
{
get
{
if ( _looked ) return _prefab;
_looked = true;
_prefab = ResourceLibrary.Get<PrefabFile>( DecalPrefab );
if ( _prefab is null )
Log.Warning( $"[nz] bullet decal '{DecalPrefab}' not found — no holes" );
return _prefab;
}
}
/// <summary>Put a hole on a surface.</summary>
/// <summary>
/// Is this something a bullet hole must NOT be stuck to?
///
/// ⛔ HITSCAN PENETRATION IS WHY THIS MATTERS, not decal accumulation. Bullets here penetrate —
/// a whole ARC9 Penetration stat drives it — and a hitscan round is resolved in ONE frame, so a
/// single trigger pull down a corridor of thirteen zombies spawned thirteen decal prefabs in the
/// same frame. The 30-hole cap bounds what survives; it does nothing about the burst.
///
/// ⛔ AND A DECAL ON A SKINNED MODEL IS WRONG ANYWAY. A Decal component is a world projection:
/// stuck to a walking zombie it stays where the geometry WAS, so the hole slides off the body
/// within a stride — the same static-emitter problem ZombieAI.FollowVoices documents for sound,
/// with no equivalent fix.
///
/// ⚠️ RAGDOLL AND PLAYER TOO. A corpse is tagged `ragdoll` the moment it dies (ZombieAI:5002),
/// and it is still shootable; players are shootable in friendly fire. Neither should collect
/// holes, and both are moving skinned models for the same reason.
/// </summary>
public static bool IsFlesh( GameObject go )
{
if ( !go.IsValid() ) return false;
// ⚠️ Ancestors too. A hit resolves against a hitbox or a collider on a CHILD of the zombie —
// `head` is its own tagged object — so testing only the hit object itself would let every
// headshot through, which is most of them.
for ( var o = go; o.IsValid(); o = o.Parent )
if ( o.Tags.HasAny( "zombie", "ragdoll", "player" ) )
return true;
return false;
}
// ── the burn that rides the body ───────────────────────────────────
//
// ⛔ BOTH REASONS THE `IsFlesh` GATE EXISTS ARE ANSWERED HERE, WHICH IS WHY THIS IS AN
// EXCEPTION AND NOT A REVERSAL. Holes are refused on bodies because (a) one hitscan round
// through thirteen zombies spawns thirteen decals in a single frame and (b) a decal is a world
// projection, so it stays where the geometry WAS and slides off a walking model within a
// stride. The per-body cap answers (a) — a corridor gets three marks per body however many
// rounds pass through it — and the one-second life answers (b), because a mark that is gone
// before the next stride never has time to come adrift. Parenting to the body means it does
// not drift at all.
//
// ⚠️ PACKED ROUNDS ONLY, which is the request rather than a limitation: *"i want the pap
// bullet decal to also be placed on zombies"*. An unpacked round still marks nothing, so the
// burn stays the thing that says a gun is packed.
//
// ⚠️ AND IT IS A SEPARATE BUDGET FROM `MaxHoles`, ON PURPOSE: *"this does not contribute to
// the decal limit, instead each zombie has a limit of 3"*. Sharing the queue would let one
// horde push every hole off the walls inside a second of firing — the wall marks are the
// long-lived record of a fight and these are a one-second flourish, so they must not compete
// for the same thirty slots.
/// <summary>Packed rounds burn the bodies they hit too. `nz_decal_flesh 0` turns it off.</summary>
public static bool FleshEnabled { get => _fleshOn ?? true; set => _fleshOn = value; }
static bool? _fleshOn;
/// <summary>
/// How many burns one body may carry at once; the oldest is destroyed to make room. 3.
///
/// ⚠️ THIS IS A PENETRATION NUMBER, NOT A TASTE NUMBER. Penetration here is a literal body
/// counter — a battle rifle is stamped for seven bodies — so the question the cap answers is
/// what one trigger pull does to the zombie NEAREST the muzzle, which takes every pellet of
/// every round on its way to the other six.
/// </summary>
public static int FleshMax { get => _fleshMax ?? 3; set => _fleshMax = value; }
static int? _fleshMax;
/// <summary>Seconds from landing to gone. 1.</summary>
public static float FleshFadeTime { get => _fleshFade ?? 1f; set => _fleshFade = value; }
static float? _fleshFade;
/// <summary>Size on top of the wall burn's. 1 — the same mark, on a body.</summary>
public static float FleshScale { get => _fleshScale ?? 1f; set => _fleshScale = value; }
static float? _fleshScale;
// ⚠️ FOUR NULLABLE-BACKED GETTERS RATHER THAN FOUR INITIALISERS. INSTRUCTIONS.md §1 —
// a static auto-property initialiser does NOT re-run on hotload, so a default changed in code
// arrives at a running editor still holding the old value. This project has been bitten by it
// in PhdAugments, the PaP palette, `GlowEnabled` and the shoot-anim toggle.
/// <summary>
/// The body a burn should be stuck to, or null if this hit does not take one.
///
/// ⛔ THE `ZombieAI` COMPONENT IS THE AUTHORITY, NOT THE TAG, AND THE DIFFERENCE IS THE CAP.
/// Tags are on the CHILDREN too — `head` is its own tagged object, which is the entire reason
/// `IsFlesh` walks ancestors — so a tag search returns whichever body PART was hit, and "three
/// per zombie" quietly becomes three per limb. One component per zombie, one queue per
/// component, three per zombie.
///
/// ⚠️ PLAYERS ARE DELIBERATELY EXCLUDED even though `IsFlesh` covers them. The request was
/// zombies, and friendly fire is off anyway (Health.IsFriendlyFire) — a burn on a team-mate
/// would be a mark with no damage behind it, which reads as a bug in the damage code.
///
/// ⚠️ IT ANSWERS null BEFORE THE WALK FOR AN UNPACKED ROUND, so the ancestor search does not
/// run per pellet for the great majority of shots that could never produce a burn. `papLevel`
/// is free at the call site; the walk is not.
/// </summary>
public static GameObject BurnableBody( GameObject hit, int papLevel, bool energy = false )
{
// ⚠️ THE EARLY-OUT STILL HAS TO BE FREE, which is the whole reason this method exists
// separately from the walk below. An energy round is a flag the caller already holds, so
// adding it costs a boolean and keeps the ancestor search off the hot path for every
// ordinary pellet.
var hot = energy && EnergyEnabled;
if ( !Enabled || !FleshEnabled ) return null;
if ( !hot && (!PapEnabled || papLevel <= 0) ) return null;
if ( !hit.IsValid() ) return null;
GameObject tagged = null;
for ( var o = hit; o.IsValid(); o = o.Parent )
{
if ( o.Components.Get<ZombieAI>( FindMode.EverythingInSelf ) is not null )
return o;
// ⚠️ A FALLBACK FOR THE THINGS THAT ARE ZOMBIE-SHAPED WITHOUT BEING ZOMBIES — the
// Armor perk's decoy is tagged `zombie` and carries no AI (Armor.cs:492). Nearest
// match rather than highest, because with no AI component there is nothing on the
// hierarchy that says where the body stops.
if ( tagged is null && o.Tags.HasAny( "zombie", "ragdoll" ) )
tagged = o;
}
return tagged;
}
/// <summary>
/// Burn a body. Returns null for anything that is not a packed hit on a zombie.
///
/// ⛔ NO FALLBACK TO THE PLAIN HOLE, WHICH IS THE OPPOSITE OF `Spawn`'s RULE AND FOR THE
/// OPPOSITE REASON. On a wall, "no mark at all" reads as a bug, so any mark beats none. On a
/// body the ordinary grey hole is precisely what was deliberately NOT being drawn before this
/// — falling back to it would scatter unpacked-looking holes over zombies as a side effect of
/// a packed-only feature, and would bring back the sliding-decal problem the gate was for.
/// </summary>
public static GameObject SpawnOnFlesh( GameObject body, Vector3 pos, Vector3 normal,
int papLevel, bool energy = false )
{
if ( !body.IsValid() ) return null;
var hot = energy && EnergyEnabled;
if ( !Enabled || !FleshEnabled ) return null;
if ( !hot && (!PapEnabled || papLevel <= 0) ) return null;
// ⚠️ ONE ROLL OF THE TIER COLOUR, held and reused below. `ColourFor` picks a random shade
// within the tier each call, so asking twice would fade the burn toward a shade it never
// landed in — the same trap `Spawn` documents for its cooler.
//
// ⚠️ THE ENERGY BURN IS A FIXED COLOUR AND SO HAS NO SUCH PROBLEM, but it goes through
// the same single variable anyway so everything below stays one code path.
Color tint;
if ( hot ) tint = EnergyBurn;
else if ( PapMuzzleFlash.ColourFor( papLevel ) is Color rolled ) tint = rolled;
else return null;
// ⛔ THE SAME PAIR OF NUMBERS `Spawn` DERIVES, and they have to agree with it. A body and
// the wall behind it get hit by the same round; a burn that changed size or colour
// depending on what stopped it would read as two different weapons firing.
var boost = hot ? EnergyTint : PapTint;
var fleshSize = Scale * PapScale * FleshScale * (hot ? EnergyScale : 1f);
var prefab = PapPrefab;
if ( prefab is null ) return null;
var scene = SceneUtility.GetPrefabScene( prefab );
if ( scene is null ) return null;
var life = MathX.Clamp( FleshFadeTime, 0.05f, 30f );
var go = scene.Clone( new CloneConfig
{
Name = "bullet_burn_flesh",
StartEnabled = true,
Transform = new()
{
Position = pos,
Rotation = Rotation.LookAt( -normal ),
Scale = fleshSize,
},
} );
// ⛔ NETWORK MODE BEFORE THE REPARENT, NOT AFTER. The prefab is authored networked and
// the body it is about to hang from IS a networked object (ZombieCommands:2202), so a clone
// still asking for networking as it is parented is a spawn request from every client that
// already made its own — the same mark five times over in a five-player game. The effect
// is global because the RPC that reaches this is, not because the object is.
go.NetworkMode = NetworkMode.Never;
// ⚠️ `true` KEEPS THE WORLD TRANSFORM, which also divides out whatever `nz_zscale` did to
// the body: a burn on a giant is the same size as a burn on an ordinary zombie, because a
// bullet made it and bullets do not scale with what they hit.
go.SetParent( body, true );
var decal = go.Components.Get<Decal>( FindMode.EverythingInSelfAndDescendants );
if ( decal.IsValid() )
{
var full = tint * boost;
decal.ColorTint = full;
var fade = go.Components.Create<FleshFade>();
fade.Target = decal;
fade.Base = full;
fade.Life = life;
}
// ⚠️ THE FULL LIFE, NOT `HotWallTime`. A wall burn leaves an approved static scar behind
// its flash; a body burn leaves nothing, so cutting the flare short would leave a dull mark
// sitting on the zombie for the rest of the second — which is the look this was asked to
// replace.
AddHotCore( go, tint, Scale * PapScale * FleshScale, life );
// ⚠️ A BACKSTOP, NOT THE MECHANISM. `FleshFade` destroys the object the frame it finishes;
// this is what removes a burn whose decal component could not be found, which is the one
// path where nothing else is left holding the clean-up.
go.DestroyAsync( life + 0.25f );
// ⛔ NOT `Track`. See the section header — the wall queue is a different budget and a
// body must not spend it.
body.Components.GetOrCreate<FleshBurns>()?.Take( go, System.Math.Max( 0, FleshMax ) );
return go;
}
/// <summary>
/// One zombie's burns, oldest first.
///
/// ⛔ A COMPONENT ON THE BODY RATHER THAN A STATIC DICTIONARY KEYED BY IT. A dictionary would
/// hold a reference to every zombie ever shot for as long as the round lasted, because nothing
/// tells a static that a GameObject died — and a round here runs to wave forty. This dies with
/// the zombie it counts and takes the queue with it.
///
/// ⚠️ THE QUEUE IS A PROPERTY, NOT A FIELD INITIALISER, for the reason `Holes` is one:
/// INSTRUCTIONS.md §1, hotload migrates a component and a collection built in an initialiser
/// arrives null on the other side.
/// </summary>
public sealed class FleshBurns : Component
{
System.Collections.Generic.Queue<GameObject> _marks;
System.Collections.Generic.Queue<GameObject> Marks => _marks ??= new();
/// <summary>
/// Add one and shed the excess.
///
/// ⚠️ EVICTING BY QUEUE LENGTH SHEDS THE DEAD FIRST, AND THAT IS THE POINT. Every burn
/// also fades and destroys itself inside a second, so at any fire rate worth capping the
/// front of this queue is mostly expired references — dequeuing one costs nothing and
/// brings the count down without taking a visible mark with it. The LIVE total therefore
/// never exceeds the cap and usually sits under it, which is the right way round: three is
/// a ceiling on what is on the body, not a quota to keep full.
///
/// ⚠️ `while`, NOT `if` — `nz_decal_flesh 1 1` has to shed two marks on the next hit
/// rather than one per hit for the next two hits.
/// </summary>
public void Take( GameObject go, int cap )
{
var q = Marks;
q.Enqueue( go );
while ( q.Count > cap )
{
var oldest = q.Dequeue();
if ( oldest.IsValid() ) oldest.Destroy();
}
}
}
/// <summary>
/// Fade one burn out, then remove it.
///
/// ⚠️ IT DESTROYS WHERE `BurnCool` DISABLES ITSELF, and the two are opposite on purpose. A
/// wall burn cools to a dim scar that has to survive another fifty-nine seconds, so killing it
/// on cool would delete the mark the moment it finished forming; this one IS gone at the end of
/// its ramp, so leaving the object behind would park an invisible decal on a zombie for the
/// rest of that zombie's life — and a horde would accumulate them three at a time.
///
/// ⛔ RGB AND ALPHA BOTH. Whether `Decal.ColorTint`'s alpha reaches the blend is not
/// something this file can assert from here, and a mark that faded to black instead of to
/// nothing would leave a black smear on the body — worse than the mark it replaced. Driving
/// both means it vanishes under either answer, and costs one multiply.
/// </summary>
public sealed class FleshFade : Component
{
[Property] public Decal Target { get; set; }
[Property] public Color Base { get; set; } = Color.White;
[Property] public float Life { get; set; } = 1f;
TimeSince _since;
protected override void OnStart() => _since = 0f;
protected override void OnUpdate()
{
if ( !Target.IsValid() ) { GameObject.Destroy(); return; }
float t = MathX.Clamp( _since / (Life > 0.001f ? Life : 0.001f), 0f, 1f );
// ⚠️ LINEAR, WHERE THE WALL BURN EASES. `BurnCool` eases because heat leaving metal
// is fast then slow; this is a mark being wiped rather than something cooling, and over
// one second an ease reads as a stutter at the end rather than as a curve.
float k = 1f - t;
Target.ColorTint = new Color( Base.r * k, Base.g * k, Base.b * k, Base.a * k );
if ( t >= 1f ) GameObject.Destroy();
}
}
// ── the hot core ───────────────────────────────────────────────────
//
// Requested: *"i need the decal to be a lot more intense, like make it seem like its emitting a
// bright light while not making it emmit light."*
//
// ⛔ `PapTint` CANNOT GET THERE, AND RAISING IT FURTHER MAKES IT WORSE. That knob scales the
// decal's ALBEDO, and albedo is a reflectance: it is multiplied by whatever light already falls
// on the surface, so a burn in a dim corridor is dim no matter what the number says — the one
// place a packed round most wants to read as hot. Its own note already records the ceiling: by
// x3 the tier's SECOND channel starts clipping, so the mark walks toward white and gets PALER
// rather than brighter. That is the opposite of intense.
//
// ⚠️ SO THE INTENSITY IS AN ADDITIVE, UNLIT SPRITE LAID OVER THE MARK. Additive blending
// ADDS to what is already on screen instead of replacing it, and `Lighting = false` takes it out
// of the lighting solution entirely — so it is exactly as bright in a black corridor as in
// daylight, which is what a thing that emits its own light looks like. It emits nothing: no
// PointLight, no shadow, nothing else in the scene changes brightness. The same three lines
// `Powerup` uses for its glow, for the same reason.
//
// ⚠️ AND ABOVE 1 IT BLOOMS RATHER THAN CLIPS. `PapMuzzleFlash.ColourFor` already documents
// that the palette is LINEAR and that *"values above 1 are allowed and bloom rather than clip"*
// — so a colour pushed past 1 blows the core out to white and throws the tier's hue into the
// halo around it. That halo is the whole effect: it is how a bright source reads on camera, and
// it is why this is a multiplier on the tier colour rather than a white sprite.
//
// ⛔ IT FADES, AND ON A WALL IT FADES FAST. A permanent additive quad on each of thirty
// holes is thirty layers of overdraw and a light show on a wall that is supposed to be a scar —
// and the STATIC burn is the look that won a comparison against two animated ones
// (`GlowEnabled`). So the flare is the moment of impact, and what it leaves behind is the mark
// that was already approved. On a body there is nothing to leave behind: the whole decal is
// gone in a second, so the flare rides its full life.
/// <summary>The flare over a packed impact. ON — `nz_decal_hot 0` turns it off.</summary>
public static bool HotCore { get => _hotCore ?? true; set => _hotCore = value; }
static bool? _hotCore;
/// <summary>
/// The engine's soft radial flare, by way of the sprite `Powerup` already uses.
///
/// ⚠️ REUSED RATHER THAN AUTHORED. `textures/particles/flares/light_glow_01` is a radial
/// falloff with no colour of its own, which is precisely what a tinted additive core wants —
/// generating another one would be a second asset to keep and no different on screen.
/// </summary>
public const string GlowSprite = "sprites/nz/powerup_glow.sprite";
/// <summary>
/// The burn's OWN texture, as a sprite, for the layer that carries the detail.
///
/// ⛔ A SMOOTH DISC OVER A TEXTURED MARK IS WHY IT LOOKED WRONG. Reported: *"it looks a lot
/// less detailed than the decal."* Both layers were the same radial flare — a perfect circle
/// with a perfect gradient — sitting on top of a burn that has grain, streaks, ejecta and an
/// irregular edge. The glow was the brightest thing on screen, so the eye read ITS shape and the
/// decal's detail was what got thrown away.
///
/// ⚠️ AND THE TEXTURE NEEDS NO PREPARATION, WHICH IS THE WHOLE REASON THIS IS CHEAP.
/// `gen_pap_decal.py` writes the colour map as luminance — 0.82 at the core, 0.035 over the
/// scorch — with the grain and streaks in the alpha. Additive blending adds in proportion to
/// brightness, so the core comes through hot and the scorch adds essentially nothing: the mask
/// this layer needs is already the image. A dedicated emissive map would be a second texture to
/// keep in step with the first for no visible difference.
///
/// ⚠️ IT IS THE SAME FILE THE DECAL PROJECTS, not a copy, so retuning the burn in the
/// generator moves the mark and its glow together and they cannot drift apart.
/// </summary>
public const string BurnSprite = "sprites/nz/pap_burn_glow.sprite";
/// <summary>
/// Glow width as a multiple of the burn's own width. 0.55.
///
/// ⚠️ TIED TO THE MARK, NOT A WORLD SIZE, so `nz_decal_pap`'s scale still moves both
/// together and a bigger burn does not end up with a glow rattling around inside it.
///
/// ⛔ IT NOW MEASURES THE ONLY LAYER THERE IS. It used to size the smooth halo, with
/// `HotCoreSize` taking a fraction of that for the textured core; 1 × 0.55 was the width that
/// actually reached the screen, so 0.55 here is the same glow, said once instead of twice.
///
/// ⚠️ UNDER 1 BECAUSE THE DECAL'S ROTATION IS RANDOM PER IMPACT (the prefab seeds
/// `Decal.Rotation` across 0—360) and a billboard cannot match it. At full width you would see
/// the same irregular burn twice at two angles — a double image. Kept inside the mark, the
/// mismatch is not legible.
///
/// ⚠️ AND IT SPREADS ON ITS OWN AS `HotPower` RISES, because power lifts dim texels into
/// view rather than only brightening bright ones — so the glow covers more of the mark at 24
/// than at 6 without this number moving at all.
/// </summary>
public static float HotSize { get => _hotSize ?? 0.55f; set => _hotSize = value; }
static float? _hotSize;
/// <summary>
/// Colour multiplier on the flare. 6.
///
/// ⚠️ 24, AND THE MEASUREMENT SAYS THAT IS SAFE RATHER THAN RECKLESS. Requested: *"increase
/// hot power a lot."* The obvious fear is the one the smooth halo actually suffered from — that
/// high power saturates everything into a featureless white disc — so the texture was swept
/// before the number moved. Fraction of the burn with EVERY channel clipped, i.e. pure white:
///
/// power 6 12 20 26 34 44
/// white 0.0% 0.1% 0.3% 0.4% 0.5% 0.7%
/// lit 6.6% 7.3% 11.2% 14.7% 17.8% 20.4%
///
/// ⛔ RAISING POWER ADDS DETAIL HERE, WHICH IS THE OPPOSITE OF WHAT IT DID TO THE HALO, and
/// the texture is why. A smooth radial gradient has most of its area in the mid-tones, so the
/// clip contour sweeps across it and flattens it. The burn's bright region is SMALL and its
/// scorch sits at 0.035, so power lifts the dark parts into view faster than it crushes the
/// light ones: the visible area triples between 6 and 44 while the white area never reaches 1%.
///
/// ⚠️ CLIPPING IS ALSO PER CHANNEL, WHICH IS WHAT KEEPS THE TIER READABLE. A pink round's
/// red saturates long before its green does, so the centre goes white-hot while the edge stays
/// unmistakably pink — pure white needs all three, and a saturated tier colour rarely gets
/// there.
/// </summary>
public static float HotPower { get => _hotPower ?? 24f; set => _hotPower = value; }
static float? _hotPower;
/// <summary>Seconds the flare lasts on a WALL. 0.45 — an impact, not a lamp.</summary>
public static float HotWallTime { get => _hotWall ?? 0.45f; set => _hotWall = value; }
static float? _hotWall;
/// <summary>
/// The burn decal's authored width, in units — `decals/nz/pap_burn.decal` says `Width: 6`.
///
/// ⚠️ A NAMED CONSTANT BECAUSE THE FLARE IS SIZED FROM IT. `Decal.Size` is 1,1 in the prefab
/// and the real footprint comes from the .decal resource, so "how big is this mark in the world"
/// is 6 x the transform scale — not 1 x it, which is the number a reader of the prefab alone
/// would reach for.
/// </summary>
const float DecalWidth = 6f;
/// <summary>
/// Lay an additive flare over a burn and start it dying.
///
/// ⛔ A CHILD OBJECT, NOT A COMPONENT ON THE BURN ITSELF, and for a different reason than
/// `Powerup`'s (which is dodging a tumble). The burn's own transform carries the decal's
/// `Rotation.LookAt( -normal )` and its scale; a sprite billboards toward the camera and sizes
/// itself in world units, so sharing that transform would rotate a billboard that is meant to
/// face the viewer and scale a size that is already absolute.
/// </summary>
static void AddHotCore( GameObject go, Color tint, float worldScale, float life )
{
if ( !HotCore || life <= 0f || !go.IsValid() ) return;
// ⚠️ FALLS BACK TO THE OLD RADIAL FLARE RATHER THAN TO NOTHING. A sprite that fails to
// load is a packaging problem, not a reason for packed rounds to stop glowing — and this
// asset is exactly the kind that goes missing in a published build, which is why
// `sprites/nz/*.sprite` and `decals/nz/*.png` are in the .sbproj's resource list. Degrades
// to a plain glow instead of to a bug report.
var sprite = ResourceLibrary.Get<Sprite>( BurnSprite )
?? ResourceLibrary.Get<Sprite>( GlowSprite );
if ( sprite is null ) return;
var glowGO = new GameObject( true, "burn_glow" );
glowGO.SetParent( go, false );
glowGO.LocalPosition = Vector3.Zero;
// ⚠️ SAID EXPLICITLY EVEN THOUGH THE PARENT ALREADY IS. A runtime GameObject defaults to
// wanting networking, and "it inherits from the parent" is the kind of assumption that
// produced five copies of one mark the first time round.
glowGO.NetworkMode = NetworkMode.Never;
// ⛔ ONE LAYER. The second was a smooth radial flare at full width, and it was removed
// on sight: *"remove this new circle we made, instead increase hot power a lot."* It had
// become the problem it was added to solve — at a quarter of the power across the WHOLE
// width it was simply bigger and brighter than the textured layer inside it, so the shape
// the eye read was a circle and the burn's structure was buried under it.
//
// ⚠️ AND THE ENGINE ALREADY DOES THE JOB IT WAS DOING. A soft glow around a bright
// source is bloom, which the scene applies to anything over 1 — `PapMuzzleFlash.ColourFor`
// says so in as many words. Drawing a second quad to fake it was spending overdraw to
// duplicate a post-process, and the fake had a hard geometric edge the real one does not.
var core = Flare( glowGO, sprite, DecalWidth * worldScale * HotSize, tint, HotPower );
var fade = glowGO.Components.Create<HotFade>();
fade.Core = core;
fade.CoreBase = core.Color;
fade.Life = life;
}
/// <summary>One additive, unlit layer of the flare.</summary>
static SpriteRenderer Flare( GameObject on, Sprite sprite, float width, Color tint, float power )
{
var r = on.Components.Create<SpriteRenderer>();
r.Sprite = sprite;
r.Size = new Vector2( width, width );
// ⚠️ BUILT COMPONENT BY COMPONENT RATHER THAN `tint * power`, so the multiplier cannot
// reach ALPHA. `Color * float` scales all four, and an alpha of 6 is a value no blend mode
// has an opinion about — it either clamps or it does something undefined, and neither is
// worth finding out per bullet.
r.Color = new Color( tint.r * power, tint.g * power, tint.b * power, 1f );
r.Additive = true;
// ⚠️ UNLIT AND SHADOWLESS — it is emissive by definition. `Powerup` carries the same
// pair with the same note: a glow that takes the room's lighting goes dim in exactly the
// dark corner where it most needs to be seen.
r.Lighting = false;
r.Shadows = false;
// ⚠️ SOFTENS WHERE THE QUAD MEETS THE SURFACE. A billboard planted on a wall intersects
// it, and without this the sprite is cut off by a hard diagonal line — which reads as a
// rendering bug rather than as a glow.
r.DepthFeather = 8f;
return r;
}
/// <summary>
/// Drive one flare from full to nothing, then stop drawing it.
///
/// ⚠️ IT DISABLES THE RENDERER AT THE END RATHER THAN JUST REACHING ZERO. An additive sprite
/// at colour zero adds nothing and is invisible, but it is still a quad being rasterised and
/// blended — for another 59.5 seconds, on up to thirty wall burns at once. Switching it off is
/// what makes the cost the impact rather than the scar.
///
/// ⚠️ SQUARED FALLOFF, so it is brightest instantly and gone quickly. A linear ramp on
/// something this bright reads as a lamp being dimmed; an impact flash does not dim, it stops.
/// </summary>
public sealed class HotFade : Component
{
[Property] public SpriteRenderer Core { get; set; }
[Property] public Color CoreBase { get; set; } = Color.White;
[Property] public float Life { get; set; } = 0.45f;
TimeSince _since;
protected override void OnStart() => _since = 0f;
protected override void OnUpdate()
{
if ( !Core.IsValid() ) { Enabled = false; return; }
float t = MathX.Clamp( _since / (Life > 0.001f ? Life : 0.001f), 0f, 1f );
float k = (1f - t) * (1f - t);
Core.Color = new Color( CoreBase.r * k, CoreBase.g * k, CoreBase.b * k, CoreBase.a * k );
// ⚠️ THE CLIP POINT WALKS INWARD AS THIS DIMS, AND THAT IS THE FADE'S REAL SHAPE. At
// ×24 most of the burn is over the ceiling, so the first part of the ramp shrinks the
// white region rather than dimming anything — the mark appears to cool from the edge in
// before it starts to disappear, which is what a cooling burn does.
if ( t >= 1f ) { Core.Enabled = false; Enabled = false; }
}
}
public static GameObject Spawn( Vector3 pos, Vector3 normal, int papLevel = 0,
bool energy = false )
{
if ( !Enabled ) return null;
// ⚠️ FALLS BACK TO THE PLAIN HOLE rather than to nothing, on every way this can fail — the
// feature off, the prefab missing, the tier having no colour. A packed gun that stops
// marking walls at all reads as a bug; one that marks them the ordinary way reads as the
// burn not being enabled, which is what happened.
//
// ⛔ THE ENERGY BURN OUTRANKS THE TIER, and that is the right way round. A packed Prisma
// would otherwise stop leaving its own mark at the moment it became most interesting, and
// the weapon's identity matters more here than which tier bought it.
var hot = energy && EnergyEnabled;
var burn = hot
? EnergyBurn
: (PapEnabled && papLevel > 0 ? PapMuzzleFlash.ColourFor( papLevel ) : null);
// ⚠️ ONE MULTIPLIER FOR BOTH THE CLONE AND THE FLARE, worked out once. They were already
// two copies of `Scale * PapScale`, and a third place to keep in step is how the flare
// ends up a different size from the hole it sits in.
var size = Scale * PapScale * (hot ? EnergyScale : 1f);
var boost = hot ? EnergyTint : PapTint;
var prefab = burn.HasValue ? (PapPrefab ?? Prefab) : Prefab;
if ( prefab is null ) return null;
var scene = SceneUtility.GetPrefabScene( prefab );
if ( scene is null ) return null;
// ⚠️ `LookAt( -normal )` matches what the impact clone already used, so the
// hole faces out of the wall rather than into it. A decal projecting the wrong
// way is invisible, which is indistinguishable from not spawning at all.
var go = scene.Clone( new CloneConfig
{
Name = "bullet_hole",
StartEnabled = true,
Transform = new()
{
Position = pos,
Rotation = Rotation.LookAt( -normal ),
Scale = burn.HasValue ? size : Scale,
},
} );
// ⛔ TINTED AFTER THE CLONE, NOT BY AUTHORING THE PREFAB. The prefab is shared by every
// impact on screen; writing the colour into it would repaint the burns already on the wall
// every time somebody with a different tier fired.
if ( burn is Color tint )
{
var decal = go.Components.Get<Decal>( FindMode.EverythingInSelfAndDescendants );
// ⚠️ `PapTint` APPLIES HERE AND TO THE COOLER'S BASE BOTH, so switching cooling on does
// not quietly halve the intensity by starting from the unboosted colour.
if ( decal.IsValid() ) decal.ColorTint = tint * boost;
// ⚠️ THE COOLER TAKES THE SAME COLOUR OBJECT, never a second `ColourFor` call — that
// picks a random shade each time, so a second roll would cool the burn toward a
// different shade than it landed in.
if ( decal.IsValid() && GlowEnabled && CoolTime > 0f )
{
var cool = go.Components.Create<BurnCool>();
cool.Target = decal;
cool.Base = tint * boost;
cool.Life = CoolTime;
cool.Hot = HotTint;
cool.Cold = ColdTint;
decal.ColorTint = tint * boost * HotTint; // no dim first frame
}
// ⚠️ INSIDE THE TIER BLOCK, so an unpacked hole never gets one. The flare IS the
// packed identity here — a grey hole that flashed would say a gun is packed when it
// is not.
AddHotCore( go, tint, size, HotWallTime );
}
go.NetworkMode = NetworkMode.Never;
go.DestroyAsync( Lifetime );
Track( go );
return go;
}
/// <summary>
/// Queue a hole and evict the oldest once over the cap.
///
/// ⚠️ SKIPS ALREADY-DEAD ENTRIES WHILE EVICTING. Every hole also has a `DestroyAsync`
/// timer, so the queue is full of objects that expired on their own — dequeuing one of
/// those and calling it an eviction would leave the live count above the cap, and the
/// cap would appear to be off by however many had timed out.
///
/// ⚠️ `while`, NOT `if`. A cap lowered at runtime — `nz_decals_max 5` with 30 on the
/// walls — has to shed the excess rather than trim one per shot, or the new limit would
/// take twenty-five more bullets to take effect.
/// </summary>
static void Track( GameObject go )
{
var q = Holes;
q.Enqueue( go );
var cap = System.Math.Max( 0, MaxHoles );
while ( q.Count > cap )
{
var oldest = q.Dequeue();
if ( oldest.IsValid() ) oldest.Destroy();
}
}
/// <summary>
/// Drop expired entries and return how many are actually alive.
///
/// ⚠️ THE QUEUE IS THE AUTHORITY, NOT A SCENE SWEEP. The old count in `nz_decals`
/// walked every object in the scene looking for the name "bullet_hole", which is
/// correct but says nothing about whether the CAP is working — a sweep finding 30 and a
/// queue holding 400 stale references look identical from the outside.
/// </summary>
static int Prune()
{
var q = Holes;
var live = 0;
for ( var i = q.Count; i > 0; i-- )
{
var go = q.Dequeue();
if ( !go.IsValid() ) continue;
q.Enqueue( go );
live++;
}
return live;
}
// ── commands ─────────────────────────────────────────────────────────────
/// <summary>Tune holes: `nz_decals [0/1] [lifetime] [scale]`.</summary>
[ConCmd( "nz_decals" )]
public static void Cmd( int on = -1, float lifetime = -1f, float scale = -1f )
{
if ( on >= 0 ) Enabled = on != 0;
if ( lifetime >= 0f ) Lifetime = MathX.Clamp( lifetime, 1f, 600f );
if ( scale >= 0f ) Scale = MathX.Clamp( scale, 0.05f, 10f );
// ⚠️ BOTH COUNTS, AND THEY SHOULD AGREE. The queue is what the cap acts on; the
// scene sweep is the independent check. A sweep higher than the queue means holes
// are reaching the world by some path that does not go through Track — which is
// exactly the bug this file just fixed, in a new place.
var tracked = Prune();
var swept = Game.ActiveScene?.GetAllObjects( true )
.Count( o => o.Name == "bullet_hole" ) ?? 0;
Log.Info( Enabled
? $"[nz] bullet holes ON — {Lifetime:0.#}s, size ×{Scale:0.##}"
+ $", {tracked}/{MaxHoles} tracked"
+ (swept != tracked ? $", {swept} in the scene ⚠ MISMATCH" : "")
: "[nz] bullet holes off" );
}
/// <summary>
/// `nz_decals_max [n]` — the ceiling, and shed the excess immediately.
///
/// ⚠️ ENFORCED ON THE SPOT rather than at the next bullet. A cap you set and cannot
/// see the effect of until you fire again is a cap nobody can judge.
/// </summary>
[ConCmd( "nz_decals_max" )]
public static void MaxCmd( int max = -1 )
{
if ( max >= 0 )
{
MaxHoles = max;
var q = Holes;
while ( q.Count > MaxHoles )
{
var oldest = q.Dequeue();
if ( oldest.IsValid() ) oldest.Destroy();
}
}
Log.Info( $"[nz] bullet holes capped at {MaxHoles} — {Prune()} on the walls" );
}
/// <summary>`nz_decals_clear` — wipe every hole now.</summary>
[ConCmd( "nz_decals_clear" )]
public static void ClearCmd()
{
var q = Holes;
var n = 0;
while ( q.Count > 0 )
{
var go = q.Dequeue();
if ( !go.IsValid() ) continue;
go.Destroy();
n++;
}
Log.Info( $"[nz] cleared {n} bullet hole(s)" );
}
/// <summary>
/// `nz_decal_pap [0|1] [scale] [tint]` — the packed burn: switch it off, change how much
/// wider it is than an ordinary hole, or how intense its colour is. Bare command reports.
///
/// ⚠️ `tint` IS THE INTENSITY KNOB and it is the one to reach for first. See `PapTint` —
/// above 1 saturates the tier's dominant channel instead of washing the mark toward white.
/// </summary>
[ConCmd( "nz_decal_pap" )]
public static void PapCmd( int on = -1, float scale = -1f, float tint = -1f )
{
if ( on >= 0 ) PapEnabled = on != 0;
if ( scale > 0f ) PapScale = scale;
if ( tint > 0f ) PapTint = tint;
Log.Info( $"[nz-decal] packed burn {(PapEnabled ? "ON" : "off")}, x{PapScale:0.##} the"
+ $" size of a hole, colour x{PapTint:0.##}"
+ $" (hole scale {Scale:0.##}, cap {MaxHoles}, life {Lifetime:0}s)" );
Log.Info( $"[nz-decal] asset {PapDecalPrefab}"
+ (PapPrefab is null ? " ⛔ MISSING — packed shots fall back to holes" : " ok") );
Log.Info( $"[nz-decal] cooling {(GlowEnabled ? "ON" : "off")}"
+ $" — tint x{HotTint:0.##} -> x{ColdTint:0.##} over {CoolTime:0.##}s" );
}
/// <summary>
/// `nz_decal_energy [on] [scale] [tint]` — the energy weapon's burn. Bare, it reports.
/// </summary>
[ConCmd( "nz_decal_energy" )]
public static void EnergyCmd( int on = -1, float scale = -1f, float tint = -1f )
{
if ( on >= 0 ) EnergyEnabled = on > 0;
if ( scale > 0f ) EnergyScale = scale;
if ( tint > 0f ) EnergyTint = tint;
Log.Info( $"[nz-decal] energy burn {(EnergyEnabled ? "on" : "off")}"
+ $" · ×{EnergyScale:0.##} the pap burn (final {Scale * PapScale * EnergyScale:0.##})"
+ $" · tint ×{EnergyTint:0.##}" );
Log.Info( $"[nz-decal] colour ({EnergyBurn.r:0.00}, {EnergyBurn.g:0.00},"
+ $" {EnergyBurn.b:0.00}) → lit ({EnergyBurn.r * EnergyTint:0.00},"
+ $" {EnergyBurn.g * EnergyTint:0.00}, {EnergyBurn.b * EnergyTint:0.00})" );
}
/// <summary>
/// `nz_decal_energy_colour <r> <g> <b>` — 0-1 or 0-255, it works out which.
/// </summary>
[ConCmd( "nz_decal_energy_colour" )]
public static void EnergyColourCmd( float r = -1f, float g = -1f, float b = -1f )
{
if ( r < 0f || g < 0f || b < 0f ) { EnergyCmd(); return; }
if ( r > 1f || g > 1f || b > 1f ) { r /= 255f; g /= 255f; b /= 255f; }
EnergyBurn = new Color( r.Clamp( 0f, 1f ), g.Clamp( 0f, 1f ), b.Clamp( 0f, 1f ) );
EnergyCmd();
}
/// <summary>
/// `nz_decal_glow [0|1] [brightness] [radius] [life]` — the cooling flash at a packed impact.
///
/// ⚠️ SEPARATE FROM `nz_decal_pap` BECAUSE THEY FAIL SEPARATELY. The burn is a decal and the
/// heat is a light; "the mark is right but the glow is wrong" is exactly the report this
/// came from, and one command for both would make that untunable.
/// </summary>
[ConCmd( "nz_decal_glow" )]
public static void GlowCmd( int on = -1, float hot = -1f, float cold = -1f, float time = -1f )
{
if ( on >= 0 ) GlowEnabled = on != 0;
if ( hot > 0f ) HotTint = hot;
if ( cold >= 0f ) ColdTint = cold;
if ( time > 0f ) CoolTime = time;
Log.Info( $"[nz-decal] burn cooling {(GlowEnabled ? "ON" : "off")} — tint x{HotTint:0.##}"
+ $" at impact, x{ColdTint:0.##} once cool, {CoolTime:0.##}s to get there" );
Log.Info( "[nz-decal] no light is emitted — this is the decal's own colour" );
Log.Info( "[nz-decal] nz_decal_papwall puts one of every tier up" );
}
/// <summary>
/// `nz_decal_hot [0|1] [power] [size] [wall-seconds]` — the flare over a packed impact.
///
/// ⚠️ SEPARATE FROM `nz_decal_glow`, WHICH IS A DIFFERENT EFFECT WITH A CONFUSINGLY SIMILAR
/// NAME. That one ramps the DECAL'S OWN TINT from hot to cold and is off because it lost a
/// comparison; this is an additive sprite laid over the top. Folding them together would make
/// the rejected effect impossible to revisit, which is the only reason its code is still here.
///
/// ⚠️ `power` IS THE ONE TO TURN. See `HotPower` — it is not bounded by a reflectance the
/// way `nz_decal_pap`'s tint is, so it keeps paying instead of washing toward white.
/// </summary>
[ConCmd( "nz_decal_hot" )]
public static void HotCmd( int on = -1, float power = -1f, float size = -1f, float wall = -1f )
{
if ( on >= 0 ) HotCore = on != 0;
if ( power > 0f ) HotPower = power;
if ( size > 0f ) HotSize = size;
if ( wall >= 0f ) HotWallTime = wall;
var width = DecalWidth * Scale * PapScale * HotSize;
Log.Info( $"[nz-decal] impact flare {(HotCore ? "ON" : "off")} — {width:0.#} units wide,"
+ $" {HotSize:0.##}x the burn, colour x{HotPower:0.##}" );
// ⚠️ PRINTS THE CLIP POINT, WHICH IS THE NUMBER THAT ACTUALLY EXPLAINS THE LOOK. The
// layer saturates wherever its texture exceeds 1/power, so that figure says how much of the
// burn is rendering flat — "x24" says nothing, "everything above 0.04 is at the ceiling"
// says why it looks the way it does.
Log.Info( $"[nz-decal] clips above {(HotPower > 0f ? 1f / HotPower : 1f):0.###}"
+ " — per CHANNEL, so a tier's strong channel whites out while its weak one still"
+ " carries the hue" );
// ⚠️ SAYS WHICH SPRITE THE CORE ACTUALLY GOT. The fallback is silent by design, and a
// silent fallback you cannot see is how the old `Instance?.AddDecal` no-op survived for
// months — if the detail is missing, this line is the first place to look.
Log.Info( ResourceLibrary.Get<Sprite>( BurnSprite ) is null
? $"[nz-decal] sprite ⛔ {BurnSprite} MISSING — fell back to the smooth flare,"
+ " so the core has no detail"
: $"[nz-decal] core sprite {BurnSprite} — the burn's own texture, so it glows with the"
+ " same grain and streaks the decal has" );
Log.Info( $"[nz-decal] {HotWallTime:0.##}s on a wall, {FleshFadeTime:0.##}s on a zombie"
+ " — a body's burn is gone with it, a wall's leaves the scar behind" );
Log.Info( "[nz-decal] additive and unlit: it emits NO light, nothing else in the scene"
+ " changes brightness" );
Log.Info( ResourceLibrary.Get<Sprite>( GlowSprite ) is null
? $"[nz-decal] ⛔ {GlowSprite} MISSING — no flare will draw"
: $"[nz-decal] sprite {GlowSprite} ok" );
}
/// <summary>
/// `nz_decal_flesh [0|1] [max] [fade] [scale]` — the burn a packed round leaves on a zombie.
///
/// ⚠️ ITS OWN COMMAND BECAUSE IT IS ITS OWN BUDGET. `nz_decals_max` moves the WALL cap and
/// has nothing to say about bodies; one command for both would make 30 and 3 look like the same
/// number turned down, which is exactly the confusion the separate queue exists to prevent.
/// </summary>
[ConCmd( "nz_decal_flesh" )]
public static void FleshCmd( int on = -1, int max = -1, float fade = -1f, float scale = -1f )
{
if ( on >= 0 ) FleshEnabled = on != 0;
if ( max >= 0 ) FleshMax = max;
if ( fade > 0f ) FleshFadeTime = fade;
if ( scale > 0f ) FleshScale = scale;
Log.Info( $"[nz-decal] burns on zombies {(FleshEnabled ? "ON" : "off")} — {FleshMax} per body,"
+ $" gone in {FleshFadeTime:0.##}s, size ×{Scale * PapScale * FleshScale:0.##}" );
Log.Info( "[nz-decal] packed rounds only — an unpacked hit still marks nothing" );
Log.Info( $"[nz-decal] a separate budget from the wall cap ({MaxHoles}); bodies do not spend it" );
}
/// <summary>
/// `nz_decal_fleshtest [tier]` — burn the zombie under the crosshair five times, so the mark,
/// the fade and the cap can all be judged without packing a gun and finding a horde.
///
/// ⚠️ FIVE, NOT THREE, AND THAT IS THE WHOLE TEST. Three would look identical whether the cap
/// works or not; the two extra are what show the oldest being evicted rather than accumulating.
/// If five stay on the body, `FleshBurns` is not being reached.
///
/// ⚠️ IT REPORTS THE SWITCHES BEFORE THE TRACE, because "nothing happened" has four possible
/// causes here — decals off, packed burns off, body burns off, or not looking at a zombie —
/// and three of them are invisible from in front of the monitor.
/// </summary>
[ConCmd( "nz_decal_fleshtest" )]
public static void FleshTest( int tier = 3 )
{
if ( !Enabled || !FleshEnabled || !PapEnabled )
{
Log.Warning( $"[nz-decal] nothing will draw — holes {(Enabled ? "on" : "OFF")},"
+ $" packed burns {(PapEnabled ? "on" : "OFF")},"
+ $" body burns {(FleshEnabled ? "on" : "OFF")}" );
Log.Warning( "[nz-decal] nz_decals 1 / nz_decal_pap 1 / nz_decal_flesh 1" );
return;
}
var player = NZPlayer.Local;
var controller = player?.Components.Get<PlayerController>();
if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }
tier = System.Math.Max( 1, System.Math.Min( tier, PapMuzzleFlash.ActivePalettes.Length ) );
var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;
var rot = controller?.EyeAngles.ToRotation() ?? player.WorldRotation;
var tr = Game.ActiveScene.Trace.Ray( eye, eye + rot.Forward * 4096f )
.IgnoreGameObjectHierarchy( player.GameObject )
.Run();
var body = BurnableBody( tr.GameObject, tier );
if ( !body.IsValid() )
{
Log.Info( tr.Hit
? $"[nz-decal] '{tr.GameObject?.Name}' is not a zombie — face one"
: "[nz-decal] nothing under the crosshair" );
return;
}
// ⚠️ SPACED ALONG THE BODY'S OWN UP, not the world's, so the row lies along a crawler or
// a ragdoll rather than climbing out of it — the same reason `nz_decal_papwall` steps along
// the surface it hit instead of along the view.
var up = body.WorldRotation.Up;
var made = 0;
for ( var i = 0; i < 5; i++ )
if ( SpawnOnFlesh( body, tr.HitPosition + up * ((i - 2) * 6f), tr.Normal, tier ).IsValid() )
made++;
Log.Info( $"[nz-decal] {made} burn(s) MK{tier} on '{body.Name}' — {FleshMax} should be left"
+ $" on it, all gone in {FleshFadeTime:0.##}s" );
}
/// <summary>
/// `nz_decal_papwall` — put one burn of every tier on the wall you are looking at, so the
/// five can be compared without packing five guns.
///
/// ⚠️ SPACED ALONG THE SURFACE, not stacked — five decals at one point is one decal you can
/// see and four you cannot, which would look exactly like the tint not working.
/// </summary>
[ConCmd( "nz_decal_papwall" )]
public static void PapWall()
{
var player = NZPlayer.Local;
var controller = player?.Components.Get<PlayerController>();
if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }
var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;
var rot = controller?.EyeAngles.ToRotation() ?? player.WorldRotation;
var tr = Game.ActiveScene.Trace.Ray( eye, eye + rot.Forward * 4096f )
.IgnoreGameObjectHierarchy( player.GameObject )
.Run();
if ( !tr.Hit ) { Log.Info( "[nz] nothing to hit — face a wall" ); return; }
// Step along the surface rather than along the view, so the row lies flat on whatever
// was hit instead of drifting off it on an angled wall.
var right = Vector3.Cross( tr.Normal, Vector3.Up ).Normal;
if ( right.Length < 0.1f ) right = Vector3.Cross( tr.Normal, Vector3.Forward ).Normal;
int made = 0;
for ( int tier = 1; tier <= PapMuzzleFlash.ActivePalettes.Length; tier++ )
{
var at = tr.HitPosition + right * ((tier - 3) * 14f);
if ( Spawn( at, tr.Normal, tier ).IsValid() ) made++;
}
Log.Info( $"[nz-decal] {made} burn(s) MK1..MK{PapMuzzleFlash.ActivePalettes.Length} on the wall" );
}
/// <summary>Put one where you are looking: `nz_decal_test`.</summary>
[ConCmd( "nz_decal_test" )]
public static void Test()
{
var player = NZPlayer.Local;
var controller = player?.Components.Get<PlayerController>();
if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }
var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;
var fwd = (controller?.EyeAngles.ToRotation() ?? player.WorldRotation).Forward;
var tr = Game.ActiveScene.Trace.Ray( eye, eye + fwd * 4096f )
.IgnoreGameObjectHierarchy( player.GameObject )
.Run();
if ( !tr.Hit ) { Log.Info( "[nz] nothing to hit" ); return; }
var go = Spawn( tr.HitPosition, tr.Normal );
// ⚠️ Reports the child count, because an empty GameObject is exactly the
// failure this feature was built to fix — "it spawned" was the misleading
// answer last time.
Log.Info( go.IsValid()
? $"[nz] hole at {tr.HitPosition} — {go.Children.Count} child object(s), "
+ $"{go.Components.GetAll( FindMode.EverythingInSelfAndDescendants ).Count()} component(s)"
: "[nz] no hole — prefab missing or decals off" );
}
}