import './style.css'; import { cloneState, isFull, newGame, playTurn, unlockedCategories, type GameState, type Position } from './core/GridLogic'; import { Storage } from './core/Storage'; import { Sound } from './core/Sound'; import { BoardView } from './render/BoardView'; import { UIManager } from './ui/UIManager'; import { buildingName, categoryLabel, formatNumber, t } from './ui/i18n'; async function boot(): Promise { const storage = new Storage(), saved = storage.load(); let state = saved?.state ?? newGame(); let undo: GameState | null = saved?.undo ?? null; let highScore = Math.max(saved?.highScore ?? 0, storage.best()); let busy = false, active = false, hasSave = Boolean(saved), newBest = false; const ui = new UIManager(), view = new BoardView(), sound = new Sound(); await view.init(ui.boardHost, ui.nextHost); const sync = (): void => { ui.update(state, highScore, undo !== null, busy); view.setInput(active && !busy && !ui.modalOpen && !isFull(state.grid)); }; const save = (): void => { if (!storage.save(state, undo, highScore)) ui.storageUnavailable(); }; const over = (): void => { if (isFull(state.grid)) ui.gameOver(state.score, highScore, newBest, undo !== null); sync(); }; const start = (): void => { active = true; ui.setMode('game'); sound.unlock(); sync(); over(); }; ui.onPlay = start; ui.onNew = () => { if (busy) return; state = newGame(); undo = null; newBest = false; hasSave = true; view.render(state.grid); view.setNext(state.next); save(); start(); ui.announce(t('newAnnouncement')); }; ui.onMenu = () => { if (busy) return; active = false; save(); hasSave = true; ui.setMode('menu', hasSave); sync(); }; ui.onUndo = () => { if (busy || !undo) return; state = cloneState(undo); undo = null; newBest = false; view.render(state.grid); view.setNext(state.next); save(); active = true; ui.setMode('game'); sync(); ui.toast(t('undoToast')); }; ui.onSound = () => { sound.enabled = !sound.enabled; ui.sound(sound.enabled); sound.unlock(); sound.note(); }; ui.onModalChange = () => sync(); view.onSelect = message => ui.announce(message); view.onPlace = async (target: Position): Promise => { if (!active || busy || ui.modalOpen) return; const result = playTurn(state, target); if (!result) { ui.toast(t('occupied')); return; } sound.unlock(); busy = true; const previousCategories = unlockedCategories(state.score); undo = cloneState(state); const previousBest = highScore; // Commit and save before awaiting any animation, so closing mid-cascade is safe. state = result.state; hasSave = true; highScore = Math.max(highScore, state.score); newBest = state.score > previousBest; save(); view.setNext(state.next); sync(); try { sound.note(); await view.place(target, result.placed); for (const step of result.merges) { sound.note(step.chain); await view.merge(target, step); } if (result.merges.length > 1) ui.toast(t('chain', { count: result.merges.length })); const unlocked = unlockedCategories(state.score).filter(category => !previousCategories.includes(category)); if (unlocked.length) ui.toast(t('unlock', { category: unlocked.map(categoryLabel).join(', ') })); ui.announce(t('turnAnnouncement', { score: formatNumber(state.score), merges: result.merges.length, name: buildingName(state.next), tier: state.next.tier })); } catch (error) { console.error('Animation recovered:', error); view.render(state.grid); } finally { busy = false; view.setNext(state.next); sync(); over(); } }; window.addEventListener('keydown', event => { if (event.repeat || ui.modalOpen || busy || event.ctrlKey || event.metaKey || event.altKey) return; if (event.key.toLowerCase() === 'u' && active) { event.preventDefault(); ui.onUndo(); } if (event.key === 'Escape' && active) ui.onMenu(); }); window.addEventListener('pagehide', () => { if (hasSave) save(); }); view.render(state.grid); view.setNext(state.next); ui.setMode('menu', hasSave); sync(); if (!storage.available) ui.storageUnavailable(); } void boot().catch(error => { console.error(error); const root = document.querySelector('#app')!; root.innerHTML = `

${t('errorTitle')}

${t('errorBody')}

`; document.getElementById('retry')!.addEventListener('click', () => location.reload()); });