Game juice pass
@gameforge/game-juice-pass
Make a working game feel good: an ordered juice pass that adds screen shake, hit stop, particles, squash and stretch, easing, score popups, flashes, and tiny Web Audio sounds to the moments that matter, each behind a config value and a reduced-motion setting. Ships dependency-free canvas snippets.
SkillOtherv1.0.0 (latest)
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1// Juice snippets for canvas games. Plain JavaScript, no dependencies.
- 2// Copy only the pieces a game needs, and wire each one to a CONFIG value so it can be tuned.
- 3// Every timer here is in seconds and every update takes dt, matching a requestAnimationFrame loop.
- 5// 1. Screen shake (trauma model): add trauma on impact, shake = trauma squared, decays over time.
- 6const shake = { trauma: 0, max: 14, decay: 1.6 };
- 7function addTrauma(amount) { shake.trauma = Math.min(1, shake.trauma + amount); }
- 8function updateShake(dt) { shake.trauma = Math.max(0, shake.trauma - shake.decay * dt); }
- 9function applyShake(ctx) {
- 10 const s = shake.trauma * shake.trauma * shake.max;
- 11 if (s > 0) ctx.translate((Math.random() * 2 - 1) * s, (Math.random() * 2 - 1) * s);
- 12}
- 14// 2. Hit stop: freeze the simulation for a few frames on a big hit. Drawing keeps running.
- 15let hitStop = 0;
- 16function addHitStop(seconds) { hitStop = Math.max(hitStop, seconds); }
- 17function simDt(dt) {
- 18 if (hitStop > 0) { hitStop -= dt; return 0; }
- 19 return dt;
- 20}
- 22// 3. Particles: small, short-lived, pooled in one array.
- 23const particles = [];
- 24function burst(x, y, { count = 16, speed = 220, life = 0.5, color = "#ffd166", gravity = 500 } = {}) {
- 25 for (let i = 0; i < count; i++) {
- 26 const a = Math.random() * Math.PI * 2, v = speed * (0.4 + Math.random() * 0.6);
- 27 particles.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, life, max: life, color, gravity });
- 28 }
- 29}
- 30function updateParticles(dt) {
- 31 for (const p of particles) { p.x += p.vx * dt; p.y += p.vy * dt; p.vy += p.gravity * dt; p.life -= dt; }
- 32 for (let i = particles.length - 1; i >= 0; i--) if (particles[i].life <= 0) particles.splice(i, 1);
- 33}
- 34function drawParticles(ctx) {
- 35 for (const p of particles) {
- 36 ctx.globalAlpha = Math.max(0, p.life / p.max);
- 37 ctx.fillStyle = p.color;
- 38 ctx.fillRect(p.x - 2, p.y - 2, 4, 4);
- 39 }
- 40 ctx.globalAlpha = 1;
- 41}
- 43// 4. Easing and tweens: animate any numeric property.
- 44const ease = {
- 45 outCubic: (t) => 1 - Math.pow(1 - t, 3),
- 46 outBack: (t) => 1 + 2.7 * Math.pow(t - 1, 3) + 1.7 * Math.pow(t - 1, 2),
- 47 outElastic: (t) => (t === 0 || t === 1 ? t : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI) / 3) + 1),
- 48};
- 49const tweens = [];
- 50function tween(obj, key, to, duration, fn = ease.outCubic) {
- 51 tweens.push({ obj, key, from: obj[key], to, t: 0, duration, fn });
- 52}
- 53function updateTweens(dt) {
- 54 for (const tw of tweens) {
- 55 tw.t = Math.min(1, tw.t + dt / tw.duration);
- 56 tw.obj[tw.key] = tw.from + (tw.to - tw.from) * tw.fn(tw.t);
- 57 }
- 58 for (let i = tweens.length - 1; i >= 0; i--) if (tweens[i].t >= 1) tweens.splice(i, 1);
- 59}
- 61// 5. Squash and stretch: keep area roughly constant. Call on land (squash) and jump (stretch).
- 62function squash(entity, amount = 0.3) {
- 63 entity.sx = 1 + amount; entity.sy = 1 - amount;
- 64 tween(entity, "sx", 1, 0.18, ease.outBack);
- 65 tween(entity, "sy", 1, 0.18, ease.outBack);
- 66}
- 67function drawScaled(ctx, entity, drawFn) {
- 68 ctx.save();
- 69 ctx.translate(entity.x, entity.y + entity.h / 2); // scale from the feet
- 70 ctx.scale(entity.sx ?? 1, entity.sy ?? 1);
- 71 ctx.translate(-entity.x, -(entity.y + entity.h / 2));
- 72 drawFn();
- 73 ctx.restore();
- 74}
- 76// 6. Floating score text.
- 77const popups = [];
- 78function popup(x, y, text, color = "#ffffff") { popups.push({ x, y, text, color, life: 0.7 }); }
- 79function updatePopups(dt) {
- 80 for (const p of popups) { p.y -= 60 * dt; p.life -= dt; }
- 81 for (let i = popups.length - 1; i >= 0; i--) if (popups[i].life <= 0) popups.splice(i, 1);
- 82}
- 83function drawPopups(ctx) {
- 84 ctx.textAlign = "center";
- 85 ctx.font = "bold 20px system-ui, sans-serif";
- 86 for (const p of popups) {
- 87 ctx.globalAlpha = Math.min(1, p.life * 2);
- 88 ctx.fillStyle = p.color;
- 89 ctx.fillText(p.text, p.x, p.y);
- 90 }
- 91 ctx.globalAlpha = 1;
- 92}
- 94// 7. Screen flash: a quick full-screen tint on damage or pickup.
- 95let flash = { alpha: 0, color: "#ffffff" };
- 96function addFlash(color = "#ffffff", alpha = 0.35) { flash = { alpha, color }; }
- 97function drawFlash(ctx, w, h, dt) {
- 98 if (flash.alpha <= 0) return;
- 99 ctx.globalAlpha = flash.alpha;
- 100 ctx.fillStyle = flash.color;
- 101 ctx.fillRect(0, 0, w, h);
- 102 ctx.globalAlpha = 1;
- 103 flash.alpha = Math.max(0, flash.alpha - 2.5 * dt);
- 104}
- 106// 8. Sound with no audio files: tiny Web Audio blips. Browsers start audio only after a user gesture,
- 107// so create the context lazily inside the first input handler.
- 108let audio = null;
- 109let muted = false;
- 110function blip({ freq = 660, to = 880, duration = 0.08, type = "square", volume = 0.08 } = {}) {
- 111 if (muted) return;
- 112 audio ??= new (window.AudioContext || window.webkitAudioContext)();
- 113 const t = audio.currentTime;
- 114 const osc = audio.createOscillator(), gain = audio.createGain();
- 115 osc.type = type;
- 116 osc.frequency.setValueAtTime(freq, t);
- 117 osc.frequency.exponentialRampToValueAtTime(to, t + duration);
- 118 gain.gain.setValueAtTime(volume, t);
- 119 gain.gain.exponentialRampToValueAtTime(0.0001, t + duration);
- 120 osc.connect(gain).connect(audio.destination);
- 121 osc.start(t);
- 122 osc.stop(t + duration);
- 123}
- 124const sfx = {
- 125 pickup: () => blip({ freq: 660, to: 1320, duration: 0.09 }),
- 126 jump: () => blip({ freq: 300, to: 600, duration: 0.12, type: "triangle" }),
- 127 hit: () => blip({ freq: 180, to: 60, duration: 0.2, type: "sawtooth", volume: 0.12 }),
- 128};
- 130// 9. Respect players who ask for less motion.
- 131const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
- 132if (reducedMotion) { shake.max = 3; }
