Files
arcane-tycoon/js/main.js
T
deepseek ac00687480 Arcane Tycoon — Heroes & Magic theme park tycoon game
Complete browser game inspired by OpenRCT2 with fantasy twist:
- Custom roller coaster designer with physics-based ratings + on-ride POV
- 10 animated rides, 7 shops, 16 scenery items, path network & guest AI
- Heroes guild vs monster invasions (5 classes, XP/gear/bosses)
- Magic spell system (8 spells), research tree, economy/marketing/loans
- Day-night cycle, weather, park rating, awards, 4 scenarios
- Save/load slots + autosave, procedural WebAudio SFX/music
- Isometric canvas renderer, minimap, diagnostics overlay
- Test suites: smoke(13), linkcheck, inputcheck, framecheck, rendercheck, flow
2026-08-23 06:59:21 +00:00

566 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============ 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 };