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