This commit is contained in:
parent
ac33e2fec6
commit
35a4dc2dbf
9 changed files with 106 additions and 23 deletions
|
|
@ -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);
|
||||
|
|
|
|||
14
js/audio.js
14
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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
21
js/input.js
21
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) => {
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
10
js/ui.js
10
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'];
|
||||
|
|
|
|||
2
sw.js
2
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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue