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:
parent
d9ec697af8
commit
5861dd6b97
@ -1,7 +1,12 @@
|
|||||||
import { createStore } from './createStore.js';
|
import { createStore } from './createStore.js';
|
||||||
|
|
||||||
/** High score + the catch log for the hidden pinball table (#/pinball). */
|
/** 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) {
|
export function recordPinball(score) {
|
||||||
if (score > pinball.get().best) pinball.set((s) => ({ ...s, best: score }));
|
if (score > pinball.get().best) pinball.set((s) => ({ ...s, best: score }));
|
||||||
@ -18,3 +23,7 @@ export function recordCatch(mon) {
|
|||||||
export function togglePinballSound() {
|
export function togglePinballSound() {
|
||||||
pinball.set((s) => ({ ...s, sound: !s.sound }));
|
pinball.set((s) => ({ ...s, sound: !s.sound }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function togglePinballHaptics() {
|
||||||
|
pinball.set((s) => ({ ...s, haptics: !s.haptics }));
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
import { el, onTeardown } from '../lib/dom.js';
|
import { el, onTeardown } from '../lib/dom.js';
|
||||||
import { loadSnapshot } from '../data/snapshot.js';
|
import { loadSnapshot } from '../data/snapshot.js';
|
||||||
import { createPinball } from '../lib/pinball.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';
|
import { Sprite } from '../components/Sprite.js';
|
||||||
|
|
||||||
const pretty = (name) => name.replace(/-/g, ' ');
|
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
|
* 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.
|
* 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 ? '🔊' : '🔇';
|
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, {
|
const game = createPinball(canvas, {
|
||||||
onScore: (s) => (scoreEl.textContent = s.toLocaleString()),
|
onScore: (s) => (scoreEl.textContent = s.toLocaleString()),
|
||||||
onBalls: renderBalls,
|
onBalls: renderBalls,
|
||||||
onBump: (kind) => (kind === 'post' ? sfx.post() : sfx.bump()),
|
onBump: (kind) => {
|
||||||
onDrain: () => sfx.drain(),
|
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)),
|
onCharge: (c) => launchBtn.style.setProperty('--charge', c.toFixed(3)),
|
||||||
pickMon: () => {
|
pickMon: () => {
|
||||||
const s = snap.species[(Math.random() * snap.species.length) | 0];
|
const s = snap.species[(Math.random() * snap.species.length) | 0];
|
||||||
@ -102,6 +158,7 @@ export async function PinballView() {
|
|||||||
},
|
},
|
||||||
onCatch: (mon) => {
|
onCatch: (mon) => {
|
||||||
sfx.catch();
|
sfx.catch();
|
||||||
|
haptics.catch();
|
||||||
recordCatch(mon);
|
recordCatch(mon);
|
||||||
renderCaught();
|
renderCaught();
|
||||||
catchPop.replaceChildren(
|
catchPop.replaceChildren(
|
||||||
@ -115,6 +172,7 @@ export async function PinballView() {
|
|||||||
},
|
},
|
||||||
onGameOver: (score) => {
|
onGameOver: (score) => {
|
||||||
recordPinball(score);
|
recordPinball(score);
|
||||||
|
haptics.over();
|
||||||
bestEl.textContent = `Best ${pinball.get().best.toLocaleString()}`;
|
bestEl.textContent = `Best ${pinball.get().best.toLocaleString()}`;
|
||||||
overlay.replaceChildren(
|
overlay.replaceChildren(
|
||||||
el('p', { class: 'pin__over-score' }, `${score.toLocaleString()} points`),
|
el('p', { class: 'pin__over-score' }, `${score.toLocaleString()} points`),
|
||||||
@ -145,7 +203,10 @@ export async function PinballView() {
|
|||||||
game.setFlipper(f, down);
|
game.setFlipper(f, down);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.key === ' ' || e.key === 'ArrowDown') {
|
} else if (e.key === ' ' || e.key === 'ArrowDown') {
|
||||||
if (down && !e.repeat) sfx.launch();
|
if (down && !e.repeat) {
|
||||||
|
sfx.launch();
|
||||||
|
haptics.launch();
|
||||||
|
}
|
||||||
game.setPlunge(down);
|
game.setPlunge(down);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
@ -185,6 +246,7 @@ export async function PinballView() {
|
|||||||
onpointerdown: (e) => {
|
onpointerdown: (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sfx.launch();
|
sfx.launch();
|
||||||
|
haptics.launch();
|
||||||
game.setPlunge(true);
|
game.setPlunge(true);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -219,7 +281,15 @@ export async function PinballView() {
|
|||||||
el('a', { class: 'link', href: '#/whos-that' }, "Who's that Pokémon?"),
|
el('a', { class: 'link', href: '#/whos-that' }, "Who's that Pokémon?"),
|
||||||
),
|
),
|
||||||
el('header', { class: 'view__header' }, el('h1', {}, 'Pinball')),
|
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),
|
el('div', { class: 'pin__stage' }, canvas, overlay, catchPop),
|
||||||
caughtStrip,
|
caughtStrip,
|
||||||
el('div', { class: 'pin__controls' }, launchBtn),
|
el('div', { class: 'pin__controls' }, launchBtn),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user