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
514 lines
25 KiB
JavaScript
514 lines
25 KiB
JavaScript
// ============ 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;
|
||
}
|