Weapons/PapMuzzleFlash.cs

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.

File Access
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 &lt;tier&gt; &lt;r&gt; &lt;g&gt; &lt;b&gt; [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);
		}
	}
}