Echo / src /ui /GameUI.js
mlmihjaz's picture
Pond celebration on master / level-up / finish
6494327
Raw
History Blame Contribute Delete
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 &nbsp;(${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';
}
}