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:
+565
@@ -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 };
|
||||
Reference in New Issue
Block a user