import '../App.css'; import { Analytics, makeAnalyticsGameInfo } from '../analytics'; import type { AnalyticsTransport } from '../analytics/AnalyticsTransport'; import { GameAnalyticsTransport } from '../analytics/GameAnalyticsTransport'; import { NullAnalyticsTransport } from '../analytics/NullAnalyticsTransport'; import { GameStore } from '../game/GameStore'; import { clearSave, hasSave } from '../game/persistence'; import { GameScreen } from './GameScreen'; import { createMainMenu } from './MainMenu'; import { createSettingsScreen } from './SettingsScreen'; /** Top-level screen controller (menu <-> game) — the plain-TS replacement for App.tsx. Owns the * GameStore/Analytics singletons for the page's whole lifetime, same as the original module-level * singletons (no React StrictMode double-invocation to guard against here, since nothing * constructs this more than once — see main.ts). */ export class App { private readonly root: HTMLElement; private readonly store: GameStore; private readonly analytics: Analytics; private gameScreen: GameScreen | null = null; constructor(root: HTMLElement) { this.root = root; const gaGameKey = import.meta.env.VITE_GAMEANALYTICS_GAME_KEY; const gaGameSecret = import.meta.env.VITE_GAMEANALYTICS_GAME_SECRET; let transport: AnalyticsTransport; if (!gaGameKey || !gaGameSecret) { console.warn('VITE_GAMEANALYTICS_GAME_KEY/VITE_GAMEANALYTICS_GAME_SECRET not set — analytics disabled (see .env.example).'); transport = new NullAnalyticsTransport(); } else { transport = new GameAnalyticsTransport(gaGameKey, gaGameSecret); } this.analytics = new Analytics(transport, () => makeAnalyticsGameInfo(this.store)); this.analytics.start(); this.store = new GameStore(this.analytics); // Always starts on the menu, regardless of whether a save exists — loading straight into a // resumed game on every page (re)load isn't what was asked for. this.showMenu(); } private showMenu(): void { this.teardownGameScreen(); const menu = createMainMenu( this.analytics, () => this.handleNewGame(), () => this.handleContinue(), () => this.showSettings(), ); this.root.replaceChildren(menu); } private showSettings(): void { const settings = createSettingsScreen( this.store.seed, () => this.showMenu(), (seed) => this.handleNewGame(seed), ); this.root.replaceChildren(settings); } private showGame(): void { this.gameScreen = new GameScreen( this.store, () => this.handleExitToMenu(), () => this.handleDeath(), ); this.root.replaceChildren(this.gameScreen.el); // Only now is `el` actually attached to the document — GameScreen's canvas sizes itself from // the map div's clientWidth/Height, which reads 0 on a still-detached element. this.gameScreen.mount(); } /** Starts a fresh game — a freshly random seed by default, or an explicit one when called from * Settings' seed field (which also acts as "restart the game with this seed"). */ private handleNewGame(seed?: number): void { // A resumable save existing means there's a live (not-dead) game being abandoned by starting // fresh instead of continuing it — record that as the last level's failure. A death already // records its own failure at the moment it happens (see GameStore.landRabbitStrike) and clears // the save there too, so this naturally doesn't double-fire right after a death. if (hasSave()) this.analytics.death('restarted'); clearSave(); if (seed !== undefined) this.store.setNextSeed(seed); this.showGame(); } private handleContinue(): void { this.showGame(); } private handleExitToMenu(): void { this.store.save(); this.showMenu(); } // Called after a death overlay is acknowledged: the store already wiped the save itself the // moment the character was caught, so this just returns to the menu without saving over it. private handleDeath(): void { this.showMenu(); } private teardownGameScreen(): void { this.gameScreen?.destroy(); this.gameScreen = null; } }