// Wires the modules together and runs the frame loop. import { createSettings } from './settings.js'; import { setLang, detectLang } from './i18n.js'; import { Game } from './game.js'; import { Renderer } from './renderer.js'; import { createInput, blockBrowserGestures } from './input.js'; import { sfx, buzz, setMuted, setHaptics, installAudioUnlock } from './audio.js'; import { createWakeLock } from './device.js'; import { getTheme } from './themes.js'; import { createUI } from './ui.js'; const params = new URLSearchParams(location.search); const DEBUG = params.has('debug') && params.get('debug') !== '0'; const WAKE_HOLD_MS = 2 * 60 * 1000; // keep the screen on this long after the last touch const canvas = document.getElementById('stage'); const settings = createSettings(); setLang(settings.get().lang || detectLang()); const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)'); const renderer = new Renderer(canvas); const wakeLock = createWakeLock(); const game = new Game({ emit: onGameEvent, getSettings: () => settings.get(), isReduced: () => reducedMotion.matches, }); const noTouch = navigator.maxTouchPoints === 0 && matchMedia('(pointer: fine)').matches; const ui = createUI({ settings, debug: DEBUG, showDesktopHint: noTouch && !DEBUG }); function applySettings(s) { const theme = getTheme(s.theme); renderer.setTheme(theme); document.documentElement.dataset.theme = s.theme; document.querySelector('meta[name="theme-color"]').content = theme.bg; setMuted(s.muted); setHaptics(s.haptics); } applySettings(settings.get()); settings.subscribe(applySettings); // ---- game events -> sound, haptics, visuals, text ---- function onGameEvent(type, data = {}) { const now = performance.now(); renderer.onEvent(type, data, now, game); ui.onEvent(type, data); switch (type) { case 'join': sfx.join(data.ring.colorIdx); buzz(8); break; case 'beat': sfx.beat(data.index); buzz(25); break; case 'tick': sfx.tick(data.progress, data.final); buzz(data.final ? 35 : 8); break; case 'reveal': sfx.reveal(); buzz([90, 60, 450]); break; case 'eliminate': sfx.crack(); if (data.chickened) sfx.chicken(); buzz([40, 30, 120]); break; case 'orderStep': sfx.pop(data.n); buzz(data.n === 1 ? 120 : 20); break; case 'chicken': sfx.chicken(); buzz([60, 50, 60, 50, 60]); break; } } // ---- input ---- blockBrowserGestures(canvas); installAudioUnlock(); const input = createInput(canvas, { debug: DEBUG, onDown: (id, x, y, now) => game.fingerDown(id, x, y, now), onMove: (id, x, y) => game.fingerMove(id, x, y), onUp: (id, now) => game.fingerUp(id, now), }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { input.clearReal(); game.cancelAll(performance.now()); } }); if (DEBUG) { document.body.classList.add('debug'); document.getElementById('debug-hud').hidden = false; const n = Number(params.get('fingers')); if (n > 0) input.addFakeCircle(Math.min(n, 12)); } // ---- frame loop ---- let lastTouchAt = -Infinity; function frame() { const now = performance.now(); game.update(now); renderer.draw(game, now); ui.update(game, now); if (game.anyDown()) lastTouchAt = now; wakeLock.set(now - lastTouchAt < WAKE_HOLD_MS); requestAnimationFrame(frame); } requestAnimationFrame(frame); // ---- PWA ---- if ('serviceWorker' in navigator && !DEBUG) { window.addEventListener('load', () => { navigator.serviceWorker.register('sw.js').catch(() => {}); }); } // Handy in the console while testing. if (DEBUG) Object.assign(window, { game, settings, renderer });