/** * GameUI โ DOM bindings for the game card, grade track, sidebar, settings. * Wires GSAP, confetti, cheers, TTS, translator. */ import gsap from 'gsap'; import confetti from 'canvas-confetti'; import { gradeFor, gradeDesc, starsForWord, starsStr } from '../game/Grades.js'; import { getMeaning } from '../game/Translator.js'; import * as Store from '../game/Store.js'; import { Cheer } from '../audio/Cheer.js'; import { STAGES, stageForLen } from '../game/Stages.js'; const disp = (w) => (w === 'i' ? 'I' : w); export class GameUI { constructor({ game, tts, scene3d, leaderboardUI }) { this.game = game; this.tts = tts; this.scene3d = scene3d; this.lbUI = leaderboardUI; this.tilesEl = this.$('tiles'); this.typedEl = this.$('typed'); this.fb = this.$('feedback'); this.speakerEl = this.$('speaker'); this._bindGame(); this._bindUI(); this._buildWeatherDebug(); this._buildSaveIndicator(); this._buildReportButton(); } /** Bottom-right pill that flashes "๐พ Saved" each time Store persists a word. */ _buildSaveIndicator() { const el = document.createElement('div'); el.id = 'saveIndicator'; el.style.cssText = ` position:fixed;right:18px;bottom:18px;z-index:9998; background:rgba(26,32,46,.92);color:#fff; padding:8px 14px;border-radius:999px; font:600 13px/1 system-ui,sans-serif; box-shadow:0 4px 12px rgba(0,0,0,.4); opacity:0;transform:translateY(8px); transition:opacity .25s ease, transform .25s ease; pointer-events:none; `; document.body.appendChild(el); this._saveIndicatorEl = el; Store.onSave((info) => { let label, color; if (info.idb) { label = '๐พ Saved'; color = '#46D39A'; } else if (info.ls) { label = '๐พ Saved (local)'; color = '#F0A830'; } else if (info.mem) { label = 'โ In memory only'; color = '#FF6B6B'; } else { label = 'โ Save failed'; color = '#FF6B6B'; } el.textContent = `${label} ยท ${info.word}`; el.style.borderLeft = `4px solid ${color}`; el.style.opacity = '1'; el.style.transform = 'translateY(0)'; clearTimeout(this._saveTimer); this._saveTimer = setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateY(8px)'; }, 1400); }); } /** Top-right "๐ Report Problem" button with diagnostics + WhatsApp link. */ _buildReportButton() { // Container so the button + "recent fixes" card stay aligned to the same // right edge regardless of width. const col = document.createElement('div'); col.id = 'reportColumn'; col.style.cssText = ` position:fixed;right:18px;top:18px;z-index:9999; display:flex;flex-direction:column;align-items:flex-end;gap:10px; max-width:300px; `; const btn = document.createElement('button'); btn.type = 'button'; btn.id = 'reportBtn'; btn.textContent = '๐ Report Problem'; btn.style.cssText = ` background:#FF6B6B;color:#fff;border:none; padding:8px 14px;border-radius:8px; font:600 13px/1 system-ui,sans-serif;cursor:pointer; box-shadow:0 4px 10px rgba(0,0,0,.35); align-self:stretch; `; btn.addEventListener('click', () => this._openReportDialog()); col.appendChild(btn); col.appendChild(this._buildRecentFixesCard()); document.body.appendChild(col); } /** Small green card listing recently shipped fixes โ visible reassurance * that the developer is actively working on reported issues. Dismissable; * the dismissal persists in localStorage so it doesn't nag returning users. * Bump RECENT_FIXES_VERSION when adding new entries to re-show the card. */ _buildRecentFixesCard() { const RECENT_FIXES_VERSION = 'v4-2026-06-13'; const DISMISS_KEY = 'echo3d:fixesDismissedVersion'; const card = document.createElement('div'); card.id = 'recentFixes'; let dismissed = false; try { dismissed = localStorage.getItem(DISMISS_KEY) === RECENT_FIXES_VERSION; } catch {} card.style.cssText = ` background:rgba(20,28,40,.94); color:#e6f6ec; border:1px solid rgba(70,211,154,.55); border-left:4px solid #46D39A; border-radius:10px; padding:10px 12px 12px; font:13px/1.4 system-ui,sans-serif; box-shadow:0 6px 16px rgba(0,0,0,.4); width:100%;box-sizing:border-box; ${dismissed ? 'display:none;' : ''} `; card.innerHTML = `
We'll attach diagnostic info so the developer can fix it fast.
Or message +94 77 786 9736 on WhatsApp.
`; back.appendChild(card); document.body.appendChild(back); const ta = card.querySelector('#reportText'); ta.value = summary; ta.focus(); const updateWa = () => { const txt = ta.value; const url = `https://wa.me/94777869736?text=${encodeURIComponent(txt)}`; card.querySelector('#reportSendWa').href = url; }; updateWa(); ta.addEventListener('input', updateWa); card.querySelector('#reportCopy').addEventListener('click', async () => { try { await navigator.clipboard.writeText(ta.value); const b = card.querySelector('#reportCopy'); const old = b.textContent; b.textContent = 'โ Copied'; setTimeout(() => { b.textContent = old; }, 1200); } catch { ta.select(); } }); card.querySelector('#reportClose').addEventListener('click', () => back.remove()); back.addEventListener('click', (e) => { if (e.target === back) back.remove(); }); } /** TEMPORARY: compact icon-only climate switcher. Bottom-left, out of the way. */ _buildWeatherDebug() { const bar = document.createElement('div'); bar.id = 'weatherDebug'; // Pin to the ECHO logo's left edge. .wrap is max-width:1180px centered // with 18px padding, so on wider viewports the logo isn't at x=0. bar.style.cssText = ` position:fixed; left:max(18px, calc((100vw - 1180px) / 2 + 18px)); top:74px;z-index:9999; display:flex;gap:8px;background:transparent;border:none; padding:0;box-shadow:none; `; STAGES.forEach((s, i) => { const b = document.createElement('button'); b.type = 'button'; b.dataset.stage = i; b.title = s.name; b.style.cssText = ` width:30px;height:30px;display:grid;place-items:center; background:transparent;border:none;border-radius:8px; font-size:22px;line-height:1;padding:0;cursor:pointer; filter:drop-shadow(0 1px 2px rgba(0,0,0,.6)); opacity:.55;transition:opacity .15s ease,transform .15s ease,filter .15s ease; `; b.textContent = s.emoji; b.addEventListener('click', () => this._forceStage(i, bar)); b.addEventListener('mouseenter', () => { if (Number(b.dataset.stage) !== this._activeDebugStage) { b.style.opacity = '0.95'; b.style.transform = 'scale(1.1)'; } }); b.addEventListener('mouseleave', () => { if (Number(b.dataset.stage) !== this._activeDebugStage) { b.style.opacity = '0.55'; b.style.transform = 'scale(1)'; } }); bar.appendChild(b); }); document.body.appendChild(bar); this._weatherDebugBar = bar; this._activeDebugStage = 0; this._highlightDebugStage(0); } _highlightDebugStage(idx) { if (!this._weatherDebugBar) return; this._activeDebugStage = idx; [...this._weatherDebugBar.querySelectorAll('button')].forEach((b, i) => { const on = i === idx; b.style.opacity = on ? '1' : '0.55'; b.style.transform = on ? 'scale(1.18)' : 'scale(1)'; b.style.filter = on ? 'drop-shadow(0 0 6px rgba(255,224,138,.85)) drop-shadow(0 1px 2px rgba(0,0,0,.6))' : 'drop-shadow(0 1px 2px rgba(0,0,0,.6))'; }); } _forceStage(idx) { if (this.scene3d.setWeatherStage) this.scene3d.setWeatherStage(idx); if (window.__sounds) window.__sounds.setWeatherStage(idx); this._highestStage = Math.max(this._highestStage ?? 0, idx); this._renderMedals(); this._highlightDebugStage(idx); } $(id) { return document.getElementById(id); } _bindGame() { this.game.on('word', () => this._onWordLoaded()); this.game.on('levelup', (n) => this._onLevelUp(n)); this.game.on('finish', () => this._onFinish()); } _bindUI() { this.typedEl.addEventListener('input', () => { this.game.revealed = false; this._renderTiles(); }); this.typedEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); this._submit(); } if (e.key === 'Tab') { e.preventDefault(); if (this.game.current) this.tts.speak(this.game.current.w); } }); this.speakerEl.addEventListener('click', () => { Cheer.prime(); if (this.game.current) this.tts.speak(this.game.current.w); }); this.speakerEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (this.game.current) this.tts.speak(this.game.current.w); } }); this.$('revealBtn').addEventListener('click', () => { if (!this.game.current) return; this.game.revealed = true; this.typedEl.value = ''; this._renderTiles(); this.fb.className = 'feedback'; this.fb.innerHTML = `It's ${disp(this.game.current.w)}.`; this.typedEl.focus(); }); this.$('skipBtn').addEventListener('click', () => { if (this.game.current) this.game.advance(); }); this.$('hearAgainBtn').addEventListener('click', () => { if (!this.game.current) return; const w = this.game.current.w; this.tts.speak(`${w}. ${w.split('').join(', ')}.`); }); this.$('rate').addEventListener('input', (e) => { const r = parseFloat(e.target.value); this.tts.setRate(r); this.$('rateVal').textContent = r.toFixed(2) + 'ร'; Store.setMeta('rate', r); }); this.$('thr').addEventListener('input', (e) => { const t = parseInt(e.target.value); this.game.threshold = t; this.$('thrVal').textContent = t; Store.setMeta('threshold', t); this.renderAll(); }); this.$('engineSel').addEventListener('change', (e) => { this.tts.setEngine(e.target.value); Store.setMeta('engine', e.target.value); if (this.game.current) this.tts.speak(this.game.current.w); }); this.$('showMeaning').addEventListener('change', (e) => { this.showMeaning = e.target.checked; Store.setMeta('showMeaning', e.target.checked); this.$('meaning').style.display = e.target.checked ? 'grid' : 'none'; if (this.game.current && e.target.checked) this._showMeaningFor(this.game.current.w); }); this.$('cheerSound').addEventListener('change', (e) => { Cheer.enabled = e.target.checked; Store.setMeta('cheerSound', e.target.checked); }); this.$('ambient3D').addEventListener('change', (e) => { this.scene3d.setAmbient(e.target.checked); Store.setMeta('ambient3D', e.target.checked); }); this.$('ambientAudio').addEventListener('change', (e) => { if (window.__ambient) window.__ambient.setEnabled(e.target.checked); if (window.__sounds) window.__sounds.setEnabled(e.target.checked); Store.setMeta('ambientAudio', e.target.checked); }); this.$('resetBtn').addEventListener('click', async () => { if (!confirm('Erase all progress?')) return; this.game.reset(); this.tilesEl.innerHTML = ''; this.typedEl.value = ''; this.typedEl.disabled = true; this.fb.textContent = ''; this.$('overlay').classList.remove('hidden'); this.renderAll(); }); this.$('startBtn').addEventListener('click', () => { Cheer.prime(); this.$('overlay').classList.add('hidden'); this.game.advance(); }); } async applySettings() { this.$('thr').value = this.game.threshold; this.$('thrVal').textContent = this.game.threshold; const rate = parseFloat(await Store.getMeta('rate', 0.95)); this.tts.setRate(rate); this.$('rate').value = rate; this.$('rateVal').textContent = rate.toFixed(2) + 'ร'; const engine = await Store.getMeta('engine', 'stream:Brian'); this.tts.setEngine(engine); this.$('engineSel').value = engine; this.showMeaning = (await Store.getMeta('showMeaning', true)) !== false; this.$('showMeaning').checked = this.showMeaning; if (!this.showMeaning) this.$('meaning').style.display = 'none'; Cheer.enabled = (await Store.getMeta('cheerSound', true)) !== false; this.$('cheerSound').checked = Cheer.enabled; const ambient = (await Store.getMeta('ambient3D', true)) !== false; this.$('ambient3D').checked = ambient; this.scene3d.setAmbient(ambient); const ambAudio = (await Store.getMeta('ambientAudio', true)) !== false; this.$('ambientAudio').checked = ambAudio; if (window.__ambient) window.__ambient.setEnabled(ambAudio); if (window.__sounds) window.__sounds.setEnabled(ambAudio); } // ----- game callbacks ----- _onWordLoaded() { const r = this.game.current; this.typedEl.value = ''; this.typedEl.disabled = false; this.fb.textContent = ''; this.fb.className = 'feedback'; this._renderTiles(); this.typedEl.focus(); this.tts.speak(r.w); if (this.showMeaning) this._showMeaningFor(r.w); this.renderAll(); } _onLevelUp(n) { const g = gradeFor(n); const lengths = this.game.dict.lengths(); const prevStage = this._highestStage ?? 0; const newStage = stageForLen(n, lengths); if (newStage.idx > prevStage) { // Player just crossed into a new weather stage โ celebrate. this._showBanner(`${newStage.emoji} ${newStage.name.toUpperCase()}\nstage cleared ยท ${this._medalLabel(newStage.medal)}`); if (window.__sounds) { window.__sounds.fire('winner'); setTimeout(() => window.__sounds.fire('award'), 400); } } else { this._showBanner(`๐ ${g.emoji} ${g.name.toUpperCase()}\n${n}-letter words`); } this._fireConfetti('big'); Cheer.levelUp(); this.scene3d.celebrate?.(); this.renderAll(); } _medalLabel(tier) { return ({ bronze: '๐ฅ bronze medal', silver: '๐ฅ silver medal', gold: '๐ฅ gold medal', trophy: '๐ TROPHY' })[tier] || ''; } _onFinish() { this.game.current = null; this.typedEl.disabled = true; this.tilesEl.innerHTML = ''; this.fb.className = 'feedback good'; this.fb.innerHTML = '๐ ICE AGE CONQUERED ยท Every grade cleared!'; this._highestStage = STAGES.length - 1; if (this.scene3d.setWeatherStage) this.scene3d.setWeatherStage(STAGES.length - 1); if (window.__sounds) window.__sounds.setWeatherStage(STAGES.length - 1); this._renderMedals(); this._showBanner(`๐ TROPHY UNLOCKED\nIce Age conquered`); if (window.__sounds) { window.__sounds.fire('winner'); setTimeout(() => window.__sounds.fire('award'), 500); } this._fireConfetti('big'); Cheer.levelUp(); // Trophy โ fire the celebration three times for a bigger finale. this.scene3d.celebrate?.(); setTimeout(() => this.scene3d.celebrate?.(), 600); setTimeout(() => this.scene3d.celebrate?.(), 1200); this.renderAll(); } _submit() { const res = this.game.submit(this.typedEl.value); if (res.kind === 'replay') { this.tts.speak(this.game.current.w); return; } if (res.kind === 'correct') { this._renderTiles(true); this.fb.className = 'feedback good'; this.fb.innerHTML = `โ Correct (${res.streak}/${res.threshold})`; this._fireConfetti('small'); Cheer.small(); this._floatPlus('+1', '#46D39A'); this.typedEl.disabled = true; setTimeout(() => this.game.advance(), 900); this.renderAll(); return; } if (res.kind === 'master') { this._renderTiles(true); this.fb.className = 'feedback good'; this.fb.innerHTML = `โจ Mastered ${disp(res.word)} ยท ${starsStr(res.stars)}`; this._fireConfetti('big'); Cheer.master(); this._floatPlus('โจ ' + starsStr(res.stars), '#F0A830'); this._starBurst(); // Pond greeting โ cascade jump from every fish + rainbow burst. this.scene3d.celebrate?.(); this.lbUI.bumpUserStats(res.stars, 1, this.game.len); this.typedEl.disabled = true; setTimeout(() => this.game.advance(), 950); this.renderAll(); return; } if (res.kind === 'wrong') { this.tilesEl.classList.remove('shake'); void this.tilesEl.offsetWidth; this.tilesEl.classList.add('shake'); this._renderTiles(); this.fb.className = 'feedback fail'; if (res.reveal) this.fb.innerHTML = `Not quite โ it's ${disp(res.word)}. Listen again.`; else this.fb.textContent = 'Not quite โ listen again and retype it.'; this.typedEl.value = ''; this.typedEl.focus(); setTimeout(() => this.tts.speak(this.game.current.w), 250); this.renderAll(); } } // ----- rendering ----- _renderTiles(allOk) { const r = this.game.current; this.tilesEl.innerHTML = ''; if (!r) return; const target = r.w; const typed = this.typedEl.value.toLowerCase(); for (let i = 0; i < target.length; i++) { const t = document.createElement('div'); t.className = 'tile'; if (this.game.revealed && !typed) { t.textContent = disp(target)[i] || target[i]; t.classList.add('reveal'); } else if (i < typed.length) { t.textContent = typed[i]; t.classList.add(typed[i] === target[i] ? 'ok' : 'bad'); if (allOk) { t.className = 'tile ok'; t.textContent = target[i]; } } else { t.classList.add('ghost'); t.textContent = 'ยท'; } this.tilesEl.appendChild(t); // GSAP entry animation gsap.fromTo(t, { scale: 0.7, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.22, ease: 'back.out(2)', delay: i * 0.02 }); } if (typed.length > target.length && !allOk) { for (let i = target.length; i < typed.length; i++) { const t = document.createElement('div'); t.className = 'tile bad'; t.textContent = typed[i]; this.tilesEl.appendChild(t); } } } async _showMeaningFor(w) { const m = this.$('meaning'); if (!this.showMeaning) { m.style.display = 'none'; return; } m.style.display = 'grid'; this.$('m-si').textContent = 'โฆ'; this.$('m-si').classList.add('loading'); this.$('m-ta').textContent = 'โฆ'; this.$('m-ta').classList.add('loading'); m.classList.remove('empty'); const obj = await getMeaning(w); if (this.game.current && this.game.current.w === w) { this.$('m-si').textContent = obj.si; this.$('m-si').classList.remove('loading'); this.$('m-ta').textContent = obj.ta; this.$('m-ta').classList.remove('loading'); } } renderAll() { this._renderTrack(); this._renderSidebar(); this._updateDifficulty(); this.lbUI.render(); } _renderTrack() { const track = this.$('track'); track.innerHTML = ''; let currentEl = null; this.game.dict.lengths().forEach((len, i) => { const g = gradeFor(len); const pool = this.game.levelPool(len); const done = pool.filter(w => this.game.isMastered(w)).length; const total = pool.length; const pct = total ? Math.round(done / total * 100) : 0; const d = document.createElement('div'); d.className = 'lvl'; if (len === this.game.len) { d.classList.add('current'); currentEl = d; } else if (total > 0 && done === total) d.classList.add('done'); // 3D-extruded grade number replaces the unicode emoji icon. d.innerHTML = `