280 lines
9.5 KiB
JavaScript
280 lines
9.5 KiB
JavaScript
// 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 } 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: <div class="seg" data-setting="mode"> 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) return [t('hint'), showDesktopHint ? t('desktopHint') : modeSummary(s), '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;
|
|
},
|
|
};
|
|
}
|