// ============ 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 = `

🎯 Goal

Build a magical theme park! Complete scenario objectives (top-right 🏆): attract guests, raise your rating, repel monster invasions and build thrilling custom coasters.

🧱 Basics

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.

🎢 Custom Coaster

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!).

🧑‍💼 Staff

Handymen clean litter & vomit, Mechanics fix breakdowns, Guards deter vandals, Jesters entertain queues. Wages are charged monthly.

⚔️ Heroes & Monsters

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.

✨ Magic

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).

🔬 Research

Earn RP from rides & magic scenery, spend it in the 🔬 lab to unlock advanced rides, shops, spells and hero classes.

💰 Economy

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 📈.

⌨️ Shortcuts

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; }