PURPOSE

Game-feel layer (“juice”) for impactful moments. Dispatches particle bursts and micro-SFX in response to named gameplay events, and runs a cosmetic squash/stretch tween on the ship sprite for weapon recoil and firing anticipation. Ported from 05-vfx.js Juice system.

OWNS

  • JUICE_TABLE — registry mapping event-name strings to per-event particle counts.
  • Juice — module exposing fire(event) and a no-op update(dt).
  • WEAPON_RECOIL — archetype-keyed recoil profiles (sx, sy, dur).
  • WEAPON_RECOIL_OVERRIDES — weapon-id-keyed overrides for specific projectile subtypes.
  • ANTICIPATE_MAX — module-private cap on anticipation squeeze amplitude.
  • ShipRecoil — singleton holding current display scale, recoil tween state, and per-frame anticipation accumulators (sx, sy, _tx, _ty, _timer, _dur, _antSx, _antSy, plus compat-only chargeGlow and _fireFlash).

READS FROM

  • ship from ../core — used as the world-space anchor for juice particle bursts.
  • RecoilProfile type alias SpecRecoilProfile from ../../data/weapons/_types — accepted as the highest-priority override to ShipRecoil.fire.

PUSHES TO

  • Particles.burst(x, y, count, 'spark', { r: 200, g: 220, b: 255 }, 60) — emits a spark burst at the ship position when a juice event has a nonzero particle count.
  • MicroSfx.play(event) — plays the per-event micro-SFX cue layered under the visual juice.
  • ShipRecoil.sx / ShipRecoil.sy — written by ShipRecoil.update for the renderer to consume as the ship sprite’s squash/stretch scale.

DOES NOT

  • Does not apply screen shake — the shake system has been deleted.
  • Does not apply time dilation — the dilation system has been deleted (Juice.update is intentionally empty).
  • Does not modify player physics velocity on weapon fire. As of v5.156.4 the recoil kick is removed; ship movement is fully under joystick control at all times.
  • Does not emit lightning / charge-glow effects — chargeGlow and _fireFlash are retained only for API compatibility.
  • Does not consult the event name on unknown keys — Juice.fire silently returns when the event is not in JUICE_TABLE.
  • Does not stack anticipation additively — multiple warming weapons compose multiplicatively into _antSx / _antSy per frame.

Signals

Juice event registry (JUICE_TABLE) — every supported Juice.fire(event) key with its particle count:

EventParticles
player_shot0
kill0
enemy_kill_tiny0
enemy_kill0
enemy_kill_large3
elite_kill10
boss_hit5
boss_kill30
player_hit0
player_hull_hit0
shield_hit0
shield_break20
shield_broken20
levelup0
beacon_done20
event_done12
comet_catch0
solar_flare20
revive25
detected8
crate_break0
crate_break_big0
ram_hit_light0
ram_hit_medium0
ram_hit_heavy2
object_hit0
object_destroyed20
extraction_done20
chase_catch15
player_death25
aoe_pulse10
invuln15
warp_collect20

Zero-particle entries still dispatch their micro-SFX cue via MicroSfx.play(event).

Entry points

  • Juice.fire(event: string): void — central dispatch. Looks up JUICE_TABLE[event], early-returns on unknown events, optionally emits a spark burst at ship.x/ship.y when particles > 0 and ship is defined, then always calls MicroSfx.play(event).
  • Juice.update(_dt: number): void — no-op kept for the engine update contract.
  • ShipRecoil.resetAnticipation(): void — clears _antSx and _antSy to 1. Called once per frame before weapon updates.
  • ShipRecoil.anticipate(progress: number, pulseStrength: number): void — adds a per-weapon warmup squeeze using t = progress * progress and squeeze = t * ANTICIPATE_MAX * pulseStrength; multiplicatively compresses _antSx and stretches _antSy. No-op when progress <= 0.
  • ShipRecoil.fire(archetype, weaponId?, _fireAngle?, pulseStrength?, specProfile?): void — sets the recoil tween. Lookup priority is specProfileWEAPON_RECOIL_OVERRIDES[weaponId]WEAPON_RECOIL[archetype]. No-op when no profile resolves or profile.dur <= 0. Deformation is scaled by pulseStrength ?? 1.0: _tx = 1 + (profile.sx - 1) * ps, _ty = 1 + (profile.sy - 1) * ps. _dur and _timer are seeded from profile.dur. _fireAngle is unused (kept for API stability).
  • ShipRecoil.update(dt: number): void — advances _timer, computes recoil scale via an elastic ease-out (Math.pow(2, -10*t) * Math.sin(((t - p/4) * 2π) / p) + 1 with p = 0.3), lerps from squash target back to 1, then composes the final sx / sy as recoilSx * _antSx and recoilSy * _antSy.

WEAPON_RECOIL archetypes shipped: projectile, beam, sniper, field, nova, flamethrower, chain, leech, vortex, vent, melee, drone, sigil, homing, explosive. drone and sigil use identity profiles with dur: 0, so ShipRecoil.fire is a no-op for them.

WEAPON_RECOIL_OVERRIDES shipped: shotgun, railgun, missile, machine_gun.

Pattern notes

  • Data-in-table, behavior-in-code: every event count and recoil profile lives in module-level constants (JUICE_TABLE, WEAPON_RECOIL, WEAPON_RECOIL_OVERRIDES); no magic numbers inside the dispatch / tween functions except ANTICIPATE_MAX (named) and the elastic-ease constants inside ShipRecoil.update.
  • Silent fallback on unknown event keys in Juice.fire — boundary tolerance against typos at callsites rather than a crash. Internal callers must keep event strings in sync with JUICE_TABLE.
  • Particle burst is gated on both j.particles > 0 and ship being defined; micro-SFX is always dispatched regardless of particle count.
  • Recoil profile lookup is a three-tier override chain: spec-level (per-weapon WeaponCoreSpec.recoilProfile) beats weapon-id table beats archetype default.
  • Anticipation and recoil are two independent multiplicative channels: ShipRecoil.sx = recoilSx * _antSx, same for sy. Renderer reads only the composed sx / sy.
  • Anticipation is a per-frame accumulator: callers must invoke resetAnticipation() before the per-weapon tick each frame, then each warming weapon calls anticipate(progress, pulseStrength).
  • Elastic ease-out is used for the snap-back tween to provide overshoot-and-settle feel.
  • chargeGlow and _fireFlash are intentionally retained as dead fields to preserve the existing ShipRecoil shape after the lightning system removal.
  • v5.156.4 explicitly removed all physics-velocity kicks from fire; the file’s comments document this contract so future edits don’t silently restore movement coupling.