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
+402
View File
@@ -0,0 +1,402 @@
// ============ dialogs.js — modal dialogs ============
import { getState, objectiveProgress } from '../game/state.js';
import { el, fmtMoney, fmtNum, fmtDate } from '../core/util.js';
import { SCENARIOS, AWARDS_POOL, UNLOCKS, RESEARCH_TRACKS } from '../core/config.js';
import { unlocksByTrack, buyUnlock } from '../game/research.js';
import { CAMPAIGNS, startCampaign, takeLoan, repayLoan, FIN_CATEGORIES } from '../game/economy.js';
import { sfx, setVolumes, getVolumes, startMusic, stopMusic, isMusicOn } from '../core/audio.js';
import * as saveSys from '../game/save.js';
import { refreshPalette, showToast } from './ui.js';
const $ = id => document.getElementById(id);
let currentClose = null;
export function openModal(title, content, opts = {}) {
closeModal();
const root = $('modal-root');
root.innerHTML = '';
root.classList.remove('hidden');
const closeBtn = el('button', {}, '✕');
const box = el('div', { class: 'modal' + (opts.wide ? ' wide' : '') },
el('div', { class: 'modal-head' }, el('span', {}, title), closeBtn),
el('div', { class: 'modal-body' }, content),
);
if (opts.foot) box.appendChild(el('div', { class: 'modal-foot' }, opts.foot));
closeBtn.addEventListener('click', closeModal);
root.appendChild(box);
root.onclick = e => { if (e.target === root) closeModal(); };
currentClose = opts.onOpen || null;
return { close: closeModal, box };
}
export function closeModal() {
$('modal-root').classList.add('hidden');
$('modal-root').innerHTML = '';
currentClose = null;
}
export function isModalOpen() { return !$('modal-root').classList.contains('hidden'); }
export function refreshOpenDialogs() {
if (typeof refreshCurrent === 'function') refreshCurrent();
}
let refreshCurrent = null;
// ---------------- Research ----------------
export function openResearch() {
const st = getState();
const content = el('div');
content.appendChild(el('div', { style: 'margin-bottom:10px;color:#cdd6f4;font-size:.9rem' },
`Research points: `, el('b', { style: 'color:var(--accent)' }, fmtNum(st.research.rp)),
` — earned passively from open rides & magic scenery.`));
if (st.sandbox) content.appendChild(el('div', { class: 'ctx-row' }, 'Sandbox: everything unlocked.'));
const byTrack = unlocksByTrack();
for (const [tid, track] of Object.entries(RESEARCH_TRACKS)) {
const items = byTrack[tid] || [];
const box = el('div', { class: 'res-track' },
el('h4', {}, `${track.icon} ${track.name}`));
const row = el('div', { class: 'res-items' });
for (const u of items) {
const owned = st.sandbox || st.research.unlocked.includes(u.key);
const item = el('div', { class: 'res-item ' + (owned ? 'done' : (st.research.rp >= u.rp ? 'avail' : 'avail cant')) },
owned ? '✔ ' : '', u.label, owned ? '' : el('span', { style: 'color:var(--gold)' }, ` · ${u.rp} RP`));
if (!owned) item.addEventListener('click', () => {
if (buyUnlock(st, u.key)) { sfx.cash(); openResearch(); refreshPalette(); }
else sfx.error();
});
row.appendChild(item);
}
box.appendChild(row);
content.appendChild(box);
}
openModal('🔬 Research Laboratory', content);
}
// ---------------- Finance ----------------
export function openFinance() {
const st = getState();
const content = el('div');
// summary
const cur = st.finance.current;
const table = el('table', { class: 'fin' });
table.appendChild(el('tr', {}, el('th', {}, 'Category'), el('th', {}, 'This month')));
let net = 0;
for (const [k, label] of FIN_CATEGORIES) {
const v = cur[k];
if (!v) continue;
net += v;
table.appendChild(el('tr', {},
el('td', {}, label),
el('td', { class: v >= 0 ? 'pos' : 'neg' }, fmtMoney(v, true))));
}
table.appendChild(el('tr', {}, el('td', { style: 'color:var(--gold)' }, 'Net'), el('td', { class: net >= 0 ? 'pos' : 'neg', style: 'color:inherit' }, fmtMoney(net, true))));
content.appendChild(table);
// history sparkline
const hist = st.finance.history.slice(-12).map(h2 => Object.values(h2).reduce((a, b) => a + b, 0));
if (hist.length) {
const cvs = el('canvas', { width: 420, height: 80 });
cvs.style.cssText = 'width:100%;margin-top:12px;background:var(--bg1);border-radius:10px;border:1px solid var(--panel-brd)';
requestAnimationFrame(() => drawSpark(cvs, hist));
content.appendChild(el('div', { style: 'font-size:.8rem;color:var(--ink-dim);margin-top:8px' }, 'Monthly profit history'));
content.appendChild(cvs);
}
// loan
const loanBox = el('div', { style: 'display:flex;gap:8px;align-items:center;margin-top:14px;flex-wrap:wrap' });
loanBox.appendChild(el('span', { style: 'font-size:.9rem' }, `🏦 Loan: ${fmtMoney(st.loan)} / limit ${fmtMoney(st.loanLimit)}`));
const b1 = el('button', { class: 'btn' }, '+$5,000 loan');
b1.addEventListener('click', () => { takeLoan(st, 5000); openFinance(); });
const b2 = el('button', { class: 'btn' }, '-$5,000 repay');
b2.addEventListener('click', () => { repayLoan(st, 5000); openFinance(); });
loanBox.appendChild(b1); loanBox.appendChild(b2);
content.appendChild(loanBox);
// marketing
content.appendChild(el('h4', { style: 'margin:16px 0 6px;color:var(--gold)' }, '📣 Marketing campaigns'));
for (const c of CAMPAIGNS) {
const row = el('div', { class: 'set-row' },
el('span', {}, `${c.name}${fmtMoney(c.cost)}, +${c.pull} guests/s for ${c.weeks} weeks`));
const b = el('button', { class: 'btn primary' }, 'Start');
b.addEventListener('click', () => {
if (startCampaign(st, c.id)) { sfx.cash(); openFinance(); } else sfx.error();
});
row.appendChild(b);
content.appendChild(row);
}
const active = st.campaigns.filter(c => c.weeksLeft > 0);
if (active.length) content.appendChild(el('div', { style: 'font-size:.78rem;color:var(--good);margin-top:4px' },
'Active: ' + active.map(c => `${c.name} (${c.weeksLeft}w)`).join(', ')));
openModal('📈 Finances', content);
}
function drawSpark(cvs, data) {
const ctx = cvs.getContext('2d');
const W = cvs.width, H = cvs.height;
ctx.clearRect(0, 0, W, H);
const max = Math.max(...data.map(Math.abs), 100);
const bw = W / data.length;
data.forEach((v, i) => {
const h = Math.abs(v) / max * (H / 2 - 6);
ctx.fillStyle = v >= 0 ? '#57d97a' : '#ff6b6b';
if (v >= 0) ctx.fillRect(i * bw + 2, H / 2 - h, bw - 4, h);
else ctx.fillRect(i * bw + 2, H / 2, bw - 4, h);
});
ctx.strokeStyle = 'rgba(255,255,255,.15)';
ctx.beginPath(); ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2); ctx.stroke();
}
// ---------------- Hero guild ----------------
export function openGuildDialog() {
const st = getState();
const content = el('div');
if (!st.guild) {
content.appendChild(el('div', { style: 'line-height:1.6' },
el('p', {}, 'You need a Heroes Guild before you can recruit heroes.'),
el('p', { style: 'color:#9aa4c0;font-size:.85rem' }, 'Open the ⚔️ Heroes build tab and place the Guild Hall (2×2) next to a path.')));
openModal('🛡️ Heroes Guild', content);
return;
}
import('../game/heroes.js').then(H => {
const capEl = H.guildCap(st);
content.appendChild(el('div', { style: 'display:flex;justify-content:space-between;font-size:.85rem;margin-bottom:10px' },
el('span', {}, `Roster ${st.heroes.length}/${capEl}`),
el('span', {}, `⚔️ Kills ${st.heroStats.kills} · 💰 Loot ${fmtMoney(st.heroStats.lootGold)} · 🛡 Repelled ${st.invasion.repelled}`)));
const cards = el('div', { class: 'hero-cards' });
for (const h of st.heroes) {
const card = el('div', { class: 'hero-card' },
el('div', { class: 'portrait' }, h.alive ? h.def.icon : '💀'),
el('div', { style: 'flex:1' },
el('div', { style: 'display:flex;justify-content:space-between' },
el('b', {}, h.name), el('span', { style: 'color:var(--ink-dim)' }, `Lv ${h.lvl} · ${h.def.name}`)),
el('div', { class: 'hp-bar' }, el('div', { style: `width:${Math.max(0, h.hp / h.maxHp) * 100}%` })),
el('div', { class: 'xp-bar' }, el('div', { style: `width:${(h.xp / h.xpNext) * 100}%` })),
el('div', { style: 'font-size:.72rem;color:var(--ink-dim);margin-top:3px' },
h.alive ? `${Math.round(h.hp)}/${h.maxHp} hp · ⚔${Math.round(h.def.dmg * (1 + (h.lvl - 1) * .1) * (1 + h.gear * .25)).toFixed(0)} · kills ${h.kills}` : `Reviving in ${Math.ceil(h.revivingT)}s`),
));
cards.appendChild(card);
}
content.appendChild(cards);
// recruit row
content.appendChild(el('h4', { style: 'margin:14px 0 6px;color:var(--gold)' }, 'Recruit'));
const recRow = el('div', { class: 'res-items' });
Object.values(HeroClassesSafe()).forEach(cls => {
const locked = !H.clsUnlocked(st, cls.id);
const b = el('button', { class: 'btn' + (locked ? '' : ''), disabled: locked || st.heroes.length >= capEl ? 'true' : null },
`${cls.icon} ${cls.name}${fmtMoney(cls.cost)}${locked ? ' 🔒' : ''}`);
b.title = cls.desc;
b.addEventListener('click', () => {
const res = H.recruitHero(st, cls.id);
if (res.error) { sfx.error(); showToast('!', res.error, 'bad'); }
else { sfx.levelup(); openGuildDialog(); }
});
recRow.appendChild(b);
});
content.appendChild(recRow);
openModal('🛡️ Heroes Guild Hall', content, { wide: false });
});
}
import { HERO_CLASSES } from '../core/config.js';
function HeroClassesSafe() { return HERO_CLASSES; }
// ---------------- Objectives ----------------
export function openObjectives() {
const st = getState();
const scen = SCENARIOS.find(s => s.id === st.scenario);
const content = el('div');
content.appendChild(el('div', { style: 'margin-bottom:10px;font-size:.9rem' },
`🏰 ${st.park.name}${scen?.name || ''}`));
if (!scen?.goals.length) {
content.appendChild(el('p', { style: 'color:#9aa4c0' }, 'Sandbox mode: no objectives — build your dream!'));
} else {
for (const g of scen.goals) {
const prog = objectiveProgress(st, g);
const done = prog >= g.value;
const row = el('div', { style: 'margin-bottom:8px' },
el('div', { style: 'display:flex;justify-content:space-between;font-size:.88rem' },
el('span', {}, (done ? '✔ ' : '☐ ') + g.text),
el('b', { style: done ? 'color:var(--good)' : 'color:var(--ink-dim)' }, `${g.type === 'coasterExcite' && g.value < 10 ? prog.toFixed(1) : fmtNum(Math.min(prog, g.value))}/${fmtNum(g.value)}`)),
el('div', { class: 'bar', style: 'height:6px' }, el('div', { style: `width:${Math.min(100, prog / g.value * 100)}%;background:${done ? 'var(--good)' : 'var(--accent)'}` })),
);
content.appendChild(row);
}
}
if (st.awards.length) {
content.appendChild(el('h4', { style: 'margin:14px 0 6px;color:var(--gold)' }, '🏆 Awards'));
for (const aid of st.awards) {
const a = AWARDS_POOL.find(x => x.id === aid);
if (a) content.appendChild(el('div', { style: 'font-size:.85rem' }, `🏅 ${a.name}`));
}
}
openModal('🏆 Objectives & Awards', content);
}
// ---------------- Park settings ----------------
export function openParkSettings() {
const st = getState();
const content = el('div');
// park name
const nameIn = el('input', { type: 'text', value: st.park.name, maxlength: '30' });
nameIn.style.width = '220px';
nameIn.addEventListener('change', () => { st.park.name = nameIn.value || 'Unnamed Park'; });
content.appendChild(rowSetting('Park name', nameIn));
// entrance fee
const feeCtl = el('span');
const mkFee = () => {
feeCtl.innerHTML = '';
const minus = el('button', { class: 'btn' }, '');
const plus = el('button', { class: 'btn' }, '+');
minus.addEventListener('click', () => { st.park.entranceFee = Math.max(0, st.park.entranceFee - 1); mkFee(); });
plus.addEventListener('click', () => { st.park.entranceFee++; mkFee(); });
feeCtl.append(minus, el('span', { style: 'padding:0 10px;color:var(--gold)' }, fmtMoney(st.park.entranceFee)), plus);
};
mkFee();
content.appendChild(rowSetting('Entrance fee', feeCtl));
// open/close
const openB = el('button', { class: 'btn ' + (st.park.open ? 'danger' : 'primary') }, st.park.open ? 'Close park' : 'Open park');
openB.addEventListener('click', () => { st.park.open = !st.park.open; openParkSettings(); });
content.appendChild(rowSetting('Park status', openB));
content.appendChild(el('h4', { style: 'margin:14px 0 4px;color:var(--gold)' }, '🔊 Audio'));
const vols = getVolumes();
const mkVol = (label, key) => {
const inp = el('input', { type: 'range', min: '0', max: '1', step: '0.05', value: String(vols[key]) });
inp.addEventListener('input', () => { setVolumes({ [key]: +inp.value }); });
return rowSetting(label, inp);
};
content.appendChild(mkVol('Master volume', 'master'));
content.appendChild(mkVol('Music', 'music'));
content.appendChild(mkVol('Effects', 'sfx'));
const musicB = el('button', { class: 'btn' }, isMusicOn() ? '⏹ Stop music' : '🎵 Play music');
musicB.addEventListener('click', () => { isMusicOn() ? stopMusic() : startMusic(); openParkSettings(); });
content.appendChild(rowSetting('Ambient music', musicB));
content.appendChild(el('h4', { style: 'margin:14px 0 4px;color:var(--gold)' }, '💾 Data'));
const expB = el('button', { class: 'btn' }, 'Export save to file');
expB.addEventListener('click', () => saveSys.exportSave(st));
content.appendChild(rowSetting('Export', expB));
const quitB = el('button', { class: 'btn danger' }, 'Quit to Main Menu');
quitB.addEventListener('click', () => {
saveSys.autosave(st);
location.reload();
});
content.appendChild(rowSetting('Session', quitB));
openModal('⚙️ Park Settings', content);
}
function rowSetting(label, ctl) {
return el('div', { class: 'set-row' }, el('span', {}, label), ctl);
}
// ---------------- Save/Load ----------------
export function openSaveLoad() {
const st = getState();
const content = el('div');
const list = saveSys.listSaves();
for (const s of list) {
const row = el('div', { class: 'set-row' },
el('span', {}, s.exists
? `${s.slot === 'auto' ? '⟳ Autosave' : '📁 ' + s.slot}: ${s.parkName}${s.date}, ${s.guests} guests`
: `${s.slot === 'auto' ? '⟳ Autosave' : '📁 ' + s.slot}: empty`));
const btns = el('span', {});
const sb = el('button', { class: 'btn primary' }, 'Save');
sb.addEventListener('click', () => { saveSys.saveTo(st, s.slot); showToast('Saved!', `Game saved to ${s.slot}`, 'good'); openSaveLoad(); });
btns.appendChild(sb);
if (s.exists && s.slot !== 'auto') {
const lb = el('button', { class: 'btn', style: 'margin-left:6px' }, 'Load');
lb.addEventListener('click', () => {
const loaded = saveSys.loadFrom(s.slot);
if (loaded) { closeModal(); showToast('Loaded!', 'Welcome back.', 'good'); window.__onGameLoaded?.(); }
else showToast('Load failed', 'Corrupt save?', 'bad');
});
btns.appendChild(lb);
}
row.appendChild(btns);
content.appendChild(row);
}
// import file
const fileIn = el('input', { type: 'file', accept: '.json', style: 'display:none' });
fileIn.addEventListener('change', async () => {
const f = fileIn.files[0];
if (!f) return;
const text = await f.text();
const loaded = saveSys.importSaveText(text);
if (loaded) { closeModal(); showToast('Imported!', 'Save file loaded.', 'good'); window.__onGameLoaded?.(); }
else showToast('Import failed', 'Invalid file', 'bad');
});
const impB = el('button', { class: 'btn', style: 'margin-top:10px' }, '📂 Import from file…');
impB.addEventListener('click', () => fileIn.click());
content.appendChild(impB);
content.appendChild(fileIn);
openModal('💾 Save / Load', content);
}
// ---------------- Help ----------------
export function openHelp() {
const c = el('div', { class: 'help-cols' });
c.innerHTML = `
<h4>🎯 Goal</h4>
<p>Build a magical theme park! Complete scenario objectives (top-right 🏆): attract guests, raise your rating, repel monster invasions and build thrilling custom coasters.</p>
<h4>🧱 Basics</h4>
<p>Lay <b>Paths</b> from the entrance gate. Guests arrive automatically and wander paths. Add <b>Shops</b> (food/drinks/toilets!) beside paths and <b>Rides</b> with their entrance touching a path.</p>
<h4>🎢 Custom Coaster</h4>
<p>Pick the Coaster tab → place the <b>Station</b> on a flat tile next to a path. Add pieces (slopes, curves, loops!) until the circuit returns to the station heading the same way, then press <b>Finish</b>. Test it, then Open. Bigger drops & loops = more excitement (and intensity!).</p>
<h4>🧑‍💼 Staff</h4>
<p>Handymen clean litter & vomit, Mechanics fix breakdowns, Guards deter vandals, Jesters entertain queues. Wages are charged monthly.</p>
<h4>⚔️ Heroes & Monsters</h4>
<p>Build the <b>Heroes Guild</b> (Heroes tab), then recruit Knights, Rangers, Mages… When monsters invade (watch the warnings), heroes auto-engage. Kills earn gold, XP and mana. Buy gear upgrades from a hero's panel.</p>
<h4>✨ Magic</h4>
<p>Mana regenerates over time; <b>Ley Pools</b>, Rune Stones and Glowcaps raise max mana. Cast spells like Joy Aura (happiness), Monster Bane or Warding Sigil (blocks invasions).</p>
<h4>🔬 Research</h4>
<p>Earn RP from rides & magic scenery, spend it in the 🔬 lab to unlock advanced rides, shops, spells and hero classes.</p>
<h4>💰 Economy</h4>
<p>Income: entrance fees, ride tickets, shop sales. Costs: construction, monthly wages & running costs. Set ticket prices per ride (price ≈ excitement works well). Loans & marketing live under 📈.</p>
<h4>⌨️ Shortcuts</h4>
<p><kbd>WASD/arrows</kbd> pan · <kbd>Q/E</kbd> or wheel zoom · <kbd>Space</kbd> pause · <kbd>1-3</kbd> speed · <kbd>T</kbd> research · <kbd>F</kbd> finance · <kbd>G</kbd> guild · <kbd>H</kbd> help · <kbd>Esc</kbd> cancel/close · Right-click cancels placement.</p>
`;
openModal('📖 How to Play', c, { wide: true });
}
// ---------------- Scenario picker ----------------
export function openScenarioPicker(onPick) {
const grid = el('div', { class: 'scen-grid' });
for (const sc of SCENARIOS) {
const card = el('div', { class: 'scen-card' },
el('h3', {}, `${sc.icon} ${sc.name}`),
el('div', { class: 'diff' }, sc.diff),
el('p', {}, sc.blurb),
el('ul', { class: 'scen-goals' }, sc.goals.map(g => el('li', {}, '• ' + g.text))),
sc.sandbox ? null : el('div', { style: 'font-size:.75rem;color:var(--ink-dim);margin-top:6px' }, `Start: ${fmtMoney(sc.cash)}`),
);
card.addEventListener('click', () => { onPick(sc.id); });
grid.appendChild(card);
}
const wrap = el('div', {},
el('div', { style: 'margin-bottom:12px;color:#9aa4c0;font-size:.9rem' }, 'Choose a scenario to rule:'),
grid);
openModal('🏰 New Game', wrap, { wide: true });
}
// ---------------- Win/Lose ----------------
export function maybeShowEndModal(state, onRestart) {
if (state._endShown) return false;
if (state.won || state.lost) {
state._endShown = true;
state.won ? sfx.victory() : sfx.defeat();
const scen = SCENARIOS.find(s => s.id === state.scenario);
const c = el('div', { style: 'text-align:center;padding:20px 10px' },
el('div', { style: 'font-size:3.4rem' }, state.won ? '🏆' : '💀'),
el('h2', { style: 'color:' + (state.won ? 'var(--gold)' : 'var(--bad)') + ';margin:10px 0' },
state.won ? 'Victory!' : 'Bankrupt!'),
el('p', { style: 'color:#9aa4c0;line-height:1.6' },
state.won
? `${state.park.name} has completed every objective of ${scen?.name}. Your legend echoes across the kingdom!`
: 'The kingdom coffers ran dry. The dragons mourn… but every tycoon rises again.'),
el('div', { style: 'margin-top:14px;display:flex;gap:10px;justify-content:center' },
el('button', { class: 'btn primary', onclick: () => { closeModal(); onRestart(); } }, state.won ? '🎉 New Game' : '🔄 Try Again'),
el('button', { class: 'btn', onclick: () => { state._endShown = false; state.won = false; state.lost = false; state.freeplay = true; closeModal(); showToast('Free Play', 'Objectives complete — the park is yours!', 'gold'); } }, 'Keep Playing')),
);
openModal(state.won ? '🏆 Victory!' : '💀 Game Over', c);
return true;
}
return false;
}
+181
View File
@@ -0,0 +1,181 @@
// ============ povui.js — on-ride first-person camera ============
import { sampleTrack } from '../game/coaster.js';
import { sfx } from '../core/audio.js';
let povRAF = null;
export function stopPOV() {
const ov = document.getElementById('pov-overlay');
if (ov) ov.remove();
if (povRAF) { cancelAnimationFrame(povRAF); povRAF = null; }
}
export function startPOV(ride) {
if (!ride?.track?.length && !ride.def) return;
// custom coasters use track; prebuilt rides get a synthesized scenic track
let track = ride.track;
if (!track || !track.length) {
track = synthTrackFor(ride);
ride.cycleDur = ride.def.rideTime;
}
stopPOV();
sfx.whoosh();
const ov = document.createElement('div');
ov.id = 'pov-overlay';
ov.innerHTML = `
<canvas id="pov-canvas"></canvas>
<div id="pov-hud">
<span>🎢 <b>${ride.name}</b></span>
<span>💨 <span id="pov-speed">0</span> km/h</span>
<span>⚡ ${ride.excite.toFixed ? ride.excite.toFixed(1) : ride.excite}</span>
<span id="pov-time"></span>
</div>
<button id="pov-exit" class="btn danger">✕ Exit Ride</button>`;
document.body.appendChild(ov);
const cvs = document.getElementById('pov-canvas');
const ctx = cvs.getContext('2d');
document.getElementById('pov-exit').addEventListener('click', stopPOV);
let prog = 0, lastT = performance.now();
const dur = Math.max(8, ride.cycleDur);
const maxSpeed = ride.stats?.maxSpeed || Math.round((ride.def?.excite || 4) * 12);
const isNight = () => { const st = window.__getState?.(); return st ? (st.time.hour >= 20 || st.time.hour < 6) : false; };
function resize() { cvs.width = innerWidth; cvs.height = innerHeight; }
resize();
window.addEventListener('resize', resize);
function frame(now) {
const dt = Math.min(0.05, (now - lastT) / 1000);
lastT = now;
prog += dt / dur;
const s = sampleTrack(track, prog % 0.9999);
drawFrame(ctx, cvs.width, cvs.height, s, prog, maxSpeed, isNight(), track, prog);
const sp = document.getElementById('pov-speed');
if (sp) sp.textContent = Math.round(maxSpeed * (0.55 + Math.abs(s.slope ?? 0) * 0.18));
const tm = document.getElementById('pov-time');
if (tm) tm.textContent = `${Math.round((prog % 1) * 100)}%`;
if (!document.getElementById('pov-overlay')) return; // exited
povRAF = requestAnimationFrame(frame);
}
povRAF = requestAnimationFrame(frame);
}
function synthTrackFor(ride) {
// simple oval circuit with a hill
const pts = [];
const w = ride.w || 3, h = ride.h || 3;
const cx = ride.x + w / 2, cy = ride.y + h / 2;
const N = 28;
for (let i = 0; i < N; i++) {
const a = i / N * Math.PI * 2;
pts.push({
x: cx + Math.cos(a) * (w / 2 + 1.5),
y: cy + Math.sin(a) * (h / 2 + 1.5),
z: Math.round(Math.abs(Math.sin(a * 2)) * 3),
type: 'straight', dir: Math.floor(a / (Math.PI / 2)) % 4,
lift: false,
});
}
return pts;
}
function drawFrame(ctx, W, H, s, prog, maxSpeed, night, track, rawProg) {
// sky
const g = ctx.createLinearGradient(0, 0, 0, H);
if (night) { g.addColorStop(0, '#0a0e2a'); g.addColorStop(1, '#232a55'); }
else { g.addColorStop(0, '#69aef0'); g.addColorStop(1, '#cfe6f7'); }
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// loop rotation flips world
const loopRot = s.loop ? (prog % 0.9999 > 0.45 && prog % 0.9999 < 0.62 ? Math.PI : 0) : 0;
const slopeTilt = clampN((s.slope ?? 0) * -26, -160, 160);
const steerShift = Math.sin(rawProg * Math.PI * 8) * 40;
ctx.save();
ctx.translate(W / 2 + steerShift, H / 2 + slopeTilt * 0.4);
ctx.rotate(loopRot);
// ground
const horizonY = 60 + slopeTilt;
const gg = ctx.createLinearGradient(0, horizonY, 0, H * 2);
gg.addColorStop(0, '#7fb069'); gg.addColorStop(1, '#3d6b35');
ctx.fillStyle = gg;
ctx.fillRect(-W, horizonY, W * 2, H * 2);
// water strip far away
ctx.fillStyle = night ? '#16204d' : '#3a7cc9';
ctx.fillRect(-W, horizonY - 14, W * 2, 10);
// scrolling ground stripes (speed feel)
const speedF = 0.5 + maxSpeed / 60;
const scroll = (rawProg * 900 * speedF) % 120;
ctx.strokeStyle = 'rgba(255,255,255,.10)';
ctx.lineWidth = 3;
for (let i = -3; i < 22; i++) {
const y = horizonY + ((i * 120 - scroll) ** 1.35) / 40;
if (y > H * 1.6) break;
ctx.beginPath();
ctx.moveTo(-W, y);
ctx.lineTo(W, y);
ctx.stroke();
}
// track rails ahead (perspective V)
ctx.strokeStyle = '#ffd166';
ctx.lineWidth = 6;
ctx.beginPath();
ctx.moveTo(-70, H * 0.75); ctx.quadraticCurveTo(-30, horizonY + 140, -16, horizonY + 34);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(70, H * 0.75); ctx.quadraticCurveTo(30, horizonY + 140, 16, horizonY + 34);
ctx.stroke();
// passing posts
const postScroll = (rawProg * 40) % 3;
ctx.fillStyle = 'rgba(60,64,80,.85)';
for (let i = 0; i < 6; i++) {
const k = i - postScroll;
if (k < -1) continue;
const px = (k - 2) * 260;
const ph = 90 + (i % 3) * 30;
ctx.fillRect(px, horizonY + 60, 10, ph + 200);
}
// trees silhouettes occasionally
for (let i = 0; i < 4; i++) {
const k = i - ((rawProg * 17) % 1) * 1;
const tx = ((i * 397 + Math.floor(rawProg * 17) * 131) % (W * 2)) - W / 2;
ctx.fillStyle = night ? '#101530' : '#2e6b34';
ctx.beginPath();
ctx.arc(tx, horizonY + 46, 34, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
// wind speed lines at high speed
if (maxSpeed > 45) {
ctx.strokeStyle = 'rgba(255,255,255,.25)';
for (let i = 0; i < 8; i++) {
const y = Math.random() * H;
const x = Math.random() * W;
ctx.lineWidth = Math.random() * 2;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + 60 + Math.random() * 90, y + (Math.random() - .5) * 10); ctx.stroke();
}
}
// vignette
const vg = ctx.createRadialGradient(W / 2, H / 2, H * 0.35, W / 2, H / 2, H);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(0,0,10,.42)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
// coaster car front
ctx.fillStyle = '#e05b5b';
ctx.beginPath();
ctx.moveTo(W / 2 - 130, H + 40);
ctx.quadraticCurveTo(W / 2, H - 150, W / 2 + 130, H + 40);
ctx.closePath();
ctx.fill();
}
function clampN(v, a, b) { return v < a ? a : v > b ? b : v; }
+513
View File
@@ -0,0 +1,513 @@
// ============ ui.js — HUD, toolbar palettes, context panel, toasts ============
import { getState } from '../game/state.js';
import { el, fmtMoney, fmtNum, clamp } from '../core/util.js';
import { RIDE_TYPES, SHOP_TYPES, SCENERY_TYPES, STAFF_TYPES, HERO_CLASSES, SPELLS, PATH_TYPES, WEATHER } from '../core/config.js';
import { isUnlocked } from '../game/research.js';
import { buildDiscount } from '../game/magic.js';
import { sfx } from '../core/audio.js';
import { openModal, closeModal, refreshOpenDialogs } from './dialogs.js';
// ---- global ui state (mirrored onto state._ui for renderer ghosts) ----
export const ui = {
tool: 'select',
sel: null, // selected palette def {…}
coasterPiece: 'straight',
heroSubtool: null,
};
const $ = id => document.getElementById(id);
export function initUI() {
// toolbar clicks
document.querySelectorAll('#toolbar .tbtn').forEach(b => {
b.addEventListener('click', () => { sfx.click(); setTool(b.dataset.tool); });
});
$('pal-close').addEventListener('click', () => { setTool('select'); });
$('btn-pause').addEventListener('click', togglePause);
document.querySelectorAll('.spd').forEach(b => b.addEventListener('click', () => setSpeed(+b.dataset.speed)));
$('btn-research').addEventListener('click', () => import('./dialogs.js').then(d => d.openResearch()));
$('btn-finance').addEventListener('click', () => import('./dialogs.js').then(d => d.openFinance()));
$('btn-heroes').addEventListener('click', () => import('./dialogs.js').then(d => d.openGuildDialog()));
$('btn-objectives').addEventListener('click', () => import('./dialogs.js').then(d => d.openObjectives()));
$('btn-park').addEventListener('click', () => import('./dialogs.js').then(d => d.openParkSettings()));
$('btn-save').addEventListener('click', () => import('./dialogs.js').then(d => d.openSaveLoad()));
$('btn-help').addEventListener('click', () => import('./dialogs.js').then(d => d.openHelp()));
}
export function setSpeed(spd) {
const st = getState();
st._speed = spd;
st._paused = false;
document.querySelectorAll('.spd').forEach(b => b.classList.toggle('active', +b.dataset.speed === spd));
$('btn-pause').textContent = '⏸';
$('btn-pause').classList.remove('active');
}
export function togglePause() {
const st = getState();
st._paused = !st._paused;
$('btn-pause').textContent = st._paused ? '▶' : '⏸';
$('btn-pause').classList.toggle('active', st._paused);
}
export function setTool(tool) {
ui.tool = tool;
ui.sel = null;
ui.heroSubtool = null;
document.querySelectorAll('#toolbar .tbtn').forEach(b => b.classList.toggle('active', b.dataset.tool === tool));
syncUiToState();
if (tool === 'select') { hidePalette(); return; }
showPalette();
renderPalette();
}
function syncUiToState() {
const st = getState();
if (!st) return;
st._ui = { tool: ui.tool, sel: ui.sel, coasterPiece: ui.coasterPiece, heroSubtool: ui.heroSubtool };
}
export function showPalette() { $('palette').classList.remove('hidden'); }
export function hidePalette() {
$('palette').classList.add('hidden');
$('tool-hint').classList.add('hidden');
}
export function refreshPalette() { if (!$('palette').classList.contains('hidden')) renderPalette(); }
// ---------------- palettes ----------------
function palHeaderHint(txt) {
$('tool-hint').textContent = txt;
$('tool-hint').classList.remove('hidden');
}
function renderPalette() {
const st = getState();
const body = $('pal-body');
body.innerHTML = '';
const titles = {
path: 'Build Paths', coaster: 'Coaster Designer', ride: 'Build Rides', shop: 'Build Shops',
scenery: 'Scenery', terrain: 'Terrain Tools', staff: 'Hire Staff', heroes: 'Heroes & Defense', magic: 'Spellbook',
};
$('pal-title').textContent = titles[ui.tool] || 'Build';
switch (ui.tool) {
case 'path': renderPathPal(body); break;
case 'coaster': renderCoasterPal(body); break;
case 'ride': renderGridPal(body, RIDE_TYPES, 'ride'); break;
case 'shop': renderGridPal(body, SHOP_TYPES, 'shop'); break;
case 'scenery': renderGridPal(body, SCENERY_TYPES, 'scenery'); break;
case 'terrain': renderTerrainPal(body); break;
case 'staff': renderStaffPal(body); break;
case 'heroes': renderHeroesPal(body); break;
case 'magic': renderMagicPal(body); break;
}
body.appendChild(el('div', { class: 'ctx-row', style: 'grid-column:1/-1;color:#7d88a8;font-size:.72rem;text-align:center' },
'Left-click place · Right-click cancel/undo'));
}
function priceTag(cost) {
const st = getState();
const disc = Math.round(cost * buildDiscount(st));
return disc < cost ? `${fmtMoney(disc)}` : fmtMoney(cost);
}
function palButton({ icon, name, price, locked, cantAfford, selected, onClick, title }) {
const b = el('div', {
class: 'pal-item' + (locked ? ' locked' : '') + (cantAfford ? ' unaffordable' : '') + (selected ? ' selected' : ''),
title: title || '',
}, el('div', { class: 'ic' }, icon), el('div', { class: 'nm' }, name), el('div', { class: 'pr' }, locked ? '🔒' : price));
b.addEventListener('click', () => { if (!locked) onClick(b); });
return b;
}
function renderPathPal(body) {
Object.values(PATH_TYPES).forEach(pt => {
const key = pt.id === 'cobble' ? 'cobble' : null;
const locked = pt.id === 'cobble' && !isUnlocked(getState(), 'cobble');
body.appendChild(palButton({
icon: pt.id === 'pavement' ? '🧱' : '🪨', name: pt.name, price: fmtMoney(pt.cost),
locked,
selected: ui.sel?.kind === 'path' && ui.sel.pt === pt.id,
onClick: () => { ui.sel = { kind: 'path', pt: pt.id }; palHeaderHint(`Placing ${pt.name}: click/drag on ground`); markSel(); },
}));
});
body.appendChild(palButton({
icon: '❌', name: 'Bulldoze', price: 'refund',
selected: ui.sel?.kind === 'doze',
onClick: () => { ui.sel = { kind: 'doze' }; palHeaderHint('Bulldozer: click paths, shops & scenery'); markSel(); },
}));
function markSel() { renderPalette(); }
}
function renderTerrainPal(body) {
[['grass', '🌱'], ['sand', '🏖️'], ['rock', '⛰️'], ['water', '💧']].forEach(([tid, ic]) => {
body.appendChild(palButton({
icon: ic, name: tid[0].toUpperCase() + tid.slice(1), price: fmtMoney(20),
selected: ui.sel?.kind === 'terrain' && ui.sel.t === tid,
onClick: () => { ui.sel = { kind: 'terrain', t: tid }; palHeaderHint(`Painting ${tid}`); renderPalette(); },
}));
});
}
function renderGridPal(body, defs, kind) {
Object.values(defs).forEach(def => {
const locked = !(def.tier === 0 || isUnlocked(getState(), def.id));
const cost = def.cost * buildDiscount(getState());
body.appendChild(palButton({
icon: def.icon, name: def.name, price: priceTag(def.cost),
locked,
cantAfford: getState().cash < cost && !getState().sandbox,
selected: ui.sel?.id === def.id,
title: def.desc || '',
onClick: () => {
ui.sel = { ...def, kind };
palHeaderHint(kind === 'ride' ? `Click to place ${def.name}` : `Click to place ${def.name}`);
renderPalette();
},
}));
});
}
function renderCoasterPal(body) {
renderCoasterPieces(body);
}
import { PIECES, MIN_COASTER_PIECES } from '../core/config.js';
import { getSession, sessionActive, addPiece, undoPiece, cancelCoaster, finishCoaster, computeStats, validatePiece, startCoasterSession, isCircuitClosed } from '../game/coaster.js';
function renderCoasterPieces(body) {
const st = getState();
const sess = getSession(st);
if (!sess) {
body.innerHTML = `<div style="grid-column:1/-1;font-size:.82rem;color:#9aa4c0;line-height:1.45">
Build a <b style="color:var(--gold)">station</b> first — click a flat tile <b>next to a path</b>.<br>
Then add pieces to form a closed circuit back to the station.<br><br>
🟡 cursor = next slot · Right-click = undo · Chain lifts are automatic.</div>`;
body.appendChild(el('div', { class: 'pal-item', style: 'grid-column:1/-1' },
el('div', { class: 'ic' }, '🏗️'), el('div', { class: 'nm' }, 'Place Station'),
el('div', { class: 'pr' }, fmtMoney(300))));
body.lastChild.addEventListener('click', () => {
ui.sel = { kind: 'coasterStation' };
palHeaderHint('Click a flat tile ADJACENT TO A PATH to place the station');
renderPalette();
});
return;
}
// live stats
const stats = computeStats(sess.pieces.map(p => ({ ...p })));
const closed = isCircuitClosed(sess);
const info = el('div', { style: 'grid-column:1/-1;background:var(--bg2);border-radius:10px;padding:8px 10px;font-size:.75rem;line-height:1.5;border:1px solid var(--panel-brd)' },
el('div', {}, `🎢 ${sess.name}${sess.pieces.length} pieces · spent ${fmtMoney(sess.spent)}`),
el('div', {}, `${stats.excitement.toFixed(1)} · ☠️ ${stats.intensity.toFixed(1)} · 🤢 ${stats.nausea.toFixed(1)} · 💨 ${stats.maxSpeed} km/h`),
el('div', { style: closed ? 'color:var(--good)' : 'color:#9aa4c0' }, closed ? '✔ Circuit closed — you can Finish!' : `↩ Return to station (need ≥ ${MIN_COASTER_PIECES} pieces)`),
);
body.appendChild(info);
Object.values(PIECES).forEach(pc => {
if (pc.id === 'station') return;
const v = validatePiece(st, sess, pc.id);
body.appendChild(palButton({
icon: pc.icon, name: pc.name, price: priceTag(pc.cost),
cantAfford: !st.sandbox && st.cash < pc.cost * buildDiscount(st),
selected: ui.coasterPiece === pc.id,
title: v.ok ? '' : ('Next slot: ' + v.reason),
onClick: () => { ui.coasterPiece = pc.id; palHeaderHint(`${pc.name}: click to add (${v.ok ? 'valid' : v.reason})`); renderPalette(); },
}));
});
// action row
const row = el('div', { style: 'grid-column:1/-1;display:flex;gap:6px;margin-top:4px' });
const mkBtn = (label, cls, fn, disabled) => {
const b = el('button', { class: 'btn ' + cls, disabled: disabled ? 'true' : null }, label);
b.addEventListener('click', fn);
return b;
};
row.appendChild(mkBtn('Undo', '', () => { undoPiece(st); renderPalette(); }, sess.pieces.length <= 1));
row.appendChild(mkBtn('✓ Finish', 'primary', () => {
const res = finishCoaster(st);
if (res.error) { sfx.error(); alertToast(res.error, 'bad'); }
else { sfx.openRide(); setTool('select'); }
renderPalette();
}, !closed || sess.pieces.length < MIN_COASTER_PIECES));
row.appendChild(mkBtn('Cancel', 'danger', () => { cancelCoaster(st); setTool('select'); }));
body.appendChild(row);
}
function renderStaffPal(body) {
const st = getState();
Object.values(STAFF_TYPES).forEach(def => {
const count = st.staff.filter(s => s.type === def.id).length;
body.appendChild(palButton({
icon: def.icon, name: `${def.name}${count ? ` ×${count}` : ''}`, price: `$${def.wage}/mo`,
title: `${def.desc} — hire cost $100`,
onClick: () => {
import('../game/staff.js').then(m => {
const s = m.hireStaff(st, def.id);
if (s) { sfx.place(); renderPalette(); }
});
},
}));
});
}
import { buildGuild, recruitHero, guildCap, clsUnlocked } from '../game/heroes.js';
function renderHeroesPal(body) {
const st = getState();
if (!st.guild) {
body.innerHTML = `<div style="grid-column:1/-1;font-size:.82rem;color:#9aa4c0;line-height:1.5">Monsters will invade soon! Build the <b style="color:var(--mana)">Heroes Guild</b> to recruit defenders.</div>`;
const b = el('div', { class: 'pal-item', style: 'grid-column:1/-1' },
el('div', { class: 'ic' }, '🏰'), el('div', { class: 'nm' }, 'Build Guild Hall'), el('div', { class: 'pr' }, fmtMoney(1500)));
b.addEventListener('click', () => {
ui.heroSubtool = 'guild';
palHeaderHint('Click to place the Heroes Guild (2×2, must touch a path)');
renderPalette();
});
body.appendChild(b);
return;
}
const cap = guildCap(st);
const head = el('div', { style: 'grid-column:1/-1;font-size:.78rem;color:#cbb2ff;display:flex;justify-content:space-between;padding:2px 4px' },
el('span', {}, `⚔️ Roster ${st.heroes.length}/${cap}`),
el('span', {}, `Monster kills: ${st.heroStats.kills}`));
body.appendChild(head);
Object.values(HERO_CLASSES).forEach(cls => {
const locked = !clsUnlocked(st, cls.id);
body.appendChild(palButton({
icon: cls.icon, name: `${cls.name} $${cls.hp}hp`, price: fmtMoney(cls.cost),
locked,
cantAfford: !st.sandbox && st.cash < cls.cost,
title: cls.desc,
onClick: () => {
const res = recruitHero(st, cls.id);
if (res.error) { sfx.error(); alertToast(res.error, 'bad'); }
else { sfx.levelup(); renderPalette(); refreshOpenDialogs(); }
},
}));
});
// invasion info
const scen = getScenCfg(st);
const monthsAway = Math.max(0, st.invasion.nextMonthIdx - monthIndexOf(st));
body.appendChild(el('div', { style: 'grid-column:1/-1;font-size:.75rem;color:#ff9d76;padding:4px' },
`⚠ Next invasion in ~${monthsAway} month(s) · Repelled: ${st.invasion.repelled}`));
if (!scen || true) { /* keep simple */ }
}
import { SCENARIOS } from '../core/config.js';
function getScenCfg(st) { return SCENARIOS.find(s => s.id === st.scenario); }
function monthIndexOf(st) { return st.time.year * 12 + st.time.month; }
function renderMagicPal(body) {
const st = getState();
Object.values(SPELLS).forEach((sp, i) => {
const locked = !(sp.tier === 0 || isUnlocked(st, sp.id));
const cd = st.spells.cds[sp.id] || 0;
const active = st.spells.active[sp.id] > 0;
const item = palButton({
icon: sp.icon, name: sp.name + (active ? ' ✨' : ''), price: locked ? '🔒' : `${Math.round(sp.mana)} mana`,
locked,
cantAfford: st.mana < sp.mana || cd > 0,
title: sp.desc,
onClick: () => {
import('../game/magic.js').then(m => {
const res = m.castSpell(st, sp.id);
if (res.ok) { sfx.spell(); }
else { sfx.error(); alertToast(res.why, 'bad'); }
renderPalette();
});
},
});
if (cd > 0) {
item.appendChild(el('div', { style: 'position:absolute;inset:0;background:rgba(10,10,20,.55);border-radius:10px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:bold' }, `${Math.ceil(cd)}s`));
}
if (active) item.style.borderColor = 'var(--mana)';
body.appendChild(item);
});
body.appendChild(el('div', { style: 'grid-column:1/-1;font-size:.74rem;color:#cbb2ff;padding:4px;line-height:1.5' },
`🔮 Mana ${Math.floor(st.mana)}/${st.manaMax} (+${(st.manaRegen || .4).toFixed(1)}/s)`, el('br'), 'Build Ley Pools, Rune Stones & Glowcaps to raise your mana.'));
}
// ---------------- toasts ----------------
export function updateToasts(state) {
while (state.toasts.length) {
const t = state.toasts.shift();
showToast(t.title, t.text, t.kind);
}
}
let toastCount = 0;
export function showToast(title, text, kind = 'info') {
const wrap = $('toasts');
while (wrap.children.length >= 5) wrap.firstChild.remove();
const t = el('div', { class: `toast ${kind === 'info' ? '' : kind}` },
el('div', { class: 't-title' }, title), text ? el('div', {}, text) : null);
wrap.appendChild(t);
toastCount++;
if (kind === 'bad') sfx.error();
setTimeout(() => { t.classList.add('fade'); setTimeout(() => t.remove(), 700); }, 5200);
}
export function alertToast(text, kind = 'bad') { showToast('!', text, kind); }
// ---------------- HUD ----------------
const hudCache = {};
function setText(id, txt) {
if (hudCache[id] !== txt) { hudCache[id] = txt; $(id).innerHTML = txt; }
}
export function updateHUD(state) {
const cashCls = state.cash < 0 ? 'neg' : '';
setText('stat-cash', `💰 <b class="${cashCls}" style="color:${state.cash < 0 ? '#ff6b6b' : 'var(--gold)'}">${fmtMoney(state.cash)}</b>`);
setText('stat-guests', `🧑‍🤝‍🧑 <b>${fmtNum(state.guests.length)}</b>`);
setText('stat-rating', `⭐ <b>${state.stats.rating}</b>`);
const mp = Math.floor(state.mana);
setText('mana-num', `${mp}/${state.manaMax}`);
$('mana-fill').style.width = `${clamp(mp / state.manaMax * 100, 0, 100)}%`;
setText('stat-weather', WEATHER[state.weather.cur].icon);
const h = Math.floor(state.time.hour), mnt = Math.floor((state.time.hour - h) * 60);
setText('stat-date', `📅 Y${state.time.year} ${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][state.time.month]} ${state.time.day} · ${String(h).padStart(2, '0')}:${String(mnt).padStart(2, '0')}`);
if (state._paused) $('btn-pause').textContent = '▶';
}
// ---------------- context panel ----------------
export function hideContext() { $('context-panel').classList.add('hidden'); const st = getState(); if (st) st._uiSelEntity = null; }
export function showContextFor(entity) {
const st = getState();
st._uiSelEntity = entity;
const panel = $('context-panel');
panel.classList.remove('hidden');
panel.innerHTML = '';
panel.appendChild(contextContent(entity));
}
function ctxRow(label, valueHtml) {
return el('div', { class: 'ctx-row' }, el('span', {}, label), el('b', { html: valueHtml }));
}
function bar(frac, color) {
return el('div', { class: 'bar' }, el('div', { style: `width:${clamp(frac * 100, 0, 100)}%;background:${color}` }));
}
function actionBtn(label, fn, cls = '') {
const b = el('button', { class: 'btn ' + cls }, label);
b.addEventListener('click', fn);
return b;
}
function contextContent(entity) {
const st = getState();
const wrap = el('div');
const closeX = el('button', {}, '✕');
closeX.addEventListener('click', hideContext);
if (entity.kind === 'ride') {
const r = entity.ref;
wrap.appendChild(el('div', { class: 'ctx-title' }, r.name, closeX));
wrap.appendChild(ctxRow('Status', r.status.toUpperCase()));
wrap.appendChild(ctxRow('Ticket price', fmtMoney(r.price)));
wrap.appendChild(ctxRow('Excitement / Intensity', `${r.excite.toFixed(1)} / ${r.intensity.toFixed(1)}`));
wrap.appendChild(ctxRow('Nausea', r.nausea.toFixed(1)));
wrap.appendChild(ctxRow('Riders total', fmtNum(r.totalRiders)));
wrap.appendChild(ctxRow('Income', fmtMoney(r.income)));
wrap.appendChild(ctxRow('Queue / riding', `${r.queue.length} / ${r.riders.length}`));
if (r.isCustomCoaster && r.stats) {
wrap.appendChild(ctxRow('Max speed', r.stats.maxSpeed + ' km/h'));
wrap.appendChild(ctxRow('Length / drops', `${r.stats.length} / ${r.stats.drops}`));
wrap.appendChild(ctxRow('Inversions', String(r.stats.inversions)));
}
const br = el('div', { class: 'btnrow' });
if (r.status === 'open') br.appendChild(actionBtn('Close', () => { import('../game/rides.js').then(m => { m.setRideOpen(st, r, false); showContextFor(entity); }); }));
else if (r.status !== 'broken') br.appendChild(actionBtn('▶ Open', () => { import('../game/rides.js').then(m => { m.setRideOpen(st, r, true); sfx.openRide(); showContextFor(entity); }); }));
if (r.status !== 'broken') br.appendChild(actionBtn('Test', () => { import('../game/rides.js').then(m => { m.startTest(st, r); showContextFor(entity); }); }));
// price steppers
const prow = el('div', { class: 'btnrow' });
prow.appendChild(actionBtn(' price', () => { r.price = Math.max(0, r.price - 1); showContextFor(entity); }));
prow.appendChild(actionBtn('+ price', () => { r.price++; showContextFor(entity); }));
br.appendChild(prow);
br.appendChild(actionBtn('🚪 On-ride Cam', () => import('./povui.js').then(p => p.startPOV(r)), 'primary'));
br.appendChild(actionBtn('🗑 Demolish', 'danger', () => { import('../game/rides.js').then(m => { m.removeRide(st, r); hideContext(); sfx.demolish(); }); }));
wrap.appendChild(br);
} else if (entity.kind === 'shop') {
const s = entity.ref;
wrap.appendChild(el('div', { class: 'ctx-title' }, s.def.name, closeX));
wrap.appendChild(ctxRow('Price', fmtMoney(s.price)));
wrap.appendChild(ctxRow('Sold', fmtNum(s.sold)));
wrap.appendChild(ctxRow('Income', fmtMoney(s.income)));
wrap.appendChild(ctxRow('Stock', s.stock === Infinity ? '∞' : fmtNum(Math.max(0, s.stock))));
if (s.damaged > 0) wrap.appendChild(ctxRow('<span style="color:var(--bad)">DAMAGED</span>', `${Math.round((1 - s.damaged) * 100)}% — repairs slowly`));
const br = el('div', { class: 'btnrow' });
br.appendChild(actionBtn('', () => { s.price = Math.max(0, s.price - 1); showContextFor(entity); }));
br.appendChild(actionBtn('+', () => { s.price++; showContextFor(entity); }));
br.appendChild(actionBtn('🗑 Demolish', 'danger', () => {
earnRefundShop(st, s); hideContext();
}));
wrap.appendChild(br);
} else if (entity.kind === 'guest') {
const g = entity.ref;
wrap.appendChild(el('div', { class: 'ctx-title' }, g.name, closeX));
wrap.appendChild(ctxRow('Happiness', `${Math.round(g.happiness)}%`)); wrap.appendChild(bar(g.happiness / 100, '#57d97a'));
wrap.appendChild(ctxRow('Energy', `${Math.round(g.energy)}%`)); wrap.appendChild(bar(g.energy / 100, '#58c1ff'));
wrap.appendChild(ctxRow('Hunger', Math.round(g.hunger) + '%')); wrap.appendChild(bar(g.hunger / 100, '#ffb347'));
wrap.appendChild(ctxRow('Thirst', Math.round(g.thirst) + '%')); wrap.appendChild(bar(g.thirst / 100, '#58c1ff'));
wrap.appendChild(ctxRow('Bladder', Math.round(g.toilet) + '%')); wrap.appendChild(bar(g.toilet / 100, '#a86bff'));
wrap.appendChild(ctxRow('Cash', fmtMoney(g.money)));
wrap.appendChild(ctxRow('Rides taken', String(g.ridesCount)));
if (g.thoughts.length) {
wrap.appendChild(el('div', { style: 'margin-top:6px;font-size:.78rem;color:#cdd6f4' }, '💭 ' + g.thoughts[0]));
}
} else if (entity.kind === 'hero') {
const h = entity.ref;
wrap.appendChild(el('div', { class: 'ctx-title' }, `${h.def.icon} ${h.name}`, closeX));
wrap.appendChild(ctxRow('Class / Level', `${h.def.name} · Lv ${h.lvl}`));
wrap.appendChild(ctxRow('HP', `${Math.round(h.hp)}/${h.maxHp}`)); wrap.appendChild(bar(h.hp / h.maxHp, '#e05b5b'));
wrap.appendChild(ctxRow('XP', `${h.xp}/${h.xpNext}`)); wrap.appendChild(bar(h.xp / h.xpNext, '#58c1ff'));
wrap.appendChild(ctxRow('Kills', String(h.kills)));
wrap.appendChild(ctxRow('Gear tier', String(h.gear)));
const br = el('div', { class: 'btnrow' });
br.appendChild(actionBtn('⚒ Buy Gear', () => {
import('../game/heroes.js').then(m => {
const res = m.buyGear(st, h);
if (res.error) { sfx.error(); alertToast(res.error); } else { sfx.cash(); showContextFor(entity); refreshOpenDialogs(); }
});
}, 'primary'));
wrap.appendChild(br);
} else if (entity.kind === 'monster') {
const mo = entity.ref;
wrap.appendChild(el('div', { class: 'ctx-title' }, `${mo.def.icon} ${mo.def.name}`, closeX));
wrap.appendChild(ctxRow('HP', `${Math.round(mo.hp)}/${mo.maxHp}`)); wrap.appendChild(bar(mo.hp / mo.maxHp, '#ff6b6b'));
wrap.appendChild(ctxRow('Threat', '☠'.repeat(Math.min(5, Math.ceil(mo.def.threat / 2)))));
wrap.appendChild(el('div', { style: 'font-size:.78rem;color:#ff9d76;margin-top:4px' }, 'Your heroes will engage automatically!'));
} else if (entity.kind === 'scenery') {
const sc = entity.ref;
wrap.appendChild(el('div', { class: 'ctx-title' }, sc.def.name, closeX));
wrap.appendChild(ctxRow('Beauty', '+' + (sc.def.beauty || 0)));
if (sc.def.manaCap) wrap.appendChild(ctxRow('Mana capacity', '+' + sc.def.manaCap));
if (sc.def.manaRegen) wrap.appendChild(ctxRow('Mana regen', '+' + sc.def.manaRegen + '/s'));
const br = el('div', { class: 'btnrow' });
br.appendChild(actionBtn('🗑 Remove', 'danger', () => {
import('../game/state.js').then(m => {
m.removeScenery(st, sc);
st.cash += Math.round(sc.def.cost * 0.5);
hideContext(); sfx.demolish();
});
}));
wrap.appendChild(br);
}
return wrap;
}
function earnRefundShop(st, s) {
import('../game/state.js').then(m2 => {
st.map.clearObject(s.x, s.y);
st.shops = st.shops.filter(x => x !== s);
st.cash += Math.round(s.def.cost * 0.5);
sfx.demolish();
});
}
/** find entity near a world point */
export function pickEntity(state, wx, wy) {
let best = null, bd = 1.1;
for (const g of state.guests) { const d = Math.hypot(g.x - wx, g.y - wy); if (d < bd) { bd = d; best = { kind: 'guest', ref: g }; } }
for (const s of state.staff) { const d = Math.hypot(s.x - wx, s.y - wy); if (d < bd) { bd = d; best = { kind: 'staff', ref: s }; } }
for (const h of state.heroes) { if (!h.alive) continue; const d = Math.hypot(h.x - wx, h.y - wy); if (d < bd) { bd = d; best = { kind: 'hero', ref: h }; } }
for (const mo of state.monsters) { const d = Math.hypot(mo.x - wx, mo.y - wy); if (d < bd) { bd = d; best = { kind: 'monster', ref: mo }; } }
if (best) return best;
// buildings: check map objects
const o = state.map.getObject(Math.floor(wx), Math.floor(wy));
if (o?.kind === 'ride') { const r = state.rides.find(r => r.id === o.id); if (r) return { kind: 'ride', ref: r }; }
if (o?.kind === 'shop') { const s = state.shops.find(s => s.id === o.id); if (s) return { kind: 'shop', ref: s }; }
if (o?.kind === 'guild') return { kind: 'guildBuilding' };
if (o?.kind === 'scenery') { const sc = state.sceneryList.find(s => s.id === o.id); if (sc) return { kind: 'scenery', ref: sc }; }
if (o?.kind === 'track') { const r = state.rides.find(r => r.id === o.id); if (r) return { kind: 'ride', ref: r }; }
return null;
}