Download src/ui/GameUI.js from mlmihjaz/Echo: direct link, hf CLI and curl.
- Browser
- Download file 30.2 kB
-
https://huggingface.co/spaces/mlmihjaz/Echo/resolve/main/src/ui/GameUI.js
- Command line
-
hf download hf://spaces/mlmihjaz/Echo/src/ui/GameUI.js
-
curl -L -o GameUI.js https://huggingface.co/spaces/mlmihjaz/Echo/resolve/main/src/ui/GameUI.js
30.2 kB
| /** | |
| * 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 = ` | |
| <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'); | |
| // Build modal | |
| 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(); }); | |
| } | |
| /** 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 <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); | |
| } | |
| // ----- 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 <span class="ans">${disp(res.word)}</span> · ${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 <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(); | |
| } | |
| } | |
| // ----- 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 = ` | |
| <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; // restart animation | |
| 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; | |
| // Real-time counters (pulse on increment) | |
| 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); | |
| // Push the weather stage to both the 3D scene and the sound library. | |
| 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'; | |
| } | |
| } | |