diff --git a/css/app.css b/css/app.css index 4bdfaa9..14cdd0b 100644 --- a/css/app.css +++ b/css/app.css @@ -82,6 +82,7 @@ html, body { margin: 0; min-height: 1.4em; max-width: 32ch; + white-space: pre-line; color: var(--fg); opacity: 0.75; font-size: clamp(0.95rem, 4vw, 1.1rem); diff --git a/js/audio.js b/js/audio.js index 1b36fb6..1086c17 100644 --- a/js/audio.js +++ b/js/audio.js @@ -45,7 +45,14 @@ export function unlockAudio() { const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; } - if (ctx.state !== 'running') ctx.resume().catch(() => {}); + if (ctx.state !== 'running') { + ctx.resume().catch(() => {}); + // Older iOS Safari only unlocks once a sound starts inside the gesture. + const src = ctx.createBufferSource(); + src.buffer = ctx.createBuffer(1, 1, ctx.sampleRate); + src.connect(ctx.destination); + src.start(); + } } catch { /* audio unavailable */ } @@ -56,7 +63,10 @@ export function installAudioUnlock() { for (const t of ['pointerdown', 'pointerup', 'touchend', 'click', 'keydown']) window.addEventListener(t, unlockAudio, opts); } -const live = () => ctx && ctx.state === 'running' && !muted; +/** False until the browser has let us start audio (needs a tap, not just a touch down). */ +export const audioUnlocked = () => !!ctx && ctx.state === 'running'; + +const live = () => audioUnlocked() && !muted; function tone({ type = 'sine', freq, freqEnd, at = 0, dur, gain = 0.2, attack = 0.005, filter }) { const t0 = ctx.currentTime + at; diff --git a/js/i18n.js b/js/i18n.js index e594521..284bdcf 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -24,6 +24,7 @@ export const translations = { lastStanding: 'Last one standing!', tapRestart: 'Tap to play again', desktopHint: 'On a computer? Add ?debug=1 to the address to test with a mouse.', + tapForSound: '🔈 Tap the screen once to turn on sound', team: 'Team {name}', modeLabel: 'Mode', @@ -90,6 +91,7 @@ export const translations = { lastStanding: 'Laatste overlevende!', tapRestart: 'Tik om opnieuw te spelen', desktopHint: 'Op een computer? Zet ?debug=1 achter het adres om met de muis te testen.', + tapForSound: '🔈 Tik één keer op het scherm voor geluid', team: 'Team {name}', modeLabel: 'Modus', diff --git a/js/input.js b/js/input.js index d3e4bfe..2923801 100644 --- a/js/input.js +++ b/js/input.js @@ -1,5 +1,7 @@ // Pointer input. Fingers are tracked by pointerId; pointercancel counts as // a lift (Android cancels touches it thinks are palms or system gestures). +// Browsers may recycle pointerIds, while the game keeps lifted fingers on +// screen after a round, so every touch gets its own never-reused finger id. // // Debug mode (?debug=1): mouse clicks place persistent fake fingers. // Click a fake finger again to remove it; drag it to move it. @@ -8,7 +10,8 @@ const DRAG_THRESHOLD = 6; export function createInput(canvas, { debug, onDown, onMove, onUp }) { - const active = new Set(); // real pointer ids currently down + const active = new Map(); // pointerId -> finger id, for touches currently down + let touchSeq = 0; const fakes = new Map(); // fake id -> {x, y} let fakeSeq = 0; let mouse = null; // {pointerId, fakeId, startX, startY, moved, created} @@ -17,8 +20,10 @@ export function createInput(canvas, { debug, onDown, onMove, onUp }) { const hitRadius = () => Math.max(40, Math.min(64, Math.min(innerWidth, innerHeight) * 0.115)); function release(pointerId) { - if (!active.delete(pointerId)) return; - onUp(String(pointerId), now()); + const id = active.get(pointerId); + if (id == null) return; + active.delete(pointerId); + onUp(id, now()); } canvas.addEventListener('pointerdown', (e) => { @@ -32,14 +37,16 @@ export function createInput(canvas, { debug, onDown, onMove, onUp }) { } catch { /* not fatal */ } - active.add(e.pointerId); - onDown(String(e.pointerId), e.clientX, e.clientY, now()); + release(e.pointerId); // a recycled id whose lift we never saw + const id = `t${++touchSeq}`; + active.set(e.pointerId, id); + onDown(id, e.clientX, e.clientY, now()); }); canvas.addEventListener('pointermove', (e) => { if (mouse && e.pointerId === mouse.pointerId) return debugMove(e); - if (!active.has(e.pointerId)) return; - onMove(String(e.pointerId), e.clientX, e.clientY); + const id = active.get(e.pointerId); + if (id != null) onMove(id, e.clientX, e.clientY); }); const up = (e) => { diff --git a/js/renderer.js b/js/renderer.js index 5dde596..41d356b 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -75,7 +75,7 @@ export class Renderer { data.style === 'teams' ? game.players.map((id) => game.rings.get(id)).filter(Boolean).map((ring) => ({ ring, color: this.theme.team[game.plan.teams.get(ring.id)] })) : data.winners.map((id) => game.rings.get(id)).filter(Boolean).map((ring) => ({ ring, color: this.colorOf(ring) })); - this.reveal = createReveal(data.style, { targets, w: this.w, h: this.h, R, start: now, palette: this.theme.colors }); + this.reveal = createReveal(data.style, { targets, view: this, start: now, palette: this.theme.colors }); break; } case 'phase': diff --git a/js/reveals.js b/js/reveals.js index fa4a4c5..aed13be 100644 --- a/js/reveals.js +++ b/js/reveals.js @@ -16,18 +16,20 @@ function farthestCorner(x, y, w, h) { /** * @param {string} style flood | confetti | spotlight | shockwave | calm | teams - * @param {{targets:{ring:{x:number,y:number}, color:string}[], w:number, h:number, R:number, start:number, palette:string[]}} o + * @param {{targets:{ring:{x:number,y:number}, color:string}[], view:{w:number,h:number,R:number}, start:number, palette:string[]}} o + * `view` is read every frame so the effect follows resizes (e.g. entering fullscreen). */ export function createReveal(style, o) { const make = { flood, confetti, spotlight, shockwave, calm, teams }[style] || calm; return { winnerWhite: false, drawBack() {}, drawFront() {}, shake: null, ...make(o) }; } -function flood({ targets, w, h, start }) { +function flood({ targets, view, start }) { const single = targets.length === 1; return { winnerWhite: true, drawBack(g, now) { + const { w, h, R } = view; const k = easeInOutCubic(clamp01((now - start) / 850)); g.globalAlpha = single ? 1 : 0.8; for (const { ring, color } of targets) { @@ -51,7 +53,7 @@ function flood({ targets, w, h, start }) { }; } -function confetti({ targets, w, h, R, start, palette }) { +function confetti({ targets, view, start, palette }) { const parts = []; const per = Math.round(170 / targets.length); for (const { ring, color } of targets) { @@ -82,6 +84,7 @@ function confetti({ targets, w, h, R, start, palette }) { const grav = 1100; return { drawBack(g, now) { + const { w, h, R } = view; const k = clamp01((now - start) / 700); for (const { ring, color } of targets) { const grad = g.createRadialGradient(ring.x, ring.y, R, ring.x, ring.y, Math.max(w, h)); @@ -92,6 +95,7 @@ function confetti({ targets, w, h, R, start, palette }) { } }, drawFront(g, now) { + const { w, h, R } = view; const T = (now - start) / 1000; for (const p of parts) { const t = T - p.delay; @@ -118,16 +122,15 @@ function confetti({ targets, w, h, R, start, palette }) { }; } -function spotlight({ targets, w, h, R, start }) { +function spotlight({ targets, view, start }) { // Darkness is drawn at half resolution: it's all soft gradients anyway. const scale = 0.5; const off = document.createElement('canvas'); - off.width = Math.ceil(w * scale); - off.height = Math.ceil(h * scale); const o = off.getContext('2d'); - const spots = targets.map(() => ({ sx: Math.random() * w, sy: Math.random() * h * 0.6, ph: Math.random() * 6 })); + const spots = targets.map(() => ({ sx: Math.random() * view.w, sy: Math.random() * view.h * 0.6, ph: Math.random() * 6 })); return { drawBack(g, now) { + const { w, h, R } = view; const k = clamp01((now - start - 900) / 500); if (!k) return; for (const { ring, color } of targets) { @@ -139,7 +142,14 @@ function spotlight({ targets, w, h, R, start }) { } }, drawFront(g, now) { + const { w, h, R } = view; const t = (now - start) / 1000; + const ow = Math.ceil(w * scale); + const oh = Math.ceil(h * scale); + if (off.width !== ow || off.height !== oh) { + off.width = ow; + off.height = oh; + } o.setTransform(scale, 0, 0, scale, 0, 0); o.globalCompositeOperation = 'source-over'; o.clearRect(0, 0, w, h); @@ -164,7 +174,7 @@ function spotlight({ targets, w, h, R, start }) { }; } -function shockwave({ targets, w, h, R, start }) { +function shockwave({ targets, view, start }) { const delays = [0, 0.14, 0.3]; return { shake(now) { @@ -174,6 +184,7 @@ function shockwave({ targets, w, h, R, start }) { return [Math.sin(now * 0.09) * amp, Math.cos(now * 0.113) * amp]; }, drawBack(g, now) { + const { w, h, R } = view; const t = (now - start) / 1000; for (const { ring, color } of targets) { g.fillStyle = hexToRgba(color, 0.25 * clamp01((t - 0.2) / 0.6)); @@ -197,6 +208,7 @@ function shockwave({ targets, w, h, R, start }) { } }, drawFront(g, now) { + const { w, h, R } = view; const t = (now - start) / 1000; if (t < 0.15) { g.fillStyle = `rgba(255,255,255,${0.55 * (1 - t / 0.15)})`; @@ -207,9 +219,10 @@ function shockwave({ targets, w, h, R, start }) { } /** prefers-reduced-motion: a slow, gentle colour fade. */ -function calm({ targets, w, h, R, start }) { +function calm({ targets, view, start }) { return { drawBack(g, now) { + const { w, h, R } = view; const k = easeInOutCubic(clamp01((now - start) / 1200)); for (const { ring, color } of targets) { const grad = g.createRadialGradient(ring.x, ring.y, R, ring.x, ring.y, Math.max(w, h) * 0.9); @@ -222,9 +235,10 @@ function calm({ targets, w, h, R, start }) { }; } -function teams({ targets, R, start }) { +function teams({ targets, view, start }) { return { drawBack(g, now) { + const { w, h, R } = view; const k = easeOutCubic(clamp01((now - start) / 700)); for (const { ring, color } of targets) { const r = R * (1.5 + 2.5 * k); diff --git a/js/ui.js b/js/ui.js index 69d9100..88b39ab 100644 --- a/js/ui.js +++ b/js/ui.js @@ -4,7 +4,7 @@ import { t, setLang, getLang, detectLang, LANGS } from './i18n.js'; import { MODES } from './modes.js'; import { TIMING } from './game.js'; import { pick } from './random.js'; -import { canVibrate } from './audio.js'; +import { canVibrate, audioUnlocked } from './audio.js'; import { fullscreenSupported, isFullscreen, toggleFullscreen, onFullscreenChange } from './device.js'; const $ = (sel) => document.querySelector(sel); @@ -207,7 +207,13 @@ export function createUI({ settings, debug, showDesktopHint }) { const count = game.activeRings().length; switch (game.phase) { case 'idle': - if (count === 0) return [t('hint'), showDesktopHint ? t('desktopHint') : modeSummary(s), 'hint']; + if (count === 0) { + if (showDesktopHint) return [t('hint'), t('desktopHint'), 'hint']; + // Browsers only allow sound after a tap (a finger *lift*), so ask for one. + const soundHint = !s.muted && !audioUnlocked() ? ` +${t('tapForSound')}` : ''; + return [t('hint'), modeSummary(s) + soundHint, 'hint']; + } return [t('needMore'), modeSummary(s), 'hint']; case 'gathering': return [t('holdStill'), '', 'soft']; diff --git a/sw.js b/sw.js index a2d4bea..fba856c 100644 --- a/sw.js +++ b/sw.js @@ -2,7 +2,7 @@ // cache fallback so updates show up immediately when online and the app // still works fully offline. Bump VERSION when the file list changes. -const VERSION = 'picker-v1'; +const VERSION = 'picker-v2'; const SHELL = [ './', './index.html', diff --git a/tests/logic.html b/tests/logic.html index f59d2c9..15fec31 100644 --- a/tests/logic.html +++ b/tests/logic.html @@ -14,6 +14,8 @@ import { Game, TIMING } from '../js/game.js'; import { planRound, makeHighlightSchedule } from '../js/modes.js'; import { randomInt } from '../js/random.js'; +import { createInput } from '../js/input.js'; +import { createReveal } from '../js/reveals.js'; const results = []; function test(name, fn) { @@ -270,6 +272,47 @@ test('unknown pointer ids and double lifts are harmless', () => { assert(h.game.rings.size === 0, 'pruned'); }); +test('recycled pointerIds still restart after a round (input -> game)', () => { + const h = setup(); + const canvas = document.createElement('canvas'); + document.body.append(canvas); + createInput(canvas, { + debug: false, + onDown: (id, x, y) => h.game.fingerDown(id, x, y, h.now), + onMove: (id, x, y) => h.game.fingerMove(id, x, y), + onUp: (id) => h.game.fingerUp(id, h.now), + }); + const fire = (type, pointerId, x = 50, y = 50) => + canvas.dispatchEvent(new PointerEvent(type, { pointerId, pointerType: 'touch', clientX: x, clientY: y, bubbles: true, cancelable: true })); + fire('pointerdown', 1, 50, 50); + fire('pointerdown', 2, 250, 250); + h.until('result'); + fire('pointerup', 1); + fire('pointerup', 2); + h.run(TIMING.restartGraceMs + 100); + assert(h.game.canRestart(h.now), 'can restart'); + fire('pointerdown', 1); // the browser reuses pointerId 1 for the tap + assert(h.game.phase === 'idle' && h.count('restart') === 1, 'tap with a recycled pointerId restarts, phase ' + h.game.phase); + fire('pointerup', 1); + h.run(500); + assert(h.game.rings.size === 0, 'tap ring removed'); + canvas.remove(); +}); + +test('reveal follows a resize (entering fullscreen) without leaving an unpainted strip', () => { + const c = document.createElement('canvas'); + c.width = 100; + c.height = 200; + const g = c.getContext('2d'); + const view = { w: 100, h: 100, R: 10 }; + const ring = { x: 50, y: 50 }; + const fx = createReveal('flood', { targets: [{ ring, color: '#ff0000' }], view, start: 0, palette: ['#ff0000'] }); + view.h = 200; // screen grew after the reveal started + fx.drawBack(g, 5000); + const px = g.getImageData(50, 190, 1, 1).data; + assert(px[0] > 100 && px[3] === 255, 'bottom painted, got ' + Array.from(px)); +}); + // Service worker: parses, and every precached file exists. try { const src = await (await fetch('../sw.js')).text();