// ============ 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 = `
Build a magical theme park! Complete scenario objectives (top-right 🏆): attract guests, raise your rating, repel monster invasions and build thrilling custom coasters.
Lay Paths from the entrance gate. Guests arrive automatically and wander paths. Add Shops (food/drinks/toilets!) beside paths and Rides with their entrance touching a path.
Pick the Coaster tab → place the Station 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 Finish. Test it, then Open. Bigger drops & loops = more excitement (and intensity!).
Handymen clean litter & vomit, Mechanics fix breakdowns, Guards deter vandals, Jesters entertain queues. Wages are charged monthly.
Build the Heroes Guild (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.
Mana regenerates over time; Ley Pools, Rune Stones and Glowcaps raise max mana. Cast spells like Joy Aura (happiness), Monster Bane or Warding Sigil (blocks invasions).
Earn RP from rides & magic scenery, spend it in the 🔬 lab to unlock advanced rides, shops, spells and hero classes.
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 📈.
WASD/arrows pan · Q/E or wheel zoom · Space pause · 1-3 speed · T research · F finance · G guild · H help · Esc cancel/close · Right-click cancels placement.
`; 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; }