From f957a93c3637d72c9674a1a52ab7a76174f97549 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 10 Sep 2026 14:45:01 -0400 Subject: [PATCH] =?UTF-8?q?Pinball:=20Pok=C3=A9mon=20theming,=20real=20cat?= =?UTF-8?q?ches,=20sound;=20fix=20the=20plunger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Theming: - Bumpers are Poké Balls now; a lit one "opens" white with a halo. - A big faint Poké Ball watermark on the playfield. - Catching (light all 5) pops the wild Pokémon's official artwork with "Gotcha! was caught!", logs it to a persistent "Caught" strip of sprites below the table (pdx.pinball.caught), and plays a 4-note jingle. - WebAudio blips for bumpers / posts / drain / launch (no assets), with a 🔊 mute toggle in the HUD (pdx.pinball.sound). Plunger: - Was drawn above the ball and travelled up while charging. Now it sits below the shooter-lane floor and is pulled DOWN to charge. - Stuck plunger fixed: a global window `pointerup` releases it, and `blur` / `visibilitychange` panic-release both the plunger and any held flippers. Launch button uses onpointerdown + release on up/leave/cancel. - Ignore auto-repeat keydown so a held key doesn't re-trigger. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- src/lib/pinball.js | 160 ++++++++++++++++++++++---------------- src/store/pinball.js | 16 +++- src/styles/layout.css | 74 ++++++++++++++++++ src/views/PinballView.js | 164 +++++++++++++++++++++++++++++++-------- 4 files changed, 309 insertions(+), 105 deletions(-) diff --git a/src/lib/pinball.js b/src/lib/pinball.js index 7715925..cc4a987 100644 --- a/src/lib/pinball.js +++ b/src/lib/pinball.js @@ -2,7 +2,8 @@ * A tiny single-table pinball engine. Canvas 2D, fixed-timestep physics * with sub-stepping so the ball doesn't tunnel thin walls. * - * createPinball(canvas, { onScore, onBalls, onGameOver, onCatch, pickName }) + * createPinball(canvas, { onScore, onBalls, onGameOver, onCatch, onBump, + * onDrain, pickMon }) * → { start, stop, newGame, setFlipper('L'|'R', down), setPlunge(down) } * * All geometry is in logical units (a W×H table); the renderer scales to @@ -118,7 +119,7 @@ export function createPinball(canvas, cbs = {}) { charge: 0, // 0..1, for the plunger visual only lit: TABLE.bumpers.map(() => false), catchT: 0, - catchName: '', + catchMon: null, scale: 1, }; @@ -251,6 +252,7 @@ export function createPinball(canvas, cbs = {}) { b.x = p.x + nx * (PHYS.ballR + p.r + 0.5); b.y = p.y + ny * (PHYS.ballR + p.r + 0.5); addScore(25); + cbs.onBump?.('post'); } } for (let i = 0; i < TABLE.bumpers.length; i++) { @@ -276,14 +278,16 @@ export function createPinball(canvas, cbs = {}) { b.x = bp.x + nx * (PHYS.ballR + bp.r + 0.5); b.y = bp.y + ny * (PHYS.ballR + bp.r + 0.5); addScore(100); + cbs.onBump?.('bumper'); if (!state.lit[i]) { state.lit[i] = true; if (state.lit.every(Boolean)) { state.lit = TABLE.bumpers.map(() => false); addScore(5000); - state.catchName = (cbs.pickName?.() || 'Something').toUpperCase(); - state.catchT = 1.6; - cbs.onCatch?.(state.catchName); + const mon = cbs.pickMon?.() || { id: 0, name: 'Something' }; + state.catchMon = mon; + state.catchT = 2.2; + cbs.onCatch?.(mon); } } } @@ -344,6 +348,7 @@ export function createPinball(canvas, cbs = {}) { function loseBall() { state.balls -= 1; cbs.onBalls?.(state.balls); + cbs.onDrain?.(); if (state.balls <= 0) { state.over = true; state.ball = null; @@ -374,6 +379,38 @@ export function createPinball(canvas, cbs = {}) { ctx.stroke(); } + /** A Poké Ball centred at (cx,cy), radius r, rotated by ang. */ + function pokeball(cx, cy, r, ang, topColor) { + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(ang); + ctx.beginPath(); + ctx.arc(0, 0, r, 0, Math.PI * 2); + ctx.clip(); + ctx.fillStyle = '#f4f4f6'; + ctx.fillRect(-r, -r, r * 2, r * 2); + ctx.fillStyle = topColor; + ctx.fillRect(-r, -r, r * 2, r); + const band = Math.max(2.4, r * 0.3); + ctx.fillStyle = '#17181d'; + ctx.fillRect(-r, -band / 2, r * 2, band); + const br = Math.max(2, r * 0.36); + ctx.beginPath(); + ctx.arc(0, 0, br, 0, Math.PI * 2); + ctx.fillStyle = '#17181d'; + ctx.fill(); + ctx.beginPath(); + ctx.arc(0, 0, br * 0.55, 0, Math.PI * 2); + ctx.fillStyle = '#f4f4f6'; + ctx.fill(); + ctx.restore(); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.lineWidth = Math.max(1, r * 0.14); + ctx.strokeStyle = 'rgba(0,0,0,0.45)'; + ctx.stroke(); + } + const accentRGB = (() => { const h = /^#([0-9a-f]{6})$/i.test(accent) ? accent : '#b3161a'; return [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); @@ -406,6 +443,23 @@ export function createPinball(canvas, cbs = {}) { for (let x = 40; x < W; x += 40) line(x, 0, x, H); for (let y = 40; y < H; y += 40) line(0, y, W, y); + // big Poké Ball watermark on the playfield + ctx.save(); + ctx.globalAlpha = 0.05; + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(W / 2, 262, 118, 0, Math.PI * 2); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(W / 2 - 118, 262); + ctx.lineTo(W / 2 + 118, 262); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(W / 2, 262, 34, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + ctx.lineCap = 'round'; ctx.lineJoin = 'round'; @@ -450,28 +504,22 @@ export function createPinball(canvas, cbs = {}) { ctx.stroke(); } - // bumpers — concentric, with a lit glow + // bumpers — Poké Balls; a lit one "opens" (white) with a halo for (let i = 0; i < TABLE.bumpers.length; i++) { const bp = TABLE.bumpers[i]; const lit = state.lit[i]; if (lit) { + ctx.save(); ctx.shadowColor = accent; - ctx.shadowBlur = 22; + ctx.shadowBlur = 24; } + pokeball(bp.x, bp.y, bp.r, 0, lit ? '#ffffff' : accent); + if (lit) ctx.restore(); ctx.beginPath(); - ctx.arc(bp.x, bp.y, bp.r, 0, Math.PI * 2); - ctx.fillStyle = lit ? accent : '#3a3e4b'; - ctx.fill(); - ctx.shadowBlur = 0; - ctx.lineWidth = 3; - ctx.strokeStyle = lit ? '#fff' : accent; + ctx.arc(bp.x, bp.y, bp.r + 2, 0, Math.PI * 2); + ctx.lineWidth = 2; + ctx.strokeStyle = lit ? '#fff' : accentA(0.5); ctx.stroke(); - ctx.beginPath(); - ctx.arc(bp.x, bp.y, bp.r * 0.5, 0, Math.PI * 2); - ctx.fillStyle = lit ? '#fff' : mix(accent, [255, 255, 255], 0.15); - ctx.globalAlpha = lit ? 0.9 : 0.5; - ctx.fill(); - ctx.globalAlpha = 1; } // flippers @@ -489,68 +537,42 @@ export function createPinball(canvas, cbs = {}) { ctx.fill(); } - // plunger - const py = 470 - state.charge * 18; - ctx.strokeStyle = 'rgba(255,255,255,0.35)'; - ctx.lineWidth = 3; - line(297, py + 22, 297, 486); - ctx.fillStyle = 'rgba(255,255,255,0.75)'; - ctx.beginPath(); - ctx.roundRect?.(289, py, 16, 22, 4); - ctx.fill(); - if (!ctx.roundRect) ctx.fillRect(289, py, 16, 22); + // plunger — sits below the shooter-lane floor and is pulled DOWN to charge + { + const top = 480 + state.charge * 22; // more charge → further down + ctx.strokeStyle = 'rgba(255,255,255,0.28)'; + ctx.lineWidth = 4; + line(297, 474, 297, top); // shaft + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(288, top, 18, 12, 4); + else ctx.rect(288, top, 18, 12); + ctx.fill(); + } - // ball — a spinning Poké Ball + // ball — a spinning Poké Ball with a drop shadow const b = state.ball; if (b) { const r = PHYS.ballR; - ctx.save(); - ctx.translate(b.x, b.y + 1); ctx.beginPath(); - ctx.arc(0, 1.5, r, 0, Math.PI * 2); - ctx.fillStyle = 'rgba(0,0,0,0.3)'; + ctx.arc(b.x, b.y + 2.5, r, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(0,0,0,0.28)'; ctx.fill(); - ctx.restore(); - - ctx.save(); - ctx.translate(b.x, b.y); - ctx.rotate(b.spin); - ctx.beginPath(); - ctx.arc(0, 0, r, 0, Math.PI * 2); - ctx.clip(); - ctx.fillStyle = '#f4f4f6'; - ctx.fillRect(-r, -r, r * 2, r * 2); - ctx.fillStyle = accent; - ctx.fillRect(-r, -r, r * 2, r); - ctx.fillStyle = '#1b1c22'; - ctx.fillRect(-r, -1.6, r * 2, 3.2); - ctx.beginPath(); - ctx.arc(0, 0, 2.6, 0, Math.PI * 2); - ctx.fillStyle = '#1b1c22'; - ctx.fill(); - ctx.beginPath(); - ctx.arc(0, 0, 1.5, 0, Math.PI * 2); - ctx.fillStyle = '#f4f4f6'; - ctx.fill(); - ctx.restore(); - ctx.beginPath(); - ctx.arc(b.x, b.y, r, 0, Math.PI * 2); - ctx.lineWidth = 1; - ctx.strokeStyle = 'rgba(0,0,0,0.4)'; - ctx.stroke(); + pokeball(b.x, b.y, r, b.spin, accent); } if (state.catchT > 0) { - ctx.globalAlpha = Math.min(1, state.catchT); - ctx.fillStyle = 'rgba(0,0,0,0.5)'; - ctx.fillRect(0, H / 2 - 66, W, 92); + const name = (state.catchMon?.name || 'a Pokémon').toUpperCase(); + ctx.globalAlpha = Math.min(1, state.catchT / 1.4); + ctx.fillStyle = 'rgba(0,0,0,0.6)'; + ctx.fillRect(0, H / 2 - 60, W, 88); ctx.fillStyle = '#fff'; - ctx.font = 'bold 22px system-ui, sans-serif'; + ctx.font = 'bold 20px system-ui, sans-serif'; ctx.textAlign = 'center'; - ctx.fillText(`Caught ${state.catchName}!`, W / 2, H / 2 - 30); - ctx.font = 'bold 15px system-ui, sans-serif'; + ctx.fillText('Gotcha!', W / 2, H / 2 - 30); + ctx.font = 'bold 17px system-ui, sans-serif'; ctx.fillStyle = accent; - ctx.fillText('+5000', W / 2, H / 2 - 6); + ctx.fillText(`${name} was caught!`, W / 2, H / 2 - 6); ctx.globalAlpha = 1; ctx.textAlign = 'left'; } diff --git a/src/store/pinball.js b/src/store/pinball.js index 5619f0e..5cf4f36 100644 --- a/src/store/pinball.js +++ b/src/store/pinball.js @@ -1,8 +1,20 @@ import { createStore } from './createStore.js'; -/** High score for the hidden pinball table (#/pinball). */ -export const pinball = createStore('pdx.pinball', { best: 0 }); +/** High score + the catch log for the hidden pinball table (#/pinball). */ +export const pinball = createStore('pdx.pinball', { best: 0, caught: [], sound: true }); export function recordPinball(score) { if (score > pinball.get().best) pinball.set((s) => ({ ...s, best: score })); } + +/** Log a Pokémon "caught" on the table (most recent first, de-duped, capped). */ +export function recordCatch(mon) { + pinball.set((s) => { + const caught = [mon, ...s.caught.filter((m) => m.id !== mon.id)].slice(0, 24); + return { ...s, caught }; + }); +} + +export function togglePinballSound() { + pinball.set((s) => ({ ...s, sound: !s.sound })); +} diff --git a/src/styles/layout.css b/src/styles/layout.css index c94fced..26e655b 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -3964,3 +3964,77 @@ color: var(--text-dim); text-align: center; } +.pin__sound { + margin-left: 4px; + border: none; + background: none; + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 2px; +} + +/* catch pop-up over the table */ +.pin__catch[hidden] { + display: none; +} +.pin__catch { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + border-radius: var(--radius-lg); + background: radial-gradient(60% 50% at 50% 45%, rgba(0, 0, 0, 0.82), rgba(0, 0, 0, 0.55)); + animation: pinCatch 0.3s ease; +} +@keyframes pinCatch { + from { + opacity: 0; + transform: scale(0.9); + } +} +.pin__catch img { + filter: drop-shadow(0 6px 16px rgba(0, 0, 0, 0.5)); +} +.pin__catch-title { + margin: 6px 0 0; + font-size: 1.4rem; + font-weight: 800; +} +.pin__catch-name { + margin: 0; + font-weight: 700; + color: var(--accent); + text-transform: capitalize; +} + +/* "caught this run" strip */ +.pin__caught { + min-height: 38px; + margin-top: 10px; +} +.pin__caught-row { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} +.pin__caught-label { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); + margin-right: 4px; +} +.pin__caught-row img { + background: var(--surface-2); + border-radius: 8px; +} +.pin__caught-empty { + font-size: 0.78rem; + color: var(--text-dim); +} diff --git a/src/views/PinballView.js b/src/views/PinballView.js index 6fd719c..14004ba 100644 --- a/src/views/PinballView.js +++ b/src/views/PinballView.js @@ -1,39 +1,119 @@ import { el, onTeardown } from '../lib/dom.js'; import { loadSnapshot } from '../data/snapshot.js'; import { createPinball } from '../lib/pinball.js'; -import { pinball, recordPinball } from '../store/pinball.js'; +import { pinball, recordPinball, recordCatch, togglePinballSound } from '../store/pinball.js'; +import { Sprite } from '../components/Sprite.js'; const pretty = (name) => name.replace(/-/g, ' '); +// --- tiny WebAudio blips (no assets) --------------------------------- +let actx = null; +function tone(freq, dur, type = 'square', vol = 0.05, slideTo) { + if (!pinball.get().sound) return; + try { + actx = actx || new (window.AudioContext || window.webkitAudioContext)(); + if (actx.state === 'suspended') actx.resume(); + const t = actx.currentTime; + const o = actx.createOscillator(); + const g = actx.createGain(); + o.type = type; + o.frequency.setValueAtTime(freq, t); + if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, t + dur); + g.gain.setValueAtTime(vol, t); + g.gain.exponentialRampToValueAtTime(0.0001, t + dur); + o.connect(g).connect(actx.destination); + o.start(t); + o.stop(t + dur + 0.02); + } catch { + /* audio not available — no-op */ + } +} +const sfx = { + bump: () => tone(320 + Math.random() * 120, 0.06), + post: () => tone(520, 0.04, 'triangle', 0.035), + drain: () => tone(300, 0.35, 'sawtooth', 0.05, 90), + launch: () => tone(180, 0.22, 'sawtooth', 0.05, 520), + catch: () => { + [523, 659, 784, 1047].forEach((f, i) => + setTimeout(() => tone(f, 0.12, 'square', 0.05), i * 90), + ); + }, +}; + /** - * Hidden mini-game — a single pinball table. Reached from the "Who's that - * Pokémon?" screen's nav row, or the bare #/pinball hash. + * 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. * - * ← / → (or A / L) work the flippers; hold Space (or the Launch button) to - * pull the plunger. On touch, tap the left or right half of the table. + * ← / → (or A / L / Z) work the flippers; hold Space or the Launch button + * to pull the plunger. On touch, tap the table's left or right half. */ export async function PinballView() { const view = el('section', { class: 'view pin' }); const snap = await loadSnapshot(); const scoreEl = el('span', { class: 'pin__score' }, '0'); - const bestEl = el('span', { class: 'pin__best' }, `Best ${pinball.get().best}`); + const bestEl = el('span', { class: 'pin__best' }, `Best ${pinball.get().best.toLocaleString()}`); const ballsEl = el('span', { class: 'pin__balls' }); const overlay = el('div', { class: 'pin__over', hidden: true }); - + const catchPop = el('div', { class: 'pin__catch', hidden: true }); + const caughtStrip = el('div', { class: 'pin__caught' }); const canvas = el('canvas', { class: 'pin__canvas', 'aria-label': 'Pinball table' }); function renderBalls(n) { ballsEl.textContent = '●'.repeat(Math.max(0, n)) || '—'; } + function renderCaught() { + const list = pinball.get().caught; + caughtStrip.replaceChildren( + list.length + ? el( + 'div', + { class: 'pin__caught-row' }, + el('span', { class: 'pin__caught-label' }, 'Caught'), + ...list.slice(0, 14).map((m) => Sprite(m.id, { size: 30, alt: m.name })), + ) + : el('span', { class: 'pin__caught-empty' }, 'Light all 5 Poké Balls to catch one.'), + ); + } + + let catchTimer = null; + const soundBtn = el('button', { + class: 'pin__sound', + type: 'button', + title: 'Sound', + 'aria-label': 'Toggle sound', + onclick: () => { + togglePinballSound(); + soundBtn.textContent = pinball.get().sound ? '🔊' : '🔇'; + }, + }); + soundBtn.textContent = pinball.get().sound ? '🔊' : '🔇'; const game = createPinball(canvas, { onScore: (s) => (scoreEl.textContent = s.toLocaleString()), onBalls: renderBalls, - pickName: () => pretty(snap.species[(Math.random() * snap.species.length) | 0].name), + onBump: (kind) => (kind === 'post' ? sfx.post() : sfx.bump()), + onDrain: () => sfx.drain(), + pickMon: () => { + const s = snap.species[(Math.random() * snap.species.length) | 0]; + return { id: s.id, name: pretty(s.name) }; + }, + onCatch: (mon) => { + sfx.catch(); + recordCatch(mon); + renderCaught(); + catchPop.replaceChildren( + Sprite(mon.id, { style: 'official', size: 120, alt: mon.name }), + el('p', { class: 'pin__catch-title' }, 'Gotcha!'), + el('p', { class: 'pin__catch-name' }, `${mon.name} was caught!`), + ); + catchPop.hidden = false; + clearTimeout(catchTimer); + catchTimer = setTimeout(() => (catchPop.hidden = true), 2200); + }, onGameOver: (score) => { recordPinball(score); - bestEl.textContent = `Best ${pinball.get().best}`; + bestEl.textContent = `Best ${pinball.get().best.toLocaleString()}`; overlay.replaceChildren( el('p', { class: 'pin__over-score' }, `${score.toLocaleString()} points`), el( @@ -54,21 +134,16 @@ export async function PinballView() { }); // --- input -------------------------------------------------------- - const KEYS = { - ArrowLeft: 'L', - ArrowRight: 'R', - a: 'L', - d: 'R', - z: 'L', - l: 'R', - }; + const KEYS = { ArrowLeft: 'L', ArrowRight: 'R', a: 'L', d: 'R', z: 'L', l: 'R' }; function onKey(down) { return (e) => { + if (e.repeat) return; const f = KEYS[e.key]; if (f) { game.setFlipper(f, down); e.preventDefault(); } else if (e.key === ' ' || e.key === 'ArrowDown') { + if (down && !e.repeat) sfx.launch(); game.setPlunge(down); e.preventDefault(); } @@ -79,8 +154,8 @@ export async function PinballView() { window.addEventListener('keydown', kd); window.addEventListener('keyup', ku); - // Per-pointer flipper tracking so two thumbs work and nothing sticks if a - // pointerup is missed. `resync` recomputes each flipper from live pointers. + // Per-pointer flipper tracking: two thumbs work, and a missed pointerup + // can never leave a flipper stuck on. const pointers = new Map(); // pointerId -> 'L' | 'R' function resync() { let L = false; @@ -101,20 +176,34 @@ export async function PinballView() { }; for (const ev of ['pointerup', 'pointercancel', 'pointerleave', 'lostpointercapture']) canvas.addEventListener(ev, drop); - // Clear everything if focus/visibility is lost mid-press. - const clearAll = () => { + + const launchBtn = el('button', { + class: 'button pin__launch', + type: 'button', + onpointerdown: (e) => { + e.preventDefault(); + sfx.launch(); + game.setPlunge(true); + }, + }); + launchBtn.textContent = 'Launch (hold)'; + const releasePlunge = () => game.setPlunge(false); + for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) + launchBtn.addEventListener(ev, releasePlunge); + + // Global safety net: any release / focus loss drops the plunger and flippers. + const panic = () => { + game.setPlunge(false); if (pointers.size) { pointers.clear(); resync(); } }; - window.addEventListener('blur', clearAll); - document.addEventListener('visibilitychange', clearAll); - - const launchBtn = el('button', { class: 'button pin__launch', type: 'button' }, 'Launch (hold)'); - for (const ev of ['pointerdown']) launchBtn.addEventListener(ev, () => game.setPlunge(true)); - for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) - launchBtn.addEventListener(ev, () => game.setPlunge(false)); + const onVis = () => document.hidden && panic(); + window.addEventListener('pointerup', releasePlunge); + window.addEventListener('pointercancel', panic); + window.addEventListener('blur', panic); + document.addEventListener('visibilitychange', onVis); view.append( el( @@ -125,26 +214,33 @@ 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), - el('div', { class: 'pin__stage' }, canvas, overlay), + el('div', { class: 'pin__hud' }, el('span', {}, 'Score ', scoreEl), ballsEl, bestEl, soundBtn), + el('div', { class: 'pin__stage' }, canvas, overlay, catchPop), + caughtStrip, el('div', { class: 'pin__controls' }, launchBtn), el( 'p', { class: 'pin__hint' }, - '← / → flippers · hold Space to launch · tap the table’s left / right half on touch', + '← / → flippers · hold Space to launch · on touch, tap the table’s left / right half', ), ); renderBalls(3); - // Give the canvas a layout box before the engine measures it. + renderCaught(); requestAnimationFrame(() => game.start()); + const offStore = pinball.subscribe(() => renderCaught()); + onTeardown(view, () => { game.stop(); + clearTimeout(catchTimer); + offStore(); window.removeEventListener('keydown', kd); window.removeEventListener('keyup', ku); - window.removeEventListener('blur', clearAll); - document.removeEventListener('visibilitychange', clearAll); + window.removeEventListener('pointerup', releasePlunge); + window.removeEventListener('pointercancel', panic); + window.removeEventListener('blur', panic); + document.removeEventListener('visibilitychange', onVis); }); return view; }