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
566 lines
19 KiB
JavaScript
566 lines
19 KiB
JavaScript
// ============ 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 };
|