Arcane Tycoon — Heroes & Magic theme park tycoon game

Complete browser game inspired by OpenRCT2 with fantasy twist:
- Custom roller coaster designer with physics-based ratings + on-ride POV
- 10 animated rides, 7 shops, 16 scenery items, path network & guest AI
- Heroes guild vs monster invasions (5 classes, XP/gear/bosses)
- Magic spell system (8 spells), research tree, economy/marketing/loans
- Day-night cycle, weather, park rating, awards, 4 scenarios
- Save/load slots + autosave, procedural WebAudio SFX/music
- Isometric canvas renderer, minimap, diagnostics overlay
- Test suites: smoke(13), linkcheck, inputcheck, framecheck, rendercheck, flow
This commit is contained in:
2026-08-23 06:59:21 +00:00
commit ac00687480
30 changed files with 6772 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
*.log
.DS_Store
__pycache__/
+94
View File
@@ -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 (0999) 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.*
+287
View File
@@ -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}
+95
View File
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Arcane Tycoon — Heroes &amp; Magic Theme Park</title>
<meta name="description" content="Build the ultimate fantasy theme park: design roller coasters, manage guests, recruit heroes and cast powerful spells.">
<link rel="stylesheet" href="css/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎡</text></svg>">
</head>
<body>
<div id="app">
<canvas id="game"></canvas>
<!-- ===== TOP HUD ===== -->
<div id="topbar" class="hidden">
<div class="hud-group brand" title="Arcane Tycoon">✦ Arcane Tycoon</div>
<div class="hud-group stat" id="stat-cash" title="Park cash">💰 <span></span></div>
<div class="hud-group stat" id="stat-guests" title="Guests in park">🧑‍🤝‍🧑 <span>0</span></div>
<div class="hud-group stat" id="stat-rating" title="Park rating"><span>0</span></div>
<div class="hud-group stat" id="stat-mana" title="Mana — grows with ley pools &amp; magic scenery">
<span class="mana-icon">🔮</span><div class="mana-bar"><div id="mana-fill"></div></div><span id="mana-num">0/50</span>
</div>
<div class="hud-group stat" id="stat-weather" title="Weather">☀️</div>
<div class="hud-group stat clock" id="stat-date" title="Date & time">📅 </div>
<div class="spacer"></div>
<button class="hbtn" id="btn-pause" title="Pause / resume (Space)"></button>
<div id="speed-group">
<button class="hbtn spd active" data-speed="1" title="Normal speed (1)"></button>
<button class="hbtn spd" data-speed="2" title="Fast speed (2)">▶▶</button>
<button class="hbtn spd" data-speed="3" title="Very fast (3)">▶▶▶</button>
</div>
<button class="hbtn" id="btn-research" title="Research (T)">🔬</button>
<button class="hbtn" id="btn-finance" title="Finances (F)">📈</button>
<button class="hbtn" id="btn-heroes" title="Hero Guild (G)">🛡️</button>
<button class="hbtn" id="btn-objectives" title="Objectives">🏆</button>
<button class="hbtn" id="btn-park" title="Park settings">⚙️</button>
<button class="hbtn" id="btn-save" title="Save / Load (Ctrl+S)">💾</button>
<button class="hbtn" id="btn-help" title="How to play (H)"></button>
</div>
<!-- ===== MINIMAP ===== -->
<div id="minimap-wrap" class="hidden"><canvas id="minimap" width="180" height="180"></canvas></div>
<!-- ===== BOTTOM TOOLBAR ===== -->
<div id="toolbar" class="hidden">
<button class="tbtn tool-select active" data-tool="select" title="Select / inspect (Esc)">🖱️<span>Select</span></button>
<button class="tbtn" data-tool="path" title="Build paths">🧱<span>Paths</span></button>
<button class="tbtn" data-tool="coaster" title="Custom coaster designer">🎢<span>Coaster</span></button>
<button class="tbtn" data-tool="ride" title="Build rides">🎠<span>Rides</span></button>
<button class="tbtn" data-tool="shop" title="Build shops">🍔<span>Shops</span></button>
<button class="tbtn" data-tool="scenery" title="Place scenery">🌳<span>Scenery</span></button>
<button class="tbtn" data-tool="terrain" title="Terrain tools">⛰️<span>Terrain</span></button>
<button class="tbtn" data-tool="staff" title="Hire staff">🧹<span>Staff</span></button>
<button class="tbtn" data-tool="heroes" title="Heroes & invasions">⚔️<span>Heroes</span></button>
<button class="tbtn" data-tool="magic" title="Cast spells"><span>Magic</span></button>
</div>
<!-- Build palette panel -->
<div id="palette" class="hidden">
<div class="pal-head"><span id="pal-title">Build</span><button id="pal-close" title="Close"></button></div>
<div id="pal-body"></div>
</div>
<!-- Context / selection panel -->
<div id="context-panel" class="hidden"></div>
<!-- Tool hint bar -->
<div id="tool-hint" class="hidden"></div>
<!-- Toasts -->
<div id="toasts"></div>
<!-- Generic modal root -->
<div id="modal-root" class="hidden"></div>
<!-- ===== MAIN MENU ===== -->
<div id="main-menu">
<div class="mm-inner">
<h1 class="mm-title">ARCANE TYCOON</h1>
<div class="mm-sub">Heroes · Magic · Roller Coasters</div>
<div class="mm-buttons">
<button id="mm-new" class="big-btn">🏰 New Game</button>
<button id="mm-continue" class="big-btn hidden">↻ Continue</button>
<button id="mm-how" class="big-btn ghost">📖 How to Play</button>
</div>
<div class="mm-credit">Inspired by classic theme-park tycoon games · Built for the web</div>
</div>
</div>
<!-- Scenario picker modal is rendered by ui.js into #modal-root -->
</div>
<script type="module" src="js/main.js"></script>
</body>
</html>
+111
View File
@@ -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; }
+308
View File
@@ -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 },
];
+88
View File
@@ -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; }
}
+266
View File
@@ -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,
};
}
+96
View File
@@ -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;
}
+406
View File
@@ -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;
}
+354
View File
@@ -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 });
}
+99
View File
@@ -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;
}
+32
View File
@@ -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;
}
+219
View File
@@ -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,
};
}
+78
View File
@@ -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;
}
}
+208
View File
@@ -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.' });
}
+451
View File
@@ -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;
}
+565
View File
@@ -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 };
+990
View File
@@ -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();
}
+402
View File
@@ -0,0 +1,402 @@
// ============ dialogs.js — modal dialogs ============
import { getState, objectiveProgress } from '../game/state.js';
import { el, fmtMoney, fmtNum, fmtDate } from '../core/util.js';
import { SCENARIOS, AWARDS_POOL, UNLOCKS, RESEARCH_TRACKS } from '../core/config.js';
import { unlocksByTrack, buyUnlock } from '../game/research.js';
import { CAMPAIGNS, startCampaign, takeLoan, repayLoan, FIN_CATEGORIES } from '../game/economy.js';
import { sfx, setVolumes, getVolumes, startMusic, stopMusic, isMusicOn } from '../core/audio.js';
import * as saveSys from '../game/save.js';
import { refreshPalette, showToast } from './ui.js';
const $ = id => document.getElementById(id);
let currentClose = null;
export function openModal(title, content, opts = {}) {
closeModal();
const root = $('modal-root');
root.innerHTML = '';
root.classList.remove('hidden');
const closeBtn = el('button', {}, '✕');
const box = el('div', { class: 'modal' + (opts.wide ? ' wide' : '') },
el('div', { class: 'modal-head' }, el('span', {}, title), closeBtn),
el('div', { class: 'modal-body' }, content),
);
if (opts.foot) box.appendChild(el('div', { class: 'modal-foot' }, opts.foot));
closeBtn.addEventListener('click', closeModal);
root.appendChild(box);
root.onclick = e => { if (e.target === root) closeModal(); };
currentClose = opts.onOpen || null;
return { close: closeModal, box };
}
export function closeModal() {
$('modal-root').classList.add('hidden');
$('modal-root').innerHTML = '';
currentClose = null;
}
export function isModalOpen() { return !$('modal-root').classList.contains('hidden'); }
export function refreshOpenDialogs() {
if (typeof refreshCurrent === 'function') refreshCurrent();
}
let refreshCurrent = null;
// ---------------- Research ----------------
export function openResearch() {
const st = getState();
const content = el('div');
content.appendChild(el('div', { style: 'margin-bottom:10px;color:#cdd6f4;font-size:.9rem' },
`Research points: `, el('b', { style: 'color:var(--accent)' }, fmtNum(st.research.rp)),
` — earned passively from open rides & magic scenery.`));
if (st.sandbox) content.appendChild(el('div', { class: 'ctx-row' }, 'Sandbox: everything unlocked.'));
const byTrack = unlocksByTrack();
for (const [tid, track] of Object.entries(RESEARCH_TRACKS)) {
const items = byTrack[tid] || [];
const box = el('div', { class: 'res-track' },
el('h4', {}, `${track.icon} ${track.name}`));
const row = el('div', { class: 'res-items' });
for (const u of items) {
const owned = st.sandbox || st.research.unlocked.includes(u.key);
const item = el('div', { class: 'res-item ' + (owned ? 'done' : (st.research.rp >= u.rp ? 'avail' : 'avail cant')) },
owned ? '✔ ' : '', u.label, owned ? '' : el('span', { style: 'color:var(--gold)' }, ` · ${u.rp} RP`));
if (!owned) item.addEventListener('click', () => {
if (buyUnlock(st, u.key)) { sfx.cash(); openResearch(); refreshPalette(); }
else sfx.error();
});
row.appendChild(item);
}
box.appendChild(row);
content.appendChild(box);
}
openModal('🔬 Research Laboratory', content);
}
// ---------------- Finance ----------------
export function openFinance() {
const st = getState();
const content = el('div');
// summary
const cur = st.finance.current;
const table = el('table', { class: 'fin' });
table.appendChild(el('tr', {}, el('th', {}, 'Category'), el('th', {}, 'This month')));
let net = 0;
for (const [k, label] of FIN_CATEGORIES) {
const v = cur[k];
if (!v) continue;
net += v;
table.appendChild(el('tr', {},
el('td', {}, label),
el('td', { class: v >= 0 ? 'pos' : 'neg' }, fmtMoney(v, true))));
}
table.appendChild(el('tr', {}, el('td', { style: 'color:var(--gold)' }, 'Net'), el('td', { class: net >= 0 ? 'pos' : 'neg', style: 'color:inherit' }, fmtMoney(net, true))));
content.appendChild(table);
// history sparkline
const hist = st.finance.history.slice(-12).map(h2 => Object.values(h2).reduce((a, b) => a + b, 0));
if (hist.length) {
const cvs = el('canvas', { width: 420, height: 80 });
cvs.style.cssText = 'width:100%;margin-top:12px;background:var(--bg1);border-radius:10px;border:1px solid var(--panel-brd)';
requestAnimationFrame(() => drawSpark(cvs, hist));
content.appendChild(el('div', { style: 'font-size:.8rem;color:var(--ink-dim);margin-top:8px' }, 'Monthly profit history'));
content.appendChild(cvs);
}
// loan
const loanBox = el('div', { style: 'display:flex;gap:8px;align-items:center;margin-top:14px;flex-wrap:wrap' });
loanBox.appendChild(el('span', { style: 'font-size:.9rem' }, `🏦 Loan: ${fmtMoney(st.loan)} / limit ${fmtMoney(st.loanLimit)}`));
const b1 = el('button', { class: 'btn' }, '+$5,000 loan');
b1.addEventListener('click', () => { takeLoan(st, 5000); openFinance(); });
const b2 = el('button', { class: 'btn' }, '-$5,000 repay');
b2.addEventListener('click', () => { repayLoan(st, 5000); openFinance(); });
loanBox.appendChild(b1); loanBox.appendChild(b2);
content.appendChild(loanBox);
// marketing
content.appendChild(el('h4', { style: 'margin:16px 0 6px;color:var(--gold)' }, '📣 Marketing campaigns'));
for (const c of CAMPAIGNS) {
const row = el('div', { class: 'set-row' },
el('span', {}, `${c.name}${fmtMoney(c.cost)}, +${c.pull} guests/s for ${c.weeks} weeks`));
const b = el('button', { class: 'btn primary' }, 'Start');
b.addEventListener('click', () => {
if (startCampaign(st, c.id)) { sfx.cash(); openFinance(); } else sfx.error();
});
row.appendChild(b);
content.appendChild(row);
}
const active = st.campaigns.filter(c => c.weeksLeft > 0);
if (active.length) content.appendChild(el('div', { style: 'font-size:.78rem;color:var(--good);margin-top:4px' },
'Active: ' + active.map(c => `${c.name} (${c.weeksLeft}w)`).join(', ')));
openModal('📈 Finances', content);
}
function drawSpark(cvs, data) {
const ctx = cvs.getContext('2d');
const W = cvs.width, H = cvs.height;
ctx.clearRect(0, 0, W, H);
const max = Math.max(...data.map(Math.abs), 100);
const bw = W / data.length;
data.forEach((v, i) => {
const h = Math.abs(v) / max * (H / 2 - 6);
ctx.fillStyle = v >= 0 ? '#57d97a' : '#ff6b6b';
if (v >= 0) ctx.fillRect(i * bw + 2, H / 2 - h, bw - 4, h);
else ctx.fillRect(i * bw + 2, H / 2, bw - 4, h);
});
ctx.strokeStyle = 'rgba(255,255,255,.15)';
ctx.beginPath(); ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2); ctx.stroke();
}
// ---------------- Hero guild ----------------
export function openGuildDialog() {
const st = getState();
const content = el('div');
if (!st.guild) {
content.appendChild(el('div', { style: 'line-height:1.6' },
el('p', {}, 'You need a Heroes Guild before you can recruit heroes.'),
el('p', { style: 'color:#9aa4c0;font-size:.85rem' }, 'Open the ⚔️ Heroes build tab and place the Guild Hall (2×2) next to a path.')));
openModal('🛡️ Heroes Guild', content);
return;
}
import('../game/heroes.js').then(H => {
const capEl = H.guildCap(st);
content.appendChild(el('div', { style: 'display:flex;justify-content:space-between;font-size:.85rem;margin-bottom:10px' },
el('span', {}, `Roster ${st.heroes.length}/${capEl}`),
el('span', {}, `⚔️ Kills ${st.heroStats.kills} · 💰 Loot ${fmtMoney(st.heroStats.lootGold)} · 🛡 Repelled ${st.invasion.repelled}`)));
const cards = el('div', { class: 'hero-cards' });
for (const h of st.heroes) {
const card = el('div', { class: 'hero-card' },
el('div', { class: 'portrait' }, h.alive ? h.def.icon : '💀'),
el('div', { style: 'flex:1' },
el('div', { style: 'display:flex;justify-content:space-between' },
el('b', {}, h.name), el('span', { style: 'color:var(--ink-dim)' }, `Lv ${h.lvl} · ${h.def.name}`)),
el('div', { class: 'hp-bar' }, el('div', { style: `width:${Math.max(0, h.hp / h.maxHp) * 100}%` })),
el('div', { class: 'xp-bar' }, el('div', { style: `width:${(h.xp / h.xpNext) * 100}%` })),
el('div', { style: 'font-size:.72rem;color:var(--ink-dim);margin-top:3px' },
h.alive ? `${Math.round(h.hp)}/${h.maxHp} hp · ⚔${Math.round(h.def.dmg * (1 + (h.lvl - 1) * .1) * (1 + h.gear * .25)).toFixed(0)} · kills ${h.kills}` : `Reviving in ${Math.ceil(h.revivingT)}s`),
));
cards.appendChild(card);
}
content.appendChild(cards);
// recruit row
content.appendChild(el('h4', { style: 'margin:14px 0 6px;color:var(--gold)' }, 'Recruit'));
const recRow = el('div', { class: 'res-items' });
Object.values(HeroClassesSafe()).forEach(cls => {
const locked = !H.clsUnlocked(st, cls.id);
const b = el('button', { class: 'btn' + (locked ? '' : ''), disabled: locked || st.heroes.length >= capEl ? 'true' : null },
`${cls.icon} ${cls.name}${fmtMoney(cls.cost)}${locked ? ' 🔒' : ''}`);
b.title = cls.desc;
b.addEventListener('click', () => {
const res = H.recruitHero(st, cls.id);
if (res.error) { sfx.error(); showToast('!', res.error, 'bad'); }
else { sfx.levelup(); openGuildDialog(); }
});
recRow.appendChild(b);
});
content.appendChild(recRow);
openModal('🛡️ Heroes Guild Hall', content, { wide: false });
});
}
import { HERO_CLASSES } from '../core/config.js';
function HeroClassesSafe() { return HERO_CLASSES; }
// ---------------- Objectives ----------------
export function openObjectives() {
const st = getState();
const scen = SCENARIOS.find(s => s.id === st.scenario);
const content = el('div');
content.appendChild(el('div', { style: 'margin-bottom:10px;font-size:.9rem' },
`🏰 ${st.park.name}${scen?.name || ''}`));
if (!scen?.goals.length) {
content.appendChild(el('p', { style: 'color:#9aa4c0' }, 'Sandbox mode: no objectives — build your dream!'));
} else {
for (const g of scen.goals) {
const prog = objectiveProgress(st, g);
const done = prog >= g.value;
const row = el('div', { style: 'margin-bottom:8px' },
el('div', { style: 'display:flex;justify-content:space-between;font-size:.88rem' },
el('span', {}, (done ? '✔ ' : '☐ ') + g.text),
el('b', { style: done ? 'color:var(--good)' : 'color:var(--ink-dim)' }, `${g.type === 'coasterExcite' && g.value < 10 ? prog.toFixed(1) : fmtNum(Math.min(prog, g.value))}/${fmtNum(g.value)}`)),
el('div', { class: 'bar', style: 'height:6px' }, el('div', { style: `width:${Math.min(100, prog / g.value * 100)}%;background:${done ? 'var(--good)' : 'var(--accent)'}` })),
);
content.appendChild(row);
}
}
if (st.awards.length) {
content.appendChild(el('h4', { style: 'margin:14px 0 6px;color:var(--gold)' }, '🏆 Awards'));
for (const aid of st.awards) {
const a = AWARDS_POOL.find(x => x.id === aid);
if (a) content.appendChild(el('div', { style: 'font-size:.85rem' }, `🏅 ${a.name}`));
}
}
openModal('🏆 Objectives & Awards', content);
}
// ---------------- Park settings ----------------
export function openParkSettings() {
const st = getState();
const content = el('div');
// park name
const nameIn = el('input', { type: 'text', value: st.park.name, maxlength: '30' });
nameIn.style.width = '220px';
nameIn.addEventListener('change', () => { st.park.name = nameIn.value || 'Unnamed Park'; });
content.appendChild(rowSetting('Park name', nameIn));
// entrance fee
const feeCtl = el('span');
const mkFee = () => {
feeCtl.innerHTML = '';
const minus = el('button', { class: 'btn' }, '');
const plus = el('button', { class: 'btn' }, '+');
minus.addEventListener('click', () => { st.park.entranceFee = Math.max(0, st.park.entranceFee - 1); mkFee(); });
plus.addEventListener('click', () => { st.park.entranceFee++; mkFee(); });
feeCtl.append(minus, el('span', { style: 'padding:0 10px;color:var(--gold)' }, fmtMoney(st.park.entranceFee)), plus);
};
mkFee();
content.appendChild(rowSetting('Entrance fee', feeCtl));
// open/close
const openB = el('button', { class: 'btn ' + (st.park.open ? 'danger' : 'primary') }, st.park.open ? 'Close park' : 'Open park');
openB.addEventListener('click', () => { st.park.open = !st.park.open; openParkSettings(); });
content.appendChild(rowSetting('Park status', openB));
content.appendChild(el('h4', { style: 'margin:14px 0 4px;color:var(--gold)' }, '🔊 Audio'));
const vols = getVolumes();
const mkVol = (label, key) => {
const inp = el('input', { type: 'range', min: '0', max: '1', step: '0.05', value: String(vols[key]) });
inp.addEventListener('input', () => { setVolumes({ [key]: +inp.value }); });
return rowSetting(label, inp);
};
content.appendChild(mkVol('Master volume', 'master'));
content.appendChild(mkVol('Music', 'music'));
content.appendChild(mkVol('Effects', 'sfx'));
const musicB = el('button', { class: 'btn' }, isMusicOn() ? '⏹ Stop music' : '🎵 Play music');
musicB.addEventListener('click', () => { isMusicOn() ? stopMusic() : startMusic(); openParkSettings(); });
content.appendChild(rowSetting('Ambient music', musicB));
content.appendChild(el('h4', { style: 'margin:14px 0 4px;color:var(--gold)' }, '💾 Data'));
const expB = el('button', { class: 'btn' }, 'Export save to file');
expB.addEventListener('click', () => saveSys.exportSave(st));
content.appendChild(rowSetting('Export', expB));
const quitB = el('button', { class: 'btn danger' }, 'Quit to Main Menu');
quitB.addEventListener('click', () => {
saveSys.autosave(st);
location.reload();
});
content.appendChild(rowSetting('Session', quitB));
openModal('⚙️ Park Settings', content);
}
function rowSetting(label, ctl) {
return el('div', { class: 'set-row' }, el('span', {}, label), ctl);
}
// ---------------- Save/Load ----------------
export function openSaveLoad() {
const st = getState();
const content = el('div');
const list = saveSys.listSaves();
for (const s of list) {
const row = el('div', { class: 'set-row' },
el('span', {}, s.exists
? `${s.slot === 'auto' ? '⟳ Autosave' : '📁 ' + s.slot}: ${s.parkName}${s.date}, ${s.guests} guests`
: `${s.slot === 'auto' ? '⟳ Autosave' : '📁 ' + s.slot}: empty`));
const btns = el('span', {});
const sb = el('button', { class: 'btn primary' }, 'Save');
sb.addEventListener('click', () => { saveSys.saveTo(st, s.slot); showToast('Saved!', `Game saved to ${s.slot}`, 'good'); openSaveLoad(); });
btns.appendChild(sb);
if (s.exists && s.slot !== 'auto') {
const lb = el('button', { class: 'btn', style: 'margin-left:6px' }, 'Load');
lb.addEventListener('click', () => {
const loaded = saveSys.loadFrom(s.slot);
if (loaded) { closeModal(); showToast('Loaded!', 'Welcome back.', 'good'); window.__onGameLoaded?.(); }
else showToast('Load failed', 'Corrupt save?', 'bad');
});
btns.appendChild(lb);
}
row.appendChild(btns);
content.appendChild(row);
}
// import file
const fileIn = el('input', { type: 'file', accept: '.json', style: 'display:none' });
fileIn.addEventListener('change', async () => {
const f = fileIn.files[0];
if (!f) return;
const text = await f.text();
const loaded = saveSys.importSaveText(text);
if (loaded) { closeModal(); showToast('Imported!', 'Save file loaded.', 'good'); window.__onGameLoaded?.(); }
else showToast('Import failed', 'Invalid file', 'bad');
});
const impB = el('button', { class: 'btn', style: 'margin-top:10px' }, '📂 Import from file…');
impB.addEventListener('click', () => fileIn.click());
content.appendChild(impB);
content.appendChild(fileIn);
openModal('💾 Save / Load', content);
}
// ---------------- Help ----------------
export function openHelp() {
const c = el('div', { class: 'help-cols' });
c.innerHTML = `
<h4>🎯 Goal</h4>
<p>Build a magical theme park! Complete scenario objectives (top-right 🏆): attract guests, raise your rating, repel monster invasions and build thrilling custom coasters.</p>
<h4>🧱 Basics</h4>
<p>Lay <b>Paths</b> from the entrance gate. Guests arrive automatically and wander paths. Add <b>Shops</b> (food/drinks/toilets!) beside paths and <b>Rides</b> with their entrance touching a path.</p>
<h4>🎢 Custom Coaster</h4>
<p>Pick the Coaster tab → place the <b>Station</b> on a flat tile next to a path. Add pieces (slopes, curves, loops!) until the circuit returns to the station heading the same way, then press <b>Finish</b>. Test it, then Open. Bigger drops & loops = more excitement (and intensity!).</p>
<h4>🧑‍💼 Staff</h4>
<p>Handymen clean litter & vomit, Mechanics fix breakdowns, Guards deter vandals, Jesters entertain queues. Wages are charged monthly.</p>
<h4>⚔️ Heroes & Monsters</h4>
<p>Build the <b>Heroes Guild</b> (Heroes tab), then recruit Knights, Rangers, Mages… When monsters invade (watch the warnings), heroes auto-engage. Kills earn gold, XP and mana. Buy gear upgrades from a hero's panel.</p>
<h4>✨ Magic</h4>
<p>Mana regenerates over time; <b>Ley Pools</b>, Rune Stones and Glowcaps raise max mana. Cast spells like Joy Aura (happiness), Monster Bane or Warding Sigil (blocks invasions).</p>
<h4>🔬 Research</h4>
<p>Earn RP from rides & magic scenery, spend it in the 🔬 lab to unlock advanced rides, shops, spells and hero classes.</p>
<h4>💰 Economy</h4>
<p>Income: entrance fees, ride tickets, shop sales. Costs: construction, monthly wages & running costs. Set ticket prices per ride (price ≈ excitement works well). Loans & marketing live under 📈.</p>
<h4>⌨️ Shortcuts</h4>
<p><kbd>WASD/arrows</kbd> pan · <kbd>Q/E</kbd> or wheel zoom · <kbd>Space</kbd> pause · <kbd>1-3</kbd> speed · <kbd>T</kbd> research · <kbd>F</kbd> finance · <kbd>G</kbd> guild · <kbd>H</kbd> help · <kbd>Esc</kbd> cancel/close · Right-click cancels placement.</p>
`;
openModal('📖 How to Play', c, { wide: true });
}
// ---------------- Scenario picker ----------------
export function openScenarioPicker(onPick) {
const grid = el('div', { class: 'scen-grid' });
for (const sc of SCENARIOS) {
const card = el('div', { class: 'scen-card' },
el('h3', {}, `${sc.icon} ${sc.name}`),
el('div', { class: 'diff' }, sc.diff),
el('p', {}, sc.blurb),
el('ul', { class: 'scen-goals' }, sc.goals.map(g => el('li', {}, '• ' + g.text))),
sc.sandbox ? null : el('div', { style: 'font-size:.75rem;color:var(--ink-dim);margin-top:6px' }, `Start: ${fmtMoney(sc.cash)}`),
);
card.addEventListener('click', () => { onPick(sc.id); });
grid.appendChild(card);
}
const wrap = el('div', {},
el('div', { style: 'margin-bottom:12px;color:#9aa4c0;font-size:.9rem' }, 'Choose a scenario to rule:'),
grid);
openModal('🏰 New Game', wrap, { wide: true });
}
// ---------------- Win/Lose ----------------
export function maybeShowEndModal(state, onRestart) {
if (state._endShown) return false;
if (state.won || state.lost) {
state._endShown = true;
state.won ? sfx.victory() : sfx.defeat();
const scen = SCENARIOS.find(s => s.id === state.scenario);
const c = el('div', { style: 'text-align:center;padding:20px 10px' },
el('div', { style: 'font-size:3.4rem' }, state.won ? '🏆' : '💀'),
el('h2', { style: 'color:' + (state.won ? 'var(--gold)' : 'var(--bad)') + ';margin:10px 0' },
state.won ? 'Victory!' : 'Bankrupt!'),
el('p', { style: 'color:#9aa4c0;line-height:1.6' },
state.won
? `${state.park.name} has completed every objective of ${scen?.name}. Your legend echoes across the kingdom!`
: 'The kingdom coffers ran dry. The dragons mourn… but every tycoon rises again.'),
el('div', { style: 'margin-top:14px;display:flex;gap:10px;justify-content:center' },
el('button', { class: 'btn primary', onclick: () => { closeModal(); onRestart(); } }, state.won ? '🎉 New Game' : '🔄 Try Again'),
el('button', { class: 'btn', onclick: () => { state._endShown = false; state.won = false; state.lost = false; state.freeplay = true; closeModal(); showToast('Free Play', 'Objectives complete — the park is yours!', 'gold'); } }, 'Keep Playing')),
);
openModal(state.won ? '🏆 Victory!' : '💀 Game Over', c);
return true;
}
return false;
}
+181
View File
@@ -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 = `
<canvas id="pov-canvas"></canvas>
<div id="pov-hud">
<span>🎢 <b>${ride.name}</b></span>
<span>💨 <span id="pov-speed">0</span> km/h</span>
<span>⚡ ${ride.excite.toFixed ? ride.excite.toFixed(1) : ride.excite}</span>
<span id="pov-time"></span>
</div>
<button id="pov-exit" class="btn danger">✕ Exit Ride</button>`;
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; }
+513
View File
@@ -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 = `<div style="grid-column:1/-1;font-size:.82rem;color:#9aa4c0;line-height:1.45">
Build a <b style="color:var(--gold)">station</b> first — click a flat tile <b>next to a path</b>.<br>
Then add pieces to form a closed circuit back to the station.<br><br>
🟡 cursor = next slot · Right-click = undo · Chain lifts are automatic.</div>`;
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 = `<div style="grid-column:1/-1;font-size:.82rem;color:#9aa4c0;line-height:1.5">Monsters will invade soon! Build the <b style="color:var(--mana)">Heroes Guild</b> to recruit defenders.</div>`;
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', `💰 <b class="${cashCls}" style="color:${state.cash < 0 ? '#ff6b6b' : 'var(--gold)'}">${fmtMoney(state.cash)}</b>`);
setText('stat-guests', `🧑‍🤝‍🧑 <b>${fmtNum(state.guests.length)}</b>`);
setText('stat-rating', `⭐ <b>${state.stats.rating}</b>`);
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('<span style="color:var(--bad)">DAMAGED</span>', `${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;
}
+105
View File
@@ -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;
}
}
+119
View File
@@ -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))];
}
+84
View File
@@ -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);
+87
View File
@@ -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);
+98
View File
@@ -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);
+103
View File
@@ -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);
+63
View File
@@ -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);
+266
View File
@@ -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);