// ============ main.js — bootstrap, game loop, input ============ import { getState, newGame, advanceTime, recomputeStats, checkObjectives, parkValue, isNight } from './game/state.js'; import { updateGuests } from './game/guests.js'; import { updateStaff } from './game/staff.js'; import { updateRides } from './game/rides.js'; import { updateBattles } from './game/heroes.js'; import { tickSpells } from './game/magic.js'; import { tickResearch } from './game/state.js'; import { render, makeCamera, screenToWorld, worldToScreen, renderMinimap } from './render/renderer.js'; import { initUI, setTool, ui, updateHUD, updateToasts, hideContext, showContextFor, pickEntity, refreshPalette, setSpeed, showToast, alertToast } from './ui/ui.js'; import { openModal, closeModal, isModalOpen, maybeShowEndModal, openScenarioPicker, openHelp } from './ui/dialogs.js'; import { stopPOV } from './ui/povui.js'; import * as saveSys from './game/save.js'; import { sfx, unlockAudio, startMusic } from './core/audio.js'; import { TILE_W, TILE_H, SCENARIOS, PATH_TYPES } from './core/config.js'; import { fmtMoney } from './core/util.js'; import { buildDiscount } from './game/magic.js'; import { startCoasterSession, sessionActive, addPiece, undoPiece, cancelCoaster } from './game/coaster.js'; import { addRideObj, addShopObj, addSceneryObj } from './game/state.js'; import { cacheScenario, buildGuild } from './game/heroes.js'; const $ = id => document.getElementById(id); // --- roundRect polyfill (older Safari/Firefox lack CanvasPath.roundRect) --- if (typeof CanvasRenderingContext2D !== 'undefined' && !CanvasRenderingContext2D.prototype.roundRect) { CanvasRenderingContext2D.prototype.roundRect = function (x, y, w, h, r) { if (typeof r === 'number') r = [r, r, r, r]; else if (!Array.isArray(r)) r = [0, 0, 0, 0]; const [tl, tr, br, bl] = r.map(v => Math.min(v || 0, Math.abs(w) / 2, Math.abs(h) / 2)); this.moveTo(x + tl, y); this.lineTo(x + w - tr, y); this.quadraticCurveTo(x + w, y, x + w, y + tr); this.lineTo(x + w, y + h - br); this.quadraticCurveTo(x + w, y + h, x + w - br, y + h); this.lineTo(x + bl, y + h); this.quadraticCurveTo(x, y + h, x, y + h - bl); this.lineTo(x, y + tl); this.quadraticCurveTo(x, y, x + tl, y); return this; }; } const canvas = $('game'); const ctx = canvas.getContext('2d'); let cam = makeCamera(); let cw = 0, ch = 0; let mouse = { x: 0, y: 0, tile: null }; let dragBtn = -1, dragMoved = false, dragStart = null; let lastT = performance.now(); let statTimer = 0; function resize() { cw = canvas.width = innerWidth; ch = canvas.height = innerHeight; } addEventListener('resize', resize); resize(); // ---------------- boot / menu ---------------- initUI(); $('mm-new').addEventListener('click', () => { unlockAudio(); openScenarioPicker(id => { startNewGame(id); }); }); $('mm-how').addEventListener('click', () => { unlockAudio(); openHelp(); }); if (saveSys.hasAutosave()) { const saves = saveSys.listSaves(); const auto = saves.find(s => s.slot === 'auto'); if (auto?.exists) { $('mm-continue').classList.remove('hidden'); $('mm-continue').textContent = `↻ Continue — ${auto.parkName} (${auto.date})`; $('mm-continue').addEventListener('click', () => { unlockAudio(); const st = saveSys.loadFrom('auto'); if (st) onGameStarted(st); else alertToast('Autosave corrupted', 'bad'); }); } } function startNewGame(scenId) { const st = newGame(scenId); onGameStarted(st); } function onGameStarted(st) { const scen = SCENARIOS.find(s => s.id === st.scenario); cacheScenario(st, scen); st._speed = 1; st._paused = false; cam.x = st.map.entranceX + 2; cam.y = st.map.entranceY - 6; cam.zoom = Math.min(1.4, Math.max(0.8, innerWidth / 1500)); $('main-menu').classList.add('hidden'); ['topbar', 'toolbar', 'minimap-wrap'].forEach(id => $(id).classList.remove('hidden')); syncUi(); if (!st._musicStarted) { st._musicStarted = true; try { startMusic(); } catch { } } showToast('Welcome!', `${scen.name} — good luck!`, 'gold'); if (!st.uiHintsSeen.help) { st.uiHintsSeen.help = true; setTimeout(() => openHelp(), 600); } } window.__onGameLoaded = function () { const st = getState(); if (!st) return; onGameStarted(st); }; // ---------------- main loop ---------------- let _errCount = 0; addEventListener('error', ev => { console.error(ev.error || ev.message); const t = document.querySelector?.('#toasts'); if (!_errShown && t) { _errShown = true; import('./ui/ui.js').then(u => u.showToast('Runtime error', String(ev.message || ev.error).slice(0, 120), 'bad')); } }); function loop(now) { requestAnimationFrame(loop); try { loopBody(now); } catch (e) { console.error(e); if (_errCount < 5 && typeof document !== 'undefined') { _errCount++; try { import('./ui/ui.js').then(u => u.showToast(`Loop error #${_errCount}`, String(e.message || e).slice(0, 140), 'bad')); } catch { } } } } let __diag = null, __fpsN = 0, __fpsT = 0, __fps = 0, __frameNo = 0; addEventListener('keydown', e => { if (e.key === 'F3') { e.preventDefault(); const d = document.getElementById('diag-overlay'); if (d) d.style.display = d.style.display === 'none' ? 'block' : 'none'; } }); function ensureDiag() { if (__diag || typeof document === 'undefined') return __diag; __diag = document.createElement('div'); __diag.id = 'diag-overlay'; __diag.style.cssText = 'position:fixed;left:8px;top:52px;z-index:200;background:rgba(0,0,0,.72);color:#7CFC9A;font:11px/1.5 monospace;padding:6px 10px;border-radius:8px;pointer-events:none;white-space:pre;max-width:420px'; document.body?.appendChild ? document.body.appendChild(__diag) : null; return __diag; } function updateDiag(st) { const d = ensureDiag(); if (!d) return; __frameNo++; __fpsN++; if (performance.now() - __fpsT > 500) { __fps = Math.round(__fpsN * 1000 / (performance.now() - __fpsT)); __fpsT = performance.now(); __fpsN = 0; } const errs = Object.entries(renderErrorsRef()).map(([k, e]) => `ERR ${k}: ${String(e.message || e).slice(0, 90)}`).join('\n'); d.textContent = `FPS ${__fps} · frame ${__frameNo}\n` + `cam ${cam.x.toFixed(1)}, ${cam.y.toFixed(1)} · z${cam.zoom.toFixed(2)}\n` + `guests ${st ? st.guests.length : 0} · paused ${st?._paused} · speed ${st?._speed}\n` + `keys ${Object.keys(keys).filter(k => keys[k]).join('+') || '-'}\n` + (errs ? errs : ''); } import { renderErrors } from './render/renderer.js'; function renderErrorsRef() { return renderErrors; } function loopBody(now) { const dtReal = Math.min(0.06, (now - lastT) / 1000); lastT = now; const st = getState(); if (!st || !st.map) { // draw animated menu backdrop ctx.fillStyle = '#0b0e1a'; ctx.fillRect(0, 0, cw, ch); return; } handlePanKeys(dtReal); if (!st._paused && !isModalOpen() && !document.getElementById('pov-overlay')) { const mul = [1, 1, 2.2, 4][st._speed ?? 1]; const dt = dtReal * mul; simStep(st, dt); } render(ctx, st, cam, cw, ch, mouse); updateHUD(st); updateToasts(st); // periodic stats & objectives (every ~1.5s) statTimer -= dtReal; if (statTimer <= 0) { statTimer = 1.5; recomputeStats(st); checkObjectives(st); maybeShowEndModal(st, backToMenu); refreshContextIfOpen(st); maybeAutosave(st); } updateDiag(st); // minimap every ~0.6s mmTimer -= dtReal; if (mmTimer <= 0) { mmTimer = 0.6; const mm = $('minimap'); if (mm && !$('minimap-wrap').classList.contains('hidden')) { const mctx = mm.getContext('2d'); renderMinimap(mctx, st, cam, cw, ch); } } } requestAnimationFrame(loop); let mmTimer = 0.5; function maybeAutosave(st) { if (st.autosaveMonthCounter >= 1) { st.autosaveMonthCounter = 0; saveSys.autosave(st); } } function backToMenu() { location.reload(); } // ---------------- simulation step ---------------- function simStep(st, dt) { advanceTime(st, dt); tickSpells(st, dt); updateGuests(st, dt); updateStaff(st, dt); updateRides(st, dt); updateBattles(st, dt); tickResearch(st, dt); } // ---------------- camera controls ---------------- const keys = {}; addEventListener('keydown', e => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; keys[e.key.toLowerCase()] = true; const st = getState(); if (!st) return; switch (e.key.toLowerCase()) { case ' ': e.preventDefault(); togglePauseKb(); break; case '1': case '2': case '3': setSpeed(+e.key); break; case 't': import('./ui/dialogs.js').then(d => d.openResearch()); break; case 'f': import('./ui/dialogs.js').then(d => d.openFinance()); break; case 'g': import('./ui/dialogs.js').then(d => d.openGuildDialog()); break; case 'h': import('./ui/dialogs.js').then(d => d.openHelp()); break; case 'm': import('./core/audio.js').then(a => a.isMusicOn() ? a.stopMusic() : a.startMusic()); break; case 'escape': onEscape(); break; case 's': if (e.ctrlKey) { e.preventDefault(); import('./ui/dialogs.js').then(d => d.openSaveLoad()); } break; case 'z': if (sessionActive(st)) { undoPiece(st); refreshPalette(); } break; default: if (/^[qew]$/i.test(e.key)) { /* handled in pan */ } } }); addEventListener('keyup', e => { keys[e.key.toLowerCase()] = false; }); function togglePauseKb() { import('./ui/ui.js').then(u => u.togglePause()); } function onEscape() { const st = getState(); if (isModalOpen()) { closeModal(); return; } if (document.getElementById('pov-overlay')) { stopPOV(); return; } if (sessionActive(st)) { cancelCoaster(st); setTool('select'); return; } if (ui.tool !== 'select') { setTool('select'); } else hideContext(); } function handlePanKeys(dt) { const spd = 420 * dt / cam.zoom; // Screen-relative panning: in this iso projection, // screen-up = world (-x, -y) · screen-down = (+x, +y) // screen-left = world (-x, +y) · screen-right = (+x, -y) const k = spd * 0.72; // ≈1/√2 per axis so screen speed feels right if (keys['w'] || keys['arrowup']) { cam.x -= k; cam.y -= k; } if ((keys['s'] && !keys['control']) || keys['arrowdown']) { cam.x += k; cam.y += k; } if (keys['a'] || keys['arrowleft']) { cam.x -= k; cam.y += k; } if (keys['d'] || keys['arrowright']) { cam.x += k; cam.y -= k; } if (keys['q']) zoomAt(cw / 2, ch / 2, 1 - dt * 1.2); if (keys['e']) zoomAt(cw / 2, ch / 2, 1 + dt * 1.2); clampCam(); } function clampCam() { const st = getState(); if (!st?.map) return; const n = st.map.size; cam.x = Math.max(-10, Math.min(n + 10, cam.x)); cam.y = Math.max(-10, Math.min(n + 10, cam.y)); } function zoomAt(px, py, factor) { cam.zoom = Math.max(0.45, Math.min(2.4, cam.zoom * factor)); clampCam(); } canvas.addEventListener('wheel', e => { e.preventDefault(); zoomAt(e.clientX, e.clientY, e.deltaY < 0 ? 1.12 : 0.89); }, { passive: false }); // ---------------- mouse ---------------- canvas.addEventListener('contextmenu', e => e.preventDefault()); canvas.addEventListener('pointerdown', e => { unlockAudio(); const st = getState(); if (!st?._ui) syncUi(); dragBtn = e.button; dragMoved = false; dragStart = { x: e.clientX, y: e.clientY, camX: cam.x, camY: cam.y }; }); canvas.addEventListener('pointermove', e => { mouse.x = e.clientX; mouse.y = e.clientY; const st = getState(); if (st?.map) { const [wx, wy] = screenToWorld(cam, cw, ch, e.clientX, e.clientY); mouse.tile = st.map.inBounds(wx, wy) ? [wx, wy] : null; mouse.world = [wx, wy]; } if (dragStart && dragBtn === 0 && (ui.tool === 'select' || e.shiftKey)) { const dx = e.clientX - dragStart.x, dy = e.clientY - dragStart.y; if (Math.hypot(dx, dy) > 6) { dragMoved = true; // iso-consistent pan: screen dx maps to both axes cam.x = dragStart.camX - (dx / (TILE_W / 2) + dy / (TILE_H / 2)) / 2 / cam.zoom; cam.y = dragStart.camY + (dx / (TILE_W / 2) - dy / (TILE_H / 2)) / 2 / cam.zoom; clampCam(); } } // path painting while dragging if (dragStart && dragBtn === 0 && st && ui.tool === 'path' && ui.sel?.kind === 'path') { paintPathAt(st, mouse.tile); } if (dragStart && dragBtn === 0 && st && ui.tool === 'terrain' && ui.sel?.kind === 'terrain') { paintTerrainAt(st, mouse.tile); } }); canvas.addEventListener('pointerup', e => { const st = getState(); dragBtn = -1; if (!st || !st.map) { dragStart = null; return; } const wasDrag = dragMoved; dragStart = null; if (e.button === 2) { rightClick(st); return; } if (e.button !== 0) return; if (wasDrag && (ui.tool === 'select' && !e.shiftKey)) return; // was a pan leftClick(st, e); }); function syncUi() { const st = getState(); if (st) st._ui = { tool: ui.tool, sel: ui.sel, coasterPiece: ui.coasterPiece, heroSubtool: ui.heroSubtool }; } function rightClick(st) { if (sessionActive(st)) { undoPiece(st); refreshPalette(); return; } if (ui.tool !== 'select') { setTool('select'); } } function leftClick(st, e) { const t = mouse.tile; if (!t) return; const [x, y] = t; const tool = ui.tool; if (tool === 'select') { const [wx, wy] = mouse.world; const ent = pickEntity(st, wx + 0.5, wy + 0.5); if (ent && ent.kind !== 'guildBuilding') showContextFor(ent); else if (ent?.kind === 'guildBuilding') import('./ui/dialogs.js').then(d => d.openGuildDialog()); else hideContext(); sfx.click(); return; } if (tool === 'path') { if (ui.sel?.kind === 'doze') dozeTile(st, x, y); else if (ui.sel?.kind === 'path') paintPathAt(st, t); return; } if (tool === 'terrain') { paintTerrainAt(st, t); return; } if (tool === 'coaster') { if (!sessionActive(st)) { const res = startCoasterSession(st, x, y); if (res.error) { sfx.error(); alertToast(res.error, 'bad'); } else { sfx.place(); ui.coasterPiece = 'straight'; refreshPalette(); syncUi(); } } else { const res = addPiece(st, ui.coasterPiece); if (res.error) { sfx.error(); alertToast(res.error, 'bad'); } else sfx.place(); refreshPalette(); } return; } if (tool === 'ride' || tool === 'shop') { placeBuilding(st, t); return; } if (tool === 'scenery') { placeScenery(st, t); return; } if (tool === 'heroes') { if (ui.heroSubtool === 'guild') { const g = buildGuild(st, x, y); if (g) { st.cash -= 1500; st.finance.current['construction'] = (st.finance.current['construction'] || 0) - 1500; sfx.openRide(); ui.heroSubtool = null; syncUi(); refreshPalette(); showToast('Heroes Guild built!', 'Now recruit your first heroes.', 'good'); } else { sfx.error(); alertToast('Needs a clear 2×2 spot touching a path', 'bad'); } } return; } } // ---------------- placement helpers ---------------- function payBuild(st, cost) { cost = Math.round(cost * buildDiscount(st)); if (!st.sandbox && st.cash < cost) { sfx.error(); alertToast(`Not enough money (${fmtMoney(cost)})`, 'bad'); return false; } st.cash -= cost; st.finance.current['construction'] = (st.finance.current['construction'] || 0) - cost; return true; } function paintPathAt(st, t) { if (!t) return; const [x, y] = t; const i = st.map.idx(x, y); if (!st.map.isBuildable(x, y) || st.map.objects[i]) return; const ptId = ui.sel.pt || 'pavement'; if (ptId === 'cobble' && !st.research.unlocked.includes('cobble')) return; if (st.map.pathType[i] === (ptId === 'cobble' ? 2 : 1)) return; const cost = PATH_TYPES[ptId].cost; if (!payBuild(st, cost)) return; st.map.pathType[i] = ptId === 'cobble' ? 2 : 1; st.map.litter[i] = 0; sfx.click(); } function paintTerrainAt(st, t) { if (!t) return; const [x, y] = t; const i = st.map.idx(x, y); if (st.map.objects[i] || st.map.pathType[i]) return; if (!payBuild(st, 20)) return; const mapT = { grass: 0, sand: 1, rock: 2, water: 3 }[ui.sel.t] ?? 0; st.map.terrain[i] = mapT; } function dozeTile(st, x, y) { const o = st.map.getObject(x, y); if (o?.kind === 'scenery') { const sc = st.sceneryList.find(s => s.id === o.id); if (sc) { import('./game/state.js').then(m => { m.removeScenery(st, sc); st.cash += Math.round((sc.def.cost || 20) * 0.5); sfx.demolish(); }); } return; } if (o?.kind === 'shop') { const sh = st.shops.find(s => s.id === o.id); if (sh) { st.map.clearObject(sh.x, sh.y); st.shops = st.shops.filter(q => q !== sh); st.cash += Math.round(sh.def.cost * 0.5); hideContext(); sfx.demolish(); } return; } if (o?.kind === 'ride' || o?.kind === 'track') { alertToast('Use the ride panel → Demolish for rides', 'bad'); return; } const i = st.map.idx(x, y); if (st.map.pathType[i]) { st.map.pathType[i] = 0; st.cash += 3; sfx.demolish(); } } function entranceAdjacentPathOk(st, x, y, w, h) { for (let yy = -1; yy <= h; yy++) { for (let xx = -1; xx <= w; xx++) { const inside = xx >= 0 && yy >= 0 && xx < w && yy < h; if (inside) continue; if ((xx === -1 || xx === w) && (yy === -1 || yy === h)) continue; if (st.map.isPath(x + xx, y + yy)) { // perimeter cell adjacent to path becomes entrance anchor return { px: Math.min(w - 1, Math.max(0, xx)), py: Math.min(h - 1, Math.max(0, yy)), pathX: x + xx, pathY: y + yy }; } } } return null; } function placeBuilding(st, t) { const def = ui.sel; if (!def) return; const [x, y] = t; const w = def.w, h = def.h; // footprint free? for (let yy = 0; yy < h; yy++) for (let xx = 0; xx < w; xx++) { if (!st.map.isBuildable(x + xx, y + yy) || st.map.occupied(x + xx, y + yy)) { sfx.error(); alertToast('Blocked location', 'bad'); return; } } const ent = entranceAdjacentPathOk(st, x, y, w, h); if (!ent) { sfx.error(); alertToast(`${def.name} must touch a path so guests can enter!`, 'bad'); return; } if (!payBuild(st, def.cost)) return; if (def.kind === 'ride') { const ride = addRideObj(st, def.id, x, y, { entranceX: x + ent.px, entranceY: y + ent.py }); ride.status = 'closed'; sfx.place(); showToast(def.name + ' built!', 'Test it, then Open when ready.'); } else { addShopObj(st, def.id, x, y); sfx.place(); } } function placeScenery(st, t) { const def = ui.sel; if (!def) return; const [x, y] = t; const size = def.size || 1; for (let yy = 0; yy < size; yy++) for (let xx = 0; xx < size; xx++) { if (!st.map.isBuildable(x + xx, y + yy) || st.map.occupied(x + xx, y + yy)) { sfx.error(); alertToast('Blocked location', 'bad'); return; } } if (!payBuild(st, def.cost)) return; addSceneryObj(st, def.id, x, y); import('./game/state.js').then(m => m.recomputeManaCap(st)); sfx.place(); } // ---------------- context panel live refresh ---------------- function refreshContextIfOpen(st) { const ent = st._uiSelEntity; if (!ent) return; const panel = $('context-panel'); if (panel.classList.contains('hidden')) return; // rebuild content only for dynamic entities if (ent.kind === 'guest' || ent.kind === 'hero' || ent.kind === 'monster' || ent.kind === 'ride' || ent.kind === 'shop') { import('./ui/ui.js').then(u => u.showContextFor(ent)); } } // minimap click-to-move $('minimap').addEventListener('pointerdown', e => { const st = getState(); if (!st?.map) return; const rect = e.target.getBoundingClientRect(); const fx = (e.clientX - rect.left) / rect.width; const fy = (e.clientY - rect.top) / rect.height; cam.x = fx * st.map.size; cam.y = fy * st.map.size; clampCam(); }); export { cam };