Diagnostics/VisualRecoilTuner.cs

A developer console command utility to tune visual recoil on weapons. It records each weapon ShootInfo baseline values once, enumerates live Weapon components (primary and secondary), and scales their VisualRecoilUp/Side/Roll/Punch fields by a factor or reports current and authored values.

ReflectionFile Access
using Sandbox;
using System.Collections.Generic;
using System.Linq;
using SWB.Base;

namespace NZombies;

/// <summary>
/// Dial how hard the MODEL kicks, live. `nz_vrecoil 0.4`.
///
/// ⛔ THE MODEL'S KICK, NOT THE CAMERA'S. `VisualRecoil*` moves the gun on screen and nothing else —
/// where the bullet goes is `RecoilUp`/`RecoilSide`, a separate set. Scaling the wrong one makes the
/// weapon look calm while still throwing your aim at the ceiling, and the two read identically in a
/// prefab diff.
///
/// ⚠️ RELATIVE TO THE AUTHORED VALUES, captured the first time a weapon is touched. Scaling the
/// CURRENT numbers would compound — `nz_vrecoil 0.5` twice would land on 0.25 while reporting 0.5 —
/// so every call is measured from the same baseline and calling it again with 1 restores the gun.
/// </summary>
public static class VisualRecoilTuner
{
	/// <summary>The four magnitudes. Recovery is deliberately absent: it is timing, not size.</summary>
	static readonly string[] Fields =
		{ "VisualRecoilUp", "VisualRecoilSide", "VisualRecoilRoll", "VisualRecoilPunch" };

	// ⚠️ Keyed on the ShootInfo instance, so Primary and Secondary are remembered separately and a
	// respawned weapon simply gets a fresh baseline rather than inheriting the last one's.
	static readonly Dictionary<ShootInfo, float[]> Authored = new();

	static float[] BaselineOf( ShootInfo si )
	{
		if ( Authored.TryGetValue( si, out var v ) ) return v;
		v = new[] { si.VisualRecoilUp, si.VisualRecoilSide, si.VisualRecoilRoll, si.VisualRecoilPunch };
		Authored[si] = v;
		return v;
	}

	static void Apply( ShootInfo si, float factor )
	{
		var b = BaselineOf( si );
		si.VisualRecoilUp = b[0] * factor;
		si.VisualRecoilSide = b[1] * factor;
		si.VisualRecoilRoll = b[2] * factor;
		si.VisualRecoilPunch = b[3] * factor;
	}

	static IEnumerable<(Weapon w, ShootInfo si, string slot)> Live()
	{
		var weapons = Game.ActiveScene?.GetAllComponents<Weapon>()
			.Where( w => w.IsValid() && w.GameObject.Enabled ) ?? Enumerable.Empty<Weapon>();

		foreach ( var w in weapons )
		{
			if ( w.Primary is not null ) yield return (w, w.Primary, "Primary");
			if ( w.Secondary is not null ) yield return (w, w.Secondary, "Secondary");
		}
	}

	/// <summary>
	/// `nz_vrecoil [factor]` — scale the model kick on every weapon in hand. No argument reports.
	/// </summary>
	[ConCmd( "nz_vrecoil" )]
	public static void Scale( float factor = -1f )
	{
		var live = Live().ToList();
		if ( live.Count == 0 ) { Log.Info( "[vrecoil] no weapon in hand" ); return; }

		foreach ( var (w, si, slot) in live )
		{
			var b = BaselineOf( si );
			if ( factor >= 0 ) Apply( si, factor );

			Log.Info( $"[vrecoil] {w.DisplayName} ({slot})  up {si.VisualRecoilUp:0.####}"
				+ $"  side {si.VisualRecoilSide:0.####}  roll {si.VisualRecoilRoll:0.####}"
				+ $"  punch {si.VisualRecoilPunch:0.####}"
				+ $"   [authored {b[0]:0.####} {b[1]:0.####} {b[2]:0.####} {b[3]:0.####}]" );
		}

		if ( factor >= 0 )
			Log.Info( $"[vrecoil] x{factor:0.###} of authored on {live.Count} shootinfo(s)."
				+ " Say the word and it gets baked into all 73 Destiny prefabs." );
		else
			Log.Info( "[vrecoil] nz_vrecoil <factor>   e.g. 0.4 for a much softer kick, 1 to restore" );
	}
}