/** * 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 = `
โœ… Recently fixed
Updated ${new Date().toLocaleDateString()}
`; 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 = `

๐Ÿ†˜ Report a Problem

We'll attach diagnostic info so the developer can fix it fast.

๐Ÿ“ฑ Send via WhatsApp

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 = `
${i + 1}
${g.name}
${len} letters ยท ${total.toLocaleString()} words
${pct}%
`; 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 = '
Mastered words collect here.
'; } 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)} ${'โ˜…'.repeat(n)}`; 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 = `
${s.emoji}
${s.short}
`; 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'; } }