Static utility that manages Pack-a-Punch muzzle flash colours and spawning. It stores default and camo-specific colour palettes, exposes console commands to inspect and tweak palettes and flash settings, tints local particle effects, and spawns short-lived non-shadowing point lights parented to the shooter to simulate the flash.
using Sandbox;
using System.Collections.Generic;
using System;
namespace NZombies;
/// <summary>
/// THE PACK-A-PUNCH MUZZLE FLASH — a coloured light thrown off every shot, per upgrade tier.
///
/// ⚠️ IT WAS VIOLET-ONLY AND THE REST OF THIS HEADER STILL DESCRIBES THAT PORT, correctly — the
/// mechanism is unchanged and only the palette moved. See `Palettes`.
///
/// Ported from `entities/effects/muz_pap/init.lua`. That effect has two halves: a
/// particle system fed a five-colour palette, and a DYNAMIC LIGHT at the muzzle
/// tinted from the same palette.
///
/// ⛔ THE LIGHT IS THE PART THAT SELLS IT, and it is the half worth porting first.
/// The particle is a `.pcf` we cannot read, but the thing you actually notice in
/// game is the room going purple on every shot — a packed weapon lights the walls,
/// the zombies and your own hands in violet. That is reproducible exactly, because
/// it is just a coloured point light with a short life.
///
/// ⚠️ Colours are the original's defaults, straight from
/// `sv_mapsettings.lua:525` — five violets, one picked at random per shot. The
/// randomness matters: a single fixed purple reads as a filter, five reads as fire.
/// </summary>
public static class PapMuzzleFlash
{
/// <summary>
/// `nzMapping.Settings.papmuzzlecol`, verbatim.
///
/// ⚠️ Stored as 0-1 vectors in the lua and converted here — they are multiplied
/// by 255 for the dlight there, which means they are already linear colour, not
/// gamma. Passing them straight to a light Color is the same operation.
/// </summary>
public static readonly Color[] Palette =
{
new( 0.470f, 0f, 1f ),
new( 0.431f, 0.156f, 1f ),
new( 0.647f, 0.549f, 1f ),
new( 0.196f, 0.078f, 0.431f ),
new( 0.235f, 0.078f, 0.705f ),
};
/// <summary>
/// ONE PALETTE PER PACK-A-PUNCH TIER. Index 0 is MK1.
///
/// ⛔ MK1 IS NO LONGER THE CANONICAL VIOLET, BY REQUEST. `Palette` above still holds
/// `nzMapping.Settings.papmuzzlecol` verbatim and is still the record of what the original
/// used, but nothing reads it for gameplay any more — the ladder chosen here is
/// green / orange / pink / dark blue / red. Kept rather than deleted because it is the only
/// place the ported value is written down, and a future "what did the original look like"
/// has nowhere else to go.
///
/// ⚠️ MK4 IS THE ONE TO WATCH. Dark blue is the dimmest of the five by a wide margin — at the
/// 2.4x light multiplier it clamps to roughly (0.12, 0.29, 1), so it stays blue and readable
/// but throws far less light than its neighbours. That is what "dark blue" means and it is
/// deliberate; if it disappears in play, raise the blue channel rather than the others.
///
/// ⚠️ FIVE SHADES EACH, BECAUSE THE PER-SHOT VARIATION IS THE POINT. `Fire` picks one at random
/// per trigger pull; a tier that was a single flat colour would read as a decal rather than as a
/// muzzle flash. The shades stay close enough together that the TIER is what you read, not the
/// shade — see `ColourFor`.
///
/// ⚠️ THE PROGRESSION IS A HUE WALK, NOT A BRIGHTNESS RAMP. Every tier has to be identifiable in
/// a dark room at a glance and while moving, so they are spaced around the wheel — green,
/// orange, pink, dark blue, red — rather than five shades of one colour, which would be four
/// nobody can tell apart plus one.
///
/// ⚠️ LINEAR, NOT GAMMA, like `Palette`'s own note says. Values above 1 are intentional on the
/// hot tiers: these are multiplied into a light colour by `Brightness`, so a channel at 1.2
/// blooms rather than clips.
/// </summary>
/// ⛔ A PROPERTY FED BY A METHOD, NOT A FIELD INITIALISER, AND THAT IS NOT STYLE. A static's
/// initialiser runs ONCE, when the static is first touched — a hotload swaps the code and
/// keeps the state. Editing the colours below and recompiling therefore changed nothing in a
/// running session: the editor went green, the source said green/orange/pink, and the game
/// kept firing violet and cyan. Verified by asking `nz_pap_colours`, not by reading the file.
///
/// ⚠️ `NewPalettes()` IS CODE, SO A HOTLOAD DOES REPLACE IT. `nz_pap_colours_reset` calls it
/// and reassigns — which is why the setter is private rather than the whole thing readonly.
/// That one command is the difference between "the source is right" and "the game is right".
public static Color[][] Palettes { get; private set; } = NewPalettes();
/// <summary>The canonical tier palette. The ONLY place these numbers are written.</summary>
static Color[][] NewPalettes() => new Color[][]
{
new Color[] // MK1 — green
{
new( 0.235f, 1f, 0.235f ),
new( 0.470f, 1f, 0.313f ),
new( 0.078f, 0.784f, 0.156f ),
new( 0.627f, 1f, 0.470f ),
new( 0.117f, 0.627f, 0.196f ),
},
new Color[] // MK2 — orange
{
new( 1f, 0.450f, 0.050f ),
new( 1f, 0.549f, 0.156f ),
new( 0.900f, 0.350f, 0f ),
new( 1f, 0.650f, 0.313f ),
new( 0.784f, 0.300f, 0f ),
},
new Color[] // MK3 — pink
{
new( 1f, 0.156f, 0.650f ),
new( 1f, 0.350f, 0.784f ),
new( 0.900f, 0.078f, 0.500f ),
new( 1f, 0.549f, 0.862f ),
new( 0.820f, 0.100f, 0.549f ),
},
new Color[] // MK4 — dark blue
{
new( 0.050f, 0.120f, 1f ),
new( 0.120f, 0.250f, 1f ),
new( 0.020f, 0.060f, 0.784f ),
new( 0.250f, 0.400f, 1f ),
new( 0.030f, 0.090f, 0.549f ),
},
new Color[] // MK5 — red
{
new( 1f, 0.156f, 0.078f ),
new( 1f, 0.313f, 0.196f ),
new( 0.900f, 0f, 0f ),
new( 1f, 0.549f, 0.431f ),
new( 1.200f, 0.235f, 0.117f ),
},
};
/// <summary>
/// `nz_pap_colours_reset` — push the palette from source into the RUNNING game.
///
/// ⛔ IT EXISTS BECAUSE RECOMPILING IS NOT ENOUGH. See `Palettes`. After editing the colours,
/// this is the step that makes the session agree with the file — without it the muzzle flash,
/// the tracer and the burn decal all keep the palette the session started with, and every
/// report reads correct because they are all reading the same stale array.
/// </summary>
[ConCmd( "nz_pap_colours_reset" )]
public static void ResetColours()
{
Palettes = NewPalettes();
_camoPalettesFor = null;
_camoPalettes = null;
Log.Info( $"[nz-pap] palette reloaded from source — {ActivePalettes.Length} tier(s)" );
ColoursCmd();
}
// ── a camo's own palette ──────────────────────────────────────────────────────────────────
/// <summary>
/// The tier palettes a camo brings with it, or null for one that has none (Crazy Place and Silver Etching wear
/// <see cref="Palettes"/>, which were chosen to match Crazy Place in the first place).
///
/// ⛔ BASALT'S HEX CAMO WEARS ITS OWN, BY REQUEST (2026-09-28): *"the bullet tracer and decal of pack a punch, the color
/// must match these new camos on basalt"*. Each tier is the camo's seam light — the colours `Tools/basalt_hex_camo.py`
/// paints — taken to linear: the colour, a quarter of the way to its hot core, a fifth darker, over half-way to the core,
/// and a fifth over 1 to bloom. Change a colour there, change it here.
///
/// ⚠️ A SIXTH TIER. MK6 is basalt's Easter egg's, and its purple is the egg's own flame; the default ladder has five and
/// clamps MK6 to its MK5.
/// </summary>
static Color[][] NewCamoPalettes( string camoId ) => camoId switch
{
"basalt_hex" => new Color[][]
{
new Color[] // MK1 — the map's strip white, #fff1d6
{
new( 1f, 0.880f, 0.672f ), new( 1f, 0.910f, 0.754f ), new( 0.800f, 0.704f, 0.538f ),
new( 1f, 0.946f, 0.853f ), new( 1.200f, 1.056f, 0.807f ),
},
new Color[] // MK2 — the Easter egg's red, #ff2a1a
{
new( 1f, 0.023f, 0.010f ), new( 1f, 0.131f, 0.103f ), new( 0.800f, 0.019f, 0.008f ),
new( 1f, 0.261f, 0.214f ), new( 1.200f, 0.028f, 0.012f ),
},
new Color[] // MK3 — the Easter egg's yellow, #ffc400
{
new( 1f, 0.552f, 0f ), new( 1f, 0.634f, 0.113f ), new( 0.800f, 0.442f, 0f ),
new( 1f, 0.732f, 0.248f ), new( 1.200f, 0.662f, 0f ),
},
new Color[] // MK4 — the Easter egg's green, #1eff3c
{
new( 0.013f, 1f, 0.045f ), new( 0.156f, 1f, 0.195f ), new( 0.010f, 0.800f, 0.036f ),
new( 0.327f, 1f, 0.375f ), new( 0.016f, 1.200f, 0.054f ),
},
new Color[] // MK5 — the Easter egg's blue, #2a55ff
{
new( 0.023f, 0.091f, 1f ), new( 0.154f, 0.229f, 1f ), new( 0.019f, 0.073f, 0.800f ),
new( 0.311f, 0.395f, 1f ), new( 0.028f, 0.109f, 1.200f ),
},
new Color[] // MK6 — the Easter egg's purple flame, #a64dff
{
new( 0.381f, 0.074f, 1f ), new( 0.508f, 0.235f, 1f ), new( 0.305f, 0.059f, 0.800f ),
new( 0.660f, 0.427f, 1f ), new( 0.458f, 0.089f, 1.200f ),
},
},
_ => null,
};
// ⚠️ HELD, NOT BUILT PER SHOT: `ColourFor` runs on every flash, tracer and burn. Rebuilt when the camo changes, and by
// `nz_pap_colours_reset` — the same rule as `Palettes`: a hotload keeps what a static holds (INSTRUCTIONS.md §1).
static string _camoPalettesFor;
static Color[][] _camoPalettes;
/// <summary>
/// The palettes every packed shot is coloured from right now: the active camo's own (<see cref="NewCamoPalettes"/>),
/// or the default ladder. `PapCamo.ActiveId` is the map's config, or `nz_camo_set` — so the flash, the tracers and the
/// burn follow the gun's camo wherever it changes.
/// </summary>
public static Color[][] ActivePalettes
{
get
{
var id = PapCamo.ActiveId;
if ( _camoPalettesFor != id )
{
_camoPalettesFor = id;
_camoPalettes = NewCamoPalettes( id );
}
return _camoPalettes ?? Palettes;
}
}
/// <summary>
/// A shot's colour for a Pack-a-Punch level. 0 or below means unpacked.
///
/// ⛔ THE ONE AUTHOR FOR EVERY PATH — the local flash, the relayed flash, the local tracer and
/// the relayed tracer all come through here. Four call sites each doing their own
/// `Random.FromArray` on their own palette is how a remote violet drifts from a local one, which
/// `PackedStreak`'s own note already warned about when there was only one palette to get wrong.
///
/// ⚠️ CLAMPS RATHER THAN RETURNING NULL for a level past the end. `PapSettings.Tiers` is
/// per-map config and could be raised above the palettes here; a packed gun with no colour would
/// present as an UNPACKED gun, which reads as the upgrade having failed.
/// </summary>
public static Color? ColourFor( int papLevel )
{
if ( !Enabled || papLevel <= 0 ) return null;
// ⚠️ THE ACTIVE CAMO'S PALETTE — basalt's hexes bring their own (see `ActivePalettes`)
var palettes = ActivePalettes;
var tier = palettes[Math.Min( papLevel - 1, palettes.Length - 1 )];
return tier is { Length: > 0 } ? Game.Random.FromArray( tier ) : null;
}
/// <summary>
/// `nz_pap_colours` — print every tier's palette, or retune one live:
/// `nz_pap_colours <tier> <r> <g> <b> [shade]`.
///
/// ⚠️ SHADE DEFAULTS TO ALL FIVE, which flattens that tier's per-shot variation — deliberately,
/// because "what does this tier look like as one colour" is the question you ask while tuning.
/// Pass a shade index to put the variation back one entry at a time.
///
/// ⚠️ RGB IS LINEAR 0-1, not 0-255 and not gamma — `Palette`'s note explains why. Values above
/// 1 are allowed and bloom rather than clip.
///
/// ⚠️ THE CHANGE IS LIVE BUT NOT SAVED. These are code defaults, not config, so a retune lasts
/// until the next hotload — find a colour here, then put it in `Palettes`.
/// </summary>
[ConCmd( "nz_pap_colours" )]
public static void ColoursCmd( int tier = -1, float r = -1f, float g = -1f, float b = -1f,
int shade = -1 )
{
// ⚠️ THE ACTIVE CAMO'S PALETTES — basalt's hexes on basalt — are what is listed and retuned
var palettes = ActivePalettes;
if ( tier >= 1 && tier <= palettes.Length && r >= 0f && g >= 0f && b >= 0f )
{
var pal = palettes[tier - 1];
// ⚠️ EVERY TIER OWNS ITS ARRAY NOW. MK1 used to BE `Palette` by reference, so retuning
// tier 1 also rewrote the canonical violet; since MK1 became green that aliasing is gone
// and `Palette` is a record nothing writes.
if ( shade >= 0 && shade < pal.Length ) pal[shade] = new Color( r, g, b );
else for ( int i = 0; i < pal.Length; i++ ) pal[i] = new Color( r, g, b );
Log.Info( $"[nz-pap] MK{tier} {(shade >= 0 ? $"shade {shade}" : "all shades")}"
+ $" = {r:0.###},{g:0.###},{b:0.###}" );
}
Log.Info( $"[nz-pap] muzzle flash + tracer + burn colour by tier (flash {(Enabled ? "on" : "OFF")})"
+ $" — {(ReferenceEquals( palettes, Palettes ) ? "the default ladder" : $"the {PapCamo.ActiveId} camo's own")}" );
for ( int t = 0; t < palettes.Length; t++ )
{
var pal = palettes[t];
var shades = string.Join( " ", pal.Select( c => $"{c.r:0.##},{c.g:0.##},{c.b:0.##}" ) );
Log.Info( $"[nz-pap] MK{t + 1} {shades}" );
}
var cap = NZPlayer.PapMaxLevel; // the cap as it stands: MK6 once basalt's Easter egg is complete
if ( cap > palettes.Length )
Log.Warning( $"[nz-pap] ⚠ this map allows {cap} tiers but only {palettes.Length} palettes"
+ " exist — anything above clamps to the top one" );
}
/// <summary>Master switch — `nz_pap_flash 0` to compare against a plain shot.</summary>
public static bool Enabled { get; set; } = true;
/// <summary>
/// How far the light reaches. 85 — the original's default is 144 and this was 190.
///
/// ⛔ THE LIGHT WAS THE LOUDEST THING IN THE ROOM AND IT SHOULD NOT HAVE BEEN. Requested:
/// *"tune down a lot the light emission from pap muzzle flash"*. At 190 it reached a third
/// further than the original it was modelled on, and every shot of an automatic weapon
/// repainted the walls — the effect stopped reading as a gun flashing and started reading as
/// a strobe attached to the player.
///
/// ⚠️ RADIUS AND BRIGHTNESS ARE BOTH CUT, NOT ONE OF THEM, because they do different jobs
/// and cutting only one trades a problem for another. Brightness alone leaves a dim wash over
/// the same wide area — still a lit room, just a murkier one. Radius alone leaves the same
/// intensity in a tighter pool, which reads as brighter, not calmer. Together they take the
/// light back to a glow around the barrel.
/// </summary>
public static float Radius { get; set; } = 85f;
/// <summary>
/// How bright. 0.8 — was 2.4. The original clamps its equivalent at 5.
///
/// ⚠️ THE COLOUR IS UNTOUCHED AND THAT IS THE POINT OF CUTTING THE LIGHT RATHER THAN THE
/// PALETTE. The per-tier tint on the flash, the tracer and the decal is what says a weapon is
/// packed and which tier it is; the LIGHT is only how far that colour is thrown onto the
/// scenery. Dimming the palette instead would have made the identity harder to read while
/// leaving the room just as lit.
///
/// ⚠️ `ParticleScale` IS DELIBERATELY NOT TOUCHED. That is the flash SPRITE — a packed gun
/// still looks like it is straining, which is the half of the effect that reads on the weapon
/// itself rather than on the walls. `nz_pap_flash -1 -1 -1 <scale>` if that wants cutting too.
/// </summary>
public static float Brightness { get; set; } = 0.8f;
/// <summary>
/// How much bigger a packed weapon's own muzzle particle gets.
///
/// The original exposes this as `nz_pap_muzzleflash_size`. A packed gun should
/// look like it is straining, not merely tinted.
/// </summary>
public static float ParticleScale { get; set; } = 1.5f;
/// <summary>
/// Flash duration, derived from fire rate.
///
/// ⚠️ FASTER GUNS GET SHORTER FLASHES, which is the original's own rule
/// (muz_pap:41 — 0.2s at 60rpm down to 0.05s at 600). Without it an automatic
/// weapon's flashes overlap into one continuous purple glow and the individual
/// shots stop reading.
/// </summary>
public static float LifeFor( float rpm )
=> MathX.Clamp( MathX.Lerp( 0.2f, 0.05f, rpm / 600f ), 0.05f, 0.2f );
/// <summary>
/// Throw a flash at this muzzle.
///
/// ⚠️ Parented to the MUZZLE object, so it follows the gun through recoil and
/// movement for its whole life rather than hanging in the air where the shot
/// started. The original re-positions its dlight every frame for the same
/// reason; parenting gets it for free.
/// </summary>
/// <summary>
/// Colour the weapon's OWN muzzle flash particle, and throw the light, using
/// ONE colour for both.
///
/// ⛔ ONE COLOUR PER SHOT, SHARED — a deliberate divergence. The original tints
/// its light from the palette (`cointoss`) while handing the particle system all
/// five at once as control points, because its .pcf varies them internally.
/// Ours cannot do that, and picking independently for the two halves gives a
/// lavender flash throwing a deep-violet light — two effects rather than one.
/// Sharing the pick keeps each shot a single colour and lets the VARIATION live
/// between shots, which is where it reads anyway.
/// </summary>
public static void Fire( GameObject muzzle, GameObject follow, float rpm,
GameObject flashParticle, int papLevel )
{
// ⚠️ AN UNPACKED GUN GETS NO TINT AT ALL — it keeps whatever the weapon's own flash effect
// authored, which is the behaviour that has always been right and was previously expressed
// by never calling this. The call site does not know that, so the gate lives here.
if ( ColourFor( papLevel ) is not Color colour ) return;
TintParticle( flashParticle, colour );
Spawn( muzzle, follow, rpm, colour );
}
/// <summary>
/// Recolour every emitter in a spawned muzzle-flash effect.
///
/// ⛔ `ApplyColor` MUST BE SET, not just `Tint`. The tint is gated behind it, so
/// assigning a colour to a prefab authored with colour application off changes
/// nothing at all — and looks exactly like the tint not working.
///
/// ⚠️ EVERY ParticleEffect in the hierarchy, not just the first. A muzzle flash
/// prefab is usually several emitters — a core, a glow, some sparks — and
/// tinting one leaves the rest firing the original orange next to the violet.
/// </summary>
public static void TintParticle( GameObject flash, Color colour )
{
if ( !flash.IsValid() )
{
if ( Debug ) Log.Warning( "[pap-flash] no muzzle particle to tint — "
+ "this weapon has no MuzzleFlashParticle, so only the light shows" );
return;
}
int n = 0;
foreach ( var effect in flash.Components
.GetAll<ParticleEffect>( FindMode.EverythingInSelfAndDescendants ) )
{
effect.ApplyColor = true;
effect.Tint = colour;
n++;
}
// ⚠️ Counts the emitters, because "the tint did not work" and "the tint
// worked on one of three emitters" look identical in game and are different
// bugs. Zero means the prefab has no ParticleEffect at all.
if ( Debug )
Log.Info( $"[pap-flash] tinted {n} emitter(s) {colour} on '{flash.Name}'" );
}
/// <summary>Log what the flash is doing: `nz_pap_flash_debug 1`.</summary>
public static bool Debug { get; set; }
[ConCmd( "nz_pap_flash_debug" )]
public static void CmdDebug( int on = -1 )
{
Debug = on < 0 ? !Debug : on > 0;
Log.Info( $"[nz] pap flash debug {(Debug ? "ON" : "off")}" );
}
/// <summary>
/// SOMEBODY ELSE'S PACKED SHOT, arriving off `NZNet.ShotTracer`.
///
/// ⛔ NO PARTICLE, ONLY THE LIGHT, AND THAT IS THE HONEST LIMIT. The particle belongs to
/// the firing weapon's `MuzzleFlashParticle`, and that weapon is `NetworkMode.Never` — it does
/// not exist on this machine to be tinted. The light is the half that lights the ROOM, which
/// is the half a bystander was ever going to see anyway.
///
/// ⚠️ ONE FLASH PER SHOT, NOT PER PELLET. `RelayTracer` sends a message per BULLET, so a
/// shotgun sends eight — while the local path calls `Fire` once per trigger pull. Without this
/// gate a remote buckshot blast is eight stacked lights and reads as a flashbang. The window
/// is far below any real fire interval (1200rpm = 50ms), so it only ever eats same-frame
/// pellets, never a fast gun's next shot.
/// </summary>
public static void Remote( string shooter, GameObject gun, float rpm, int papLevel )
{
if ( !gun.IsValid() || string.IsNullOrEmpty( shooter ) ) return;
// ⚠️ THE SHOOTER'S TIER, so a teammate's MK5 throws red light on YOUR walls too. A remote
// flash picking from the violet palette was the visible half of the same bug
// `PackedStreak` had — see its note.
if ( ColourFor( papLevel ) is not Color colour ) return;
if ( _lastRemote.TryGetValue( shooter, out var last ) && Time.Now - last < 0.02f ) return;
_lastRemote[shooter] = Time.Now;
Spawn( gun, gun, rpm > 1f ? rpm : 600f, colour );
}
static readonly Dictionary<string, float> _lastRemote = new();
public static void Spawn( GameObject muzzle, GameObject follow, float rpm, Color colour )
{
if ( !Enabled || !muzzle.IsValid() ) return;
var scene = muzzle.Scene;
if ( !scene.IsValid() ) return;
var go = scene.CreateObject();
go.Name = "pap muzzle flash";
go.Flags |= GameObjectFlags.NotSaved;
// ⛔ PARENTED TO THE PLAYER, NOT TO THE MUZZLE. In first person the muzzle
// belongs to the VIEWMODEL, which renders in its own pass — a light hung
// there lights the viewmodel and nothing else, so the room stays dark and
// the whole effect is invisible. Verified in the scene tree:
// `Player Controller/Viewmodel - nz_m1911/muzzle/pap muzzle flash`.
//
// The original sidesteps this by never attaching at all — its dlight is
// placed at a WORLD position (`OwnerEnt:EyePos() + forward * dist`) and
// re-positioned each frame. Parenting to the player is the same idea with
// the following done for us.
go.SetParent( follow.IsValid() ? follow : null );
// ⚠️ WORLD position, set AFTER parenting. The muzzle's world transform is
// where the light belongs; SetParent preserves world position, so assigning
// it here survives the reparent either way.
go.WorldPosition = muzzle.WorldPosition;
var light = go.Components.Create<PointLight>();
light.LightColor = colour * Brightness;
light.Radius = Radius;
// ⛔ NO SHADOWS. PointLight defaults to casting them, and this light is
// created ONCE PER BULLET — an automatic weapon would be asking for a fresh
// shadow-map render ten times a second, for a light that exists for a tenth
// of one. Nothing about a muzzle flash needs to cast a shadow; it is there
// to tint the room for an instant.
light.Shadows = false;
var life = go.Components.Create<Fade>();
life.Life = HoldOverride > 0f ? HoldOverride : LifeFor( rpm );
life.Light = light;
}
/// <summary>
/// Tune it live: `nz_pap_flash [on] [radius] [brightness] [scale]`.
///
/// ⚠️ A light is the hardest thing in this project to judge from a static
/// screenshot — it lasts 0.05-0.2s and only exists while a trigger is held. So
/// the knobs have to be reachable from the console DURING play; a recompile per
/// guess would be unusable.
/// </summary>
[ConCmd( "nz_pap_flash" )]
public static void Cmd( int on = -1, float radius = -1f, float brightness = -1f,
float scale = -1f )
{
if ( on >= 0 ) Enabled = on != 0;
if ( radius > 0f ) Radius = radius;
if ( brightness > 0f ) Brightness = brightness;
if ( scale > 0f ) ParticleScale = scale;
Log.Info( $"[nz] pap muzzle flash {(Enabled ? "ON" : "off")} — "
+ $"radius {Radius:0}, brightness {Brightness:0.##}, "
+ $"particle x{ParticleScale:0.##}, {ActivePalettes.Length} tiers x {ActivePalettes[0].Length} shades, "
+ $"life {LifeFor( 600f ):0.###}s at 600rpm" );
}
/// <summary>
/// Hold the flash so it can be photographed: `nz_pap_flash_hold 3`.
///
/// ⛔ THE ONLY WAY TO LOOK AT IT. At 0.05-0.2s the flash is gone long before a
/// screenshot round-trip completes, so every attempt at verifying it catches an
/// unlit room and proves nothing. Stretching the life is the same trick that
/// made the Pack-a-Punch travel visible.
/// </summary>
[ConCmd( "nz_pap_flash_hold" )]
public static void Hold( float seconds = -1f )
{
if ( seconds >= 0f ) HoldOverride = seconds;
Log.Info( HoldOverride > 0f
? $"[nz] flash life forced to {HoldOverride:0.##}s — nz_pap_flash_hold 0 to restore"
: "[nz] flash life back to rate-derived" );
}
/// <summary>When above zero, overrides the rate-derived life. Testing only.</summary>
public static float HoldOverride { get; set; }
/// <summary>
/// Fades the flash out and removes it.
///
/// ⛔ FADES RATHER THAN CUTS. A light that vanishes on a frame boundary pops,
/// and at 0.05s that pop is most of what you see. Falling off over the life
/// reads as a flash even though it is the same duration.
///
/// ⚠️ A component rather than an `await GameTask.DelaySeconds` — the weapon can
/// be destroyed mid-flash (Pack-a-Punch strips it, a round ends, the player
/// goes down) and a continuation would come back to a dead object. A component
/// on the doomed object simply stops being ticked.
/// </summary>
public sealed class Fade : Component
{
[Property] public float Life { get; set; } = 0.1f;
[Property] public PointLight Light { get; set; }
TimeSince _since;
Color _start;
protected override void OnStart()
{
_since = 0f;
if ( Light.IsValid() ) _start = Light.LightColor;
}
protected override void OnUpdate()
{
float t = _since / MathF.Max( Life, 0.001f );
if ( t >= 1f )
{
GameObject.Destroy();
return;
}
if ( Light.IsValid() )
Light.LightColor = _start * (1f - t);
}
}
}