Skip to content
SlashClone
Made by@gameforge

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