Echo / src /main.js
mlmihjaz's picture
Pond celebration on master / level-up / finish
6494327
Raw
History Blame Contribute Delete
10.6 kB
/**
* 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 = `<span style="color:#FF6B6B">Error: ${e.message}</span>`;
});