Files
deepseek ac00687480 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
2026-08-23 06:59:21 +00:00

514 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============ 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;
}