Files
deepseek ac00687480 Arcane Tycoon — Heroes & Magic theme park tycoon game
Complete browser game inspired by OpenRCT2 with fantasy twist:
- Custom roller coaster designer with physics-based ratings + on-ride POV
- 10 animated rides, 7 shops, 16 scenery items, path network & guest AI
- Heroes guild vs monster invasions (5 classes, XP/gear/bosses)
- Magic spell system (8 spells), research tree, economy/marketing/loans
- Day-night cycle, weather, park rating, awards, 4 scenarios
- Save/load slots + autosave, procedural WebAudio SFX/music
- Isometric canvas renderer, minimap, diagnostics overlay
- Test suites: smoke(13), linkcheck, inputcheck, framecheck, rendercheck, flow
2026-08-23 06:59:21 +00:00

403 lines
20 KiB
JavaScript
Raw Permalink Blame History

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