| |
| |
| |
| |
|
|
| 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(); |
| } |
|
|
| |
| _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); |
| }); |
| } |
|
|
| |
| _buildReportButton() { |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| _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 = ` |
| <div style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:6px"> |
| <strong style="color:#46D39A;font-size:13px">✅ Recently fixed</strong> |
| <button id="fixesDismiss" title="Dismiss" style=" |
| background:transparent;color:#9aa6b2;border:none; |
| font-size:18px;line-height:1;cursor:pointer;padding:0 4px; |
| ">×</button> |
| </div> |
| <ul style="margin:0;padding-left:18px;font-size:12.5px;line-height:1.5"> |
| <li><b>Level/word progress saving fixed</b> — IndexedDB + localStorage + server sync</li> |
| <li><b>Cross-device sync</b> — login on any browser (incl. incognito) and your progress follows you</li> |
| <li><b>Sea waves redesigned</b> — irregular open-sea wavelets instead of straight ridges</li> |
| <li><b>🆘 Report Problem</b> button — one tap to send diagnostics via WhatsApp</li> |
| </ul> |
| <div style="margin-top:8px;font-size:11.5px;opacity:.7"> |
| Updated ${new Date().toLocaleDateString()} |
| </div> |
| `; |
| card.querySelector('#fixesDismiss').addEventListener('click', () => { |
| card.style.display = 'none'; |
| try { localStorage.setItem(DISMISS_KEY, RECENT_FIXES_VERSION); } catch {} |
| }); |
| return card; |
| } |
|
|
| _openReportDialog() { |
| const diag = Store.diagnostics(); |
| const ua = navigator.userAgent; |
| const summary = [ |
| `ECHO 3D problem report`, |
| `When: ${new Date().toLocaleString()}`, |
| `Storage: IDB=${diag.idbWorking ? 'ok' : 'NO'} LS=${diag.lsWorking ? 'ok' : 'NO'} mem-only=${diag.useMemory}`, |
| `User namespace: ${diag.namespace}`, |
| `Word records (localStorage): ${diag.wordCountLs}`, |
| `Mastered (this session): ${this.game.totalMastered()}`, |
| `Current level: ${this.game.len}-letter`, |
| `Writes — IDB ok/err: ${diag.stats.idbWriteOk}/${diag.stats.idbWriteErr}, LS ok/err: ${diag.stats.lsWriteOk}/${diag.stats.lsWriteErr}`, |
| `Last error: ${diag.stats.lastError || 'none'}`, |
| `Browser: ${ua}`, |
| ``, |
| `Describe what went wrong:`, |
| ``, |
| ].join('\n'); |
|
|
| |
| const back = document.createElement('div'); |
| back.id = 'reportModal'; |
| back.style.cssText = ` |
| position:fixed;inset:0;z-index:100000; |
| background:rgba(0,0,0,.72); |
| display:grid;place-items:center; |
| padding:20px; |
| `; |
| const card = document.createElement('div'); |
| card.style.cssText = ` |
| background:#1a202e;color:#fff;border-radius:14px; |
| max-width:560px;width:100%;padding:22px 24px; |
| font:14px/1.5 system-ui,sans-serif; |
| box-shadow:0 20px 60px rgba(0,0,0,.5); |
| `; |
| card.innerHTML = ` |
| <h2 style="margin:0 0 6px;font-size:20px">🆘 Report a Problem</h2> |
| <p style="margin:0 0 14px;opacity:.8;font-size:13px"> |
| We'll attach diagnostic info so the developer can fix it fast. |
| </p> |
| <textarea id="reportText" style=" |
| width:100%;height:200px;background:#0f1420;color:#fff; |
| border:1px solid #2a3346;border-radius:8px; |
| padding:10px;font:12px/1.5 ui-monospace,monospace; |
| resize:vertical;box-sizing:border-box; |
| "></textarea> |
| <div style="margin-top:14px;display:flex;gap:10px;flex-wrap:wrap"> |
| <a id="reportSendWa" target="_blank" rel="noopener" style=" |
| flex:1;min-width:170px;text-align:center; |
| background:#25D366;color:#fff;text-decoration:none; |
| padding:11px 16px;border-radius:8px;font-weight:700; |
| ">📱 Send via WhatsApp</a> |
| <button id="reportCopy" style=" |
| flex:1;min-width:120px;background:#3a4458;color:#fff; |
| border:none;padding:11px 16px;border-radius:8px; |
| font-weight:700;cursor:pointer; |
| ">📋 Copy</button> |
| <button id="reportClose" style=" |
| background:transparent;color:#fff;border:1px solid #3a4458; |
| padding:11px 16px;border-radius:8px;font-weight:700;cursor:pointer; |
| ">Close</button> |
| </div> |
| <p style="margin:14px 0 0;opacity:.7;font-size:12px;text-align:center"> |
| Or message <a href="https://wa.me/94777869736" target="_blank" rel="noopener" |
| style="color:#46D39A;font-weight:700">+94 77 786 9736</a> on WhatsApp. |
| </p> |
| `; |
| 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(); }); |
| } |
|
|
| |
| _buildWeatherDebug() { |
| const bar = document.createElement('div'); |
| bar.id = 'weatherDebug'; |
| |
| |
| 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 <span class="ans">${disp(this.game.current.w)}</span>.`; |
| 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); |
| } |
|
|
| |
|
|
| _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) { |
| |
| 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(); |
| |
| 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 <span class="ans">${disp(res.word)}</span> · ${starsStr(res.stars)}`; |
| this._fireConfetti('big'); |
| Cheer.master(); |
| this._floatPlus('✨ ' + starsStr(res.stars), '#F0A830'); |
| this._starBurst(); |
| |
| 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 <span class="ans">${disp(res.word)}</span>. 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(); |
| } |
| } |
|
|
| |
|
|
| _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.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'); |
| |
| d.innerHTML = ` |
| <div class="g-num">${i + 1}</div> |
| <div class="g-name">${g.name}</div> |
| <div class="g-sub">${len} letters · ${total.toLocaleString()} words</div> |
| <div class="g-pct">${pct}%</div> |
| <div class="g-bar"><i style="width:${pct}%"></i></div>`; |
| d.title = `${g.name} (${g.sub}) · ${len}-letter words · ${done}/${total} mastered`; |
| d.addEventListener('click', () => { this.game.jumpTo(len); this.renderAll(); }); |
| track.appendChild(d); |
| }); |
| if (currentEl) setTimeout(() => currentEl.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }), 50); |
| } |
|
|
| _setCounter(id, value) { |
| const el = this.$(id); |
| if (!el) return; |
| const next = String(value); |
| if (el.textContent !== next) { |
| el.textContent = next; |
| el.classList.remove('bump'); |
| void el.offsetWidth; |
| el.classList.add('bump'); |
| } |
| } |
|
|
| _renderSidebar() { |
| const pool = this.game.levelPool(this.game.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 g = gradeFor(this.game.len); |
| this.$('p-lvlnum').textContent = `${g.emoji} ${g.name}`; |
| this.$('p-lvldesc').textContent = `${this.game.len}-letter words · ${g.sub}`; |
| this.$('p-done').textContent = done; |
| this.$('p-total').textContent = total; |
| this.$('p-pct').textContent = pct + '%'; |
| this.$('p-bar').style.width = pct + '%'; |
| this.$('p-remaining').textContent = Math.max(0, total - done); |
| this.$('p-streak').textContent = this.game.current ? this.game.current.streak : 0; |
| |
| this._setCounter('t-mastered', this.game.totalMastered().toLocaleString()); |
| const fam = this.game.familiarised(); |
| this.$('fam-count').textContent = fam.length; |
| const list = this.$('famlist'); |
| if (!fam.length) { list.innerHTML = '<div class="famempty">Mastered words collect here.</div>'; } |
| else { |
| list.innerHTML = ''; |
| for (const r of fam) { |
| const s = document.createElement('span'); s.className = 'fw'; |
| const n = r.stars || starsForWord(r); |
| s.innerHTML = `${disp(r.w)} <span class="s">${'★'.repeat(n)}</span>`; |
| s.title = `Click to hear · ${n}/3 stars`; |
| s.addEventListener('click', () => this.tts.speak(r.w)); |
| list.appendChild(s); |
| } |
| } |
| } |
|
|
| _updateDifficulty() { |
| const lengths = this.game.dict.lengths(); |
| const idx = Math.max(0, lengths.indexOf(this.game.len)); |
| const total = Math.max(1, lengths.length - 1); |
| this.scene3d.setIntensity(idx / total); |
| |
| const stage = stageForLen(this.game.len, lengths); |
| if (this.scene3d.setWeatherStage) this.scene3d.setWeatherStage(stage.idx); |
| if (window.__sounds) window.__sounds.setWeatherStage(stage.idx); |
| this._highestStage = Math.max(this._highestStage ?? 0, stage.idx); |
| this._renderMedals(); |
| this._highlightDebugStage?.(stage.idx); |
| } |
|
|
| _renderMedals() { |
| const row = this.$('medalRow'); |
| if (!row) return; |
| if (row.children.length !== STAGES.length) { |
| row.innerHTML = ''; |
| for (const s of STAGES) { |
| const m = document.createElement('div'); |
| m.className = `medal medal-${s.medal}`; |
| m.title = s.name; |
| m.innerHTML = `<div class="m-emoji">${s.emoji}</div><div class="m-name">${s.short}</div>`; |
| row.appendChild(m); |
| } |
| } |
| const reached = this._highestStage ?? stageForLen(this.game.len, this.game.dict.lengths()).idx; |
| [...row.children].forEach((el, i) => { |
| el.classList.toggle('earned', i <= reached); |
| el.classList.toggle('current', i === reached); |
| }); |
| const count = this.$('medals-count'); |
| if (count) count.textContent = `${Math.min(reached + 1, STAGES.length)}/${STAGES.length}`; |
| } |
|
|
| _showBanner(text) { |
| const lu = this.$('levelup'); lu.style.display = 'grid'; |
| const sp = lu.querySelector('span'); sp.textContent = text; |
| gsap.fromTo(sp, { scale: 0.4, rotate: -8, opacity: 0 }, { scale: 1.05, rotate: 0, opacity: 1, duration: 0.6, ease: 'back.out(2)' }); |
| gsap.to(sp, { opacity: 0, scale: 1.15, duration: 0.4, delay: 1.4 }); |
| setTimeout(() => lu.style.display = 'none', 1900); |
| } |
|
|
| _fireConfetti(intensity) { |
| const count = intensity === 'big' ? 180 : 60; |
| confetti({ particleCount: count, spread: 80, origin: { y: 0.7 }, colors: ['#F0A830', '#46D39A', '#8B7BFF', '#FF6B6B', '#ffd166'] }); |
| if (intensity === 'big') { |
| setTimeout(() => confetti({ particleCount: 120, angle: 60, spread: 55, origin: { x: 0, y: 0.7 } }), 200); |
| setTimeout(() => confetti({ particleCount: 120, angle: 120, spread: 55, origin: { x: 1, y: 0.7 } }), 400); |
| } |
| } |
|
|
| _floatPlus(text, color) { |
| const card = this.$('gameCard'); |
| const f = document.createElement('div'); |
| f.style.cssText = `position:absolute;font-family:var(--mono);font-weight:700;font-size:22px;color:${color || '#46D39A'};pointer-events:none;z-index:7;left:${30 + Math.random() * 40}%;top:55%`; |
| f.textContent = text; |
| card.appendChild(f); |
| gsap.fromTo(f, { y: 0, opacity: 0, scale: 0.6 }, { y: -60, opacity: 1, scale: 1.1, duration: 0.4, ease: 'back.out(2)' }); |
| gsap.to(f, { opacity: 0, duration: 0.5, delay: 0.6, onComplete: () => f.remove() }); |
| } |
|
|
| _starBurst() { |
| const container = this.$('burst'); |
| if (!container) return; |
| container.innerHTML = ''; |
| const N = 14; |
| for (let i = 0; i < N; i++) { |
| const s = document.createElement('div'); s.className = 'star'; |
| const rect = container.getBoundingClientRect(); |
| s.style.left = (rect.width / 2 - 7) + 'px'; |
| s.style.top = (rect.height / 2 - 7) + 'px'; |
| container.appendChild(s); |
| const angle = (i / N) * Math.PI * 2; |
| const dist = 110 + Math.random() * 60; |
| gsap.to(s, { |
| x: Math.cos(angle) * dist, y: Math.sin(angle) * dist, |
| opacity: 0, scale: 0.4, rotate: 360, duration: 1.1, ease: 'power2.out', |
| onComplete: () => s.remove(), |
| }); |
| } |
| } |
|
|
| startBootProgress(msg) { this.$('bootStatus').textContent = msg; } |
| finishBoot(msg) { |
| this.$('bootStatus').textContent = msg; |
| this.$('startBtn').disabled = false; |
| this.$('startBtn').textContent = '▶ Start'; |
| } |
| } |
|
|