From 5861dd6b97986c92c64f6459718b8bbf6886f848 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 10 Sep 2026 21:06:07 -0400 Subject: [PATCH] Pinball: haptic feedback (Vibration API) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Short vibrations on bumper/peg hits, launch, drain, catch, and game over, gated behind a new 📳 toggle in the HUD. Rattle hits are throttled so they don't stack up; the celebratory events (catch, drain, game over) use a pattern and bypass the throttle. The toggle only appears where navigator.vibrate exists (Android Chrome/Firefox); iOS and desktop simply don't show it. State persists in pdx.pinball alongside the sound setting. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- src/store/pinball.js | 11 +++++- src/views/PinballView.js | 80 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/store/pinball.js b/src/store/pinball.js index 5cf4f36..7146509 100644 --- a/src/store/pinball.js +++ b/src/store/pinball.js @@ -1,7 +1,12 @@ import { createStore } from './createStore.js'; /** High score + the catch log for the hidden pinball table (#/pinball). */ -export const pinball = createStore('pdx.pinball', { best: 0, caught: [], sound: true }); +export const pinball = createStore('pdx.pinball', { + best: 0, + caught: [], + sound: true, + haptics: true, +}); export function recordPinball(score) { if (score > pinball.get().best) pinball.set((s) => ({ ...s, best: score })); @@ -18,3 +23,7 @@ export function recordCatch(mon) { export function togglePinballSound() { pinball.set((s) => ({ ...s, sound: !s.sound })); } + +export function togglePinballHaptics() { + pinball.set((s) => ({ ...s, haptics: !s.haptics })); +} diff --git a/src/views/PinballView.js b/src/views/PinballView.js index 3800513..33f562f 100644 --- a/src/views/PinballView.js +++ b/src/views/PinballView.js @@ -1,7 +1,13 @@ import { el, onTeardown } from '../lib/dom.js'; import { loadSnapshot } from '../data/snapshot.js'; import { createPinball } from '../lib/pinball.js'; -import { pinball, recordPinball, recordCatch, togglePinballSound } from '../store/pinball.js'; +import { + pinball, + recordPinball, + recordCatch, + togglePinballSound, + togglePinballHaptics, +} from '../store/pinball.js'; import { Sprite } from '../components/Sprite.js'; const pretty = (name) => name.replace(/-/g, ' '); @@ -40,6 +46,29 @@ const sfx = { }, }; +// --- haptics (Android / supporting browsers; a no-op elsewhere) ------- +const canVibrate = typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function'; +let lastBuzz = 0; +function buzz(pattern, force) { + if (!canVibrate || !pinball.get().haptics) return; + const now = performance.now(); + if (!force && now - lastBuzz < 24) return; // don't stack up during a rattle + lastBuzz = now; + try { + navigator.vibrate(pattern); + } catch { + /* vibration not available — no-op */ + } +} +const haptics = { + bump: () => buzz(14), + post: () => buzz(7), + launch: () => buzz(22, true), + drain: () => buzz([45, 35, 70], true), + catch: () => buzz([16, 40, 16, 40, 45], true), + over: () => buzz([80, 60, 80], true), +}; + /** * Hidden mini-game — a single Pokémon-flavoured pinball table. Reached from * the "Who's that Pokémon?" screen's nav row, or the bare #/pinball hash. @@ -90,11 +119,38 @@ export async function PinballView() { }); soundBtn.textContent = pinball.get().sound ? '🔊' : '🔇'; + const hapticBtn = canVibrate + ? el('button', { + class: 'pin__sound', + type: 'button', + title: 'Vibration', + 'aria-label': 'Toggle vibration', + onclick: () => { + togglePinballHaptics(); + const on = pinball.get().haptics; + hapticBtn.textContent = on ? '📳' : '📴'; + if (on) navigator.vibrate(20); + }, + }) + : null; + if (hapticBtn) hapticBtn.textContent = pinball.get().haptics ? '📳' : '📴'; + const game = createPinball(canvas, { onScore: (s) => (scoreEl.textContent = s.toLocaleString()), onBalls: renderBalls, - onBump: (kind) => (kind === 'post' ? sfx.post() : sfx.bump()), - onDrain: () => sfx.drain(), + onBump: (kind) => { + if (kind === 'post') { + sfx.post(); + haptics.post(); + } else { + sfx.bump(); + haptics.bump(); + } + }, + onDrain: () => { + sfx.drain(); + haptics.drain(); + }, onCharge: (c) => launchBtn.style.setProperty('--charge', c.toFixed(3)), pickMon: () => { const s = snap.species[(Math.random() * snap.species.length) | 0]; @@ -102,6 +158,7 @@ export async function PinballView() { }, onCatch: (mon) => { sfx.catch(); + haptics.catch(); recordCatch(mon); renderCaught(); catchPop.replaceChildren( @@ -115,6 +172,7 @@ export async function PinballView() { }, onGameOver: (score) => { recordPinball(score); + haptics.over(); bestEl.textContent = `Best ${pinball.get().best.toLocaleString()}`; overlay.replaceChildren( el('p', { class: 'pin__over-score' }, `${score.toLocaleString()} points`), @@ -145,7 +203,10 @@ export async function PinballView() { game.setFlipper(f, down); e.preventDefault(); } else if (e.key === ' ' || e.key === 'ArrowDown') { - if (down && !e.repeat) sfx.launch(); + if (down && !e.repeat) { + sfx.launch(); + haptics.launch(); + } game.setPlunge(down); e.preventDefault(); } @@ -185,6 +246,7 @@ export async function PinballView() { onpointerdown: (e) => { e.preventDefault(); sfx.launch(); + haptics.launch(); game.setPlunge(true); }, }); @@ -219,7 +281,15 @@ export async function PinballView() { el('a', { class: 'link', href: '#/whos-that' }, "Who's that Pokémon?"), ), el('header', { class: 'view__header' }, el('h1', {}, 'Pinball')), - el('div', { class: 'pin__hud' }, el('span', {}, 'Score ', scoreEl), ballsEl, bestEl, soundBtn), + el( + 'div', + { class: 'pin__hud' }, + el('span', {}, 'Score ', scoreEl), + ballsEl, + bestEl, + soundBtn, + hapticBtn, + ), el('div', { class: 'pin__stage' }, canvas, overlay, catchPop), caughtStrip, el('div', { class: 'pin__controls' }, launchBtn),