// ============ 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 = `
Build a station first — click a flat tile next to a path.
Then add pieces to form a closed circuit back to the station.

🟡 cursor = next slot · Right-click = undo · Chain lifts are automatic.
`; 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 = `
Monsters will invade soon! Build the Heroes Guild to recruit defenders.
`; 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', `💰 ${fmtMoney(state.cash)}`); setText('stat-guests', `🧑‍🤝‍🧑 ${fmtNum(state.guests.length)}`); setText('stat-rating', `⭐ ${state.stats.rating}`); 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('DAMAGED', `${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; }