Pinball: haptic feedback (Vibration API)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-10 21:06:07 -04:00
parent d9ec697af8
commit 5861dd6b97
2 changed files with 85 additions and 6 deletions

View File

@ -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 }));
}

View File

@ -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),