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 exposingfire(event)and a no-opupdate(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-onlychargeGlowand_fireFlash).
READS FROM
shipfrom../core— used as the world-space anchor for juice particle bursts.RecoilProfiletype aliasSpecRecoilProfilefrom../../data/weapons/_types— accepted as the highest-priority override toShipRecoil.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 byShipRecoil.updatefor 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.updateis 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 —
chargeGlowand_fireFlashare retained only for API compatibility. - Does not consult the event name on unknown keys —
Juice.firesilently returns when the event is not inJUICE_TABLE. - Does not stack anticipation additively — multiple warming weapons compose multiplicatively into
_antSx/_antSyper frame.
Signals
Juice event registry (JUICE_TABLE) — every supported Juice.fire(event) key with its particle count:
| Event | Particles |
|---|---|
player_shot | 0 |
kill | 0 |
enemy_kill_tiny | 0 |
enemy_kill | 0 |
enemy_kill_large | 3 |
elite_kill | 10 |
boss_hit | 5 |
boss_kill | 30 |
player_hit | 0 |
player_hull_hit | 0 |
shield_hit | 0 |
shield_break | 20 |
shield_broken | 20 |
levelup | 0 |
beacon_done | 20 |
event_done | 12 |
comet_catch | 0 |
solar_flare | 20 |
revive | 25 |
detected | 8 |
crate_break | 0 |
crate_break_big | 0 |
ram_hit_light | 0 |
ram_hit_medium | 0 |
ram_hit_heavy | 2 |
object_hit | 0 |
object_destroyed | 20 |
extraction_done | 20 |
chase_catch | 15 |
player_death | 25 |
aoe_pulse | 10 |
invuln | 15 |
warp_collect | 20 |
Zero-particle entries still dispatch their micro-SFX cue via MicroSfx.play(event).
Entry points
Juice.fire(event: string): void— central dispatch. Looks upJUICE_TABLE[event], early-returns on unknown events, optionally emits a spark burst atship.x/ship.ywhenparticles > 0andshipis defined, then always callsMicroSfx.play(event).Juice.update(_dt: number): void— no-op kept for the engine update contract.ShipRecoil.resetAnticipation(): void— clears_antSxand_antSyto 1. Called once per frame before weapon updates.ShipRecoil.anticipate(progress: number, pulseStrength: number): void— adds a per-weapon warmup squeeze usingt = progress * progressandsqueeze = t * ANTICIPATE_MAX * pulseStrength; multiplicatively compresses_antSxand stretches_antSy. No-op whenprogress <= 0.ShipRecoil.fire(archetype, weaponId?, _fireAngle?, pulseStrength?, specProfile?): void— sets the recoil tween. Lookup priority isspecProfile→WEAPON_RECOIL_OVERRIDES[weaponId]→WEAPON_RECOIL[archetype]. No-op when no profile resolves orprofile.dur <= 0. Deformation is scaled bypulseStrength ?? 1.0:_tx = 1 + (profile.sx - 1) * ps,_ty = 1 + (profile.sy - 1) * ps._durand_timerare seeded fromprofile.dur._fireAngleis 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) + 1withp = 0.3), lerps from squash target back to 1, then composes the finalsx/syasrecoilSx * _antSxandrecoilSy * _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 exceptANTICIPATE_MAX(named) and the elastic-ease constants insideShipRecoil.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 withJUICE_TABLE. - Particle burst is gated on both
j.particles > 0andshipbeing 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 forsy. Renderer reads only the composedsx/sy. - Anticipation is a per-frame accumulator: callers must invoke
resetAnticipation()before the per-weapon tick each frame, then each warming weapon callsanticipate(progress, pulseStrength). - Elastic ease-out is used for the snap-back tween to provide overshoot-and-settle feel.
chargeGlowand_fireFlashare intentionally retained as dead fields to preserve the existingShipRecoilshape 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.