Weapons/MwRecoilFx.cs

Recoil visual effects for guns. Provides a global static MwRecoilFx that computes screen rattle, FOV kick, eye pushback and a virtual view punch, exposes console commands to tweak and preview them, and per-weapon MwGunRecoil that simulates the viewmodel springs; MwRecoilView is a camera modifier that applies the composed camera shake and steps the punch each frame.

Native Interop
using Sandbox;
using SWB.Base;
using System;

namespace NZombies;

/// <summary>
/// The Modern Warfare Base's recoil LOOK on our guns: what the screen and the gun do on each shot.
/// Asked for as *"it's not how the bullet changes, not the mechanical part, purely the visual one — I love
/// how recoil feels visually in this base"* (the MW Base, Workshop 2459720887, read in full on 2026-09-28).
///
/// ⛔ NOTHING HERE MOVES THE AIM. `EyeAngles` is never written: where the bullets go, how far the view climbs
/// and how it recovers are exactly what `GetRecoilAngles` made them. Everything below is the cameras'
/// composed view (`ICameraModifier`, `MwRecoilView`) and the viewmodel's pose (`ViewModelHandler.ApplyMwLook`).
///
/// Three things, each with the base's own numbers:
///
///   1. THE SCREEN RATTLE (the base's `SWEP:CalcView`). A shot SETS a shake level; the view pitches on a cosine
///      and rolls on a sine at `clamp(RPM / 10, 55, 90)` rad/s — 9 to 14 wobbles a second — while the level
///      decays at `20 - 600 / that` per second. Pitch is 0.5 x level², roll is the level, both in degrees, and
///      aiming keeps 40% of the pitch. The FOV widens by 1.5 x the level (degrees) and the eye drops back by the
///      level (units). The Kastov 545's 1.15 is our x1 gun (`AnchorShake`).
///   2. THE GUN'S SPRINGS (`SWEP:ShakeViewModel` and the viewmodel's `recoil()`). A shot sets random targets — a
///      push back, a random pitch, yaw and roll — held about 0.07 s and then walked back, and stiff springs chase
///      them (`MwGunRecoil`). Aimed, the turn is cut to 6.5-10% and a random roll kick of 3 x the shake level
///      takes over. The settings are the Kastov 545's, not the base's bare defaults — see `VmVertical`.
///   3. WHAT IS LEFT OF THE VIEW PUNCH. The base's aim kick is a Source view punch, a spring. Ours is instant and
///      stays, so the punch is run here from OUR kicks and only its visual remainder is shown: its roll on the
///      camera, and at the hip — where the base shows the camera only 70% of the punch — the other 30% as the gun
///      climbing on screen.
///
/// ⚠️ THE GUN SHAKES WITH THE WORLD, BECAUSE IN THE BASE IT DOES. GMod draws the viewmodel from the same shaken
/// view as the world but places it from the view BEFORE the shake, so the whole picture rattles together. Ours
/// has its own camera, so the rattle goes on that camera too (`GunCamera`), not on the gun's pose.
///
/// ⚠️ HOW STRONG, PER GUN: the base sets `Recoil.Shake` by hand on every gun. Ours comes from the gun's vertical
/// recoil multiplier through the SAME curve the lean uses (`GlobalHandling.VisualSpread`, 0.45), so a sniper
/// rattles about three times an SMG instead of twenty — see `Strength`.
///
/// `nz_mw_recoil 0` is our own look again; `nz_mw_shake` and `nz_mw_gun` scale the two halves; `nz_mw_kick`
/// previews the rattle without firing.
/// </summary>
public static class MwRecoilFx
{
	/// <summary>`nz_mw_recoil` — the whole look. Off is our own gun recoil and no screen rattle.</summary>
	public static bool On { get; set; } = true;

	/// <summary>`nz_mw_shake` — the screen rattle, FOV kick, push back and the punch's roll. 0 turns them off.</summary>
	public static float ShakeScale { get; set; } = 1f;

	/// <summary>`nz_mw_gun` — the gun's springs. 0 hands the gun back to our own visual recoil.</summary>
	public static float GunScale { get; set; } = 1f;

	/// <summary>
	/// The highest shake level a shot can set.
	///
	/// ⚠️ THE PITCH IS 0.5 x LEVEL², so 3 is a 4.5° jolt and 4.7 — what the heaviest multiplier would ask for
	/// uncapped — would be 11°. The base has no cap because it sets each gun's level by hand.
	/// </summary>
	public static float ShakeCap { get; set; } = 3f;

	/// <summary>
	/// The Kastov 545's `Recoil.Shake`. Its average kick once the climb is going (0.74° a shot) is our x1 gun's
	/// (0.35 x 2.2 = 0.77°), so it is what our x1 gun rattles at.
	/// </summary>
	public const float AnchorShake = 1.15f;

	/// <summary>True when the MW springs, not our own visual recoil, move the gun.</summary>
	public static bool OwnsGun => On && GunScale > 0f;

	// ── the gun's settings: THE KASTOV 545'S, the gun this look was read from and the one `AnchorShake` is ──
	//
	// ⛔ NOT THE BASE'S BARE DEFAULTS, which it only falls back on for a gun that sets none. Simulated on a
	// median gun at 740 rpm they roll a hip burst up to 14°, and one way: `Recoil.Angles.r` -1 biases three
	// shots in four to the same side — the "always to the left" lean this project has already taken out of
	// its own tilt twice. The Kastov sets no bias and stiffer, better-damped springs; the same burst peaks
	// near 6° of roll, 3.7° of yaw and 1.3° of pitch, and a single shot at 2.6°, 1° and 0.4°.
	const float VmVertical = 0.3f;     // Recoil.ViewModel.VerticalMultiplier
	const float VmHorizontal = 0.75f;  // Recoil.ViewModel.HorizontalMultiplier (the roll rides it too)
	const float PushBack = 2.5f;       // ViewModelOffsets.Recoil.Pos.y — units back at the eye (the base's own is 2)
	const float Drop = 0f;             // ViewModelOffsets.Recoil.Pos.z (-0.5)
	const float RollBias = 0f;         // ViewModelOffsets.Recoil.Angles.r (-1)

	/// <summary>`Recoil.ViewModel.SnapMultiplier`: the springs' stiffness, x2 on the Kastov.</summary>
	public const float VmSnap = 2f;

	/// <summary>`Recoil.ViewModel.LoosenessMultiplier`: the damping is DIVIDED by it, so 0.5 is twice as damped.</summary>
	public const float VmLooseness = 0.5f;

	/// <summary>`Recoil.AdsShakeMultiplier`: the rattle while aimed, x1.05 on the Kastov.</summary>
	const float AdsShake = 1.05f;

	// ── the local player's last shot ────────────────────────────────────────────────────────────────────────────

	static float _level0, _shotAt = -999f, _wobble = 74f, _decay = 11.9f;

	/// <summary>How far into aiming the held gun is, 0-1, eased at 18/s as the base's viewmodel eases it.</summary>
	public static float Aim { get; set; }

	/// <summary>The held gun's own camera, so the rattle can go on it too. The gun registers it every frame.</summary>
	public static CameraComponent GunCamera { get; set; }

	// ⚠️ THE VIRTUAL VIEW PUNCH, SOURCE CONVENTION: x pitch (negative is up), y yaw, z roll, in degrees.
	static Vector3 _punch, _punchVel;
	static float _punchStepped = -999f;

	/// <summary>
	/// Whether the punch is still being stepped.
	///
	/// ⛔ A PUNCH NOBODY STEPS IS FROZEN WHERE IT WAS, and it is the camera's roll and the gun's climb. The body that
	/// steps it (`MwRecoilView`) is replaced on a respawn and only made again on the new body's first shot, so a
	/// death mid-burst would otherwise hold the new view rolled and the new gun raised until then. A quarter second
	/// unstepped and it counts as zero.
	/// </summary>
	static bool PunchLive => Time.Now - _punchStepped < 0.25f;

	// diagnostics — `nz_mw_recoil` prints them
	static int _shots, _lastRpm;
	static float _lastLevel, _lastStrength, _lastGun;
	static int _mainFrames, _gunFrames;

	/// <summary>
	/// The shake level now.
	///
	/// ⛔ A PURE FUNCTION OF `Time.Now`, NOT A VALUE DECAYED EACH FRAME. The main camera, the gun's camera and the
	/// gun's springs all read it, from different components in no fixed order; one number stepped by one of them
	/// would reach the others a frame apart, and the gun would rattle out of step with the world.
	/// </summary>
	public static float Level
	{
		get
		{
			if ( _level0 <= 0f ) return 0f;
			var t = MathF.Max( 0f, Time.Now - _shotAt );
			var level = _level0 * MathF.Exp( -_decay * t );
			return level < 0.001f ? 0f : level;
		}
	}

	/// <summary>
	/// How strong a gun's look is, from its vertical recoil multiplier.
	///
	/// ⛔ NOT THE RAW MULTIPLIER. Ours were solved for recoil per SECOND — a 42 rpm Kar98K carries x22.86 — and a
	/// per-shot visual on that scale is a cartwheel (`GlobalHandling.SpreadFactor` has the fleet's numbers). The
	/// lean's own curve, mult^0.45, keeps the classes apart and in order: 0.38 → 0.65, the median 1.24 → 1.10,
	/// the 90th percentile 12 → 3.1, the Kar98K → 4.1.
	/// </summary>
	static float Strength( ShootInfo si )
	{
		var mult = GlobalHandling.UseRecoilBase
			? si.RecoilVerticalMult
			: si.RecoilUp > 0f && GlobalHandling.VerticalBase > 0f ? si.RecoilUp / GlobalHandling.VerticalBase : 1f;

		if ( mult <= 0f ) mult = 1f;

		return Math.Clamp( MathF.Pow( mult, GlobalHandling.VisualSpread ), 0.35f, 5f );
	}

	/// <summary>
	/// One shot from the local player's gun. Called from `Weapon.Shoot` with the kick `GetRecoilAngles` just made,
	/// which this only READS.
	/// </summary>
	public static void OnShot( Weapon weapon, ShootInfo si, Angles kick )
	{
		if ( !On || weapon is null || si is null || weapon.IsProxy ) return;

		var player = NZPlayer.Local;
		if ( !player.IsValid() ) return;

		// ⚠️ ON THE PLAYER, THE WAY `CameraShake` IS, and made on the first shot. A new body after a respawn gets
		// its own on its own first shot; the state above is static and carries across.
		player.Components.GetOrCreate<MwRecoilView>();

		var now = Time.Now;

		// ⚠️ THE BASE'S HIP CONE HAS GROWN PAST ITS CLAMP BY THE SECOND ROUND OF A BURST, and is back under it about
		// 0.1 s after the last. Its gun jolt reads 0.425 of it at first and 0.6 once the burst is going.
		var sprayed = now - _shotAt < 0.11f;

		var strength = Strength( si );
		var visual = MathF.Max( 0f, si.VisualRecoilScale );

		// ── 1. the rattle: SET, not added, as the base does — a sustained burst holds it at the top ──
		var raw = MathF.Min( AnchorShake * strength * visual, ShakeCap );
		var wobble = Math.Clamp( si.RPM / 10f, 55f, 90f );

		_wobble = wobble;
		_decay = 20f - 600f / wobble;
		_level0 = raw * MathF.Max( 0f, ShakeScale );
		_shotAt = now;

		// ── 2. the gun, from the punch as it stands BEFORE this shot's impulse — the base reads it the same way ──
		if ( !PunchLive )
		{
			_punch = default;
			_punchVel = default;
		}

		var gun = GunScale * visual * Math.Clamp( MathF.Sqrt( strength ), 0.75f, 1.6f );

		if ( gun > 0f && si.UseVisualRecoil && weapon.ViewModelHandler is not null )
			KickGun( weapon.ViewModelHandler.MwGun, gun, raw, _decay, sprayed );

		// ── 3. the punch: the base's `ViewPunch`, an impulse of 20 x the kick, with a roll of -0.3 x its side ──
		//
		// ⚠️ THE KICK IS COMPRESSED FIRST, THE WAY THE LEAN'S IS (`SpreadFactor`). It is the real aim kick, and on a
		// per-second multiplier that is 17.6° a shot for the Kar98K; the punch's leftovers are per-shot visuals, so
		// they take mult^0.45 like everything else here, not the whole x22.86.
		var pitchKick = kick.pitch * GlobalHandling.SpreadFactor( si.RecoilVerticalMult );
		var yawKick = kick.yaw * GlobalHandling.SpreadFactor( si.RecoilHorizontalMult );
		_punchVel += new Vector3( pitchKick, yawKick, -0.3f * yawKick ) * 20f;

		_shots++;
		_lastRpm = si.RPM;
		_lastLevel = _level0;
		_lastStrength = strength;
		_lastGun = gun;
	}

	/// <summary>
	/// The base's `SWEP:ShakeViewModel`, line for line, with the Kastov's offsets and multipliers, scaled by
	/// <paramref name="scale"/>.
	/// </summary>
	static void KickGun( MwGunRecoil gun, float scale, float shake, float decay, bool sprayed )
	{
		var aim = Aim;
		var hip = 1f - aim;

		var cone = MathX.Lerp( 0.5f, sprayed ? 0.6f : 0.425f, hip );
		var d = MathX.Lerp( 0.3f, 1f, hip );

		var vpPitch = _punch.x * MathX.Lerp( 0f, 0.1f, aim ) + Game.Random.Float( -cone, cone );
		var vpYaw = _punch.y * MathX.Lerp( 0.1f, 0.5f, aim ) + MathX.Lerp( Game.Random.Float( -cone, cone ), 0f, aim );

		var pitch = vpPitch * VmVertical;
		var yaw = -vpYaw * VmHorizontal;
		var roll = Game.Random.Float( -1f, 1f ) * VmHorizontal + MathX.Lerp( RollBias, RollBias * 0.5f, d );

		var back = MathX.Lerp( PushBack * 0.5f, PushBack, d );
		var side = (yaw * 1.5f - roll * 0.5f) * d;
		var up = (pitch * 1.5f + roll * 0.5f + Drop) * d;

		var rollKick = (Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f) * shake * 3f * GunScale;

		gun.Kick( new Vector3( side, back, up ) * scale, new Vector3( pitch, yaw, roll ) * scale, rollKick, shake, decay );
	}

	/// <summary>
	/// Steps the virtual punch: Source's `CBasePlayer::DecayPunchAngle`, at its own 66 ticks a second.
	/// </summary>
	internal static void StepPunch( float dt )
	{
		_punchStepped = Time.Now;

		if ( _punch.LengthSquared <= 0.001f && _punchVel.LengthSquared <= 0.001f )
		{
			_punch = default;
			_punchVel = default;
			return;
		}

		var steps = Math.Clamp( (int)MathF.Ceiling( dt / 0.015f ), 1, 8 );
		var h = dt / steps;

		for ( var i = 0; i < steps; i++ )
		{
			_punch += _punchVel * h;
			_punchVel *= MathF.Max( 0f, 1f - 9f * h );
			_punchVel -= _punch * Math.Clamp( 65f * h, 0f, 2f );
		}
	}

	/// <summary>
	/// The part of the punch the base keeps OFF the camera and so shows on the gun: 30% at the hip, none aimed
	/// (`Recoil.Crosshair`). As a turn in the gun camera's own frame.
	/// </summary>
	public static Rotation PunchOnGun( float aim )
	{
		if ( !On || GunScale <= 0f ) return Rotation.Identity;

		var c = MathX.Lerp( 0.3f, 0f, aim );
		if ( c <= 0f || !PunchLive || _punch.LengthSquared < 1e-8f ) return Rotation.Identity;

		return Rotation.From( _punch.x * c, _punch.y * c, _punch.z * c );
	}

	/// <summary>
	/// Reshape one camera's view. The main camera gets the rattle, the punch's roll and a WIDER field of view;
	/// the gun's camera gets the same rattle and push back with a NARROWER one, because the base takes the FOV kick
	/// back off its viewmodel — the world punches away while the gun punches toward you.
	/// </summary>
	internal static void Shape( ref CameraView view, bool main )
	{
		var level = Level;
		var roll = main && ShakeScale > 0f && PunchLive ? (1f - MathX.Lerp( 0.3f, 0f, Aim )) * _punch.z : 0f;

		if ( level <= 0f && MathF.Abs( roll ) < 0.0005f ) return;

		var turn = Rotation.Identity;

		if ( level > 0f )
		{
			var phase = Time.Now * _wobble;
			var ads = MathX.Lerp( 1f, AdsShake, Aim );
			var pitch = MathF.Cos( phase ) * level * 0.5f * MathX.Lerp( 1f, 0.4f, Aim ) * level * ads;
			turn = Rotation.From( pitch, 0f, MathF.Sin( phase ) * level * ads );
		}

		if ( roll != 0f ) turn *= Rotation.FromRoll( roll );

		view.Rotation *= turn;

		if ( level > 0f )
		{
			view.Position -= view.Rotation.Forward * level;

			var k = PopRatio( level );
			view.FieldOfView = ScaleFov( view.FieldOfView, main ? k : 1f / k );
		}

		if ( main ) _mainFrames++;
		else _gunFrames++;
	}

	/// <summary>
	/// The base's FOV kick as a ratio: +1.5 x level DEGREES on the player's own horizontal FOV, turned into how much
	/// wider the picture gets — so it reads the same whichever axis a camera's field of view is measured on.
	/// </summary>
	static float PopRatio( float level )
	{
		var h = Math.Clamp( Preferences.FieldOfView, 30f, 150f );
		var popped = MathF.Min( h + level * 1.5f, 170f );
		return MathF.Tan( MathX.DegreeToRadian( popped ) * 0.5f ) / MathF.Tan( MathX.DegreeToRadian( h ) * 0.5f );
	}

	static float ScaleFov( float fov, float k )
		=> MathX.RadianToDegree( 2f * MathF.Atan( MathF.Tan( MathX.DegreeToRadian( fov ) * 0.5f ) * k ) );

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

	/// <summary>`nz_mw_recoil [0|1]` — the MW Base's recoil look on or off, then what the last shot did.</summary>
	[ConCmd( "nz_mw_recoil" )]
	public static void RecoilCmd( int on = -1 )
	{
		if ( on >= 0 ) On = on != 0;
		Report();
	}

	/// <summary>`nz_mw_shake [scale] [cap]` — the screen rattle's strength (0 = off) and the highest level a shot can set.</summary>
	[ConCmd( "nz_mw_shake" )]
	public static void ShakeCmd( float scale = -1f, float cap = -1f )
	{
		if ( scale >= 0f ) ShakeScale = scale;
		if ( cap > 0f ) ShakeCap = cap;
		Report();
	}

	/// <summary>`nz_mw_gun [scale]` — the gun's springs' strength; 0 gives the gun back to our own visual recoil.</summary>
	[ConCmd( "nz_mw_gun" )]
	public static void GunCmd( float scale = -1f )
	{
		if ( scale >= 0f ) GunScale = scale;
		Report();
	}

	/// <summary>
	/// `nz_mw_kick [level] [rpm]` — the rattle one shot of that level would set, without firing. 1.15 at 740 is the
	/// Kastov 545, our x1 gun.
	/// </summary>
	[ConCmd( "nz_mw_kick" )]
	public static void KickCmd( float level = AnchorShake, int rpm = 740 )
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[mw-recoil] no local player" ); return; }

		player.Components.GetOrCreate<MwRecoilView>();

		var wobble = Math.Clamp( rpm / 10f, 55f, 90f );
		_wobble = wobble;
		_decay = 20f - 600f / wobble;
		_level0 = MathF.Min( MathF.Max( 0f, level ), ShakeCap ) * MathF.Max( 0f, ShakeScale );
		_shotAt = Time.Now;

		Log.Info( $"[mw-recoil] kick: level {_level0:0.##} at {rpm} rpm"
			+ (On ? "" : " — ⚠ the look is OFF, so nothing will show (nz_mw_recoil 1)") );
	}

	static void Report()
	{
		Log.Info( $"[mw-recoil] {(On ? "ON" : "OFF — our own gun recoil, no screen rattle")}"
			+ $" · shake x{ShakeScale:0.##} (cap {ShakeCap:0.##}) · gun x{GunScale:0.##}"
			+ (GunScale <= 0f && On ? " (our own gun recoil)" : "") );

		if ( _shots == 0 )
		{
			Log.Info( "[mw-recoil]   no shot seen yet — fire once, then run it again" );
			return;
		}

		var hz = _wobble / MathF.Tau;
		var fade = 3f / _decay;   // e^-3: down to 5%

		Log.Info( $"[mw-recoil]   last shot ({_lastRpm} rpm, strength x{_lastStrength:0.##}): level {_lastLevel:0.##}"
			+ $" — pitch ±{0.5f * _lastLevel * _lastLevel:0.##}°, roll ±{_lastLevel:0.##}° at {hz:0.#} Hz,"
			+ $" gone in {fade:0.##}s · FOV +{_lastLevel * 1.5f:0.#}° · eye back {_lastLevel:0.##} · gun x{_lastGun:0.##}" );

		Log.Info( $"[mw-recoil]   frames shaped: main camera {_mainFrames}, gun camera {_gunFrames}"
			+ (_mainFrames == 0 ? " — ⛔ THE CAMERA WAS NEVER REACHED: the modifier is not running"
				: _gunFrames == 0 ? " — ⚠ the gun's camera was never reached, so the gun holds still while the world shakes"
				: "") );
	}
}

/// <summary>
/// Puts `MwRecoilFx`'s rattle on the main camera and on the held gun's camera, and steps its view punch.
///
/// ⛔ A CAMERA MODIFIER, NOT A WRITE TO THE CAMERA OR TO `EyeAngles`. `CameraShake` records two dead ends —
/// writing `Scene.Camera.WorldRotation` raced `PlayerController` and lost, and `PlayerController.CameraOffset`
/// showed nothing — and so shakes through `EyeAngles`, which moves the aim for the frame it is on. This engine's
/// camera pipeline (`ICameraModifier`, 26.09) composes every camera's view after Update: the player's own
/// modifier writes the eye view at order ~0, and this reshapes it at 200 ("held weapons ~200"), touching the
/// render only.
///
/// ⚠️ ON THE LOCAL PLAYER ONLY. Every camera runs every modifier in the scene, so a copy on someone else's body
/// would shake your screen for their shots; `IsProxy` refuses it.
/// </summary>
public sealed class MwRecoilView : Component, ICameraModifier
{
	int ICameraModifier.CameraOrder => 200;

	protected override void OnUpdate()
	{
		if ( IsProxy ) return;
		MwRecoilFx.StepPunch( Time.Delta );
	}

	void ICameraModifier.ModifyCamera( CameraComponent camera, ref CameraView view )
	{
		if ( IsProxy || !MwRecoilFx.On || !camera.IsValid() ) return;

		if ( camera == Scene.Camera ) MwRecoilFx.Shape( ref view, true );
		else if ( camera == MwRecoilFx.GunCamera ) MwRecoilFx.Shape( ref view, false );
	}
}

/// <summary>
/// The MW Base viewmodel's recoil springs (`ENT:recoil()` in its cl_calcview.lua), per held gun.
///
/// ⚠️ THE BASE'S OWN SPRING, INCLUDING ITS DAMPING: acceleration = (target - x) x k - v x w x √⌊k⌋, stepped
/// velocity-first. So √k is the frequency and w/2 the damping ratio. The base's hip k 80, w 1.25 and aimed k 240,
/// w 0.85 take the Kastov's Snap x2 and Looseness 0.5: hip 12.6 rad/s at 1.25 of critical, aimed 21.9 at 0.85.
///
/// ⚠️ STEPPED AT 120 Hz, NOT ONCE A FRAME. The base steps once per frame, so its gun moves differently at 60 and
/// 144 fps — 10-15% in a simulation of exactly this code. Sub-stepping holds it to 2%.
///
/// ⚠️ THE ANGLE TARGET IS x10 WHAT THE SHOT SET. That is the base's (`SetTarget(target * 10)`), and why a
/// random ±0.5 reads as a ±5° jolt at the hip.
/// </summary>
public sealed class MwGunRecoil
{
	Vector3 _posTarget, _pos, _posVel;   // (right, forward, up) — the base's cPos order
	Vector3 _angTarget, _ang, _angVel;   // (pitch, yaw, roll) — the base's Angle, before the x10
	float _resetSpeed = 1f;
	float _roll, _rollShown;
	float _shake0, _shakeAt = -999f, _shakeDecay = 11.9f, _shakeShown;

	/// <summary>A shot's targets, as `SWEP:ShakeViewModel` builds them; `SetRecoilTargets` then holds them.</summary>
	public void Kick( Vector3 pos, Vector3 ang, float roll, float shake, float decay )
	{
		_posTarget = pos;
		_angTarget = ang;

		// ⚠️ -1, NOT 0: the walk back eases up from below zero, so a target HOLDS for about 0.07 s first.
		_resetSpeed = -1f;
		_roll = roll;

		_shake0 = shake;
		_shakeAt = Time.Now;
		_shakeDecay = decay;
	}

	public void Step( float dt, float aim )
	{
		if ( dt <= 0f ) return;
		dt = MathF.Min( dt, 0.1f );

		var steps = Math.Clamp( (int)MathF.Ceiling( dt * 120f ), 1, 12 );
		for ( var i = 0; i < steps; i++ )
			StepOnce( dt / steps, aim );
	}

	void StepOnce( float dt, float aim )
	{
		var ease = MathF.Min( 1f, 10f * dt );
		var shake = _shake0 > 0f ? _shake0 * MathF.Exp( -_shakeDecay * MathF.Max( 0f, Time.Now - _shakeAt ) ) : 0f;

		_shakeShown = MathX.Lerp( _shakeShown, shake, ease );
		_roll = MathX.Lerp( _roll, 0f, ease );
		_rollShown = MathX.Lerp( _rollShown, _roll, ease );

		var angK = MathX.Lerp( 80f, 240f, aim ) * MwRecoilFx.VmSnap;
		var angW = MathX.Lerp( 1.25f, 0.85f, aim ) / MwRecoilFx.VmLooseness;
		var posK = MathX.Lerp( 80f, 120f, aim ) * MwRecoilFx.VmSnap;
		var posW = MathX.Lerp( 1f, 1.2f, aim ) / MwRecoilFx.VmLooseness;

		_resetSpeed = MathX.Lerp( _resetSpeed, 1f, ease );
		var reset = Math.Clamp( _resetSpeed, 0f, 1f ) * 100f * dt;

		_posTarget = Toward0( _posTarget, reset );
		_angTarget = Toward0( _angTarget, reset );

		Spring( ref _pos, ref _posVel, _posTarget, posK, posW, dt );
		Spring( ref _ang, ref _angVel, _angTarget * 10f, angK, angW, dt );
	}

	/// <summary>
	/// What the springs show, in the base's terms: <paramref name="turn"/> is its (pitch up, yaw left, roll) and
	/// <paramref name="move"/> its (right, forward, up), both about and along the eye.
	/// </summary>
	public void Output( float aim, out Vector3 turn, out Vector3 move )
	{
		var p = _ang.x * MathX.Lerp( 1f, 0.065f, aim );
		var y = _ang.y * MathX.Lerp( 1f, 0.08f, aim );
		var r = _ang.z * MathX.Lerp( 1f, 0.1f, aim );

		turn = new Vector3( -p, -y, r + MathX.Lerp( 0f, _rollShown, aim ) );

		var m = MathX.Lerp( 1f, 0.35f, aim );

		// ⚠️ AIMED, THE SHAKE PUSHES THE GUN BACK TOO (1.5 x the level) — capped at 3 units here, because a level-3
		// shotgun would otherwise put a 4.5-unit shove into a sight that sits a few units from the eye.
		var shove = MathF.Min( 3f, MathX.Lerp( 0f, _shakeShown * 1.5f, aim ) );
		move = new Vector3( -_pos.x * m, -_pos.y * m - shove, _pos.z * m );
	}

	static Vector3 Toward0( Vector3 v, float step )
		=> new( Toward0( v.x, step ), Toward0( v.y, step ), Toward0( v.z, step ) );

	static float Toward0( float v, float step )
		=> v > 0f ? MathF.Max( 0f, v - step ) : MathF.Min( 0f, v + step );

	static void Spring( ref Vector3 x, ref Vector3 v, Vector3 target, float k, float w, float dt )
	{
		var damping = w * MathF.Sqrt( MathF.Floor( k ) );
		v += ((target - x) * k - v * damping) * dt;
		x += v * dt;
	}
}