/** * ECHO 3D — entry point. * * Wires the modules: * Scene3D → real Three.js scene (sky + clouds + grass + goats + stars) * TTS → StreamElements / Google / browser voices * Auth → localStorage-backed accounts * Game → word logic * GameUI → DOM bindings, GSAP animations, confetti * AuthUI → login/register form * LeaderboardUI → live scoreboard */ import './styles/main.css'; import { Scene3D } from './scene/Scene3D.js'; import { TTS } from './audio/TTS.js'; import { AmbientAudio } from './audio/AmbientAudio.js'; import { SoundLibrary } from './audio/SoundLibrary.js'; import { Auth } from './auth/Auth.js'; import * as Store from './game/Store.js'; import { loadFullDictionary } from './game/Dictionary.js'; import { Game } from './game/Game.js'; import { AuthUI } from './ui/AuthUI.js'; import { LeaderboardUI } from './ui/LeaderboardUI.js'; import { GameUI } from './ui/GameUI.js'; import { AdminUI } from './ui/AdminUI.js'; // Boot loader helper — updates the full-screen overlay before the app renders. const bootLoader = { msgEl: () => document.getElementById('bootMsg'), barEl: () => document.getElementById('bootBar'), set(pct, msg) { const m = this.msgEl(); if (m && msg) m.textContent = msg; const b = this.barEl(); if (b) b.style.width = Math.max(0, Math.min(100, pct)) + '%'; }, done() { const el = document.getElementById('bootLoader'); if (!el) return; el.classList.add('gone'); setTimeout(() => el.remove(), 700); }, }; async function boot() { bootLoader.set(5, 'Initialising…'); // Detect the admin URL up-front so we can gate the auth screen accordingly. const adminUrl = (() => { const url = new URL(location.href); if (url.searchParams.has('admin')) return true; if (location.hash === '#admin') return true; if (/\/admin\/?$/.test(location.pathname)) return true; return false; })(); // If the existing cached session belongs to a non-admin user but the URL // says /admin, clear it so the form requires an admin sign-in. if (adminUrl) { const me = Auth.current(); if (me && !me.isAdmin) await Auth.logout().catch(() => {}); } // ---- Auth UI first — it must work even if the 3D scene fails to init ---- const leaderboardUI = new LeaderboardUI(); const authUI = new AuthUI({ onEnter: () => enterApp(), adminOnly: adminUrl }); // Admin dashboard. The game stays mounted in the background; switching to // the game view just hides the admin overlay. const adminUI = new AdminUI({ onSwitchToGame: () => { document.getElementById('appWrap').style.display = ''; document.getElementById('bg3d').style.display = 'block'; // Strip the admin marker from the URL so a refresh lands on the game. const url = new URL(location.href); url.searchParams.delete('admin'); if (url.hash === '#admin') url.hash = ''; history.replaceState(null, '', url); }, }); document.getElementById('adminBtn')?.addEventListener('click', () => openAdminPage()); /** * Routes that should land on the admin dashboard: * /admin (works on dev server; on HF Spaces static this is a 404) * ?admin (works everywhere) * #admin (works everywhere, recommended for static hosts) */ function isAdminRoute() { const url = new URL(location.href); if (url.searchParams.has('admin')) return true; if (location.hash === '#admin') return true; if (/\/admin\/?$/.test(location.pathname)) return true; return false; } function openAdminPage() { if (!Auth.isAdmin()) { alert('Admin access only. Sign in with an admin account.'); return; } document.getElementById('appWrap').style.display = 'none'; document.getElementById('bg3d').style.display = 'none'; adminUI.openAsPage(); } async function enterApp(asGuest) { try { // Switch the persistence namespace to the now-logged-in user's email // (or 'guest' if they hit Continue as guest). If the namespace actually // changed, reload progress and re-render the UI so the player sees // *their* data rather than whatever was visible before login. // AuthUI passes asGuest=true after the guest button — Auth.logout() // isn't awaited there, so the cached user may still resolve briefly. const me = asGuest ? null : Auth.current(); const ns = (me?.email || 'guest').toLowerCase(); if (Store.getNamespace() !== ns) { await Store.setNamespace(ns); // game/ui are created later in boot() but enterApp is only called // after they exist (either at end of boot, or via authUI onEnter // which fires only once the login form is interactive). // Pull this user's per-word progress from the backend, then load // (the merge inside syncFromRemote lands records in IDB/LS so // reloadProgress sees them). await Store.syncFromRemote(); await game.reloadProgress(); await ui.applySettings(); ui.renderAll(); } leaderboardUI.refreshUserChip(); leaderboardUI.render(); leaderboardUI.startAutoRefresh(); const adminBtn = document.getElementById('adminBtn'); if (adminBtn) adminBtn.style.display = Auth.isAdmin() ? 'block' : 'none'; // Default landing = the game. The admin dashboard only opens when the // URL explicitly requests it. if (isAdminRoute()) { if (Auth.isAdmin()) openAdminPage(); else alert('Admin access only. Sign in with an admin account.'); } } catch (e) { console.warn('enterApp non-fatal:', e); } } /** Top-of-screen banner shown when persistent storage is unavailable. */ function showStorageWarning(msg) { if (!msg || document.getElementById('storageWarning')) return; const el = document.createElement('div'); el.id = 'storageWarning'; el.textContent = '⚠ ' + msg; el.style.cssText = ` position:fixed;top:0;left:0;right:0;z-index:99999; background:#FF6B6B;color:#fff;padding:8px 14px; font:600 13px/1.3 system-ui,sans-serif;text-align:center; box-shadow:0 2px 6px rgba(0,0,0,.3);cursor:pointer; `; el.title = 'Click to dismiss'; el.addEventListener('click', () => el.remove()); document.body.appendChild(el); } // ---- Try the 3D scene, but never let a failure break auth ---- bootLoader.set(15, 'Building the 3D world…'); let scene3d; try { scene3d = new Scene3D(document.getElementById('bg3d')); } catch (e) { console.error('3D scene failed to initialize:', e); // Stub that satisfies the methods GameUI calls scene3d = { setIntensity() {}, setAmbient(on) { document.getElementById('bg3d').style.display = on ? 'block' : 'none'; }, celebrate() {}, }; } // Persistence bootLoader.set(25, 'Opening local database…'); await Store.open(); // If there's a cached session, scope all reads to that user's namespace // BEFORE we load progress — otherwise loadProgress reads from 'guest' // and the player sees nothing. { const me = Auth.current(); if (me?.email) await Store.setNamespace(me.email.toLowerCase()); } // Surface a visible warning if persistent storage isn't available — the old // code silently fell back to in-memory and lost progress on reload. showStorageWarning(Store.warning()); // TTS const tts = new TTS({ audioEl: document.getElementById('audio'), speakerEl: document.getElementById('speaker'), }); // Ambient day/night sound — bird chirps by day, crickets + owl by night. // Needs the in-game clock so it knows time-of-day. const ambient = new AmbientAudio(); if (scene3d?.gameClock) ambient.setClock(scene3d.gameClock); // File-based sound library (day/night/wind/water/birds/crane/goat/bubbles). // Reads /public/sounds/manifest.json; see /public/sounds/README.txt. const soundLib = new SoundLibrary(); if (scene3d?.gameClock) soundLib.setClock(scene3d.gameClock); // Start audio on first user gesture so autoplay policy is satisfied. const _startAmbient = () => { ambient.start(); soundLib.start(); window.removeEventListener('pointerdown', _startAmbient); window.removeEventListener('keydown', _startAmbient); }; window.addEventListener('pointerdown', _startAmbient); window.addEventListener('keydown', _startAmbient); // expose to settings panel + scene events window.__ambient = ambient; window.__sounds = soundLib; // Dictionary load (progress shown on full-screen boot loader + game card) const progress = (m) => { const el = document.getElementById('bootStatus'); if (el) el.textContent = m; // Map the dictionary phase across 35–85% of the boot bar. const pct = m.includes('cached') ? 80 : m.includes('common-word') ? 40 : m.includes('full English') ? 55 : m.includes('Merging') ? 75 : m.includes('Caching') ? 82 : 50; bootLoader.set(pct, m); }; let dict; try { bootLoader.set(35, 'Loading dictionary…'); dict = await loadFullDictionary({ getCache: Store.getCache, setCache: Store.setCache, progress, }); } catch (e) { bootLoader.set(100, '⚠ Failed to load dictionary — check your internet.'); progress('⚠ Failed to load dictionary — check your internet.'); return; } // Game bootLoader.set(90, 'Preparing your progress…'); const game = new Game(dict); // If a session is cached, pull the user's word progress from the backend // BEFORE loadProgress so the merged local store has the latest from any // browser/device the user logged in from. No-op when offline or guest. await Store.syncFromRemote(); await game.loadProgress(); const ui = new GameUI({ game, tts, scene3d, leaderboardUI }); await ui.applySettings(); ui.renderAll(); ui.finishBoot(`✓ ${dict.set.size.toLocaleString()} English words ready · ${dict.lengths().length} grades`); // Decide login vs main app bootLoader.set(100, 'Ready.'); if (Auth.session()) { authUI.hide(); enterApp(); } else { authUI.show(); } bootLoader.done(); // Resize sanity for the 3D canvas window.addEventListener('resize', () => { /* Scene3D handles this internally */ }); } boot().catch((e) => { console.error(e); const el = document.getElementById('bootStatus'); if (el) el.innerHTML = `Error: ${e.message}`; });