fixes
Some checks failed
Publish Docker image / image (push) Has been cancelled

This commit is contained in:
David 2026-10-09 13:55:15 +02:00
parent ac33e2fec6
commit 35a4dc2dbf
9 changed files with 106 additions and 23 deletions

View file

@ -82,6 +82,7 @@ html, body {
margin: 0; margin: 0;
min-height: 1.4em; min-height: 1.4em;
max-width: 32ch; max-width: 32ch;
white-space: pre-line;
color: var(--fg); color: var(--fg);
opacity: 0.75; opacity: 0.75;
font-size: clamp(0.95rem, 4vw, 1.1rem); font-size: clamp(0.95rem, 4vw, 1.1rem);

View file

@ -45,7 +45,14 @@ export function unlockAudio() {
const d = noiseBuf.getChannelData(0); const d = noiseBuf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; 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 { } catch {
/* audio unavailable */ /* audio unavailable */
} }
@ -56,7 +63,10 @@ export function installAudioUnlock() {
for (const t of ['pointerdown', 'pointerup', 'touchend', 'click', 'keydown']) window.addEventListener(t, unlockAudio, opts); 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 }) { function tone({ type = 'sine', freq, freqEnd, at = 0, dur, gain = 0.2, attack = 0.005, filter }) {
const t0 = ctx.currentTime + at; const t0 = ctx.currentTime + at;

View file

@ -24,6 +24,7 @@ export const translations = {
lastStanding: 'Last one standing!', lastStanding: 'Last one standing!',
tapRestart: 'Tap to play again', tapRestart: 'Tap to play again',
desktopHint: 'On a computer? Add ?debug=1 to the address to test with a mouse.', 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}', team: 'Team {name}',
modeLabel: 'Mode', modeLabel: 'Mode',
@ -90,6 +91,7 @@ export const translations = {
lastStanding: 'Laatste overlevende!', lastStanding: 'Laatste overlevende!',
tapRestart: 'Tik om opnieuw te spelen', tapRestart: 'Tik om opnieuw te spelen',
desktopHint: 'Op een computer? Zet ?debug=1 achter het adres om met de muis te testen.', 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}', team: 'Team {name}',
modeLabel: 'Modus', modeLabel: 'Modus',

View file

@ -1,5 +1,7 @@
// Pointer input. Fingers are tracked by pointerId; pointercancel counts as // Pointer input. Fingers are tracked by pointerId; pointercancel counts as
// a lift (Android cancels touches it thinks are palms or system gestures). // 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. // Debug mode (?debug=1): mouse clicks place persistent fake fingers.
// Click a fake finger again to remove it; drag it to move it. // Click a fake finger again to remove it; drag it to move it.
@ -8,7 +10,8 @@
const DRAG_THRESHOLD = 6; const DRAG_THRESHOLD = 6;
export function createInput(canvas, { debug, onDown, onMove, onUp }) { 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} const fakes = new Map(); // fake id -> {x, y}
let fakeSeq = 0; let fakeSeq = 0;
let mouse = null; // {pointerId, fakeId, startX, startY, moved, created} 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)); const hitRadius = () => Math.max(40, Math.min(64, Math.min(innerWidth, innerHeight) * 0.115));
function release(pointerId) { function release(pointerId) {
if (!active.delete(pointerId)) return; const id = active.get(pointerId);
onUp(String(pointerId), now()); if (id == null) return;
active.delete(pointerId);
onUp(id, now());
} }
canvas.addEventListener('pointerdown', (e) => { canvas.addEventListener('pointerdown', (e) => {
@ -32,14 +37,16 @@ export function createInput(canvas, { debug, onDown, onMove, onUp }) {
} catch { } catch {
/* not fatal */ /* not fatal */
} }
active.add(e.pointerId); release(e.pointerId); // a recycled id whose lift we never saw
onDown(String(e.pointerId), e.clientX, e.clientY, now()); const id = `t${++touchSeq}`;
active.set(e.pointerId, id);
onDown(id, e.clientX, e.clientY, now());
}); });
canvas.addEventListener('pointermove', (e) => { canvas.addEventListener('pointermove', (e) => {
if (mouse && e.pointerId === mouse.pointerId) return debugMove(e); if (mouse && e.pointerId === mouse.pointerId) return debugMove(e);
if (!active.has(e.pointerId)) return; const id = active.get(e.pointerId);
onMove(String(e.pointerId), e.clientX, e.clientY); if (id != null) onMove(id, e.clientX, e.clientY);
}); });
const up = (e) => { const up = (e) => {

View file

@ -75,7 +75,7 @@ export class Renderer {
data.style === 'teams' 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)] })) ? 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) })); : 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; break;
} }
case 'phase': case 'phase':

View file

@ -16,18 +16,20 @@ function farthestCorner(x, y, w, h) {
/** /**
* @param {string} style flood | confetti | spotlight | shockwave | calm | teams * @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) { export function createReveal(style, o) {
const make = { flood, confetti, spotlight, shockwave, calm, teams }[style] || calm; const make = { flood, confetti, spotlight, shockwave, calm, teams }[style] || calm;
return { winnerWhite: false, drawBack() {}, drawFront() {}, shake: null, ...make(o) }; 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; const single = targets.length === 1;
return { return {
winnerWhite: true, winnerWhite: true,
drawBack(g, now) { drawBack(g, now) {
const { w, h, R } = view;
const k = easeInOutCubic(clamp01((now - start) / 850)); const k = easeInOutCubic(clamp01((now - start) / 850));
g.globalAlpha = single ? 1 : 0.8; g.globalAlpha = single ? 1 : 0.8;
for (const { ring, color } of targets) { 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 parts = [];
const per = Math.round(170 / targets.length); const per = Math.round(170 / targets.length);
for (const { ring, color } of targets) { for (const { ring, color } of targets) {
@ -82,6 +84,7 @@ function confetti({ targets, w, h, R, start, palette }) {
const grav = 1100; const grav = 1100;
return { return {
drawBack(g, now) { drawBack(g, now) {
const { w, h, R } = view;
const k = clamp01((now - start) / 700); const k = clamp01((now - start) / 700);
for (const { ring, color } of targets) { for (const { ring, color } of targets) {
const grad = g.createRadialGradient(ring.x, ring.y, R, ring.x, ring.y, Math.max(w, h)); 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) { drawFront(g, now) {
const { w, h, R } = view;
const T = (now - start) / 1000; const T = (now - start) / 1000;
for (const p of parts) { for (const p of parts) {
const t = T - p.delay; 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. // Darkness is drawn at half resolution: it's all soft gradients anyway.
const scale = 0.5; const scale = 0.5;
const off = document.createElement('canvas'); const off = document.createElement('canvas');
off.width = Math.ceil(w * scale);
off.height = Math.ceil(h * scale);
const o = off.getContext('2d'); 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 { return {
drawBack(g, now) { drawBack(g, now) {
const { w, h, R } = view;
const k = clamp01((now - start - 900) / 500); const k = clamp01((now - start - 900) / 500);
if (!k) return; if (!k) return;
for (const { ring, color } of targets) { for (const { ring, color } of targets) {
@ -139,7 +142,14 @@ function spotlight({ targets, w, h, R, start }) {
} }
}, },
drawFront(g, now) { drawFront(g, now) {
const { w, h, R } = view;
const t = (now - start) / 1000; 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.setTransform(scale, 0, 0, scale, 0, 0);
o.globalCompositeOperation = 'source-over'; o.globalCompositeOperation = 'source-over';
o.clearRect(0, 0, w, h); 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]; const delays = [0, 0.14, 0.3];
return { return {
shake(now) { 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]; return [Math.sin(now * 0.09) * amp, Math.cos(now * 0.113) * amp];
}, },
drawBack(g, now) { drawBack(g, now) {
const { w, h, R } = view;
const t = (now - start) / 1000; const t = (now - start) / 1000;
for (const { ring, color } of targets) { for (const { ring, color } of targets) {
g.fillStyle = hexToRgba(color, 0.25 * clamp01((t - 0.2) / 0.6)); 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) { drawFront(g, now) {
const { w, h, R } = view;
const t = (now - start) / 1000; const t = (now - start) / 1000;
if (t < 0.15) { if (t < 0.15) {
g.fillStyle = `rgba(255,255,255,${0.55 * (1 - 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. */ /** prefers-reduced-motion: a slow, gentle colour fade. */
function calm({ targets, w, h, R, start }) { function calm({ targets, view, start }) {
return { return {
drawBack(g, now) { drawBack(g, now) {
const { w, h, R } = view;
const k = easeInOutCubic(clamp01((now - start) / 1200)); const k = easeInOutCubic(clamp01((now - start) / 1200));
for (const { ring, color } of targets) { 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); 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 { return {
drawBack(g, now) { drawBack(g, now) {
const { w, h, R } = view;
const k = easeOutCubic(clamp01((now - start) / 700)); const k = easeOutCubic(clamp01((now - start) / 700));
for (const { ring, color } of targets) { for (const { ring, color } of targets) {
const r = R * (1.5 + 2.5 * k); const r = R * (1.5 + 2.5 * k);

View file

@ -4,7 +4,7 @@ import { t, setLang, getLang, detectLang, LANGS } from './i18n.js';
import { MODES } from './modes.js'; import { MODES } from './modes.js';
import { TIMING } from './game.js'; import { TIMING } from './game.js';
import { pick } from './random.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'; import { fullscreenSupported, isFullscreen, toggleFullscreen, onFullscreenChange } from './device.js';
const $ = (sel) => document.querySelector(sel); const $ = (sel) => document.querySelector(sel);
@ -207,7 +207,13 @@ export function createUI({ settings, debug, showDesktopHint }) {
const count = game.activeRings().length; const count = game.activeRings().length;
switch (game.phase) { switch (game.phase) {
case 'idle': 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']; return [t('needMore'), modeSummary(s), 'hint'];
case 'gathering': case 'gathering':
return [t('holdStill'), '', 'soft']; return [t('holdStill'), '', 'soft'];

2
sw.js
View file

@ -2,7 +2,7 @@
// cache fallback so updates show up immediately when online and the app // cache fallback so updates show up immediately when online and the app
// still works fully offline. Bump VERSION when the file list changes. // still works fully offline. Bump VERSION when the file list changes.
const VERSION = 'picker-v1'; const VERSION = 'picker-v2';
const SHELL = [ const SHELL = [
'./', './',
'./index.html', './index.html',

View file

@ -14,6 +14,8 @@
import { Game, TIMING } from '../js/game.js'; import { Game, TIMING } from '../js/game.js';
import { planRound, makeHighlightSchedule } from '../js/modes.js'; import { planRound, makeHighlightSchedule } from '../js/modes.js';
import { randomInt } from '../js/random.js'; import { randomInt } from '../js/random.js';
import { createInput } from '../js/input.js';
import { createReveal } from '../js/reveals.js';
const results = []; const results = [];
function test(name, fn) { function test(name, fn) {
@ -270,6 +272,47 @@ test('unknown pointer ids and double lifts are harmless', () => {
assert(h.game.rings.size === 0, 'pruned'); 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. // Service worker: parses, and every precached file exists.
try { try {
const src = await (await fetch('../sw.js')).text(); const src = await (await fetch('../sw.js')).text();