// DOM layer: overlay messages, toolbar and the settings sheet. 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, audioUnlocked } from './audio.js'; import { fullscreenSupported, isFullscreen, toggleFullscreen, onFullscreenChange } from './device.js'; const $ = (sel) => document.querySelector(sel); export function stakeText(s) { return s.stakeId ? t(`stakePresets.${s.stakeId}`) : s.stakeText.trim(); } export function createUI({ settings, debug, showDesktopHint }) { const msgMain = $('#msg-main'); const msgSub = $('#msg-sub'); const toolbar = $('#toolbar'); const sheet = $('#sheet'); const backdrop = $('#sheet-backdrop'); const btnSettings = $('#btn-settings'); const btnSound = $('#btn-sound'); const btnFull = $('#btn-fullscreen'); const stakeInput = $('#stake-input'); const hud = $('#debug-hud'); let chickenMsg = ''; let shownMain = null; let shownSub = null; let shownClass = null; let toolbarShown = null; // ---- toolbar ---- btnSettings.addEventListener('click', openSheet); btnSound.addEventListener('click', () => settings.set({ muted: !settings.get().muted })); const standaloneFull = matchMedia('(display-mode: fullscreen)').matches; btnFull.hidden = !fullscreenSupported() || standaloneFull; btnFull.addEventListener('click', toggleFullscreen); onFullscreenChange(syncToolbar); // ---- sheet ---- function openSheet() { sheet.hidden = false; backdrop.hidden = false; requestAnimationFrame(() => document.body.classList.add('sheet-open')); sheet.focus({ preventScroll: true }); } function closeSheet() { document.body.classList.remove('sheet-open'); stakeInput.blur(); setTimeout(() => { if (!document.body.classList.contains('sheet-open')) { sheet.hidden = true; backdrop.hidden = true; } }, 250); btnSettings.focus({ preventScroll: true }); } backdrop.addEventListener('click', closeSheet); sheet.querySelector('.sheet-close').addEventListener('click', closeSheet); window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !sheet.hidden) closeSheet(); }); // Segmented controls:
buttons with data-value. function buildSeg(el, values, labelFn) { el.replaceChildren( ...values.map((v) => { const b = document.createElement('button'); b.type = 'button'; b.dataset.value = String(v); b.textContent = labelFn(v); return b; }), ); } sheet.addEventListener('click', (e) => { const btn = e.target.closest('button'); if (!btn) return; const seg = btn.closest('.seg'); if (seg && btn.dataset.value != null) { const key = seg.dataset.setting; let v = btn.dataset.value; if (key === 'teams') v = Number(v); if (key === 'muted') v = v === 'true'; if (key === 'haptics') v = v === 'true'; if (key === 'lang') setLang(v); settings.set({ [key]: v }); return; } if (btn.dataset.step) { const n = Math.max(1, Math.min(9, settings.get().pickN + Number(btn.dataset.step))); settings.set({ pickN: n }); return; } if (btn.dataset.preset) { const id = btn.dataset.preset; settings.set(settings.get().stakeId === id ? { stakeId: null, stakeText: '' } : { stakeId: id, stakeText: '' }); return; } if (btn.id === 'stake-clear') settings.set({ stakeId: null, stakeText: '' }); }); stakeInput.addEventListener('input', () => settings.set({ stakeId: null, stakeText: stakeInput.value.slice(0, 60) })); stakeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') stakeInput.blur(); }); function applyTranslations() { document.title = t('appTitle'); for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n); for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria)); stakeInput.placeholder = t('stakesPlaceholder'); buildSeg($('#seg-mode'), MODES, (m) => t(`mode.${m}`)); buildSeg($('#seg-teams'), [2, 3, 4], String); buildSeg($('#seg-theme'), ['neon', 'pastel', 'contrast'], (v) => t(`theme.${v}`)); buildSeg($('#seg-lang'), LANGS, (v) => ({ en: 'English', nl: 'Nederlands' })[v]); buildSeg($('#seg-sound'), [false, true], (v) => t(v ? 'off' : 'on')); buildSeg($('#seg-haptics'), [true, false], (v) => t(v ? 'on' : 'off')); const presets = $('#stake-presets'); presets.replaceChildren( ...Object.keys(t('stakePresets')).map((id) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'chip'; b.dataset.preset = id; b.textContent = t(`stakePresets.${id}`); return b; }), ); } function syncSheet() { const s = settings.get(); const lang = getLang(); const sel = { mode: s.mode, teams: String(s.teams), theme: s.theme, lang, muted: String(s.muted), haptics: String(s.haptics) }; for (const seg of sheet.querySelectorAll('.seg')) { const cur = sel[seg.dataset.setting]; for (const b of seg.children) b.setAttribute('aria-pressed', String(b.dataset.value === cur)); } $('#mode-desc').textContent = t(`modeDesc.${s.mode}`); $('#row-pickn').hidden = s.mode !== 'pickN'; $('#row-teams').hidden = s.mode !== 'teams'; $('#row-haptics').hidden = !canVibrate; $('#pickn-value').textContent = String(s.pickN); for (const b of $('#stake-presets').children) b.setAttribute('aria-pressed', String(b.dataset.preset === s.stakeId)); if (document.activeElement !== stakeInput) stakeInput.value = s.stakeId ? '' : s.stakeText; $('#stake-clear').hidden = !s.stakeId && !s.stakeText; syncToolbar(); } function syncToolbar() { const s = settings.get(); btnSound.classList.toggle('is-muted', s.muted); btnSound.setAttribute('aria-label', t(s.muted ? 'unmute' : 'mute')); btnSound.setAttribute('aria-pressed', String(s.muted)); const fs = isFullscreen(); btnFull.classList.toggle('is-full', fs); btnFull.setAttribute('aria-label', t(fs ? 'exitFullscreen' : 'fullscreen')); } settings.subscribe((s, patch) => { if ('lang' in patch) { setLang(s.lang || detectLang()); applyTranslations(); } syncSheet(); }); applyTranslations(); syncSheet(); if (debug && new URLSearchParams(location.search).get('open') === 'settings') openSheet(); // ---- overlay messages ---- function modeSummary(s) { let out = t(`mode.${s.mode}`); if (s.mode === 'pickN') out += ` · ${s.pickN}`; if (s.mode === 'teams') out += ` · ${s.teams}`; const stake = stakeText(s); if (stake && s.mode !== 'teams') out += ` · “${stake}”`; return out; } function resultMessage(game, s) { const stake = stakeText(s); switch (game.plan?.mode) { case 'teams': return t('teamsReady'); case 'order': return stake ? `#1: ${t('youStake', { stake })}` : t('orderDone'); case 'elimination': return stake ? t('youStake', { stake }) : t('lastStanding'); default: return stake ? t('youStake', { stake }) : t('youreIt'); } } function messages(game, now) { const s = settings.get(); const count = game.activeRings().length; switch (game.phase) { case 'idle': 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']; case 'countdown': return [String(TIMING.beats - Math.max(0, game.beat)), '', 'count']; case 'chicken': return [chickenMsg, '', 'alert']; case 'elimPause': { const out = game.lastOut; const ring = out && game.rings.get(out.id); if (!out) return ['', '', '']; return [t(out.chickened ? 'chickenOut' : 'eliminated'), ring?.down ? t('liftFinger') : '', 'alert']; } case 'result': { const sub = game.canRestart(now) ? t('tapRestart') : ''; return [resultMessage(game, s), sub, 'result']; } default: return ['', '', '']; } } return { onEvent(type) { if (type === 'chicken') chickenMsg = pick(t('chicken')); }, update(game, now) { const [main, sub, cls] = messages(game, now); if (main !== shownMain || cls !== shownClass) { msgMain.textContent = main; msgMain.className = `msg ${cls}`; // restart the entrance animation void msgMain.offsetWidth; msgMain.classList.add('in'); shownMain = main; shownClass = cls; } if (sub !== shownSub) { msgSub.textContent = sub; shownSub = sub; } const showTools = (game.phase === 'idle' && !game.anyDown()) || (game.phase === 'result' && !game.anyDown()); if (showTools !== toolbarShown) { toolbar.classList.toggle('hidden', !showTools); toolbar.inert = !showTools; toolbarShown = showTools; } if (debug) { const fps = this._fps(now); hud.textContent = `${game.phase} · ${game.activeRings().length} fingers · ${fps} fps`; } }, _last: 0, _frames: 0, _fpsVal: 0, _fps(now) { this._frames++; if (now - this._last > 500) { this._fpsVal = Math.round((this._frames * 1000) / (now - this._last)); this._frames = 0; this._last = now; } return this._fpsVal; }, }; }