Weapon Animation Lab
Recoil, the action cycle and the reload — every moving part, in milliseconds.
Pistol
hold the stage to fireThe whole slide cycle is about 90 ms — shorter than the muzzle flash lingers in most players’ eyes. It reads not because you can see each phase but because the slide is caught at the rear for a beat before it returns.
Weapons
Sidearm
Automatic
Manual
Exotic
Fire
A round leaves every 158 ms — 9.5f at 60 fps.
Recoil
Damping ratio 0.68 — under critical, so the weapon overshoots past rest before settling. That dip is most of what reads as snappy.
Action cycle
Slide
The hold at the rear is what separates a mechanism from a slider. Set it to 0 and the same travel reads as the part bouncing rather than being worked. A manual action also caps the rate of fire — the weapon cannot fire until it is home.
Ejection & muzzle
A flash under about 30 ms is under two frames at 60 fps — it reads as a flicker on some displays and is missed entirely on others. Two to four frames is the safe range. The case should leave mid-travel, not at the shot.
Reload
0.96 s total. Press Reload above to play it — the action cycles once at the end if a chamber time is set.
Camera
1.4f at 60 fps, running at 20% speed.
Shake is trauma squared, so it settles fast and small hits stay small. Trauma adds up across hits and clamps at 1 — that is what stops a burst from turning the screen into noise.
Your sprite sheet
using System.Collections;
using UnityEngine;
/// <summary>
/// Pistol — recoil spring plus the action cycle.
/// Tuned in Weapon Animation Lab on burakkaan.dev.
///
/// Hierarchy this expects:
/// Weapon (this component, holds the rest pose)
/// RecoilPivot — driven by the spring, never keyframed
/// Body — receiver, barrel, sprite
/// Action — slide / bolt / forestock, driven by the cycle
/// Muzzle — empty transform at the barrel tip
/// Magazine — optional, driven by the reload
///
/// Recoil and the cycle are separate on purpose. Recoil is physics you feel;
/// the cycle is animation on fixed timings. Baking both into one clip means
/// you cannot change the rate of fire without re-authoring the animation.
/// </summary>
public class WeaponAnimator : MonoBehaviour
{
public enum ActionType { Fixed, Slide, Bolt, Pump, BoltAction, Rotary }
[Header("Fire")]
public float roundsPerMinute = 380f;
[Tooltip("0 = full auto while held.")]
[Min(0)] public int roundsPerPull = 1;
[Header("Recoil")]
public float kickBack = 0.100f; // world units
public float kickUp = 0.030f;
public float muzzleClimb = 8.0f; // degrees
public float stiffness = 260f;
public float damping = 22f;
[Range(0f, 1f)] public float accumulation = 0.25f;
public float kickCeiling = 0.160f;
[Header("Action cycle (seconds)")]
public ActionType action = ActionType.Slide;
public float cycleDelay = 0.010f;
public float travelBack = 0.032f;
[Tooltip("Held at the rear. Set this to 0 and the part reads as bouncing, not being worked.")]
public float holdAtRear = 0.018f;
public float travelForward = 0.044f;
public float travelDistance = 0.160f;
[Tooltip("The player works this action, so it gates the rate of fire.")]
public bool manualAction = false;
[Header("Rotary")]
public float spinUp = 0.000f;
public float spinDown = 0.000f;
public float barrelRpm = 0f;
[Header("Ejection & muzzle")]
public bool ejectShells = true;
public float ejectAt = 0.044f;
public GameObject shellPrefab;
public Transform ejectionPort;
public ParticleSystem muzzleFlash;
[Header("Reload (seconds)")]
public float magOut = 0.280f;
public float magIn = 0.420f;
public float chamber = 0.260f;
[Header("Refs")]
public Transform recoilPivot;
public Transform actionPart;
public Transform barrelGroup;
public Transform magazine;
public Transform muzzle;
public CombatImpact impact;
// spring state
float _back, _backVel, _up, _upVel, _roll, _rollVel;
// cycle state
float _nextShotTime;
int _burstLeft;
bool _prevHeld;
float _spin;
Vector3 _pivotRest, _actionRest, _magRest;
Coroutine _cycle, _reload;
/// <summary>Seconds between rounds the trigger allows.</summary>
public float FireInterval => 60f / Mathf.Max(1f, roundsPerMinute);
/// <summary>Seconds the action needs from the shot to back in battery.</summary>
public float CycleTime =>
action == ActionType.Fixed || action == ActionType.Rotary
? 0f
: cycleDelay + travelBack + holdAtRear + travelForward;
/// <summary>What the weapon can actually sustain: 380 rpm as tuned.</summary>
public float Gate => manualAction ? Mathf.Max(FireInterval, CycleTime) : FireInterval;
public bool Reloading => _reload != null;
public bool SpunUp => action != ActionType.Rotary || _spin >= 1f;
void Awake()
{
if (recoilPivot == null) recoilPivot = transform;
_pivotRest = recoilPivot.localPosition;
if (actionPart != null) _actionRest = actionPart.localPosition;
if (magazine != null) _magRest = magazine.localPosition;
}
/// <summary>Drive this from input every frame.</summary>
public void SetTrigger(bool held)
{
if (roundsPerPull > 0)
{
if (held && !_prevHeld) _burstLeft = roundsPerPull; // needs a fresh pull
if (!held) _burstLeft = 0;
}
_prevHeld = held;
// Rotary barrels have to reach speed before anything leaves them: the
// animation gates the shooting here, not the other way round.
if (action == ActionType.Rotary)
{
float rate = held && !Reloading
? Time.deltaTime / Mathf.Max(0.001f, spinUp)
: -Time.deltaTime / Mathf.Max(0.001f, spinDown);
_spin = Mathf.Clamp01(_spin + rate);
if (barrelGroup != null)
barrelGroup.Rotate(0f, 0f, _spin * barrelRpm * 6f * Time.deltaTime);
}
bool canFire = (roundsPerPull > 0 ? _burstLeft > 0 : held) && SpunUp && !Reloading;
if (canFire && Time.time >= _nextShotTime)
{
Fire();
_nextShotTime = Time.time + Gate;
if (roundsPerPull > 0) _burstLeft--;
}
}
public void Fire()
{
_back = Mathf.Min(kickCeiling, _back * accumulation + kickBack);
_up = Mathf.Min(kickCeiling, _up * accumulation + kickUp);
_roll = Mathf.Min(kickCeiling * 150f, _roll * accumulation + muzzleClimb);
if (muzzleFlash != null) muzzleFlash.Play();
if (impact != null) impact.AddTrauma(impact.traumaPerHit);
SpawnRound();
// Restarting rather than stacking: at a high rate of fire the previous
// cycle is often still running, and two coroutines fighting over one
// transform is what makes an action jitter.
if (_cycle != null) StopCoroutine(_cycle);
_cycle = StartCoroutine(CycleAction());
}
void SpawnRound()
{
// Your projectile or raycast goes here.
if (muzzle != null) Debug.DrawRay(muzzle.position, muzzle.right * 5f, Color.yellow, 0.3f);
}
IEnumerator CycleAction()
{
if (ejectShells) StartCoroutine(EjectAfter(ejectAt));
if (actionPart == null || CycleTime <= 0f) yield break;
if (cycleDelay > 0f) yield return new WaitForSeconds(cycleDelay);
// Out is eased Out and back is eased In: the part decelerates into the
// rear stop against its spring, then accelerates out of it. Swapping the
// two is what makes a cycle look like a slider being dragged.
yield return Travel(0f, travelDistance, travelBack, true);
if (holdAtRear > 0f) yield return new WaitForSeconds(holdAtRear);
yield return Travel(travelDistance, 0f, travelForward, false);
actionPart.localPosition = _actionRest;
_cycle = null;
}
IEnumerator Travel(float from, float to, float duration, bool easeOut)
{
float t = 0f;
while (t < duration)
{
t += Time.deltaTime;
float k = Mathf.Clamp01(t / Mathf.Max(0.0001f, duration));
k = easeOut ? 1f - (1f - k) * (1f - k) : k * k;
actionPart.localPosition = _actionRest + Vector3.left * Mathf.Lerp(from, to, k);
yield return null;
}
}
IEnumerator EjectAfter(float delay)
{
if (delay > 0f) yield return new WaitForSeconds(delay);
if (shellPrefab == null || ejectionPort == null) yield break;
var shell = Instantiate(shellPrefab, ejectionPort.position, ejectionPort.rotation);
var rb = shell.GetComponent<Rigidbody2D>();
if (rb != null)
{
rb.linearVelocity = ejectionPort.up * Random.Range(2.2f, 3.4f)
+ ejectionPort.right * Random.Range(0.4f, 1.1f);
rb.angularVelocity = Random.Range(-720f, 720f);
}
}
public void Reload()
{
if (Reloading) return;
_reload = StartCoroutine(ReloadRoutine());
}
IEnumerator ReloadRoutine()
{
if (magazine != null && magOut > 0f)
{
float t = 0f;
while (t < magOut)
{
t += Time.deltaTime;
float k = Mathf.Clamp01(t / magOut);
magazine.localPosition = _magRest + Vector3.down * (k * 0.34f);
yield return null;
}
magazine.gameObject.SetActive(false);
}
if (magazine != null && magIn > 0f)
{
magazine.gameObject.SetActive(true);
float t = 0f;
while (t < magIn)
{
t += Time.deltaTime;
float k = Mathf.Clamp01(t / magIn);
magazine.localPosition = _magRest + Vector3.down * ((1f - k) * 0.34f);
yield return null;
}
magazine.localPosition = _magRest;
}
// Working the action after the magazine seats — skip it if the weapon
// never went to slide lock.
if (chamber > 0f && actionPart != null)
{
yield return Travel(0f, travelDistance, chamber * 0.45f, true);
yield return Travel(travelDistance, 0f, chamber * 0.55f, false);
}
_reload = null;
}
void LateUpdate()
{
float dt = Time.deltaTime;
Step(ref _back, ref _backVel, stiffness, damping, dt);
Step(ref _up, ref _upVel, stiffness, damping, dt);
Step(ref _roll, ref _rollVel, stiffness * 0.85f, damping, dt);
recoilPivot.localPosition = _pivotRest + new Vector3(-_back, _up, 0f);
recoilPivot.localRotation = Quaternion.Euler(0f, 0f, _roll);
}
/// <summary>
/// Substepped at 4 ms. A stiff spring integrated once per frame diverges on a
/// long frame — that is the "recoil launched the gun off screen" bug.
/// </summary>
static void Step(ref float value, ref float velocity, float k, float c, float dt)
{
int steps = Mathf.Clamp(Mathf.CeilToInt(dt / 0.004f), 1, 8);
float h = dt / steps;
for (int i = 0; i < steps; i++)
{
float accel = -value * k - velocity * c;
velocity += accel * h;
value += velocity * h;
}
}
void OnDrawGizmosSelected()
{
if (muzzle == null) return;
Gizmos.color = new Color(1.000f, 0.824f, 0.478f, 0.600f);
Gizmos.DrawWireSphere(muzzle.position, 0.08f);
}
}Recoil and the action cycle are driven separately: the spring runs every frame in LateUpdate, the cycle is a coroutine on fixed timings. Mixing them into one tween is what makes a fast weapon look like it is sliding rather than working.