// 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;
},
};
}