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.
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;
}
}