commit ac006874801d2485ec1b019d7c8c9bf006f2264b Author: deepseek Date: Sun Aug 23 06:59:21 2026 +0000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5344727 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.log +.DS_Store +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..21431b6 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# ✦ Arcane Tycoon — Heroes & Magic Theme Park + +A complete, publish-ready **browser theme-park tycoon game** inspired by the classic +RollerCoaster Tycoon / OpenRCT2 formula — supercharged with **heroes, monsters and magic**. +100% vanilla JavaScript + Canvas, zero dependencies, no build step. + +**▶ Play: http://127.0.0.1:8020** + +--- + +## Features + +### 🎢 Park Building (the RCT heart) +- Isometric canvas world (up to 64×64 tiles), pan / zoom / minimap +- **Path network** with guest pathfinding (BFS), pavement & cobblestone +- **10 prebuilt rides** — Unicorn Carousel, Sky Wheel, Gravity Spire drop tower, + Fairy Swings, Haunted Crypt, River Sprite Flume, Dragonling Coaster, + Portal Blasters, Broomstick Tower… all procedurally animated +- **7 shops** with stock, pricing and needs-driven demand (food / drink / toilets!) +- **16 scenery items** with beauty auras, lighting and mana properties +- Terrain painting (grass / sand / rock / water) and bulldozer with refunds + +### 🎢 Custom Coaster Designer +- Place a station next to a path, then lay pieces: straights, curves, slopes, + steep drops and **loops** — chain lifts are automatic +- Real-ish physics: energy conservation with friction → max speed, airtime, + drops, inversions feed **Excitement / Intensity / Nausea ratings** +- Track must form a closed circuit back to the station; live stats while building +- **On-ride POV camera**: ride your own coaster first-person with speed lines, loops & day/night skies + +### 🧑‍🤝‍🧑 Guest Simulation +- Individual guests with name, cash, thrill-preference, color +- Needs AI: hunger, thirst, bladder, energy → they seek shops, benches, rides & exits +- Thought bubbles, happiness dynamics, littering & vomit, fleeing from monsters + +### 🧑‍💼 Staff +Handymen (clean), Mechanics (repair breakdowns), Guards (deter vandalism), +Court Jesters (entertain queues). Monthly wages & ride running costs. + +### ⚔️ Heroes & Monster Invasions +- Build the **Heroes Guild**, recruit Knights, Rangers, Battle Mages, Clerics, Paladins +- Monsters (slimes, goblins, dire wolves, troll brutes, Void Wraith bosses) invade + on a schedule; heroes auto-engage with melee/ranged/AoE/healer roles +- XP, level-ups, gear tiers, revive timers, loot gold, damage numbers + +### ✨ Magic System +- Mana pool that grows with Ley Pools, Rune Stones & Glowcap scenery +- 8 spells: Sunburst, Joy Aura, Healing Light, Fortune Rain, Artificer's Haste + (instant half-price building), Monster Bane, Warding Sigil, Transmutation + +### 🔬 Progression & Economy +- Research lab: spend RP across 4 tracks to unlock rides, shops, spells, hero classes +- Full finance: monthly close, wages, loans & interest, marketing campaigns +- Park rating (0–999) from rides/happiness/cleanliness/beauty/facilities/safety +- Quarterly awards, objectives per scenario, win/lose flow + +### 🌦️ World +Day/night cycle with lamp glows · weather (sunny/cloudy/rain/storm) affecting +attendance & mood · procedural WebAudio SFX + generative ambient music. + +### 💾 Quality of life +3 save slots + monthly autosave (localStorage), JSON export/import, +pause & 3 speeds, keyboard shortcuts, tooltips, tutorial/help dialog. + +## Scenarios +| Scenario | Difficulty | Hook | +|---|---|---| +| Enchanted Meadows | Easy | Learn the ropes by a sleepy lake | +| Dragonspine Pass | Medium | Rocky pass, early & frequent invasions | +| Void Rift Crisis | Hard | Boss wraiths, aggressive schedule | +| Sandbox Kingdom | Sandbox | $1M, everything unlocked | + +## Run it + +Any static file server works: + +```bash +cd rollercoaster +python3 -m http.server 8020 +# open http://127.0.0.1:8020 +``` + +Or run the headless test-suite (no browser needed): + +```bash +node tests/smoke.mjs # 13 integration tests over the full simulation +``` + +## Tech notes +- Pure ES modules, Canvas 2D, WebAudio — no frameworks, no assets, ~6k LOC +- Deterministic seeded map generation (Mulberry32), serializable RNG +- Fixed-timestep simulation decoupled from rendering; entity caps keep 60 fps + +*Inspired by Chris Sawyer's classic & the OpenRCT2 project. All code original.* diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..02a40be --- /dev/null +++ b/css/style.css @@ -0,0 +1,287 @@ +/* ============ Arcane Tycoon — theme ============ */ +:root { + --bg0: #0b0e1a; + --bg1: #131829; + --bg2: #1b2238; + --panel: rgba(16, 20, 36, .94); + --panel-brd: #3a4668; + --gold: #f5c542; + --gold-dim: #b9962e; + --ink: #e8ecf7; + --ink-dim: #9aa4c0; + --good: #57d97a; + --bad: #ff6b6b; + --warn: #ffb347; + --mana: #a86bff; + --accent: #58c1ff; + font-size: 15px; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { width: 100%; height: 100%; overflow: hidden; } +body { + background: var(--bg0); + color: var(--ink); + font-family: "Trebuchet MS", "Segoe UI", Verdana, sans-serif; + user-select: none; +} +#app { position: fixed; inset: 0; } +#game { position: absolute; inset: 0; display: block; cursor: default; } +.hidden { display: none !important; } +.spacer { flex: 1; } + +/* ============ Top HUD ============ */ +#topbar { + position: absolute; top: 8px; left: 8px; right: 8px; + display: flex; align-items: center; gap: 6px; + pointer-events: auto; z-index: 30; +} +.hud-group { + background: var(--panel); + border: 1px solid var(--panel-brd); + border-radius: 10px; + padding: 6px 12px; + font-size: .95rem; + display: flex; align-items: center; gap: 6px; + box-shadow: 0 2px 10px rgba(0,0,0,.45); + white-space: nowrap; +} +.hud-group.brand { color: var(--gold); font-weight: bold; letter-spacing: .06em; text-shadow: 0 0 12px rgba(245,197,66,.35); } +.hud-group.stat span b { color: var(--gold); } +.mana-bar { width: 90px; height: 10px; background: #241a3d; border-radius: 6px; overflow: hidden; border: 1px solid #4a3a75; } +#mana-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #7b3ff2, #c39bff); transition: width .25s; } +#mana-num { font-size: .78rem; color: #cbb2ff; min-width: 52px; } +.clock { font-variant-numeric: tabular-nums; } + +.hbtn { + background: var(--panel); border: 1px solid var(--panel-brd); color: var(--ink); + border-radius: 10px; padding: 6px 11px; font-size: 1rem; cursor: pointer; + box-shadow: 0 2px 10px rgba(0,0,0,.45); +} +.hbtn:hover { border-color: var(--gold); background: #1c2440; } +.hbtn.active { border-color: var(--gold); color: var(--gold); } +#speed-group { display: flex; gap: 4px; } + +/* ============ Minimap ============ */ +#minimap-wrap { + position: absolute; right: 10px; top: 54px; z-index: 25; + border: 1px solid var(--panel-brd); border-radius: 10px; overflow: hidden; + box-shadow: 0 4px 14px rgba(0,0,0,.5); line-height: 0; background:#000; +} +#minimap { cursor: crosshair; } + +/* ============ Toolbar ============ */ +#toolbar { + position: absolute; left: 50%; transform: translateX(-50%); bottom: 8px; + display: flex; gap: 5px; padding: 6px; z-index: 30; + background: var(--panel); border: 1px solid var(--panel-brd); border-radius: 14px; + box-shadow: 0 4px 16px rgba(0,0,0,.55); +} +.tbtn { + display: flex; flex-direction: column; align-items: center; gap: 2px; + min-width: 62px; padding: 6px 4px; + background: transparent; border: 1px solid transparent; border-radius: 10px; + color: var(--ink); font-size: 1.25rem; cursor: pointer; +} +.tbtn span { font-size: .68rem; color: var(--ink-dim); letter-spacing: .02em; } +.tbtn:hover { background: #202a4a; } +.tbtn.active { border-color: var(--gold); background: #232d52; } +.tbtn.active span { color: var(--gold); } + +/* ============ Palette ============ */ +#palette { + position: absolute; left: 8px; bottom: 76px; width: 308px; max-height: 62vh; + background: var(--panel); border: 1px solid var(--panel-brd); border-radius: 12px; + z-index: 32; display: flex; flex-direction: column; + box-shadow: 0 6px 22px rgba(0,0,0,.55); +} +.pal-head { + display: flex; justify-content: space-between; align-items: center; + padding: 9px 12px; border-bottom: 1px solid var(--panel-brd); + color: var(--gold); font-weight: bold; letter-spacing: .04em; +} +.pal-head button { background:none;border:none;color:var(--ink-dim);cursor:pointer;font-size:1rem; } +.pal-head button:hover{color:var(--bad);} +#pal-body { overflow-y: auto; padding: 8px; display: grid; grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); gap: 7px; } +.pal-item { + background: var(--bg2); border: 1px solid var(--panel-brd); border-radius: 10px; + padding: 8px 6px; text-align: center; cursor: pointer; position: relative; + display:flex;flex-direction:column;align-items:center;gap:3px; +} +.pal-item:hover { border-color: var(--gold); background: #232c50; } +.pal-item.selected { outline: 2px solid var(--gold); } +.pal-item.locked { opacity: .45; filter: grayscale(.8); cursor: not-allowed; } +.pal-item .ic { font-size: 1.5rem; line-height: 1; } +.pal-item .nm { font-size: .72rem; color: var(--ink); line-height:1.15; } +.pal-item .pr { font-size: .7rem; color: var(--gold); } +.pal-item.unaffordable .pr { color: var(--bad); } + +/* ============ Context panel ============ */ +#context-panel { + position: absolute; right: 10px; top: 250px; width: 264px; + background: var(--panel); border: 1px solid var(--panel-brd); border-radius: 12px; + z-index: 28; padding: 12px; box-shadow: 0 6px 22px rgba(0,0,0,.55); + max-height: 46vh; overflow-y: auto; +} +.ctx-title { color: var(--gold); font-weight: bold; margin-bottom: 6px; display:flex;justify-content:space-between;align-items:center; } +.ctx-title button{background:none;border:none;color:var(--ink-dim);cursor:pointer;} +.ctx-row { display: flex; justify-content: space-between; font-size: .84rem; padding: 2.5px 0; color: var(--ink-dim); } +.ctx-row b { color: var(--ink); font-weight: normal; } +.bar { height: 8px; background: #26203c; border-radius: 5px; overflow: hidden; margin: 3px 0 7px; border:1px solid #3a3157;} +.bar > div { height: 100%; border-radius: 5px; } +.btnrow { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } + +/* ============ Buttons ============ */ +.btn { + background: linear-gradient(180deg, #2a3559, #1d2542); + color: var(--ink); border: 1px solid var(--panel-brd); + border-radius: 9px; padding: 7px 13px; cursor: pointer; font-size: .88rem; +} +.btn:hover { border-color: var(--gold); color: var(--gold); } +.btn.primary { background: linear-gradient(180deg, #8a6b1d, #6b520f); border-color: var(--gold); color: #fff; } +.btn.primary:hover { filter: brightness(1.15); color:#fff; } +.btn.danger { border-color: #a04444; color: #ff9c9c; } +.btn:disabled { opacity: .4; cursor: not-allowed; } + +/* ============ Tool hint ============ */ +#tool-hint { + position: absolute; top: 56px; left: 50%; transform: translateX(-50%); + background: rgba(20,16,40,.92); border: 1px solid var(--mana); + color: #d9ccff; border-radius: 999px; padding: 6px 18px; font-size: .85rem; z-index: 31; + box-shadow: 0 0 18px rgba(140,90,255,.25); +} + +/* ============ Toasts ============ */ +#toasts { + position: absolute; right: 10px; bottom: 76px; width: 300px; + display: flex; flex-direction: column-reverse; gap: 7px; z-index: 40; pointer-events: none; +} +.toast { + background: var(--panel); border: 1px solid var(--panel-brd); border-left: 4px solid var(--accent); + border-radius: 10px; padding: 8px 12px; font-size: .83rem; + animation: toast-in .25s ease-out; box-shadow: 0 4px 14px rgba(0,0,0,.5); +} +.toast.gold { border-left-color: var(--gold); } +.toast.bad { border-left-color: var(--bad); } +.toast.good { border-left-color: var(--good); } +.toast.magic { border-left-color: var(--mana); } +.toast .t-title { color: var(--gold); font-weight: bold; font-size: .8rem; } +@keyframes toast-in { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: none; } } +.toast.fade { opacity: 0; transition: opacity .6s; } + +/* ============ Modal ============ */ +#modal-root { + position: fixed; inset: 0; background: rgba(5,7,14,.66); z-index: 100; + display: flex; align-items: center; justify-content: center; + backdrop-filter: blur(3px); +} +.modal { + background: linear-gradient(180deg, #171d33, #10142a); + border: 1px solid var(--panel-brd); border-radius: 16px; + width: min(720px, 94vw); max-height: 88vh; display: flex; flex-direction: column; + box-shadow: 0 20px 70px rgba(0,0,0,.7), inset 0 1px 0 rgba(255,255,255,.05); +} +.modal.wide { width: min(980px, 96vw); } +.modal-head { + padding: 14px 18px; border-bottom: 1px solid var(--panel-brd); + display: flex; justify-content: space-between; align-items: center; + color: var(--gold); font-weight: bold; font-size: 1.1rem; letter-spacing: .05em; +} +.modal-head button { background: none; border: none; color: var(--ink-dim); font-size: 1.2rem; cursor: pointer; } +.modal-head button:hover { color: var(--bad); } +.modal-body { padding: 16px 18px; overflow-y: auto; } +.modal-foot { padding: 12px 18px; border-top: 1px solid var(--panel-brd); display: flex; justify-content: flex-end; gap: 8px; } + +/* Scenario cards */ +.scen-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.scen-card { + border: 1px solid var(--panel-brd); border-radius: 12px; padding: 14px; + background: var(--bg2); cursor: pointer; transition: transform .12s, border-color .12s; +} +.scen-card:hover { border-color: var(--gold); transform: translateY(-2px); } +.scen-card h3 { color: var(--gold); margin-bottom: 4px; font-size: 1.02rem; } +.scen-card p { font-size: .82rem; color: var(--ink-dim); margin-bottom: 8px; } +.scen-card .diff { font-size: .74rem; color: var(--warn); letter-spacing: .1em; } +.scen-goals { list-style: none; margin-top: 6px; } +.scen-goals li { font-size: .78rem; color: var(--ink-dim); padding: 1px 0; } + +/* Research */ +.res-track { margin-bottom: 14px; border: 1px solid var(--panel-brd); border-radius: 12px; padding: 10px 12px; background: var(--bg2); } +.res-track h4 { color: var(--accent); margin-bottom: 6px; display:flex; justify-content:space-between; } +.res-items { display: flex; flex-wrap: wrap; gap: 6px; } +.res-item { border: 1px solid var(--panel-brd); border-radius: 9px; padding: 6px 10px; font-size: .8rem; background: var(--bg1); } +.res-item.done { border-color: var(--good); color: var(--good); } +.res-item.avail { cursor: pointer; border-color: var(--warn); } +.res-item.avail:hover { background: #33301c; } +.res-item.avail.cant { opacity:.5; cursor:not-allowed; } + +/* Finances table */ +table.fin { width: 100%; border-collapse: collapse; font-size: .85rem; } +table.fin th, table.fin td { padding: 5px 8px; text-align: right; border-bottom: 1px solid #232a44; } +table.fin th:first-child, table.fin td:first-child { text-align: left; } +table.fin th { color: var(--ink-dim); font-weight: normal; } +.pos { color: var(--good); } .neg { color: var(--bad); } + +/* Hero guild */ +.hero-card { border: 1px solid var(--panel-brd); border-radius: 12px; padding: 10px; background: var(--bg2); display:flex; gap:10px; align-items:center; } +.hero-card .portrait { font-size: 2rem; filter: drop-shadow(0 0 8px rgba(120,160,255,.35)); } +.hero-cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:10px; } +.hp-bar { height: 7px; background:#33202c; border-radius:5px; overflow:hidden; } +.hp-bar>div{height:100%;background:linear-gradient(90deg,#e5484d,#ff8a5c);} +.xp-bar { height: 5px; background:#20303c; border-radius:5px; overflow:hidden; margin-top:3px;} +.xp-bar>div{height:100%;background:var(--accent);} + +/* Help / tutorial */ +.help-cols { columns: 2; column-gap: 26px; font-size: .86rem; color: var(--ink-dim); } +.help-cols h4 { color: var(--gold); margin: 8px 0 4px; break-inside: avoid-column; } +.help-cols p, .help-cols li { margin-bottom: 4px; break-inside: avoid-column; } +kbd { background:#242c4c; border:1px solid #3c486e; border-radius:5px; padding:0 6px; font-family:inherit; font-size:.78rem; color:#cdd6f4;} + +/* Settings rows */ +.set-row { display:flex; justify-content:space-between; align-items:center; padding:7px 0; border-bottom:1px solid #222a46; font-size:.9rem; } +input[type=range]{ accent-color: var(--gold); width:170px;} +input[type=text], input[type=number], select{ + background:#0e1326; color:var(--ink); border:1px solid var(--panel-brd); border-radius:7px; padding:5px 9px; font-family:inherit; +} + +/* ============ Main menu ============ */ +#main-menu { + position: fixed; inset: 0; z-index: 80; + background: + radial-gradient(1100px 500px at 50% -80px, rgba(168,107,255,.28), transparent 60%), + radial-gradient(900px 480px at 85% 108%, rgba(88,193,255,.16), transparent 55%), + radial-gradient(800px 420px at 12% 105%, rgba(245,197,66,.13), transparent 55%), + linear-gradient(180deg, #0a0d1c 0%, #12172c 55%, #1a1430 100%); + display: flex; align-items: center; justify-content: center; +} +.mm-inner { text-align: center; animation: mm-in .7s ease-out; } +@keyframes mm-in { from { opacity: 0; transform: translateY(16px) scale(.98);} to {opacity:1;transform:none;} } +.mm-title { + font-size: clamp(2.6rem, 7vw, 4.6rem); letter-spacing: .18em; color: var(--gold); + text-shadow: 0 0 30px rgba(245,197,66,.45), 0 4px 0 #6b520f; font-weight: 900; +} +.mm-sub { color: #cbb2ff; letter-spacing: .42em; margin: 10px 0 38px; font-size: .95rem; text-transform: uppercase; } +.mm-buttons { display: flex; flex-direction: column; gap: 13px; width: 290px; margin: 0 auto; } +.big-btn { + padding: 14px; font-size: 1.08rem; border-radius: 13px; cursor: pointer; + background: linear-gradient(180deg, #8a6b1d, #5d480d); color: #fff; + border: 1px solid var(--gold); letter-spacing: .06em; + box-shadow: 0 6px 22px rgba(245,197,66,.22); +} +.big-btn:hover { filter: brightness(1.2); transform: translateY(-1px); } +.big-btn.ghost { background: rgba(255,255,255,.05); border-color: var(--panel-brd); color: var(--ink); box-shadow:none; } +.mm-credit { margin-top: 44px; color: #5d6787; font-size: .76rem; letter-spacing: .08em; } + +/* POV overlay (on-ride camera) */ +#pov-overlay { position: fixed; inset: 0; z-index: 70; background: #000; } +#pov-canvas { width: 100%; height: 100%; display:block; } +#pov-hud { + position:absolute; top:14px; left:50%; transform:translateX(-50%); + background:rgba(10,10,25,.7); border:1px solid var(--panel-brd); border-radius:999px; + padding:8px 22px; display:flex; gap:22px; font-size:.9rem; align-items:center; +} +#pov-exit { position:absolute; top:14px; right:14px; } + +/* scrollbars */ +::-webkit-scrollbar{width:9px;height:9px} +::-webkit-scrollbar-thumb{background:#2c3658;border-radius:6px} +::-webkit-scrollbar-track{background:transparent} diff --git a/index.html b/index.html new file mode 100644 index 0000000..e3ad3a9 --- /dev/null +++ b/index.html @@ -0,0 +1,95 @@ + + + + + +Arcane Tycoon — Heroes & Magic Theme Park + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+ + + diff --git a/js/core/audio.js b/js/core/audio.js new file mode 100644 index 0000000..c473d2a --- /dev/null +++ b/js/core/audio.js @@ -0,0 +1,111 @@ +// ============ audio.js — procedural WebAudio SFX & ambient music ============ +let AC = null; +let masterGain = null, musicGain = null, sfxGain = null; +let musicTimer = null; +let enabled = true; +const settings = { master: 0.7, music: 0.5, sfx: 0.8 }; + +export function initAudio() { + if (AC) return; + try { + AC = new (window.AudioContext || window.webkitAudioContext)(); + masterGain = AC.createGain(); + musicGain = AC.createGain(); + sfxGain = AC.createGain(); + musicGain.connect(masterGain); + sfxGain.connect(masterGain); + masterGain.connect(AC.destination); + applyVolumes(); + } catch (e) { console.warn('Audio unavailable', e); enabled = false; } +} +export function audioSupported() { return !!AC; } +export function setVolumes(v) { + Object.assign(settings, v); + try { localStorage.setItem('at_audio', JSON.stringify(settings)); } catch { } + applyVolumes(); +} +export function getVolumes() { return { ...settings }; } +function applyVolumes() { + if (!AC) return; + masterGain.gain.value = settings.master; + musicGain.gain.value = settings.music * 0.5; + sfxGain.gain.value = settings.sfx; +} + +// resume on first user gesture +export function unlockAudio() { + initAudio(); + if (AC && AC.state === 'suspended') AC.resume(); +} + +function tone(freq, dur, type = 'sine', vol = 0.3, dest, slideTo) { + if (!AC || !enabled) return; + const o = AC.createOscillator(); + const g = AC.createGain(); + o.type = type; + o.frequency.value = freq; + if (slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), AC.currentTime + dur); + g.gain.setValueAtTime(vol, AC.currentTime); + g.gain.exponentialRampToValueAtTime(0.0001, AC.currentTime + dur); + o.connect(g); g.connect(dest || sfxGain); + o.start(); o.stop(AC.currentTime + dur + 0.02); +} +function noise(dur, vol = 0.2, filterFreq = 800) { + if (!AC || !enabled) return; + const len = AC.sampleRate * dur; + const buf = AC.createBuffer(1, len, AC.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1; + const src = AC.createBufferSource(); + src.buffer = buf; + const f = AC.createBiquadFilter(); + f.type = 'lowpass'; f.frequency.value = filterFreq; + const g = AC.createGain(); + g.gain.setValueAtTime(vol, AC.currentTime); + g.gain.exponentialRampToValueAtTime(0.0001, AC.currentTime + dur); + src.connect(f); f.connect(g); g.connect(sfxGain); + src.start(); +} + +export const sfx = { + click() { tone(660, .06, 'square', .12); }, + place() { noise(.08, .25, 500); tone(220, .1, 'triangle', .2); }, + demolish() { noise(.22, .3, 300); }, + cash() { tone(880, .09, 'sine', .18); setTimeout(() => tone(1320, .12, 'sine', .16), 70); }, + error() { tone(180, .18, 'sawtooth', .15, null, 120); }, + openRide() { [440, 554, 659, 880].forEach((f, i) => setTimeout(() => tone(f, .14, 'triangle', .15), i * 90)); }, + whoosh() { noise(.5, .18, 900); }, + spell() { + if (!AC) return; + [660, 830, 990, 1245].forEach((f, i) => setTimeout(() => tone(f, .3, 'sine', .12), i * 60)); + }, + hit() { noise(.07, .28, 700); tone(140, .08, 'square', .14); }, + monsterRoar() { tone(90, .5, 'sawtooth', .25, null, 50); noise(.4, .2, 250); }, + victory() { [523, 659, 784, 1046].forEach((f, i) => setTimeout(() => tone(f, .35, 'triangle', .2), i * 160)); }, + defeat() { [400, 340, 280, 200].forEach((f, i) => setTimeout(() => tone(f, .4, 'sawtooth', .15), i * 200)); }, + levelup() { [600, 750, 900].forEach((f, i) => setTimeout(() => tone(f, .12, 'square', .1), i * 80)); }, +}; + +// ------- generative ambient music: gentle fantasy pad loop ------- +const SCALE = [261.63, 293.66, 329.63, 392.00, 440.00, 523.25]; // C pentatonic-ish +let musicOn = false; +export function startMusic() { + if (!AC || musicOn) return; + musicOn = true; + const stepFn = () => { + if (!musicOn) return; + // soft chord every ~2s + const root = SCALE[Math.floor(Math.random() * 3)]; + const third = SCALE[Math.floor(Math.random() * SCALE.length)]; + tone(root / 2, 2.4, 'sine', .10, musicGain); + tone(third, 2.2, 'triangle', .05, musicGain); + if (Math.random() < 0.4) tone(root * 2, 1.8, 'sine', .04, musicGain); + musicTimer = setTimeout(stepFn, 1800 + Math.random() * 900); + }; + stepFn(); +} +export function stopMusic() { + musicOn = false; + if (musicTimer) clearTimeout(musicTimer); +} +export function isMusicOn() { return musicOn; } diff --git a/js/core/config.js b/js/core/config.js new file mode 100644 index 0000000..3901c29 --- /dev/null +++ b/js/core/config.js @@ -0,0 +1,308 @@ +// ============ config.js — all game data & tuning constants ============ + +export const TILE_W = 64, TILE_H = 32; // iso tile size at zoom 1 +export const Z_STEP = 14; // pixels per height unit at zoom 1 + +export const TERRAIN = { + 0: { name: 'Grass', base: '#4d8a3d', alt: '#57a047', walk: false }, + 1: { name: 'Sand', base: '#cbb26a', alt: '#d5bd77', walk: false }, + 2: { name: 'Rock', base: '#7a7f8a', alt: '#868b96', walk: false }, + 3: { name: 'Water', base: '#2e6db4', alt: '#3a7cc9', walk: false }, +}; + +// Directions: E, S, W, N (screen: +x = lower-right, +y = lower-left) +export const DIRS = [[1, 0], [0, 1], [-1, 0], [0, -1]]; +export const DIR_NAMES = ['E', 'S', 'W', 'N']; + +export const PATH_TYPES = { + pavement: { name: 'Pavement', cost: 10, color: '#b8b2a5', edge: '#8d887c' }, + cobble: { name: 'Cobblestone', cost: 14, color: '#6f6a80', edge: '#514d60' }, +}; + +// ---------------- RIDES ---------------- +// stats: excite/intensity/nausea base; rideTime sec; capacity guests per cycle +export const RIDE_TYPES = { + carousel: { + id: 'carousel', name: 'Unicorn Carousel', icon: '🎠', w: 2, h: 2, cost: 2200, + runCost: 18, rideTime: 22, capacity: 12, excite: 2.4, intensity: 1.6, nausea: 1.1, + tier: 0, desc: 'A gentle classic. Glittering unicorns spin beneath golden canopy.', + color: '#e59ae0', color2: '#f5c542', + }, + ferris: { + id: 'ferris', name: 'Sky Wheel', icon: '🎡', w: 3, h: 3, cost: 4200, + runCost: 30, rideTime: 40, capacity: 20, excite: 3.2, intensity: 2.0, nausea: 1.4, + tier: 0, desc: 'Tower above the park in a glass gondola with views for miles.', + color: '#7fb2ff', color2: '#ffd166', + }, + teacups: { + id: 'teacups', name: "Wizard's Teacups", icon: '🫖', w: 2, h: 2, cost: 1800, + runCost: 15, rideTime: 25, capacity: 15, excite: 2.8, intensity: 3.4, nausea: 4.2, + tier: 0, desc: 'Spinning cups of a very caffeinated sorcerer. Nauseating fun!', + color: '#ff9d76', color2: '#ffe08a', + }, + drop_tower: { + id: 'drop_tower', name: 'Gravity Spire', icon: '🗼', w: 2, h: 2, cost: 5200, + runCost: 38, rideTime: 18, capacity: 16, excite: 5.6, intensity: 7.2, nausea: 3.4, + tier: 1, desc: 'Rise 40 meters… then let gravity have you. A breath-stealer.', + color: '#b0b6c8', color2: '#ff5c5c', + }, + swings: { + id: 'swings', name: 'Fairy Swings', icon: '🎐', w: 3, h: 2, cost: 3400, + runCost: 24, rideTime: 28, capacity: 16, excite: 3.6, intensity: 3.0, nausea: 2.4, + tier: 1, desc: 'Fly on enchanted chairs steered by tiny fairies.', + color: '#a5e6ff', color2: '#f7a8ff', + }, + haunted: { + id: 'haunted', name: 'Haunted Crypt', icon: '👻', w: 3, h: 3, cost: 4800, + runCost: 34, rideTime: 45, capacity: 18, excite: 5.2, intensity: 4.6, nausea: 2.0, + tier: 1, desc: 'Dark ride through crypts guarded by very committed ghosts.', + color: '#9a86c8', color2: '#5de0c8', + }, + logflume: { + id: 'logflume', name: 'River Sprite Flume', icon: '🛶', w: 4, h: 6, cost: 7200, + runCost: 46, rideTime: 70, capacity: 20, excite: 5.8, intensity: 4.8, nausea: 3.0, + tier: 2, desc: 'Meandering river channel ending in a mighty splash.', + color: '#63c5ea', color2: '#8d5a2b', + }, + dragon_coaster: { + id: 'dragon_coaster', name: 'Dragonling Coaster', icon: '🐉', w: 5, h: 5, cost: 9000, + runCost: 55, rideTime: 60, capacity: 24, excite: 6.8, intensity: 6.4, nausea: 3.6, + tier: 2, desc: 'A ready-made junior coaster ridden on friendly young dragons.', + color: '#66d977', color2: '#f43f5e', + }, + portal: { + id: 'portal', name: 'Portal Blasters', icon: '🌀', w: 3, h: 3, cost: 8500, + runCost: 50, rideTime: 50, capacity: 16, excite: 6.2, intensity: 5.4, nausea: 2.2, + tier: 2, desc: 'Shoot glowing orbs across dimensions from your hover-chair.', + color: '#a86bff', color2: '#58c1ff', + }, + broom_tower: { + id: 'broom_tower', name: 'Broomstick Tower', icon: '🧹', w: 2, h: 2, cost: 6800, + runCost: 42, rideTime: 35, capacity: 14, excite: 6.0, intensity: 5.0, nausea: 3.2, + tier: 3, desc: 'Straddle a racing broom as it spirals up and around the spire.', + color: '#c98d4e', color2: '#ffd166', + }, +}; + +// ---------------- COASTER PIECES ---------------- +export const PIECES = { + straight: { id: 'straight', name: 'Straight', icon: '━', cost: 120, dz: 0, turn: 0 }, + curveL: { id: 'curveL', name: 'Curve Left', icon: '↰', cost: 140, dz: 0, turn: -1 }, + curveR: { id: 'curveR', name: 'Curve Right',icon: '↱', cost: 140, dz: 0, turn: 1 }, + up: { id: 'up', name: 'Slope Up', icon: '↗', cost: 170, dz: 1, turn: 0 }, + down: { id: 'down', name: 'Slope Down', icon: '↘', cost: 150, dz: -1, turn: 0 }, + steepUp: { id: 'steepUp', name: 'Steep Up', icon: '⇗', cost: 210, dz: 2, turn: 0 }, + steepDown: { id: 'steepDown', name: 'Steep Drop', icon: '⇘', cost: 190, dz: -2, turn: 0 }, + loop: { id: 'loop', name: 'Loop', icon: '◯', cost: 400, dz: 0, turn: 0 }, + station: { id: 'station', name: 'Station', icon: '▤', cost: 300, dz: 0, turn: 0 }, +}; +export const MAX_Z = 14; +export const MIN_COASTER_PIECES = 6; + +// ---------------- SHOPS ---------------- +export const SHOP_TYPES = { + drinks: { id: 'drinks', name: 'Potion Fizz', icon: '🥤', cost: 500, price: 6, need: 'thirst', needFix: 75, stock: 250, tier: 0, desc: 'Sparkling tonics in wild colors.' }, + food: { id: 'food', name: 'Dragon Grill', icon: '🍗', cost: 650, price: 9, need: 'hunger', needFix: 80, stock: 250, tier: 0, desc: 'Flame-grilled drumsticks (mildly fireproof).' }, + icecream: { id: 'icecream', name: 'Frost Imp Cream', icon: '🍦', cost: 550, price: 7, need: 'thirst', needFix: 55, stock: 200, tier: 0, desc: 'Ice cream that whispers frost puns.' }, + souvenir: { id: 'souvenir', name: 'Curiosity Shop', icon: '🎁', cost: 700, price: 14, need: 'shop', needFix: 100, stock: 999999, tier: 0, desc: 'Wands, hats and questionable relics.', happyBoost: 8 }, + toilet: { id: 'toilet', name: 'Restrooms', icon: '🚻', cost: 400, price: 2, need: 'toilet', needFix: 100, stock: 999999, tier: 0, desc: 'Essential plumbing. Guests will thank you.' }, + balloon: { id: 'balloon', name: 'Balloon Stall', icon: '🎈', cost: 350, price: 5, need: 'shop', needFix: 100, stock: 999999, tier: 0, desc: 'Floating joy on a string.', happyBoost: 6 }, + firstaid: { id: 'firstaid', name: 'Healers Hut', icon: '⛑️', cost: 600, price: 0, need: 'health', needFix: 100, stock: 999999, tier: 1, desc: 'Patch up guests who overdid the loops.' }, +}; + +// ---------------- SCENERY ---------------- +export const SCENERY_TYPES = { + tree_oak: { id: 'tree_oak', name: 'Oak Tree', icon: '🌳', cost: 45, size: 1, beauty: 3 }, + tree_pine: { id: 'tree_pine', name: 'Pine Tree', icon: '🌲', cost: 45, size: 1, beauty: 3 }, + tree_cherry: { id: 'tree_cherry', name: 'Cherry Blossom',icon: '🌸', cost: 90, size: 1, beauty: 6, tier: 1 }, + flowerbed: { id: 'flowerbed', name: 'Flower Bed', icon: '🌷', cost: 35, size: 1, beauty: 4 }, + hedge: { id: 'hedge', name: 'Hedge', icon: '🌿', cost: 30, size: 1, beauty: 2 }, + bench: { id: 'bench', name: 'Bench', icon: '🪑', cost: 40, size: 1, beauty: 1, rest: true }, + bin: { id: 'bin', name: 'Litter Bin', icon: '🗑️', cost: 30, size: 1, beauty: 0, antiLitter: 6 }, + lamp: { id: 'lamp', name: 'Street Lamp', icon: '🏮', cost: 55, size: 1, beauty: 2, light: 4 }, + fountain: { id: 'fountain', name: 'Fountain', icon: '⛲', cost: 380, size: 1, beauty: 10, tier: 0 }, + statue_knight:{id:'statue_knight',name: 'Knight Statue', icon: '🗿', cost: 260, size: 1, beauty: 8, tier: 1 }, + statue_dragon:{id:'statue_dragon',name: 'Dragon Statue', icon: '🐲', cost: 520, size: 2, beauty: 14, tier: 2 }, + crystal_lamp:{ id: 'crystal_lamp',name: 'Crystal Lamp', icon: '💎', cost: 160, size: 1, beauty: 5, light: 7, manaCap: 5, tier: 1 }, + ley_pool: { id: 'ley_pool', name: 'Ley Pool', icon: '🔮', cost: 800, size: 2, beauty: 8, manaCap: 25, manaRegen: .5, light: 6, tier: 0, magic: true, desc: 'A pool of raw magic. Raises max mana & regen.' }, + rune_stone: { id: 'rune_stone', name: 'Rune Stone', icon: '🪨', cost: 300, size: 1, beauty: 6, manaCap: 10, manaRegen: .2, tier: 1, magic: true, desc: 'Ancient stone humming with power.' }, + mushroom_glow:{id:'mushroom_glow',name: 'Glowcap Cluster',icon:'🍄', cost: 120, size: 1, beauty: 5, light: 5, manaCap: 3, tier: 1, magic: true }, + banner: { id: 'banner', name: 'Park Banner', icon: '🚩', cost: 50, size: 1, beauty: 3 }, +}; + +// ---------------- STAFF ---------------- +export const STAFF_TYPES = { + handyman: { id: 'handyman', name: 'Handyman', icon: '🧹', wage: 14, desc: 'Sweeps litter & vomit, waters flowers.' }, + mechanic: { id: 'mechanic', name: 'Mechanic', icon: '🔧', wage: 22, desc: 'Inspects & repairs rides.' }, + guard: { id: 'guard', name: 'Guard', icon: '💂', wage: 18, desc: 'Deters vandalism near shops & rides.' }, + entertainer: { id: 'entertainer', name: 'Court Jester', icon: '🤡', wage: 16, desc: 'Entertains queuing guests (+happiness).' }, +}; + +// ---------------- HEROES ---------------- +export const HERO_CLASSES = { + knight: { id: 'knight', name: 'Knight', icon: '🛡️', hp: 130, dmg: 14, range: 1.2, speed: 1.9, atkCd: 1.1, cost: 800, tier: 0, desc: 'Stalwart frontline defender.' }, + ranger: { id: 'ranger', name: 'Ranger', icon: '🏹', hp: 75, dmg: 11, range: 4.0, speed: 2.4, atkCd: .9, cost: 750, tier: 0, desc: 'Strikes from afar with enchanted arrows.' }, + mage: { id: 'mage', name: 'Battle Mage', icon: '🧙', hp: 60, dmg: 19, range: 3.4, speed: 1.8, atkCd: 1.6, cost: 950, tier: 0, aoe: 1.6, desc: 'Hurls arcane bolts that splash damage.' }, + cleric: { id: 'cleric', name: 'Cleric', icon: '⚕️', hp: 70, dmg: 4, range: 3.2, speed: 2.0, atkCd: 1.4, cost: 900, tier: 1, heal: 10, desc: 'Heals nearby heroes every second.' }, + paladin: { id: 'paladin', name: 'Paladin', icon: '⚔️', hp: 175, dmg: 17, range: 1.3, speed: 1.8, atkCd: 1.2, cost: 1500, tier: 2, desc: 'Holy warrior with immense staying power.' }, +}; +export const MAX_HEROES = 6; + +// ---------------- MONSTERS ---------------- +export const MONSTER_TYPES = { + slime: { id: 'slime', name: 'Slime', icon: '🟢', hp: 34, dmg: 6, speed: .9, gold: 40, xp: 10, threat: 1 }, + goblin: { id: 'goblin', name: 'Goblin', icon: '👺', hp: 52, dmg: 9, speed: 1.6, gold: 60, xp: 16, threat: 2 }, + wolf: { id: 'wolf', name: 'Dire Wolf', icon: '🐺', hp: 44, dmg: 11, speed: 2.4, gold: 70, xp: 18, threat: 2 }, + brute: { id: 'brute', name: 'Troll Brute', icon: '👹', hp: 240, dmg: 24, speed: .95, gold: 260, xp: 60, threat: 5 }, + boss: { id: 'boss', name: 'Void Wraith', icon: '☠️', hp: 700, dmg: 34, speed: 1.2, gold: 900, xp: 200, threat: 10 }, +}; + +// ---------------- SPELLS ---------------- +export const SPELLS = { + sunburst: { id: 'sunburst', name: 'Sunburst', icon: '☀️', mana: 15, cd: 60, dur: 0, tier: 0, + desc: 'Instantly clear the skies to sunny weather.' }, + joy_aura: { id: 'joy_aura', name: 'Joy Aura', icon: '😊', mana: 25, cd: 45, dur: 20, tier: 0, + desc: 'All guests gain steady happiness while active.' }, + healing_light: { id: 'healing_light', name: 'Healing Light', icon: '💚', mana: 30, cd: 50, dur: 0, tier: 0, + desc: 'Fully heals all heroes instantly.' }, + fortune_rain: { id: 'fortune_rain', name: 'Fortune Rain', icon: '💸', mana: 40, cd: 100, dur: 30, tier: 1, + desc: 'Guests spend 60% more for the duration.' }, + swift_build: { id: 'swift_build', name: "Artificer's Haste", icon: '⚡', mana: 35, cd: 130, dur: 25, tier: 1, + desc: 'Construction is instant and half price while active.' }, + monster_bane: { id: 'monster_bane', name: 'Monster Bane', icon: '💥', mana: 45, cd: 90, dur: 0, tier: 2, + desc: 'Deals 70 damage to every monster in the park.' }, + warding_sigil: { id: 'warding_sigil', name: 'Warding Sigil', icon: '🛡️', mana: 50, cd: 160, dur: 60, tier: 2, + desc: 'Blocks invasions; monsters flee while active.' }, + transmute: { id: 'transmute', name: 'Transmutation', icon: '🪙', mana: 55, cd: 150, dur: 0, tier: 3, + desc: 'Conjure $900 from thin air.' }, +}; + +// ---------------- RESEARCH ---------------- +export const RESEARCH_TRACKS = { + rides: { id: 'rides', name: 'Ride Engineering', icon: '🎢' }, + trade: { id: 'trade', name: 'Commerce', icon: '🏪' }, + magic: { id: 'magic', name: 'Arcane Studies', icon: '✨' }, + heroes: { id: 'heroes', name: 'Heroes Guild', icon: '⚔️' }, +}; +// unlock entries: {key, track, rp, label} +export const UNLOCKS = [ + // rides + { key: 'drop_tower', track: 'rides', rp: 110, label: 'Gravity Spire', kind: 'ride' }, + { key: 'swings', track: 'rides', rp: 80, label: 'Fairy Swings', kind: 'ride' }, + { key: 'haunted', track: 'rides', rp: 140, label: 'Haunted Crypt', kind: 'ride' }, + { key: 'logflume', track: 'rides', rp: 220, label: 'River Sprite Flume', kind: 'ride' }, + { key: 'dragon_coaster', track: 'rides', rp: 260, label: 'Dragonling Coaster', kind: 'ride' }, + { key: 'portal', track: 'rides', rp: 300, label: 'Portal Blasters', kind: 'ride' }, + { key: 'broom_tower',track: 'rides', rp: 380, label: 'Broomstick Tower', kind: 'ride' }, + // commerce + { key: 'icecream', track: 'trade', rp: 60, label: 'Frost Imp Cream', kind: 'shop' }, + { key: 'balloon', track: 'trade', rp: 40, label: 'Balloon Stall', kind: 'shop' }, + { key: 'firstaid', track: 'trade', rp: 90, label: 'Healers Hut', kind: 'shop' }, + { key: 'cobble', track: 'trade', rp: 50, label: 'Cobblestone paths', kind: 'path' }, + { key: 'statue_dragon', track: 'trade', rp: 130, label: 'Dragon Statue', kind: 'scenery' }, + // magic + { key: 'ley_pool', track: 'magic', rp: 100, label: 'Ley Pool', kind: 'scenery' }, + { key: 'rune_stone', track: 'magic', rp: 70, label: 'Rune Stone', kind: 'scenery' }, + { key: 'mushroom_glow', track: 'magic', rp: 50, label: 'Glowcap Cluster', kind: 'scenery' }, + { key: 'crystal_lamp', track: 'magic', rp: 60, label: 'Crystal Lamp', kind: 'scenery' }, + { key: 'tree_cherry', track: 'magic', rp: 40, label: 'Cherry Blossom', kind: 'scenery' }, + { key: 'fortune_rain', track: 'magic', rp: 120, label: 'Spell: Fortune Rain', kind: 'spell' }, + { key: 'swift_build', track: 'magic', rp: 130, label: 'Spell: Artificer\u2019s Haste', kind: 'spell' }, + { key: 'monster_bane', track: 'magic', rp: 200, label: 'Spell: Monster Bane', kind: 'spell' }, + { key: 'warding_sigil',track: 'magic', rp: 240, label: 'Spell: Warding Sigil', kind: 'spell' }, + { key: 'transmute', track: 'magic', rp: 320, label: 'Spell: Transmutation', kind: 'spell' }, + // heroes + { key: 'cleric', track: 'heroes', rp: 110, label: 'Cleric class', kind: 'hero' }, + { key: 'paladin', track: 'heroes', rp: 260, label: 'Paladin class', kind: 'hero' }, + { key: 'guild2', track: 'heroes', rp: 150, label: 'Guild Hall II (+2 roster)', kind: 'heroCap' }, + { key: 'gear2', track: 'heroes', rp: 190, label: 'Fine Gear (+25% hero power)', kind: 'heroGear' }, +]; + +// ---------------- GUESTS ---------------- +export const GUEST_NAMES = ['Ada','Bram','Cora','Dilan','Elke','Finn','Gwen','Hugo','Iris','Jasper','Kira','Liam','Mira','Noah','Odette','Pim','Quinn','Rosa','Sven','Tilda','Ulf','Vera','Wren','Xavi','Yara','Zane','Bree','Cato','Dora','Eppo','Faye','Gus','Hanne','Ivo','Jet','Koos','Loes','Mads','Nienke','Otto','Puck','Rens','Saar','Ties','Usko','Vos','Wilma','Ylva','Zeno']; +export const GUEST_COLORS = ['#e05b5b','#5b8ee0','#57d97a','#e0b23d','#b45be0','#5bd9c9','#ff9d76','#8fa3ff']; + +export const THOUGHTS = { + great_ride: ['{r} was amazing!', 'Best. Ride. Ever!', '{r} made my day!'], + good_ride: ['{r} was fun!', 'Enjoyed {r} a lot.'], + meh_ride: ["{r} was okay I guess.", '{r} could be better…'], + scary: ['{r} was terrifying!', 'Never again on {r}!'], + hungry: ["I'm starving", 'Need food soon'], + thirsty: ["I'm so thirsty", 'Could really use a drink'], + toilet: ['I need a restroom!', 'Where are the toilets?!'], + broke: ["I've spent all my money", 'Everything costs gold…'], + happy_park: ['What a lovely park!', 'This place is magical!'], + litter: ['All this litter…', 'Someone should clean up!'], + vandal: ['Vandals everywhere!', 'This park feels unsafe'], + monster: ['MONSTER!! Run!', 'Help! Heroes! Help!'], + long_queue: ['Such a long queue…', 'Waiting forever for {r}'], + broken: ['{r} has broken down again!'], + expensive: ['{r} is pricey…', 'Entrance fee is steep!'], + no_exit: ["Can't find the way out!", 'This maze of paths…'], +}; + +// ---------------- WEATHER ---------------- +export const WEATHER = { + sunny: { id: 'sunny', name: 'Sunny', icon: '☀️', spawnMul: 1.15, happyDrain: -.02, tint: null }, + cloudy: { id: 'cloudy', name: 'Cloudy', icon: '⛅', spawnMul: 1.0, happyDrain: 0, tint: 'rgba(120,130,160,.08)' }, + rain: { id: 'rain', name: 'Rain', icon: '🌧️', spawnMul: .6, happyDrain: .05, tint: 'rgba(60,80,140,.18)' }, + storm: { id: 'storm', name: 'Storm', icon: '⛈️', spawnMul: .35, happyDrain: .12, tint: 'rgba(30,40,90,.28)' }, +}; + +// ---------------- SCENARIOS ---------------- +export const SCENARIOS = [ + { + id: 'meadows', name: 'Enchanted Meadows', diff: 'EASY', icon: '🌻', + blurb: 'Rolling green fields beside a sleepy lake. Perfect ground for a first magical kingdom.', + cash: 30000, loanLimit: 20000, mapSeed: 1337, mapSize: 52, + gen: { lake: 1, trees: 90, rocks: 12, sand: true }, + goals: [ + { id: 'guests', text: 'Have 220 guests in the park', type: 'guests', value: 220 }, + { id: 'rating', text: 'Reach park rating 450', type: 'rating', value: 450 }, + { id: 'cash', text: 'Grow park value to $45,000', type: 'cash', value: 45000 }, + ], + loseCash: -8000, invasionStartMonth: 4, invasionEvery: 4, invasionScale: 1, + }, + { + id: 'dragonspine', name: 'Dragonspine Pass', diff: 'MEDIUM', icon: '🏔️', + blurb: 'A rocky mountain pass where dragons nest. Build fast — the horde comes early and often.', + cash: 26000, loanLimit: 25000, mapSeed: 4242, mapSize: 56, + gen: { lake: 1, trees: 60, rocks: 60, sand: false, rocky: true }, + goals: [ + { id: 'guests', text: 'Have 320 guests in the park', type: 'guests', value: 320 }, + { id: 'rating', text: 'Reach park rating 550', type: 'rating', value: 550 }, + { id: 'invasions', text: 'Repel 3 monster invasions', type: 'invasions', value: 3 }, + { id: 'coaster', text: 'Open a custom coaster with excitement ≥ 5.0', type: 'coasterExcite', value: 5 }, + ], + loseCash: -10000, invasionStartMonth: 2, invasionEvery: 3, invasionScale: 1.4, + }, + { + id: 'voidrift', name: 'Void Rift Crisis', diff: 'HARD', icon: '🌌', + blurb: 'The sky is torn. Void wraiths pour through rifts while guests still demand roller coasters. Heroes wanted.', + cash: 24000, loanLimit: 30000, mapSeed: 9021, mapSize: 56, + gen: { lake: 1, trees: 40, rocks: 80, sand: false, rocky: true, void: true }, + goals: [ + { id: 'guests', text: 'Have 420 guests in the park', type: 'guests', value: 420 }, + { id: 'rating', text: 'Reach park rating 620', type: 'rating', value: 620 }, + { id: 'boss', text: 'Defeat a Void Wraith', type: 'bossKill', value: 1 }, + { id: 'coaster', text: 'Open a custom coaster with excitement ≥ 6.0', type: 'coasterExcite', value: 6 }, + ], + loseCash: -12000, invasionStartMonth: 1, invasionEvery: 2, invasionScale: 1.9, bossAt: 3, + }, + { + id: 'sandbox', name: 'Sandbox Kingdom', diff: 'SANDBOX', icon: '🧪', + blurb: 'Unlimited money, everything unlocked. Build the impossible.', + cash: 1000000, loanLimit: 0, mapSeed: 777, mapSize: 64, sandbox: true, + gen: { lake: 2, trees: 110, rocks: 20, sand: true }, + goals: [], loseCash: -999999999, invasionStartMonth: 6, invasionEvery: 5, invasionScale: 1.2, + }, +]; + +export const AWARDS_POOL = [ + { id: 'prettiest', name: 'Prettiest Park', test: s => s.avgBeauty > 4 }, + { id: 'safest', name: 'Safest Kingdom', test: s => s.heroStats.kills > 20 && s.vandalism < 3 }, + { id: 'tidiest', name: 'Tidiest Park', test: s => s.litterCount < 8 }, + { id: 'thrills', name: 'Best Thrills', test: s => s.bestExcite >= 7 }, + { id: 'foodie', name: 'Finest Dining', test: s => s.shops.length >= 5 && s.avgHappy > 65 }, + { id: 'magical', name: 'Most Magical Park', test: s => s.magicCount >= 6 }, +]; diff --git a/js/core/util.js b/js/core/util.js new file mode 100644 index 0000000..45d7ab4 --- /dev/null +++ b/js/core/util.js @@ -0,0 +1,88 @@ +// ============ util.js — shared helpers ============ + +export const clamp = (v, a, b) => v < a ? a : v > b ? b : v; +export const lerp = (a, b, t) => a + (b - a) * t; +export const dist2 = (x1, y1, x2, y2) => { const dx = x2 - x1, dy = y2 - y1; return dx * dx + dy * dy; }; +export const dist = (x1, y1, x2, y2) => Math.sqrt(dist2(x1, y1, x2, y2)); + +let _uid = 1; +export const uid = () => _uid++; +export function resetUid(v) { _uid = v; } +export const curUid = () => _uid; + +/** Mulberry32 seeded RNG */ +export function makeRng(seed) { + let a = seed >>> 0; + const fn = function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + fn.getState = () => a; + fn.setState = v => { a = v >>> 0; }; + return fn; +} + +export function fmtMoney(n, signed = false) { + const neg = n < 0; + let v = Math.abs(Math.round(n)); + let s; + if (v >= 1e9) s = (v / 1e9).toFixed(2) + 'B'; + else if (v >= 1e6) s = (v / 1e6).toFixed(2) + 'M'; + else s = v.toLocaleString('en-US'); + return (neg ? '-$' : (signed && n > 0 ? '+$' : '$')) + s; +} +export function fmtNum(n) { return Math.round(n).toLocaleString('en-US'); } + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; +export function fmtDate(t) { + return `${MONTHS[t.month]} ${t.day}, Year ${t.year}`; +} +export function fmtClock(t) { + const h = Math.floor(t.hour), m = Math.floor((t.hour - h) * 60); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +/** pick weighted item from [[item, weight], ...] or array with .w prop */ +export function weightedPick(rng, items) { + let total = 0; + for (const it of items) total += (it.w !== undefined ? it.w : it[1]); + let r = rng() * total; + for (const it of items) { + r -= (it.w !== undefined ? it.w : it[1]); + if (r <= 0) return it.it !== undefined ? it.it : it[0]; + } + return items[items.length - 1]; +} + +export const choice = (rng, arr) => arr[Math.floor(rng() * arr.length)]; +export const chance = (rng, p) => rng() < p; + +/** DOM helper */ +export function el(tag, attrs = {}, ...children) { + const e = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'class') e.className = v; + else if (k === 'html') e.innerHTML = v; + else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2).toLowerCase(), v); + else if (v !== null && v !== undefined) e.setAttribute(k, v); + } + for (const c of children.flat()) { + if (c === null || c === undefined) continue; + e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + } + return e; +} + +export function download(filename, text) { + try { + const blob = new Blob([text], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = filename; + document.body.appendChild(a); a.click(); + setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 400); + return true; + } catch (e) { console.error('download failed', e); return false; } +} diff --git a/js/game/coaster.js b/js/game/coaster.js new file mode 100644 index 0000000..de17ea1 --- /dev/null +++ b/js/game/coaster.js @@ -0,0 +1,266 @@ +// ============ coaster.js — custom roller coaster designer & physics ============ +import { PIECES, DIRS, MAX_Z, MIN_COASTER_PIECES } from '../core/config.js'; +import { pay } from './economy.js'; +import { clamp } from '../core/util.js'; +import { addRideObj } from './state.js'; + +const rng = Math.random; + +// ---------------- build session ---------------- +export function startCoasterSession(state, x, y, dir) { + // station must touch a path tile so guests can reach it + const okAdj = [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]].some(c => state.map.isPath(c[0], c[1])); + if (!okAdj) return { error: 'The station must be adjacent to a path.' }; + if (!state.map.isBuildable(x, y) || state.map.occupied(x, y)) return { error: 'Blocked location.' }; + const session = { + active: true, + pieces: [{ type: 'station', x, y, z: 0, dir, lift: false }], + cx: x, cy: y, cz: 0, cdir: dir, + spent: 300, + name: 'Custom Coaster ' + (state.rides.filter(r => r.isCustomCoaster).length + 1), + trainColor: '#e05b5b', + }; + state.map.setObject(x, y, { kind: 'track', id: -1 }); + state._coasterBuild = session; + return session; +} + +export function sessionActive(state) { return !!state._coasterBuild?.active; } +export function getSession(state) { return state._coasterBuild || null; } + +export function nextCellFor(session, pieceId) { + const def = PIECES[pieceId]; + const [dx, dy] = DIRS[session.cdir]; + return { + x: session.cx + dx, + y: session.cy + dy, + z: session.cz + def.dz, + dir: (session.cdir + def.turn + 4) % 4, + }; +} + +export function validatePiece(state, session, pieceId) { + const def = PIECES[pieceId]; + const nc = nextCellFor(session, pieceId); + const m = state.map; + if (!m.inBounds(nc.x, nc.y)) return { ok: false, reason: 'Outside the park bounds' }; + if (m.terrainAt(nc.x, nc.y) === 3) return { ok: false, reason: "Can't build over water" }; + if (m.objects[m.idx(nc.x, nc.y)]) { + const o = m.getObject(nc.x, nc.y); + if (!(o.kind === 'track')) return { ok: false, reason: 'Blocked by ' + o.kind }; + return { ok: false, reason: 'Track already here' }; + } + if (nc.z < 0) return { ok: false, reason: "Can't go underground" }; + if (nc.z > MAX_Z) return { ok: false, reason: 'Too high!' }; + return { ok: true, cell: nc }; +} + +export function pieceCost(state, pieceId) { + let c = PIECES[pieceId].cost; + if (state.spells.active.swift_build) c *= 0.5; + return Math.round(c); +} + +export function addPiece(state, pieceId) { + const session = getSession(state); + if (!session) return { error: 'No active session' }; + const v = validatePiece(state, session, pieceId); + if (!v.ok) return { error: v.reason }; + const def = PIECES[pieceId]; + const cost = pieceCost(state, pieceId); + if (!state.sandbox && state.cash < cost) return { error: 'Not enough money' }; + if (!state.sandbox) pay(state, cost, 'construction'); + session.spent += cost; + // lift hill: ascending pieces before the first descent + const hasDrop = session.pieces.some(p => p.type === 'down' || p.type === 'steepDown'); + const lift = !hasDrop && (pieceId === 'up' || pieceId === 'steepUp'); + session.pieces.push({ type: pieceId, ...v.cell, lift }); + session.cx = v.cell.x; session.cy = v.cell.y; session.cz = v.cell.z; session.cdir = v.cell.dir; + state.map.setObject(v.cell.x, v.cell.y, { kind: 'track', id: -1 }); + state.map.pathType[state.map.idx(v.cell.x, v.cell.y)] = 0; + return { ok: true }; +} + +export function undoPiece(state) { + const session = getSession(state); + if (!session || session.pieces.length <= 1) return false; + const last = session.pieces.pop(); + state.map.clearObject(last.x, last.y); + // recompute cursor from new last piece + const cur = session.pieces[session.pieces.length - 1]; + const def = PIECES[cur.type]; + session.cx = cur.x; session.cy = cur.y; session.cz = cur.z; session.cdir = cur.dir; + session.spent = Math.max(300, session.spent - PIECES[last.type].cost); + return true; +} + +export function cancelCoaster(state) { + const session = getSession(state); + if (!session) return; + for (const p of session.pieces) state.map.clearObject(p.x, p.y); + state._coasterBuild = null; +} + +export function isCircuitClosed(session) { + const st = session.pieces[0]; + const [dx, dy] = [DIRS[st.dir][0], DIRS[st.dir][1]]; + return ( + session.cx === st.x - dx && session.cy === st.y - dy && + session.cz === st.z && session.cdir === st.dir && + session.pieces.length >= MIN_COASTER_PIECES + ); +} + +export function finishCoaster(state) { + const session = getSession(state); + if (!session) return { error: 'No active session' }; + if (session.pieces.length < MIN_COASTER_PIECES) return { error: `Need at least ${MIN_COASTER_PIECES} pieces` }; + if (!isCircuitClosed(session)) { + return { error: 'The track must form a complete circuit back to the station!' }; + } + const stats = computeStats(session.pieces); + // choose an entrance piece that touches an external path (guests must reach it) + const DIR4 = DIRS; + let entPiece = session.pieces[0], bestEntD = Infinity; + for (const p of session.pieces) { + for (const [dx, dy] of DIR4) { + if (state.map.isPath(p.x + dx, p.y + dy)) { + const st0 = session.pieces[0]; + const d = Math.abs(p.x - st0.x) + Math.abs(p.y - st0.y); + if (d < bestEntD) { bestEntD = d; entPiece = p; } + break; + } + } + } + if (bestEntD === Infinity) { + state.toasts.push({ kind: 'info', title: `${session.name} built`, text: 'Tip: connect a path next to the track so guests can queue!' }); + } + // bounding box footprint + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of session.pieces) { + minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); + } + const w = maxX - minX + 1, h = maxY - minY + 1; + const station = session.pieces[0]; + const ride = addRideObj(state, 'dragon_coaster', minX, minY, { + name: session.name, + coaster: true, + track: session.pieces.map(p => ({ ...p })), + stats, + entranceX: entPiece.x, entranceY: entPiece.y, + }); + // override footprint fields set by addRideObj from def + ride.w = w; ride.h = h; + ride.excite = stats.excitement; ride.intensity = stats.intensity; ride.nausea = stats.nausea; + ride.cycleDur = stats.rideTime; + ride.price = Math.max(2, Math.round(stats.excitement * 1.2)); + ride.train = { progress: 0, color: session.trainColor }; + // re-mark all track cells to this ride + for (let i = 0; i < session.pieces.length; i++) { + const p = session.pieces[i]; + state.map.setObject(p.x, p.y, { kind: 'track', id: ride.id, pi: i }); + } + // ensure footprint cells (non-track inside bbox) belong to ride too + const covered = new Set(session.pieces.map(p => `${p.x},${p.y}`)); + for (let yy = 0; yy < h; yy++) for (let xx = 0; xx < w; xx++) { + const key = `${minX + xx},${minY + yy}`; + if (!covered.has(key) && state.map.inBounds(minX + xx, minY + yy) && !state.map.objects[state.map.idx(minX + xx, minY + yy)]) { + state.map.setObject(minX + xx, minY + yy, { kind: 'ride', id: ride.id, ox: xx, oy: yy }); + } + } + state._coasterBuild = null; + state.toasts.push({ kind: 'gold', title: `${ride.name} built!`, text: `Excitement ${stats.excitement.toFixed(1)} · Intensity ${stats.intensity.toFixed(1)} · Nausea ${stats.nausea.toFixed(1)} — test & open it!` }); + return { ok: true, ride }; +} + +// ---------------- physics / rating ---------------- +const G_ACC = 9.81 * 2.5; // 1 z-unit ≈ 2.5 m +export function computeStats(pieces) { + let v = 0; // m/s + let maxV = 0, sumV = 0; + let inversions = 0, drops = 0, biggestDrop = 0; + let turns = 0, straights = 0; + let prevZ = 0, peakZ = 0, dropFrom = 0; + let airPieces = 0; + const n = pieces.length; + for (let i = 0; i < n; i++) { + const p = pieces[i]; + const dzM = (p.z - prevZ) * 2.5; + prevZ = p.z; + peakZ = Math.max(peakZ, p.z); + switch (p.type) { + case 'loop': inversions++; break; + case 'curveL': case 'curveR': turns++; break; + case 'straight': case 'station': straights++; break; + case 'up': case 'steepUp': + if (!p.lift) v = Math.sqrt(Math.max(0, v * v - 2 * G_ACC * dzM)); + else v = 7; // chain lift crawl + break; + case 'down': case 'steepDown': { + if (p.type === 'down') drops++; else { drops++; } + dropFrom = peakZ; + const gainV = Math.sqrt(Math.max(0, v * v + 2 * G_ACC * (-dzM))); + if (gainV > v + 12 && p.type === 'steepDown') airPieces++; + v = gainV; + break; + } + } + // friction & limits + v *= 0.992; + if (v > 38) v = 38; // safety limit + if (v < 4 && !(p.lift)) v = 4; // anti-stall handbrake + maxV = Math.max(maxV, v); + sumV += v; + if (p.type === 'station' && i > 0) v = Math.max(v, 6); // station brake run + } + const avgV = sumV / n; + const totalDrop = Math.max(peakZ, 1); + const lenScore = clamp(n / 40, 0, 1.6); + const speedScore = maxV / 26; + let excitement = 1.2 + + lenScore * 1.9 + + speedScore * 2.1 + + inversions * 1.15 + + Math.min(drops, 8) * 0.45 + + airPieces * 0.55 + + Math.min(turns, 10) * 0.16 + + totalDrop / 22; + excitement = clamp(excitement, 0.5, 10); + let intensity = 0.8 + speedScore * 2.6 + inversions * 0.9 + airPieces * 0.7 + totalDrop / 14 + Math.min(drops, 6) * 0.25; + intensity = clamp(intensity, 0.4, 10); + let nausea = 0.5 + turns / n * 6 + inversions * 1.1 + (turns > n * 0.45 ? 1.5 : 0) + airPieces * 0.35; + nausea = clamp(nausea, 0.3, 10); + const rideTime = clamp(6 + n * (avgV > 18 ? 0.75 : 1.05), 8, 120); + const maxSpeedKmh = Math.round(maxV * 3.6); + return { + excitement: round1(excitement), intensity: round1(intensity), nausea: round1(nausea), + maxSpeed: maxSpeedKmh, avgSpeed: Math.round(avgV * 3.6), + drops, inversions, rideTime: Math.round(rideTime), length: n, maxHeight: peakZ, + airtimePieces: airPieces, + }; +} +function round1(x) { return Math.round(x * 10) / 10; } + +/** sample a point along the track for POV / animation */ +export function sampleTrack(track, t01) { + if (!track.length) return null; + const f = t01 * (track.length - 1); + const i = clamp(Math.floor(f), 0, track.length - 1); + const t = f - i; + const a = track[i]; + const b = track[Math.min(i + 1, track.length - 1)]; + // direction angle in screen space (iso-ish approximation for POV) + const dx = b.x - a.x, dy = b.y - a.y; + const screenAng = Math.atan2((dx + dy), (dx - dy) * 0.5); + return { + x: a.x + (b.x - a.x) * t, + y: a.y + (b.y - a.y) * t, + z: a.z + (b.z - a.z) * t, + turn: a.turn ?? 0, + slope: (b.z - a.z), + loop: a.type === 'loop', + ang: screenAng, + type: a.type, + }; +} diff --git a/js/game/economy.js b/js/game/economy.js new file mode 100644 index 0000000..09774c5 --- /dev/null +++ b/js/game/economy.js @@ -0,0 +1,96 @@ +// ============ economy.js — money, finance cycles, marketing ============ +import { fmtMoney } from '../core/util.js'; + +export function pay(state, amount, category, note) { + state.cash -= amount; + pushFin(state, category, -amount); + return true; +} +export function earn(state, amount, category, note) { + state.cash += amount; + pushFin(state, category, amount); + return true; +} + +function pushFin(state, category, amount) { + const m = state.finance.current; + m[category] = (m[category] || 0) + amount; +} + +export const FIN_CATEGORIES = [ + ['rideTickets', 'Ride tickets'], + ['shopSales', 'Shop sales'], + ['entrance', 'Entrance fees'], + ['construction', 'Construction'], + ['wages', 'Staff wages'], + ['research', 'Research'], + ['marketing', 'Marketing'], + ['heroes', 'Heroes & gear'], + ['loot', 'Monster loot'], + ['runCosts', 'Ride running costs'], + ['misc', 'Misc'], + ['loans', 'Loans'], +]; + +/** Monthly finance close: wages, interest, archive */ +export function monthClose(state) { + // wages + let wages = 0; + for (const s of state.staff) wages += s.wage; + for (const r of state.rides) wages += Math.round(r.def.runCost); + pay(state, wages, 'wages'); + // loan interest 1%/month + if (state.loan > 0) { + const interest = Math.ceil(state.loan * 0.01); + pay(state, interest, 'loans'); + } + // archive current month + const cur = { ...state.finance.current }; + state.finance.history.push(cur); + if (state.finance.history.length > 36) state.finance.history.shift(); + state.finance.current = {}; + state.lastMonthProfit = Object.entries(cur).reduce((a, [, v]) => a + v, 0); + return wages; +} + +// ---------------- Marketing campaigns ---------------- +export const CAMPAIGNS = [ + { id: 'flyers', name: 'Fairy Flyer Drop', cost: 400, weeks: 6, pull: 1.6 }, + { id: 'heralds', name: 'Town Crier Heralds', cost: 900, weeks: 8, pull: 2.4 }, + { id: 'crystal', name: 'Crystal Ball Vision', cost: 1800, weeks: 10, pull: 3.6 }, +]; + +export function startCampaign(state, id) { + const c = CAMPAIGNS.find(c => c.id === id); + if (!c || state.cash < c.cost) return false; + pay(state, c.cost, 'marketing'); + state.campaigns.push({ id: c.id, name: c.name, weeksLeft: c.weeks, pull: c.pull }); + return true; +} +export function campaignPull(state) { + let p = 0; + for (const c of state.campaigns) p += c.pull; + return p; +} +export function tickCampaigns(state) { + for (let i = state.campaigns.length - 1; i >= 0; i--) { + state.campaigns[i].weeksLeft--; + if (state.campaigns[i].weeksLeft <= 0) state.campaigns.splice(i, 1); + } +} + +// ---------------- Loan ---------------- +export function takeLoan(state, amount) { + amount = Math.min(amount, state.loanLimit - state.loan); + if (amount <= 0) return false; + state.loan += amount; + earn(state, amount, 'loans'); + return true; +} +export function repayLoan(state, amount) { + amount = Math.min(amount, state.loan, state.cash); + if (amount <= 0) return false; + state.loan -= amount; + pay(state, amount, 'loans'); + return true; +} diff --git a/js/game/guests.js b/js/game/guests.js new file mode 100644 index 0000000..c51b8d7 --- /dev/null +++ b/js/game/guests.js @@ -0,0 +1,406 @@ +// ============ guests.js — guest spawning, needs AI, spending ============ +import { GUEST_NAMES, GUEST_COLORS, THOUGHTS, WEATHER } from '../core/config.js'; +import { makeRng, choice, clamp, uid } from '../core/util.js'; +import { findPath, randomNearbyPath, snapToPath } from '../world/path.js'; +import { earn } from './economy.js'; +import { isNight } from './state.js'; + +const rng = Math.random; // guests tolerate non-seeded rng + +export function spawnGuest(state) { + const m = state.map; + const ex = m.entranceX, ey = m.entranceY; + // entrance fee check + const willing = state.stats.rating * 0.45; + const fee = state.park.entranceFee; + if (fee > 0 && fee > willing && rng() > 0.25) return null; // refuses steep fees + const g = { + id: uid(), kind: 'guest', + name: choice(rng, GUEST_NAMES) + ' ' + String.fromCharCode(65 + Math.floor(rng() * 26)) + '.', + color: choice(rng, GUEST_COLORS), + x: ex + (rng() - 0.5) * 1.6, y: ey + 2.5, + path: [], state: 'entering', target: null, + hunger: 15 + rng() * 30, thirst: 15 + rng() * 30, toilet: rng() * 20, energy: 70 + rng() * 30, + happiness: 55 + rng() * 25, money: 60 + Math.floor(rng() * 140), + spendMul: 0.8 + rng() * 0.7, + prefThrill: rng(), // 0 = gentle lover, 1 = thrill seeker + thoughts: [], bubble: null, bubbleT: 0, + rideCd: 4 + rng() * 10, + paidEntrance: 0, + ridesCount: 0, favRide: null, + speed: 1.7 + rng() * 0.9, + fleeingT: 0, + }; + if (fee > 0 && fee <= g.money) { + g.money -= fee; g.paidEntrance = fee; + earn(state, fee, 'entrance'); + } + state.guests.push(g); + return g; +} + +export function addThought(g, key, extra = {}) { + const pool = THOUGHTS[key]; + if (!pool) return; + let text = choice(rng, pool); + if (extra.r) text = text.replace('{r}', extra.r); + g.thoughts.unshift(text); + if (g.thoughts.length > 6) g.thoughts.pop(); + g.bubble = text; g.bubbleT = 4 + rng() * 3; +} + +/** spawn accumulator driven by park conditions */ +export function updateSpawning(state, dt) { + if (!state.park.open || isNight(state.time)) return; + if (state.spells.active.warding_sigil) { /* ward doesn't block guests */ } + const cap = state.sandbox ? 380 : 320; + if (state.guests.length >= cap) return; + const t = state.time.hour; + let curve = 0; + if (t < 9) curve = (t - 7) / 3; + else if (t < 11) curve = 0.7; + else if (t < 17) curve = 1.0; + else if (t < 19) curve = 0.6; + else curve = Math.max(0, (20.5 - t) / 2); + if (curve <= 0) return; + const w = WEATHER[state.weather.cur]; + let pull = campaignPullCache(state); + const ratingFactor = clamp(state.stats.rating / 400, 0.2, 1.6); + let rate = (0.55 + pull * 0.22) * curve * w.spawnMul * ratingFactor; + state.guestSpawnAcc += rate * dt; + while (state.guestSpawnAcc >= 1) { + state.guestSpawnAcc -= 1; + if (state.guests.length >= cap) break; + spawnGuest(state); + } +} +let _pullCache = { t: 0, v: 0 }; +function campaignPullCache(state) { + if (!state.campaigns.length) return 0; + if (performance.now() - _pullCache.t > 2000) { _pullCache.t = performance.now(); } + let p = 0; for (const c of state.campaigns) p += c.pull; + return p; +} + +// ---------------- per-guest update ---------------- +export function updateGuests(state, dt) { + updateSpawning(state, dt); + const m = state.map; + for (let i = state.guests.length - 1; i >= 0; i--) { + const g = state.guests[i]; + stepGuest(state, g, dt); + if (g.state === 'gone') state.guests.splice(i, 1); + } +} + +function stepGuest(state, g, dt) { + const m = state.map; + if (g.rideCd > 0) g.rideCd -= dt; + // bubble timer + if (g.bubbleT > 0) g.bubbleT -= dt; + // needs rise + g.hunger = clamp(g.hunger + dt * 0.55, 0, 100); + g.thirst = clamp(g.thirst + dt * 0.75, 0, 100); + g.toilet = clamp(g.toilet + dt * 0.42 * (g.thirst / 60 + 0.5), 0, 100); + if (g.state !== 'riding') g.energy = clamp(g.energy - dt * 0.28, 0, 100); + // flee override + if (g.fleeingT > 0) { + g.fleeingT -= dt; + moveAlong(state, g, dt, 1.8); + if (g.path.length) return; + if (g.fleeingT > 0) wanderTo(state, g, 6); + return; + } + // happiness dynamics + let hd = -dt * 0.06; + if (g.hunger > 85 || g.thirst > 88 || g.toilet > 90) hd -= dt * 0.5; + hd -= WEATHER[state.weather.cur].happyDrain * dt; + if (state.stats.magicCount > 0) hd += dt * 0.01 * Math.min(state.stats.magicCount, 10); + if (state.spells.active.joy_aura) hd += dt * 1.6; + g.happiness = clamp(g.happiness + hd, 0, 100); + + switch (g.state) { + case 'entering': { + // walk into plaza then start deciding + moveAlong(state, g, dt); + if (!g.path.length) { g.state = 'walking'; decide(state, g); } + break; + } + case 'walking': { + moveAlong(state, g, dt); + if (!g.path.length) arrive(state, g); + break; + } + case 'buying': { + g.targetT -= dt; + if (g.targetT <= 0) completePurchase(state, g); + break; + } + case 'resting': { + g.targetT -= dt; + g.energy = clamp(g.energy + dt * 6, 0, 100); + g.happiness = clamp(g.happiness + dt * 0.4, 0, 100); + if (g.targetT <= 0) { g.state = 'walking'; decide(state, g); } + break; + } + case 'queuing': case 'riding': + // managed by rides module + break; + case 'leaving': { + moveAlong(state, g, dt); + if (!g.path.length) { + if (g.y > state.map.size - 2.2) g.state = 'gone'; + else decideLeave(state, g); + } + break; + } + } + // random leave chance late day + if (isNight(state.time) && (g.state === 'walking') && rng() < dt * 0.25) decideLeave(state, g); + if ((g.happiness < 12 || g.money < 3) && g.state === 'walking' && rng() < dt * 0.08) { + if (g.happiness < 12) addThought(g, g.money < 3 ? 'broke' : 'happy_park'); + decideLeave(state, g); + } +} + +function moveAlong(state, g, dt, mul = 1) { + if (!g.path.length) return; + const [tx, ty] = g.path[0]; + const dx = tx - g.x, dy = ty - g.y; + const d = Math.hypot(dx, dy); + const stepLen = g.speed * mul * dt; + if (d <= stepLen) { + g.x = tx; g.y = ty; g.path.shift(); + } else { + g.x += dx / d * stepLen; g.y += dy / d * stepLen; + } +} + +function arrive(state, g) { + const tgt = g.target; + if (!tgt) { decide(state, g); return; } + if (tgt.kind === 'shop') { + const shop = state.shops.find(s => s.id === tgt.id); + if (!shop) { decide(state, g); return; } + if (shop.damaged > 0) { addThought(g, 'vandal'); decide(state, g); return; } + g.state = 'buying'; g.targetT = 1.2; + return; + } + if (tgt.kind === 'bench') { + g.state = 'resting'; g.targetT = 4 + rng() * 4; + return; + } + if (tgt.kind === 'ride') { + const ride = state.rides.find(r => r.id === tgt.id); + if (!ride || ride.status !== 'open') { decide(state, g); return; } + joinQueue(state, g, ride); + return; + } + decide(state, g); +} + +export function finishRide(state, g, ride, verdict) { + g.ridesCount++; + g.rideCd = 8 + rng() * 14; + g.energy = clamp(g.energy - 6, 0, 100); + // excitement enjoyment depends on taste match + const fit = 1 - Math.abs(ride.intensity / 10 - g.prefThrill); + let gain = (verdict === 'great' ? 14 : verdict === 'good' ? 9 : verdict === 'meh' ? 3 : -6) * clamp(fit + 0.4, 0.2, 1.3); + g.happiness = clamp(g.happiness + gain, 0, 100); + if (verdict === 'great') addThought(g, 'great_ride', { r: ride.name }); + else if (verdict === 'good') addThought(g, 'good_ride', { r: ride.name }); + else if (verdict === 'meh') addThought(g, 'meh_ride', { r: ride.name }); + else addThought(g, 'scary', { r: ride.name }); + if (verdict !== 'bad') g.favRide = ride.type; + // nausea side effects + if (ride.nausea > 3.5 && rng() < ride.nausea / 26) { + const tx = Math.round(g.x), ty = Math.round(g.y); + if (state.map.inBounds(tx, ty)) state.map.vomit[state.map.idx(tx, ty)] = 1; + g.happiness = clamp(g.happiness - 8, 0, 100); + } + // litter from food items bought earlier + if (rng() < 0.25) dropLitter(state, g); + // walk out of the ride exit area before deciding + g.state = 'walking'; + wanderTo(state, g, 3); +} + +function completePurchase(state, g) { + const shop = state.shops.find(s => s.id === g.target?.id); + g.state = 'walking'; + if (!shop) { decide(state, g); return; } + const fortuneMul = state.spells.active.fortune_rain ? 1.6 : 1; + const price = Math.round(shop.price * g.spendMul * fortuneMul); + if (g.money >= price) { + g.money -= price; shop.stock--; shop.sold++; shop.income += price; + earn(state, price, 'shopSales'); + const def = shop.def; + if (def.need === 'hunger') g.hunger = Math.max(0, g.hunger - def.needFix); + if (def.need === 'thirst') g.thirst = Math.max(0, g.thirst - def.needFix); + if (def.need === 'toilet') g.toilet = Math.max(0, g.toilet - def.needFix); + if (def.need === 'health') g.happiness = clamp(g.happiness + 10, 0, 100); + if (def.happyBoost) g.happiness = clamp(g.happiness + def.happyBoost, 0, 100); + g.happiness = clamp(g.happiness + 4, 0, 100); + if (def.need === 'hunger' || def.need === 'thirst') { + // carrying food/drink may become litter + g.carryingFood = true; + } + if (def.need === 'toilet' && rng() < 0.02) {/* nothing */ } + } else if (rng() < 0.3) addThought(g, 'broke'); + decide(state, g); +} + +export function dropLitter(state, g) { + const tx = Math.round(g.x), ty = Math.round(g.y); + if (!state.map.inBounds(tx, ty)) return; + // bins nearby prevent litter + for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) { + const o = state.map.getObject(tx + dx, ty + dy); + if (o && o.kind === 'scenery') { + const sc = state.sceneryList.find(s => s.id === o.id); + if (sc && sc.def.antiLitter) return; + } + } + const i = state.map.idx(tx, ty); + if (state.map.pathType[i]) state.map.litter[i] = clamp(state.map.litter[i] + 0.6, 0, 1.5); + g.carryingFood = false; +} + +// ---------------- decisions ---------------- +function decide(state, g) { + // urgent needs first + if (g.toilet > 82) { if (tryGoShop(state, g, s => s.type === 'toilet')) return; } + if (g.hunger > 78) { if (tryGoShop(state, g, s => s.type === 'food')) { return; } } + if (g.thirst > 74) { if (tryGoShop(state, g, s => s.type === 'drinks' || s.type === 'icecream')) return; } + if (g.energy < 22) { + if (tryGoBench(state, g)) return; + if (rng() < 0.5) { decideLeave(state, g); return; } + } + // ride? + if (g.rideCd <= 0) { + const ride = pickRide(state, g); + if (ride) { goTarget(state, g, { kind: 'ride', id: ride.id }, ride.entranceX, ride.entranceY, ride); return; } + } + // casual shopping + if (rng() < 0.3 && tryGoShop(state, g, s => s.type === 'souvenir' || s.type === 'balloon')) return; + // satisfy needs opportunistically + if (g.hunger > 50 && tryGoShop(state, g, s => s.type === 'food')) return; + if (g.thirst > 48 && tryGoShop(state, g, s => s.type === 'drinks' || s.type === 'icecream')) return; + if (g.toilet > 55 && tryGoShop(state, g, s => s.type === 'toilet')) return; + // wander + wanderTo(state, g, 8 + Math.floor(rng() * 8)); +} + +function pickRide(state, g) { + const cands = state.rides.filter(r => r.status === 'open' && r.queue.length < 24); + if (!cands.length) return null; + // score: intensity preference match + closeness + low queue + let best = null, bestScore = -1; + for (const r of cands) { + const fit = 1 - Math.abs((r.intensity || 0) / 10 - g.prefThrill) * 2; + if (fit < 0.05 && rng() < 0.8) continue; // wrong vibe usually skip + const d = Math.hypot(r.entranceX - g.x, r.entranceY - g.y); + const score = fit * 2 + Math.max(0, 1.2 - d / 40) + (r.queue.length < 6 ? 0.4 : 0) + rng() * 0.6 + - (r.price * g.spendMul > g.money ? 5 : 0) + + (r.type === g.favRide ? 0.8 : 0); + if (score > bestScore) { bestScore = score; best = r; } + } + return bestScore > 0.3 ? best : null; +} + +function goTarget(state, g, target, tx, ty, rideObj) { + const [sx, sy] = snapToPath(state.map, g.x, g.y); + let ex = tx, eyy = ty; + if (rideObj) { + // aim for a path tile adjacent to ride entrance + const adj = adjacentPath(state.map, rideObj.entranceX, rideObj.entranceY); + if (adj) { ex = adj[0]; eyy = adj[1]; } + else { wanderTo(state, g, 4); return false; } + } + const p = findPath(state.map, sx, sy, ex, eyy, 9000); + if (!p) { wanderTo(state, g, 5); return false; } + g.path = p; g.target = target; g.state = 'walking'; + return true; +} + +function tryGoShop(state, g, pred) { + const opts = state.shops.filter(s => pred(s) && !s.damaged && s.stock > 0); + if (!opts.length) return false; + opts.sort((a, b) => Math.hypot(a.x - g.x, a.y - g.y) - Math.hypot(b.x - g.x, b.y - g.y)); + for (let k = 0; k < Math.min(3, opts.length); k++) { + const s = opts[k]; + const adj = adjacentPath(state.map, s.x, s.y); + if (!adj) continue; + if (goTarget(state, g, { kind: 'shop', id: s.id }, adj[0], adj[1])) return true; + } + return false; +} + +function tryGoBench(state, g) { + const cands = state.sceneryList.filter(sc => sc.def.rest); + if (!cands.length) return false; + cands.sort((a, b) => Math.hypot(a.x - g.x, a.y - g.y) - Math.hypot(b.x - g.x, b.y - g.y)); + const b = cands[0]; + const adj = adjacentPath(state.map, b.x, b.y); + if (!adj) return false; + return goTarget(state, g, { kind: 'bench', id: b.id }, adj[0], adj[1]); +} + +export function wanderTo(state, g, r) { + const [sx, sy] = snapToPath(state.map, g.x, g.y); + const dest = randomNearbyPath(state.map, rng, sx, sy, 2, r); + if (!dest) { /* isolated: stay */ g.path = []; return false; } + const p = findPath(state.map, sx, sy, dest[0], dest[1], 3000); + if (p) { g.path = p; g.target = { kind: 'wander' }; if (g.state !== 'queuing' && g.state !== 'riding' && g.state !== 'buying' && g.state !== 'resting') g.state = 'walking'; } + return !!p; +} + +export function decideLeave(state, g) { + const m = state.map; + const gx = m.entranceX, gy = Math.min(m.size - 1, m.entranceY + 3); + const [sx, sy] = snapToPath(m, g.x, g.y); + const p = findPath(m, sx, sy, gx, gy, 12000); + if (p) { g.path = p; g.state = 'leaving'; g.target = { kind: 'exit' }; } + else { addThought(g, 'no_exit'); wanderTo(state, g, 6); } +} + +export function adjacentPath(map, x, y, prefer = null) { + const dirs = prefer ? [[prefer[0], prefer[1]], ...[[1, 0], [0, 1], [-1, 0], [0, -1]].filter(d => d[0] !== prefer[0] || d[1] !== prefer[1])] : [[1, 0], [0, 1], [-1, 0], [0, -1]]; + for (const [dx, dy] of dirs) { + if (map.isPath(x + dx, y + dy)) return [x + dx, y + dy]; + } + return null; +} + +// queue joining shared with rides module +import { joinQueue } from './rides.js'; + +export function scareGuests(state, x, y, radius) { + for (const g of state.guests) { + if (g.state === 'riding' || g.state === 'queuing') continue; + const d = Math.hypot(g.x - x, g.y - y); + if (d < radius) { + g.happiness = clamp(g.happiness - 14, 0, 100); + addThought(g, 'monster'); + // flee away from x,y toward entrance-ish random direction + const ang = Math.atan2(g.y - y, g.x - x) + (rng() - 0.5); + const fx = Math.round(clamp(g.x + Math.cos(ang) * 8, 1, state.map.size - 2)); + const fy = Math.round(clamp(g.y + Math.sin(ang) * 8, 1, state.map.size - 2)); + const dest = nearestWalkableNear(state.map, fx, fy); + if (dest) { + const p = findPath(state.map, Math.round(g.x), Math.round(g.y), dest[0], dest[1], 2500); + if (p) { g.path = p; g.fleeingT = 3 + rng() * 3; g.state = 'walking'; } + } + } + } +} + +function nearestWalkableNear(map, x, y) { + for (let r = 0; r < 10; r++) { + for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { + if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue; + if (map.isWalkable(x + dx, y + dy)) return [x + dx, y + dy]; + } + } + return null; +} diff --git a/js/game/heroes.js b/js/game/heroes.js new file mode 100644 index 0000000..c2e25c0 --- /dev/null +++ b/js/game/heroes.js @@ -0,0 +1,354 @@ +// ============ heroes.js — guild, heroes, monster invasions, battles ============ +import { HERO_CLASSES, MONSTER_TYPES, DIRS } from '../core/config.js'; +import { uid, clamp, choice } from '../core/util.js'; +import { earn } from './economy.js'; +import { scareGuests } from './guests.js'; + +const rng = Math.random; + +// ---------------- Guild ---------------- +export function buildGuild(state, x, y) { + const m = state.map; + for (let yy = 0; yy < 2; yy++) for (let xx = 0; xx < 2; xx++) { + if (!m.isBuildable(x + xx, y + yy) || m.occupied(x + xx, y + yy)) return null; + // must touch a path so heroes/guests can find it + } + const nearPath = [[x + 2, y], [x + 2, y + 1], [x - 1, y], [x - 1, y + 1], [x, y + 2], [x + 1, y + 2]] + .some(c => m.isPath(c[0], c[1])); + if (!nearPath) return null; + const guild = { x, y, w: 2, h: 2, cap: 4 }; + state.guild = guild; + for (let yy = 0; yy < 2; yy++) for (let xx = 0; xx < 2; xx++) + m.setObject(x + xx, y + yy, { kind: 'guild', ox: xx, oy: yy }); + return guild; +} + +export function guildCap(state) { + let cap = 4; + if (state.research.unlocked.includes('guild2')) cap += 2; + return cap; +} + +export function recruitHero(state, clsId) { + const def = HERO_CLASSES[clsId]; + if (!def || !state.guild) return { error: 'Build the Heroes Guild first!' }; + if (state.heroes.length >= guildCap(state)) return { error: 'Guild roster is full.' }; + if (!clsUnlocked(state, clsId)) return { error: 'Not researched yet.' }; + if (state.cash < def.cost && !state.sandbox) return { error: 'Not enough gold.' }; + if (!state.sandbox) { + payGold(state, def.cost); + } + const h = { + id: uid(), kind: 'hero', cls: clsId, def, + name: randomHeroName(clsId), + x: state.guild.x + 0.5 + rng(), y: state.guild.y + 1.7, + hp: def.hp, maxHp: def.hp, + lvl: 1, xp: 0, xpNext: 40, + gear: 0, // gear tiers bought + atkCd: 0, path: [], targetId: null, + revivingT: 0, alive: true, kills: 0, + speed: def.speed, + }; + state.heroes.push(h); + state.toasts.push({ kind: 'good', title: `${h.name} joins the guild!`, text: def.name + ' ready for battle.' }); + return { ok: true, hero: h }; +} +function payGold(state, amt) { + if (state.sandbox) return; + state.cash -= amt; + state.finance.current['heroes'] = (state.finance.current['heroes'] || 0) - amt; +} +function payGoldRaw(state, amt) { + state.cash -= amt; + state.finance.current['heroes'] = (state.finance.current['heroes'] || 0) - amt; +} +export function buyGear(state, hero) { + const tiers = [400, 900, 1600]; + if (hero.gear >= 3) return { error: 'Fully geared!' }; + const cost = tiers[hero.gear]; + if (!state.sandbox && state.cash < cost) return { error: 'Not enough gold.' }; + payGoldRaw(state, cost); + hero.gear++; + hero.maxHp = Math.round(hero.maxHp * 1.18); + hero.hp = hero.maxHp; + state.toasts.push({ kind: 'gold', title: `${hero.name} upgraded!`, text: `Gear tier ${hero.gear} equipped.` }); + return { ok: true }; +} +export function clsUnlocked(state, clsId) { + const def = HERO_CLASSES[clsId]; + if (def.tier === 0 || state.sandbox) return true; + if (def.id === 'cleric') return state.research.unlocked.includes('cleric'); + if (def.id === 'paladin') return state.research.unlocked.includes('paladin'); + return false; +} +const FIRST = ['Aldric','Bryn','Cedric','Dara','Elric','Faela','Gareth','Hilda','Ivar','Jora','Kael','Lyra','Merrick','Nyx','Orin','Perrin','Rowan','Sable','Torvald','Ulric','Vera','Wulfric','Ysolde','Zephyr']; +function randomHeroName() { + return choice(rng, FIRST) + ' the ' + choice(rng, ['Brave','Bold','Grim','Swift','Bright','Stalwart','Valiant','Wise']); +} + +// ---------------- Invasions ---------------- +export function maybeStartInvasion(state) { + if (!state.pendingInvasion) return; + state.pendingInvasion = false; + if (state.spells.active.warding_sigil) { + state.toasts.push({ kind: 'magic', title: 'Warding Sigil holds!', text: 'The rift falters — invasion repelled by magic.' }); + return; + } + spawnWave(state); +} + +function edgeSpawnPoint(map) { + for (let tries = 0; tries < 80; tries++) { + const side = Math.floor(rng() * 4); + let x, y; + if (side === 0) { x = 1 + Math.floor(rng() * (map.size - 2)); y = 1; } + else if (side === 1) { x = 1 + Math.floor(rng() * (map.size - 2)); y = map.size - 2; } + else if (side === 2) { x = 1; y = 1 + Math.floor(rng() * (map.size - 2)); } + else { x = map.size - 2; y = 1 + Math.floor(rng() * (map.size - 2)); } + if (map.terrainAt(x, y) !== 3 && !map.occupied(x, y)) return [x + 0.5, y + 0.5]; + } + return [map.entranceX, map.size - 2]; +} + +export function spawnWave(state) { + const scen = require_scen(state); + state.invasion.waveActive = true; + state.invasion.count++; + const scale = scen?.invasionScale || 1; + const yearF = 1 + (state.time.year - 1) * 0.35; + let count = Math.max(2, Math.round((2.5 + state.time.year * scale) * yearF * 0.8)); + const types = []; + for (let i = 0; i < count; i++) { + const r = rng(); + if (r < 0.35) types.push('slime'); + else if (r < 0.7) types.push('goblin'); + else if (r < 0.9) types.push('wolf'); + else types.push('brute'); + } + if (scen?.bossAt && state.invasion.count % scen.bossAt === 0) types.push('boss'); + for (const t of types) { + const def = MONSTER_TYPES[t]; + const [x, y] = edgeSpawnPoint(state.map); + state.monsters.push({ + id: uid(), kind: 'monster', type: t, def, + x, y, hp: def.hp, maxHp: def.hp, + atkCd: rng() * 1.2, speed: def.speed, + slowT: 0, flashT: 0, + }); + } + state.toasts.push({ kind: 'bad', title: '⚔️ INVASION!', text: `${types.length} monsters pour into your park! Your heroes will fight.` }); +} +function require_scen(state) { + // lazy import avoidance: scenario config passed via state cache + return state._scenCfg || null; +} +export function cacheScenario(state, scen) { state._scenCfg = scen; } + +// ---------------- per-frame updates ---------------- +export function updateBattles(state, dt) { + maybeStartInvasion(state); + const warding = !!state.spells.active.warding_sigil; + + // --- monsters --- + for (let i = state.monsters.length - 1; i >= 0; i--) { + const mo = state.monsters[i]; + let tgtObjLocal = undefined; + if (mo.flashT > 0) mo.flashT -= dt; + if (warding) { + mo.hp -= dt * 8; + // flee to nearest edge + const ex = mo.x < state.map.size / 2 ? 0.5 : state.map.size - 0.5; + const ey = mo.y < state.map.size / 2 ? 0.5 : state.map.size - 0.5; + steer(mo, ex, ey, dt * 1.3, state.map); + if (mo.x <= 1 || mo.y <= 1 || mo.x >= state.map.size - 1 || mo.y >= state.map.size - 1) { + state.monsters.splice(i, 1); continue; + } + if (mo.hp <= 0) killMonster(state, i, null); + continue; + } + // pick victim: nearest guest within aggro, else nearest building + let tx = null, ty = null, mode = null, bestD = Infinity; + for (const g of state.guests) { + if (g.state === 'riding') continue; + const d = Math.hypot(g.x - mo.x, g.y - mo.y); + if (d < 9 && d < bestD) { bestD = d; tx = g.x; ty = g.y; mode = 'guest'; } + } + if (mode === null) { + let bestShop = null, bsD = Infinity; + for (const s of [...state.shops, ...state.rides]) { + const d = Math.hypot((s.x + 0.5) - mo.x, (s.y + 0.5) - mo.y); + if (d < bsD) { bsD = d; bestShop = s; } + } + if (bestShop) { + tx = bestShop.x + 0.5; ty = bestShop.y + 0.5; mode = 'building'; tgtObjLocal = bestShop; + } + } + if (tx !== null) steer(mo, tx, ty, dt, state.map); + mo.atkCd -= dt; + if (mode === 'guest' && bestD < 0.8 && mo.atkCd <= 0) { + mo.atkCd = 1.2; + scareGuests(state, mo.x, mo.y, 3); + for (const g of state.guests) { + if (Math.hypot(g.x - mo.x, g.y - mo.y) < 1.2) { + g.happiness = clamp(g.happiness - 16, 0, 100); + g.money = Math.max(0, g.money - Math.floor(rng() * 15)); + } + } + addFloat(state, mo.x, mo.y - 0.6, 'RAWR!', '#ff6b6b'); + } else if (mode === 'building' && tgtObjLocal) { + const d = Math.hypot(tgtObjLocal.x + 0.5 - mo.x, tgtObjLocal.y + 0.5 - mo.y); + if (d < 1.4 && mo.atkCd <= 0) { + mo.atkCd = 1.4; + tgtObjLocal.damaged = Math.min(1.01, (tgtObjLocal.damaged || 0) + 0.34); + addFloat(state, tgtObjLocal.x + 0.5, tgtObjLocal.y, 'SMASH', '#ff9d76'); + if (tgtObjLocal.damaged > 1 && tgtObjLocal.def) { + state.vandalism = Math.min(20, state.vandalism + 2); + state.toasts.push({ kind: 'bad', title: `${tgtObjLocal.def.name} wrecked!`, text: 'It needs repairs before it can serve guests again.' }); + const selfIdx = state.monsters.indexOf(mo); + if (selfIdx >= 0) killMonster(state, selfIdx, null, true); + continue; + } + } + } + if (mo.hp <= 0) killMonster(state, i, null); + } + + // --- heroes --- + for (const h of state.heroes) { + if (!h.alive) { + h.revivingT -= dt; + if (h.revivingT <= 0) { + h.alive = true; h.hp = Math.round(h.maxHp * 0.6); + h.x = state.guild.x + 0.5; h.y = state.guild.y + 1.7; + state.toasts.push({ kind: 'good', title: `${h.name} revived`, text: 'Back from the healing temple.' }); + } + continue; + } + h.atkCd -= dt; + // regen slowly + h.hp = clamp(h.hp + dt * 0.8, 0, h.maxHp); + // find target + let target = null, bd = Infinity; + for (const mo of state.monsters) { + const d = Math.hypot(mo.x - h.x, mo.y - h.y); + if (d < bd) { bd = d; target = mo; } + } + const engageRange = h.def.range; + if (target && bd < 14 + engageRange) { + if (bd > engageRange) { + steer(h, target.x, target.y, dt, state.map); + } else if (h.atkCd <= 0) { + h.atkCd = h.def.atkCd; + heroAttack(state, h, target); + } + } else { + patrolHero(state, h, dt); + } + } + + // --- wave resolution --- + if (state.invasion.waveActive && state.monsters.length === 0) { + state.invasion.waveActive = false; + state.invasion.repelled++; + const reward = 150 * (require_scen(state)?.invasionScale || 1); + earn(state, Math.round(reward), 'loot'); + state.mana = clamp(state.mana + 15, 0, state.manaMax); + state.toasts.push({ kind: 'gold', title: 'Invasion repelled!', text: `The crowd cheers! Reward $${Math.round(reward)}.` }); + } + + // auto-repair broken buildings over time + for (const s of state.shops) if (s.damaged > 0) s.damaged = Math.max(0, s.damaged - dt / 90); + for (const r of state.rides) if (r.damaged > 0) r.damaged = Math.max(0, r.damaged - dt / 120); +} + +function heroAttack(state, h, target) { + const gearMul = 1 + h.gear * 0.25 + (state.research.unlocked.includes('gear2') ? 0.25 : 0); + const dmg = h.def.dmg * (1 + (h.lvl - 1) * 0.1) * gearMul; + const targets = []; + if (h.def.aoe) { + for (const mo of state.monsters) if (Math.hypot(mo.x - target.x, mo.y - target.y) <= h.def.aoe + 0.4) targets.push(mo); + } else targets.push(target); + for (const mo of targets) { + mo.hp -= dmg; + mo.flashT = 0.18; + } + state.effects.push({ kind: 'hit', x: target.x, y: target.y, t: 0, dur: 0.3, color: h.cls === 'mage' ? '#a86bff' : '#ffd166', aoe: !!h.def.aoe }); + addFloat(state, target.x, target.y - 0.5, String(Math.round(dmg)), h.cls === 'mage' ? '#c39bff' : '#ffe08a'); + // cleric heal pulse instead + if (h.def.heal) { + for (const ally of state.heroes) { + if (!ally.alive || ally === h) continue; + if (Math.hypot(ally.x - h.x, ally.y - h.y) < h.def.range + 1) { + ally.hp = clamp(ally.hp + h.def.heal, 0, ally.maxHp); + state.effects.push({ kind: 'heal', x: ally.x, y: ally.y, t: 0, dur: 0.4 }); + } + } + } +} + +function killMonster(state, idx, killerHero, silent = false) { + const mo = state.monsters[idx]; + if (!mo) return; + state.monsters.splice(idx, 1); + if (silent) return; + earn(state, mo.def.gold, 'loot'); + state.heroStats.kills++; + state.heroStats.lootGold += mo.def.gold; + state.mana = clamp(state.mana + 2, 0, state.manaMax); + if (mo.type === 'boss') state.invasion.bossKilled = true; + state.effects.push({ kind: 'poof', x: mo.x, y: mo.y, t: 0, dur: 0.5, icon: mo.def.icon }); + // xp share + for (const h of state.heroes) { + if (!h.alive) continue; + if (Math.hypot(h.x - mo.x, h.y - mo.y) < 11) { + h.kills++; + h.xp += mo.def.xp; + while (h.xp >= h.xpNext) { + h.xp -= h.xpNext; + h.lvl++; + h.xpNext = Math.round(h.xpNext * 1.5); + h.maxHp = Math.round(h.maxHp * 1.15); + h.hp = h.maxHp; + addFloat(state, h.x, h.y - 1, 'LEVEL UP!', '#57d97a'); + state.toasts.push({ kind: 'good', title: `${h.name} reached level ${h.lvl}!`, text: '' }); + } + } + } +} + +function patrolHero(state, h, dt) { + if (!h._patrol || Math.hypot(h._patrol[0] - h.x, h._patrol[1] - h.y) < 0.5 || rng() < dt * 0.1) { + const gx = state.guild ? state.guild.x : h.x; + const gy = state.guild ? state.guild.y : h.y; + h._patrol = [ + clamp(gx + (rng() - 0.5) * 24, 2, state.map.size - 3), + clamp(gy + (rng() - 0.5) * 24, 2, state.map.size - 3), + ]; + } + steer(h, h._patrol[0], h._patrol[1], dt * 0.7, state.map); +} + +/** simple steering with water avoidance */ +function steer(e, tx, ty, dt, map) { + const dx = tx - e.x, dy = ty - e.y; + const d = Math.hypot(dx, dy); + if (d < 0.05) return; + let nx = e.x + dx / d * e.speed * dt; + let ny = e.y + dy / d * e.speed * dt; + if (map.terrainAt(Math.floor(nx), Math.floor(ny)) === 3) { + // try sliding around water + const px = -dy / d, py = dx / d; + nx = e.x + (dx / d * 0.5 + px) ; + ny = e.y + (dy / d * 0.5 + py); + const l = Math.hypot(nx - e.x, ny - e.y) || 1; + nx = e.x + (nx - e.x) / l * e.speed * dt; + ny = e.y + (ny - e.y) / l * e.speed * dt; + if (map.terrainAt(Math.floor(nx), Math.floor(ny)) === 3) return; // stuck this frame + } + e.x = clamp(nx, 0.2, map.size - 0.2); + e.y = clamp(ny, 0.2, map.size - 0.2); +} + +function addFloat(state, x, y, text, color) { + state.floatTexts.push({ x, y, text, color, t: 0, dur: 1.1 }); +} diff --git a/js/game/magic.js b/js/game/magic.js new file mode 100644 index 0000000..d7770e0 --- /dev/null +++ b/js/game/magic.js @@ -0,0 +1,99 @@ +// ============ magic.js — spell casting system ============ +import { SPELLS } from '../core/config.js'; +import { clamp } from '../core/util.js'; + +export function spellUnlocked(state, id) { + const def = SPELLS[id]; + if (!def) return false; + if (state.sandbox || def.tier === 0) return true; + return state.research.unlocked.includes(id); +} + +export function canCast(state, id) { + const def = SPELLS[id]; + if (!spellUnlocked(state, id)) return { ok: false, why: 'Not researched' }; + if ((state.spells.cds[id] || 0) > 0) return { ok: false, why: 'Recharging' }; + if (state.mana < def.mana) return { ok: false, why: 'Not enough mana' }; + return { ok: true }; +} + +export function castSpell(state, id) { + const chk = canCast(state, id); + if (!chk.ok) return chk; + const def = SPELLS[id]; + state.mana -= def.mana; + state.spells.cds[id] = def.cd; + switch (id) { + case 'sunburst': + state.spells.active.sunburst = 2; // brief force-sunny + state.weather.cur = 'sunny'; + burst(state, '☀️'); + break; + case 'joy_aura': + state.spells.active.joy_aura = def.dur; + burst(state, '😊'); + break; + case 'healing_light': + for (const h of state.heroes) if (h.alive) { h.hp = h.maxHp; state.effects.push({ kind: 'heal', x: h.x, y: h.y, t: 0, dur: 0.8 }); } + burst(state, '💚'); + break; + case 'fortune_rain': + state.spells.active.fortune_rain = def.dur; + burst(state, '💸'); + break; + case 'swift_build': + state.spells.active.swift_build = def.dur; + burst(state, '⚡'); + break; + case 'monster_bane': { + for (const mo of [...state.monsters]) mo.hp -= 70; + for (let i = state.monsters.length - 1; i >= 0; i--) { + if (state.monsters[i].hp <= 0) { /* deaths handled by battle loop */ } + } + burst(state, '💥'); + state.toasts.push({ kind: 'magic', title: 'Monster Bane!', text: 'Arcane fire rains on the invaders.' }); + break; + } + case 'warding_sigil': + state.spells.active.warding_sigil = def.dur; + burst(state, '🛡️'); + state.toasts.push({ kind: 'magic', title: 'Warding Sigil raised', text: 'Monsters flee; invasions blocked while active.' }); + break; + case 'transmute': { + transmuteGold(state); + burst(state, '🪙'); + break; + } + } + return { ok: true }; +} + +function transmuteGold(state) { + state.cash += 900; + state.finance.current['misc'] = (state.finance.current['misc'] || 0) + 900; + state.toasts.push({ kind: 'gold', title: 'Transmutation!', text: '+$900 conjured from the arcane ether.' }); +} + +function burst(state, icon) { + state.effects.push({ kind: 'spellburst', icon, t: 0, dur: 1.4 }); +} + +export function tickSpells(state, dt) { + // cooldowns + for (const k of Object.keys(state.spells.cds)) { + if (state.spells.cds[k] > 0) state.spells.cds[k] -= dt; + } + // durations + for (const k of Object.keys(state.spells.active)) { + state.spells.active[k] -= dt; + if (state.spells.active[k] <= 0) delete state.spells.active[k]; + } + // mana regen + if (state.manaRegen === undefined) state.manaRegen = 0.4; + state.mana = clamp(state.mana + state.manaRegen * dt, 0, state.manaMax); +} + +/** construction discount multiplier from spells */ +export function buildDiscount(state) { + return state.spells.active.swift_build ? 0.5 : 1; +} diff --git a/js/game/research.js b/js/game/research.js new file mode 100644 index 0000000..dc780ef --- /dev/null +++ b/js/game/research.js @@ -0,0 +1,32 @@ +// ============ research.js — unlock tree ============ +import { UNLOCKS, RESEARCH_TRACKS } from '../core/config.js'; + +export function isUnlocked(state, key) { + if (state.sandbox) return true; + return state.research.unlocked.includes(key); +} + +export function canBuy(state, u) { + if (isUnlocked(state, u.key)) return { ok: false, why: 'owned' }; + if (state.research.rp >= u.rp) return { ok: true }; + return { ok: false, why: 'rp' }; +} + +export function buyUnlock(state, key) { + const u = UNLOCKS.find(u => u.key === key); + if (!u) return false; + const chk = canBuy(state, u); + if (!chk.ok) return false; + state.research.rp -= u.rp; + state.research.spentTotal += u.rp; + state.research.unlocked.push(key); + state.toasts.push({ kind: 'good', title: 'Research complete!', text: `${u.label} is now available to build.` }); + return true; +} + +export function unlocksByTrack() { + const out = {}; + for (const t of Object.keys(RESEARCH_TRACKS)) out[t] = []; + for (const u of UNLOCKS) (out[u.track] || (out[u.track] = [])).push(u); + return out; +} diff --git a/js/game/rides.js b/js/game/rides.js new file mode 100644 index 0000000..1d38ac4 --- /dev/null +++ b/js/game/rides.js @@ -0,0 +1,219 @@ +// ============ rides.js — ride lifecycle, queues, cycles, breakdowns ============ +import { earn } from './economy.js'; +import { uid, clamp } from '../core/util.js'; +import { finishRide } from './guests.js'; + +const rng = Math.random; + +export function joinQueue(state, g, ride) { + const cost = Math.round(ride.price * g.spendMul); + if (cost > g.money) { + // can't afford — try later + g.rideCd = 10 + rng() * 8; + g.state = 'walking'; + return false; + } + if (ride.queue.length >= 30) { + g.rideCd = 6; + g.state = 'walking'; + return false; + } + ride.queue.push(g.id); + g.state = 'queuing'; + // visual slot placement + const i = ride.queue.length - 1; + const row = Math.floor(i / 4), col = i % 4; + g.x = ride.entranceX + (col - 1.5) * 0.45; + g.y = ride.entranceY + 0.9 + row * 0.55; + return true; +} + +export function updateRides(state, dt) { + for (const r of state.rides) { + r.animPhase += dt; + switch (r.status) { + case 'open': stepOpenRide(state, r, dt); break; + case 'testing': stepTestingRide(state, r, dt); break; + case 'broken': break; // waits for mechanic + } + } +} + +function stepOpenRide(state, r, dt) { + // random breakdown + const hazard = 0.0022 * (1.06 - r.reliability) * (1 + (r.intensity || 2) / 8) * (r.isCustomCoaster ? 1.5 : 1); + if (rng() < dt * hazard) { breakDown(state, r); return; } + // board guests + const cap = capacityOf(r); + while (r.riders.length < cap && r.queue.length) { + const gid = r.queue.shift(); + const g = state.guests.find(g => g.id === gid); + if (!g) continue; + const cost = Math.round(r.price * (g.spendMul || 1)); + if ((g.money ?? 0) < cost) { + g.state = 'walking'; g.happiness = clamp(g.happiness - 3, 0, 100); + continue; + } + g.money -= cost; + earn(state, cost, 'rideTickets'); + r.income += cost; + g.state = 'riding'; + r.riders.push(gid); + } + // run cycle + if (r.riders.length > 0 || r.cycleT > 0) { + r.cycleT += dt; + advanceTrainIfCoaster(state, r); + if (r.cycleT >= r.cycleDur) { + unloadRide(state, r); + } + } +} + +function stepTestingRide(state, r, dt) { + r.cycleT += dt; + advanceTrainIfCoaster(state, r); + if (r.cycleT >= r.cycleDur) { + r.status = 'closed'; + r.cycleT = 0; + resetTrain(r); + state.toasts.push({ kind: 'good', title: `${r.name} tested OK`, text: 'You can now open it to guests.' }); + } +} + +function capacityOf(r) { + if (!r.isCustomCoaster) return r.def.capacity; + return Math.max(4, Math.min(r.def.capacity, Math.floor(trackLength(r) / 12) + 4)); +} + +function unloadRide(state, r) { + for (const gid of r.riders) { + const g = state.guests.find(g => g.id === gid); + if (!g) continue; + const fit = 1 - Math.abs((r.intensity || 2) / 10 - (g.prefThrill ?? 0.5)) * 2; + let verdict; + const score = (r.excite || 1) * clamp(fit + 0.35, 0.15, 1.25); + if ((r.intensity - (g.prefThrill * 10)) > 5.5) verdict = 'bad'; + else if (score > 5.5) verdict = 'great'; + else if (score > 3) verdict = 'good'; + else verdict = 'meh'; + g.state = 'walking'; + finishRide(state, g, r, verdict); + } + r.totalRiders += r.riders.length; + r.riders = []; + r.cycleT = 0; + resetTrain(r); + // pull next waiting guests toward queue head + reflowQueue(state, r); +} + +function reflowQueue(state, r) { + r.queue.forEach((gid, i) => { + const g = state.guests.find(g => g.id === gid); + if (!g) return; + const row = Math.floor(i / 4), col = i % 4; + g.x = r.entranceX + (col - 1.5) * 0.45; + g.y = r.entranceY + 0.9 + row * 0.55; + }); +} + +export function breakDown(state, r) { + r.status = 'broken'; + r.breakdownT = 24 + rng() * 26; + r.brokenCount++; + // scare riders off + for (const gid of r.riders) { + const g = state.guests.find(g => g.id === gid); + if (g) { g.state = 'walking'; g.happiness = clamp(g.happiness - 18, 0, 100); } + } + r.riders = []; + r.queue.forEach(gid => { + const g = state.guests.find(g => g.id === gid); + if (g) { g.state = 'walking'; g.rideCd = 15; } + }); + r.queue = []; + state.toasts.push({ kind: 'bad', title: `${r.name} broke down!`, text: 'Send a mechanic!' }); +} + +export function setRideOpen(state, r, open) { + if (open) { + if (r.status === 'broken') return false; + r.status = 'open'; + r.cycleT = 0; + } else { + r.status = 'closed'; + r.queue.forEach(gid => { + const g = state.guests.find(g => g.id === gid); + if (g) g.state = 'walking'; + }); + r.queue = []; + } + return true; +} + +export function startTest(state, r) { + if (r.status === 'broken') return false; + r.status = 'testing'; + r.cycleT = 0; + resetTrain(r); + return true; +} + +export function removeRide(state, r) { + // refund half construction value + const refund = Math.round(r.def.cost * 0.5 * (r.isCustomCoaster ? 0.6 : 1)); + earn(state, refund, 'misc'); + for (let yy = 0; yy < r.h; yy++) for (let xx = 0; xx < r.w; xx++) { + const o = state.map.getObject(r.x + xx, r.y + yy); + if (o && o.kind === 'ride' && o.id === r.id) state.map.clearObject(r.x + xx, r.y + yy); + } + // free track cells + if (r.track) { + for (let i = 0; i < r.track.length; i++) { + const t = r.track[i]; + const o = state.map.getObject(t.x, t.y); + if (o && o.kind === 'track') state.map.clearObject(t.x, t.y); + } + } + [...r.queue, ...r.riders].forEach(gid => { + const g = state.guests.find(g => g.id === gid); + if (g) g.state = 'walking'; + }); + state.rides = state.rides.filter(x => x !== r); + return refund; +} + +// ---------------- custom coaster train animation ---------------- +export function trackLength(r) { + return r.track ? r.track.length : 0; +} +function resetTrain(r) { + if (r.train) { r.train.pos = 0; r.train.speed = 0; r.train.done = false; } +} +function advanceTrainIfCoaster(state, r) { + if (!r.isCustomCoaster || !r.track?.length) return; + const tr = r.train; + if (!tr) return; + const frac = r.cycleT / r.cycleDur; + tr.progress = clamp(frac, 0, 1); // renderer interpolates along track +} + +/** Compute per-tick position of coaster train for smooth animation */ +export function trainPosition(r) { + if (!r.track || !r.track.length) return null; + const prog = r.train?.progress ?? 0; + const f = prog * (r.track.length - 1); + const i = Math.floor(f); + const t = f - i; + const a = r.track[Math.min(i, r.track.length - 1)]; + const b = r.track[Math.min(i + 1, r.track.length - 1)]; + return { + x: a.x + (b.x - a.x) * t, + y: a.y + (b.y - a.y) * t, + z: a.z + (b.z - a.z) * t, + loop: a.loop ? (t < 0.5 ? t * 2 : 2 - t * 2) : 0, + dirIdx: i % r.track.length, + piece: a, + }; +} diff --git a/js/game/save.js b/js/game/save.js new file mode 100644 index 0000000..468adbd --- /dev/null +++ b/js/game/save.js @@ -0,0 +1,78 @@ +// ============ save.js — localStorage slots, autosave, export/import ============ +import { serialize, deserialize } from '../game/state.js'; +import { download } from '../core/util.js'; + +const KEY = 'arcane_tycoon_save_'; +const SLOTS = ['auto', 'slot1', 'slot2', 'slot3']; + +export function listSaves() { + const out = []; + for (const s of SLOTS) { + try { + const raw = localStorage.getItem(KEY + s); + if (!raw) { out.push({ slot: s, exists: false }); continue; } + const d = JSON.parse(raw); + out.push({ + slot: s, exists: true, + parkName: d.park?.name || 'Park', + scenario: d.scenario, + date: `Y${d.time?.year} M${(d.time?.month ?? 0) + 1}`, + cash: d.cash, + guests: d.guests?.length ?? 0, + savedAt: d.savedAt, + }); + } catch { + out.push({ slot: s, exists: false }); + } + } + return out; +} + +export function saveTo(state, slot) { + const data = serialize(state); + data.savedAt = Date.now(); + try { + localStorage.setItem(KEY + slot, JSON.stringify(data)); + return true; + } catch (e) { + console.error('save failed', e); + return false; + } +} + +export function loadFrom(slot) { + try { + const raw = localStorage.getItem(KEY + slot); + if (!raw) return null; + return deserialize(JSON.parse(raw)); + } catch (e) { + console.error('load failed', e); + return null; + } +} + +export function hasAutosave() { + try { return !!localStorage.getItem(KEY + 'auto'); } catch { return false; } +} + +export function autosave(state) { + return saveTo(state, 'auto'); +} + +export function exportSave(state) { + const data = serialize(state); + data.savedAt = Date.now(); + const name = (state.park.name || 'park').replace(/\W+/g, '_').toLowerCase(); + download(`arcane-tycoon-${name}.json`, JSON.stringify(data)); +} + +export function importSaveText(text) { + try { + const data = JSON.parse(text); + if (!data.version) throw new Error('Not an Arcane Tycoon save'); + return deserialize(data); + } catch (e) { + console.error('import failed', e); + return null; + } +} diff --git a/js/game/staff.js b/js/game/staff.js new file mode 100644 index 0000000..24f955e --- /dev/null +++ b/js/game/staff.js @@ -0,0 +1,208 @@ +// ============ staff.js — handymen, mechanics, guards, jesters ============ +import { STAFF_TYPES } from '../core/config.js'; +import { uid, clamp, choice } from '../core/util.js'; +import { findPath, randomNearbyPath, snapToPath } from '../world/path.js'; +import { pay } from './economy.js'; + +const rng = Math.random; + +export function hireStaff(state, typeId) { + const def = STAFF_TYPES[typeId]; + if (!def || state.cash < 100) return null; + pay(state, 100, 'wages', 'hire'); + // spawn at entrance plaza + const m = state.map; + const x = m.entranceX + Math.floor(rng() * 3 - 1), y = m.entranceY - 3; + const s = { + id: uid(), kind: 'staff', type: typeId, def, + name: `${def.name} #${state.staff.filter(q => q.type === typeId).length + 1}`, + x, y, path: [], state: 'idle', + taskT: 0, workT: 0, targetTask: null, + speed: 1.6 + rng() * 0.5, wage: def.wage, + }; + state.staff.push(s); + return s; +} + +export function fireStaff(state, id) { + const i = state.staff.findIndex(s => s.id === id); + if (i >= 0) { state.staff.splice(i, 1); return true; } + return false; +} + +export function updateStaff(state, dt) { + for (const s of state.staff) stepStaff(state, s, dt); +} + +function stepStaff(state, s, dt) { + if (s.path.length) { + moveAlong(s, dt); + if (!s.path.length) onArrive(state, s); + return; + } + s.taskT -= dt; + if (s.state === 'working') { + s.workT -= dt; + doWork(state, s, dt); + if (s.workT <= 0) { s.state = 'idle'; s.taskT = 0.4; } + return; + } + if (s.taskT > 0) return; + findTask(state, s); +} + +function moveAlong(s, dt) { + const [tx, ty] = s.path[0]; + const dx = tx - s.x, dy = ty - s.y; + const d = Math.hypot(dx, dy); + const stepLen = s.speed * dt; + if (d <= stepLen) { s.x = tx; s.y = ty; s.path.shift(); } + else { s.x += dx / d * stepLen; s.y += dy / d * stepLen; } +} + +function onArrive(state, s) { + if (!s.targetTask) { s.state = 'idle'; s.taskT = 0.5; return; } + s.state = 'working'; + s.workT = s.targetTask.kind === 'repair' ? 3 : 2.2; +} + +function findTask(state, s) { + switch (s.type) { + case 'handyman': findCleanTask(state, s); break; + case 'mechanic': findRepairTask(state, s); break; + case 'guard': patrolNear(state, s, 'shop'); break; + case 'entertainer': patrolNear(state, s, 'ride'); break; + } + if (!s.path?.length && !s.targetTask) s.taskT = 1 + rng() * 2; +} + +function walkTo(state, s, tx, ty) { + const [sx, sy] = snapToPath(state.map, s.x, s.y); + const p = findPath(state.map, sx, sy, tx, ty, 5000); + if (p) s.path = p; + else { + // staff can walk anywhere slowly (they know shortcuts) + s.x = tx; s.y = ty; // teleport fallback keeps game playable + } +} + +// handyman: nearest litter/vomit tile +function findCleanTask(state, s) { + let best = null, bestD = Infinity; + const m = state.map; + const cx = Math.round(s.x), cy = Math.round(s.y); + const R = 26; + for (let y = Math.max(0, cy - R); y < Math.min(m.size, cy + R); y++) { + for (let x = Math.max(0, cx - R); x < Math.min(m.size, cx + R); x++) { + const i = m.idx(x, y); + if (!m.pathType[i]) continue; + const dirt = m.litter[i] + m.vomit[i] * 2; + if (dirt < 0.3) continue; + const d = Math.hypot(x - cx, y - cy); + if (d < bestD) { bestD = d; best = [x, y]; } + } + } + if (best) { + s.targetTask = { kind: 'clean' }; + walkTo(state, s, best[0], best[1]); + } else if (rng() < 0.25) { + wanderStaff(state, s); + } +} + +// mechanic: broken ride needing repair +function findRepairTask(state, s) { + const broken = state.rides.filter(r => r.status === 'broken'); + if (!broken.length) { + // preventive maintenance on random ride + if (rng() < 0.02 && state.rides.length) { + const r = choice(rng, state.rides); + const adj = entranceAdj(state, r); + if (adj) { s.targetTask = { kind: 'service', ride: r.id }; walkTo(state, s, adj[0], adj[1]); } + } + return; + } + const r = broken[0]; + const adj = entranceAdj(state, r); + if (adj) { s.targetTask = { kind: 'repair', ride: r.id }; walkTo(state, s, adj[0], adj[1]); } +} + +function entranceAdj(state, r) { + const cands = [[r.entranceX + 1, r.entranceY], [r.entranceX - 1, r.entranceY], [r.entranceX, r.entranceY + 1], [r.entranceX, r.entranceY - 1]]; + for (const c of cands) if (state.map.isPath(c[0], c[1])) return c; + return null; +} + +function patrolNear(state, s, kind) { + let pool; + if (kind === 'shop') pool = [...state.shops]; + else pool = state.rides.filter(r => r.status === 'open' && r.queue.length > 0); + if (!pool.length) pool = state.rides.concat(state.shops.map(s2 => ({ entranceX: s2.x, entranceY: s2.y }))); + if (!pool.length) { wanderStaff(state, s); return; } + const t = choice(rng, pool); + const ex = t.entranceX ?? t.x, ey = t.entranceY ?? t.y; + const cands = [[ex + 1, ey], [ex - 1, ey], [ex, ey + 1], [ex, ey - 1]].filter(c => state.map.isPath(c[0], c[1])); + if (!cands.length) { wanderStaff(state, s); return; } + const c = choice(rng, cands); + s.targetTask = { kind: kind === 'shop' ? 'guard' : 'entertain', x: c[0], y: c[1] }; + walkTo(state, s, c[0], c[1]); +} + +function wanderStaff(state, s) { + const dest = randomNearbyPath(state.map, rng, Math.round(s.x), Math.round(s.y), 3, 10); + if (dest) walkTo(state, s, dest[0], dest[1]); +} + +function doWork(state, s, dt) { + const task = s.targetTask; + if (!task) return; + const m = state.map; + switch (task.kind) { + case 'clean': { + const i = m.idx(Math.round(s.x), Math.round(s.y)); + m.litter[i] = Math.max(0, m.litter[i] - dt * 0.9); + m.vomit[i] = Math.max(0, m.vomit[i] - dt * 0.7); + break; + } + case 'repair': { + const r = state.rides.find(r => r.id === task.ride); + if (r && r.status === 'broken') { + r.breakdownT -= dt * 2; + if (r.breakdownT <= 0) fixRide(state, r); + } + break; + } + case 'service': { + const r = state.rides.find(r => r.id === task.ride); + if (r) r.reliability = clamp(r.reliability + dt * 0.05, 0, 0.99); + break; + } + case 'guard': { + state.vandalism = Math.max(0, state.vandalism - dt * 0.08); + break; + } + case 'entertain': { + for (const gid of collectQueuedGuests(state, s.x, s.y)) { + const g = state.guests.find(g => g.id === gid); + if (g) g.happiness = clamp(g.happiness + dt * 1.8, 0, 100); + } + break; + } + } +} + +function collectQueuedGuests(state, x, y) { + const out = []; + for (const r of state.rides) { + if (Math.hypot(r.entranceX - x, r.entranceY - y) > 12) continue; + out.push(...r.queue); + } + return out.slice(0, 30); +} + +export function fixRide(state, r) { + r.status = 'closed'; // reopen manually or auto after check + r.breakdownT = 0; + r.brokenCount++; + state.toasts.push({ kind: 'good', title: `${r.name} repaired`, text: 'Ready to reopen.' }); +} diff --git a/js/game/state.js b/js/game/state.js new file mode 100644 index 0000000..8ebab18 --- /dev/null +++ b/js/game/state.js @@ -0,0 +1,451 @@ +// ============ state.js — central game state, time, spawning, objectives ============ +import { GameMap } from '../world/map.js'; +import { makeRng, uid, resetUid, clamp, choice } from '../core/util.js'; +import { SCENARIOS, WEATHER, RIDE_TYPES, SHOP_TYPES, SCENERY_TYPES, STAFF_TYPES, GUEST_NAMES, GUEST_COLORS, AWARDS_POOL, MAX_Z, UNLOCKS } from '../core/config.js'; +import { monthClose, tickCampaigns } from './economy.js'; + +export const HOUR_RATE = 24 / 480; // 1 in-game day = 480 real seconds at 1× +export const DAYS_PER_MONTH = 8; // short months keep finance cycles lively +export const GUEST_CAP = 320; + +let S = null; +export const getState = () => S; +export function setState(s) { S = s; } + +export function newGame(scenarioId) { + const scen = SCENARIOS.find(s => s.id === scenarioId) || SCENARIOS[0]; + resetUid(1); + const st = { + version: 3, + scenario: scen.id, + sandbox: !!scen.sandbox, + startedAt: Date.now(), + rngSeed: (Math.random() * 0xffffffff) >>> 0, + cash: scen.cash, + loan: 0, + loanLimit: scen.loanLimit, + park: { + name: pickParkName(scen), + open: true, + entranceFee: scen.id === 'meadows' ? 0 : 5, + }, + time: { hour: 8.5, day: 1, month: 0, year: 1 }, + weather: { cur: 'sunny', timer: 90 + Math.random() * 120 }, + mana: 40, manaMax: 40, + research: { rp: 0, spentTotal: 0, unlocked: [] }, + spells: { active: {}, cds: {} }, + campaigns: [], + map: null, + rides: [], shops: [], sceneryList: [], staff: [], guests: [], heroes: [], monsters: [], + effects: [], // transient visual effects {kind,x,y,t,dur,...} + floatTexts: [], + guild: null, // {x,y,w,h,cap} + heroStats: { kills: 0, lootGold: 0, losses: 0 }, + invasion: { nextMonthIdx: 0, waveActive: false, repelled: 0, bossKilled: false, count: 0 }, + stats: null, + finance: { current: {}, history: [], lastMonthProfit: 0 }, + ratingHistory: [0], + awards: [], + vandalism: 0, + objectivesDone: {}, + won: false, lost: false, + toasts: [], // drained by UI + guestSpawnAcc: 0, + autosaveMonthCounter: 0, + uiHintsSeen: {}, + }; + st.map = buildMap(scen); + placeInitialLayout(st, scen); + if (st.sandbox) unlockEverythingSync(st); + st.invasion.nextMonthIdx = monthIndex(st) + scen.invasionStartMonth; + recomputeManaCap(st); + recomputeStats(st); + setState(st); + return st; +} + +function pickParkName(scen) { + const names = ['Everdawn Park', 'Moonhollow Gardens', 'Silverbranch Park', 'Emberfall Kingdom', 'Starweald Gardens']; + return names[Math.floor(Math.random() * names.length)]; +} + +function buildMap(scen) { + const m = new GameMap(scen.mapSize); + m.generate(scen.gen, scen.mapSeed); + return m; +} + +/** Entrance plaza: gate marker + initial paths + guild plot reserved */ +function placeInitialLayout(st, scen) { + const m = st.map; + const ex = m.entranceX, ey = m.entranceY; + // clear & pave entrance corridor + plaza + for (let y = ey - 2; y < Math.min(m.size, ey + 4); y++) { + for (let x = ex - 2; x <= ex + 2; x++) { + const i = m.idx(x, y); + if (m.terrain[i] === 3) m.terrain[i] = 0; // dry any water near entrance + m.pathType[i] = 1; + } + } + // plaza square + for (let y = ey - 6; y < ey - 2; y++) { + for (let x = ex - 4; x <= ex + 4; x++) { + if (!m.inBounds(x, y)) continue; + const i = m.idx(x, y); + if (m.terrain[i] === 3) m.terrain[i] = 0; + m.pathType[i] = 1; + } + } + // a couple of starter trees around plaza + const rng = makeRng(scen.mapSeed ^ 777); + let placedTrees = 0; + for (let tries = 0; tries < 400 && placedTrees < 10; tries++) { + const x = Math.floor(rng() * m.size), y = Math.floor(rng() * m.size); + if (!m.isBuildable(x, y) || m.occupied(x, y)) continue; + if (Math.abs(x - ex) < 6 && y > ey - 8) continue; + addSceneryObj(st, 'tree_' + (rng() < .5 ? 'oak' : 'pine'), x, y, true); + placedTrees++; + } +} + +// ---------------- entity factories ---------------- +export function addRideObj(state, typeId, x, y, opts = {}) { + const def = RIDE_TYPES[typeId]; + const ride = { + id: uid(), type: typeId, def, + name: opts.name || def.name, + x, y, w: def.w, h: def.h, + price: Math.round(def.excite * 0.8), + status: 'closed', // closed | testing | open | broken + queue: [], // guest ids waiting + riders: [], // guest ids currently riding + cycleT: 0, cycleDur: def.rideTime, + breakdownT: 0, reliability: 0.92 + Math.random() * 0.06, + totalRiders: 0, income: 0, + animPhase: 0, + excite: def.excite, intensity: def.intensity, nausea: def.nausea, + isCustomCoaster: !!opts.coaster, + track: opts.track || null, // coaster piece list + train: opts.train || null, + stats: opts.stats || null, + entranceX: opts.entranceX ?? x, entranceY: opts.entranceY ?? y, + exitTile: opts.exitTile || null, + brokenCount: 0, + }; + state.rides.push(ride); + if (!opts.coaster) { + for (let yy = 0; yy < def.h; yy++) for (let xx = 0; xx < def.w; xx++) { + state.map.setObject(x + xx, y + yy, { kind: 'ride', id: ride.id, ox: xx, oy: yy }); + state.map.pathType[state.map.idx(x + xx, y + yy)] = 0; + } + } + return ride; +} + +export function addShopObj(state, typeId, x, y) { + const def = SHOP_TYPES[typeId]; + const shop = { + id: uid(), type: typeId, def, x, y, + price: def.price, stock: def.stock >= 999999 ? Infinity : def.stock, + sold: 0, income: 0, damaged: 0, + }; + state.shops.push(shop); + state.map.setObject(x, y, { kind: 'shop', id: shop.id }); + state.map.pathType[state.map.idx(x, y)] = 0; + return shop; +} + +export function addSceneryObj(state, typeId, x, y, free = false) { + const def = SCENERY_TYPES[typeId]; + if (!def) return null; + if (def.size === 2 && !free && !canPlaceRect(state.map, x, y, 2, 2)) return null; + const obj = { id: uid(), type: typeId, def, x, y }; + state.sceneryList.push(obj); + const size = def.size || 1; + for (let yy = 0; yy < size; yy++) for (let xx = 0; xx < size; xx++) + state.map.setObject(x + xx, y + yy, { kind: 'scenery', id: obj.id, ox: xx, oy: yy }); + return obj; +} +export function removeScenery(state, obj) { + state.sceneryList = state.sceneryList.filter(o => o !== obj); + const size = obj.def.size || 1; + for (let yy = 0; yy < size; yy++) for (let xx = 0; xx < size; xx++) { + const o = state.map.getObject(obj.x + xx, obj.y + yy); + if (o && o.kind === 'scenery' && o.id === obj.id) state.map.clearObject(obj.x + xx, obj.y + yy); + } +} +export function canPlaceRect(map, x, y, w, h) { + for (let yy = 0; yy < h; yy++) for (let xx = 0; xx < w; xx++) { + if (!map.isBuildable(x + xx, y + yy) || map.occupied(x + xx, y + yy)) return false; + } + return true; +} + +// ---------------- main step ---------------- +export function step(state, dt) { + if (state.won || state.lost) dt = Math.min(dt, 0); // freeze sim on end + advanceTime(state, dt); + tickWeather(state, dt); + // imported lazily by main via update modules +} + +export function advanceTime(state, dt) { + const t = state.time; + t.hour += dt * HOUR_RATE; + while (t.hour >= 24) { + t.hour -= 24; + t.day++; + if (t.day > DAYS_PER_MONTH) { + t.day = 1; + t.month++; + onNewMonth(state); + if (t.month > 11) { t.month = 0; t.year++; onNewYear(state); } + } + } +} + +export function monthIndex(state) { return state.time.year * 12 + state.time.month; } + +function onNewMonth(state) { + monthClose(state); + tickCampaigns(state); + quarterlyAwards(state); + checkInvasionSchedule(state); + state.autosaveMonthCounter++; + state.toasts.push({ kind: 'month', title: 'New Month', text: `Welcome to ${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][state.time.month]}, Year ${state.time.year}.` }); +} + +function onNewYear(state) { + state.toasts.push({ kind: 'gold', title: `Year ${state.time.year} begins!`, text: 'The kingdom grows stronger.' }); +} + +function quarterlyAwards(state) { + if ((state.time.month % 3) !== 0) return; + recomputeStats(state); + const s = state.stats; + for (const a of AWARDS_POOL) { + if (state.awards.includes(a.id)) continue; + try { if (a.test(s)) { state.awards.push(a.id); state.toasts.push({ kind: 'gold', title: 'Award Won!', text: `${a.name} — your park is famous!` }); } } catch { } + } +} + +function checkInvasionSchedule(state) { + const scen = SCENARIOS.find(s => s.id === state.scenario); + if (!scen || !scen.invasionEvery) return; + if (state.invasion.nextMonthIdx <= monthIndex(state)) { + state.invasion.nextMonthIdx = monthIndex(state) + scen.invasionEvery; + state.pendingInvasion = true; // consumed by heroes module + } +} + +// ---------------- weather ---------------- +function tickWeather(state, dt) { + state.weather.timer -= dt; + if (state.weather.timer <= 0) { + const roll = Math.random(); + const order = state.weather.cur === 'sunny' ? ['cloudy', 'sunny', 'rain'] + : state.weather.cur === 'cloudy' ? ['sunny', 'rain', 'cloudy', 'storm'] + : state.weather.cur === 'rain' ? ['cloudy', 'rain', 'sunny', 'storm'] + : ['rain', 'cloudy', 'sunny']; + let next = order[0]; + if (roll < 0.45) next = order[0]; else if (roll < 0.75) next = order[1] || next; else next = order[order.length - 1]; + setWeather(state, next); + state.weather.timer = 80 + Math.random() * 160; + } + // sunburst spell forces sunny + if (state.spells.active.sunburst) setWeather(state, 'sunny'); +} +export function setWeather(state, w) { + if (state.weather.cur === w) return; + state.weather.cur = w; + state.toasts.push({ kind: 'info', title: `Weather: ${WEATHER[w].name}`, text: '' }); +} + +// ---------------- mana ---------------- +export function recomputeManaCap(state) { + let cap = 40, regen = 0.4; + for (const sc of state.sceneryList) { + if (sc.def.manaCap) cap += sc.def.manaCap; + if (sc.def.manaRegen) regen += sc.def.manaRegen; + } + cap = Math.min(cap, 300); + state.manaMax = cap; + state.manaRegen = regen; +} + +// ---------------- stats & rating ---------------- +export function recomputeStats(state) { + const m = state.map; + let happySum = 0, happyN = 0; + for (const g of state.guests) { happySum += g.happiness; happyN++; } + const avgHappy = happyN ? happySum / happyN : 70; + const litterCount = m.countLitter(); + let magicCount = 0, beautySum = 0, lightCount = 0; + for (const sc of state.sceneryList) { + beautySum += sc.def.beauty || 0; + if (sc.def.magic) magicCount++; + if (sc.def.light) lightCount++; + } + const openRideTypes = new Set(); + let openRides = 0, brokenRides = 0; + for (const r of state.rides) { + if (r.status === 'open') { openRides++; openRideTypes.add(r.type); } + if (r.status === 'broken') brokenRides++; + } + let bestExcite = 0; + for (const r of state.rides) bestExcite = Math.max(bestExcite, r.excite || 0); + const hasFood = state.shops.some(s => s.type === 'food'); + const hasDrink = state.shops.some(s => s.type === 'drinks'); + const hasToilet = state.shops.some(s => s.type === 'toilet'); + const facilities = (hasFood ? 40 : 0) + (hasDrink ? 30 : 0) + (hasToilet ? 30 : 0); + const pathTiles = countPaths(m); + const avgBeauty = pathTiles ? beautySum / pathTiles : 0; + + const rating = clamp(Math.round( + Math.min(openRideTypes.size, 8) / 8 * 150 + + avgHappy / 100 * 260 + + Math.max(0, 190 - litterCount * 4 - state.vandalism * 12) + + Math.min(avgBeauty * 14, 140) + + facilities + + Math.min(state.heroStats.kills * 1.2, 60) + ), 0, 999); + + state.stats = { + avgHappy, litterCount, avgBeauty, magicCount, openRides, openRideTypes: openRideTypes.size, + brokenRides, bestExcite, facilities, rating, + vandalism: state.vandalism, + guests: state.guests.length, + shops: state.shops.length, + }; + state.ratingHistory.push(rating); + if (state.ratingHistory.length > 240) state.ratingHistory.shift(); + return state.stats; +} + +function countPaths(m) { + let c = 0; + for (let i = 0; i < m.pathType.length; i++) if (m.pathType[i]) c++; + return c; +} + +export function parkValue(state) { + let v = state.cash - state.loan; + for (const r of state.rides) v += r.def.cost * 0.7; + for (const s of state.shops) v += s.def.cost * 0.7; + for (const sc of state.sceneryList) v += (sc.def.cost || 0) * 0.5; + return Math.round(v); +} + +// ---------------- objectives ---------------- +export function objectiveProgress(state, goal) { + switch (goal.type) { + case 'guests': return state.guests.length; + case 'rating': return state.stats?.rating || 0; + case 'cash': return parkValue(state); + case 'invasions': return state.invasion.repelled; + case 'bossKill': return state.invasion.bossKilled ? 1 : 0; + case 'coasterExcite': { + let best = 0; + for (const r of state.rides) if (r.isCustomCoaster && r.status === 'open') best = Math.max(best, r.excite); + return best; + } + default: return 0; + } +} + +export function checkObjectives(state) { + if (state.won || state.freeplay) return; + const scen = SCENARIOS.find(s => s.id === state.scenario); + if (!scen || !scen.goals.length) return; + let all = true; + for (const g of scen.goals) { + const done = objectiveProgress(state, g) >= g.value; + if (!done) all = false; + state.objectivesDone[g.id] = done; + } + if (all) { + state.won = true; + state.toasts.push({ kind: 'gold', title: '🏆 VICTORY!', text: `${scen.name} conquered — all objectives complete!` }); + } + if (state.cash < scen.loseCash && !state.lost) { + state.lost = true; + state.toasts.push({ kind: 'bad', title: 'BANKRUPTCY', text: 'The kingdom has run out of gold…' }); + } +} + +export function isNight(t) { return t.hour >= 20 || t.hour < 6; } + +// ---------------- research RP accrual ---------------- +export function tickResearch(state, dt) { + let rate = 0.05; + rate += state.stats.openRides * 0.02; + rate += state.stats.magicCount * 0.03; + if (state.guests.length > 50) rate += 0.03; + state.research.rp += rate * dt; +} + +// ---------------- sandbox unlock ---------------- +export function unlockEverythingSync(state) { + for (const u of UNLOCKS) if (!state.research.unlocked.includes(u.key)) state.research.unlocked.push(u.key); +} + +// ---------------- serialization ---------------- +export function serialize(state) { + const m = state.map; + return { + ...state, + map: undefined, + mapData: { + size: m.size, + terrain: Array.from(m.terrain), + pathType: Array.from(m.pathType), + litter: Array.from(m.litter), + vomit: Array.from(m.vomit), + objects: m.objects, + scatterSeed: m.scatterSeed, + entranceX: m.entranceX, entranceY: m.entranceY, + }, + rngState: typeof state._rng?.getState === 'function' ? state._rng.getState() : 0, + stats: undefined, + _statsSnapshot: state.stats, + }; +} + +export function deserialize(data) { + resetUid(data.startedAt % 100000 || 1); + const scen = SCENARIOS.find(s => s.id === data.scenario) || SCENARIOS[0]; + const st = JSON.parse(JSON.stringify({ ...data, mapData: undefined })); + const m = new GameMap(data.mapData.size); + m.terrain = Uint8Array.from(data.mapData.terrain); + m.pathType = Uint8Array.from(data.mapData.pathType); + m.litter = Float32Array.from(data.mapData.litter); + m.vomit = Float32Array.from(data.mapData.vomit); + m.objects = data.mapData.objects; + m.scatterSeed = data.mapData.scatterSeed; + m.entranceX = data.mapData.entranceX; m.entranceY = data.mapData.entranceY; + st.map = m; + // restore def references lost through JSON + for (const r of st.rides) { r.def = RIDE_TYPES[r.type]; } + for (const s of st.shops) { s.def = SHOP_TYPES[s.type]; } + for (const sc of st.sceneryList) { sc.def = SCENERY_TYPES[sc.type]; } + for (const sf of st.staff) { sf.def = STAFF_TYPES[sf.type]; } + // restore uid counter beyond any loaded id + let maxId = 1; + for (const arr of [st.rides, st.shops, st.sceneryList, st.staff, st.guests, st.heroes, st.monsters]) { + if (!Array.isArray(arr)) continue; + for (const e of arr) if (e && typeof e.id === 'number' && e.id > maxId) maxId = e.id; + } + resetUid(maxId + 1); + st.stats = st._statsSnapshot || recomputeStats(st); + st.rngSeed = data.rngSeed ?? 12345; + setState(st); + return st; +} + +/** attach runtime rng */ +export function ensureRng(state) { + if (!state._rng || typeof state._rng !== 'function') { + state._rng = makeRng(state.rngSeed || 42); + } + return state._rng; +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..de410e1 --- /dev/null +++ b/js/main.js @@ -0,0 +1,565 @@ +// ============ main.js — bootstrap, game loop, input ============ +import { getState, newGame, advanceTime, recomputeStats, checkObjectives, parkValue, isNight } from './game/state.js'; +import { updateGuests } from './game/guests.js'; +import { updateStaff } from './game/staff.js'; +import { updateRides } from './game/rides.js'; +import { updateBattles } from './game/heroes.js'; +import { tickSpells } from './game/magic.js'; +import { tickResearch } from './game/state.js'; +import { render, makeCamera, screenToWorld, worldToScreen, renderMinimap } from './render/renderer.js'; +import { initUI, setTool, ui, updateHUD, updateToasts, hideContext, showContextFor, pickEntity, refreshPalette, setSpeed, showToast, alertToast } from './ui/ui.js'; +import { openModal, closeModal, isModalOpen, maybeShowEndModal, openScenarioPicker, openHelp } from './ui/dialogs.js'; +import { stopPOV } from './ui/povui.js'; +import * as saveSys from './game/save.js'; +import { sfx, unlockAudio, startMusic } from './core/audio.js'; +import { TILE_W, TILE_H, SCENARIOS, PATH_TYPES } from './core/config.js'; +import { fmtMoney } from './core/util.js'; +import { buildDiscount } from './game/magic.js'; +import { startCoasterSession, sessionActive, addPiece, undoPiece, cancelCoaster } from './game/coaster.js'; +import { addRideObj, addShopObj, addSceneryObj } from './game/state.js'; +import { cacheScenario, buildGuild } from './game/heroes.js'; + +const $ = id => document.getElementById(id); + +// --- roundRect polyfill (older Safari/Firefox lack CanvasPath.roundRect) --- +if (typeof CanvasRenderingContext2D !== 'undefined' && !CanvasRenderingContext2D.prototype.roundRect) { + CanvasRenderingContext2D.prototype.roundRect = function (x, y, w, h, r) { + if (typeof r === 'number') r = [r, r, r, r]; + else if (!Array.isArray(r)) r = [0, 0, 0, 0]; + const [tl, tr, br, bl] = r.map(v => Math.min(v || 0, Math.abs(w) / 2, Math.abs(h) / 2)); + this.moveTo(x + tl, y); + this.lineTo(x + w - tr, y); + this.quadraticCurveTo(x + w, y, x + w, y + tr); + this.lineTo(x + w, y + h - br); + this.quadraticCurveTo(x + w, y + h, x + w - br, y + h); + this.lineTo(x + bl, y + h); + this.quadraticCurveTo(x, y + h, x, y + h - bl); + this.lineTo(x, y + tl); + this.quadraticCurveTo(x, y, x + tl, y); + return this; + }; +} + +const canvas = $('game'); +const ctx = canvas.getContext('2d'); +let cam = makeCamera(); +let cw = 0, ch = 0; +let mouse = { x: 0, y: 0, tile: null }; +let dragBtn = -1, dragMoved = false, dragStart = null; +let lastT = performance.now(); +let statTimer = 0; + +function resize() { + cw = canvas.width = innerWidth; + ch = canvas.height = innerHeight; +} +addEventListener('resize', resize); +resize(); + +// ---------------- boot / menu ---------------- +initUI(); +$('mm-new').addEventListener('click', () => { + unlockAudio(); + openScenarioPicker(id => { + startNewGame(id); + }); +}); +$('mm-how').addEventListener('click', () => { unlockAudio(); openHelp(); }); +if (saveSys.hasAutosave()) { + const saves = saveSys.listSaves(); + const auto = saves.find(s => s.slot === 'auto'); + if (auto?.exists) { + $('mm-continue').classList.remove('hidden'); + $('mm-continue').textContent = `↻ Continue — ${auto.parkName} (${auto.date})`; + $('mm-continue').addEventListener('click', () => { + unlockAudio(); + const st = saveSys.loadFrom('auto'); + if (st) onGameStarted(st); else alertToast('Autosave corrupted', 'bad'); + }); + } +} + +function startNewGame(scenId) { + const st = newGame(scenId); + onGameStarted(st); +} + +function onGameStarted(st) { + const scen = SCENARIOS.find(s => s.id === st.scenario); + cacheScenario(st, scen); + st._speed = 1; st._paused = false; + cam.x = st.map.entranceX + 2; + cam.y = st.map.entranceY - 6; + cam.zoom = Math.min(1.4, Math.max(0.8, innerWidth / 1500)); + $('main-menu').classList.add('hidden'); + ['topbar', 'toolbar', 'minimap-wrap'].forEach(id => $(id).classList.remove('hidden')); + syncUi(); + if (!st._musicStarted) { + st._musicStarted = true; + try { startMusic(); } catch { } + } + showToast('Welcome!', `${scen.name} — good luck!`, 'gold'); + if (!st.uiHintsSeen.help) { + st.uiHintsSeen.help = true; + setTimeout(() => openHelp(), 600); + } +} +window.__onGameLoaded = function () { + const st = getState(); + if (!st) return; + onGameStarted(st); +}; + +// ---------------- main loop ---------------- +let _errCount = 0; +addEventListener('error', ev => { + console.error(ev.error || ev.message); + const t = document.querySelector?.('#toasts'); + if (!_errShown && t) { + _errShown = true; + import('./ui/ui.js').then(u => u.showToast('Runtime error', String(ev.message || ev.error).slice(0, 120), 'bad')); + } +}); + +function loop(now) { + requestAnimationFrame(loop); + try { + loopBody(now); + } catch (e) { + console.error(e); + if (_errCount < 5 && typeof document !== 'undefined') { + _errCount++; + try { import('./ui/ui.js').then(u => u.showToast(`Loop error #${_errCount}`, String(e.message || e).slice(0, 140), 'bad')); } catch { } + } + } +} + +let __diag = null, __fpsN = 0, __fpsT = 0, __fps = 0, __frameNo = 0; +addEventListener('keydown', e => { if (e.key === 'F3') { e.preventDefault(); const d = document.getElementById('diag-overlay'); if (d) d.style.display = d.style.display === 'none' ? 'block' : 'none'; } }); + +function ensureDiag() { + if (__diag || typeof document === 'undefined') return __diag; + __diag = document.createElement('div'); + __diag.id = 'diag-overlay'; + __diag.style.cssText = 'position:fixed;left:8px;top:52px;z-index:200;background:rgba(0,0,0,.72);color:#7CFC9A;font:11px/1.5 monospace;padding:6px 10px;border-radius:8px;pointer-events:none;white-space:pre;max-width:420px'; + document.body?.appendChild ? document.body.appendChild(__diag) : null; + return __diag; +} + +function updateDiag(st) { + const d = ensureDiag(); + if (!d) return; + __frameNo++; + __fpsN++; + if (performance.now() - __fpsT > 500) { __fps = Math.round(__fpsN * 1000 / (performance.now() - __fpsT)); __fpsT = performance.now(); __fpsN = 0; } + const errs = Object.entries(renderErrorsRef()).map(([k, e]) => `ERR ${k}: ${String(e.message || e).slice(0, 90)}`).join('\n'); + d.textContent = + `FPS ${__fps} · frame ${__frameNo}\n` + + `cam ${cam.x.toFixed(1)}, ${cam.y.toFixed(1)} · z${cam.zoom.toFixed(2)}\n` + + `guests ${st ? st.guests.length : 0} · paused ${st?._paused} · speed ${st?._speed}\n` + + `keys ${Object.keys(keys).filter(k => keys[k]).join('+') || '-'}\n` + + (errs ? errs : ''); +} + +import { renderErrors } from './render/renderer.js'; +function renderErrorsRef() { return renderErrors; } + +function loopBody(now) { + const dtReal = Math.min(0.06, (now - lastT) / 1000); + lastT = now; + const st = getState(); + if (!st || !st.map) { + // draw animated menu backdrop + ctx.fillStyle = '#0b0e1a'; + ctx.fillRect(0, 0, cw, ch); + return; + } + handlePanKeys(dtReal); + if (!st._paused && !isModalOpen() && !document.getElementById('pov-overlay')) { + const mul = [1, 1, 2.2, 4][st._speed ?? 1]; + const dt = dtReal * mul; + simStep(st, dt); + } + render(ctx, st, cam, cw, ch, mouse); + updateHUD(st); + updateToasts(st); + // periodic stats & objectives (every ~1.5s) + statTimer -= dtReal; + if (statTimer <= 0) { + statTimer = 1.5; + recomputeStats(st); + checkObjectives(st); + maybeShowEndModal(st, backToMenu); + refreshContextIfOpen(st); + maybeAutosave(st); + } + updateDiag(st); + // minimap every ~0.6s + mmTimer -= dtReal; + if (mmTimer <= 0) { + mmTimer = 0.6; + const mm = $('minimap'); + if (mm && !$('minimap-wrap').classList.contains('hidden')) { + const mctx = mm.getContext('2d'); + renderMinimap(mctx, st, cam, cw, ch); + } + } +} +requestAnimationFrame(loop); + +let mmTimer = 0.5; +function maybeAutosave(st) { + if (st.autosaveMonthCounter >= 1) { + st.autosaveMonthCounter = 0; + saveSys.autosave(st); + } +} +function backToMenu() { + location.reload(); +} + +// ---------------- simulation step ---------------- +function simStep(st, dt) { + advanceTime(st, dt); + tickSpells(st, dt); + updateGuests(st, dt); + updateStaff(st, dt); + updateRides(st, dt); + updateBattles(st, dt); + tickResearch(st, dt); +} + +// ---------------- camera controls ---------------- +const keys = {}; +addEventListener('keydown', e => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + keys[e.key.toLowerCase()] = true; + const st = getState(); + if (!st) return; + switch (e.key.toLowerCase()) { + case ' ': e.preventDefault(); togglePauseKb(); break; + case '1': case '2': case '3': setSpeed(+e.key); break; + case 't': import('./ui/dialogs.js').then(d => d.openResearch()); break; + case 'f': import('./ui/dialogs.js').then(d => d.openFinance()); break; + case 'g': import('./ui/dialogs.js').then(d => d.openGuildDialog()); break; + case 'h': import('./ui/dialogs.js').then(d => d.openHelp()); break; + case 'm': import('./core/audio.js').then(a => a.isMusicOn() ? a.stopMusic() : a.startMusic()); break; + case 'escape': onEscape(); break; + case 's': if (e.ctrlKey) { e.preventDefault(); import('./ui/dialogs.js').then(d => d.openSaveLoad()); } break; + case 'z': if (sessionActive(st)) { undoPiece(st); refreshPalette(); } break; + default: + if (/^[qew]$/i.test(e.key)) { /* handled in pan */ } + } +}); +addEventListener('keyup', e => { keys[e.key.toLowerCase()] = false; }); + +function togglePauseKb() { + import('./ui/ui.js').then(u => u.togglePause()); +} +function onEscape() { + const st = getState(); + if (isModalOpen()) { closeModal(); return; } + if (document.getElementById('pov-overlay')) { stopPOV(); return; } + if (sessionActive(st)) { cancelCoaster(st); setTool('select'); return; } + if (ui.tool !== 'select') { setTool('select'); } + else hideContext(); +} + +function handlePanKeys(dt) { + const spd = 420 * dt / cam.zoom; + // Screen-relative panning: in this iso projection, + // screen-up = world (-x, -y) · screen-down = (+x, +y) + // screen-left = world (-x, +y) · screen-right = (+x, -y) + const k = spd * 0.72; // ≈1/√2 per axis so screen speed feels right + if (keys['w'] || keys['arrowup']) { cam.x -= k; cam.y -= k; } + if ((keys['s'] && !keys['control']) || keys['arrowdown']) { cam.x += k; cam.y += k; } + if (keys['a'] || keys['arrowleft']) { cam.x -= k; cam.y += k; } + if (keys['d'] || keys['arrowright']) { cam.x += k; cam.y -= k; } + if (keys['q']) zoomAt(cw / 2, ch / 2, 1 - dt * 1.2); + if (keys['e']) zoomAt(cw / 2, ch / 2, 1 + dt * 1.2); + clampCam(); +} +function clampCam() { + const st = getState(); + if (!st?.map) return; + const n = st.map.size; + cam.x = Math.max(-10, Math.min(n + 10, cam.x)); + cam.y = Math.max(-10, Math.min(n + 10, cam.y)); +} +function zoomAt(px, py, factor) { + cam.zoom = Math.max(0.45, Math.min(2.4, cam.zoom * factor)); + clampCam(); +} +canvas.addEventListener('wheel', e => { + e.preventDefault(); + zoomAt(e.clientX, e.clientY, e.deltaY < 0 ? 1.12 : 0.89); +}, { passive: false }); + +// ---------------- mouse ---------------- +canvas.addEventListener('contextmenu', e => e.preventDefault()); +canvas.addEventListener('pointerdown', e => { + unlockAudio(); + const st = getState(); + if (!st?._ui) syncUi(); + dragBtn = e.button; dragMoved = false; + dragStart = { x: e.clientX, y: e.clientY, camX: cam.x, camY: cam.y }; +}); +canvas.addEventListener('pointermove', e => { + mouse.x = e.clientX; mouse.y = e.clientY; + const st = getState(); + if (st?.map) { + const [wx, wy] = screenToWorld(cam, cw, ch, e.clientX, e.clientY); + mouse.tile = st.map.inBounds(wx, wy) ? [wx, wy] : null; + mouse.world = [wx, wy]; + } + if (dragStart && dragBtn === 0 && (ui.tool === 'select' || e.shiftKey)) { + const dx = e.clientX - dragStart.x, dy = e.clientY - dragStart.y; + if (Math.hypot(dx, dy) > 6) { + dragMoved = true; + // iso-consistent pan: screen dx maps to both axes + cam.x = dragStart.camX - (dx / (TILE_W / 2) + dy / (TILE_H / 2)) / 2 / cam.zoom; + cam.y = dragStart.camY + (dx / (TILE_W / 2) - dy / (TILE_H / 2)) / 2 / cam.zoom; + clampCam(); + } + } + // path painting while dragging + if (dragStart && dragBtn === 0 && st && ui.tool === 'path' && ui.sel?.kind === 'path') { + paintPathAt(st, mouse.tile); + } + if (dragStart && dragBtn === 0 && st && ui.tool === 'terrain' && ui.sel?.kind === 'terrain') { + paintTerrainAt(st, mouse.tile); + } +}); +canvas.addEventListener('pointerup', e => { + const st = getState(); + dragBtn = -1; + if (!st || !st.map) { dragStart = null; return; } + const wasDrag = dragMoved; dragStart = null; + if (e.button === 2) { rightClick(st); return; } + if (e.button !== 0) return; + if (wasDrag && (ui.tool === 'select' && !e.shiftKey)) return; // was a pan + leftClick(st, e); +}); + +function syncUi() { + const st = getState(); + if (st) st._ui = { tool: ui.tool, sel: ui.sel, coasterPiece: ui.coasterPiece, heroSubtool: ui.heroSubtool }; +} + +function rightClick(st) { + if (sessionActive(st)) { undoPiece(st); refreshPalette(); return; } + if (ui.tool !== 'select') { setTool('select'); } +} + +function leftClick(st, e) { + const t = mouse.tile; + if (!t) return; + const [x, y] = t; + const tool = ui.tool; + + if (tool === 'select') { + const [wx, wy] = mouse.world; + const ent = pickEntity(st, wx + 0.5, wy + 0.5); + if (ent && ent.kind !== 'guildBuilding') showContextFor(ent); + else if (ent?.kind === 'guildBuilding') import('./ui/dialogs.js').then(d => d.openGuildDialog()); + else hideContext(); + sfx.click(); + return; + } + + if (tool === 'path') { + if (ui.sel?.kind === 'doze') dozeTile(st, x, y); + else if (ui.sel?.kind === 'path') paintPathAt(st, t); + return; + } + if (tool === 'terrain') { paintTerrainAt(st, t); return; } + + if (tool === 'coaster') { + if (!sessionActive(st)) { + const res = startCoasterSession(st, x, y); + if (res.error) { sfx.error(); alertToast(res.error, 'bad'); } + else { sfx.place(); ui.coasterPiece = 'straight'; refreshPalette(); syncUi(); } + } else { + const res = addPiece(st, ui.coasterPiece); + if (res.error) { sfx.error(); alertToast(res.error, 'bad'); } + else sfx.place(); + refreshPalette(); + } + return; + } + + if (tool === 'ride' || tool === 'shop') { + placeBuilding(st, t); + return; + } + if (tool === 'scenery') { + placeScenery(st, t); + return; + } + if (tool === 'heroes') { + if (ui.heroSubtool === 'guild') { + const g = buildGuild(st, x, y); + if (g) { + st.cash -= 1500; + st.finance.current['construction'] = (st.finance.current['construction'] || 0) - 1500; + sfx.openRide(); + ui.heroSubtool = null; syncUi(); + refreshPalette(); + showToast('Heroes Guild built!', 'Now recruit your first heroes.', 'good'); + } else { sfx.error(); alertToast('Needs a clear 2×2 spot touching a path', 'bad'); } + } + return; + } +} + +// ---------------- placement helpers ---------------- +function payBuild(st, cost) { + cost = Math.round(cost * buildDiscount(st)); + if (!st.sandbox && st.cash < cost) { sfx.error(); alertToast(`Not enough money (${fmtMoney(cost)})`, 'bad'); return false; } + st.cash -= cost; + st.finance.current['construction'] = (st.finance.current['construction'] || 0) - cost; + return true; +} + +function paintPathAt(st, t) { + if (!t) return; + const [x, y] = t; + const i = st.map.idx(x, y); + if (!st.map.isBuildable(x, y) || st.map.objects[i]) return; + const ptId = ui.sel.pt || 'pavement'; + if (ptId === 'cobble' && !st.research.unlocked.includes('cobble')) return; + if (st.map.pathType[i] === (ptId === 'cobble' ? 2 : 1)) return; + const cost = PATH_TYPES[ptId].cost; + if (!payBuild(st, cost)) return; + st.map.pathType[i] = ptId === 'cobble' ? 2 : 1; + st.map.litter[i] = 0; + sfx.click(); +} + +function paintTerrainAt(st, t) { + if (!t) return; + const [x, y] = t; + const i = st.map.idx(x, y); + if (st.map.objects[i] || st.map.pathType[i]) return; + if (!payBuild(st, 20)) return; + const mapT = { grass: 0, sand: 1, rock: 2, water: 3 }[ui.sel.t] ?? 0; + st.map.terrain[i] = mapT; +} + +function dozeTile(st, x, y) { + const o = st.map.getObject(x, y); + if (o?.kind === 'scenery') { + const sc = st.sceneryList.find(s => s.id === o.id); + if (sc) { + import('./game/state.js').then(m => { + m.removeScenery(st, sc); + st.cash += Math.round((sc.def.cost || 20) * 0.5); + sfx.demolish(); + }); + } + return; + } + if (o?.kind === 'shop') { + const sh = st.shops.find(s => s.id === o.id); + if (sh) { + st.map.clearObject(sh.x, sh.y); + st.shops = st.shops.filter(q => q !== sh); + st.cash += Math.round(sh.def.cost * 0.5); + hideContext(); + sfx.demolish(); + } + return; + } + if (o?.kind === 'ride' || o?.kind === 'track') { + alertToast('Use the ride panel → Demolish for rides', 'bad'); + return; + } + const i = st.map.idx(x, y); + if (st.map.pathType[i]) { + st.map.pathType[i] = 0; + st.cash += 3; + sfx.demolish(); + } +} + +function entranceAdjacentPathOk(st, x, y, w, h) { + for (let yy = -1; yy <= h; yy++) { + for (let xx = -1; xx <= w; xx++) { + const inside = xx >= 0 && yy >= 0 && xx < w && yy < h; + if (inside) continue; + if ((xx === -1 || xx === w) && (yy === -1 || yy === h)) continue; + if (st.map.isPath(x + xx, y + yy)) { + // perimeter cell adjacent to path becomes entrance anchor + return { px: Math.min(w - 1, Math.max(0, xx)), py: Math.min(h - 1, Math.max(0, yy)), pathX: x + xx, pathY: y + yy }; + } + } + } + return null; +} + +function placeBuilding(st, t) { + const def = ui.sel; + if (!def) return; + const [x, y] = t; + const w = def.w, h = def.h; + // footprint free? + for (let yy = 0; yy < h; yy++) for (let xx = 0; xx < w; xx++) { + if (!st.map.isBuildable(x + xx, y + yy) || st.map.occupied(x + xx, y + yy)) { + sfx.error(); alertToast('Blocked location', 'bad'); return; + } + } + const ent = entranceAdjacentPathOk(st, x, y, w, h); + if (!ent) { sfx.error(); alertToast(`${def.name} must touch a path so guests can enter!`, 'bad'); return; } + if (!payBuild(st, def.cost)) return; + if (def.kind === 'ride') { + const ride = addRideObj(st, def.id, x, y, { entranceX: x + ent.px, entranceY: y + ent.py }); + ride.status = 'closed'; + sfx.place(); + showToast(def.name + ' built!', 'Test it, then Open when ready.'); + } else { + addShopObj(st, def.id, x, y); + sfx.place(); + } +} + +function placeScenery(st, t) { + const def = ui.sel; + if (!def) return; + const [x, y] = t; + const size = def.size || 1; + for (let yy = 0; yy < size; yy++) for (let xx = 0; xx < size; xx++) { + if (!st.map.isBuildable(x + xx, y + yy) || st.map.occupied(x + xx, y + yy)) { + sfx.error(); alertToast('Blocked location', 'bad'); return; + } + } + if (!payBuild(st, def.cost)) return; + addSceneryObj(st, def.id, x, y); + import('./game/state.js').then(m => m.recomputeManaCap(st)); + sfx.place(); +} + +// ---------------- context panel live refresh ---------------- +function refreshContextIfOpen(st) { + const ent = st._uiSelEntity; + if (!ent) return; + const panel = $('context-panel'); + if (panel.classList.contains('hidden')) return; + // rebuild content only for dynamic entities + if (ent.kind === 'guest' || ent.kind === 'hero' || ent.kind === 'monster' || ent.kind === 'ride' || ent.kind === 'shop') { + import('./ui/ui.js').then(u => u.showContextFor(ent)); + } +} + +// minimap click-to-move +$('minimap').addEventListener('pointerdown', e => { + const st = getState(); + if (!st?.map) return; + const rect = e.target.getBoundingClientRect(); + const fx = (e.clientX - rect.left) / rect.width; + const fy = (e.clientY - rect.top) / rect.height; + cam.x = fx * st.map.size; + cam.y = fy * st.map.size; + clampCam(); +}); + +export { cam }; diff --git a/js/render/renderer.js b/js/render/renderer.js new file mode 100644 index 0000000..8b8da55 --- /dev/null +++ b/js/render/renderer.js @@ -0,0 +1,990 @@ +// ============ renderer.js — isometric canvas renderer ============ +import { TILE_W, TILE_H, Z_STEP } from '../core/config.js'; +import { clamp } from '../core/util.js'; +import { getState } from '../game/state.js'; +import { trainPosition } from '../game/rides.js'; + +export const TW2 = TILE_W / 2, TH2 = TILE_H / 2; + +export function makeCamera() { + return { x: 26, y: 26, zoom: 1 }; +} +export function worldToScreen(cam, cw, ch, wx, wy, wz = 0) { + const z = cam.zoom; + const ux = wx - cam.x, uy = wy - cam.y; // camera-relative + return [ + (ux - uy) * TW2 * z + cw / 2, + (ux + uy) * TH2 * z + ch / 2 - (wz || 0) * Z_STEP * z, + ]; +} +export function screenToWorld(cam, cw, ch, sx, sy) { + const z = cam.zoom; + const A = (sx - cw / 2) / (TW2 * z); + const B = (sy - ch / 2) / (TH2 * z); + // ignore height for tile picking + const wx = (A + B) / 2 + cam.x; + const wy = (B - A) / 2 + cam.y; + return [Math.floor(wx), Math.floor(wy)]; +} + +// ---------------- main entry ---------------- +export const renderErrors = {}; +function guarded(name, fn) { + try { fn(); } + catch (e) { + if (!renderErrors[name]) { + renderErrors[name] = e; + console.error(`[render:${name}]`, e); + } + } +} + +export function render(ctx, state, cam, cw, ch, mouse) { + ctx.clearRect(0, 0, cw, ch); + // sky backdrop gradient + const night = nightFactor(state.time.hour); + const g = ctx.createLinearGradient(0, 0, 0, ch); + if (night > 0.5) { g.addColorStop(0, '#0b1030'); g.addColorStop(1, '#141a38'); } + else if (night > 0) { g.addColorStop(0, '#4a5fa8'); g.addColorStop(1, '#8d7fb8'); } + else { g.addColorStop(0, '#79b7e8'); g.addColorStop(1, '#a8d8f0'); } + ctx.fillStyle = g; + ctx.fillRect(0, 0, cw, ch); + + guarded('terrain', () => drawTerrain(ctx, state, cam, cw, ch)); + guarded('objects', () => drawObjects(ctx, state, cam, cw, ch)); + guarded('entities', () => drawEntities(ctx, state, cam, cw, ch)); + guarded('track', () => drawTrackAll(ctx, state, cam, cw, ch)); + guarded('ghosts', () => drawGhosts(ctx, state, cam, cw, ch, mouse)); + guarded('effects', () => drawEffects(ctx, state, cam, cw, ch)); + guarded('weather', () => drawWeatherFx(ctx, state, cw, ch)); + guarded('daynight', () => drawDayNight(ctx, state, cam, cw, ch)); +} + +function nightFactor(hour) { + // 0 = full day, 1 = full night + if (hour >= 21 || hour < 5) return 1; + if (hour >= 19) return (hour - 19) / 2; + if (hour < 7) return 1 - (hour - 5) / 2; + return 0; +} + +// ---------------- terrain & paths ---------------- +const TERRAIN_COLORS = { 0: ['#4d8a3d', '#57a047'], 1: ['#cbb26a', '#d5bd77'], 2: ['#7a7f8a', '#868b96'], 3: ['#2e6db4', '#3a7cc9'] }; + +function tilePoly(ctx, sx, sy, zoom) { + const w = TW2 * zoom, h = TH2 * zoom; + ctx.beginPath(); + ctx.moveTo(sx, sy - h); + ctx.lineTo(sx + w, sy); + ctx.lineTo(sx, sy + h); + ctx.lineTo(sx - w, sy); + ctx.closePath(); +} + +/** World-space bounds of everything visible on screen (all 4 corners -> iso diamond). */ +export function visibleBounds(cam, cw, ch, pad = 80) { + const z = cam.zoom; + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const [sx, sy] of [[-pad, -pad], [cw + pad, -pad], [-pad, ch + pad], [cw + pad, ch + pad]]) { + const A = (sx - cw / 2) / (TW2 * z); + const B = (sy - ch / 2) / (TH2 * z); + const wx = (A + B) / 2 + cam.x, wy = (B - A) / 2 + cam.y; + if (wx < minX) minX = wx; + if (wx > maxX) maxX = wx; + if (wy < minY) minY = wy; + if (wy > maxY) maxY = wy; + } + return { + x0: Math.floor(minX) - 1, x1: Math.ceil(maxX) + 1, + y0: Math.floor(minY) - 1, y1: Math.ceil(maxY) + 1, + }; +} + +function drawTerrain(ctx, state, cam, cw, ch) { + const m = state.map, z = cam.zoom; + const n = m.size; + // visible bounds in world space (transform ALL four screen corners) + const vb = visibleBounds(cam, cw, ch, 80); + const x0 = clamp(vb.x0, 0, n - 1), x1 = clamp(vb.x1, 0, n - 1); + const y0 = clamp(vb.y0, 0, n - 1), y1 = clamp(vb.y1, 0, n - 1); + for (let y = y0; y <= y1; y++) { + for (let x = x0; x <= x1; x++) { + const i = m.idx(x, y); + const t = m.terrain[i]; + const [c1, c2] = TERRAIN_COLORS[t] || TERRAIN_COLORS[0]; + const [sx, sy] = worldToScreen(cam, cw, ch, x + 0.5, y + 0.5); + tilePoly(ctx, sx, sy, z); + ctx.fillStyle = ((x + y) % 2 === 0) ? c1 : c2; + ctx.fill(); + // water shimmer + if (t === 3 && ((x * 7 + y * 13 + Math.floor(state.time.hour * 30)) % 11 === 0)) { + ctx.fillStyle = 'rgba(255,255,255,.18)'; + ctx.fillRect(sx - 6 * z, sy - 1 * z, 12 * z, 2 * z); + } + // path + const pt = m.pathType[i]; + if (pt) { + tilePoly(ctx, sx, sy, z * 0.92); + ctx.fillStyle = pt === 1 ? '#b8b2a5' : '#6f6a80'; + ctx.fill(); + ctx.strokeStyle = pt === 1 ? 'rgba(0,0,0,.15)' : 'rgba(255,255,255,.08)'; + ctx.lineWidth = Math.max(1, z); + ctx.stroke(); + // litter/vomit stains + const lit = m.litter[i], vom = m.vomit[i]; + if (lit > 0.25 || vom > 0.25) { + ctx.fillStyle = vom > 0.25 ? 'rgba(150,190,90,.55)' : 'rgba(90,70,40,.5)'; + ctx.beginPath(); + ctx.arc(sx + ((x * 13) % 7 - 3) * z, sy + ((y * 17) % 5 - 2) * z, 2.4 * z + lit * 2 * z, 0, Math.PI * 2); + ctx.fill(); + } + } + } + } + // park border walls + ctx.strokeStyle = 'rgba(20,24,40,.5)'; + ctx.lineWidth = 2 * z; + const corners = [[0, 0], [n, 0], [n, n], [0, n]].map(([x, y]) => worldToScreen(cam, cw, ch, x, y)); + ctx.beginPath(); + corners.forEach(([sx, sy], i) => i ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy)); + ctx.closePath(); + ctx.stroke(); + + // entrance gate + drawEntrance(ctx, state, cam, cw, ch); +} + +function drawEntrance(ctx, state, cam, cw, ch) { + const m = state.map; + const ex = m.entranceX, ey = m.size - 1; + const [sx, sy] = worldToScreen(cam, cw, ch, ex + 0.5, ey + 0.9); + const z = cam.zoom; + ctx.save(); + ctx.translate(sx, sy); + ctx.scale(z, z); + // gate pillars + ctx.fillStyle = '#8d887c'; + ctx.fillRect(-34, -46, 10, 46); + ctx.fillRect(24, -46, 10, 46); + ctx.fillStyle = '#f5c542'; + ctx.fillRect(-36, -56, 14, 10); + ctx.fillRect(22, -56, 14, 10); + // arch banner + ctx.fillStyle = '#5b3ea8'; + ctx.beginPath(); + ctx.moveTo(-30, -50); + ctx.quadraticCurveTo(0, -78, 30, -50); + ctx.lineTo(30, -44); + ctx.quadraticCurveTo(0, -70, -30, -44); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = '#fff'; + ctx.font = 'bold 7px Trebuchet MS'; + ctx.textAlign = 'center'; + ctx.fillText('ARCANE PARK', 0, -54); + ctx.restore(); +} + +// ---------------- objects (sorted by depth) ---------------- +function drawObjects(ctx, state, cam, cw, ch) { + const items = []; + for (const r of state.rides) items.push({ d: r.x + r.y + r.w + r.h, kind: 'ride', o: r }); + for (const s of state.shops) items.push({ d: s.x + s.y + 1, kind: 'shop', o: s }); + for (const sc of state.sceneryList) items.push({ d: sc.x + sc.y + sc.def.size, kind: 'scenery', o: sc }); + if (state.guild) items.push({ d: state.guild.x + state.guild.y + 3, kind: 'guild', o: state.guild }); + items.sort((a, b) => a.d - b.d); + for (const it of items) { + switch (it.kind) { + case 'ride': drawRide(ctx, state, it.o, cam, cw, ch); break; + case 'shop': drawShop(ctx, it.o, cam, cw, ch, state); break; + case 'scenery': drawScenery(ctx, it.o, cam, cw, ch); break; + case 'guild': drawGuild(ctx, it.o, cam, cw, ch); break; + } + } +} + +function shadow(ctx, sx, sy, rx, ry) { + ctx.fillStyle = 'rgba(0,0,0,.22)'; + ctx.beginPath(); + ctx.ellipse(sx, sy, rx, ry, 0, 0, Math.PI * 2); + ctx.fill(); +} + +function drawShop(ctx, s, cam, cw, ch, state) { + const [sx, sy] = worldToScreen(cam, cw, ch, s.x + 0.5, s.y + 0.5); + const z = cam.zoom; + shadow(ctx, sx, sy + 2 * z, 16 * z, 7 * z); + ctx.save(); ctx.translate(sx, sy); ctx.scale(z, z); + // hut body + ctx.fillStyle = s.damaged > 0.05 ? '#6b5b4d' : '#a8814f'; + ctx.fillRect(-13, -22, 26, 22); + // striped awning + ctx.fillStyle = '#e05b5b'; + for (let i = 0; i < 4; i++) { if (i % 2 === 0) { ctx.fillStyle = '#e05b5b'; } else ctx.fillStyle = '#fff'; ctx.fillRect(-14 + i * 7, -28, 7, 7); } + ctx.fillStyle = '#5b3ea8'; + ctx.fillRect(-15, -29, 30, 3); + // roof peak + ctx.fillStyle = '#7c5230'; + ctx.beginPath(); ctx.moveTo(-15, -22); ctx.lineTo(0, -34); ctx.lineTo(15, -22); ctx.closePath(); ctx.fill(); + // counter glow when open + if (!s.damaged) { + ctx.fillStyle = '#ffd166'; + ctx.font = 'bold 10px serif'; ctx.textAlign = 'center'; + ctx.fillText(s.def.icon, 0, -8); + } else { + ctx.fillStyle = '#ff6b6b'; ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center'; + ctx.fillText('✚', 0, -8); + } + ctx.restore(); +} + +function drawGuild(ctx, gd, cam, cw, ch) { + const cx = gd.x + gd.w / 2, cy = gd.y + gd.h / 2; + const [sx, sy] = worldToScreen(cam, cw, ch, cx, cy); + const z = cam.zoom; + shadow(ctx, sx, sy + 4 * z, 30 * z, 12 * z); + ctx.save(); ctx.translate(sx, sy); ctx.scale(z, z); + // stone keep + ctx.fillStyle = '#7d8496'; + ctx.fillRect(-26, -34, 52, 34); + ctx.fillStyle = '#666d7e'; + ctx.fillRect(-30, -44, 10, 44); + ctx.fillRect(20, -44, 10, 44); + ctx.fillStyle = '#4a4f60'; + ctx.fillRect(-30, -48, 10, 6); + ctx.fillRect(20, -48, 10, 6); + // door + ctx.fillStyle = '#3d2c1e'; + ctx.fillRect(-6, -16, 12, 16); + // banners + ctx.fillStyle = '#a86bff'; + ctx.fillRect(-20, -30, 6, 14); + ctx.fillRect(14, -30, 6, 14); + // shield emblem + ctx.fillStyle = '#f5c542'; + ctx.beginPath(); ctx.arc(0, -26, 6, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#5b3ea8'; + ctx.font = 'bold 8px serif'; ctx.textAlign = 'center'; + ctx.fillText('⚔', 0, -23); + // pennant + ctx.strokeStyle = '#3d2c1e'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(0, -34); ctx.lineTo(0, -52); ctx.stroke(); + ctx.fillStyle = '#e05b5b'; + ctx.beginPath(); ctx.moveTo(0, -52); ctx.lineTo(12 * (0.8 + 0.2 * Math.sin(performance.now() / 300)), -48); ctx.lineTo(0, -44); ctx.closePath(); ctx.fill(); + ctx.restore(); +} + +function drawScenery(ctx, sc, cam, cw, ch) { + const [sx, sy] = worldToScreen(cam, cw, ch, sc.x + sc.def.size / 2, sc.y + sc.def.size / 2); + const z = cam.zoom; + const t = sc.type; + ctx.save(); ctx.translate(sx, sy); ctx.scale(z, z); + switch (t) { + case 'tree_oak': case 'tree_pine': case 'tree_cherry': { + shadow(ctx, 0, 2, 10, 4); + ctx.fillStyle = '#6b4a2b'; ctx.fillRect(-2, -10, 4, 10); + if (t === 'tree_pine') { + ctx.fillStyle = '#2e6b34'; + ctx.beginPath(); ctx.moveTo(0, -34); ctx.lineTo(10, -12); ctx.lineTo(-10, -12); ctx.closePath(); ctx.fill(); + ctx.beginPath(); ctx.moveTo(0, -26); ctx.lineTo(12, -6); ctx.lineTo(-12, -6); ctx.closePath(); ctx.fill(); + } else { + const col = t === 'tree_cherry' ? '#f7a8d0' : '#3f7d3a'; + ctx.fillStyle = col; + ctx.beginPath(); ctx.arc(0, -16, 10, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(-7, -12, 7, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(7, -12, 7, 0, Math.PI * 2); ctx.fill(); + } + break; + } + case 'flowerbed': { + shadow(ctx, 0, 1, 9, 4); + ctx.fillStyle = '#7a5230'; ctx.fillRect(-9, -4, 18, 6); + for (let i = 0; i < 4; i++) { + ctx.fillStyle = ['#ff6b6b', '#ffd166', '#f7a8ff', '#fff'][i]; + ctx.beginPath(); ctx.arc(-6 + i * 4, -5 - (i % 2), 2.2, 0, Math.PI * 2); ctx.fill(); + } + break; + } + case 'hedge': { + ctx.fillStyle = '#2e6b34'; + ctx.beginPath(); ctx.roundRect(-10, -10, 20, 12, 4); ctx.fill(); + break; + } + case 'bench': { + shadow(ctx, 0, 1, 9, 3); + ctx.fillStyle = '#8d887c'; ctx.fillRect(-9, -3, 18, 3); + ctx.fillStyle = '#6b4a2b'; ctx.fillRect(-9, -8, 18, 4); + break; + } + case 'bin': { + shadow(ctx, 0, 1, 5, 2.5); + ctx.fillStyle = '#3f7d3a'; ctx.fillRect(-4, -10, 8, 10); + ctx.fillStyle = '#2e5d2b'; ctx.fillRect(-5, -12, 10, 3); + break; + } + case 'lamp': case 'crystal_lamp': { + shadow(ctx, 0, 1, 4, 2); + ctx.fillStyle = '#3d4457'; ctx.fillRect(-1.2, -20, 2.4, 20); + if (t === 'lamp') { ctx.fillStyle = '#ffd166'; ctx.beginPath(); ctx.arc(0, -22, 4, 0, Math.PI * 2); ctx.fill(); } + else { + ctx.fillStyle = '#a86bff'; + ctx.beginPath(); ctx.moveTo(0, -28); ctx.lineTo(4.5, -21); ctx.lineTo(0, -14); ctx.lineTo(-4.5, -21); ctx.closePath(); ctx.fill(); + } + break; + } + case 'fountain': { + shadow(ctx, 0, 2, 13, 6); + ctx.fillStyle = '#8d887c'; ctx.beginPath(); ctx.ellipse(0, 0, 12, 6, 0, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#58c1ff'; ctx.beginPath(); ctx.ellipse(0, 0, 9, 4.2, 0, 0, Math.PI * 2); ctx.fill(); + const ph = performance.now() / 200 % 1; + ctx.fillStyle = 'rgba(180,225,255,.9)'; + ctx.fillRect(-1, -14 - ph * 4, 2, 8); + ctx.beginPath(); ctx.arc(0, -14 - ph * 4, 2, 0, Math.PI * 2); ctx.fill(); + break; + } + case 'statue_knight': { + shadow(ctx, 0, 2, 8, 3.5); + ctx.fillStyle = '#9aa4c0'; + ctx.fillRect(-3, -16, 6, 16); + ctx.beginPath(); ctx.arc(0, -19, 3.5, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#c0c8dd'; ctx.fillRect(4, -22, 1.6, 12); + break; + } + case 'statue_dragon': { + shadow(ctx, 0, 2, 14, 6); + ctx.fillStyle = '#5fae6f'; + ctx.beginPath(); ctx.ellipse(0, -8, 12, 7, 0, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(9, -14, 4.5, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#3d7d4d'; + ctx.beginPath(); ctx.moveTo(-8, -12); ctx.lineTo(-16, -20); ctx.lineTo(-6, -18); ctx.closePath(); ctx.fill(); + break; + } + case 'ley_pool': { + const pulse = 0.85 + 0.15 * Math.sin(performance.now() / 400); + ctx.fillStyle = 'rgba(168,107,255,.25)'; + ctx.beginPath(); ctx.arc(0, 0, 16 * pulse, 0, Math.PI * 2); ctx.fill(); + shadow(ctx, 0, 2, 13, 6); + ctx.fillStyle = '#5b3ea8'; ctx.beginPath(); ctx.ellipse(0, 0, 12, 6, 0, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = `rgba(195,155,255,${pulse})`; + ctx.beginPath(); ctx.ellipse(0, 0, 8, 3.8, 0, 0, Math.PI * 2); ctx.fill(); + // floating orb + const oy = -10 - Math.sin(performance.now() / 500) * 3; + ctx.fillStyle = '#e0ccff'; ctx.beginPath(); ctx.arc(0, oy, 3, 0, Math.PI * 2); ctx.fill(); + break; + } + case 'rune_stone': { + shadow(ctx, 0, 1, 6, 3); + ctx.fillStyle = '#565d73'; ctx.fillRect(-4, -16, 8, 16); + ctx.fillStyle = '#a86bff'; ctx.font = 'bold 7px serif'; ctx.textAlign = 'center'; + ctx.fillText('ᚱ', 0, -9); + break; + } + case 'mushroom_glow': { + for (let i = 0; i < 3; i++) { + const ox = [-5, 3, 0][i], h2 = [7, 9, 5][i]; + ctx.fillStyle = '#cbb2ff'; + ctx.beginPath(); ctx.arc(ox, -h2, 4, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#efe6ff'; ctx.fillRect(ox - 1, -h2, 2, h2); + } + break; + } + case 'banner': { + ctx.fillStyle = '#3d2c1e'; ctx.fillRect(-1, -22, 2, 22); + const sway = Math.sin(performance.now() / 350) * 2; + ctx.fillStyle = '#f5c542'; + ctx.beginPath(); ctx.moveTo(1, -22); ctx.lineTo(11 + sway, -19); ctx.lineTo(1, -14); ctx.closePath(); ctx.fill(); + break; + } + default: + ctx.font = '14px serif'; ctx.textAlign = 'center'; + ctx.fillText(sc.def.icon || '❓', 0, -8); + } + ctx.restore(); +} + +// ---------------- rides ---------------- +function drawRide(ctx, state, r, cam, cw, ch) { + const cx = r.x + r.w / 2, cy = r.y + r.h / 2; + const [sx, sy] = worldToScreen(cam, cw, ch, cx, cy); + const z = cam.zoom; + const t = performance.now() / 1000; + const running = r.status === 'open' || r.status === 'testing'; + const ph = r.animPhase; + shadow(ctx, sx, sy + 3 * z, 22 * r.w / 2 * z / 1, 9 * z); + ctx.save(); ctx.translate(sx, sy); ctx.scale(z, z); + + switch (r.type) { + case 'carousel': { + // canopy + ctx.fillStyle = r.def.color2; + ctx.beginPath(); ctx.moveTo(-24, -26); ctx.lineTo(0, -38); ctx.lineTo(24, -26); ctx.closePath(); ctx.fill(); + ctx.fillStyle = r.def.color; + for (let i = 0; i < 6; i++) { + const a = running ? ph * 1.2 + i * Math.PI / 3 : i * Math.PI / 3; + const ox = Math.cos(a) * 14, oy = Math.sin(a) * 6; + const bounce = Math.abs(Math.sin(ph * 2 + i)) * 3; + ctx.strokeStyle = '#8d887c'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(ox * 0.4, -26); ctx.lineTo(ox, oy - 10 + bounce); ctx.stroke(); + ctx.fillStyle = '#fff'; + ctx.beginPath(); ctx.arc(ox, oy - 13 + bounce, 4, 0, Math.PI * 2); ctx.fill(); // unicorn head-ish + ctx.fillStyle = r.def.color; + ctx.fillRect(ox - 3, oy - 10 + bounce, 6, 8); + } + ctx.fillStyle = '#8d6aa8'; + ctx.beginPath(); ctx.ellipse(0, -2, 18, 8, 0, 0, Math.PI * 2); ctx.fill(); + break; + } + case 'ferris': { + const R = 26; + ctx.strokeStyle = '#5b6274'; ctx.lineWidth = 3; + ctx.beginPath(); ctx.moveTo(-14, 0); ctx.lineTo(0, -R - 6); ctx.lineTo(14, 0); ctx.stroke(); + const rot = running ? ph * 0.35 : 0; + ctx.strokeStyle = r.def.color; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(0, -R - 6, R, 0, Math.PI * 2); ctx.stroke(); + for (let i = 0; i < 8; i++) { + const a = rot + i * Math.PI / 4; + const gx = Math.cos(a) * R, gy = -R - 6 + Math.sin(a) * R * 0.82; + ctx.strokeStyle = 'rgba(127,178,255,.6)'; + ctx.beginPath(); ctx.moveTo(0, -R - 6); ctx.lineTo(gx, gy); ctx.stroke(); + ctx.fillStyle = i % 2 ? r.def.color2 : '#fff'; + ctx.fillRect(gx - 3, gy - 3, 6, 6); + } + break; + } + case 'drop_tower': { + ctx.fillStyle = '#5b6274'; ctx.fillRect(-5, -52, 10, 52); + const cyc = running ? (ph % 4) : 0; + let carY = -8; + if (running) { + if (cyc < 2.2) carY = -8 - (cyc / 2.2) * 40; // rise + else if (cyc < 2.45) carY = -48; // top hang + else carY = -48 + ((cyc - 2.45) / 0.35) ** 2 * 40; // drop! + } + ctx.fillStyle = r.def.color2; + ctx.fillRect(-8, carY - 4, 16, 6); + ctx.fillStyle = '#ffd166'; + for (let i = 0; i < 4; i++) ctx.fillRect(-7 + i * 4, carY - 2, 2.5, 2.5); + break; + } + case 'teacups': { + ctx.fillStyle = '#c9c2b2'; + ctx.beginPath(); ctx.ellipse(0, 0, 18, 8, 0, 0, Math.PI * 2); ctx.fill(); + for (let i = 0; i < 5; i++) { + const a = running ? ph * 2 + i * Math.PI * 2 / 5 : i * Math.PI * 2 / 5; + const ox = Math.cos(a) * 11, oy = Math.sin(a) * 4.5; + ctx.fillStyle = i % 2 ? r.def.color : r.def.color2; + ctx.beginPath(); ctx.ellipse(ox, oy - 5, 5.5, 4, 0, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(ox + ox * 0.15, oy - 8, 1.6, 0, Math.PI * 2); ctx.fill(); + } + break; + } + case 'swings': { + ctx.fillStyle = '#5b6274'; + ctx.beginPath(); ctx.moveTo(-16, 0); ctx.lineTo(0, -34); ctx.lineTo(16, 0); ctx.stroke ? null : null; + ctx.beginPath(); ctx.moveTo(-16, 0); ctx.lineTo(0, -34); ctx.lineTo(16, 0); ctx.closePath(); ctx.fill(); + ctx.beginPath(); ctx.moveTo(-16, 0); ctx.lineTo(-16, -36); ctx.lineTo(16, -36); ctx.lineTo(16, 0); ctx.closePath(); ctx.fill(); + const rot = running ? ph * 1.6 : 0; + for (let i = 0; i < 8; i++) { + const a = rot + i * Math.PI / 4; + const ang = Math.cos(a) * 0.9; + const hx = Math.cos(a) * 13, hyv = -36 + Math.sin(a) * 4; + const fx = hx + Math.sin(ang) * 14, fy = hyv + Math.cos(Math.abs(ang)) * 12; + ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(hx, hyv); ctx.lineTo(fx, fy); ctx.stroke(); + ctx.fillStyle = i % 2 ? '#f7a8ff' : '#a5e6ff'; + ctx.fillRect(fx - 2.5, fy - 2, 5, 5); + } + break; + } + case 'haunted': { + ctx.fillStyle = '#4a3d68'; + ctx.fillRect(-22, -26, 44, 26); + ctx.fillStyle = '#372d52'; + ctx.beginPath(); ctx.moveTo(-26, -26); ctx.lineTo(0, -42); ctx.lineTo(26, -26); ctx.closePath(); ctx.fill(); + // windows flicker + for (let i = 0; i < 3; i++) { + const lit = Math.sin(t * 3 + i * 2) > 0; + ctx.fillStyle = lit ? '#5de0c8' : '#241d3a'; + ctx.fillRect(-14 + i * 11, -20, 6, 8); + } + if (running && Math.sin(ph * 3) > 0.93) { + ctx.globalAlpha = 0.8; ctx.font = '12px serif'; ctx.textAlign = 'center'; + ctx.fillText('👻', 6, -34); ctx.globalAlpha = 1; + } + break; + } + case 'logflume': { + // water channel ring around a hill with drop + ctx.fillStyle = '#3a7cc9'; + ctx.fillRect(-30, -6, 60, 10); + ctx.fillStyle = '#63c5ea'; + ctx.fillRect(-28, -5, 56, 6); + ctx.fillStyle = '#8d887c'; + ctx.beginPath(); ctx.moveTo(-12, -6); ctx.lineTo(0, -30); ctx.lineTo(12, -6); ctx.closePath(); ctx.fill(); + // logs animating around + if (running) { + for (let i = 0; i < 3; i++) { + const p2 = ((ph * 0.25 + i / 3) % 1); + const lx = -28 + p2 * 56; + const liftY = p2 < 0.15 ? -(p2 / 0.15) * 22 : p2 > 0.8 ? -((1 - p2) / 0.2) * 22 : 0; + const ly = p2 < 0.15 ? -6 - (p2 / 0.15) * 22 : p2 > 0.75 ? -6 - ((1 - p2) / 0.25) * 24 : -4; + ctx.fillStyle = r.def.color2; + ctx.fillRect(clamp(lx, -28, 22), ly - 4, 6, 5); + } + } + break; + } + case 'dragon_coaster': { + if (r.isCustomCoaster) break; // real track is drawn by drawTrackAll + // prebuilt junior coaster: static hills + dragon train circling + ctx.strokeStyle = '#8d887c'; ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(-26, -2); ctx.quadraticCurveTo(-14, -26, 0, -8); ctx.quadraticCurveTo(14, -30, 26, -2); + ctx.stroke(); + if (running) { + const p2 = ph % 2 / 2; + const tx2 = -26 + p2 * 52; + const ty2 = -Math.sin(p2 * Math.PI * 2) * 16 - 8; + ctx.font = '11px serif'; ctx.textAlign = 'center'; + ctx.fillText('🐉', tx2, ty2); + ctx.fillText('🐉', tx2 - 8, ty2 + 2); + } + break; + } + case 'portal': { + const spin = running ? ph * 2 : 0.4; + for (let i = 0; i < 3; i++) { + ctx.strokeStyle = `rgba(${i === 0 ? '168,107,255' : i === 1 ? '88,193,255' : '245,197,66'},${0.9 - i * 0.25})`; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.ellipse(0, -14, 16 - i * 3, 20 - i * 4, spin + i, 0, Math.PI * 2); + ctx.stroke(); + } + if (running && Math.random() < 0.3) { + ctx.fillStyle = '#fff'; + ctx.beginPath(); ctx.arc((Math.random() - 0.5) * 20, -14 + (Math.random() - 0.5) * 26, 1.4, 0, Math.PI * 2); ctx.fill(); + } + break; + } + case 'broom_tower': { + ctx.fillStyle = '#7c5230'; ctx.fillRect(-7, -46, 14, 46); + ctx.fillStyle = '#5b3ea8'; ctx.fillRect(-9, -50, 18, 6); + if (running) { + const a = ph * 2; + const bx = Math.cos(a) * 14, by = -10 + Math.sin(a * 2) * 4 - ((ph * 6) % 36); + ctx.font = '10px serif'; ctx.textAlign = 'center'; + ctx.fillText('🧹', bx, by); + } + break; + } + default: { + ctx.fillStyle = r.def.color; + ctx.fillRect(-16, -20, 32, 20); + ctx.font = '14px serif'; ctx.textAlign = 'center'; + ctx.fillText(r.def.icon, 0, -6); + } + } + + // status lamp + const statusCol = r.status === 'open' ? '#57d97a' : r.status === 'broken' ? '#ff6b6b' : r.status === 'testing' ? '#ffb347' : '#9aa4c0'; + ctx.fillStyle = statusCol; + ctx.beginPath(); ctx.arc(r.w > 2 ? 20 : 14, -r.w * 6 - 8, 3, 0, Math.PI * 2); ctx.fill(); + // name plate + if (cam.zoom > 0.75) { + ctx.font = 'bold 8px Trebuchet MS'; ctx.textAlign = 'center'; + ctx.fillStyle = 'rgba(10,12,24,.65)'; + const tw = ctx.measureText(r.name).width + 8; + ctx.fillRect(-tw / 2, -r.w * 6 - 24, tw, 11); + ctx.fillStyle = '#f5c542'; + ctx.fillText(r.name, 0, -r.w * 6 - 16); + } + ctx.restore(); +} + +// ---------------- coaster tracks (custom) ---------------- +function trackScreenPos(cam, cw, ch, p) { + return worldToScreen(cam, cw, ch, p.x + 0.5, p.y + 0.5, p.z * 2); // z units doubled for drama +} +function drawTrackAll(ctx, state, cam, cw, ch) { + for (const r of state.rides) { + if (!r.isCustomCoaster || !r.track?.length) continue; + drawCoasterTrack(ctx, r, cam, cw, ch); + // train + const pos = trainPosition(r); + if (pos && (r.riders.length || r.status === 'testing' || r.cycleT > 0)) { + const [tx, ty] = worldToScreen(cam, cw, ch, pos.x + 0.5, pos.y + 0.5, pos.z * 2); + ctx.save(); + ctx.translate(tx, ty); + ctx.scale(cam.zoom, cam.zoom); + // little cars + ctx.fillStyle = '#333'; + ctx.fillRect(-6, -3, 12, 5); + ctx.fillStyle = r.train?.color || '#e05b5b'; + ctx.fillRect(-5, -6, 10, 4); + if (pos.loop) { ctx.font = '10px serif'; ctx.textAlign = 'center'; ctx.fillText('🎢', 0, -10); } + ctx.restore(); + } + } + // active build session ghost track + const sess = state._coasterBuild; + if (sess) drawBuildSession(ctx, state, sess, cam, cw, ch); +} + +function drawCoasterTrack(ctx, r, cam, cw, ch) { + const tr = r.track; + ctx.lineCap = 'round'; + for (let i = 0; i < tr.length; i++) { + const p = tr[i]; + const nx = tr[(i + 1) % tr.length]; + const [ax, ay] = trackScreenPos(cam, cw, ch, p); + const [bx, by] = trackScreenPos(cam, cw, ch, nx); + // supports + if (p.z > 0 && p.type !== 'station') { + const [gx, gy] = worldToScreen(cam, cw, ch, p.x + 0.5, p.y + 0.5, 0); + ctx.strokeStyle = 'rgba(70,74,92,.75)'; + ctx.lineWidth = Math.max(1, 2 * cam.zoom); + ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(gx, gy); ctx.stroke(); + } + // ties/base + ctx.strokeStyle = '#4a4257'; + ctx.lineWidth = Math.max(2, 5 * cam.zoom); + ctx.beginPath(); + ctx.moveTo(ax, ay); + if (p.turn !== 0 || nx.dir !== undefined) { + // curved piece: control point at shared corner + const mid = { x: p.x + 0.5 + DIRV[p.dir][0] * 0.5, y: p.y + 0.5 + DIRV[p.dir][1] * 0.5 }; + const [mx, my] = trackScreenPos(cam, cw, ch, { ...mid, z: (p.z + nx.z) / 2 }); + ctx.quadraticCurveTo(mx, my, bx, by); + } else { + ctx.lineTo(bx, by); + } + ctx.stroke(); + // rails highlight + ctx.strokeStyle = p.lift ? '#ffd166' : (p.type === 'loop' ? '#a86bff' : '#e8ecf7'); + ctx.lineWidth = Math.max(1, 1.6 * cam.zoom); + ctx.stroke(); + // loop decoration + if (p.type === 'loop') { + ctx.strokeStyle = 'rgba(168,107,255,.8)'; + ctx.lineWidth = Math.max(1, 2 * cam.zoom); + ctx.beginPath(); + ctx.ellipse(ax, ay - 10 * cam.zoom, 5 * cam.zoom, 12 * cam.zoom, 0, 0, Math.PI * 2); + ctx.stroke(); + } + if (p.type === 'station') { + ctx.fillStyle = '#6b7288'; + ctx.fillRect(ax - 8 * cam.zoom, ay - 4 * cam.zoom, 16 * cam.zoom, 6 * cam.zoom); + } + } +} +const DIRV = [[1, 0], [0, 1], [-1, 0], [0, -1]]; + +function drawBuildSession(ctx, state, sess, cam, cw, ch) { + // existing pieces solid + const fakeRide = { track: sess.pieces.map(p => ({ ...p, dir: p.dir ?? 0 })), train: null }; + ctx.globalAlpha = 0.95; + drawCoasterTrack(ctx, fakeRide, cam, cw, ch); + ctx.globalAlpha = 1; + // cursor marker + const [cx, cy2] = worldToScreen(cam, cw, ch, sess.cx + 0.5, sess.cy + 0.5, sess.cz * 2); + ctx.strokeStyle = '#f5c542'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(cx, cy2 - 8 * cam.zoom); ctx.lineTo(cx + 6 * cam.zoom, cy2); ctx.lineTo(cx, cy2 + 8 * cam.zoom); ctx.lineTo(cx - 6 * cam.zoom, cy2); + ctx.closePath(); ctx.stroke(); +} + +// ---------------- entities ---------------- +function drawEntities(ctx, state, cam, cw, ch) { + const ents = []; + for (const g of state.guests) ents.push({ d: g.x + g.y, e: g, k: 'g' }); + for (const s of state.staff) ents.push({ d: s.x + s.y, e: s, k: 's' }); + for (const h of state.heroes) if (h.alive) ents.push({ d: h.x + h.y, e: h, k: 'h' }); + for (const mo of state.monsters) ents.push({ d: mo.x + mo.y, e: mo, k: 'm' }); + ents.sort((a, b) => a.d - b.d); + const z = cam.zoom; + for (const { e, k } of ents) { + const [sx, sy] = worldToScreen(cam, cw, ch, e.x, e.y); + if (sx < -30 || sy < -40 || sx > cw + 30 || sy > ch + 40) continue; + if (k === 'g') { + // guest: tiny person + shadow(ctx, sx, sy, 3.4 * z, 1.5 * z); + ctx.fillStyle = e.color; + ctx.fillRect(sx - 2.4 * z, sy - 8.5 * z, 4.8 * z, 6 * z); + ctx.fillStyle = '#ffe0c0'; + ctx.beginPath(); ctx.arc(sx, sy - 10.5 * z, 2.6 * z, 0, Math.PI * 2); ctx.fill(); + // balloon! + if (e.favRide === 'balloon' && false) { } + if (z > 0.8 && e.bubbleT > 0 && e.bubble) { + drawBubble(ctx, sx, sy - 20 * z, e.bubble, z); + } + } else if (k === 's') { + shadow(ctx, sx, sy, 3.4 * z, 1.5 * z); + ctx.fillStyle = { handyman: '#57d97a', mechanic: '#ffb347', guard: '#58c1ff', entertainer: '#f7a8ff' }[e.type] || '#fff'; + ctx.fillRect(sx - 2.6 * z, sy - 9 * z, 5.2 * z, 6.4 * z); + ctx.fillStyle = '#ffe0c0'; + ctx.beginPath(); ctx.arc(sx, sy - 11 * z, 2.7 * z, 0, Math.PI * 2); ctx.fill(); + if (z > 1.1) { ctx.font = `${7 * z}px serif`; ctx.textAlign = 'center'; ctx.fillText(e.def.icon, sx, sy - 14 * z); } + } else if (k === 'h') { + shadow(ctx, sx, sy, 4.5 * z, 2 * z); + ctx.fillStyle = '#2a3559'; + ctx.fillRect(sx - 3 * z, sy - 10 * z, 6 * z, 7.5 * z); + ctx.fillStyle = '#ffe0c0'; + ctx.beginPath(); ctx.arc(sx, sy - 12.5 * z, 3 * z, 0, Math.PI * 2); ctx.fill(); + ctx.font = `${9 * z}px serif`; ctx.textAlign = 'center'; + ctx.fillText(e.def.icon, sx, sy - 16 * z); + // hp bar + const hpF = e.hp / e.maxHp; + ctx.fillStyle = 'rgba(0,0,0,.5)'; + ctx.fillRect(sx - 6 * z, sy + 2 * z, 12 * z, 2 * z); + ctx.fillStyle = hpF > 0.5 ? '#57d97a' : hpF > 0.25 ? '#ffb347' : '#ff6b6b'; + ctx.fillRect(sx - 6 * z, sy + 2 * z, 12 * z * hpF, 2 * z); + } else { + shadow(ctx, sx, sy, 5 * z, 2.2 * z); + ctx.save(); + if (e.flashT > 0) { ctx.shadowColor = '#ff5c5c'; ctx.shadowBlur = 10; } + ctx.font = `${13 * z}px serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(e.def.icon, sx, sy - 6 * z); + ctx.restore(); + const hpF = e.hp / e.maxHp; + ctx.fillStyle = 'rgba(0,0,0,.5)'; + ctx.fillRect(sx - 7 * z, sy + 2 * z, 14 * z, 2.4 * z); + ctx.fillStyle = '#ff6b6b'; + ctx.fillRect(sx - 7 * z, sy + 2 * z, 14 * z * hpF, 2.4 * z); + } + } +} + +function drawBubble(ctx, sx, sy, text, z) { + ctx.font = `${Math.max(8, 8 * z)}px Trebuchet MS`; + const w = ctx.measureText(text).width + 10; + const h = 14 * z + 4; + ctx.fillStyle = 'rgba(255,255,255,.94)'; + ctx.beginPath(); + ctx.roundRect(sx - w / 2, sy - h / 2 - h, w, h, 4); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(sx - 3, sy - h + 2); ctx.lineTo(sx + 3, sy - h + 2); ctx.lineTo(sx, sy - h + 7); + ctx.fill(); + ctx.fillStyle = '#222'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(text.length > 26 ? text.slice(0, 24) + '…' : text, sx, sy - h / 2 - h / 2); + ctx.textBaseline = 'alphabetic'; +} + +// ---------------- ghosts & tool overlays ---------------- +import { sessionActive, nextCellFor, getSession } from '../game/coaster.js'; + +function drawGhosts(ctx, state, cam, cw, ch, mouse) { + const tool = state._ui?.tool; + if (!mouse || !mouse.tile) return; + const [hx, hy] = mouse.tile; + const m = state.map; + if (sessionActive(state)) { + const sel = state._ui?.coasterPiece || 'straight'; + const sess = getSession(state); + const nc = nextCellFor(sess, sel); + const [gx, gy] = worldToScreen(cam, cw, ch, nc.x + 0.5, nc.y + 0.5, nc.z * 2); + const valid = validateGhost(state, sess, sel); + ctx.strokeStyle = valid ? '#57d97a' : '#ff6b6b'; + ctx.fillStyle = valid ? 'rgba(87,217,122,.25)' : 'rgba(255,107,107,.25)'; + ctx.lineWidth = 2; + tilePoly(ctx, gx, gy, cam.zoom * 0.96); + ctx.fill(); ctx.stroke(); + } else if (tool === 'path' || tool === 'terrain' || (tool === 'scenery' && state._ui?.sel?.size === 1) || tool === 'select') { + const [gx, gy] = worldToScreen(cam, cw, ch, hx + 0.5, hy + 0.5); + ctx.strokeStyle = 'rgba(245,197,66,.9)'; + ctx.lineWidth = Math.max(1.2, cam.zoom); + tilePoly(ctx, gx, gy, cam.zoom * 0.98); + ctx.stroke(); + } else if ((tool === 'ride' || tool === 'shop' || tool === 'scenery') && state._ui?.sel) { + const def = state._ui.sel; + const w = def.w || def.size || 1, h = def.h || def.size || 1; + const ok = canGhostPlace(state, hx, hy, w, h); + for (let yy = 0; yy < h; yy++) for (let xx = 0; xx < w; xx++) { + const [gx, gy] = worldToScreen(cam, cw, ch, hx + xx + 0.5, hy + yy + 0.5); + ctx.fillStyle = ok ? 'rgba(87,217,122,.3)' : 'rgba(255,107,107,.3)'; + tilePoly(ctx, gx, gy, cam.zoom * 0.96); + ctx.fill(); + ctx.strokeStyle = ok ? '#57d97a' : '#ff6b6b'; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + } else if (tool === 'heroes' && state._ui?.heroSubtool === 'guild') { + const ok = guildGhostOk(state, hx, hy); + for (let yy = 0; yy < 2; yy++) for (let xx = 0; xx < 2; xx++) { + const [gx, gy] = worldToScreen(cam, cw, ch, hx + xx + 0.5, hy + yy + 0.5); + ctx.fillStyle = ok ? 'rgba(168,107,255,.3)' : 'rgba(255,107,107,.3)'; + tilePoly(ctx, gx, gy, cam.zoom * 0.96); + ctx.fill(); + ctx.strokeStyle = ok ? '#a86bff' : '#ff6b6b'; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + } +} +import { validatePiece } from '../game/coaster.js'; +function validateGhost(state, sess, sel) { + return validatePiece(state, sess, sel).ok; +} +function canGhostPlace(state, x, y, w, h) { + const m = state.map; + for (let yy = 0; yy < h; yy++) for (let xx = 0; xx < w; xx++) { + if (!m.isBuildable(x + xx, y + yy) || m.occupied(x + xx, y + yy)) return false; + } + return true; +} +function guildGhostOk(state, x, y) { + const m = state.map; + for (let yy = 0; yy < 2; yy++) for (let xx = 0; xx < 2; xx++) { + if (!m.isBuildable(x + xx, y + yy) || m.occupied(x + xx, y + yy)) return false; + } + return true; +} + +// ---------------- effects ---------------- +function drawEffects(ctx, state, cam, cw, ch) { + const z = cam.zoom; + // float texts + for (let i = state.floatTexts.length - 1; i >= 0; i--) { + const f = state.floatTexts[i]; + f.t += 1 / 60; + if (f.t >= f.dur) { state.floatTexts.splice(i, 1); continue; } + const [sx, sy] = worldToScreen(cam, cw, ch, f.x, f.y); + const a = 1 - f.t / f.dur; + ctx.globalAlpha = a; + ctx.font = `bold ${10 * z}px Trebuchet MS`; + ctx.textAlign = 'center'; + ctx.fillStyle = '#000'; + ctx.fillText(f.text, sx + 1, sy - 18 * z - f.t * 18 + 1); + ctx.fillStyle = f.color || '#fff'; + ctx.fillText(f.text, sx, sy - 18 * z - f.t * 18); + ctx.globalAlpha = 1; + } + // battle/spell effects + for (let i = state.effects.length - 1; i >= 0; i--) { + const ef = state.effects[i]; + ef.t += 1 / 60; + if (ef.t >= ef.dur) { state.effects.splice(i, 1); continue; } + const p = ef.t / ef.dur; + if (ef.kind === 'hit') { + const [sx, sy] = worldToScreen(cam, cw, ch, ef.x, ef.y); + ctx.strokeStyle = ef.color || '#ffd166'; + ctx.lineWidth = 2 * z; + ctx.globalAlpha = 1 - p; + ctx.beginPath(); + ctx.arc(sx, sy - 6 * z, (4 + p * 12) * z, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = 1; + } else if (ef.kind === 'poof') { + const [sx, sy] = worldToScreen(cam, cw, ch, ef.x, ef.y); + ctx.globalAlpha = 1 - p; + ctx.font = `${14 * z}px serif`; ctx.textAlign = 'center'; + ctx.fillText(ef.icon, sx, sy - 8 * z - p * 14); + ctx.globalAlpha = 1; + } else if (ef.kind === 'heal') { + const [sx, sy] = worldToScreen(cam, cw, ch, ef.x, ef.y); + ctx.globalAlpha = 1 - p; + ctx.fillStyle = '#57d97a'; + ctx.font = `bold ${9 * z}px serif`; ctx.textAlign = 'center'; + ctx.fillText('✚', sx, sy - 12 * z - p * 12); + ctx.globalAlpha = 1; + } else if (ef.kind === 'spellburst') { + ctx.globalAlpha = (1 - p) * 0.9; + ctx.font = `${64 * (0.5 + p)}px serif`; + ctx.textAlign = 'center'; + ctx.fillText(ef.icon, cw / 2, ch / 2 - 40); + ctx.globalAlpha = 1; + } + } +} + +// ---------------- weather & night overlays ---------------- +let raindrops = []; +for (let i = 0; i < 160; i++) raindrops.push({ x: Math.random(), y: Math.random(), s: 0.5 + Math.random() }); + +function drawWeatherFx(ctx, state, cw, ch) { + const w = state.weather.cur; + if (w === 'rain' || w === 'storm') { + const count = w === 'storm' ? 160 : 90; + ctx.strokeStyle = 'rgba(160,190,255,.4)'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let i = 0; i < count; i++) { + const d = raindrops[i]; + d.y += 0.02 * d.s; d.x += 0.004 * d.s; + if (d.y > 1) { d.y = -0.05; d.x = Math.random(); } + const rx = d.x * cw, ry = d.y * ch; + ctx.moveTo(rx, ry); + ctx.lineTo(rx - 3, ry + 9 * d.s); + } + ctx.stroke(); + if (w === 'storm' && Math.random() < 0.006) { + ctx.fillStyle = 'rgba(255,255,220,.55)'; + ctx.fillRect(0, 0, cw, ch); + } + } +} + +function drawDayNight(ctx, state, cam, cw, ch) { + const n = nightFactor(state.time.hour); + if (n > 0) { + ctx.fillStyle = `rgba(8,10,34,${n * 0.42})`; + ctx.fillRect(0, 0, cw, ch); + // lamp glows + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + for (const sc of state.sceneryList) { + if (!sc.def.light) continue; + const [sx, sy] = worldToScreen(cam, cw, ch, sc.x + 0.5, sc.y + 0.5, 1); + const rad = sc.def.light * 10 * cam.zoom; + const grad = ctx.createRadialGradient(sx, sy, 0, sx, sy, rad); + const col = sc.type === 'crystal_lamp' || sc.def.magic ? '168,107,255' : '255,209,102'; + grad.addColorStop(0, `rgba(${col},${0.35 * n})`); + grad.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = grad; + ctx.beginPath(); ctx.arc(sx, sy, rad, 0, Math.PI * 2); ctx.fill(); + } + ctx.restore(); + } + const wt = { sunny: null, cloudy: 'rgba(120,130,160,.08)', rain: 'rgba(60,80,140,.15)', storm: 'rgba(30,40,90,.25)' }[state.weather.cur]; + if (wt) { ctx.fillStyle = wt; ctx.fillRect(0, 0, cw, ch); } +} + +// ---------------- minimap ---------------- +export function renderMinimap(mmCtx, state, cam, cw, ch) { + const n = state.map.size; + const S = mmCtx.canvas.width / n; + const img = mmCtx.createImageData(mmCtx.canvas.width, mmCtx.canvas.height); + // simpler: clear and paint rects + mmCtx.clearRect(0, 0, mmCtx.canvas.width, mmCtx.canvas.height); + for (let y = 0; y < n; y++) { + for (let x = 0; x < n; x++) { + const i = state.map.idx(x, y); + let c; + if (state.map.pathType[i]) c = '#b8b2a5'; + else c = (TERRAIN_COLORS[state.map.terrain[i]] || TERRAIN_COLORS[0])[0]; + mmCtx.fillStyle = c; + mmCtx.fillRect(x * S, y * S, S + 0.5, S + 0.5); + } + } + for (const sc of state.sceneryList) { mmCtx.fillStyle = '#2e6b34'; mmCtx.fillRect(sc.x * S, sc.y * S, S, S); } + for (const r of state.rides) { mmCtx.fillStyle = '#e05b5b'; mmCtx.fillRect(r.x * S, r.y * S, r.w * S, r.h * S); } + for (const s of state.shops) { mmCtx.fillStyle = '#ffd166'; mmCtx.fillRect(s.x * S, s.y * S, S, S); } + if (state.guild) { mmCtx.fillStyle = '#a86bff'; mmCtx.fillRect(state.guild.x * S, state.guild.y * S, 2 * S, 2 * S); } + for (const g of state.guests) { mmCtx.fillStyle = '#fff'; mmCtx.fillRect(g.x * S - 1, g.y * S - 1, 2, 2); } + for (const mo of state.monsters) { mmCtx.fillStyle = '#ff3b3b'; mmCtx.fillRect(mo.x * S - 2, mo.y * S - 2, 4, 4); } + for (const h of state.heroes) { if (h.alive) { mmCtx.fillStyle = '#58c1ff'; mmCtx.fillRect(h.x * S - 2, h.y * S - 2, 4, 4); } } + // camera viewport indicator (diamond) + mmCtx.strokeStyle = 'rgba(255,255,255,.9)'; + mmCtx.lineWidth = 1.5; + const half = (cw / (TW2 * cam.zoom)) / 2; + const cx = cam.x, cy = cam.y; + mmCtx.beginPath(); + mmCtx.moveTo((cx) * S, (cy - half) * S); + mmCtx.lineTo((cx + half) * S, (cy) * S); + mmCtx.lineTo((cx) * S, (cy + half) * S); + mmCtx.lineTo((cx - half) * S, (cy) * S); + mmCtx.closePath(); + mmCtx.stroke(); +} diff --git a/js/ui/dialogs.js b/js/ui/dialogs.js new file mode 100644 index 0000000..8d2e35f --- /dev/null +++ b/js/ui/dialogs.js @@ -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 = ` +

🎯 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; +} diff --git a/js/ui/povui.js b/js/ui/povui.js new file mode 100644 index 0000000..0100d33 --- /dev/null +++ b/js/ui/povui.js @@ -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 = ` + +
+ 🎢 ${ride.name} + 💨 0 km/h + ⚡ ${ride.excite.toFixed ? ride.excite.toFixed(1) : ride.excite} + +
+ `; + 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; } diff --git a/js/ui/ui.js b/js/ui/ui.js new file mode 100644 index 0000000..149a8a7 --- /dev/null +++ b/js/ui/ui.js @@ -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 = `
+ Build a station first — click a flat tile next to a path.
+ Then add pieces to form a closed circuit back to the station.

+ 🟡 cursor = next slot · Right-click = undo · Chain lifts are automatic.
`; + 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 = `
Monsters will invade soon! Build the Heroes Guild to recruit defenders.
`; + 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', `💰 ${fmtMoney(state.cash)}`); + setText('stat-guests', `🧑‍🤝‍🧑 ${fmtNum(state.guests.length)}`); + setText('stat-rating', `⭐ ${state.stats.rating}`); + 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('DAMAGED', `${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; +} diff --git a/js/world/map.js b/js/world/map.js new file mode 100644 index 0000000..739540e --- /dev/null +++ b/js/world/map.js @@ -0,0 +1,105 @@ +// ============ map.js — tile world, terrain gen, object placement ============ +import { makeRng } from '../core/util.js'; + +export const T_GRASS = 0, T_SAND = 1, T_ROCK = 2, T_WATER = 3; + +export class GameMap { + constructor(size = 52) { + this.size = size; + this.terrain = new Uint8Array(size * size); // terrain type + this.pathType = new Uint8Array(size * size); // 0 none, 1 pavement, 2 cobble + this.litter = new Float32Array(size * size); // 0..1 litter amount + this.vomit = new Float32Array(size * size); + // occupancy: null or {kind:'ride'|'shop'|'scenery'|'guild', id, ox, oy} where id indexes state arrays + this.objects = new Array(size * size).fill(null); + // coaster track occupies cells too: {kind:'track', rideId, pieceIndex} + this.beauty = new Float32Array(size * size); // accumulated scenery beauty for rating + } + + idx(x, y) { return y * this.size + x; } + inBounds(x, y) { return x >= 0 && y >= 0 && x < this.size && y < this.size; } + terrainAt(x, y) { return this.inBounds(x, y) ? this.terrain[this.idx(x, y)] : -1; } + isPath(x, y) { return this.inBounds(x, y) && this.pathType[this.idx(x, y)] > 0; } + isWalkable(x, y) { + if (!this.inBounds(x, y)) return false; + const i = this.idx(x, y); + if (this.pathType[i] > 0) return true; + return false; + } + isBuildable(x, y) { + if (!this.inBounds(x, y)) return false; + const i = this.idx(x, y); + if (this.terrain[i] === T_WATER) return false; + return true; + } + occupied(x, y) { + if (!this.inBounds(x, y)) return true; + return this.objects[this.idx(x, y)] !== null; + } + + setObject(x, y, obj) { this.objects[this.idx(x, y)] = obj; } + getObject(x, y) { return this.inBounds(x, y) ? this.objects[this.idx(x, y)] : { kind: 'out' }; } + clearObject(x, y) { this.objects[this.idx(x, y)] = null; } + + /** Generate terrain from scenario gen spec */ + generate(gen, seed) { + const rng = makeRng(seed); + const n = this.size; + // value-noise-ish rolling grass with rock patches + const rockNoise = makeRng(seed ^ 0x9e3779b9); + const lakeCount = gen.lake ?? 1; + const lakes = []; + for (let i = 0; i < lakeCount; i++) { + lakes.push({ x: 8 + rng() * (n - 20), y: 8 + rng() * (n - 20), r: 4 + rng() * 4 }); + } + // entrance at south edge center + this.entranceX = Math.floor(n / 2); + this.entranceY = n - 3; + + for (let y = 0; y < n; y++) { + for (let x = 0; x < n; x++) { + const i = this.idx(x, y); + let t = T_GRASS; + // rocky regions + if (gen.rocks) { + const v = rockNoise(); + if (v < 0.10 + (gen.rocky ? 0.14 : 0)) t = T_ROCK; + } else if (rng() < 0.03) t = T_ROCK; + // sand near water + for (const L of lakes) { + const d = Math.hypot(x - L.x, y - L.y); + if (d < L.r) t = T_WATER; + else if (d < L.r + 1.6 && gen.sand) t = T_SAND; + } + this.terrain[i] = t; + } + } + // scatter rocks as scenery later by renderer using deterministic rng + this.scatterSeed = seed; + } + + /** find a free rect area of w×h buildable & unoccupied tiles near cx,cy */ + findFreeRect(w, h, cx, cy, maxR = 40) { + for (let r = 0; r < maxR; r++) { + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = cx + dx, y = cy + dy; + let ok = true; + for (let yy = 0; yy < h && ok; yy++) { + for (let xx = 0; xx < w && ok; xx++) { + if (!this.isBuildable(x + xx, y + yy) || this.occupied(x + xx, y + yy)) ok = false; + } + } + if (ok) return { x, y }; + } + } + } + return null; + } + + countLitter() { + let c = 0; + for (let i = 0; i < this.litter.length; i++) if (this.litter[i] > 0.25) c++; + return c; + } +} diff --git a/js/world/path.js b/js/world/path.js new file mode 100644 index 0000000..84fd253 --- /dev/null +++ b/js/world/path.js @@ -0,0 +1,119 @@ +// ============ path.js — BFS pathfinding over path tiles ============ +const DIRS = [[1, 0], [0, 1], [-1, 0], [0, -1]]; + +/** + * BFS from (sx,sy) to (tx,ty) over walkable path tiles. + * Returns array of [x,y] steps (excluding start, including target) or null. + */ +export function findPath(map, sx, sy, tx, ty, maxNodes = 6000) { + if (!map.isWalkable(sx, sy) || !map.isWalkable(tx, ty)) return null; + if (sx === tx && sy === ty) return []; + const size = map.size; + const prev = new Int32Array(size * size).fill(-1); + const visited = new Uint8Array(size * size); + const startIdx = sy * size + sx; + const queue = [startIdx]; + visited[startIdx] = 1; + let head = 0, nodes = 0; + const targetIdx = ty * size + tx; + while (head < queue.length && nodes < maxNodes) { + const cur = queue[head++]; + nodes++; + if (cur === targetIdx) break; + const cx = cur % size, cy = (cur / size) | 0; + for (const [dx, dy] of DIRS) { + const nx = cx + dx, ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= size || ny >= size) continue; + const ni = ny * size + nx; + if (visited[ni] || !map.isWalkable(nx, ny)) continue; + visited[ni] = 1; + prev[ni] = cur; + queue.push(ni); + } + } + if (!visited[targetIdx]) return null; + const out = []; + let cur = targetIdx; + while (cur !== startIdx) { + out.push([cur % size, (cur / size) | 0]); + cur = prev[cur]; + if (cur < 0) return null; + } + out.reverse(); + return out; +} + +/** BFS flood to collect all reachable path tiles within radius r of (x,y) */ +export function reachableWithin(map, x, y, r, outSet) { + const size = map.size; + const seen = outSet || new Set(); + const startIdx = y * size + x; + if (!map.isWalkable(x, y)) return seen; + const q = [[x, y, 0]]; + seen.add(startIdx); + let head = 0; + while (head < q.length) { + const [cx, cy, d] = q[head++]; + if (d >= r) continue; + for (const [dx, dy] of DIRS) { + const nx = cx + dx, ny = cy + dy; + if (!map.isWalkable(nx, ny)) continue; + const ni = ny * size + nx; + if (seen.has(ni)) continue; + seen.add(ni); + q.push([nx, ny, d + 1]); + } + } + return seen; +} + +/** Find nearest tile satisfying predicate via expanding ring search on paths */ +export function findNearestPathTile(map, x, y, pred, maxR = 30) { + for (let r = 0; r <= maxR; r++) { + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue; + const nx = x + dx, ny = y + dy; + if (map.isWalkable(nx, ny) && pred(nx, ny)) return [nx, ny]; + } + } + } + return null; +} + +/** Random reachable path tile within radius (for wandering) */ +export function randomNearbyPath(map, rng, x, y, minR = 3, maxR = 12) { + const r = minR + Math.floor(rng() * (maxR - minR)); + const cands = []; + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const nx = Math.round(x + dx), ny = Math.round(y + dy); + if (Math.abs(dx) + Math.abs(dy) > r || Math.abs(dx) + Math.abs(dy) < minR * 0.6) continue; + if (map.isWalkable(nx, ny)) cands.push([nx, ny]); + } + } + if (!cands.length) { + // fallback: any adjacent path + for (const [dx, dy] of DIRS) { + const nx = Math.round(x + dx), ny = Math.round(y + dy); + if (map.isWalkable(nx, ny)) return [nx, ny]; + } + return null; + } + return cands[Math.floor(rng() * cands.length)]; +} + +/** Snap a world position to the nearest walkable path tile (searching outward) */ +export function snapToPath(map, x, y, maxR = 4) { + const rx = Math.round(x), ry = Math.round(y); + if (map.isWalkable(rx, ry)) return [rx, ry]; + for (let r = 1; r <= maxR; r++) { + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue; + if (map.isWalkable(rx + dx, ry + dy)) return [rx + dx, ry + dy]; + } + } + } + return [Math.min(map.size - 1, Math.max(0, rx)), Math.min(map.size - 1, Math.max(0, ry))]; +} diff --git a/tests/flow.mjs b/tests/flow.mjs new file mode 100644 index 0000000..56b8295 --- /dev/null +++ b/tests/flow.mjs @@ -0,0 +1,84 @@ +// ============ flow.mjs — simulate New Game click flow headlessly ============ +let fails = 0; +const ok = (cond, msg) => { console.log((cond ? '✔ ' : '✘ ') + msg); if (!cond) fails++; }; + +const makeCtx = () => new Proxy(function () {}, { + get(t, p) { if (!(p in t)) t[p] = (...a) => makeCtx(); return typeof t[p] === 'function' ? t[p] : t[p]; }, + set() { return true; }, + apply() { return makeCtx(); }, +}); +const listenersOf = el => (el._listeners ??= {}); +const fakeEl = (id = '') => { + const el = { + id, style: {}, dataset: {}, children: [], files: [], + classList: { + _set: new Set(['hidden']), + add(c) { this._set.add(c); }, remove(c) { this._set.delete(c); }, + toggle(c) { this._set.has(c) ? this._set.delete(c) : this._set.add(c); }, + contains(c) { return this._set.has(c); }, + }, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), + getContext: () => makeCtx(), + appendChild(c) { el.children.push(c); c.parentNode = el; }, + remove() { const p = el.parentNode; if (p) p.children = p.children.filter(x => x !== el); }, + querySelectorAll: () => [], querySelector: () => null, + addEventListener(ev, fn) { (listenersOf(el)[ev] ??= []).push(fn); }, + dispatch(ev, arg = {}) { for (const f of listenersOf(el)[ev] || []) f(arg); }, + setAttribute() {}, focus() {}, + textContent: '', + }; + Object.defineProperty(el, 'innerHTML', { + get() { return ''; }, + set(v) { if (!v) el.children = []; }, + }); + let w = 300, h = 150; + Object.defineProperty(el, 'width', { get: () => w, set: v => { w = v; } }); + Object.defineProperty(el, 'height', { get: () => h, set: v => { h = v; } }); + return el; +}; +globalThis.window = globalThis; +globalThis.innerWidth = 1280; globalThis.innerHeight = 800; +globalThis.requestAnimationFrame = () => 0; globalThis.cancelAnimationFrame = () => {}; +globalThis.addEventListener = () => {}; globalThis.removeEventListener = () => {}; +globalThis.localStorage = { _m: {}, getItem(k) { return this._m[k] ?? null; }, setItem(k, v) { this._m[k] = String(v); }, removeItem(k) { delete this._m[k]; } }; +globalThis.document = { + els: {}, + getElementById(id) { return this.els[id] ?? (this.els[id] = fakeEl(id)); }, + createElement: tag => fakeEl(tag), + createTextNode: t => ({ nodeType: 3, textContent: String(t) }), + querySelectorAll: () => [], querySelector: () => null, + addEventListener() {}, body: { appendChild(c) { document.bodyChildren ??= []; document.bodyChildren.push(c); } }, +}; + +const main = await import('../js/main.js'); +ok(true, 'main.js evaluated (full graph)'); +const dialogs = await import('../js/ui/dialogs.js'); +const stateM = await import('../js/game/state.js'); + +// 1. click New Game +const mmNew = document.getElementById('mm-new'); +mmNew.dispatch('click'); +ok(document.getElementById('modal-root').classList.contains('hidden') === false, 'scenario picker modal visible after click'); +ok(document.getElementById('modal-root').children.length > 0, 'picker content rendered'); + +// find scenario cards among descendants +const findCards = n => { + let out = []; + for (const c of n.children || []) { + if ((c.className || '').includes && String(c.className).includes('scen-card')) out.push(c); + out = out.concat(findCards(c)); + } + return out; +}; +const cards = findCards(document.getElementById('modal-root')); +ok(cards.length === 4, `4 scenario cards rendered (${cards.length})`); + +// 2. pick first scenario +let picked = null; +cards[0].dispatch('click'); // handler calls onPick(scenarioId) → main.startNewGame +ok(stateM.getState()?.map != null, `game state created (${stateM.getState()?.scenario})`); +ok(document.getElementById('main-menu').classList.contains('hidden'), 'main menu hidden after start'); +ok(document.getElementById('topbar').classList.contains('hidden') === false, 'HUD shown after start'); + +console.log(fails ? `\n${fails} FLOW FAILURES` : '\nNEW GAME FLOW OK'); +process.exit(fails ? 1 : 0); diff --git a/tests/framecheck.mjs b/tests/framecheck.mjs new file mode 100644 index 0000000..8079b5c --- /dev/null +++ b/tests/framecheck.mjs @@ -0,0 +1,87 @@ +// ============ framecheck.mjs — run the REAL game loop for thousands of frames ============ +let fails = 0; +const ok = (cond, msg) => { console.log((cond ? '✔ ' : '✘ ') + msg); if (!cond) fails++; }; + +const makeCtx = () => new Proxy(function () {}, { + get(t, p) { + if (!(p in t)) t[p] = (...a) => makeCtx(); + const v = t[p]; + return typeof v === 'function' ? v : v; + }, + set() { return true; }, + apply() { return makeCtx(); }, +}); +const mkEl = (id = '') => ({ + id, style: {}, dataset: {}, children: [], _listeners: {}, + addEventListener(ev, fn) { (this._listeners[ev] ??= []).push(fn); }, + classList: { _s: new Set(['hidden']), add(c) { this._s.add(c); }, remove(c) { this._s.delete(c); }, toggle() {}, contains(c) { return this._s.has(c); } }, + appendChild() {}, remove() {}, setAttribute() {}, + querySelectorAll: () => [], getBoundingClientRect: () => ({ left: 0, top: 0, width: 180, height: 180 }), + getContext: () => makeCtx(), width: 300, height: 150, textContent: '', +}); +Object.defineProperty(mkEl.prototype ?? {}, 'x', { value: 0 }); + +globalThis.window = globalThis; +globalThis.innerWidth = 1280; globalThis.innerHeight = 800; +globalThis.__rafQ = []; +globalThis.requestAnimationFrame = fn => { globalThis.__rafQ.push(fn); return 1; }; +globalThis.cancelAnimationFrame = () => {}; +globalThis.addEventListener = () => {}; globalThis.removeEventListener = () => {}; +globalThis.localStorage = { _m: {}, getItem(k) { return this._m[k] ?? null; }, setItem(k, v) { this._m[k] = String(v); }, removeItem(k) { delete this._m[k]; } }; +const els = {}; +const REAL_IDS = new Set(['game','minimap','topbar','toolbar','palette','pal-body','pal-title','pal-close','context-panel','tool-hint','toasts','modal-root','main-menu','mm-new','mm-how','mm-continue','stat-cash','stat-guests','stat-rating','mana-fill','mana-num','stat-weather','stat-date','btn-pause','btn-research','btn-finance','btn-heroes','btn-objectives','btn-park','btn-save','btn-help','minimap-wrap']); +globalThis.document = { + getElementById(id) { if (!REAL_IDS.has(id)) return null; return els[id] ?? (els[id] = mkEl(id)); }, + createElement: () => mkEl(), + createTextNode: t => ({ textContent: String(t) }), + querySelectorAll: () => [], querySelector: () => null, + addEventListener() {}, body: { appendChild() {} }, +}; + +await import('../js/main.js'); +const stateM = await import('../js/game/state.js'); +const heroesM = await import('../js/game/heroes.js'); +const cfg = await import('../js/core/config.js'); + +for (const scenId of ['meadows', 'sandbox']) { + // fresh state through the REAL entry path + document.getElementById('main-menu'); // ensure element exists + const st = stateM.newGame(scenId); + heroesM.cacheScenario(st, cfg.SCENARIOS.find(s => s.id === scenId)); + st._speed = 3; // fast-forward + st._paused = false; + // give the park some content to exercise more code paths + const m = st.map; + const ex = m.entranceX, ey = m.entranceY; + for (let y = ey - 12; y < ey; y++) for (let x = ex - 8; x <= ex + 8; x++) + if (m.isBuildable(x, y) && !m.objects[m.idx(x, y)]) m.pathType[m.idx(x, y)] = 1; + stateM.addShopObj(st, 'food', ex - 4, ey - 8); + stateM.addShopObj(st, 'drinks', ex - 2, ey - 8); + stateM.addShopObj(st, 'toilet', ex + 4, ey - 8); + const ride = stateM.addRideObj(st, 'carousel', ex - 7, ey - 11, {}); + ride.status = 'open'; ride.price = 2; + + let frames = 0, crashed = null, lastErrFrame = -1; + let tNow = performance.now(); + for (; frames < 4000; frames++) { + const q = [...globalThis.__rafQ]; + globalThis.__rafQ.length = 0; + tNow += 33; // ~30fps + try { + for (const f of q) f(tNow); + } catch (e) { + crashed = e; + lastErrFrame = frames; + break; + } + } + if (crashed) { + ok(false, `[${scenId}] crashed at frame ${lastErrFrame}: ${crashed.message}`); + console.error(crashed.stack?.split('\n').slice(0, 6).join('\n')); + } else { + ok(true, `[${scenId}] ${frames} frames clean · guests=${st.guests.length} cash=${Math.round(st.cash)} hour=${st.time.hour.toFixed(1)}`); + } +} + +console.log(fails ? `\n${fails} FRAME FAILURES` : '\nREAL LOOP STABLE'); +process.exit(fails ? 1 : 0); diff --git a/tests/inputcheck.mjs b/tests/inputcheck.mjs new file mode 100644 index 0000000..322f68c --- /dev/null +++ b/tests/inputcheck.mjs @@ -0,0 +1,98 @@ +// ============ inputcheck.mjs — simulate keyboard & mouse against real handlers ============ +let fails = 0; +const ok = (cond, msg) => { console.log((cond ? '✔ ' : '✘ ') + msg); if (!cond) fails++; }; + +const makeCtx = () => new Proxy(function () {}, { + get(t, p) { if (!(p in t)) t[p] = (...a) => makeCtx(); return typeof t[p] === 'function' ? t[p] : t[p]; }, + set() { return true; }, + apply() { return makeCtx(); }, +}); +const mkEl = (id = '') => { + const el = { + id, style: {}, dataset: {}, children: [], + _listeners: {}, + addEventListener(ev, fn) { (el._listeners[ev] ??= []).push(fn); }, + dispatch(ev, arg = {}) { for (const f of el._listeners[ev] || []) f({ preventDefault() {}, stopPropagation() {}, target: el, ...arg }); }, + classList: { _s: new Set(), add(c) { this._s.add(c); }, remove(c) { this._s.delete(c); }, toggle() {}, contains: c => false }, + appendChild() {}, remove() {}, setAttribute() {}, + querySelectorAll: () => [], getBoundingClientRect: () => ({ left: 0, top: 0, width: 180, height: 180 }), + getContext: () => makeCtx(), width: 300, height: 150, + textContent: '', + }; + Object.defineProperty(el, 'innerHTML', { get() { return ''; }, set(v) { if (!v) el.children = []; } }); + return el; +}; + +globalThis.window = globalThis; +globalThis.innerWidth = 1280; globalThis.innerHeight = 800; +globalThis.__rafQueue = []; +globalThis.requestAnimationFrame = fn => { __rafQueue.push(fn); return __rafQueue.length; }; +globalThis.cancelAnimationFrame = () => {}; +globalThis.__winListeners = {}; +globalThis.addEventListener = (ev, fn) => { (globalThis.__winListeners[ev] ??= []).push(fn); }; +globalThis.removeEventListener = () => {}; +globalThis.localStorage = { _m: {}, getItem(k) { return this._m[k] ?? null; }, setItem(k, v) { this._m[k] = String(v); }, removeItem(k) { delete this._m[k]; } }; + +const els = {}; +globalThis.document = { + getElementById(id) { return els[id] ?? (els[id] = mkEl(id)); }, + createElement: t => mkEl(t), + createTextNode: t => ({ textContent: String(t) }), + querySelectorAll: () => [], querySelector: () => null, + addEventListener() {}, body: { appendChild() {} }, +}; + +// start a game FIRST (so getState() exists like a real session) +const mainMod = await import('../js/main.js'); +const cam = mainMod.cam; +const stateM = await import('../js/game/state.js'); +const heroesM = await import('../js/game/heroes.js'); +const cfg = await import('../js/core/config.js'); +const st = stateM.newGame('sandbox'); +heroesM.cacheScenario(st, cfg.SCENARIOS.find(s => s.id === 'sandbox')); +document.getElementById('main-menu').classList.remove ? null : null; + +const winDispatch = (ev, arg = {}) => { + for (const f of globalThis.__winListeners[ev] || []) f({ preventDefault() {}, target: { tagName: 'CANVAS' }, ...arg }); +}; + +// ---- KEYBOARD PAN DIRECTIONS (screen-relative) ---- +function press(key, frames = 20) { + winDispatch('keydown', { key }); + for (let i = 0; i < frames; i++) { const q = [...__rafQueue]; __rafQueue.length = 0; for (const f of q) f(performance.now()); } + winDispatch('keyup', { key }); +} +const center = () => { cam.x = 30; cam.y = 40; }; +let p; +center(); p = { ...cam }; press('w'); +ok(cam.x < p.x && cam.y < p.y, `W pans screen-up (Δ${(cam.x - p.x).toFixed(1)},${(cam.y - p.y).toFixed(1)})`); +center(); p = { ...cam }; press('s'); +ok(cam.x > p.x && cam.y > p.y, `S pans screen-down (+${(cam.x - p.x).toFixed(1)},+${(cam.y - p.y).toFixed(1)})`); +center(); p = { ...cam }; press('a'); +ok(cam.x < p.x && cam.y > p.y, `A pans screen-left (${(cam.x - p.x).toFixed(1)},+${(cam.y - p.y).toFixed(1)})`); +center(); p = { ...cam }; press('d'); +ok(cam.x > p.x && cam.y < p.y, `D pans screen-right (+${(cam.x - p.x).toFixed(1)},${(cam.y - p.y).toFixed(1)})`); + +// ---- MOUSE DRAG PAN TEST ---- +const canvas = document.getElementById('game'); +const camBeforeDrag = { x: cam.x, y: cam.y }; +canvas.dispatch('pointerdown', { button: 0, clientX: 600, clientY: 400 }); +for (let s = 1; s <= 10; s++) canvas.dispatch('pointermove', { button: 0, clientX: 600 + s * 12, clientY: 400 - s * 6, shiftKey: false }); +canvas.dispatch('pointerup', { button: 0, clientX: 720, clientY: 340 }); +const dragMoved = Math.abs(cam.x - camBeforeDrag.x) + Math.abs(cam.y - camBeforeDrag.y); +ok(dragMoved > 1, `mouse drag pans camera (delta ${dragMoved.toFixed(1)} tiles)`); + +// ---- WHEEL ZOOM TEST ---- +const zBefore = cam.zoom; +canvas.dispatch('wheel', { deltaY: -120, clientX: 640, clientY: 400 }); +ok(cam.zoom > zBefore, `wheel zooms (${zBefore.toFixed(2)} → ${cam.zoom.toFixed(2)})`); +canvas.dispatch('wheel', { deltaY: 120, clientX: 640, clientY: 400 }); + +// ---- MINIMAP CLICK TELEPORT ---- +const mm = document.getElementById('minimap'); +const mx = cam.x, my = cam.y; +mm.dispatch('pointerdown', { clientX: 90, clientY: 90, target: mm }); +ok(Math.abs(cam.x - mx) + Math.abs(cam.y - my) > 3, `minimap click teleports camera (${cam.x.toFixed(0)},${cam.y.toFixed(0)})`); + +console.log(fails ? `\n${fails} INPUT FAILURES` : '\nALL CAMERA INPUTS OK'); +process.exit(fails ? 1 : 0); diff --git a/tests/linkcheck.mjs b/tests/linkcheck.mjs new file mode 100644 index 0000000..31abab2 --- /dev/null +++ b/tests/linkcheck.mjs @@ -0,0 +1,103 @@ +// ============ linkcheck.mjs — import EVERY module under a DOM stub ============ +// Catches missing/misnamed exports (module linking errors) without a browser. +// Run: node tests/linkcheck.mjs +let failures = 0; + +const makeCtx = () => new Proxy({}, { + get(t, p) { + if (p === 'canvas') return {}; + if (!(p in t)) t[p] = (...args) => makeCtx(); + const v = t[p]; + return typeof v === 'function' ? v : t[p]; + }, + set() { return true; }, +}); + +const fakeEl = (id = '') => { + const el = { + id, style: {}, dataset: {}, children: [], + classList: { add() {}, remove() {}, toggle() {}, contains: () => true }, + setAttribute() {}, getAttribute: () => null, + addEventListener() {}, removeEventListener() {}, + appendChild(c) { el.children.push(c); }, remove() {}, click() {}, + querySelectorAll: () => [], querySelector: () => null, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), + getContext: () => makeCtx(), + focus() {}, select() {}, + files: [], + text: '', + _innerHTML: '', + firstChild: null, + }; + Object.defineProperty(el, 'innerHTML', { + get() { return el._innerHTML; }, + set(v) { + el._innerHTML = String(v); + if (!el._innerHTML) el.children = []; + el.firstChild = el.children[0] ?? null; + }, + }); + Object.defineProperty(el, 'textContent', { + get() { return el._textContent ?? ''; }, + set(v) { el._textContent = String(v); }, + }); + let width = 300, height = 150; + Object.defineProperty(el, 'width', { get: () => width, set: v => { width = v; } }); + Object.defineProperty(el, 'height', { get: () => height, set: v => { height = v; } }); + return el; +}; + +globalThis.window = globalThis; +globalThis.innerWidth = 1280; +globalThis.innerHeight = 800; +globalThis.requestAnimationFrame = () => 0; +globalThis.cancelAnimationFrame = () => {}; +globalThis.addEventListener = () => {}; +globalThis.removeEventListener = () => {}; +globalThis.localStorage = { _m: {}, getItem(k) { return this._m[k] ?? null; }, setItem(k, v) { this._m[k] = String(v); }, removeItem(k) { delete this._m[k]; } }; +globalThis.document = { + getElementById: id => (globalThis.__els ??= {})[id] ?? ((globalThis.__els[id] = fakeEl(id))), + createElement: tag => fakeEl(tag), + querySelectorAll: () => [], + querySelector: () => null, + addEventListener() {}, + body: { appendChild() {} }, +}; +globalThis.AudioContext = undefined; +globalThis.Blob = class { constructor() {} }; +globalThis.URL = { createObjectURL: () => 'blob:x', revokeObjectURL: () => {} }; + +async function load(label, path) { + try { + await import(path); + console.log(`✔ ${label}`); + } catch (e) { + failures++; + console.error(`✘ ${label}: ${e.message}`); + console.error(e.stack?.split('\n').slice(1, 3).join('\n')); + } +} + +await load('core/util', '../js/core/util.js'); +await load('core/config', '../js/core/config.js'); +await load('core/audio', '../js/core/audio.js'); +await load('world/map', '../js/world/map.js'); +await load('world/path', '../js/world/path.js'); +await load('game/economy', '../js/game/economy.js'); +await load('game/state', '../js/game/state.js'); +await load('game/guests', '../js/game/guests.js'); +await load('game/staff', '../js/game/staff.js'); +await load('game/rides', '../js/game/rides.js'); +await load('game/coaster', '../js/game/coaster.js'); +await load('game/heroes', '../js/game/heroes.js'); +await load('game/magic', '../js/game/magic.js'); +await load('game/research', '../js/game/research.js'); +await load('game/save', '../js/game/save.js'); +await load('render/renderer', '../js/render/renderer.js'); +await load('ui/ui', '../js/ui/ui.js'); +await load('ui/dialogs', '../js/ui/dialogs.js'); +await load('ui/povui', '../js/ui/povui.js'); +await load('main (full graph)', '../js/main.js'); + +console.log(failures ? `\n${failures} LINK FAILURES` : '\nALL MODULES LINK OK'); +process.exit(failures ? 1 : 0); diff --git a/tests/rendercheck.mjs b/tests/rendercheck.mjs new file mode 100644 index 0000000..855ddcb --- /dev/null +++ b/tests/rendercheck.mjs @@ -0,0 +1,63 @@ +// ============ rendercheck.mjs — regression test for terrain culling ============ +let fails = 0; +const ok = (cond, msg) => { console.log((cond ? '✔ ' : '✘ ') + msg); if (!cond) fails++; }; + +globalThis.window = globalThis; +globalThis.requestAnimationFrame = () => 0; +globalThis.document = { + getElementById: () => null, createElement: () => ({ style: {}, classList: { add() {}, remove() {} }, addEventListener() {}, appendChild() {} }), + addEventListener() {}, body: { appendChild() {} }, +}; +globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} }; + +const R = await import('../js/render/renderer.js'); +const { visibleBounds, worldToScreen, screenToWorld } = R; +ok(typeof visibleBounds === 'function', 'visibleBounds exported'); + +// --- camera translation regression: panning MUST shift screen coords --- +const camA = { x: 20, y: 20, zoom: 1 }; +const camB = { x: 30, y: 26, zoom: 1 }; +const [ax] = worldToScreen(camA, 1280, 800, 10, 10); +const [bx] = worldToScreen(camB, 1280, 800, 10, 10); +ok(Math.abs(bx - ax) > 50, `worldToScreen shifts with cam.x (${ax.toFixed(0)} → ${bx.toFixed(0)})`); +const [, ay2] = worldToScreen(camA, 1280, 800, 10, 10); +const camC = { x: 20, y: 30, zoom: 1 }; +const [, cy2] = worldToScreen(camC, 1280, 800, 10, 10); +ok(Math.abs(cy2 - ay2) > 50, `worldToScreen shifts with cam.y (${ay2.toFixed(0)} → ${cy2.toFixed(0)})`); + +// --- roundtrip screen→world→screen identity --- +for (const cz of [0.5, 1, 1.7]) { + const c = { x: 25.3, y: 41.8, zoom: cz }; + const [sx, sy] = worldToScreen(c, 1280, 800, 12.4, 33.9, 0); // ground plane + const [wx, wy] = screenToWorld(c, 1280, 800, sx, sy); + ok(wx === 12 && wy === 33, + `roundtrip zoom ${cz}: (${sx.toFixed(0)},${sy.toFixed(0)}) → (${wx},${wy}) expect (12,33)`); +} + +// --- visibleBounds must follow the camera --- +const bA = visibleBounds({ x: 5, y: 5, zoom: 1 }, 1280, 800, 80); +const bB = visibleBounds({ x: 40, y: 40, zoom: 1 }, 1280, 800, 80); +ok(bB.x0 > bA.x0 + 10 && bB.y0 > bA.y0 + 10, `bounds track camera (x0 ${bA.x0}→${bB.x0}, y0 ${bA.y0}→${bB.y0})`); + +const cases = [ + { name: 'center of 52-map, zoom 1', cam: { x: 26, y: 26, zoom: 1 }, cw: 1280, ch: 800 }, + { name: 'entrance area, zoom 1', cam: { x: 28, y: 44, zoom: 1 }, cw: 1280, ch: 800 }, + { name: 'zoomed out 0.5', cam: { x: 26, y: 26, zoom: 0.5 }, cw: 1280, ch: 800 }, + { name: 'zoomed in 2', cam: { x: 26, y: 26, zoom: 2 }, cw: 1920, ch: 1080 }, +]; +for (const c of cases) { + const b = visibleBounds(c.cam, c.cw, c.ch, 80); + const spanX = b.x1 - b.x0, spanY = b.y1 - b.y0; + // exact iso-diamond coverage: ((cw+2p)/(TW2·z) + (ch+2p)/(TH2·z)) / 2 per axis + const p = 80; + const expectSpan = ((c.cw + 2 * p) / (32 * c.cam.zoom) + (c.ch + 2 * p) / (16 * c.cam.zoom)) / 2; + ok(spanX >= expectSpan - 2 && spanY >= expectSpan - 2, + `${c.name}: span ${spanX}×${spanY} tiles (expect ≈${Math.round(expectSpan)})`); +} + +// the old bug produced a y-span of roughly 10 rows on a 1280×800 @zoom1 view +const b = visibleBounds({ x: 26, y: 26, zoom: 1 }, 1280, 800, 80); +ok(b.y1 - b.y0 > 40, `vertical coverage fixed (${b.y1 - b.y0} rows, was ~10 before)`); + +console.log(fails ? `\n${fails} RENDER FAILURES` : '\nRENDER CULLING OK'); +process.exit(fails ? 1 : 0); diff --git a/tests/smoke.mjs b/tests/smoke.mjs new file mode 100644 index 0000000..6d204fa --- /dev/null +++ b/tests/smoke.mjs @@ -0,0 +1,266 @@ +// ============ smoke.mjs — headless integration test of game logic ============ +// Run: node tests/smoke.mjs +import assert from 'node:assert'; + +// ---- minimal DOM stubs for modules that reference them at import time ---- +const noop = () => { }; +globalThis.document = { + getElementById: () => null, + createElement: () => ({ style: {}, setAttribute: noop, appendChild: noop, addEventListener: noop, classList: { add: noop, remove: noop, toggle: noop }, children: [] }), + addEventListener: noop, + body: { appendChild: noop }, +}; +globalThis.window = globalThis; +globalThis.localStorage = { + _m: {}, + getItem(k) { return this._m[k] ?? null; }, + setItem(k, v) { this._m[k] = String(v); }, + removeItem(k) { delete this._m[k]; }, +}; +globalThis.performance = globalThis.performance || { now: () => Date.now() }; +globalThis.requestAnimationFrame = noop; + +const results = []; +function test(name, fn) { + try { fn(); results.push(['PASS', name]); } + catch (e) { console.error(`FAIL: ${name}\n`, e); results.push(['FAIL', name + ' — ' + e.message]); process.exitCode = 1; } +} +async function testAsync(name, fn) { + try { await fn(); results.push(['PASS', name]); } + catch (e) { console.error(`FAIL: ${name}\n`, e); results.push(['FAIL', name + ' — ' + e.message]); process.exitCode = 1; } +} + +// ---- imports under test ---- +const stateMod = await import('../js/game/state.js'); +const guests = await import('../js/game/guests.js'); +const staff = await import('../js/game/staff.js'); +const rides = await import('../js/game/rides.js'); +const coaster = await import('../js/game/coaster.js'); +const heroes = await import('../js/game/heroes.js'); +const magic = await import('../js/game/magic.js'); +const research = await import('../js/game/research.js'); +const economy = await import('../js/game/economy.js'); +const saveSys = await import('../js/game/save.js'); +const pathf = await import('../js/world/path.js'); +const cfg = await import('../js/core/config.js'); + +const { newGame, getState, advanceTime, recomputeStats, checkObjectives, parkValue } = stateMod; +const { updateGuests } = guests; +const { updateStaff } = staff; +const { updateRides } = rides; +const { updateBattles, cacheScenario } = heroes; +const { tickSpells } = magic; + +let st; +function sim(seconds, dt = 1 / 30) { + const steps = Math.round(seconds / dt); + for (let i = 0; i < steps; i++) { + advanceTime(st, dt); + tickSpells(st, dt); + updateGuests(st, dt); + updateStaff(st, dt); + updateRides(st, dt); + updateBattles(st, dt); + research && null; + // research accrual inline (tickResearch lives in state.js) + } +} + +test('newGame creates map & entrance', () => { + st = newGame('meadows'); + cacheScenario(st, cfg.SCENARIOS.find(s => s.id === 'meadows')); + assert(st.map, 'map exists'); + assert.equal(st.cash, 30000); + assert(st.map.isPath(st.map.entranceX, st.map.entranceY - 3), 'entrance corridor paved'); + recomputeStats(st); + assert(st.stats.rating >= 0 && st.stats.rating <= 999); +}); + +const BX = st.map.entranceX - 6, BY = st.map.entranceY - 10; // shared build area +pave(BX, BY, BX + 12, BY + 8); + +test('pathfinding works on paved paths', () => { + const m = st.map; + const p = pathf.findPath(m, m.entranceX, m.entranceY - 1, BX + 2, BY + 2); + assert(p !== null, 'path found from gate to build area'); +}); + +// helper: pave a rectangle of paths +function pave(x0, y0, x1, y1) { + for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) { + if (st.map.isBuildable(x, y) && !st.map.objects[st.map.idx(x, y)]) st.map.pathType[st.map.idx(x, y)] = 1; + } +} + +test('shops and rides can be placed next to paths', () => { + const m = st.map; + const bx = BX, by = BY; + const shop = stateMod.addShopObj(st, 'food', bx + 2, by + 2); + assert(shop, 'shop placed'); + assert(m.getObject(bx + 2, by + 2).kind === 'shop'); + const ride = stateMod.addRideObj(st, 'carousel', bx + 6, by + 2, {}); + assert(ride, 'ride placed'); + ride.status = 'open'; + ride.price = 3; + st.shops[0].price = 5; +}); + +test('guests spawn, walk, buy and ride over simulated minutes', () => { + const before = st.guests.length; + sim(240); // 4 in-game hours at speed… advanceTime uses HOUR_RATE so 240s ≈ 12h + assert(st.guests.length > before, `guests arrived (${st.guests.length})`); + assert(st.guests.length > 3, 'several guests present'); + const shop = st.shops[0]; + const ride = st.rides.find(r => r.type === 'carousel'); + assert(shop.sold > 0 || shop.income > 0, 'shop made sales'); + assert(ride.totalRiders > 0 || ride.queue.length > 0, `ride used (riders=${ride.totalRiders}, queue=${ride.queue.length})`); + assert(st.cash > 29500, 'cash roughly intact or growing'); +}); + +test('custom coaster build → finish → test → open', () => { + const m = st.map; + // find a clear area near plaza + const ox = m.entranceX + 6, oy = m.entranceY - 14; + pave(ox - 2, oy - 2, ox + 9, oy + 9); + // station adjacent to a path tile we just laid + const sessRes = coaster.startCoasterSession(st, ox, oy, 0); + assert(!sessRes.error, 'session started: ' + (sessRes.error || '')); + // circuit: E E E N N N W W W S S S back to start (rectangle) + const seq = ['straight', 'straight', + 'curveL', 'straight', 'straight', 'curveL', + 'straight', 'straight', 'straight', 'straight', + 'curveL', 'straight', 'straight', + 'curveL', 'straight']; + for (const pc of seq) { + const r = coaster.addPiece(st, pc); + if (r.error) { + const s = coaster.getSession(st); + throw new Error(`piece ${pc}: ${r.error} | target=${JSON.stringify(coaster.nextCellFor(s, pc))} | pieces=[${s.pieces.map(p => `${p.type}@${p.x},${p.y},${p.z}d${p.dir}`).join(' ; ')}]`); + } + } + assert(coaster.isCircuitClosed(coaster.getSession(st)), 'circuit closed'); + const fin = coaster.finishCoaster(st); + if (!fin.ok && !fin.ride) throw new Error('finish failed: ' + fin.error); + const cr = fin.ride || st.rides.find(r => r.isCustomCoaster); + assert(cr.track.length >= 12, 'track stored'); + assert(cr.excite > 0, 'excitement computed'); + assert(!coaster.sessionActive(st), 'session ended'); + // test cycle + assert(rides.startTest(st, cr), 'testing started'); + sim(cr.cycleDur + 2); + assert.equal(cr.status, 'closed', 'test completed → closed'); + // open to public (jump back to morning so guests are out & about) + st.time.hour = 9; + assert(rides.setRideOpen(st, cr, true)); + sim(90); + console.log(` coaster entrance=(${cr.entranceX},${cr.entranceY}) q=${cr.queue.length} riders=${cr.riders.length} total=${cr.totalRiders}`); + assert(cr.queue.length > 0 || cr.totalRiders > 0 || cr.riders.length > 0, + `custom coaster attracts guests (q=${cr.queue.length} tr=${cr.totalRiders})`); +}); + +test('staff hire & mechanics repair breakdowns', () => { + const s = staff.hireStaff(st, 'handyman'); + assert(s, 'hired'); + const mech = staff.hireStaff(st, 'mechanic'); + assert(mech, 'mech hired'); + // force breakdown + const r = st.rides.find(r => r.type === 'carousel'); + rides.breakDown(st, r); + assert.equal(r.status, 'broken'); + sim(40); + assert(r.status === 'closed' || r.status === 'open' || r.reliability > 0, 'repair flow ran without error'); +}); + +test('heroes fight off an invasion', () => { + const m = st.map; + const gx = BX + 10, gy = BY + 6; // inside paved area, touches paths + const gd = heroes.buildGuild(st, gx, gy); + assert(gd, 'guild built at ' + gx + ',' + gy); + st.cash += 5000; + const rec = heroes.recruitHero(st, 'knight'); + assert(rec.ok, 'knight recruited: ' + (rec.error || '')); + heroes.recruitHero(st, 'ranger'); + heroes.spawnWave(st); // manual wave + assert(st.monsters.length >= 2, 'monsters spawned'); + sim(120); // let heroes fight + assert(st.heroStats.kills > 0 || st.monsters.length < 3, `battle progressed (kills=${st.heroStats.kills}, left=${st.monsters.length})`); + sim(180); + assert(st.monsters.length === 0, 'invasion cleared'); + assert(st.invasion.repelled >= 1 || st.heroStats.kills >= st.monsters.length, 'wave resolved'); +}); + +test('magic spells cast & expire', async () => { + st.mana = st.manaMax; + const res = magic.castSpell(st, 'joy_aura'); + assert(res.ok, 'cast ok'); + assert(st.spells.active.joy_aura > 0, 'active'); + assert(st.spells.cds.joy_aura > 0, 'cooldown set'); + assert(magic.castSpell(st, 'joy_aura').ok === false, 'cannot double-cast during cd'); + sim(30); + assert(!st.spells.active.joy_aura, 'expired after duration'); + st.research.unlocked.push('monster_bane', 'transmute'); + st.mana = st.manaMax; + st.monsters.push({ id: 999, kind: 'monster', type: 'slime', def: cfg.MONSTER_TYPES.slime, x: 10, y: 10, hp: 30, maxHp: 30, atkCd: 1, speed: 1 }); + magic.castSpell(st, 'monster_bane'); + assert(st.monsters.find(mm => mm.id === 999).hp < 30, 'bane damaged monster'); + st.mana = st.manaMax; + const c0 = st.cash; + magic.castSpell(st, 'transmute'); + assert(st.cash >= c0 + 900, 'transmute granted gold'); +}); + +test('research unlocks purchasable with RP', () => { + st.research.rp += 200; + assert(research.buyUnlock(st, 'drop_tower'), 'bought drop_tower'); + assert(stateMod.getState().research.unlocked.includes('drop_tower')); + assert(research.isUnlocked(st, 'drop_tower')); +}); + +test('finance month close archives history & charges wages', () => { + st.staff.push({ id: 555, wage: 50 }); // temp staff entry + const cashBefore = st.cash; + economy.monthClose(st); + assert(st.finance.history.length >= 1, 'history archived'); + assert(st.cash <= cashBefore, 'wages charged'); + st.staff.pop(); +}); + +await testAsync('save/load roundtrip preserves world', async () => { + saveSys.saveTo(st, 'slot1'); + const raw = localStorage.getItem('arcane_tycoon_save_slot1'); + assert(raw, 'saved bytes exist'); + const loaded = saveSys.loadFrom('slot1'); + assert(loaded, 'deserialized'); + assert.equal(loaded.map.size, st.map.size); + assert.equal(loaded.cash, st.cash); + assert.equal(loaded.rides.length, st.rides.length); + assert.equal(loaded.guests.length, st.guests.length); + assert(loaded.map.isPath(st.map.entranceX, st.map.entranceY - 3), 'paths survive'); + const r0 = loaded.rides.find(r => r.isCustomCoaster); + if (st.rides.some(r => r.isCustomCoaster)) { + assert(r0, 'custom coaster survived'); + assert(r0.def, 'def re-linked'); + assert(r0.track?.length > 5, 'track data survives'); + } +}); + +test('objectives & end-state evaluation run clean', () => { + checkObjectives(st); + assert(typeof st.won === 'boolean' && typeof st.lost === 'boolean'); + assert(parkValue(st) > 0); +}); + +test('performance: 300 sim-seconds with full park under 15s wall time', () => { + const t0 = performance.now(); + sim(300); + const elapsed = performance.now() - t0; + console.log(` perf: ${elapsed.toFixed(0)}ms for 300s of sim (${st.guests.length} guests, ${st.rides.length} rides)`); + assert(elapsed < 15000, `too slow: ${elapsed}ms`); +}); + +// summary +console.log('\n=== SMOKE RESULTS ==='); +for (const [s, n] of results) console.log(`${s === 'PASS' ? '✔' : '✘'} ${n}`); +const fails = results.filter(r => r[0] === 'FAIL').length; +console.log(fails ? `\n${fails} FAILURES` : '\nALL TESTS PASSED'); +process.exit(fails ? 1 : 0);