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
This commit is contained in:
2026-08-23 06:59:21 +00:00
commit ac00687480
30 changed files with 6772 additions and 0 deletions
+565
View File
@@ -0,0 +1,565 @@
// ============ 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 };