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:
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user