import './style.css'; import { City } from './game/city.js'; import { Renderer } from './render/renderer.js'; import { Input } from './input.js'; import { UI } from './ui/ui.js'; import { Audio } from './audio.js'; import { SaveManager } from './save.js'; import { SETTINGS_KEY, START_MONEY } from './config.js'; import { clamp } from './utils.js'; function loadSettings() { try { return { sound: true, shadows: true, autosave: true, minimap: true, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') }; } catch { return { sound: true, shadows: true, autosave: true, minimap: true }; } } class Game { constructor() { this.settings = loadSettings(); this.audio = new Audio(this.settings); this.saves = null; this.speedIdx = 1; this.paused = false; this.lastNonzeroSpeed = 1; this._simAcc = 0; this._lastT = performance.now(); this._raf = null; this._parts = null; // {renderer, input, ui} this._unsubs = []; // pick up autosaved city if present let initial = null; const probeSaves = new SaveManager(null); if (probeSaves.hasAutosave()) { initial = probeSaves.loadAutosave(); } this.city = initial || new City(); this.start(this.city); document.addEventListener('visibilitychange', () => { if (document.hidden && !this.paused) this.togglePause(); }); } // ---------- lifecycle ---------- start(city) { city.disastersEnabled = this.settings.disasters !== false; this.teardown(); this.city = city; const container = document.getElementById('app'); let renderer; try { renderer = new Renderer(container, city, this.settings); } catch (err) { console.error(err); container.innerHTML = `

WebGL unavailable 😢

PolyCity needs hardware-accelerated WebGL.
Please enable it in your browser settings or try another browser.

`; throw err; } this.saves = new SaveManager(city); const ui = new UI(this); const input = new Input({ city, renderer, ui, audio: this.audio, game: this }); this._parts = { renderer, input, ui }; this._unsubs.push(city.on('autosave', () => { if (this.settings.autosave && this.saves.autosave()) { console.info('PolyCity autosaved.'); } })); this._unsubs.push(city.on('milestone', () => { // extra flourish handled by ui toast + sound })); city.markTilesChanged(); this._syncSoon(); if (!this._raf) this.loop(); } teardown() { for (const off of this._unsubs) off(); this._unsubs = []; if (this._parts) { this._parts.input.destroy?.(); this._parts.ui.destroy?.(); this._parts.renderer.dispose?.(); this._parts = null; } } newCity({ name, seed, money }) { const c = new City(seed, name); c.money = money ?? START_MONEY; this.speedIdx = 1; this.paused = false; this.start(c); this.ui?.toast(`Welcome to ${name}, Mayor!`, 'success'); this.ui?.resetQuests(); } loadFrom(cityInstance) { this.start(cityInstance); this.ui?.toast('City loaded.', 'success'); } get ui() { return this._parts?.ui; } get input() { return this._parts?.input; } get renderer() { return this._parts?.renderer; } // ---------- controls ---------- setSpeed(idx) { this.speedIdx = idx; this.paused = idx === 0; if (idx > 0) this.lastNonzeroSpeed = idx; this.ui?.setSpeedActive(idx); } togglePause() { if (this.paused) this.setSpeed(this.lastNonzeroSpeed); else this.setSpeed(0); } applySettings(patch) { Object.assign(this.settings, patch); localStorage.setItem(SETTINGS_KEY, JSON.stringify(this.settings)); if ('shadows' in patch && this._parts) this._parts.renderer.setShadows(patch.shadows); if ('disasters' in patch && this.city) this.city.disastersEnabled = patch.disasters !== false; } queryTile(tile, x, y) { const info = this.city.getTileInfo(tile.x, tile.z); if (info) this.ui?.showInfoPopup(info, x, y); } checkQuests() { this.ui?.checkQuests(); } _syncSoon() { if (this.city.tilesDirty && this._parts) { this._parts.renderer.sync(); this.city.tilesDirty = false; this.ui?.scheduleMinimap(); } } // ---------- main loop ---------- loop() { const tick = (now) => { this._raf = requestAnimationFrame(tick); const dt = Math.min(0.05, (now - this._lastT) / 1000); this._lastT = now; // simulation stepping const msPerMonth = [0, 5000, 1800, 700][this.speedIdx] || 0; if (!this.paused && msPerMonth > 0) { this._simAcc += dt * 1000; let guard = 0; while (this._simAcc >= msPerMonth && guard < 4) { this.city.tick(); this._simAcc -= msPerMonth; guard++; } if (guard >= 4) this._simAcc = 0; // avoid runaway catch-up } this._syncSoon(); if (this._parts) { this._parts.input.updatePan(dt); this._parts.renderer.frame(dt, this.speedIdx, this.paused); } }; this._raf = requestAnimationFrame(tick); } } // ---------------- boot ---------------- window.addEventListener('DOMContentLoaded', () => { try { window.POLYCITY = new Game(); } catch (e) { console.error(e); return; } const boot = document.getElementById('bootScreen'); setTimeout(() => { boot.classList.add('off'); setTimeout(() => boot.remove(), 700); }, 450); if ('serviceWorker' in navigator && import.meta.env.PROD) { window.addEventListener('load', () => { navigator.serviceWorker.register('./sw.js').catch(() => {}); }); } }); void clamp;