Weapons/BulletTracers.cs

Static utility for bullet tracers. It manages tracer prefab lookup, global settings (chance, scale, enabled, max per shot), per-shot budgeting, applies tracer defaults to SWB weapons on deploy, recolours tracer GameObjects for 'packed' weapons, and provides console commands for testing and tuning.

File AccessExternal Download
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// Visible tracers for hitscan fire.
///
/// ⛔ SWB ALREADY IMPLEMENTS TRACERS — THE PORT SWITCHED THEM OFF. `ShootInfo`
/// defaults `BulletTracerParticle` to `prefabs/particles/tracer/tracer.prefab` and
/// `BulletTracerChance` to 0.33, and `BulletInfo.HitScan` spawns one per shot along
/// the real trace. Every ported prefab then wrote `"BulletTracerParticle": null` and
/// `"BulletTracerChance": 0.0` over the top, so the feature has been present and
/// invisible since the first weapon landed. Nothing here is new behaviour; it is the
/// authored behaviour switched back on.
///
/// ⚠️ FIXED IN CODE, NOT IN THE PREFABS. `PrefabScene` has no populated example
/// anywhere in this project's prefab JSON — every particle field is null — so the
/// serialized shape would be a guess, and a wrong guess in 31 files is a wrong guess
/// that compiles. Assigning the resource at deploy is unambiguous.
///
/// ⚠️ THE BULLET STAYS HITSCAN. A tracer is a cosmetic streak drawn along a trace
/// that has already resolved — hits, damage and penetration are decided the frame you
/// fire, exactly as before. `BulletInfo.Physical` is the projectile path and is NOT
/// what this touches.
/// </summary>
public static class BulletTracers
{
	/// <summary>Where the authored tracer lives — `ShootInfo`'s own default.</summary>
	public const string TracerPrefab = "prefabs/particles/tracer/tracer.prefab";

	/// <summary>
	/// How often a shot draws one, 0-1.
	///
	/// ⛔ EVERY SHOT. I first set this to 0.4, reasoning from real tracer rounds being
	/// loaded every few rounds — and it read as broken rather than as authentic:
	/// *"super inconsistent, i only see it every few shots."* A random streak gives no
	/// feedback about where a specific bullet went, which is the entire job here, and
	/// a player cannot tell "1-in-3 by design" from "the effect is failing".
	///
	/// ⚠️ The thing that made intermittent tracers necessary in other games is SIZE —
	/// a fat streak on every bullet becomes a laser. Fixed by shrinking it (see
	/// <see cref="Scale"/>) rather than by hiding it at random.
	/// </summary>
	public static float Chance { get; set; } = 1f;

	/// <summary>
	/// Tracer size, on top of the weapon's particle scale.
	///
	/// ⛔ SEPARATE FROM `VMParticleScale`, which is SHARED with the muzzle flash and
	/// the shell ejection — shrinking the tracer through that field shrinks the flash
	/// too, and the flash is the one that should stay big.
	///
	/// ⚠️ 0.1 of the authored size, chosen in play. The stock tracer is built for a
	/// third-person view; at first-person distances it starts a few units from the eye,
	/// so it reads as a glowing bar unless it is far smaller than its author intended.
	/// 0.35 was still too fat.
	/// </summary>
	public static float Scale { get; set; } = 0.1f;

	/// <summary>Master switch.</summary>
	public static bool Enabled { get; set; } = true;

	/// <summary>
	/// How many tracers ONE TRIGGER PULL may draw, however many projectiles it fires. 2.
	///
	/// ⛔ THIS IS THE WHOLE FIX, AND IT IS ABOUT COUNT, NOT COST. A tracer is a two-object
	/// prefab, but every one is a full `particle.Clone(...)` and s&box charges roughly 667us for
	/// it. The Olympia fires 48 pellets, doubled to 96 by Double Tap's M1, and every one asked for
	/// its own streak -- 64ms of clones in a single frame, measured, which is the entire reason
	/// that gun dropped the game to 7fps.
	///
	/// ⚠️ AND 96 STREAKS FROM ONE BARREL WAS NEVER THE INTENDED LOOK. A shotgun blast reads as a
	/// spray of light whether it draws two streaks or ninety-six; past a handful they overlap into
	/// a solid cone. This caps the count without touching the pellets, the spread, or where any
	/// bullet actually goes.
	///
	/// ⚠️ SINGLE-PROJECTILE WEAPONS ARE COMPLETELY UNAFFECTED. A rifle fires one bullet and asks
	/// for at most one tracer, so it never reaches the budget. Only shotguns and Double Tap change.
	///
	/// ⚠️ NULLABLE-BACKED, so hotload cannot carry a stale value forward past a changed default.
	/// </summary>
	public static int MaxPerShot
	{
		get => _maxPerShot ?? 2;
		set => _maxPerShot = value;
	}

	static int? _maxPerShot;

	static int _shotBudget;

	/// <summary>
	/// Reset the per-trigger-pull budget. Called once from Weapon.Shoot.
	///
	/// ⚠️ ONCE PER SHOT, NOT PER BULLET -- that is what makes it a budget rather than a chance.
	/// `BulletTracerChance` already thins tracers statistically and it is not enough on its own:
	/// at the default 0.33 a 96-projectile burst still draws about 32.
	/// </summary>
	public static void BeginShot() => _shotBudget = 0;

	/// <summary>
	/// Claim one tracer from this shot's budget. False once it is spent.
	///
	/// ⚠️ CLAIMED AFTER THE CHANCE ROLL, so the two compose: chance decides whether this bullet
	/// wants a streak, the budget decides whether it can have one.
	/// </summary>
	public static bool TakeShotBudget()
	{
		if ( MaxPerShot <= 0 ) return false;
		if ( _shotBudget >= MaxPerShot ) return false;

		_shotBudget++;
		return true;
	}

	/// <summary>`nz_tracers_max [n]` — tracers per trigger pull. 0 disables them entirely.</summary>
	[ConCmd( "nz_tracers_max" )]
	public static void MaxPerShotCmd( int n = -1 )
	{
		if ( n >= 0 ) MaxPerShot = n;

		Log.Info( $"[nz] max {MaxPerShot} tracer(s) per trigger pull"
			+ (MaxPerShot <= 0 ? "   (off)" : "")
			+ $"   · a 48-pellet shotgun doubled by Double Tap asks for 96" );
	}

	static PrefabScene _tracer;
	static bool _looked;

	/// <summary>
	/// The tracer scene, loaded once.
	///
	/// ⚠️ CACHED INCLUDING THE FAILURE. A missing prefab would otherwise be looked up
	/// once per weapon per deploy forever, and a failing resource load in this engine
	/// spams `ERROR_FILEOPEN` at frame rate — that exact loop cost the mystery box a
	/// debugging session.
	/// </summary>
	/// <summary>
	/// The tracer scene, for anything that wants to spawn one itself.
	///
	/// ⚠️ THE SAME CACHE, deliberately, not a second lookup. Fire Works clones a tracer per
	/// shot; a separate accessor would mean a second `ResourceLibrary.Get` and a second
	/// place for the missing-prefab warning to spam from — which is the loop the cache note
	/// below says already cost a debugging session once.
	/// </summary>
	public static PrefabScene TracerScene => Tracer;

	static PrefabScene Tracer
	{
		get
		{
			if ( _looked ) return _tracer;
			_looked = true;

			var file = ResourceLibrary.Get<PrefabFile>( TracerPrefab );

			if ( file is null )
			{
				Log.Warning( $"[nz] tracer prefab '{TracerPrefab}' not found — no tracers" );
				return null;
			}

			_tracer = SceneUtility.GetPrefabScene( file );
			return _tracer;
		}
	}

	/// <summary>
	/// Give a weapon its tracer back. Called as it deploys.
	///
	/// ⚠️ ONLY FILLS IN A NULL. A weapon that has been given its own tracer — by a
	/// prefab, an attachment, or a future special weapon — keeps it.
	/// </summary>
	public static void Apply( SWB.Base.Weapon wep )
	{
		if ( !wep.IsValid() || wep.Primary is null ) return;

		if ( !Enabled )
		{
			wep.Primary.BulletTracerChance = 0f;
			return;
		}

		wep.Primary.BulletTracerParticle ??= Tracer;

		// ⛔ ONLY WHEN IT IS ZERO. Overwriting unconditionally would stamp on a
		// per-weapon chance set through the weapon editor — which is stored in
		// `weapon_tuning.json` and applied moments earlier in the same deploy.
		if ( wep.Primary.BulletTracerChance <= 0f )
			wep.Primary.BulletTracerChance = Chance;
	}

	/// <summary>
	/// Paint a just-spawned tracer violet if the gun that fired it is packed.
	///
	/// ⛔ THE SAME PALETTE OBJECT AS THE MUZZLE FLASH, not a copy of the five colours. They are
	/// `nzMapping.Settings.papmuzzlecol` and a second literal list would drift the moment either is
	/// tuned — INSTRUCTIONS.md §3. It also reuses `PapMuzzleFlash.TintParticle`, which already knows
	/// the thing that is easy to get wrong: `ApplyColor` gates `Tint`, so setting a colour without
	/// it changes nothing and looks exactly like the tint failing.
	///
	/// ⚠️ THE SAME "IS IT PACKED" TEST AS THE FLASH — `ShootInfo.IsPacked`, read off the weapon's
	/// own ShootInfo. Asking PapLevelFor here would be a second source of truth for one question, and
	/// the flash and the tracer disagreeing about whether a gun is packed is a bug with no visible
	/// cause.
	///
	/// ⛔ IT USED TO BE `DamageMultiplier > 1.01f` AND THAT WAS WRONG HERE, uniquely among the four
	/// sites that asked it. This one runs PER BULLET, inside the window where `Weapon.Shoot` has
	/// multiplied the charged trigger and Micro-Burst into that field — so an unpacked gun under
	/// Double Tap's m2 drew violet tracers. See `ShootInfo.IsPacked`.
	///
	/// ⚠️ ITS OWN PICK, NOT THE FLASH'S PICK FOR THIS SHOT. `PapMuzzleFlash.Fire` deliberately
	/// shares one colour between the flash and its light because they are one effect at one point in
	/// space. The tracer is a different code path — it arrives through the `SpawnEffects` broadcast
	/// RPC, so threading that shot's colour to it would mean either a static the RPC races against
	/// or a new RPC parameter. Both of the palette's ends are violet, the streak is gone in under a
	/// tenth of a second, and the variation reads between shots either way.
	///
	/// ⚠️ FOLLOWS `nz_pap_flash`. One switch turns the whole packed-weapon violet off for
	/// comparison; a tracer that stayed purple after the flash went orange would look like a
	/// half-applied revert.
	/// </summary>
	/// <summary>
	/// The colour a tracer should be, without needing a spawned object to recolour.
	///
	/// ⚠️ TintIfPacked WALKS COMPONENTS ON AN ALREADY-CLONED PREFAB; this just answers the
	/// question. FastTracer sets one Color on one LineRenderer, so there is nothing to walk.
	/// Returns null for an unpacked weapon, meaning "use the default streak colour".
	/// </summary>
	public static Color? PackedTint( SWB.Base.ShootInfo shootInfo )
	{
		if ( shootInfo is null || !shootInfo.IsPacked ) return null;

		// ⚠️ THE TIER'S PALETTE NOW, NOT THE ONE VIOLET ONE. `ColourFor` also carries the Enabled
		// check this used to make itself — one author, see its note.
		return PapMuzzleFlash.ColourFor( shootInfo.PapLevel );
	}

	/// <summary>
	/// The streak colour for a shot described only as "packed or not".
	///
	/// ⚠️ FOR A RELAYED SHOT, WHICH HAS NO `ShootInfo` ON THIS MACHINE. Another player's weapon
	/// is `NetworkMode.Never` and does not exist here at all — only the fact travels. One author
	/// with `PackedTint` so a remote violet cannot drift from a local one.
	/// </summary>
	public static Color? PackedStreak( int papLevel )
	{
		// ⛔ TAKES THE LEVEL, NOT A BOOL. A relayed shot used to carry only "packed or not", so
		// every other player saw an MK5 draw an MK1 violet streak — the one case where the local
		// and remote presentation of the SAME shot disagreed. The wire carries the tier now.
		return PapMuzzleFlash.ColourFor( papLevel );
	}

	public static void TintIfPacked( GameObject tracer, SWB.Base.ShootInfo shootInfo )
	{
		if ( shootInfo is null || !shootInfo.IsPacked ) return;

		// ⛔ THIS READ `PapMuzzleFlash.Palette` — THE LEGACY VIOLET — UNTIL 2026-09-14. When the
		// per-tier palettes went in, four call sites were converted to `ColourFor` and this fifth
		// one was missed, so the PARTICLE tracer kept firing the original violet while the fast
		// tracer, the muzzle flash and the burn decal all used the tier colour.
		//
		// ⚠️ AND IT HID BEHIND `FastTracer.Enabled`. That branch returns before this line, so the
		// bug is invisible on the default path and appears only when the fast tracer is switched
		// off — which is exactly how it was found: `nz_tracer_pap` reported `PackedTint` returning
		// a correct green on a shot that drew warm-yellow, because the value it reported was not
		// the value that branch uses.
		//
		// ⚠️ `ColourFor` ALSO CARRIES THE `Enabled` CHECK this used to make itself — one author, see
		// its note. Nothing in gameplay reads `Palette` any more.
		if ( PapMuzzleFlash.ColourFor( shootInfo.PapLevel ) is Color colour )
			Recolour( tracer, colour );
	}

	/// <summary>
	/// Repaint a spawned tracer, gradients and all.
	///
	/// ⛔ `Tint` ALONE DOES NOTHING HERE, AND THAT IS THE BUG THIS EXISTS TO FIX. The first version
	/// called `PapMuzzleFlash.TintParticle`, which sets `ApplyColor` + `Tint` — enough for the
	/// muzzle flash, and provably not enough for this prefab: the shot stayed yellow/orange. Reading
	/// `prefabs/particles/tracer/tracer.prefab` shows why. TWO separate gradients own the colour and
	/// neither is `Tint`:
	///
	///   1. `ParticleEffect.Gradient` — a Life gradient, white → (1, 0.984, 0.110) → (1, 0.467, 0),
	///      i.e. yellow into orange. It colours the sprite, and it outranks Tint.
	///   2. `ParticleTrailRenderer.Color` — the STREAK, carrying its own copy of that same ramp,
	///      with `TintFromParticle = false`. That flag means it ignores the particle's colour
	///      outright, so nothing done to the ParticleEffect can reach it.
	///
	/// The trail is the part you actually see, and it was the part nothing was touching.
	///
	/// ⚠️ BOTH GRADIENTS COLLAPSE TO A FLAT COLOUR, not to a violet ramp. The authored ramp fades
	/// hot-to-cool to sell a burning round; a 0.1s streak 100 units long does not read as a fade,
	/// and a violet-to-darker-violet ramp just looks like the near end is brighter.
	///
	/// ⚠️ `TintFromParticle` IS ALSO SET, belt and braces — with a flat Color gradient the trail no
	/// longer needs the particle's colour, but leaving it false would mean anything that later
	/// tints the ParticleEffect silently fails to reach the trail all over again.
	/// </summary>
	public static void Recolour( GameObject tracer, Color colour )
	{
		if ( !tracer.IsValid() )
		{
			if ( PapMuzzleFlash.Debug )
				Log.Warning( "[nz-tracer] nothing to recolour — CreateParticle returned null" );
			return;
		}

		int effects = 0, trails = 0;

		foreach ( var effect in tracer.Components
			.GetAll<ParticleEffect>( FindMode.EverythingInSelfAndDescendants ) )
		{
			effect.ApplyColor = true;
			effect.Tint = colour;
			effect.Gradient = colour;
			effects++;
		}

		foreach ( var trail in tracer.Components
			.GetAll<ParticleTrailRenderer>( FindMode.EverythingInSelfAndDescendants ) )
		{
			trail.TintFromParticle = true;
			trail.Color = colour;
			trails++;
		}

		// ⚠️ COUNTS BOTH KINDS SEPARATELY. "0 effects" and "1 effect, 0 trails" are different
		// faults — the second is exactly the state that shipped a yellow tracer while the tint
		// code ran perfectly — and on screen they are indistinguishable.
		if ( PapMuzzleFlash.Debug )
			Log.Info( $"[nz-tracer] recoloured {effects} effect(s) + {trails} trail(s) {colour}" );
	}

	// ── commands ─────────────────────────────────────────────────────────────

	/// <summary>
	/// `nz_tracer_test [violet]` — fire one tracer from the eye and look at it.
	///
	/// ⛔ BECAUSE "IS IT VIOLET" TOOK A ROUND TRIP TO ANSWER AND CAME BACK WRONG. Seeing the packed
	/// colour otherwise means owning a gun, packing it, and firing — and the first attempt at this
	/// feature looked completely correct in code while shipping a yellow streak. Pass 0 for the
	/// stock tracer and 1 for the packed one; flipping between them is the whole test.
	///
	/// ⚠️ Spawns the same prefab through the same <see cref="Recolour"/> the shot path uses, so a
	/// pass here is evidence about the real thing rather than about this command.
	/// </summary>
	[ConCmd( "nz_tracer_test" )]
	public static void TestCmd( int violet = 1 )
	{
		var scene = Game.ActiveScene;
		var tracer = Tracer;

		if ( !scene.IsValid() || tracer is null )
		{
			Log.Warning( "[nz-tracer] no scene or no tracer prefab" );
			return;
		}

		var player = PlayerCharacters.Local();
		if ( !player.IsValid() ) { Log.Warning( "[nz-tracer] no player" ); return; }

		// ⚠️ Aimed where the player is LOOKING and started slightly ahead of the eye, or the streak
		// spawns inside the camera's near plane and is invisible for the reason the Scale note
		// above describes.
		var rot = player.EyeAngles.ToRotation();
		var pos = player.WorldPosition + Vector3.Up * 64f + rot.Forward * 24f;

		var go = tracer.Clone( new CloneConfig { Transform = new Transform( pos, rot ) } );

		// ⚠️ THE ARGUMENT IS A TIER NOW, NOT A BOOLEAN "violet". It was written when there was one
		// packed colour; passing 1-5 tests the tier the player would actually see, and a test
		// command that draws a colour the game no longer uses is worse than not having one.
		if ( violet != 0 && PapMuzzleFlash.ColourFor( violet < 0 ? 1 : violet ) is Color colour )
		{
			Recolour( go, colour );
			Log.Info( $"[nz-tracer] test tracer MK{(violet < 0 ? 1 : violet)} {colour}" );
		}
		else
		{
			Log.Info( "[nz-tracer] test tracer stock (yellow/orange)" );
		}
	}

	/// <summary>
	/// Tune tracers: `nz_tracers [chance] [scale]`, chance 0 to turn them off.
	///
	/// ⚠️ SCALE APPLIES INSTANTLY, chance on the next shot — scale is read when the
	/// particle spawns, so there is nothing to re-deploy for.
	/// </summary>
	/// <summary>
	/// `nz_tracer_pap` — why is the tracer not the tier colour.
	///
	/// ⛔ IT PRINTS EVERY GATE ON THE PATH, not the conclusion. The tracer colour survives four
	/// separate conditions and any one of them silently falls back to the default warm streak —
	/// which looks identical to "the feature is not implemented".
	/// </summary>
	[ConCmd( "nz_tracer_pap" )]
	public static void PapDiag()
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz-tracer] no player" ); return; }

		var active = player.Inventory?.Active;
		var wep = active.IsValid()
			? active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf )
			: null;

		if ( !wep.IsValid() || wep.Primary is null )
		{ Log.Info( "[nz-tracer] no weapon in hand" ); return; }

		var si = wep.Primary;
		var tint = PackedTint( si );

		Log.Info( $"[nz-tracer] '{wep.GameObject.Name}'  IsPacked {si.IsPacked}  PapLevel {si.PapLevel}"
			+ $"  DamageMultiplier x{si.DamageMultiplier:0.##}" );
		Log.Info( $"[nz-tracer]   PapMuzzleFlash.Enabled {PapMuzzleFlash.Enabled}"
			+ $"   ColourFor({si.PapLevel}) {(PapMuzzleFlash.ColourFor( si.PapLevel ) is Color c2 ? $"{c2.r:0.##},{c2.g:0.##},{c2.b:0.##}" : "NULL")}" );
		Log.Info( $"[nz-tracer]   PackedTint -> {(tint is Color c ? $"{c.r:0.##},{c.g:0.##},{c.b:0.##}" : "NULL — falls back to the default streak")}" );
		Log.Info( $"[nz-tracer]   tracers {(Enabled ? "on" : "OFF")}, chance {Chance:0.##},"
			+ $" fast path {(FastTracer.Enabled ? "on" : "off — particle tracer instead")},"
			+ $" default streak {FastTracer.Tint.r:0.##},{FastTracer.Tint.g:0.##},{FastTracer.Tint.b:0.##}" );

		if ( si.IsPacked && si.PapLevel <= 0 )
			Log.Warning( "[nz-tracer] ⛔ PACKED BUT NO TIER — ShootInfo.PapLevel is only written by"
				+ " NZPlayer.ApplyStoredUpgrades, which runs on EQUIP. Re-equip the weapon." );
	}

	[ConCmd( "nz_tracers" )]
	public static void Cmd( float chance = -1f, float scale = -1f )
	{
		if ( scale >= 0f )
			Scale = MathX.Clamp( scale, 0.01f, 5f );

		if ( chance >= 0f )
		{
			Chance = MathX.Clamp( chance, 0f, 1f );
			Enabled = Chance > 0f;

			// Live, so the change is visible without a weapon switch.
			foreach ( var w in Game.ActiveScene?.GetAllComponents<SWB.Base.Weapon>()
				?? Enumerable.Empty<SWB.Base.Weapon>() )
			{
				if ( w.Primary is null ) continue;
				w.Primary.BulletTracerChance = Chance;
				if ( Enabled ) w.Primary.BulletTracerParticle ??= Tracer;
			}
		}

		Log.Info( Enabled
			? $"[nz] tracers ON — {Chance:0.##} of shots, size ×{Scale:0.##}"
				+ $"  (effective {0.5f * Scale:0.###} in first person)"
			: "[nz] tracers off" );
	}
}