IMPERIUM ARCANUM — web-based Caesar 3-like city builder with heroes, magic & monsters
- Full classic layer: roads/walkers, 11-tier housing evolution, food chain, industry chains, trade routes, taxes/wages, ratings, fires & collapse - Fantasy layer: heroes (XP/levels/auras), mana & 7 spells, monsters/invasions, walls/towers/legions/golems, campaign of 5 scenarios + endless sandbox - Canvas2D isometric engine with procedural sprites, zero runtime deps - Save/load slots + autosave, WebAudio synth music/SFX, HUD/menus/minimap - Tests: headless sim smoke test + Playwright E2E (build/combat/save)
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
/tmp
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# IMPERIUM ARCANUM ⚔️🔮
|
||||||
|
|
||||||
|
**A Caesar 3–style city builder with heroes, magic and monsters — running entirely in your browser.**
|
||||||
|
|
||||||
|
Inspired by [Caesar 3](https://en.wikipedia.org/wiki/Caesar_III) and its open-source remaster
|
||||||
|
[Augustus / Julius](https://github.com/keriew/augustus), rebuilt from scratch as a modern web game — then
|
||||||
|
extended with a full **heroes & magic** fantasy layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ▶ Play
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # → http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build # outputs dist/ (single-page, static hosting ready)
|
||||||
|
pnpm preview # serve the production bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
No backend. No accounts. Saves live in your browser's local storage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏛️ Classic city-builder depth
|
||||||
|
|
||||||
|
| System | Details |
|
||||||
|
| --- | --- |
|
||||||
|
| **Roads & walkers** | Buildings may be placed anywhere; connect them with roads to employ workers. Prefects, priests, actors, teachers, doctors and tax collectors patrol as animated walkers |
|
||||||
|
| **Housing evolution** | 11 tiers from Small Tent to Estate — water → food variety → religion → entertainment → education → health + luxury goods + desirability |
|
||||||
|
| **Food chain** | Farms/orchards/fisheries → carts → granary → market buyers → peddlers feed homes; famine starves citizens and stalls growth |
|
||||||
|
| **Industry chains** | Clay→Pottery · Timber→Furniture · Olives→Oil · Grapes→Wine · Iron+Timber→Weapons — warehouses store, markets distribute |
|
||||||
|
| **Trade** | Open routes with foreign cities, toggle per-good import/export lists, quarterly caravans, price factors per partner |
|
||||||
|
| **Treasury** | Tax rates, wages, forums/senate collection coverage, unemployment and sentiment feedback |
|
||||||
|
| **Ratings & victory** | Culture / Prosperity / Peace / Favor ratings drive scenario goals across the 5-mission campaign |
|
||||||
|
| **Disasters** | Fire spreads tile-to-tile until prefects douse it; buildings collapse without engineers |
|
||||||
|
|
||||||
|
## ⚔️ Heroes
|
||||||
|
|
||||||
|
Build the **Hall of Heroes** and summon legends:
|
||||||
|
|
||||||
|
- **Aurelius the Champion** — unbreakable melee tank
|
||||||
|
- **Lyra the Huntress** — swift ranged killer
|
||||||
|
- **Magnus the Stormcaller** — area devastation, +industry aura
|
||||||
|
|
||||||
|
Heroes gain XP, level up (more HP/damage), passively boost nearby industry,
|
||||||
|
fall in battle and return from the underworld after a few months.
|
||||||
|
|
||||||
|
## 🔮 Magic
|
||||||
|
|
||||||
|
Temples trickle mana; **Ley Pylons on crystal seams**, mage towers and the **Arcanum** make you rich in it.
|
||||||
|
|
||||||
|
Spells (bottom bar): 🌿 Blessing of Ceres · 🔥 Firestorm · 💧 Healing Rain · 💨 Hermes' Haste ·
|
||||||
|
🗿 Summon Golem · 🛡️ Aegis Ward · 🔮 Divine Insight.
|
||||||
|
The Great Library and Arcanum research advanced spells.
|
||||||
|
|
||||||
|
## 👹 Monsters
|
||||||
|
|
||||||
|
Hydra, Minotaur, Harpy flocks and the Lich King (who raises skeletons) attack in scripted campaign waves
|
||||||
|
and scaling sandbox raids. Answer with walls, towers, legions trained from weapons, golems, heroes and spells.
|
||||||
|
|
||||||
|
## 🗺️ Content
|
||||||
|
|
||||||
|
- **5 campaign scenarios** — farming village → breadbasket → ley-line awakening → iron frontier → eternal city of magic
|
||||||
|
- **Endless sandbox** with prosperity-scaled monster waves
|
||||||
|
- Procedural maps: rivers, forests, clay, iron, crystal ley lines, fertile meadows
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎮 Controls
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| Left click | Place / select · drag paints roads, walls, plazas, gardens |
|
||||||
|
| Right-drag / WASD | Pan camera |
|
||||||
|
| Mouse wheel | Zoom |
|
||||||
|
| `Space` | Pause |
|
||||||
|
| `1` – `4` | Game speed |
|
||||||
|
| `Esc` | Cancel tool / close panel |
|
||||||
|
|
||||||
|
Overlays: fire risk, water coverage, desirability, farm fertility.
|
||||||
|
|
||||||
|
## 🛠️ Tech
|
||||||
|
|
||||||
|
- TypeScript + Vite, zero runtime dependencies
|
||||||
|
- Canvas2D isometric renderer with procedurally drawn sprites (no art assets)
|
||||||
|
- Deterministic seeded maps, BFS/A* pathfinding, monthly-tick economy simulation
|
||||||
|
- WebAudio synthesized music & SFX
|
||||||
|
- Test suite: headless sim test (`scripts/simtest.ts`) + Playwright E2E (`scripts/e2e-test.mjs`, `scripts/combat-e2e.mjs`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/build-simtest.mjs && node /tmp/simtest.mjs # sim balance smoke test
|
||||||
|
BASE=http://localhost:5173/ node scripts/e2e-test.mjs # full browser E2E
|
||||||
|
```
|
||||||
|
|
||||||
|
*Made with ⚖️ by an open hand — have fun, Governor.*
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
<!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>Imperium Arcanum — City Builder of Legends</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%232a1d3a'/%3E%3Cpath d='M6 24h20l-3-12h-14z' fill='%23c9a227'/%3E%3Crect x='13' y='6' width='6' height='8' fill='%23e8d28a'/%3E%3C/svg%3E" />
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--gold:#c9a227; --gold-l:#ecd07d; --parch:#e8d8ae; --ink:#2b1d10;
|
||||||
|
--panel:#3a2a1c; --panel-d:#2a1d10; --panel-l:#55402a;
|
||||||
|
--red:#b03a2e; --green:#3f7d43; --blue:#3a6ea5; --purple:#6b4fa1;
|
||||||
|
--font:'Trebuchet MS','Segoe UI',Verdana,sans-serif;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
|
||||||
|
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#14101c;font-family:var(--font);color:var(--parch);}
|
||||||
|
#game{position:absolute;inset:0;}
|
||||||
|
canvas#world{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:default;}
|
||||||
|
.hidden{display:none !important;}
|
||||||
|
button{font-family:var(--font);}
|
||||||
|
::-webkit-scrollbar{width:10px;height:10px}
|
||||||
|
::-webkit-scrollbar-track{background:#241a10;border-radius:6px}
|
||||||
|
::-webkit-scrollbar-thumb{background:#6b5233;border-radius:6px;border:2px solid #241a10}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="game">
|
||||||
|
<canvas id="world"></canvas>
|
||||||
|
<div id="ui-root"></div>
|
||||||
|
<div id="toast-root"></div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "imperium-arcanum",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Imperium Arcanum — a Caesar-like city builder with heroes and magic, in your browser.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host --port 5173",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --host --port 4173",
|
||||||
|
"check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"playwright-core": "^1.62.1",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.10"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+615
@@ -0,0 +1,615 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
devDependencies:
|
||||||
|
playwright-core:
|
||||||
|
specifier: ^1.62.1
|
||||||
|
version: 1.62.1
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.6.3
|
||||||
|
version: 5.9.3
|
||||||
|
vite:
|
||||||
|
specifier: ^5.4.10
|
||||||
|
version: 5.4.21
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [aix]
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.21.5':
|
||||||
|
resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.21.5':
|
||||||
|
resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.21.5':
|
||||||
|
resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.21.5':
|
||||||
|
resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.21.5':
|
||||||
|
resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.21.5':
|
||||||
|
resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
|
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
||||||
|
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm-eabi@4.62.5':
|
||||||
|
resolution: {integrity: sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm64@4.62.5':
|
||||||
|
resolution: {integrity: sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-arm64@4.62.5':
|
||||||
|
resolution: {integrity: sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-x64@4.62.5':
|
||||||
|
resolution: {integrity: sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-arm64@4.62.5':
|
||||||
|
resolution: {integrity: sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-x64@4.62.5':
|
||||||
|
resolution: {integrity: sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-gnueabihf@4.62.5':
|
||||||
|
resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-musleabihf@4.62.5':
|
||||||
|
resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-gnu@4.62.5':
|
||||||
|
resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-musl@4.62.5':
|
||||||
|
resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-gnu@4.62.5':
|
||||||
|
resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-musl@4.62.5':
|
||||||
|
resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-gnu@4.62.5':
|
||||||
|
resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-musl@4.62.5':
|
||||||
|
resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-gnu@4.62.5':
|
||||||
|
resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-musl@4.62.5':
|
||||||
|
resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-s390x-gnu@4.62.5':
|
||||||
|
resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-gnu@4.62.5':
|
||||||
|
resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-musl@4.62.5':
|
||||||
|
resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@rollup/rollup-openbsd-x64@4.62.5':
|
||||||
|
resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@rollup/rollup-openharmony-arm64@4.62.5':
|
||||||
|
resolution: {integrity: sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-arm64-msvc@4.62.5':
|
||||||
|
resolution: {integrity: sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-ia32-msvc@4.62.5':
|
||||||
|
resolution: {integrity: sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-gnu@4.62.5':
|
||||||
|
resolution: {integrity: sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-msvc@4.62.5':
|
||||||
|
resolution: {integrity: sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@types/estree@1.0.9':
|
||||||
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
|
esbuild@0.21.5:
|
||||||
|
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
nanoid@3.3.18:
|
||||||
|
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||||
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
picocolors@1.1.1:
|
||||||
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
playwright-core@1.62.1:
|
||||||
|
resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
postcss@8.5.26:
|
||||||
|
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
||||||
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
rollup@4.62.5:
|
||||||
|
resolution: {integrity: sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==}
|
||||||
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
source-map-js@1.2.1:
|
||||||
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
typescript@5.9.3:
|
||||||
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
|
engines: {node: '>=14.17'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
vite@5.4.21:
|
||||||
|
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': ^18.0.0 || >=20.0.0
|
||||||
|
less: '*'
|
||||||
|
lightningcss: ^1.21.0
|
||||||
|
sass: '*'
|
||||||
|
sass-embedded: '*'
|
||||||
|
stylus: '*'
|
||||||
|
sugarss: '*'
|
||||||
|
terser: ^5.4.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
less:
|
||||||
|
optional: true
|
||||||
|
lightningcss:
|
||||||
|
optional: true
|
||||||
|
sass:
|
||||||
|
optional: true
|
||||||
|
sass-embedded:
|
||||||
|
optional: true
|
||||||
|
stylus:
|
||||||
|
optional: true
|
||||||
|
sugarss:
|
||||||
|
optional: true
|
||||||
|
terser:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm-eabi@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm64@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-arm64@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-x64@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-arm64@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-x64@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-gnueabihf@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-musleabihf@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-gnu@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-musl@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-gnu@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-musl@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-gnu@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-musl@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-gnu@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-musl@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-s390x-gnu@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-gnu@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-musl@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-openbsd-x64@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-openharmony-arm64@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-arm64-msvc@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-ia32-msvc@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-gnu@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-msvc@4.62.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
|
esbuild@0.21.5:
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/aix-ppc64': 0.21.5
|
||||||
|
'@esbuild/android-arm': 0.21.5
|
||||||
|
'@esbuild/android-arm64': 0.21.5
|
||||||
|
'@esbuild/android-x64': 0.21.5
|
||||||
|
'@esbuild/darwin-arm64': 0.21.5
|
||||||
|
'@esbuild/darwin-x64': 0.21.5
|
||||||
|
'@esbuild/freebsd-arm64': 0.21.5
|
||||||
|
'@esbuild/freebsd-x64': 0.21.5
|
||||||
|
'@esbuild/linux-arm': 0.21.5
|
||||||
|
'@esbuild/linux-arm64': 0.21.5
|
||||||
|
'@esbuild/linux-ia32': 0.21.5
|
||||||
|
'@esbuild/linux-loong64': 0.21.5
|
||||||
|
'@esbuild/linux-mips64el': 0.21.5
|
||||||
|
'@esbuild/linux-ppc64': 0.21.5
|
||||||
|
'@esbuild/linux-riscv64': 0.21.5
|
||||||
|
'@esbuild/linux-s390x': 0.21.5
|
||||||
|
'@esbuild/linux-x64': 0.21.5
|
||||||
|
'@esbuild/netbsd-x64': 0.21.5
|
||||||
|
'@esbuild/openbsd-x64': 0.21.5
|
||||||
|
'@esbuild/sunos-x64': 0.21.5
|
||||||
|
'@esbuild/win32-arm64': 0.21.5
|
||||||
|
'@esbuild/win32-ia32': 0.21.5
|
||||||
|
'@esbuild/win32-x64': 0.21.5
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
nanoid@3.3.18: {}
|
||||||
|
|
||||||
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
|
playwright-core@1.62.1: {}
|
||||||
|
|
||||||
|
postcss@8.5.26:
|
||||||
|
dependencies:
|
||||||
|
nanoid: 3.3.18
|
||||||
|
picocolors: 1.1.1
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
rollup@4.62.5:
|
||||||
|
dependencies:
|
||||||
|
'@types/estree': 1.0.9
|
||||||
|
optionalDependencies:
|
||||||
|
'@napi-rs/lzma-linux-x64-gnu': 1.5.1
|
||||||
|
'@rollup/rollup-android-arm-eabi': 4.62.5
|
||||||
|
'@rollup/rollup-android-arm64': 4.62.5
|
||||||
|
'@rollup/rollup-darwin-arm64': 4.62.5
|
||||||
|
'@rollup/rollup-darwin-x64': 4.62.5
|
||||||
|
'@rollup/rollup-freebsd-arm64': 4.62.5
|
||||||
|
'@rollup/rollup-freebsd-x64': 4.62.5
|
||||||
|
'@rollup/rollup-linux-arm-gnueabihf': 4.62.5
|
||||||
|
'@rollup/rollup-linux-arm-musleabihf': 4.62.5
|
||||||
|
'@rollup/rollup-linux-arm64-gnu': 4.62.5
|
||||||
|
'@rollup/rollup-linux-arm64-musl': 4.62.5
|
||||||
|
'@rollup/rollup-linux-loong64-gnu': 4.62.5
|
||||||
|
'@rollup/rollup-linux-loong64-musl': 4.62.5
|
||||||
|
'@rollup/rollup-linux-ppc64-gnu': 4.62.5
|
||||||
|
'@rollup/rollup-linux-ppc64-musl': 4.62.5
|
||||||
|
'@rollup/rollup-linux-riscv64-gnu': 4.62.5
|
||||||
|
'@rollup/rollup-linux-riscv64-musl': 4.62.5
|
||||||
|
'@rollup/rollup-linux-s390x-gnu': 4.62.5
|
||||||
|
'@rollup/rollup-linux-x64-gnu': 4.62.5
|
||||||
|
'@rollup/rollup-linux-x64-musl': 4.62.5
|
||||||
|
'@rollup/rollup-openbsd-x64': 4.62.5
|
||||||
|
'@rollup/rollup-openharmony-arm64': 4.62.5
|
||||||
|
'@rollup/rollup-win32-arm64-msvc': 4.62.5
|
||||||
|
'@rollup/rollup-win32-ia32-msvc': 4.62.5
|
||||||
|
'@rollup/rollup-win32-x64-gnu': 4.62.5
|
||||||
|
'@rollup/rollup-win32-x64-msvc': 4.62.5
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
vite@5.4.21:
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.21.5
|
||||||
|
postcss: 8.5.26
|
||||||
|
rollup: 4.62.5
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: set this to true or false
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Browser smoke test: load game, start sandbox, watch for console errors.
|
||||||
|
import { chromium } from 'playwright-core';
|
||||||
|
|
||||||
|
const exe = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell';
|
||||||
|
const errors = [];
|
||||||
|
const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--disable-gpu'] });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||||
|
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
|
||||||
|
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
|
||||||
|
|
||||||
|
await page.goto('http://127.0.0.1:5173/', { waitUntil: 'networkidle' });
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await page.screenshot({ path: '/tmp/shot_menu.png' });
|
||||||
|
|
||||||
|
// start sandbox
|
||||||
|
await page.click('#mm-sandbox');
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
await page.screenshot({ path: '/tmp/shot_game.png' });
|
||||||
|
|
||||||
|
// try placing a road via clicks on canvas (center-ish drag)
|
||||||
|
const cv = await page.$('#world');
|
||||||
|
const box = await cv.boundingBox();
|
||||||
|
console.log('canvas at', box);
|
||||||
|
// select housing tab then road card
|
||||||
|
const tabs = await page.$$('#cattabs .cattab');
|
||||||
|
console.log('tabs:', tabs.length);
|
||||||
|
|
||||||
|
// speed up time & let it run
|
||||||
|
for (let i = 0; i < 6; i++) { await page.mouse.click(box.x + box.width / 2, box.y + 60); await page.waitForTimeout(300); }
|
||||||
|
await page.screenshot({ path: '/tmp/shot_later.png' });
|
||||||
|
|
||||||
|
console.log('CONSOLE ERRORS:', errors.length);
|
||||||
|
errors.slice(0, 12).forEach(e => console.log(' -', e.slice(0, 220)));
|
||||||
|
await browser.close();
|
||||||
|
process.exit(errors.length ? 1 : 0);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import pkg from '../node_modules/.pnpm/esbuild@0.21.5/node_modules/esbuild/lib/main.js';
|
||||||
|
const { build } = pkg;
|
||||||
|
await build({
|
||||||
|
entryPoints: ['scripts/simtest.ts'],
|
||||||
|
bundle: true, format: 'esm', platform: 'node',
|
||||||
|
outfile: '/tmp/simtest.mjs', logLevel: 'warning',
|
||||||
|
});
|
||||||
|
console.log('bundled ok');
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// Combat / heroes / magic / save-load E2E
|
||||||
|
import { chromium } from 'playwright-core';
|
||||||
|
|
||||||
|
const exe = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell';
|
||||||
|
const BASE = process.env.BASE || 'http://127.0.0.1:5173/';
|
||||||
|
const errors = [];
|
||||||
|
const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox'] });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||||
|
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
|
||||||
|
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
|
||||||
|
|
||||||
|
await page.goto(BASE, { waitUntil: 'networkidle' });
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.click('#mm-sandbox');
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
|
||||||
|
// minimal town + defenses + hero hall
|
||||||
|
const setup = await page.evaluate(() => {
|
||||||
|
const I = window.__IA;
|
||||||
|
const c = I.app.city;
|
||||||
|
let ox = -1, oy = -1;
|
||||||
|
outer:
|
||||||
|
for (let y = 8; y < c.H - 14; y++) for (let x = 4; x < c.W - 24; x++) {
|
||||||
|
let ok = true;
|
||||||
|
for (let dy = -2; dy < 9 && ok; dy++) for (let dx = -2; dx < 20; dx++) {
|
||||||
|
const t = c.tiles[(y + dy) * c.W + (x + dx)];
|
||||||
|
if (!t || t.terrain === 'water') { ok = false; break; }
|
||||||
|
}
|
||||||
|
if (ok) { ox = x; oy = y; break outer; }
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 14; i++) I.place('road', ox + i, oy);
|
||||||
|
for (let i = 1; i < 13; i++) I.place('house', ox + i, oy - 1);
|
||||||
|
I.place('well', ox + 2, oy + 1);
|
||||||
|
I.place('market', ox + 4, oy + 1);
|
||||||
|
I.place('granary', ox + 7, oy + 1);
|
||||||
|
const placeNear = (key, cx, cy, R = 7) => {
|
||||||
|
for (let dy = -R; dy <= R; dy++) for (let dx = -R; dx <= R; dx++) {
|
||||||
|
if (I.place(key, cx + dx, cy + dy).ok) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for (let i = 0; i < 8; i++) I.place('wall', ox + 2 + i, oy + 4);
|
||||||
|
I.place('tower', ox + 4, oy + 5);
|
||||||
|
placeNear('fort', ox + 11, oy + 5);
|
||||||
|
placeNear('heroHall', ox + 13, oy - 3);
|
||||||
|
I.give('weapons', 60);
|
||||||
|
I.give('iron', 100);
|
||||||
|
return { ox, oy, hall: [...c.buildings.values()].some(b => b.def === 'heroHall'), fort: [...c.buildings.values()].some(b => b.def === 'fort') };
|
||||||
|
});
|
||||||
|
console.log('setup:', JSON.stringify(setup));
|
||||||
|
|
||||||
|
// run to month 8 so fort can recruit & pop exists
|
||||||
|
await page.evaluate(() => { const I = window.__IA; for (let i = 0; i < 8 * 20; i++) I.step(0.5); });
|
||||||
|
const mid = await page.evaluate(() => {
|
||||||
|
const c = window.__IA.app.city;
|
||||||
|
return { pop: c.population, legions: [...c.walkers.values()].filter(w => w.unit?.kind === 'soldiers').length };
|
||||||
|
});
|
||||||
|
console.log('mid:', JSON.stringify(mid));
|
||||||
|
|
||||||
|
// summon hero
|
||||||
|
const heroOk = await page.evaluate(() => {
|
||||||
|
const I = window.__IA;
|
||||||
|
I.app.city.funds += 5000; // ensure affordable for test
|
||||||
|
return I.summon('champion');
|
||||||
|
});
|
||||||
|
console.log('summoned:', heroOk);
|
||||||
|
|
||||||
|
// spawn a hydra in front of the walls and let combat resolve
|
||||||
|
await page.evaluate((pos) => {
|
||||||
|
const I = window.__IA;
|
||||||
|
I.spawnWave('hydra', 1, 1);
|
||||||
|
// teleport hydra near the town for a quick fight
|
||||||
|
const foe = [...I.app.city.walkers.values()].find(w => w.unit?.side === 'foe');
|
||||||
|
if (foe) { foe.x = pos.ox + 6; foe.y = pos.oy + 6; }
|
||||||
|
}, setup);
|
||||||
|
|
||||||
|
// cast firestorm at the foe location (grant spell first)
|
||||||
|
const castRes = await page.evaluate((pos) => {
|
||||||
|
const I = window.__IA;
|
||||||
|
I.grantSpell('firestorm');
|
||||||
|
I.app.city.mana = 500;
|
||||||
|
return I.cast('firestorm', pos.ox + 7, pos.oy + 7);
|
||||||
|
}, setup);
|
||||||
|
console.log('firestorm cast:', castRes);
|
||||||
|
|
||||||
|
// step until hydra dies (max ~60s game time)
|
||||||
|
const fight = await page.evaluate(() => {
|
||||||
|
const I = window.__IA;
|
||||||
|
let slain0 = I.app.city.stats.monstersSlain;
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
I.step(0.5);
|
||||||
|
const foes = [...I.app.city.walkers.values()].filter(w => w.unit?.side === 'foe');
|
||||||
|
if (foes.length === 0) break;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
slain: I.app.city.stats.monstersSlain - slain0,
|
||||||
|
foesLeft: [...I.app.city.walkers.values()].filter(w => w.unit?.side === 'foe').length,
|
||||||
|
heroAlive: [...I.app.city.walkers.values()].some(w => w.kind === 'hero'),
|
||||||
|
golems: [...c_walkers(I)].length,
|
||||||
|
};
|
||||||
|
function c_walkers(I) { return []; }
|
||||||
|
});
|
||||||
|
console.log('fight:', JSON.stringify(fight));
|
||||||
|
|
||||||
|
// SAVE / LOAD round-trip
|
||||||
|
const before = await page.evaluate(() => {
|
||||||
|
const I = window.__IA;
|
||||||
|
I.save();
|
||||||
|
return { month: I.app.city.month, pop: I.app.city.population };
|
||||||
|
});
|
||||||
|
await page.reload({ waitUntil: 'networkidle' });
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
const after = await page.evaluate(() => {
|
||||||
|
const I = window.__IA;
|
||||||
|
const ok = I.loadAutosave();
|
||||||
|
return { ok, month: I.app?.city.month ?? -1, pop: I.app?.city.population ?? -1, before: null };
|
||||||
|
});
|
||||||
|
console.log(`save/load: before=${JSON.stringify(before)} after=${JSON.stringify(after)}`);
|
||||||
|
|
||||||
|
console.log('CONSOLE ERRORS:', errors.length);
|
||||||
|
errors.slice(0, 8).forEach(e => console.log(' -', e.slice(0, 180)));
|
||||||
|
|
||||||
|
const pass = heroOk && castRes && fight.slain >= 1 && after.ok && Math.abs(after.month - before.month) <= 1 && errors.length === 0;
|
||||||
|
console.log(pass ? '✅ COMBAT/MAGIC/SAVE E2E PASS' : '❌ FAIL');
|
||||||
|
await browser.close();
|
||||||
|
process.exit(pass ? 0 : 1);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { CAMPAIGN } from '../src/data/scenarios';
|
||||||
|
import { createCity, tickCity, placeBuilding } from '../src/sim/city';
|
||||||
|
import { ensureSimHooks } from '../src/sim/hooks';
|
||||||
|
|
||||||
|
const c = createCity(CAMPAIGN[0]);
|
||||||
|
ensureSimHooks();
|
||||||
|
let ox=-1, oy=-1;
|
||||||
|
outer: for (let y=8;y<c.H-12;y++) for (let x=8;x<c.W-14;x++){
|
||||||
|
let ok=true;
|
||||||
|
for (let dy=-2; dy<8 && ok; dy++) for (let dx=-2;dx<26;dx++){
|
||||||
|
const t=c.tiles[(y+dy)*c.W+(x+dx)];
|
||||||
|
if(!t||t.terrain==='water'||t.terrain==='tree'||t.terrain==='rock'){ok=false;break;}
|
||||||
|
}
|
||||||
|
if(ok){ox=x;oy=y;break outer;}
|
||||||
|
}
|
||||||
|
for(let i=0;i<22;i++) placeBuilding(c,'road',ox+i,oy);
|
||||||
|
for(let i=1;i<21;i++) placeBuilding(c,'house',ox+i,oy-1);
|
||||||
|
placeBuilding(c,'well',ox+2,oy+1);
|
||||||
|
placeBuilding(c,'market',ox+4,oy+1);
|
||||||
|
placeBuilding(c,'granary',ox+8,oy+1);
|
||||||
|
placeBuilding(c,'prefecture',ox+12,oy+1);
|
||||||
|
let farmPlaced=false;
|
||||||
|
for (const [fdx,fdy] of [[15,1],[14,1],[16,4],[13,4]] as const){
|
||||||
|
if(placeBuilding(c,'farmWheat',ox+fdx,oy+fdy).ok){farmPlaced=true;console.log('farm at',fdx,fdy);break;}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dump=(label:string)=>{
|
||||||
|
const f=[...c.buildings.values()].find(b=>b.def==='farmWheat')!;
|
||||||
|
const g=[...c.buildings.values()].find(b=>b.def==='granary')!;
|
||||||
|
const m=[...c.buildings.values()].find(b=>b.def==='market')!;
|
||||||
|
console.log(`--- ${label} (month ${c.month})`);
|
||||||
|
console.log(`farm workers=${f.workersLive} progress=${f.progress.toFixed(2)} wheat=${f.inv['wheat']??0}`);
|
||||||
|
console.log(`granary wheat=${g.inv['wheat']??0}`);
|
||||||
|
console.log(`market workers=${m.workersLive} inv=`, JSON.stringify(m.inv));
|
||||||
|
const wk=[...c.walkers.values()];
|
||||||
|
console.log('walkers:', wk.map(w=>`${w.kind}${w.cargo?'('+JSON.stringify(w.cargo)+')':''}`).slice(0,12).join(', '));
|
||||||
|
const hs=[...c.buildings.values()].filter(b=>b.def==='house');
|
||||||
|
const fed=hs.filter(h=>((h.goodsStock?.['wheat']??0)+(h.goodsStock?.['fruit']??0))>0).length;
|
||||||
|
console.log(`houses=${hs.length} residents=${hs.reduce((a,b)=>a+b.residents,0)} fed=${fed}`);
|
||||||
|
};
|
||||||
|
dump('start');
|
||||||
|
for (let i=0;i<20*20;i++){ tickCity(c,CAMPAIGN[0],0.5); if(i===199||i===399) dump(`t+${Math.round(i*0.5)}s`); }
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Deep E2E: start sandbox, build a working town via __IA hook, fast-forward months, assert state.
|
||||||
|
import { chromium } from 'playwright-core';
|
||||||
|
|
||||||
|
const exe = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell';
|
||||||
|
const errors = [];
|
||||||
|
const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--disable-gpu'] });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||||
|
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
|
||||||
|
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
|
||||||
|
|
||||||
|
const BASE = process.env.BASE || 'http://127.0.0.1:5173/';
|
||||||
|
await page.goto(BASE, { waitUntil: 'networkidle' });
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.click('#mm-sandbox');
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
|
||||||
|
// Build town programmatically
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const I = window.__IA;
|
||||||
|
const c = I.app.city;
|
||||||
|
// find flat grass area
|
||||||
|
let ox = -1, oy = -1;
|
||||||
|
outer:
|
||||||
|
for (let y = 8; y < c.H - 12; y++) for (let x = 4; x < c.W - 24; x++) {
|
||||||
|
let ok = true, fertileNear = false;
|
||||||
|
for (let dy = -2; dy < 9 && ok; dy++) for (let dx = -2; dx < 21; dx++) {
|
||||||
|
const t = c.tiles[(y + dy) * c.W + (x + dx)];
|
||||||
|
if (!t || t.terrain === 'water') { ok = false; break; }
|
||||||
|
if (t.terrain === 'fertile' && t.fert > 30) fertileNear = true;
|
||||||
|
}
|
||||||
|
if (ok && fertileNear) { ox = x; oy = y; break outer; }
|
||||||
|
}
|
||||||
|
if (ox < 0) return { fail: 'no spot' };
|
||||||
|
for (let i = 0; i < 20; i++) I.place('road', ox + i, oy);
|
||||||
|
for (let i = 1; i < 19; i++) I.place('house', ox + i, oy - 1);
|
||||||
|
I.place('well', ox + 2, oy + 1);
|
||||||
|
I.place('market', ox + 4, oy + 1);
|
||||||
|
I.place('granary', ox + 7, oy + 1);
|
||||||
|
I.place('prefecture', ox + 11, oy + 1);
|
||||||
|
I.place('forum', ox + 12, oy - 2) || I.place('forum', ox + 13, oy - 3);
|
||||||
|
// farm directly below the road row -> top edge touches road => connected
|
||||||
|
const fr = I.place('farmWheat', ox + 15, oy + 1);
|
||||||
|
const farmPlaced = !!fr.ok;
|
||||||
|
if (!farmPlaced) {
|
||||||
|
for (const [fdx, fdy] of [[13, 1], [17, 1], [15, -4], [12, 1], [18, 1]]) {
|
||||||
|
if (I.place('farmWheat', ox + fdx, oy + fdy).ok) { farmPlaced = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!farmPlaced) return { fail: 'no road-connected farm spot' };
|
||||||
|
return { ox, oy, funds: Math.round(c.funds), pop: c.population };
|
||||||
|
});
|
||||||
|
console.log('build result:', JSON.stringify(result));
|
||||||
|
|
||||||
|
if (result.fail) { console.log('BUILD FAILED'); process.exit(1); }
|
||||||
|
|
||||||
|
// Fast-forward 30 months synchronously
|
||||||
|
const ff = await page.evaluate(() => {
|
||||||
|
const I = window.__IA;
|
||||||
|
for (let i = 0; i < 30 * 20; i++) I.step(0.5);
|
||||||
|
const c = I.app.city;
|
||||||
|
const houses = [...c.buildings.values()].filter(b => b.def === 'house');
|
||||||
|
return {
|
||||||
|
month: c.month, pop: c.population, funds: Math.round(c.funds),
|
||||||
|
employed: c.employed, laborers: c.laborers,
|
||||||
|
sentiment: c.sentiment, ratings: c.ratings,
|
||||||
|
avgLevel: (houses.reduce((a, b) => a + b.houseLevel, 0) / Math.max(1, houses.length)).toFixed(2),
|
||||||
|
walkers: c.walkers.size,
|
||||||
|
granaryWheat: [...c.buildings.values()].filter(b => b.def === 'granary').reduce((a, b) => a + (b.inv.wheat ?? 0), 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log('after 30mo:', JSON.stringify(ff));
|
||||||
|
|
||||||
|
// Now test spells & heroes UI presence
|
||||||
|
const ui = await page.evaluate(() => ({
|
||||||
|
spellCount: document.querySelectorAll('#spellbar .spell').length,
|
||||||
|
catTabs: document.querySelectorAll('#cattabs .cattab').length,
|
||||||
|
minimap: !!document.getElementById('minimap'),
|
||||||
|
topFunds: document.getElementById('t-funds')?.textContent,
|
||||||
|
}));
|
||||||
|
console.log('ui:', JSON.stringify(ui));
|
||||||
|
|
||||||
|
await page.screenshot({ path: '/tmp/shot_town.png' });
|
||||||
|
console.log('CONSOLE ERRORS:', errors.length);
|
||||||
|
errors.slice(0, 10).forEach(e => console.log(' -', e.slice(0, 200)));
|
||||||
|
|
||||||
|
const pass = ff.pop > 40 && ff.month >= 28 && ui.spellCount === 7 && errors.length === 0;
|
||||||
|
console.log(pass ? '✅ E2E PASS' : '❌ E2E FAIL');
|
||||||
|
await browser.close();
|
||||||
|
process.exit(pass ? 0 : 1);
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Reproduce: real mouse clicks to build a road + house
|
||||||
|
import { chromium } from 'playwright-core';
|
||||||
|
const exe = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell';
|
||||||
|
const BASE = process.env.BASE || 'http://127.0.0.1:4174/';
|
||||||
|
const errors = [];
|
||||||
|
const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox'] });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||||
|
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
|
||||||
|
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
|
||||||
|
await page.goto(BASE, { waitUntil: 'networkidle' });
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.click('#mm-sandbox');
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
|
||||||
|
// helper: convert tile -> screen px using exposed camera
|
||||||
|
async function tileToScreen(tx, ty) {
|
||||||
|
return page.evaluate(([x, y]) => {
|
||||||
|
const app = window.__IA.app;
|
||||||
|
const [wx, wy] = app.cam.tileToWorld(x, y);
|
||||||
|
const [sx, sy] = app.cam.worldToScreen(wx, wy);
|
||||||
|
return [sx, sy];
|
||||||
|
}, [tx, ty]);
|
||||||
|
}
|
||||||
|
async function clickTile(x, y) {
|
||||||
|
const [sx, sy] = await tileToScreen(x, y);
|
||||||
|
await page.mouse.move(sx, sy);
|
||||||
|
await page.waitForTimeout(80);
|
||||||
|
await page.mouse.click(sx, sy);
|
||||||
|
await page.waitForTimeout(60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) pick road tool via UI card
|
||||||
|
await page.click('#cattabs .cattab[data-cat="housing"]');
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const cards = await page.$$('#buildcards .bcard');
|
||||||
|
console.log('cards:', cards.length);
|
||||||
|
for (const c of cards) console.log(' card:', (await c.textContent()).trim().slice(0, 30));
|
||||||
|
|
||||||
|
// find road card by title text
|
||||||
|
let roadCard = null;
|
||||||
|
for (const c of cards) { if ((await c.textContent()).includes('Road')) { roadCard = c; break; } }
|
||||||
|
await roadCard.click();
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
|
||||||
|
const toolState1 = await page.evaluate(() => JSON.stringify(window.__IA.app.tool));
|
||||||
|
console.log('tool after road select:', toolState1);
|
||||||
|
|
||||||
|
// center camera on map middle
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const app = window.__IA.app;
|
||||||
|
const mid = app.cam.tileToWorld(app.city.W / 2, app.city.H / 2);
|
||||||
|
app.cam.cx = mid[0]; app.cam.cy = mid[1];
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
// place road at center tile
|
||||||
|
const c0 = Math.floor(await page.evaluate(() => window.__IA.app.city.W / 2));
|
||||||
|
await clickTile(c0, c0);
|
||||||
|
const afterRoad = await page.evaluate(([x, y]) => {
|
||||||
|
const t = window.__IA.app.city.tiles[y * window.__IA.app.city.W + x];
|
||||||
|
return { road: t.road, funds: window.__IA.app.city.funds };
|
||||||
|
}, [c0, c0]);
|
||||||
|
console.log('after road click:', JSON.stringify(afterRoad));
|
||||||
|
|
||||||
|
// 2) select house card and click NEXT to the road
|
||||||
|
let houseCard = null;
|
||||||
|
for (const c of await page.$$('#buildcards .bcard')) { if ((await c.textContent()).includes('House')) { houseCard = c; break; } }
|
||||||
|
await houseCard.click();
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
console.log('tool after house select:', await page.evaluate(() => JSON.stringify(window.__IA.app.tool)));
|
||||||
|
|
||||||
|
await clickTile(c0, c0 - 1); // directly above road
|
||||||
|
const afterHouse = await page.evaluate(([x, y]) => {
|
||||||
|
const c = window.__IA.app.city;
|
||||||
|
const t = c.tiles[y * c.W + x];
|
||||||
|
const b = t.bld ? c.buildings.get(t.bld) : null;
|
||||||
|
return { bld: t.bld, def: b?.def ?? null, funds: Math.round(c.funds) };
|
||||||
|
}, [c0, c0 - 1]);
|
||||||
|
console.log('after house click:', JSON.stringify(afterHouse));
|
||||||
|
|
||||||
|
// ghost validity debug at that spot
|
||||||
|
const ghostDbg = await page.evaluate(([x, y]) => {
|
||||||
|
const app = window.__IA.app;
|
||||||
|
// replicate canPlace pieces
|
||||||
|
const c = app.city;
|
||||||
|
const t = c.tiles[y * c.W + x];
|
||||||
|
let roadAdj = false;
|
||||||
|
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
const n = c.tiles[(y + dy) * c.W + (x + dx)];
|
||||||
|
if (n && (n.road || n.plaza)) roadAdj = true;
|
||||||
|
}
|
||||||
|
return { terrain: t.terrain, bldHere: t.bld, roadAdj, tool: app.tool, ghost: app.view.ghost };
|
||||||
|
}, [c0, c0 - 1]);
|
||||||
|
console.log('ghost debug:', JSON.stringify(ghostDbg));
|
||||||
|
|
||||||
|
await page.screenshot({ path: '/tmp/build_repro.png' });
|
||||||
|
console.log('errors:', errors.length ? errors.slice(0, 5) : 0);
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Headless simulation smoke test (run via esbuild bundle + node)
|
||||||
|
import { CAMPAIGN } from '../src/data/scenarios';
|
||||||
|
import { createCity, tickCity, placeBuilding } from '../src/sim/city';
|
||||||
|
import { canPlace } from '../src/sim/city';
|
||||||
|
import { ensureSimHooks } from '../src/sim/hooks';
|
||||||
|
|
||||||
|
function fail(msg: string): never {
|
||||||
|
console.error('❌ FAIL:', msg);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sc = CAMPAIGN[0];
|
||||||
|
const c = createCity(sc);
|
||||||
|
ensureSimHooks();
|
||||||
|
|
||||||
|
// find a decent flat spot near center: scan for grass area
|
||||||
|
let ox = -1, oy = -1;
|
||||||
|
outer:
|
||||||
|
for (let y = 8; y < c.H - 12; y++) {
|
||||||
|
for (let x = 8; x < c.W - 14; x++) {
|
||||||
|
let ok = true;
|
||||||
|
for (let dy = -2; dy < 8 && ok; dy++) for (let dx = -2; dx < 16; dx++) {
|
||||||
|
const t = c.tiles[(y + dy) * c.W + (x + dx)];
|
||||||
|
if (!t || t.terrain === 'water' || t.terrain === 'tree' || t.terrain === 'rock') { ok = false; break; }
|
||||||
|
}
|
||||||
|
if (ok) { ox = x; oy = y; break outer; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ox < 0) fail('no buildable spot found');
|
||||||
|
console.log(`build spot at ${ox},${oy}`);
|
||||||
|
|
||||||
|
// lay a long main road east-west
|
||||||
|
for (let i = 0; i < 22; i++) {
|
||||||
|
if (!placeBuilding(c, 'road', ox + i, oy).ok) fail('road placement failed at +' + i);
|
||||||
|
}
|
||||||
|
// houses along north side
|
||||||
|
for (let i = 1; i < 21; i++) {
|
||||||
|
const r = placeBuilding(c, 'house', ox + i, oy - 1);
|
||||||
|
if (!r.ok) console.warn('house skip', ox + i, oy - 1);
|
||||||
|
}
|
||||||
|
// south side: services
|
||||||
|
const mk = (k: string, dx: number, dy: number) => {
|
||||||
|
const r = placeBuilding(c, k, ox + dx, oy + dy);
|
||||||
|
if (!r.ok && !['farmWheat', 'fishingWharf', 'clayPit', 'timberCamp', 'ironMine', 'crystalMine', 'leyPylon'].includes(k)) fail(k + ' placement failed: ' + r.reason);
|
||||||
|
};
|
||||||
|
mk('well', 2, 1);
|
||||||
|
mk('market', 4, 1);
|
||||||
|
mk('granary', 8, 1);
|
||||||
|
mk('prefecture', 12, 1);
|
||||||
|
// farms near the road row (must touch a road to get workers)
|
||||||
|
let farmOk = false;
|
||||||
|
const farmSpots: Array<readonly [number, number]> = [
|
||||||
|
[15, 1], [16, 1], [14, 1], [17, 1], [13, 1],
|
||||||
|
[15, 4], [17, 4], [13, 4], [18, 1], [12, 1],
|
||||||
|
];
|
||||||
|
for (const [fdx, fdy] of farmSpots) {
|
||||||
|
if (farmOk) break;
|
||||||
|
if (canPlace(c, 'farmWheat', ox + fdx, oy + fdy) === true) {
|
||||||
|
const r = placeBuilding(c, 'farmWheat', ox + fdx, oy + fdy);
|
||||||
|
if (r.ok) { console.log(`farm at ${ox + fdx},${oy + fdy}`); farmOk = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!farmOk) {
|
||||||
|
// widen: anywhere that touches the road row
|
||||||
|
for (let x = 2; x < c.W - 5 && !farmOk; x++) {
|
||||||
|
for (const dy of [1, -4] as const) {
|
||||||
|
if (canPlace(c, 'farmWheat', x, oy + dy) === true) {
|
||||||
|
const r = placeBuilding(c, 'farmWheat', x, oy + dy);
|
||||||
|
if (r.ok) { console.log('wide-scan farm at', x, oy + dy); farmOk = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!farmOk) fail('no road-connected farm spot found');
|
||||||
|
|
||||||
|
console.log('initial funds', Math.round(c.funds));
|
||||||
|
|
||||||
|
// simulate 30 months (~300s of game time at speed idx 2)
|
||||||
|
let monthsSeen = c.month;
|
||||||
|
try {
|
||||||
|
for (let step = 0; step < 34 * 20 + 60; step++) {
|
||||||
|
tickCity(c, sc, 0.5); // dt seconds per iteration
|
||||||
|
if (step % 200 === 0 && step > 0) console.log('step', step, 'month', c.month, 'walkers', c.walkers.size);
|
||||||
|
if (c.month > monthsSeen) {
|
||||||
|
monthsSeen = c.month;
|
||||||
|
if (monthsSeen % 6 === 0) {
|
||||||
|
console.log(`month ${monthsSeen}: pop=${c.population} funds=${Math.round(c.funds)} sentiment=${c.sentiment} ratings=C${c.ratings.culture}/P${c.ratings.prosperity}/Pe${c.ratings.peace}/F${c.ratings.favor}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(c.funds)) fail('funds diverged');
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('EXCEPTION IN LOOP:', e); fail(String(e)); }
|
||||||
|
|
||||||
|
console.log('LOOP DONE, months=' + c.month);
|
||||||
|
console.log('--- after ~30 months ---');
|
||||||
|
console.log(`pop=${c.population} funds=${Math.round(c.funds)} employed=${c.employed}/${c.laborers}`);
|
||||||
|
console.log('walkers alive:', c.walkers.size);
|
||||||
|
console.log('messages:', c.messages.slice(0, 8).map(m => m.text));
|
||||||
|
const houses = [...c.buildings.values()].filter(b => b.def === 'house');
|
||||||
|
const levels = houses.map(h => h.houseLevel);
|
||||||
|
console.log('houses:', houses.length, 'avg level:', (levels.reduce((a, b) => a + b, 0) / Math.max(1, levels.length)).toFixed(2), 'max:', Math.max(0, ...levels));
|
||||||
|
console.log('residents total:', houses.reduce((a, b) => a + b.residents, 0));
|
||||||
|
|
||||||
|
if (c.month < 25) fail(`only ${c.month} months advanced`);
|
||||||
|
if (c.population <= 0 && monthsSeen > 12) fail('population never grew');
|
||||||
|
if (monthsSeen >= 25 && c.population < 25) fail(`city collapsing: pop ${c.population} at month ${monthsSeen}`);
|
||||||
|
console.log('✅ SIM SMOKE TEST PASSED');
|
||||||
+344
@@ -0,0 +1,344 @@
|
|||||||
|
// ─── Imperium Arcanum — application core ───────────────────────────────────
|
||||||
|
import type { Building, CityState, Scenario, SpellId } from './sim/types';
|
||||||
|
import { SPELLS } from './sim/types';
|
||||||
|
import { def } from './data/buildings';
|
||||||
|
import {
|
||||||
|
createCity, tickCity, placementIssue, placeBuilding, removeBuilding, clearRubble,
|
||||||
|
dirtyCbs, bus, SPEEDS,
|
||||||
|
} from './sim/city';
|
||||||
|
import { ensureSimHooks } from './sim/hooks';
|
||||||
|
import { summonHeroImpl, resetHeroes, setCombatCityRef, spawnMonsterWave } from './sim/combat';
|
||||||
|
import { castSpellImpl, spellReady } from './sim/magic';
|
||||||
|
import { addGoodsToStores } from './sim/economy';
|
||||||
|
import { sendGiftToRome } from './sim/economy';
|
||||||
|
import { Camera } from './render/camera';
|
||||||
|
import { Renderer, drawMinimap, makeMinimap, type ViewState } from './render/renderer';
|
||||||
|
import { saveCity, loadCity } from './sim/save';
|
||||||
|
import { playSfx, unlockAudio, startMusic } from './audio/audio';
|
||||||
|
import { initHUD, refreshHUD, toast, showBuildingInfo, hideInfoPanel, minimapClickToTile } from './ui/hud';
|
||||||
|
import { openMainMenus, closeAllMenus, anyMenuOpen, victoryModal, closeHeroDialog } from './ui/menus';
|
||||||
|
|
||||||
|
export type Tool =
|
||||||
|
| null
|
||||||
|
| { kind: 'build'; key: string }
|
||||||
|
| { kind: 'erase' }
|
||||||
|
| { kind: 'spell'; id: SpellId }
|
||||||
|
| { kind: 'rally'; bldId: number };
|
||||||
|
|
||||||
|
export class App {
|
||||||
|
canvas!: HTMLCanvasElement;
|
||||||
|
ctx!: CanvasRenderingContext2D;
|
||||||
|
cam = new Camera();
|
||||||
|
renderer!: Renderer;
|
||||||
|
city!: CityState;
|
||||||
|
scenario!: Scenario;
|
||||||
|
tool: Tool = null;
|
||||||
|
hover = { x: -1, y: -1 };
|
||||||
|
view: ViewState = { overlay: null, ghost: null, selectedBld: 0, eraseHover: false, spellTarget: null, showGrid: false };
|
||||||
|
keys = new Set<string>();
|
||||||
|
painting = false;
|
||||||
|
panning = false;
|
||||||
|
lastPan = [0, 0];
|
||||||
|
mouseS: [number, number] = [-9999, -9999];
|
||||||
|
timeS = 0;
|
||||||
|
running = false;
|
||||||
|
minimapCv!: HTMLCanvasElement;
|
||||||
|
|
||||||
|
start(sc: Scenario, loaded?: { city: CityState }) {
|
||||||
|
ensureSimHooks();
|
||||||
|
this.scenario = sc;
|
||||||
|
this.city = loaded?.city ?? createCity(sc);
|
||||||
|
setCombatCityRef(this.city);
|
||||||
|
resetHeroes();
|
||||||
|
if (!this.canvas) {
|
||||||
|
this.canvas = document.getElementById('world') as HTMLCanvasElement;
|
||||||
|
this.ctx = this.canvas.getContext('2d')!;
|
||||||
|
window.addEventListener('resize', () => this.resize());
|
||||||
|
}
|
||||||
|
this.renderer = new Renderer(this.city);
|
||||||
|
this.cam.boundsW = this.city.W; this.cam.boundsH = this.city.H;
|
||||||
|
const mid = this.cam.tileToWorld(this.city.W / 2, this.city.H / 2);
|
||||||
|
this.cam.cx = mid[0]; this.cam.cy = mid[1];
|
||||||
|
this.resize();
|
||||||
|
dirtyCbs.length = 0;
|
||||||
|
dirtyCbs.push((x, y, w, h) => {
|
||||||
|
for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) this.renderer.invalidateTile(x + dx, y + dy);
|
||||||
|
});
|
||||||
|
this.renderer.invalidateAll();
|
||||||
|
|
||||||
|
initHUD(this);
|
||||||
|
this.bindInput();
|
||||||
|
this.running = true;
|
||||||
|
let lastT = performance.now();
|
||||||
|
const frame = (t: number) => {
|
||||||
|
if (!this.running) return;
|
||||||
|
const dt = Math.min(0.1, (t - lastT) / 1000);
|
||||||
|
lastT = t;
|
||||||
|
this.timeS += dt;
|
||||||
|
this.update(dt);
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() { this.running = false; }
|
||||||
|
|
||||||
|
resize() {
|
||||||
|
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||||
|
this.canvas.width = Math.floor(innerWidth * dpr);
|
||||||
|
this.canvas.height = Math.floor(innerHeight * dpr);
|
||||||
|
this.canvas.style.width = innerWidth + 'px';
|
||||||
|
this.canvas.style.height = innerHeight + 'px';
|
||||||
|
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
this.cam.setViewport(innerWidth, innerHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── per-frame ──
|
||||||
|
update(dt: number) {
|
||||||
|
// keep hover glued to the cursor even after keyboard pan / wheel zoom
|
||||||
|
{
|
||||||
|
const [mx, my] = this.mouseS;
|
||||||
|
if (mx > -999) {
|
||||||
|
const [tx, ty] = this.cam.screenToTile(mx, my);
|
||||||
|
if (tx !== this.hover.x || ty !== this.hover.y) this.hover = { x: tx, y: ty };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const k = this.keys;
|
||||||
|
const panSpeed = 560 / this.cam.zoom * dt;
|
||||||
|
if (k.has('w') || k.has('arrowup')) this.cam.cy -= panSpeed;
|
||||||
|
if (k.has('s') || k.has('arrowdown')) this.cam.cy += panSpeed;
|
||||||
|
if (k.has('a') || k.has('arrowleft')) this.cam.cx -= panSpeed;
|
||||||
|
if (k.has('d') || k.has('arrowright')) this.cam.cx += panSpeed;
|
||||||
|
this.cam.clampToBounds();
|
||||||
|
|
||||||
|
tickCity(this.city, this.scenario, dt);
|
||||||
|
|
||||||
|
if (this.city.autosavePending && this.city.month % 3 === 0) {
|
||||||
|
this.city.autosavePending = false;
|
||||||
|
saveCity(this.city, this.scenario, '_autosave');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateGhost();
|
||||||
|
this.renderer.drawFrame(this.ctx, this.cam, dt, this.view, this.timeS);
|
||||||
|
|
||||||
|
// minimap ~4Hz
|
||||||
|
this.mmAcc += dt;
|
||||||
|
if (this.mmAcc > 0.25 && this.minimapCv) {
|
||||||
|
this.mmAcc = 0;
|
||||||
|
const mctx = this.minimapCv.getContext('2d')!;
|
||||||
|
drawMinimap(mctx, this.city, this.cam, this.minimapCv.width, this.minimapCv.height);
|
||||||
|
}
|
||||||
|
refreshHUD(this, dt);
|
||||||
|
}
|
||||||
|
private mmAcc = 0;
|
||||||
|
|
||||||
|
updateGhost() {
|
||||||
|
const v = this.view;
|
||||||
|
v.eraseHover = false;
|
||||||
|
v.spellTarget = null;
|
||||||
|
if (this.hover.x < 0) { v.ghost = null; return; }
|
||||||
|
const t = this.tool;
|
||||||
|
if (!t) { v.ghost = null; return; }
|
||||||
|
if (t.kind === 'build') {
|
||||||
|
const issue = placementIssue(this.city, t.key, this.hover.x, this.hover.y);
|
||||||
|
v.ghost = { key: t.key, x: this.hover.x, y: this.hover.y, valid: issue === null, reason: issue ?? undefined, w: def(t.key).w, h: def(t.key).h };
|
||||||
|
} else if (t.kind === 'erase') {
|
||||||
|
const b = this.buildingAtHover();
|
||||||
|
v.ghost = { key: b ? b.def : 'plaza', x: this.hover.x, y: this.hover.y, valid: true, w: b ? b.w : 1, h: b ? b.h : 1 };
|
||||||
|
v.eraseHover = true;
|
||||||
|
} else if (t.kind === 'spell') {
|
||||||
|
const sd = SPELLS[t.id];
|
||||||
|
v.spellTarget = { radius: sd.radius || 3, range: sd.range, fromX: this.hover.x, fromY: this.hover.y };
|
||||||
|
v.ghost = null;
|
||||||
|
} else v.ghost = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildingAtHover(): Building | undefined {
|
||||||
|
const c = this.city, h = this.hover;
|
||||||
|
const t = (h.x >= 0 && h.y >= 0 && h.x < c.W && h.y < c.H) ? c.tiles[h.y * c.W + h.x] : undefined;
|
||||||
|
return t && t.bld ? c.buildings.get(t.bld) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTool(t: Tool) {
|
||||||
|
this.tool = t;
|
||||||
|
if (t?.kind !== 'build' || !['road', 'wall', 'plaza', 'garden'].includes(t.key)) this.painting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── actions ───────────────────────────────────────────────────────────────
|
||||||
|
private lastFailToast = 0;
|
||||||
|
applyToolAt(x: number, y: number) {
|
||||||
|
const t = this.tool;
|
||||||
|
if (!t) return;
|
||||||
|
if (t.kind === 'build') {
|
||||||
|
const r = placeBuilding(this.city, t.key, x, y);
|
||||||
|
if (!r.ok) {
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - this.lastFailToast > 1200) {
|
||||||
|
this.lastFailToast = now;
|
||||||
|
toast(r.reason === 'No funds' ? '💰 Not enough denarii!' : `⚠️ ${r.reason}`, 'warn');
|
||||||
|
}
|
||||||
|
playSfx('click');
|
||||||
|
}
|
||||||
|
} else if (t.kind === 'erase') {
|
||||||
|
const b = this.buildingAtHover();
|
||||||
|
if (b && b.ruin) { clearRubble(this.city, x, y); return; }
|
||||||
|
removeBuilding(this.city, x, y);
|
||||||
|
hideInfoPanel();
|
||||||
|
} else if (t.kind === 'spell') {
|
||||||
|
if (this.city.mana < SPELLS[t.id].mana) { toast('Not enough mana!', 'warn'); return; }
|
||||||
|
const ok = castSpellImpl(this.city, t.id, x + 0.5, y + 0.5);
|
||||||
|
if (ok) this.setTool(null);
|
||||||
|
} else if (t.kind === 'rally') {
|
||||||
|
for (const w of this.city.walkers.values()) {
|
||||||
|
if (w.homeId === t.bldId && w.unit?.side === 'city') { w.rallyX = x + 0.5; w.rallyY = y + 0.5; }
|
||||||
|
}
|
||||||
|
toast('Rally point set.', 'info');
|
||||||
|
this.setTool(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clickPrimary() {
|
||||||
|
unlockAudio(); startMusic();
|
||||||
|
if (anyMenuOpen() && document.getElementById('hud-root')?.classList.contains('hidden')) return;
|
||||||
|
if (this.tool) { this.applyToolAt(this.hover.x, this.hover.y); return; }
|
||||||
|
const b = this.buildingAtHover();
|
||||||
|
if (b) {
|
||||||
|
this.view.selectedBld = b.id;
|
||||||
|
showBuildingInfo(this, b);
|
||||||
|
playSfx('click');
|
||||||
|
} else {
|
||||||
|
this.view.selectedBld = 0;
|
||||||
|
hideInfoPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beginDrag() {
|
||||||
|
if (!this.tool) return;
|
||||||
|
if (this.tool.kind === 'build' && ['road', 'wall', 'plaza', 'garden'].includes(this.tool.key)) this.painting = true;
|
||||||
|
else if (this.tool.kind === 'erase') this.painting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
summonHero(id: 'champion' | 'huntress' | 'stormcaller') {
|
||||||
|
const ok = summonHeroImpl(this.city, id);
|
||||||
|
if (!ok) toast('Cannot summon — need gold/goods in storage, a Hall of Heroes, and no living copy.', 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
castFromBar(id: SpellId) {
|
||||||
|
unlockAudio();
|
||||||
|
if (!spellReady(this.city, id)) { toast('Spell not ready or not yet researched.', 'warn'); return; }
|
||||||
|
this.setTool({ kind: 'spell', id });
|
||||||
|
toast(`Choose a target for ${SPELLS[id].name}`, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
sendGift() {
|
||||||
|
if (!sendGiftToRome(this.city)) toast('A worthy gift costs 800 denarii.', 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTo(slot: string) { saveCity(this.city, this.scenario, slot); toast('Chronicle saved.', 'good'); playSfx('coin'); }
|
||||||
|
|
||||||
|
quitToMenu() {
|
||||||
|
saveCity(this.city, this.scenario, '_autosave');
|
||||||
|
this.stop();
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── input binding ─────────────────────────────────────────────────────────
|
||||||
|
private bindInput() {
|
||||||
|
const cv = this.canvas;
|
||||||
|
|
||||||
|
cv.addEventListener('contextmenu', e => e.preventDefault());
|
||||||
|
|
||||||
|
cv.addEventListener('mousedown', e => {
|
||||||
|
unlockAudio(); startMusic();
|
||||||
|
const [tx, ty] = this.cam.screenToTile(e.clientX, e.clientY);
|
||||||
|
this.hover = { x: tx, y: ty };
|
||||||
|
if (e.button === 0) { this.clickPrimary(); this.beginDrag(); }
|
||||||
|
else if (e.button === 2 || e.button === 1) { this.panning = true; this.lastPan = [e.clientX, e.clientY]; }
|
||||||
|
});
|
||||||
|
window.addEventListener('mouseup', () => { this.panning = false; this.painting = false; });
|
||||||
|
window.addEventListener('mousemove', e => {
|
||||||
|
this.mouseS = [e.clientX, e.clientY];
|
||||||
|
if (this.panning) {
|
||||||
|
this.cam.cx -= (e.clientX - this.lastPan[0]) / this.cam.zoom;
|
||||||
|
this.cam.cy -= (e.clientY - this.lastPan[1]) / this.cam.zoom;
|
||||||
|
this.lastPan = [e.clientX, e.clientY];
|
||||||
|
this.cam.clampToBounds();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [tx, ty] = this.cam.screenToTile(e.clientX, e.clientY);
|
||||||
|
if (tx !== this.hover.x || ty !== this.hover.y) {
|
||||||
|
this.hover = { x: tx, y: ty };
|
||||||
|
if (this.painting && tx >= 0 && ty >= 0 && tx < this.city.W && ty < this.city.H) this.applyToolAt(tx, ty);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cv.addEventListener('wheel', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.cam.zoomAt(e.clientX, e.clientY, e.deltaY < 0 ? 1.15 : 1 / 1.15);
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// minimap navigation
|
||||||
|
const mmClick = (e: MouseEvent) => {
|
||||||
|
const r = (e.target as HTMLCanvasElement).getBoundingClientRect();
|
||||||
|
const [wx, wy] = minimapClickToTile(this.city, this.cam, e.clientX - r.left, e.clientY - r.top, r.width, r.height);
|
||||||
|
this.cam.cx = wx; this.cam.cy = wy;
|
||||||
|
this.cam.clampToBounds();
|
||||||
|
};
|
||||||
|
const tryBindMinimap = () => {
|
||||||
|
const mm = document.getElementById('minimap') as HTMLCanvasElement | null;
|
||||||
|
if (mm && !mm.dataset.bound) { mm.dataset.bound = '1'; mm.addEventListener('mousedown', mmClick); }
|
||||||
|
else if (!mm) setTimeout(tryBindMinimap, 300);
|
||||||
|
};
|
||||||
|
setTimeout(tryBindMinimap, 100);
|
||||||
|
|
||||||
|
window.addEventListener('keydown', e => {
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
if ((e.target as HTMLElement).tagName === 'INPUT') return;
|
||||||
|
if (key === ' ') { e.preventDefault(); this.city.paused = !this.city.paused; }
|
||||||
|
else if (key === 'escape') {
|
||||||
|
if (this.tool) this.setTool(null);
|
||||||
|
else if (this.view.selectedBld) { this.view.selectedBld = 0; hideInfoPanel(); }
|
||||||
|
}
|
||||||
|
else if ('1234'.includes(key)) this.city.speedIdx = parseInt(key) - 1;
|
||||||
|
else this.keys.add(key);
|
||||||
|
});
|
||||||
|
window.addEventListener('keyup', e => this.keys.delete(e.key.toLowerCase()));
|
||||||
|
cv.addEventListener('mouseleave', () => { this.hover = { x: -1, y: -1 }; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── scenario launching ────────────────────────────────────────────────────
|
||||||
|
export function launchScenario(app: App | null, sc: Scenario, slot?: string) {
|
||||||
|
if (!app) return;
|
||||||
|
closeAllMenus();
|
||||||
|
const loaded = slot ? loadCity(slot) ?? undefined : undefined;
|
||||||
|
app.start(sc, loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── bootstrapping ─────────────────────────────────────────────────────────
|
||||||
|
export function bootApp() {
|
||||||
|
const app = new App();
|
||||||
|
// debug/test hook
|
||||||
|
(window as unknown as Record<string, unknown>).__IA = {
|
||||||
|
app,
|
||||||
|
step: (dt: number) => { if (app.city && app.scenario) tickCity(app.city, app.scenario, dt); },
|
||||||
|
place: (key: string, x: number, y: number) => app.city && placeBuilding(app.city, key, x, y),
|
||||||
|
give: (g: string, q: number) => app.city && addGoodsToStores(app.city, g as never, q),
|
||||||
|
summon: (id: string) => app.city ? summonHeroImpl(app.city, id as never) : false,
|
||||||
|
spawnWave: (kind: string, squads: number, size: number) => app.city && spawnMonsterWave(app.city, kind as never, squads, size),
|
||||||
|
cast: (id: string, x: number, y: number) => app.city ? castSpellImpl(app.city, id as never, x, y) : false,
|
||||||
|
grantSpell: (id: string) => app.city?.spellState.unlocked.add(id as never),
|
||||||
|
save: () => app.city && saveCity(app.city, app.scenario, '_autosave'),
|
||||||
|
loadAutosave: () => {
|
||||||
|
const loaded = loadCity('_autosave');
|
||||||
|
if (!loaded) return false;
|
||||||
|
app.stop();
|
||||||
|
app.start(loaded.scenario, loaded);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
openMainMenus(app);
|
||||||
|
|
||||||
|
bus.on('message', ({ text, icon }) => toast(`${icon} ${text}`, 'info'));
|
||||||
|
bus.on('sfx', name => playSfx(name));
|
||||||
|
bus.on('victory', ({ won, reason }) => victoryModal(app, won, reason));
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// ─── Audio: WebAudio synthesized SFX + ambient music ───────────────────────
|
||||||
|
import { Sfx } from '../render/fx';
|
||||||
|
|
||||||
|
let ctx: AudioContext | null = null;
|
||||||
|
let master: GainNode | null = null;
|
||||||
|
let musicGain: GainNode | null = null;
|
||||||
|
let musicTimer: number | null = null;
|
||||||
|
export const audioState = { enabled: true, music: true, volume: 0.7, started: false };
|
||||||
|
|
||||||
|
function ac(): AudioContext | null {
|
||||||
|
if (!audioState.enabled) return null;
|
||||||
|
if (!ctx) {
|
||||||
|
try {
|
||||||
|
ctx = new (window.AudioContext || (window as never as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
||||||
|
master = ctx.createGain();
|
||||||
|
master.gain.value = audioState.volume;
|
||||||
|
master.connect(ctx.destination);
|
||||||
|
musicGain = ctx.createGain();
|
||||||
|
musicGain.gain.value = 0.35;
|
||||||
|
musicGain.connect(master);
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
if (ctx.state === 'suspended') void ctx.resume();
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unlockAudio() { ac(); audioState.started = true; }
|
||||||
|
|
||||||
|
interface ToneOpts { freq: number; dur?: number; type?: OscillatorType; vol?: number; slide?: number; delay?: number; attack?: number; }
|
||||||
|
function tone(o: ToneOpts) {
|
||||||
|
const c = ac(); if (!c || !master) return;
|
||||||
|
const t0 = c.currentTime + (o.delay ?? 0);
|
||||||
|
const osc = c.createOscillator();
|
||||||
|
osc.type = o.type ?? 'square';
|
||||||
|
osc.frequency.setValueAtTime(o.freq, t0);
|
||||||
|
if (o.slide) osc.frequency.exponentialRampToValueAtTime(Math.max(20, o.freq + o.slide), t0 + (o.dur ?? 0.2));
|
||||||
|
const g = c.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t0);
|
||||||
|
g.gain.exponentialRampToValueAtTime(o.vol ?? 0.2, t0 + (o.attack ?? 0.01));
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + (o.dur ?? 0.2));
|
||||||
|
osc.connect(g); g.connect(master);
|
||||||
|
osc.start(t0); osc.stop(t0 + (o.dur ?? 0.2) + 0.05);
|
||||||
|
}
|
||||||
|
function noise(dur: number, vol = 0.25, filterFreq = 800, delay = 0) {
|
||||||
|
const c = ac(); if (!c || !master) return;
|
||||||
|
const t0 = c.currentTime + delay;
|
||||||
|
const len = Math.max(1, Math.floor(c.sampleRate * dur));
|
||||||
|
const buf = c.createBuffer(1, len, c.sampleRate);
|
||||||
|
const d = buf.getChannelData(0);
|
||||||
|
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len);
|
||||||
|
const src = c.createBufferSource();
|
||||||
|
src.buffer = buf;
|
||||||
|
const f = c.createBiquadFilter();
|
||||||
|
f.type = 'lowpass'; f.frequency.value = filterFreq;
|
||||||
|
const g = c.createGain(); g.gain.value = vol;
|
||||||
|
src.connect(f); f.connect(g); g.connect(master);
|
||||||
|
src.start(t0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SFX: Record<string, () => void> = {
|
||||||
|
build: () => { tone({ freq: 160, dur: 0.12, type: 'triangle', vol: 0.3 }); noise(0.08, 0.15, 500); },
|
||||||
|
demolish: () => { noise(0.25, 0.3, 400); tone({ freq: 90, dur: 0.2, type: 'sawtooth', vol: 0.18, slide: -40 }); },
|
||||||
|
coin: () => { tone({ freq: 880, dur: 0.07, type: 'square', vol: 0.12 }); tone({ freq: 1320, dur: 0.09, type: 'square', vol: 0.1, delay: 0.06 }); },
|
||||||
|
click: () => tone({ freq: 660, dur: 0.04, type: 'square', vol: 0.07 }),
|
||||||
|
clash: () => { noise(0.06, 0.22, 2600); tone({ freq: 2200, dur: 0.05, vol: 0.06, type: 'triangle', slide: -600 }); },
|
||||||
|
arrow: () => tone({ freq: 900, dur: 0.08, type: 'sawtooth', vol: 0.08, slide: -300 }),
|
||||||
|
zap: () => { tone({ freq: 1400, dur: 0.12, type: 'sawtooth', vol: 0.14, slide: -900 }); },
|
||||||
|
fire: () => noise(0.5, 0.12, 700),
|
||||||
|
crumble: () => { noise(0.4, 0.35, 300); tone({ freq: 70, dur: 0.35, type: 'sawtooth', vol: 0.2, slide: -30 }); },
|
||||||
|
smash: () => noise(0.12, 0.28, 900),
|
||||||
|
horn: () => { tone({ freq: 196, dur: 0.5, type: 'sawtooth', vol: 0.16 }); tone({ freq: 262, dur: 0.55, type: 'sawtooth', vol: 0.13, delay: 0.18 }); },
|
||||||
|
monster: () => { tone({ freq: 90, dur: 0.7, type: 'sawtooth', vol: 0.24, slide: -45 }); noise(0.6, 0.14, 350, 0.1); },
|
||||||
|
heal: () => { [523, 659, 784].forEach((f, i) => tone({ freq: f, dur: 0.25, type: 'sine', vol: 0.12, delay: i * 0.09 })); },
|
||||||
|
fanfare: () => { [392, 523, 659, 784].forEach((f, i) => tone({ freq: f, dur: 0.32, type: 'triangle', vol: 0.16, delay: i * 0.13 })); },
|
||||||
|
evolve: () => { tone({ freq: 523, dur: 0.1, type: 'sine', vol: 0.12 }); tone({ freq: 784, dur: 0.14, type: 'sine', vol: 0.12, delay: 0.09 }); },
|
||||||
|
firestorm: () => { noise(0.7, 0.4, 1200); tone({ freq: 60, dur: 0.6, type: 'sawtooth', vol: 0.26, slide: -25 }); },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── ambient music: slow lyre-ish arpeggio over drone ──
|
||||||
|
const SCALE = [196.0, 233.1, 261.6, 293.7, 349.2, 392.0, 466.2]; // G minor pentatonic-ish
|
||||||
|
let step = 0;
|
||||||
|
function musicStep() {
|
||||||
|
const c = ac(); if (!c || !musicGain) return;
|
||||||
|
const root = SCALE[step % SCALE.length];
|
||||||
|
// pluck
|
||||||
|
const t0 = c.currentTime;
|
||||||
|
for (const mult of [1, 2]) {
|
||||||
|
const osc = c.createOscillator();
|
||||||
|
osc.type = mult === 1 ? 'triangle' : 'sine';
|
||||||
|
osc.frequency.value = root * mult;
|
||||||
|
const g = c.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t0);
|
||||||
|
g.gain.exponentialRampToValueAtTime(mult === 1 ? 0.16 : 0.05, t0 + 0.02);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 1.4);
|
||||||
|
osc.connect(g); g.connect(musicGain);
|
||||||
|
osc.start(t0); osc.stop(t0 + 1.5);
|
||||||
|
}
|
||||||
|
if (step % 8 === 0) { // low drone swell
|
||||||
|
const osc = c.createOscillator();
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.value = SCALE[(step >> 3) % 3] / 2;
|
||||||
|
const g = c.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t0);
|
||||||
|
g.gain.linearRampToValueAtTime(0.08, t0 + 1.2);
|
||||||
|
g.gain.linearRampToValueAtTime(0.0001, t0 + 4.5);
|
||||||
|
osc.connect(g); g.connect(musicGain);
|
||||||
|
osc.start(t0); osc.stop(t0 + 4.7);
|
||||||
|
}
|
||||||
|
step++;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startMusic() {
|
||||||
|
if (musicTimer !== null || !audioState.music) return;
|
||||||
|
musicTimer = window.setInterval(() => { if (audioState.music && audioState.enabled && document.visibilityState === 'visible') musicStep(); }, 1400);
|
||||||
|
}
|
||||||
|
export function stopMusic() { if (musicTimer !== null) { clearInterval(musicTimer); musicTimer = null; } }
|
||||||
|
|
||||||
|
export function playSfx(name: string) {
|
||||||
|
if (!audioState.enabled) return;
|
||||||
|
SFX[name]?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// wire the global hook used by renderer/sim
|
||||||
|
Sfx.play = (name: string) => playSfx(name);
|
||||||
|
|
||||||
|
export function setVolume(v: number) {
|
||||||
|
audioState.volume = v;
|
||||||
|
if (master) master.gain.value = v;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// ─── Pathfinding: BFS flood + A* on tile grids ─────────────────────────────
|
||||||
|
export interface Pt { x: number; y: number }
|
||||||
|
|
||||||
|
/** Reconstruct-free BFS returning path of tiles from start to goal (inclusive). */
|
||||||
|
export function bfsPath(
|
||||||
|
W: number, H: number,
|
||||||
|
start: Pt, goal: Pt,
|
||||||
|
passable: (x: number, y: number) => boolean,
|
||||||
|
): Pt[] | null {
|
||||||
|
if (start.x === goal.x && start.y === goal.y) return [{ ...start }];
|
||||||
|
const prev = new Int32Array(W * H).fill(-1);
|
||||||
|
const seen = new Uint8Array(W * H);
|
||||||
|
const q: number[] = [start.y * W + start.x];
|
||||||
|
seen[q[0]] = 1;
|
||||||
|
const gi = goal.y * W + goal.x;
|
||||||
|
let head = 0;
|
||||||
|
while (head < q.length) {
|
||||||
|
const cur = q[head++];
|
||||||
|
if (cur === gi) break;
|
||||||
|
const cx = cur % W, cy = (cur / W) | 0;
|
||||||
|
// neighbors in iso-pleasing order
|
||||||
|
const nbs = [[cx+1,cy],[cx-1,cy],[cx,cy+1],[cx,cy-1]];
|
||||||
|
for (const [nx, ny] of nbs) {
|
||||||
|
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
|
||||||
|
const ni = ny * W + nx;
|
||||||
|
if (seen[ni] || !passable(nx, ny)) continue;
|
||||||
|
seen[ni] = 1; prev[ni] = cur; q.push(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!seen[gi]) return null;
|
||||||
|
const out: Pt[] = [];
|
||||||
|
let cur = gi;
|
||||||
|
while (cur !== -1) { out.push({ x: cur % W, y: (cur / W) | 0 }); if (cur === start.y * W + start.x) break; cur = prev[cur]; }
|
||||||
|
out.reverse();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SQRT2 = Math.SQRT2;
|
||||||
|
|
||||||
|
/** A* allowing diagonal movement; heuristic octile. */
|
||||||
|
export function astarPath(
|
||||||
|
W: number, H: number,
|
||||||
|
start: Pt, goal: Pt,
|
||||||
|
passable: (x: number, y: number) => boolean,
|
||||||
|
maxNodes = 6000,
|
||||||
|
): Pt[] | null {
|
||||||
|
const si = start.y * W + start.x, gi = goal.y * W + goal.x;
|
||||||
|
if (si === gi) return [{ ...start }];
|
||||||
|
const open: number[] = [si];
|
||||||
|
const gScore = new Float32Array(W * H).fill(Infinity);
|
||||||
|
const fScore = new Float32Array(W * H).fill(Infinity);
|
||||||
|
const prev = new Int32Array(W * H).fill(-1);
|
||||||
|
const closed = new Uint8Array(W * H);
|
||||||
|
const gx0 = goal.x, gy0 = goal.y;
|
||||||
|
gScore[si] = 0;
|
||||||
|
fScore[si] = Math.hypot(gx0 - start.x, gy0 - start.y);
|
||||||
|
let nodes = 0;
|
||||||
|
while (open.length && nodes++ < maxNodes) {
|
||||||
|
// pick lowest f (linear scan fine at this scale)
|
||||||
|
let bi = 0, bf = Infinity;
|
||||||
|
for (let i = 0; i < open.length; i++) { const f = fScore[open[i]]; if (f < bf) { bf = f; bi = i; } }
|
||||||
|
const cur = open.splice(bi, 1)[0];
|
||||||
|
if (cur === gi) return rebuild(prev, gi, start);
|
||||||
|
closed[cur] = 1;
|
||||||
|
const cx = cur % W, cy = (cur / W) | 0;
|
||||||
|
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
if (!dx && !dy) continue;
|
||||||
|
const nx = cx + dx, ny = cy + dy;
|
||||||
|
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
|
||||||
|
const ni = ny * W + nx;
|
||||||
|
if (closed[ni] || !passable(nx, ny)) continue;
|
||||||
|
if (dx && dy) { // no corner cutting
|
||||||
|
if (!passable(cx + dx, cy) || !passable(cx, cy + dy)) continue;
|
||||||
|
}
|
||||||
|
const step = dx && dy ? SQRT2 : 1;
|
||||||
|
const tg = gScore[cur] + step;
|
||||||
|
if (tg < gScore[ni]) {
|
||||||
|
prev[ni] = cur; gScore[ni] = tg;
|
||||||
|
fScore[ni] = tg + Math.hypot(gx0 - nx, gy0 - ny) * 1.001;
|
||||||
|
if (!open.includes(ni)) open.push(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
function rebuild(prevArr: Int32Array, endI: number, s: Pt): Pt[] {
|
||||||
|
const out: Pt[] = [];
|
||||||
|
let c = endI;
|
||||||
|
while (c !== -1 && out.length < 5000) { out.push({ x: c % W, y: (c / W) | 0 }); if (c === s.y * W + s.x) break; c = prevArr[c]; }
|
||||||
|
out.reverse();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/** Mulberry32 seeded PRNG — deterministic per mission. */
|
||||||
|
export class RNG {
|
||||||
|
private s: number;
|
||||||
|
constructor(seed: number) { this.s = seed >>> 0; }
|
||||||
|
next(): number { // [0,1)
|
||||||
|
let t = (this.s += 0x6d2b79f5);
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
range(a: number, b: number): number { return a + this.next() * (b - a); }
|
||||||
|
int(a: number, b: number): number { return Math.floor(this.range(a, b + 1)); } // inclusive
|
||||||
|
pick<T>(arr: readonly T[]): T { return arr[Math.floor(this.next() * arr.length)]; }
|
||||||
|
chance(p: number): boolean { return this.next() < p; }
|
||||||
|
shuffle<T>(arr: T[]): T[] {
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(this.next() * (i + 1));
|
||||||
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clamp = (v: number, lo: number, hi: number) => v < lo ? lo : v > hi ? hi : v;
|
||||||
|
export const lerp = (a: number, b: number, t: number) => a + (b - a) * clamp(t, 0, 1);
|
||||||
|
export const dist2 = (ax: number, ay: number, bx: number, by: number) => { const dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; };
|
||||||
|
export const chebyshev = (ax: number, ay: number, bx: number, by: number) => Math.max(Math.abs(ax - bx), Math.abs(ay - by));
|
||||||
|
export const fmt = new Intl.NumberFormat('en-US');
|
||||||
|
export function roman(n: number): string {
|
||||||
|
if (n <= 0) return '0';
|
||||||
|
const table: [number, string][] = [[1000,'M'],[900,'CM'],[500,'D'],[400,'CD'],[100,'C'],[90,'XC'],[50,'L'],[40,'XL'],[10,'X'],[9,'IX'],[5,'V'],[4,'IV'],[1,'I']];
|
||||||
|
let out = '';
|
||||||
|
for (const [v, s] of table) while (n >= v) { out += s; n -= v; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS = ['Ianuarius','Februarius','Martius','Aprilis','Maius','Iunius','Quintilis','Sextilis','September','October','November','December'];
|
||||||
|
export function monthName(m: number) { return MONTHS[((m % 12) + 12) % 12]; }
|
||||||
|
|
||||||
|
/** Tiny typed pub/sub. */
|
||||||
|
export class Emitter<E extends Record<string, any>> {
|
||||||
|
private map = new Map<keyof E, Set<(p: never) => void>>();
|
||||||
|
on<K extends keyof E>(k: K, fn: (p: E[K]) => void): () => void {
|
||||||
|
let s = this.map.get(k);
|
||||||
|
if (!s) { s = new Set(); this.map.set(k, s); }
|
||||||
|
s.add(fn as (p: never) => void);
|
||||||
|
return () => s!.delete(fn as (p: never) => void);
|
||||||
|
}
|
||||||
|
emit<K extends keyof E>(k: K, p: E[K]) {
|
||||||
|
const s = this.map.get(k);
|
||||||
|
if (s) for (const fn of [...s]) fn(p as never);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// ─── Building definitions ──────────────────────────────────────────────────
|
||||||
|
import type { BDef, BuildCategory } from '../sim/types';
|
||||||
|
|
||||||
|
const D = (d: Partial<BDef> & { key: string; name: string; cat: BuildCategory; w: number; h: number; cost: number }): BDef => ({
|
||||||
|
desirability: 0, desRange: 3, workers: 0, fireRisk: 0, collapseRisk: 0,
|
||||||
|
roadAccess: true, blurb: '', spriteKey: d.key, ...d,
|
||||||
|
} as BDef);
|
||||||
|
|
||||||
|
export const BUILD_ORDER: string[] = [
|
||||||
|
// housing
|
||||||
|
'house', 'road', 'plaza', 'garden', 'statue',
|
||||||
|
// food
|
||||||
|
'farmWheat', 'orchard', 'fishingWharf', 'granary', 'market',
|
||||||
|
// industry
|
||||||
|
'clayPit', 'timberCamp', 'ironMine', 'crystalMine', 'potteryWksp', 'furnitureWksp', 'oilPress', 'winery', 'weaponSmith',
|
||||||
|
'warehouse',
|
||||||
|
// civic
|
||||||
|
'well', 'fountain', 'prefecture', 'engineerPost', 'forum', 'senate',
|
||||||
|
// religion
|
||||||
|
'templeJupiter', 'templeCeres', 'templeNeptune', 'templeMars', 'oracle',
|
||||||
|
// entertainment / education / health
|
||||||
|
'theater', 'amphitheater', 'colosseum', 'school', 'academy', 'library', 'clinic', 'hospital',
|
||||||
|
// military
|
||||||
|
'wall', 'tower', 'gatehouse', 'barracks', 'fort',
|
||||||
|
// magic
|
||||||
|
'leyPylon', 'mageTower', 'arcaneLibrary', 'golemFoundry',
|
||||||
|
// heroes
|
||||||
|
'heroHall',
|
||||||
|
// trade
|
||||||
|
'tradingPost',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFS: Record<string, BDef> = Object.fromEntries(([
|
||||||
|
D({ key:'house', name:'House', cat:'housing', w:1, h:1, cost:8, desirability:-1, fireRisk:0.05, collapseRisk:0.02, roadAccess:true, housing:{level:0}, blurb:'Home for citizens. Evolves with water, food and services.' }),
|
||||||
|
D({ key:'road', name:'Road', cat:'housing', w:1, h:1, cost:2, desirability:0, roadAccess:false, blurb:'Citizens travel on roads. Most buildings need road access.', hotkey:'r' }),
|
||||||
|
D({ key:'plaza', name:'Plaza', cat:'decor', w:1, h:1, cost:6, desirability:2, desRange:4, roadAccess:false, blurb:'Paved square. Beautifies the city; walkers enjoy it.', hotkey:'p' }),
|
||||||
|
D({ key:'garden', name:'Gardens', cat:'decor', w:1, h:1, cost:12, desirability:4, desRange:5, roadAccess:false, blurb:'Pleasure gardens raise nearby desirability.', hotkey:'g' }),
|
||||||
|
D({ key:'statue', name:'Statue', cat:'decor', w:1, h:1, cost:40, desirability:7, desRange:6, roadAccess:false, blurb:'A marble statue honoring the gods. Strong local beauty.', hotkey:'s' }),
|
||||||
|
|
||||||
|
D({ key:'farmWheat', name:'Wheat Farm', cat:'food', w:3, h:3, cost:40, desirability:-2, workers:7, fireRisk:0.04, collapseRisk:0.03, produces:{good:'wheat',rate:100}, storage:{goods:['wheat'],capacity:400}, blurb:'Grows wheat on fertile land. Carts deliver to granaries.' , hotkey:'w'}),
|
||||||
|
D({ key:'orchard', name:'Fruit Orchard', cat:'food', w:3, h:3, cost:44, desirability:0, workers:8, fireRisk:0.03, collapseRisk:0.03, produces:{good:'fruit',rate:100}, storage:{goods:['fruit'],capacity:400}, blurb:'Grows fruit. Second food type speeds housing evolution.' }),
|
||||||
|
D({ key:'fishingWharf', name:'Fishing Wharf', cat:'food', w:2, h:2, cost:50, desirability:-2, workers:8, produces:{good:'meat',rate:100}, storage:{goods:['meat'],capacity:400}, blurb:'Must touch coastline. Harvests the sea as meat.' }),
|
||||||
|
D({ key:'granary', name:'Granary', cat:'storage', w:3, h:3, cost:60, desirability:-3, workers:4, storage:{goods:['wheat','fruit','meat'],capacity:800}, blurb:'Stores raw food. Markets send buyers here.' }),
|
||||||
|
D({ key:'market', name:'Market', cat:'food', w:2, h:2, cost:36, desirability:-1, workers:4, fireRisk:0.06, service:['market'], walker:{kind:'peddler',count:2,period:20,range:24}, storage:{goods:['wheat','fruit','meat','pottery','furniture','oil','wine'],capacity:600}, blurb:'Peddlers distribute food & goods to houses.' , hotkey:'m'}),
|
||||||
|
|
||||||
|
D({ key:'clayPit', name:'Clay Pit', cat:'industry', w:2, h:2, cost:36, desirability:-4, workers:8, produces:{good:'clay',rate:100}, storage:{goods:['clay'],capacity:400}, blurb:'Digs clay from deposits. Needs to sit on clay soil.' }),
|
||||||
|
D({ key:'timberCamp', name:'Timber Camp', cat:'industry', w:2, h:2, cost:36, desirability:-4, workers:8, produces:{good:'timber',rate:100}, storage:{goods:['timber'],capacity:400}, blurb:'Fells trees. Should be placed near woodland.' }),
|
||||||
|
D({ key:'ironMine', name:'Iron Mine', cat:'industry', w:2, h:2, cost:70, desirability:-5, workers:10, produces:{good:'iron',rate:100}, storage:{goods:['iron'],capacity:400}, blurb:'Extracts iron ore from rocky ground.' }),
|
||||||
|
D({ key:'crystalMine', name:'Crystal Mine', cat:'magic', w:2, h:2, cost:120, desirability:-3, workers:10, produces:{good:'crystal',rate:100}, storage:{goods:['crystal'],capacity:300}, blurb:'Harvests raw mana-crystal from glowing seams.' }),
|
||||||
|
D({ key:'potteryWksp', name:'Pottery Works', cat:'industry', w:2, h:2, cost:52, desirability:-4, workers:8, fireRisk:0.08, consumes:{clay:100}, produces:{good:'pottery',rate:100}, storage:{goods:['clay','pottery'],capacity:500}, blurb:'Turns clay into pottery — a mark of civilized homes.' }),
|
||||||
|
D({ key:'furnitureWksp', name:'Furniture Works', cat:'industry', w:2, h:2, cost:56, desirability:-4, workers:8, fireRisk:0.07, consumes:{timber:100}, produces:{good:'furniture',rate:100}, storage:{goods:['timber','furniture'],capacity:500}, blurb:'Crafts fine furniture from timber.' }),
|
||||||
|
D({ key:'oilPress', name:'Oil Press', cat:'industry', w:2, h:2, cost:58, desirability:-4, workers:8, consumes:{olives:100}, produces:{good:'oil',rate:100}, storage:{goods:['olives','oil'],capacity:500}, blurb:'Presses olives into lamp oil for villas.' }),
|
||||||
|
D({ key:'winery', name:'Winery', cat:'industry', w:2, h:2, cost:62, desirability:-3, workers:8, consumes:{grapes:100}, produces:{good:'wine',rate:100}, storage:{goods:['grapes','wine'],capacity:500}, blurb:'Ferments grapes into wine fit for nobility.' }),
|
||||||
|
D({ key:'weaponSmith', name:'Weapon Smith', cat:'industry', w:2, h:2, cost:90, desirability:-4, workers:8, fireRisk:0.09, consumes:{iron:80,timber:40}, produces:{good:'weapons',rate:100}, storage:{goods:['iron','timber','weapons'],capacity:500}, blurb:'Forges weapons for legions, heroes and towers.' }),
|
||||||
|
D({ key:'warehouse', name:'Warehouse', cat:'storage', w:3, h:3, cost:48, desirability:-3, workers:4, storage:{goods:[],capacity:2400}, blurb:'General store for materials and finished goods.' , hotkey:'v'}),
|
||||||
|
|
||||||
|
D({ key:'well', name:'Well', cat:'civic', w:1, h:1, cost:14, desirability:0, workers:0, service:['water'], walker:{kind:'laborSeeker',count:0,period:0}, roadAccess:false, blurb:'Basic water for tents and shacks. No workers needed.' }),
|
||||||
|
D({ key:'fountain', name:'Fountain', cat:'civic', w:2, h:2, cost:30, desirability:3, desRange:4, workers:4, service:['water'], roadAccess:true, blurb:'Aqueduct-fed fountain. Required for houses above shack.' }),
|
||||||
|
D({ key:'prefecture', name:'Prefecture', cat:'civic', w:1, h:1, cost:24, desirability:-2, workers:5, service:['prefect'], walker:{kind:'prefect',count:1,period:18,range:24}, fireRisk:0.01, blurb:'Prefects patrol streets, dousing fires and fighting crime.' }),
|
||||||
|
D({ key:'engineerPost', name:"Engineer's Post", cat:'civic', w:1, h:1, cost:24, desirability:-2, workers:5, service:['engineer'], walker:{kind:'engineer',count:1,period:20,range:24}, blurb:'Engineers reinforce buildings against collapse.' }),
|
||||||
|
D({ key:'forum', name:'Forum', cat:'civic', w:2, h:2, cost:38, desirability:2, desRange:4, workers:6, service:['tax'], walker:{kind:'taxman',count:1,period:60,range:28}, blurb:'Tax collectors gather denarii from houses.' }),
|
||||||
|
D({ key:'senate', name:'Senate', cat:'civic', w:3, h:3, cost:150, desirability:5, desRange:8, workers:20, service:['tax'], walker:{kind:'taxman',count:2,period:55,range:34}, unique:true, blurb:'The heart of government. Broad tax collection + ratings bonus.' }),
|
||||||
|
|
||||||
|
D({ key:'templeJupiter', name:'Temple of Jupiter', cat:'religion', w:2, h:2, cost:60, desirability:4, desRange:6, workers:6, service:['religion'], walker:{kind:'priest',count:1,period:30,range:26}, manaGen:2, blurb:'King of gods. His priests also channel small amounts of mana.' }),
|
||||||
|
D({ key:'templeCeres', name:'Temple of Ceres', cat:'religion', w:2, h:2, cost:55, desirability:3, desRange:6, workers:6, service:['religion'], walker:{kind:'priest',count:1,period:30,range:26}, manaGen:2, blurb:'Goddess of harvest. Blesses fertile fields nearby.' }),
|
||||||
|
D({ key:'templeNeptune', name:'Temple of Neptune', cat:'religion', w:2, h:2, cost:55, desirability:3, desRange:6, workers:6, service:['religion'], walker:{kind:'priest',count:1,period:30,range:26}, manaGen:2, blurb:'Lord of the sea. Calms storms and floods.' }),
|
||||||
|
D({ key:'templeMars', name:'Temple of Mars', cat:'religion', w:2, h:2, cost:65, desirability:3, desRange:6, workers:6, service:['religion'], walker:{kind:'priest',count:1,period:30,range:26}, manaGen:1, blurb:'God of war. Strengthens the hearts of defenders.' }),
|
||||||
|
D({ key:'oracle', name:'Oracle', cat:'religion', w:2, h:2, cost:180, desirability:8, desRange:9, workers:10, service:['religion'], walker:{kind:'priest',count:2,period:26,range:32}, manaGen:8, unique:true, blurb:'The Oracle peers beyond the veil, generating rich mana.' }),
|
||||||
|
|
||||||
|
D({ key:'theater', name:'Theater', cat:'entertainment', w:2, h:2, cost:48, desirability:2, desRange:5, workers:8, service:['entertainment'], walker:{kind:'actor',count:1,period:28,range:26}, blurb:'Playwrights amuse the plebs with comedies.' }),
|
||||||
|
D({ key:'amphitheater', name:'Amphitheater', cat:'entertainment', w:3, h:3, cost:110, desirability:4, desRange:7, workers:12, service:['entertainment'], walker:{kind:'actor',count:2,period:24,range:32}, blurb:'Grand spectacles for a growing city.' }),
|
||||||
|
D({ key:'colosseum', name:'Colosseum', cat:'entertainment', w:3, h:3, cost:260, desirability:6, desRange:10, workers:24, service:['entertainment'], walker:{kind:'actor',count:3,period:20,range:40}, unique:true, blurb:'Legendary arena. Gladiators thrill the whole city.' }),
|
||||||
|
|
||||||
|
D({ key:'school', name:'School', cat:'education', w:2, h:2, cost:42, desirability:1, desRange:4, workers:9, service:['education'], walker:{kind:'teacher',count:1,period:30,range:26}, blurb:'Children learn letters and numbers.' }),
|
||||||
|
D({ key:'academy', name:'Academy', cat:'education', w:3, h:3, cost:130, desirability:3, desRange:7, workers:16, service:['education'], walker:{kind:'teacher',count:2,period:25,range:34}, blurb:'Higher learning for future senators.' }),
|
||||||
|
D({ key:'library', name:'Great Library', cat:'education', w:3, h:3, cost:200, desirability:6, desRange:9, workers:14, service:['education'], walker:{kind:'teacher',count:2,period:22,range:40}, manaGen:5, research:['golem','shield'], unique:true, blurb:'Scribes decode ancient arcana. Unlocks advanced spells.' }),
|
||||||
|
D({ key:'clinic', name:'Clinic', cat:'health', w:2, h:2, cost:40, desirability:0, desRange:4, workers:8, service:['health'], walker:{kind:'doctor',count:1,period:30,range:26}, blurb:'Physicians treat fevers and battle wounds.' }),
|
||||||
|
D({ key:'hospital', name:'Hospital', cat:'health', w:3, h:3, cost:140, desirability:2, desRange:6, workers:15, service:['health'], walker:{kind:'doctor',count:2,period:25,range:34}, blurb:'Advanced care improves health and longevity.' }),
|
||||||
|
|
||||||
|
D({ key:'wall', name:'City Wall', cat:'military', w:1, h:1, cost:8, desirability:0, roadAccess:false, fireRisk:0, collapseRisk:0.01, blurb:'Stonework barrier. Enemies must break through or go around.', hotkey:'q' }),
|
||||||
|
D({ key:'tower', name:'Watch Tower', cat:'military', w:2, h:2, cost:70, desirability:0, workers:6,
|
||||||
|
tower:{hp:280,dmg:14,speed:0,ranged:true,range:7,atkCd:1.1}, blurb:'Rains arrows on raiders within range. Needs weapons? No — needs men.' }),
|
||||||
|
D({ key:'gatehouse', name:'Gatehouse', cat:'military', w:2, h:2, cost:50, desirability:0, roadAccess:true, collapseRisk:0.01, blurb:'A gate in your wall that citizens may pass, but foes cannot.' }),
|
||||||
|
D({ key:'barracks', name:'Barracks', cat:'military', w:2, h:2, cost:80, desirability:-3, workers:10, consumes:{weapons:50}, blurb:'Trains recruits into legions using weapons from stores.' }),
|
||||||
|
D({ key:'fort', name:'Fortress', cat:'military', w:3, h:3, cost:220, desirability:-6, workers:16, fort:true, blurb:'Houses a legion of 16. The steel backbone of defense.' }),
|
||||||
|
|
||||||
|
D({ key:'leyPylon', name:'Ley Pylon', cat:'magic', w:1, h:1, cost:60, desirability:1, desRange:3, workers:4, manaGen:10, roadAccess:false, blurb:'Must be built ON a crystal seam. Siphons raw mana from the ley line.' }),
|
||||||
|
D({ key:'mageTower', name:'Mage Tower', cat:'magic', w:2, h:2, cost:160, desirability:2, desRange:6, workers:8,
|
||||||
|
mageTower:{hp:220,dmg:26,speed:0,ranged:true,range:8,atkCd:1.6}, manaGen:6, blurb:'Battle-mages hurl arcane bolts at enemies in range.' }),
|
||||||
|
D({ key:'arcaneLibrary', name:'Arcanum', cat:'magic', w:3, h:3, cost:240, desirability:5, desRange:8, workers:12, manaGen:12, research:['firestorm','golem','shield','insight'], unique:true, blurb:'Master mages research the deepest spells.' }),
|
||||||
|
D({ key:'golemFoundry', name:'Golem Foundry', cat:'magic', w:2, h:2, cost:190, desirability:-2, workers:10, consumes:{iron:40}, blurb:'Constructs stone golems for permanent guard duty (costs iron + mana).' }),
|
||||||
|
|
||||||
|
D({ key:'heroHall', name:'Hall of Heroes', cat:'heroes', w:3, h:3, cost:320, desirability:6, desRange:8, workers:12, heroHall:true, unique:true, blurb:'Summon legendary heroes to defend the city and inspire industry.' }),
|
||||||
|
D({ key:'tradingPost', name:'Trading Post', cat:'trade', w:3, h:3, cost:70, desirability:-2, workers:8, blurb:'Caravans from foreign cities trade goods here each season.' }),
|
||||||
|
].map(d => [d.key, d])));
|
||||||
|
|
||||||
|
export function def(key: string): BDef { return DEFS[key]; }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// ─── Heroes roster ──────────────────────────────────────────────────────────
|
||||||
|
export { HEROES } from '../sim/types';
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// ─── Housing evolution tiers (Caesar-3 style) ──────────────────────────────
|
||||||
|
import type { ServiceType } from '../sim/types';
|
||||||
|
|
||||||
|
export interface HouseTier {
|
||||||
|
name: string; maxResidents: number;
|
||||||
|
needs: {
|
||||||
|
water: 0 | 1 | 2; // 0 none, 1 well, 2 fountain
|
||||||
|
foodTypes: number; // distinct food types required recently
|
||||||
|
goods?: { pottery?: number; furniture?: number; oil?: number; wine?: number };
|
||||||
|
services?: ServiceType[]; // must be currently covered
|
||||||
|
desirability: number; // min tile desirability
|
||||||
|
entertainmentPts?: number; // sum of venues covering
|
||||||
|
};
|
||||||
|
devolveMonthsGrace: number; // months of missing need before devolve
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HOUSE_TIERS: HouseTier[] = [
|
||||||
|
{ name:'Small Tent', maxResidents:5, needs:{ water:0, foodTypes:0, desirability:-99 }, devolveMonthsGrace:0 },
|
||||||
|
{ name:'Large Tent', maxResidents:9, needs:{ water:1, foodTypes:0, desirability:-8 }, devolveMonthsGrace:2 },
|
||||||
|
{ name:'Small Shack', maxResidents:13, needs:{ water:1, foodTypes:1, desirability:-4 }, devolveMonthsGrace:2 },
|
||||||
|
{ name:'Large Shack', maxResidents:18, needs:{ water:1, foodTypes:1, desirability:-2 }, devolveMonthsGrace:3 },
|
||||||
|
{ name:'Small Hovel', maxResidents:24, needs:{ water:1, foodTypes:1, services:['religion'], desirability:0 }, devolveMonthsGrace:3 },
|
||||||
|
{ name:'Large Hovel', maxResidents:30, needs:{ water:1, foodTypes:2, services:['religion','entertainment'], desirability:2 }, devolveMonthsGrace:3 },
|
||||||
|
{ name:'Small Casa', maxResidents:40, needs:{ water:2, foodTypes:2, services:['religion','entertainment','education'], desirability:4 }, devolveMonthsGrace:4 },
|
||||||
|
{ name:'Large Casa', maxResidents:52, needs:{ water:2, foodTypes:2, services:['religion','entertainment','education','health'], desirability:6,
|
||||||
|
goods:{pottery:1} }, devolveMonthsGrace:4 },
|
||||||
|
{ name:'Small Villa', maxResidents:70, needs:{ water:2, foodTypes:3, services:['religion','entertainment','education','health'], desirability:9,
|
||||||
|
goods:{pottery:1,furniture:1,oil:1} }, devolveMonthsGrace:5 },
|
||||||
|
{ name:'Large Villa', maxResidents:95, needs:{ water:2, foodTypes:3, services:['religion','entertainment','education','health'], desirability:12,
|
||||||
|
goods:{pottery:1,furniture:1,oil:1,wine:1}, entertainmentPts:2 }, devolveMonthsGrace:6 },
|
||||||
|
{ name:'Estate', maxResidents:130,needs:{ water:2, foodTypes:3, services:['religion','entertainment','education','health'], desirability:16,
|
||||||
|
goods:{pottery:2,furniture:2,oil:2,wine:2}, entertainmentPts:3 }, devolveMonthsGrace:6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const HOUSE_SPRITE_BY_LEVEL = [
|
||||||
|
'house_l0a','house_l0b','house_l1a','house_l1b','house_l2a','house_l2b',
|
||||||
|
'house_l3a','house_l3b','house_l4a','house_l4b','house_l5a',
|
||||||
|
];
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// ─── Campaign scenarios & sandbox config ───────────────────────────────────
|
||||||
|
import type { Scenario } from '../sim/types';
|
||||||
|
|
||||||
|
export const CAMPAIGN: Scenario[] = [
|
||||||
|
{
|
||||||
|
id: 'brundisium', name: 'I · Brundisium', subtitle: 'A farming village on the Aurelia road',
|
||||||
|
brief: 'Governor, the Senate grants you a modest purse to found a farming village. Raise 150 citizens and keep them fed. Bandits are rumored — train a prefect or two.',
|
||||||
|
mapSeed: 1101, mapW: 40, mapH: 40, funds: 3000,
|
||||||
|
startRes: { wheat: 350 },
|
||||||
|
goals: [ { kind:'population', amount:150 }, { kind:'surviveYears', amount:4 } ],
|
||||||
|
invasions: [], monsterEvents: [],
|
||||||
|
openTrades: ['Capua'], allowSpellsFromStart: false,
|
||||||
|
hints: [
|
||||||
|
'Drag RIGHT mouse (or WASD/arrows) to scroll. Mouse wheel zooms.',
|
||||||
|
'Place buildings ANYWHERE — then connect each one with a ROAD to put it to work.',
|
||||||
|
'Houses need a WELL nearby, then food from a MARKET.',
|
||||||
|
'Farms must be built on dark fertile soil; watch the fertility overlay.',
|
||||||
|
'Keep unemployment low but never at zero — industry needs hands.',
|
||||||
|
],
|
||||||
|
reward: 'The Senate is pleased. Your career begins.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'valley', name: 'II · Vale of Ceres', subtitle: 'Breadbasket of the province',
|
||||||
|
brief: 'Ceres herself blessed this valley. Feed the growing province: 400 souls, with culture taking root — build temples and schools.',
|
||||||
|
mapSeed: 2202, mapW: 48, mapH: 48, funds: 5000,
|
||||||
|
startRes: { wheat: 200 },
|
||||||
|
goals: [ { kind:'population', amount:400 }, { kind:'culture', amount:25 }, { kind:'prosperity', amount:20 } ],
|
||||||
|
invasions: [ { atYearMonth:[2,6], kind:'barbarian', squads:1, size:8, warnMonths:3 } ],
|
||||||
|
monsterEvents: [],
|
||||||
|
openTrades: ['Capua','Ravenna'],
|
||||||
|
allowSpellsFromStart: false,
|
||||||
|
hints: [ 'Temples raise Culture and please the gods.', 'A FORUM lets you collect taxes from housing.', 'Barbarians come by the northwest pass — walls and a fort will answer them.' ],
|
||||||
|
reward: 'News of your harvests reaches Rome itself.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leyline', name: 'III · The Ley Line', subtitle: 'Where crystal seams glow beneath the earth',
|
||||||
|
brief: 'Prospectors found glowing crystal seams. Build Ley Pylons atop them, awaken mana, and learn the first spells. A Hydra nests in the eastern marsh.',
|
||||||
|
mapSeed: 3303, mapW: 48, mapH: 48, funds: 8000,
|
||||||
|
startRes: { timber: 100, clay: 100 },
|
||||||
|
goals: [ { kind:'population', amount:500 }, { kind:'slayMonsters', amount:1 }, { kind:'treasury', amount:6000 } ],
|
||||||
|
invasions: [],
|
||||||
|
monsterEvents: [ { atYearMonth:[1,3], kind:'hydra', squads:1, size:1, warnMonths:3 } ],
|
||||||
|
openTrades: ['Ravenna','Syracuse'],
|
||||||
|
allowSpellsFromStart: true,
|
||||||
|
hints: [ 'Build an ARCANUM or GREAT LIBRARY to research spells.', 'LEY PYLONS only work on glowing crystal seams.', 'A HALL OF HEROES lets you summon a hero to slay the Hydra.' ],
|
||||||
|
reward: 'Magic flows for Rome. The age of legends dawns.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'frontier', name: 'IV · Iron Frontier', subtitle: 'Legions, forges and the Minotaur war',
|
||||||
|
brief: 'Iron in the hills, war on the wind. Arm legions, forge weapons, and survive the Minotaur horde that thunders out of the labyrinth each summer.',
|
||||||
|
mapSeed: 4404, mapW: 52, mapH: 52, funds: 9000,
|
||||||
|
startRes: { weapons: 50 },
|
||||||
|
goals: [ { kind:'population', amount:700 }, { kind:'peace', amount:30 }, { kind:'slayMonsters', amount:5 } ],
|
||||||
|
invasions: [ { atYearMonth:[1,9], kind:'barbarian', squads:2, size:12, warnMonths:3 }, { atYearMonth:[3,3], kind:'barbarian', squads:3, size:14, warnMonths:3 } ],
|
||||||
|
monsterEvents: [ { atYearMonth:[1,0], kind:'minotaur', squads:1, size:1, warnMonths:2 }, { atYearMonth:[2,6], kind:'minotaur', squads:2, size:1, warnMonths:2 }, { atYearMonth:[4,0], kind:'harpy', squads:2, size:3, warnMonths:2 } ],
|
||||||
|
openTrades: ['Syracuse','Mediolanum'],
|
||||||
|
allowSpellsFromStart: true,
|
||||||
|
hints: [ 'WEAPON SMITHS turn iron+timber into weapons; barracks turn weapons into legions.', 'Towers need no wall, but enemies can walk around them.', 'Heroes gain XP with each kill and grow devastatingly strong.' ],
|
||||||
|
reward: 'The frontier holds. Settlers stream in behind your shields.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'eternal', name: 'V · Arcanum Aeterna', subtitle: 'Found the eternal city of magic',
|
||||||
|
brief: 'Your greatest charge: found a city of wonders where magic and mortar meet. Reach 1200 citizens, supreme ratings — and raise a Golem Legion against the Lich King\'s host.',
|
||||||
|
mapSeed: 5505, mapW: 56, mapH: 56, funds: 14000,
|
||||||
|
startRes: { wheat: 400, pottery: 50, weapons: 80 },
|
||||||
|
goals: [ { kind:'population', amount:1200 }, { kind:'prosperity', amount:45 }, { kind:'culture', amount:55 }, { kind:'favor', amount:60 }, { kind:'slayMonsters', amount:8 } ],
|
||||||
|
invasions: [ { atYearMonth:[2,0], kind:'barbarian', squads:3, size:16, warnMonths:3 }, { atYearMonth:[3,6], kind:'barbarian', squads:4, size:18, warnMonths:3 }, { atYearMonth:[5,0], kind:'barbarian', squads:5, size:20, warnMonths:3 } ],
|
||||||
|
monsterEvents: [ { atYearMonth:[1,6], kind:'lich', squads:1, size:1, warnMonths:3 }, { atYearMonth:[3,0], kind:'lich', squads:1, size:1, warnMonths:3 }, { atYearMonth:[4,6], kind:'hydra', squads:2, size:1, warnMonths:3 }, { atYearMonth:[6,0], kind:'lich', squads:2, size:1, warnMonths:3 } ],
|
||||||
|
openTrades: ['Ravenna','Syracuse','Mediolanum','Alexandria'],
|
||||||
|
allowSpellsFromStart: true,
|
||||||
|
hints: [ 'Estates demand wine, oil, furniture AND plenty of entertainment.', 'The COLLOSSEUM... rather, COLOSSEUM anchors citywide culture.', 'Save often — the Lich raises the dead he slays.' ],
|
||||||
|
reward: 'Imperium Arcanum! Your name is written among the legends.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function makeSandbox(seed: number, w = 56, h = 56): Scenario {
|
||||||
|
return {
|
||||||
|
id: 'sandbox-' + seed, name: 'Free Province', subtitle: 'Endless sandbox',
|
||||||
|
brief: 'Build without limits. Waves of monsters scale with your prosperity. No victory, no defeat but ruin.',
|
||||||
|
mapSeed: seed, mapW: w, mapH: h, funds: 12000,
|
||||||
|
goals: [],
|
||||||
|
invasions: [], monsterEvents: [],
|
||||||
|
openTrades: ['Capua','Ravenna','Syracuse','Mediolanum','Alexandria'],
|
||||||
|
allowSpellsFromStart: true,
|
||||||
|
hints: [],
|
||||||
|
reward: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { bootApp } from './app';
|
||||||
|
|
||||||
|
bootApp();
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// ─── Camera: pan/zoom over the iso world ───────────────────────────────────
|
||||||
|
export const TILE_W = 64, TILE_H = 32;
|
||||||
|
|
||||||
|
export class Camera {
|
||||||
|
cx = 0; cy = 0; // world px at screen center
|
||||||
|
zoom = 1; // 0.5..2
|
||||||
|
vw = 800; vh = 600;
|
||||||
|
minZoom = 0.45; maxZoom = 2.2;
|
||||||
|
boundsW = 64; boundsH = 64;
|
||||||
|
|
||||||
|
setViewport(w: number, h: number) { this.vw = w; this.vh = h; }
|
||||||
|
worldToScreen(wx: number, wy: number): [number, number] {
|
||||||
|
return [(wx - this.cx) * this.zoom + this.vw / 2, (wy - this.cy) * this.zoom + this.vh / 2];
|
||||||
|
}
|
||||||
|
screenToWorld(sx: number, sy: number): [number, number] {
|
||||||
|
return [(sx - this.vw / 2) / this.zoom + this.cx, (sy - this.vh / 2) / this.zoom + this.cy];
|
||||||
|
}
|
||||||
|
screenToTile(sx: number, sy: number): [number, number] {
|
||||||
|
const [wx, wy] = this.screenToWorld(sx, sy);
|
||||||
|
const fx = wx / (TILE_W / 2), fy = wy / (TILE_H / 2);
|
||||||
|
return [Math.floor((fy + fx) / 2), Math.floor((fy - fx) / 2)];
|
||||||
|
}
|
||||||
|
tileToWorld(tx: number, ty: number): [number, number] {
|
||||||
|
return [(tx - ty) * TILE_W / 2, (tx + ty) * TILE_H / 2];
|
||||||
|
}
|
||||||
|
clampToBounds() {
|
||||||
|
const [wx, wy] = this.tileToWorld(this.boundsW / 2, this.boundsH / 2);
|
||||||
|
const mx = this.boundsW * TILE_W * 0.75, my = this.boundsH * TILE_H * 1.15;
|
||||||
|
this.cx = Math.max(-mx, Math.min(mx * 2 - mx, Math.max(this.cx, wx - mx), Math.min(this.cx, wx + mx)));
|
||||||
|
this.cy = Math.max(-my * 0.5, Math.min(my, Math.max(this.cy, wy - my * 0.6), Math.min(this.cy, wy + my * 0.6)));
|
||||||
|
}
|
||||||
|
zoomAt(sx: number, sy: number, factor: number) {
|
||||||
|
const [wx, wy] = this.screenToWorld(sx, sy);
|
||||||
|
this.zoom = Math.max(this.minZoom, Math.min(this.maxZoom, this.zoom * factor));
|
||||||
|
const [wx2, wy2] = this.screenToWorld(sx, sy);
|
||||||
|
this.cx += wx - wx2; this.cy += wy - wy2;
|
||||||
|
this.clampToBounds();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// ─── Visual effect queues (sim pushes, renderer consumes) ──────────────────
|
||||||
|
export interface Projectile { x: number; y: number; tx: number; ty: number; t: number; dur: number; color: string; arc: number; }
|
||||||
|
export interface Blast { x: number; y: number; t: number; dur: number; r0: number; r1: number; color: string; }
|
||||||
|
export interface FloatText { x: number; y: number; t: number; dur: number; text: string; color: string; size: number; }
|
||||||
|
export interface Spark { x: number; y: number; vx: number; vy: number; t: number; dur: number; color: string; size: number; grav: number; }
|
||||||
|
|
||||||
|
class FXImpl {
|
||||||
|
projectiles: Projectile[] = [];
|
||||||
|
blasts: Blast[] = [];
|
||||||
|
floats: FloatText[] = [];
|
||||||
|
sparks: Spark[] = [];
|
||||||
|
shake = 0;
|
||||||
|
|
||||||
|
projectile(x: number, y: number, tx: number, ty: number, color: string, speedPxPerS = 260, arc = 26) {
|
||||||
|
const dur = Math.hypot(tx - x, ty - y) / speedPxPerS;
|
||||||
|
this.projectiles.push({ x, y, tx, ty, t: 0, dur, color, arc });
|
||||||
|
}
|
||||||
|
blast(x: number, y: number, r1: number, color: string, dur = 0.6) {
|
||||||
|
this.blasts.push({ x, y, t: 0, dur, r0: r1 * 0.2, r1, color });
|
||||||
|
this.shake = Math.min(10, this.shake + r1 * 0.25);
|
||||||
|
}
|
||||||
|
float(x: number, y: number, text: string, color = '#ffe9a0', size = 13, dur = 1.4) {
|
||||||
|
if (this.floats.length > 60) this.floats.shift();
|
||||||
|
this.floats.push({ x, y, t: 0, dur, text, color, size });
|
||||||
|
}
|
||||||
|
spark(x: number, y: number, color: string, n = 6, power = 40, grav = 60) {
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = Math.random() * Math.PI * 2, v = power * (0.4 + Math.random());
|
||||||
|
this.sparks.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v * 0.5 - power * 0.5, t: 0, dur: 0.5 + Math.random() * 0.5, color, size: 1.5 + Math.random() * 2.5, grav });
|
||||||
|
}
|
||||||
|
if (this.sparks.length > 400) this.sparks.splice(0, this.sparks.length - 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number) {
|
||||||
|
this.shake = Math.max(0, this.shake - dt * 22);
|
||||||
|
for (const arr of [this.projectiles, this.blasts, this.floats, this.sparks] as Array<{ t: number; dur: number }[]>) {
|
||||||
|
for (let i = arr.length - 1; i >= 0; i--) { arr[i].t += dt; if (arr[i].t >= arr[i].dur) arr.splice(i, 1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clear() { this.projectiles.length = 0; this.blasts.length = 0; this.floats.length = 0; this.sparks.length = 0; this.shake = 0; }
|
||||||
|
}
|
||||||
|
export const FX = new FXImpl();
|
||||||
|
|
||||||
|
// ─── Audio hooks (filled by audio module; no-op stubs before init) ─────────
|
||||||
|
export const Sfx = {
|
||||||
|
play: (_name: string, _opts?: { vol?: number; rate?: number }) => {},
|
||||||
|
};
|
||||||
@@ -0,0 +1,810 @@
|
|||||||
|
// ─── World renderer ─────────────────────────────────────────────────────────
|
||||||
|
import type { Building, CityState, Walker } from '../sim/types';
|
||||||
|
import { def } from '../data/buildings';
|
||||||
|
import { HOUSE_SPRITE_BY_LEVEL } from '../data/housing';
|
||||||
|
import { Camera, TILE_W, TILE_H } from './camera';
|
||||||
|
import { getSprite, getRubble } from './sprites';
|
||||||
|
import { FX, Sfx } from './fx';
|
||||||
|
|
||||||
|
export type OverlayMode = null | 'desirability' | 'fire' | 'water' | 'fertility';
|
||||||
|
|
||||||
|
export interface ViewState {
|
||||||
|
overlay: OverlayMode;
|
||||||
|
ghost: { key: string; x: number; y: number; valid: boolean; reason?: string; dragFrom?: { x: number; y: number }; w: number; h: number } | null;
|
||||||
|
selectedBld: number;
|
||||||
|
eraseHover: boolean;
|
||||||
|
spellTarget: { radius: number; range: number; fromX?: number; fromY?: number } | null;
|
||||||
|
showGrid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERRAIN_COLORS: Record<string, [string, string]> = {
|
||||||
|
grass: ['#7fa04a', '#75963f'],
|
||||||
|
fertile: ['#5f8f3a', '#548233'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function shadeHex(c: string, f: number): string { // quick rgb-string shade
|
||||||
|
const n = parseInt(c.slice(1), 16);
|
||||||
|
const r = Math.min(255, ((n >> 16) & 255) * f), g = Math.min(255, ((n >> 8) & 255) * f), b = Math.min(255, (n & 255) * f);
|
||||||
|
return `rgb(${r | 0},${g | 0},${b | 0})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Renderer {
|
||||||
|
city: CityState;
|
||||||
|
terrainCv: HTMLCanvasElement;
|
||||||
|
tctx: CanvasRenderingContext2D;
|
||||||
|
private dirty = new Set<number>();
|
||||||
|
private originX: number; // screen px of tile(0,0) inside terrain canvas
|
||||||
|
private originY: number;
|
||||||
|
|
||||||
|
constructor(city: CityState) {
|
||||||
|
this.city = city;
|
||||||
|
this.originX = city.H * TILE_W / 2 + 32;
|
||||||
|
this.originY = 32;
|
||||||
|
this.terrainCv = document.createElement('canvas');
|
||||||
|
this.terrainCv.width = (city.W + city.H) * TILE_W / 2 + 64;
|
||||||
|
this.terrainCv.height = (city.W + city.H) * TILE_H / 2 + 64;
|
||||||
|
this.tctx = this.terrainCv.getContext('2d')!;
|
||||||
|
for (let i = 0; i < city.W * city.H; i++) this.dirty.add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateTile(x: number, y: number) {
|
||||||
|
const W = this.city.W;
|
||||||
|
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
const nx = x + dx, ny = y + dy;
|
||||||
|
if (nx >= 0 && ny >= 0 && nx < W && ny < this.city.H) this.dirty.add(ny * W + nx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invalidateAll() { for (let i = 0; i < this.city.W * this.city.H; i++) this.dirty.add(i); }
|
||||||
|
|
||||||
|
private drawGroundTile(x: number, y: number) {
|
||||||
|
const c = this.city, t = c.tiles[y * c.W + x], ctx = this.tctx;
|
||||||
|
const ox = this.originX + (x - y) * TILE_W / 2, oy = this.originY + (x + y) * TILE_H / 2;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(ox, oy);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, -TILE_H / 2); ctx.lineTo(TILE_W / 2, 0); ctx.lineTo(0, TILE_H / 2); ctx.lineTo(-TILE_W / 2, 0);
|
||||||
|
ctx.closePath();
|
||||||
|
let fill: string;
|
||||||
|
if (t.terrain === 'water') fill = '#33638e';
|
||||||
|
else if (t.terrain === 'rock') fill = '#8f867a';
|
||||||
|
else if (t.terrain === 'tree') fill = TERRAIN_COLORS.grass[t.variant % 2];
|
||||||
|
else if (t.terrain === 'iron') fill = '#7d786e';
|
||||||
|
else if (t.terrain === 'clay') fill = '#96703f';
|
||||||
|
else if (t.terrain === 'crystal') fill = '#4d6a72';
|
||||||
|
else fill = TERRAIN_COLORS[t.terrain][t.variant % 2];
|
||||||
|
ctx.fillStyle = fill;
|
||||||
|
ctx.fill();
|
||||||
|
if (t.terrain !== 'water') {
|
||||||
|
// subtle edge
|
||||||
|
ctx.strokeStyle = 'rgba(20,30,10,0.08)';
|
||||||
|
ctx.lineWidth = 1; ctx.stroke();
|
||||||
|
}
|
||||||
|
if (t.plaza || t.garden) {
|
||||||
|
ctx.fillStyle = t.plaza ? '#b5a888' : '#67934a';
|
||||||
|
ctx.fill();
|
||||||
|
if (t.garden) {
|
||||||
|
ctx.fillStyle = '#c94f3d'; ctx.beginPath(); ctx.arc(-6, 0, 2, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#e8c93d'; ctx.beginPath(); ctx.arc(7, 4, 2, 0, 7); ctx.fill();
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = 'rgba(120,108,84,.55)';
|
||||||
|
ctx.beginPath(); ctx.moveTo(-TILE_W / 2, 0); ctx.lineTo(TILE_W / 2, 0); ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (t.road) {
|
||||||
|
ctx.fillStyle = '#9b8862'; ctx.fill();
|
||||||
|
ctx.strokeStyle = 'rgba(70,55,30,.35)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-TILE_W / 2 + 3, 1); ctx.lineTo(0, TILE_H / 2 - 2); ctx.lineTo(TILE_W / 2 - 3, 1); ctx.lineTo(0, -TILE_H / 2 + 2);
|
||||||
|
ctx.closePath(); ctx.stroke();
|
||||||
|
ctx.fillStyle = 'rgba(60,48,28,.25)';
|
||||||
|
ctx.fillRect(-4, -2, 3, 2); ctx.fillRect(5, 2, 3, 2);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
flushDirty() {
|
||||||
|
if (!this.dirty.size) return;
|
||||||
|
for (const i of this.dirty) {
|
||||||
|
const x = i % this.city.W, y = (i / this.city.W) | 0;
|
||||||
|
this.drawGroundTile(x, y);
|
||||||
|
}
|
||||||
|
this.dirty.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main frame ──
|
||||||
|
drawFrame(ctx: CanvasRenderingContext2D, cam: Camera, dt: number, view: ViewState, timeS: number) {
|
||||||
|
const c = this.city;
|
||||||
|
this.flushDirty();
|
||||||
|
FX.update(dt);
|
||||||
|
|
||||||
|
// sky
|
||||||
|
const sky = ctx.createLinearGradient(0, 0, 0, ctx.canvas.height);
|
||||||
|
sky.addColorStop(0, '#20283e'); sky.addColorStop(0.55, '#31405c'); sky.addColorStop(1, '#243447');
|
||||||
|
ctx.fillStyle = sky; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||||
|
|
||||||
|
const shx = FX.shake ? (Math.random() - 0.5) * FX.shake : 0;
|
||||||
|
const shy = FX.shake ? (Math.random() - 0.5) * FX.shake : 0;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(shx, shy);
|
||||||
|
_setCurrentCtx(ctx);
|
||||||
|
|
||||||
|
// terrain blit
|
||||||
|
const [tx0, ty0] = cam.worldToScreen(-this.originX, -this.originY);
|
||||||
|
ctx.drawImage(this.terrainCv, tx0, ty0,
|
||||||
|
this.terrainCv.width * cam.zoom, this.terrainCv.height * cam.zoom);
|
||||||
|
|
||||||
|
// water shimmer (visible tiles)
|
||||||
|
this.drawWaterShimmer(ctx, cam, timeS);
|
||||||
|
|
||||||
|
// overlays under entities
|
||||||
|
if (view.overlay) this.drawOverlay(ctx, cam, view.overlay);
|
||||||
|
|
||||||
|
// build depth-sorted drawable list
|
||||||
|
type D = { d: number; f: () => void };
|
||||||
|
const list: D[] = [];
|
||||||
|
const margin = 220;
|
||||||
|
|
||||||
|
const pushIfVisible = (wx: number, wy: number, d: number, f: () => void) => {
|
||||||
|
const [sx, sy] = cam.worldToScreen(wx, wy);
|
||||||
|
if (sx < -margin || sy < -margin * 2 || sx > cam.vw + margin || sy > cam.vh + margin * 2) return;
|
||||||
|
list.push({ d, f });
|
||||||
|
};
|
||||||
|
|
||||||
|
// terrain features
|
||||||
|
for (let y = 0; y < c.H; y++) for (let x = 0; x < c.W; x++) {
|
||||||
|
const t = c.tiles[y * c.W + x];
|
||||||
|
if (t.bld || t.road || t.plaza || t.garden) continue; // covered by structures
|
||||||
|
if (t.terrain === 'tree') {
|
||||||
|
const key = t.variant % 3 === 0 ? 'feat_pine' : 'feat_tree';
|
||||||
|
const [wx, wy] = cam.tileToWorld(x, y);
|
||||||
|
pushFeature(list, cam, wx, wy, x + y, key, margin);
|
||||||
|
} else if (t.terrain === 'rock') {
|
||||||
|
const [wx, wy] = cam.tileToWorld(x, y);
|
||||||
|
pushFeature(list, cam, wx, wy, x + y, 'feat_rock', margin);
|
||||||
|
} else if (t.terrain === 'iron') {
|
||||||
|
const [wx, wy] = cam.tileToWorld(x, y);
|
||||||
|
pushFeature(list, cam, wx, wy, x + y, 'feat_iron', margin);
|
||||||
|
} else if (t.terrain === 'clay') {
|
||||||
|
const [wx, wy] = cam.tileToWorld(x, y);
|
||||||
|
pushFeature(list, cam, wx, wy, x + y, 'feat_clay', margin);
|
||||||
|
} else if (t.terrain === 'crystal') {
|
||||||
|
const [wx, wy] = cam.tileToWorld(x, y);
|
||||||
|
pushFeature(list, cam, wx, wy, x + y, 'feat_crystal', margin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildings
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin) {
|
||||||
|
// draw a rubble pile so ruined plots are clearly visible & obviously blocked
|
||||||
|
const [rwx, rwy] = cam.tileToWorld(b.x + b.w / 2, b.y + b.h / 2);
|
||||||
|
pushIfVisible(rwx, rwy, b.x + b.w + b.y + b.h, () => {
|
||||||
|
const rub = getRubble();
|
||||||
|
const sc = cam.zoom * Math.max(b.w, b.h) * 0.9;
|
||||||
|
ctx.globalAlpha = 0.92;
|
||||||
|
ctx.drawImage(rub.cv, rwx - rub.ax * sc, rwy - rub.ay * sc, rub.cv.width * sc, rub.cv.height * sc);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dd = def(b.def);
|
||||||
|
const [wx, wy] = cam.tileToWorld(b.x, b.y);
|
||||||
|
pushIfVisible(wx, wy, b.x + b.w + b.y + b.h, () => {
|
||||||
|
const sprKey = b.def === 'house'
|
||||||
|
? (HOUSE_SPRITE_BY_LEVEL[Math.min(b.houseLevel, HOUSE_SPRITE_BY_LEVEL.length - 1)] ?? 'house_l0a')
|
||||||
|
: dd.spriteKey;
|
||||||
|
const spr = getSprite(sprKey);
|
||||||
|
const scale = cam.zoom;
|
||||||
|
const px = wx - spr.ax * scale, py = wy - spr.ay * scale;
|
||||||
|
ctx.drawImage(spr.cv, px, py, spr.cv.width * scale, spr.cv.height * scale);
|
||||||
|
if (b.noRoad) this.drawNoRoadBadge(ctx, wx + (b.w - b.h) * TILE_W / 4, wy - 34 * scale - Math.sin(timeS * 3) * 2.5 * scale, cam.zoom);
|
||||||
|
if (dd.manaGen && b.workersLive > 0 && timeS % 1 < 0.7) {
|
||||||
|
const gx = wx + (Math.random() - 0.5) * 30 * scale, gy = wy - 30 * scale * Math.random();
|
||||||
|
ctx.fillStyle = 'rgba(150,220,255,.7)';
|
||||||
|
ctx.fillRect(gx, gy, 2.2 * scale, 2.2 * scale);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// fire on building
|
||||||
|
if (b.burning > 0) {
|
||||||
|
const cxw = wx + (b.w - b.h) * TILE_W / 4, cyw = wy + (b.w + b.h) * TILE_H / 4;
|
||||||
|
pushIfVisible(cxw, cyw, b.x + b.y + b.w + b.h + 0.4, () => this.drawFlames(ctx, cxw, cyw, timeS, Math.max(b.w, b.h)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkers
|
||||||
|
for (const w of c.walkers.values()) {
|
||||||
|
const [wx, wy] = cam.tileToWorld(w.x, w.y);
|
||||||
|
pushIfVisible(wx, wy, w.x + w.y + 0.3, () => this.drawWalker(ctx, w, wx, wy, cam.zoom, timeS));
|
||||||
|
}
|
||||||
|
|
||||||
|
list.sort((a, b2) => a.d - b2.d);
|
||||||
|
for (const item of list) item.f();
|
||||||
|
|
||||||
|
// selection highlight
|
||||||
|
if (view.selectedBld) {
|
||||||
|
const b = c.buildings.get(view.selectedBld);
|
||||||
|
if (b) this.outlineFootprint(ctx, cam, b.x, b.y, b.w, b.h, `rgba(255,235,160,${0.55 + 0.35 * Math.sin(timeS * 6)})`, 2);
|
||||||
|
}
|
||||||
|
// erase hover
|
||||||
|
if (view.eraseHover && view.ghost) {
|
||||||
|
this.outlineFootprint(ctx, cam, view.ghost.x, view.ghost.y, view.ghost.w, view.ghost.h, 'rgba(255,80,60,.85)', 2);
|
||||||
|
}
|
||||||
|
// build ghost
|
||||||
|
if (view.ghost && !view.eraseHover) {
|
||||||
|
const g = view.ghost;
|
||||||
|
const col = g.valid ? 'rgba(120,230,120,.4)' : 'rgba(240,80,60,.45)';
|
||||||
|
this.outlineFootprint(ctx, cam, g.x, g.y, g.w, g.h, col, 2, true);
|
||||||
|
const spr = getSprite(g.key);
|
||||||
|
const [gx, gy] = cam.tileToWorld(g.x, g.y);
|
||||||
|
ctx.globalAlpha = 0.65;
|
||||||
|
ctx.drawImage(spr.cv, gx - spr.ax * cam.zoom, gy - spr.ay * cam.zoom, spr.cv.width * cam.zoom, spr.cv.height * cam.zoom);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
// footprint outline (helps see the true extent of multi-tile buildings)
|
||||||
|
for (let fy = 0; fy < g.h; fy++) for (let fx = 0; fx < g.w; fx++) {
|
||||||
|
const [cx2, cy2] = cam.tileToWorld(g.x + fx, g.y + fy);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx2, cy2 - TILE_H / 2);
|
||||||
|
ctx.lineTo(cx2 + TILE_W / 2, cy2);
|
||||||
|
ctx.lineTo(cx2, cy2 + TILE_H / 2);
|
||||||
|
ctx.lineTo(cx2 - TILE_W / 2, cy2);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.strokeStyle = g.valid ? 'rgba(255,255,255,.55)' : 'rgba(255,90,70,.8)';
|
||||||
|
ctx.lineWidth = 1.4;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
if (!g.valid && g.reason) {
|
||||||
|
const [cxw, cyw] = cam.tileToWorld(g.x + g.w / 2, g.y + g.h / 2);
|
||||||
|
const [scx, scy] = cam.worldToScreen(cxw, cyw - 1.2);
|
||||||
|
ctx.font = `${Math.round(13 * Math.max(0.8, Math.min(1.4, cam.zoom)))}px Trebuchet MS`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
const tw2 = ctx.measureText(g.reason).width;
|
||||||
|
ctx.fillStyle = 'rgba(20,10,5,.82)';
|
||||||
|
ctx.fillRect(scx - tw2 / 2 - 7, scy - 15, tw2 + 14, 21);
|
||||||
|
ctx.strokeStyle = 'rgba(255,110,90,.9)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(scx - tw2 / 2 - 7, scy - 15, tw2 + 14, 21);
|
||||||
|
ctx.fillStyle = '#ffd9d0';
|
||||||
|
ctx.fillText(g.reason, scx, scy);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// grid
|
||||||
|
if (view.showGrid) this.drawGrid(ctx, cam);
|
||||||
|
|
||||||
|
// spell target circle
|
||||||
|
if (view.spellTarget) {
|
||||||
|
const st = view.spellTarget;
|
||||||
|
const [mx, my] = st.fromX !== undefined ? [st.fromX, st.fromY] : [0, 0];
|
||||||
|
void mx; void my;
|
||||||
|
// drawn at mouse by app via ghost? we draw at stored cursor world pos:
|
||||||
|
const [wx, wy] = cam.tileToWorld(st.fromX ?? 0, st.fromY ?? 0);
|
||||||
|
const rpx = st.radius * TILE_W / 2 * cam.zoom;
|
||||||
|
ctx.strokeStyle = 'rgba(170,140,255,.95)'; ctx.lineWidth = 2;
|
||||||
|
ctx.setLineDash([6, 5]);
|
||||||
|
ctx.beginPath(); ctx.ellipse(wx, wy, rpx, rpx / 2, 0, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FX layer
|
||||||
|
this.drawFx(ctx, cam);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawNoRoadBadge(ctx: CanvasRenderingContext2D, x: number, y: number, zoom: number) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
ctx.scale(zoom, zoom);
|
||||||
|
ctx.fillStyle = 'rgba(15,10,5,.55)';
|
||||||
|
ctx.beginPath(); ctx.arc(0, 0, 9, 0, Math.PI * 2); ctx.fill();
|
||||||
|
ctx.strokeStyle = '#ffb454'; ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath(); ctx.arc(0, 0, 9, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
// little road with slash
|
||||||
|
ctx.strokeStyle = '#e8d8ae'; ctx.lineWidth = 1.6;
|
||||||
|
ctx.beginPath(); ctx.moveTo(-4, 4); ctx.lineTo(-1.5, -4); ctx.lineTo(1.5, 4); ctx.stroke();
|
||||||
|
ctx.strokeStyle = '#ff7a60';
|
||||||
|
ctx.beginPath(); ctx.moveTo(-6, -6); ctx.lineTo(6, 6); ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawWaterShimmer(ctx: CanvasRenderingContext2D, cam: Camera, timeS: number) {
|
||||||
|
const c = this.city;
|
||||||
|
ctx.strokeStyle = 'rgba(190,225,250,.28)';
|
||||||
|
ctx.lineWidth = 1.4 * cam.zoom;
|
||||||
|
const step = 2;
|
||||||
|
for (let y = 0; y < c.H; y += 1) for (let x = (y % 2); x < c.W; x += step) {
|
||||||
|
const t = c.tiles[y * c.W + x];
|
||||||
|
if (t.terrain !== 'water') continue;
|
||||||
|
const [wx, wy] = cam.tileToWorld(x, y);
|
||||||
|
const ph = Math.sin(timeS * 2 + x * 1.7 + y * 2.3);
|
||||||
|
const len = 10 + ph * 4;
|
||||||
|
const [sx, sy] = cam.worldToScreen(wx - len / TILE_W / 2, wy);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(sx, sy);
|
||||||
|
ctx.lineTo(sx + len * cam.zoom, sy);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawOverlay(ctx: CanvasRenderingContext2D, cam: Camera, mode: OverlayMode) {
|
||||||
|
const c = this.city;
|
||||||
|
for (let y = 0; y < c.H; y++) for (let x = 0; x < c.W; x++) {
|
||||||
|
const t = c.tiles[y * c.W + x];
|
||||||
|
let color: string | null = null;
|
||||||
|
if (mode === 'desirability') color = desirabilityColor(t.desirability);
|
||||||
|
else if (mode === 'fire') color = fireColor(t);
|
||||||
|
else if (mode === 'water') color = waterColor(t.waterCover);
|
||||||
|
else if (mode === 'fertility') color = fertColor(t.fert, t.terrain);
|
||||||
|
if (!color) continue;
|
||||||
|
const [wx, wy] = cam.tileToWorld(x, y);
|
||||||
|
const [sx, sy] = cam.worldToScreen(wx, wy);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(sx, sy - TILE_H / 2 * cam.zoom);
|
||||||
|
ctx.lineTo(sx + TILE_W / 2 * cam.zoom, sy);
|
||||||
|
ctx.lineTo(sx, sy + TILE_H / 2 * cam.zoom);
|
||||||
|
ctx.lineTo(sx - TILE_W / 2 * cam.zoom, sy);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private outlineFootprint(ctx: CanvasRenderingContext2D, cam: Camera, x: number, y: number, w: number, h: number, color: string, lw = 2, fillArea = false) {
|
||||||
|
const corners = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]].map(([tx, ty]) => cam.tileToWorld(tx, ty));
|
||||||
|
const pts = corners.map(([wx, wy]) => cam.worldToScreen(wx, wy));
|
||||||
|
// footprint diamond: N=P(x,y) E=P(x+w,y) S=P(x+w,y+h) W=P(x,y+h)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[0][0], pts[0][1]);
|
||||||
|
ctx.lineTo(pts[1][0], pts[1][1]);
|
||||||
|
ctx.lineTo(pts[2][0], pts[2][1]);
|
||||||
|
ctx.lineTo(pts[3][0], pts[3][1]);
|
||||||
|
ctx.closePath();
|
||||||
|
if (fillArea) { ctx.fillStyle = color.replace(/[\d.]+\)$/, '0.18)'); try { ctx.fill(); } catch { /* ignore */ } }
|
||||||
|
ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawGrid(ctx: CanvasRenderingContext2D, cam: Camera) {
|
||||||
|
const c = this.city;
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,.06)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let y = 0; y < c.H; y++) for (let x = 0; x < c.W; x++) {
|
||||||
|
const [wx, wy] = cam.tileToWorld(x, y);
|
||||||
|
const [sx, sy] = cam.worldToScreen(wx, wy);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(sx, sy - TILE_H / 2 * cam.zoom);
|
||||||
|
ctx.lineTo(sx + TILE_W / 2 * cam.zoom, sy);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawFlames(ctx: CanvasRenderingContext2D, wx: number, wy: number, timeS: number, sizeTiles: number) {
|
||||||
|
const [sx, sy] = [wx, wy]; // already world px; caller translates? no—convert here
|
||||||
|
void sx; void sy;
|
||||||
|
const n = Math.round(3 + sizeTiles * 2);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const ph = timeS * 9 + i * 1.7;
|
||||||
|
const fx = wx + Math.sin(ph) * 12 * sizeTiles * 0.5;
|
||||||
|
const fy = wy - 8 - ((timeS * 26 + i * 13) % 22) - sizeTiles * 4;
|
||||||
|
const s = 4 + Math.sin(ph * 1.3) * 2 + sizeTiles;
|
||||||
|
ctx.fillStyle = i % 2 ? 'rgba(255,150,40,.85)' : 'rgba(255,210,70,.9)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(fx, fy - s * 1.6);
|
||||||
|
ctx.quadraticCurveTo(fx + s, fy, fx, fy + s * 0.6);
|
||||||
|
ctx.quadraticCurveTo(fx - s, fy, fx, fy - s * 1.6);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
if (Math.random() < 0.15) FX.spark(wx, wy - 14, 'rgba(255,160,50,.9)', 1, 26, -40);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawWalker(ctx: CanvasRenderingContext2D, w: Walker, wx: number, wy: number, zoom: number, timeS: number) {
|
||||||
|
const bob = Math.sin(w.animT * 10) * 1.2;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(wx, wy);
|
||||||
|
ctx.scale(zoom, zoom);
|
||||||
|
if (w.facing === -1) ctx.scale(-1, 1);
|
||||||
|
// shadow
|
||||||
|
ctx.fillStyle = 'rgba(10,8,4,.3)';
|
||||||
|
ctx.beginPath(); ctx.ellipse(0, 1, 7, 3, 0, 0, 7); ctx.fill();
|
||||||
|
|
||||||
|
const u = w.unit;
|
||||||
|
if (u) this.drawUnit(ctx, w, u, timeS, bob);
|
||||||
|
else this.drawCitizen(ctx, w.kind, timeS, bob, !!w.cargo && Object.keys(w.cargo).length > 0);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// hp bar (screen space, unflipped)
|
||||||
|
if (u && u.hp < u.maxHp) {
|
||||||
|
const bw = 26;
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,.55)';
|
||||||
|
ctx.fillRect(wx - bw / 2, wy - 34 * zoom - 8, bw, 4);
|
||||||
|
ctx.fillStyle = u.side === 'foe' ? '#e05545' : '#67d05f';
|
||||||
|
ctx.fillRect(wx - bw / 2, wy - 34 * zoom - 8, bw * Math.max(0, u.hp / u.maxHp), 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawCitizen(ctx: CanvasRenderingContext2D, kind: string, timeS: number, bob: number, carrying: boolean) {
|
||||||
|
let body = '#c8b89a', head = '#e8c8a0', accent = '';
|
||||||
|
switch (kind) {
|
||||||
|
case 'prefect': body = '#a8402f'; accent = '#e8d8a0'; break;
|
||||||
|
case 'engineer': body = '#4f6fa5'; accent = '#d8d8e8'; break;
|
||||||
|
case 'priest': body = '#f0ead8'; accent = '#c9a227'; break;
|
||||||
|
case 'actor': body = ['#c94f3d', '#5f943e', '#8e4a9e'][Math.floor(timeS / 2) % 3]; break;
|
||||||
|
case 'teacher': body = '#3f7d6f'; break;
|
||||||
|
case 'doctor': body = '#7da58f'; accent = '#f0ead8'; break;
|
||||||
|
case 'taxman': body = '#6b4fa1'; accent = '#ffd700'; break;
|
||||||
|
case 'peddler': body = '#b3714f'; break;
|
||||||
|
case 'immigrant': body = '#9c8a68'; break;
|
||||||
|
case 'marketBuyer': case 'cart': case 'fetchCart': body = '#8a6238'; break;
|
||||||
|
default: body = '#c0a888';
|
||||||
|
}
|
||||||
|
if (kind === 'cart' || kind === 'fetchCart' || kind === 'marketBuyer') {
|
||||||
|
// cart with ox-ish puller
|
||||||
|
ctx.fillStyle = body;
|
||||||
|
ctx.fillRect(-8, -6 + bob * 0.3, 14, 7);
|
||||||
|
ctx.fillStyle = '#4a3418';
|
||||||
|
ctx.beginPath(); ctx.arc(-5, 2, 3.4, 0, 7); ctx.arc(3, 2, 3.4, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = head;
|
||||||
|
ctx.beginPath(); ctx.arc(9, -8 + bob, 3, 0, 7); ctx.fill();
|
||||||
|
ctx.fillRect(7, -5 + bob, 4, 6);
|
||||||
|
if (carrying) {
|
||||||
|
ctx.fillStyle = '#d9b44a';
|
||||||
|
ctx.fillRect(-7, -11 + bob * 0.3, 11, 5);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// standard person
|
||||||
|
const legSwing = Math.sin(timeS * 11) * 2.4;
|
||||||
|
ctx.strokeStyle = '#5a4426'; ctx.lineWidth = 1.6;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, -5); ctx.lineTo(-legSwing, 1);
|
||||||
|
ctx.moveTo(0, -5); ctx.lineTo(legSwing, 1);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = body;
|
||||||
|
ctx.fillRect(-3, -12 + bob, 6, 8); // torso
|
||||||
|
ctx.beginPath(); ctx.arc(0, -14.5 + bob, 3, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = head;
|
||||||
|
ctx.beginPath(); ctx.arc(0, -15.5 + bob, 2.4, 0, 7); ctx.fill();
|
||||||
|
if (accent) { ctx.fillStyle = accent; ctx.fillRect(-3, -12 + bob, 6, 2); }
|
||||||
|
if (carrying) { ctx.fillStyle = '#c07a45'; ctx.beginPath(); ctx.arc(-5, -8 + bob, 2.6, 0, 7); ctx.fill(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawUnit(ctx: CanvasRenderingContext2D, w: Walker, u: NonNullable<Walker['unit']>, timeS: number, bob: number) {
|
||||||
|
const foe = u.side === 'foe';
|
||||||
|
switch (u.kind) {
|
||||||
|
case 'soldiers': {
|
||||||
|
const n = u.squadSize ?? 1;
|
||||||
|
for (let i = 0; i < Math.min(n, 16); i++) {
|
||||||
|
const ox = ((i % 4) - 1.5) * 5.5, oy = (((i / 4) | 0) - 1) * 4;
|
||||||
|
ctx.fillStyle = '#a8352f';
|
||||||
|
ctx.fillRect(ox - 2, oy - 10 + bob * 0.5, 4, 6);
|
||||||
|
ctx.fillStyle = '#d8c8a0';
|
||||||
|
ctx.beginPath(); ctx.arc(ox, oy - 12 + bob * 0.5, 2, 0, 7); ctx.fill();
|
||||||
|
// shield + spear
|
||||||
|
ctx.fillStyle = '#8a1f1f';
|
||||||
|
ctx.beginPath(); ctx.ellipse(ox + 3, oy - 7 + bob * 0.5, 2.6, 3.4, 0, 0, 7); ctx.fill();
|
||||||
|
ctx.strokeStyle = '#c8b89a'; ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath(); ctx.moveTo(ox - 2, oy - 2); ctx.lineTo(ox - 4, oy - 16); ctx.stroke();
|
||||||
|
}
|
||||||
|
// standard
|
||||||
|
if (n >= 8) {
|
||||||
|
ctx.strokeStyle = C_GOLD; ctx.lineWidth = 1.4;
|
||||||
|
ctx.beginPath(); ctx.moveTo(-12, 2); ctx.lineTo(-12, -20); ctx.stroke();
|
||||||
|
ctx.fillStyle = '#b03a2e'; ctx.fillRect(-12, -20, 8, 5);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'barbarian': {
|
||||||
|
const n = u.squadSize ?? 1;
|
||||||
|
for (let i = 0; i < Math.min(n, 12); i++) {
|
||||||
|
const ox = ((i % 4) - 1.5) * 6, oy = (((i / 4) | 0) - 1) * 4.5;
|
||||||
|
ctx.fillStyle = '#6e4a2f';
|
||||||
|
ctx.fillRect(ox - 2.4, oy - 9 + bob * 0.4, 4.8, 7);
|
||||||
|
ctx.fillStyle = '#d8a877';
|
||||||
|
ctx.beginPath(); ctx.arc(ox, oy - 11 + bob * 0.4, 2.2, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#8f2f26';
|
||||||
|
ctx.beginPath(); ctx.moveTo(ox - 3, oy - 13); ctx.lineTo(ox + 3, oy - 13); ctx.lineTo(ox, oy - 17); ctx.fill();
|
||||||
|
// axe
|
||||||
|
ctx.strokeStyle = '#5f4426'; ctx.lineWidth = 1.2;
|
||||||
|
ctx.beginPath(); ctx.moveTo(ox + 2, oy - 2); ctx.lineTo(ox + 5, oy - 12); ctx.stroke();
|
||||||
|
ctx.fillStyle = '#aab2c0'; ctx.fillRect(ox + 3.4, oy - 13, 3, 4);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'skeleton': {
|
||||||
|
ctx.strokeStyle = '#e8e2d2'; ctx.lineWidth = 1.6;
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, -10); ctx.lineTo(0, -3); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(-3, -8); ctx.lineTo(3, -8); ctx.moveTo(-2.4, -6); ctx.lineTo(2.4, -6); ctx.stroke();
|
||||||
|
ctx.fillStyle = '#e8e2d2';
|
||||||
|
ctx.beginPath(); ctx.arc(0, -12, 2.6, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#59c8d8'; ctx.fillRect(-1.4, -12.6, 1, 1); ctx.fillRect(0.6, -12.6, 1, 1);
|
||||||
|
ctx.strokeStyle = '#cfc8b8';
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, -3); ctx.lineTo(-2.4, 1); ctx.moveTo(0, -3); ctx.lineTo(2.4, 1); ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'harpy': {
|
||||||
|
const flap = Math.sin(timeS * 14) * 6;
|
||||||
|
ctx.fillStyle = '#7a5a8f';
|
||||||
|
ctx.beginPath(); ctx.ellipse(0, -14, 4, 6, 0, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#9a7ab0';
|
||||||
|
ctx.beginPath(); ctx.moveTo(-2, -18); ctx.quadraticCurveTo(-14, -20 - flap, -16, -10 - flap); ctx.quadraticCurveTo(-8, -14, -2, -12); ctx.fill();
|
||||||
|
ctx.beginPath(); ctx.moveTo(2, -18); ctx.quadraticCurveTo(14, -20 - flap, 16, -10 - flap); ctx.quadraticCurveTo(8, -14, 2, -12); ctx.fill();
|
||||||
|
ctx.fillStyle = '#e8d8a0'; ctx.beginPath(); ctx.arc(0, -21, 2.4, 0, 7); ctx.fill();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'minotaur': {
|
||||||
|
const s = 1.6;
|
||||||
|
ctx.fillStyle = '#5f4426';
|
||||||
|
ctx.fillRect(-5 * s, -14 * s + bob * 0.3, 10 * s, 10 * s);
|
||||||
|
ctx.fillStyle = '#7a5636';
|
||||||
|
ctx.beginPath(); ctx.arc(0, -17 * s + bob * 0.3, 4.4 * s, 0, 7); ctx.fill();
|
||||||
|
// horns
|
||||||
|
ctx.strokeStyle = '#efe6cd'; ctx.lineWidth = 2 * s;
|
||||||
|
ctx.beginPath(); ctx.moveTo(-4 * s, -19 * s); ctx.quadraticCurveTo(-8 * s, -23 * s, -5 * s, -26 * s); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(4 * s, -19 * s); ctx.quadraticCurveTo(8 * s, -23 * s, 5 * s, -26 * s); ctx.stroke();
|
||||||
|
// axe
|
||||||
|
ctx.strokeStyle = '#4a3418'; ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath(); ctx.moveTo(6 * s, -4 * s); ctx.lineTo(10 * s, -18 * s); ctx.stroke();
|
||||||
|
ctx.fillStyle = '#aab2c0'; ctx.fillRect(7 * s, -20 * s, 6 * s, 6 * s);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'hydra': {
|
||||||
|
const s = 2.1;
|
||||||
|
ctx.fillStyle = '#2f6b4f';
|
||||||
|
ctx.beginPath(); ctx.ellipse(0, -8 * s, 9 * s, 6 * s, 0, 0, 7); ctx.fill();
|
||||||
|
// three necks
|
||||||
|
for (let i = -1; i <= 1; i++) {
|
||||||
|
const sway = Math.sin(timeS * 4 + i * 2) * 4;
|
||||||
|
ctx.strokeStyle = '#3f7d5f'; ctx.lineWidth = 3.4;
|
||||||
|
ctx.beginPath(); ctx.moveTo(i * 6 * s, -10 * s);
|
||||||
|
ctx.quadraticCurveTo(i * 9 * s + sway, -22 * s, i * 7 * s + sway, -27 * s);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = '#3f7d5f';
|
||||||
|
ctx.beginPath(); ctx.ellipse(i * 7 * s + sway, -29 * s, 3.4, 2.6, 0, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#ffe070';
|
||||||
|
ctx.fillRect(i * 7 * s + sway - 2, -30 * s, 1.4, 1.4); ctx.fillRect(i * 7 * s + sway + 1, -30 * s, 1.4, 1.4);
|
||||||
|
}
|
||||||
|
// legs + tail
|
||||||
|
ctx.strokeStyle = '#2f6b4f'; ctx.lineWidth = 2.6;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-6 * s, -4 * s); ctx.lineTo(-7 * s, 1);
|
||||||
|
ctx.moveTo(6 * s, -4 * s); ctx.lineTo(7 * s, 1);
|
||||||
|
ctx.moveTo(-8 * s, -8 * s); ctx.quadraticCurveTo(-16 * s, -6 * s + Math.sin(timeS * 3) * 3, -18 * s, 0);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'lich': {
|
||||||
|
const s = 1.7;
|
||||||
|
const g = ctx.createRadialGradient(0, -14 * s, 2, 0, -14 * s, 26);
|
||||||
|
g.addColorStop(0, 'rgba(140,90,220,.55)'); g.addColorStop(1, 'rgba(140,90,220,0)');
|
||||||
|
ctx.fillStyle = g; ctx.fillRect(-26, -44, 52, 52);
|
||||||
|
ctx.fillStyle = '#2d2440';
|
||||||
|
ctx.beginPath(); ctx.moveTo(-8 * s, 0); ctx.quadraticCurveTo(-10 * s, -20 * s, 0, -22 * s);
|
||||||
|
ctx.quadraticCurveTo(10 * s, -20 * s, 8 * s, 0); ctx.closePath(); ctx.fill();
|
||||||
|
ctx.fillStyle = '#e8e2d2'; ctx.beginPath(); ctx.arc(0, -19 * s, 3 * s, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#8ef25f'; ctx.fillRect(-2 * s, -20 * s, 1.6, 1.6); ctx.fillRect(0.6 * s, -20 * s, 1.6, 1.6);
|
||||||
|
// staff
|
||||||
|
ctx.strokeStyle = '#4a3418'; ctx.lineWidth = 1.8;
|
||||||
|
ctx.beginPath(); ctx.moveTo(7 * s, 0); ctx.lineTo(7 * s, -26 * s); ctx.stroke();
|
||||||
|
ctx.fillStyle = '#8ef25f'; ctx.beginPath(); ctx.arc(7 * s, -28 * s, 2.6, 0, 7); ctx.fill();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'golem': {
|
||||||
|
const s = 1.5;
|
||||||
|
ctx.fillStyle = '#7f8791';
|
||||||
|
ctx.fillRect(-6 * s, -14 * s, 12 * s, 11 * s);
|
||||||
|
ctx.fillRect(-4 * s, -19 * s, 8 * s, 5 * s);
|
||||||
|
ctx.fillStyle = '#98a1ab';
|
||||||
|
ctx.fillRect(-10 * s, -13 * s, 3.6 * s, 8 * s); ctx.fillRect(6.4 * s, -13 * s, 3.6 * s, 8 * s);
|
||||||
|
ctx.fillStyle = '#59c8d8';
|
||||||
|
ctx.fillRect(-2.6 * s, -18 * s, 5.2 * s, 1.8 * s);
|
||||||
|
const pulse = 0.6 + Math.sin(timeS * 5) * 0.3;
|
||||||
|
ctx.fillStyle = `rgba(89,200,216,${pulse})`;
|
||||||
|
ctx.fillRect(-2 * s, -10 * s, 4 * s, 4 * s);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'hero': {
|
||||||
|
const lvl = w.heroLevel ?? 1;
|
||||||
|
const auraPulse = 0.35 + Math.sin(timeS * 3) * 0.15;
|
||||||
|
ctx.strokeStyle = `rgba(255,215,80,${auraPulse})`;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath(); ctx.ellipse(0, 0, 12, 5.5, 0, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
const heroId = w.heroId!;
|
||||||
|
const cape = heroId === 'champion' ? '#b03a2e' : heroId === 'huntress' ? '#3f7d43' : '#6b4fa1';
|
||||||
|
// cape flutter
|
||||||
|
const fl = Math.sin(timeS * 8) * 3;
|
||||||
|
ctx.fillStyle = cape;
|
||||||
|
ctx.beginPath(); ctx.moveTo(-3, -12); ctx.quadraticCurveTo(-8 - fl * 0.4, -6, -6, -1); ctx.lineTo(-1, -4); ctx.closePath(); ctx.fill();
|
||||||
|
// body armor
|
||||||
|
ctx.fillStyle = heroId === 'champion' ? '#b8a888' : heroId === 'huntress' ? '#4f8f5f' : '#5f4f8f';
|
||||||
|
ctx.fillRect(-3.4, -13, 6.8, 9);
|
||||||
|
ctx.fillStyle = '#e8c8a0';
|
||||||
|
ctx.beginPath(); ctx.arc(0, -16, 3, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = heroId === 'champion' ? '#c9a227' : heroId === 'huntress' ? '#3a5f2f' : '#3f3560';
|
||||||
|
ctx.fillRect(-3.4, -19, 6.8, 2.6); // helm/hood
|
||||||
|
// weapon
|
||||||
|
if (heroId === 'champion') {
|
||||||
|
ctx.strokeStyle = '#e8ecf4'; ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath(); ctx.moveTo(3, -4); ctx.lineTo(9, -20); ctx.stroke();
|
||||||
|
ctx.strokeStyle = '#c9a227'; ctx.beginPath(); ctx.moveTo(4, -8); ctx.lineTo(7, -7); ctx.stroke();
|
||||||
|
} else if (heroId === 'huntress') {
|
||||||
|
ctx.strokeStyle = '#8a6238'; ctx.lineWidth = 1.4;
|
||||||
|
ctx.beginPath(); ctx.arc(5, -10, 6, -1.2, 1.2); ctx.stroke();
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = '#4a3418'; ctx.lineWidth = 1.6;
|
||||||
|
ctx.beginPath(); ctx.moveTo(4, -2); ctx.lineTo(4, -20); ctx.stroke();
|
||||||
|
ctx.fillStyle = '#8ab0ff'; ctx.beginPath(); ctx.arc(4, -22, 2.6, 0, 7); ctx.fill();
|
||||||
|
}
|
||||||
|
if (lvl >= 3) { ctx.fillStyle = `rgba(255,215,80,${auraPulse + 0.2})`; ctx.font = 'bold 7px serif'; ctx.fillText('★'.repeat(Math.min(lvl - 2, 3)), -8, -24); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'caravan': {
|
||||||
|
ctx.fillStyle = '#a3714f';
|
||||||
|
ctx.beginPath(); ctx.ellipse(0, -8, 10, 5, 0, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#8a5638';
|
||||||
|
ctx.beginPath(); ctx.ellipse(6, -12, 4, 3, 0.4, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#c9b083';
|
||||||
|
ctx.beginPath(); ctx.ellipse(-6, -13, 4, 4, 0, 0, 7); ctx.fill();
|
||||||
|
ctx.strokeStyle = '#5f4426'; ctx.lineWidth = 1.2;
|
||||||
|
ctx.beginPath(); ctx.moveTo(-7, 0); ctx.lineTo(-7, -6); ctx.moveTo(0, 0); ctx.lineTo(0, -6); ctx.moveTo(7, 0); ctx.lineTo(7, -6); ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
ctx.fillStyle = foe ? '#a55' : '#ccc';
|
||||||
|
ctx.fillRect(-2.6, -11 + bob, 5.2, 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawFx(ctx: CanvasRenderingContext2D, cam: Camera) {
|
||||||
|
// projectiles
|
||||||
|
for (const p of FX.projectiles) {
|
||||||
|
const t01 = p.t / p.dur;
|
||||||
|
const x = p.x + (p.tx - p.x) * t01;
|
||||||
|
const y = p.y + (p.ty - p.y) * t01 - Math.sin(t01 * Math.PI) * p.arc;
|
||||||
|
const [sx, sy] = cam.worldToScreen(x, y);
|
||||||
|
ctx.fillStyle = p.color;
|
||||||
|
ctx.beginPath(); ctx.arc(sx, sy, 3 * cam.zoom, 0, 7); ctx.fill();
|
||||||
|
ctx.strokeStyle = p.color; ctx.globalAlpha = 0.4;
|
||||||
|
ctx.beginPath(); ctx.moveTo(sx, sy);
|
||||||
|
const t02 = Math.max(0, t01 - 0.12);
|
||||||
|
const x2 = p.x + (p.tx - p.x) * t02, y2 = p.y + (p.ty - p.y) * t02 - Math.sin(t02 * Math.PI) * p.arc;
|
||||||
|
const [sx2, sy2] = cam.worldToScreen(x2, y2);
|
||||||
|
ctx.lineTo(sx2, sy2); ctx.stroke();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
// blasts
|
||||||
|
for (const b of FX.blasts) {
|
||||||
|
const t01 = b.t / b.dur;
|
||||||
|
const r = b.r0 + (b.r1 - b.r0) * t01;
|
||||||
|
const [sx, sy] = cam.worldToScreen(b.x, b.y);
|
||||||
|
ctx.strokeStyle = b.color;
|
||||||
|
ctx.globalAlpha = 1 - t01;
|
||||||
|
ctx.lineWidth = 3 * cam.zoom * (1 - t01) + 1;
|
||||||
|
ctx.beginPath(); ctx.ellipse(sx, sy, r * TILE_W / 2 * cam.zoom, r * TILE_H / 2 * cam.zoom, 0, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
ctx.globalAlpha = (1 - t01) * 0.35;
|
||||||
|
ctx.fillStyle = b.color;
|
||||||
|
ctx.beginPath(); ctx.ellipse(sx, sy, r * TILE_W / 2 * cam.zoom, r * TILE_H / 2 * cam.zoom, 0, 0, Math.PI * 2); ctx.fill();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
// sparks
|
||||||
|
for (const s of FX.sparks) {
|
||||||
|
const t01 = s.t / s.dur;
|
||||||
|
const x = s.x + s.vx * s.t, y = s.y + s.vy * s.t + 0.5 * s.grav * s.t * s.t;
|
||||||
|
const [sx, sy] = cam.worldToScreen(x, y);
|
||||||
|
ctx.fillStyle = s.color;
|
||||||
|
ctx.globalAlpha = 1 - t01;
|
||||||
|
ctx.fillRect(sx - s.size * cam.zoom / 2, sy - s.size * cam.zoom / 2, s.size * cam.zoom, s.size * cam.zoom);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
// floats
|
||||||
|
for (const f of FX.floats) {
|
||||||
|
const t01 = f.t / f.dur;
|
||||||
|
const [sx, sy] = cam.worldToScreen(f.x, f.y - t01 * 22);
|
||||||
|
ctx.font = `${(f.size * Math.max(0.7, cam.zoom)).toFixed(1)}px Trebuchet MS`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.globalAlpha = 1 - t01 * t01;
|
||||||
|
ctx.strokeStyle = 'rgba(20,10,0,.8)'; ctx.lineWidth = 3;
|
||||||
|
ctx.strokeText(f.text, sx, sy);
|
||||||
|
ctx.fillStyle = f.color;
|
||||||
|
ctx.fillText(f.text, sx, sy);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushFeature(list: Array<{ d: number; f: () => void }>, cam: Camera, wx: number, wy: number, depth: number, key: string, margin: number) {
|
||||||
|
const [sx, sy] = cam.worldToScreen(wx, wy);
|
||||||
|
if (sx < -margin || sy < -margin * 2 || sx > cam.vw + margin || sy > cam.vh + margin * 2) return;
|
||||||
|
const spr = getSprite(key);
|
||||||
|
list.push({
|
||||||
|
d: depth,
|
||||||
|
f: () => ctxDrawSprite(cam, spr, wx, wy),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function ctxDrawSprite(cam: Camera, spr: { cv: HTMLCanvasElement; ax: number; ay: number }, wx: number, wy: number) {
|
||||||
|
const ctx = currentCtx!;
|
||||||
|
ctx.drawImage(spr.cv, wx - spr.ax * cam.zoom, wy - spr.ay * cam.zoom, spr.cv.width * cam.zoom, spr.cv.height * cam.zoom);
|
||||||
|
}
|
||||||
|
let currentCtx: CanvasRenderingContext2D | null = null;
|
||||||
|
export function _setCurrentCtx(c: CanvasRenderingContext2D | null) { currentCtx = c; }
|
||||||
|
|
||||||
|
const C_GOLD = '#c9a227';
|
||||||
|
|
||||||
|
// overlay color helpers
|
||||||
|
function desirabilityColor(v: number): string | null {
|
||||||
|
if (v >= 12) return 'rgba(80,220,120,.42)';
|
||||||
|
if (v >= 6) return 'rgba(140,230,110,.34)';
|
||||||
|
if (v >= 2) return 'rgba(210,230,110,.28)';
|
||||||
|
if (v >= -2) return 'rgba(230,210,110,.25)';
|
||||||
|
if (v >= -8) return 'rgba(235,150,80,.33)';
|
||||||
|
return 'rgba(220,70,50,.42)';
|
||||||
|
}
|
||||||
|
function fireColor(t: { terrain: string; fireFervor: number }): string | null {
|
||||||
|
if (t.terrain === 'water' || t.terrain === 'rock') return null;
|
||||||
|
const v = Math.max(0, Math.min(1, t.fireFervor));
|
||||||
|
if (v < 0.02) return null;
|
||||||
|
return `rgba(${180 + v * 60 | 0},${120 - v * 80 | 0},40,${0.15 + v * 0.4})`;
|
||||||
|
}
|
||||||
|
function waterColor(v: number): string | null {
|
||||||
|
if (v >= 2) return 'rgba(80,160,255,.4)';
|
||||||
|
if (v >= 1) return 'rgba(120,190,255,.3)';
|
||||||
|
if (v > 0) return 'rgba(160,210,255,.18)';
|
||||||
|
return 'rgba(200,60,40,.25)';
|
||||||
|
}
|
||||||
|
function fertColor(f: number, terrain: string): string | null {
|
||||||
|
if (terrain === 'water') return null;
|
||||||
|
const v = Math.max(0, Math.min(100, f)) / 100;
|
||||||
|
return v > 0.15 ? `rgba(60,${140 + v * 80 | 0},60,${0.12 + v * 0.35})` : 'rgba(150,130,60,.15)';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeMinimap(city: CityState, w: number, h: number): HTMLCanvasElement {
|
||||||
|
const cv = document.createElement('canvas');
|
||||||
|
cv.width = w; cv.height = h;
|
||||||
|
return cv;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawMinimap(mctx: CanvasRenderingContext2D, city: CityState, cam: Camera, w: number, h: number) {
|
||||||
|
const sx = w / city.W, sy = h / city.H;
|
||||||
|
mctx.clearRect(0, 0, w, h);
|
||||||
|
mctx.fillStyle = '#20351c';
|
||||||
|
mctx.fillRect(0, 0, w, h);
|
||||||
|
for (let y = 0; y < city.H; y++) for (let x = 0; x < city.W; x++) {
|
||||||
|
const t = city.tiles[y * city.W + x];
|
||||||
|
let col: string;
|
||||||
|
if (t.terrain === 'water') col = '#2e5a86';
|
||||||
|
else if (t.terrain === 'tree') col = '#2f5a28';
|
||||||
|
else if (t.terrain === 'rock') col = '#6e675c';
|
||||||
|
else if (t.terrain === 'iron') col = '#7d786e';
|
||||||
|
else if (t.terrain === 'clay') col = '#96703f';
|
||||||
|
else if (t.terrain === 'crystal') col = '#4d8a94';
|
||||||
|
else if (t.road) col = '#9b8862';
|
||||||
|
else if (t.bld) col = '#c8b89a';
|
||||||
|
else col = '#44682f';
|
||||||
|
mctx.fillStyle = col;
|
||||||
|
mctx.fillRect(x * sx, y * sy, Math.ceil(sx), Math.ceil(sy));
|
||||||
|
}
|
||||||
|
// walkers
|
||||||
|
for (const wk of city.walkers.values()) {
|
||||||
|
const foeW = wk.unit?.side === 'foe';
|
||||||
|
mctx.fillStyle = foeW ? '#ff5040' : (wk.heroId ? '#ffd700' : wk.unit ? '#7fd0ff' : '#ffffff');
|
||||||
|
mctx.fillRect(wk.x * sx - 1, wk.y * sy - 1, 2.4, 2.4);
|
||||||
|
}
|
||||||
|
// viewport rect
|
||||||
|
const corners: Array<[number, number]> = [
|
||||||
|
cam.screenToWorld(0, 0), cam.screenToWorld(cam.vw, 0),
|
||||||
|
cam.screenToWorld(cam.vw, cam.vh), cam.screenToWorld(0, cam.vh),
|
||||||
|
].map(([wx, wy]) => {
|
||||||
|
const fx = (wy / (TILE_H / 2) + wx / (TILE_W / 2)) / 2;
|
||||||
|
const fy = (wy / (TILE_H / 2) - wx / (TILE_W / 2)) / 2;
|
||||||
|
return [fx * sx, fy * sy] as [number, number];
|
||||||
|
});
|
||||||
|
mctx.strokeStyle = 'rgba(255,255,255,.85)';
|
||||||
|
mctx.lineWidth = 1.2;
|
||||||
|
mctx.beginPath();
|
||||||
|
mctx.moveTo(...corners[0]);
|
||||||
|
for (let i = 1; i < 4; i++) mctx.lineTo(...corners[i]);
|
||||||
|
mctx.closePath(); mctx.stroke();
|
||||||
|
void Sfx; void shadeHex;
|
||||||
|
}
|
||||||
@@ -0,0 +1,793 @@
|
|||||||
|
// ─── Procedural isometric sprite factory ───────────────────────────────────
|
||||||
|
import { TILE_W, TILE_H } from './camera';
|
||||||
|
|
||||||
|
export const TW = TILE_W, TH = TILE_H;
|
||||||
|
const PAD = 10;
|
||||||
|
|
||||||
|
export interface Sprite { cv: HTMLCanvasElement; ax: number; ay: number; } // anchor = north-corner pos
|
||||||
|
|
||||||
|
const cache = new Map<string, Sprite>();
|
||||||
|
|
||||||
|
// ── color utils ─────────────────────────────────────────────────────────────
|
||||||
|
function hex(c: string): [number, number, number] {
|
||||||
|
const n = parseInt(c.slice(1), 16);
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
|
}
|
||||||
|
function css(r: number, g: number, b: number) { return `rgb(${r | 0},${g | 0},${b | 0})`; }
|
||||||
|
export function shade(c: string, f: number): string {
|
||||||
|
const [r, g, b] = hex(c);
|
||||||
|
if (f >= 1) return css(r + (255 - r) * (f - 1), g + (255 - g) * (f - 1), b + (255 - b) * (f - 1));
|
||||||
|
return css(r * f, g * f, b * f);
|
||||||
|
}
|
||||||
|
export function mix(a: string, b: string, t: number): string {
|
||||||
|
const A = hex(a), B = hex(b);
|
||||||
|
return css(A[0] + (B[0] - A[0]) * t, A[1] + (B[1] - A[1]) * t, A[2] + (B[2] - A[2]) * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pal = { top: string; left: string; right: string };
|
||||||
|
export function pal(base: string): Pal {
|
||||||
|
return { top: shade(base, 1.22), left: base, right: shade(base, 0.62) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── geometry ────────────────────────────────────────────────────────────────
|
||||||
|
interface Corners { n: [number, number]; e: [number, number]; s: [number, number]; w: [number, number]; }
|
||||||
|
function footCorners(cx: number, cy: number, w: number, h: number): Corners {
|
||||||
|
return {
|
||||||
|
n: [cx, cy],
|
||||||
|
e: [cx + w * TW / 2, cy + w * TH / 2],
|
||||||
|
s: [cx + (w - h) * TW / 2, cy + (w + h) * TH / 2],
|
||||||
|
w: [cx - h * TW / 2, cy + h * TH / 2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function poly(ctx: CanvasRenderingContext2D, pts: Array<[number, number]>, fill: string, stroke?: string) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[0][0], pts[0][1]);
|
||||||
|
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = fill; ctx.fill();
|
||||||
|
if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 0.8; ctx.stroke(); }
|
||||||
|
}
|
||||||
|
function down(p: [number, number], z: number): [number, number] { return [p[0], p[1] - z]; }
|
||||||
|
|
||||||
|
/** Extruded iso box; returns top-face corners raised. */
|
||||||
|
export function isoBox(ctx: CanvasRenderingContext2D, cx: number, cy: number, w: number, h: number, z: number, p: Pal, outline = 'rgba(20,14,8,.45)') {
|
||||||
|
const c = footCorners(cx, cy, w, h);
|
||||||
|
poly(ctx, [down(c.w, z), down(c.s, z), c.s, c.w], p.left, outline); // SW face
|
||||||
|
poly(ctx, [down(c.s, z), down(c.e, z), c.e, c.s], p.right, outline); // SE face
|
||||||
|
poly(ctx, [down(c.n, z), down(c.e, z), down(c.s, z), down(c.w, z)], p.top, outline);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pyramid roof on top face; rh = roof height. */
|
||||||
|
export function pyramid(ctx: CanvasRenderingContext2D, c: Corners, z: number, rh: number, base: string, ridgeAxis: 'x' | 'y' = 'x') {
|
||||||
|
const N = down(c.n, z), E = down(c.e, z), S = down(c.s, z), Wp = down(c.w, z);
|
||||||
|
const midX: [number, number] = [(N[0] + S[0]) / 2, (N[1] + S[1]) / 2];
|
||||||
|
const midY: [number, number] = [(E[0] + Wp[0]) / 2, (E[1] + Wp[1]) / 2];
|
||||||
|
if (ridgeAxis === 'x') {
|
||||||
|
const r1 = down(c.n, z + rh), r2 = down(c.s, z + rh);
|
||||||
|
poly(ctx, [Wp, S, r2, r1].reverse() as Array<[number, number]>, shade(base, 0.62));
|
||||||
|
poly(ctx, [Wp, N, r1], mix(base, '#000000', 0.25));
|
||||||
|
poly(ctx, [r1, r2, S], base);
|
||||||
|
poly(ctx, [N, E, midX, r1], shade(base, 0.85));
|
||||||
|
poly(ctx, [E, S, r2, midX], shade(base, 0.55));
|
||||||
|
poly(ctx, [N, Wp, midY, r1], shade(base, 1.05));
|
||||||
|
} else {
|
||||||
|
const r1: [number, number] = [midX[0], midX[1] - rh];
|
||||||
|
poly(ctx, [Wp, S, r1], shade(base, 0.6));
|
||||||
|
poly(ctx, [S, E, r1], base);
|
||||||
|
poly(ctx, [E, N, r1], shade(base, 1.15));
|
||||||
|
poly(ctx, [N, Wp, r1], shade(base, 0.9));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ellipseShadow(ctx: CanvasRenderingContext2D, x: number, y: number, rx: number, ry: number, a = 0.25) {
|
||||||
|
ctx.fillStyle = `rgba(15,10,5,${a})`;
|
||||||
|
ctx.beginPath(); ctx.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2); ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── canvas creation ─────────────────────────────────────────────────────────
|
||||||
|
function makeCanvas(wPx: number, hPx: number, anchorX: number, anchorY: number, draw: (ctx: CanvasRenderingContext2D) => void): Sprite {
|
||||||
|
const cv = document.createElement('canvas');
|
||||||
|
cv.width = Math.ceil(wPx); cv.height = Math.ceil(hPx);
|
||||||
|
const ctx = cv.getContext('2d')!;
|
||||||
|
ctx.translate(anchorX, anchorY);
|
||||||
|
draw(ctx);
|
||||||
|
return { cv, ax: anchorX, ay: anchorY };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSprite(key: string): Sprite {
|
||||||
|
let s = cache.get(key);
|
||||||
|
if (!s) { s = buildSprite(key); cache.set(key, s); }
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function footprintOf(key: string): [number, number] {
|
||||||
|
// mirrors DEFS sizes without import cycle
|
||||||
|
switch (key) {
|
||||||
|
case 'farmWheat': case 'orchard': case 'granary': case 'senate': case 'fort':
|
||||||
|
case 'colosseum': case 'amphitheater': case 'academy': case 'library': case 'hospital':
|
||||||
|
case 'arcaneLibrary': case 'heroHall': case 'tradingPost': return [3, 3];
|
||||||
|
case 'market': case 'fishingWharf': case 'clayPit': case 'timberCamp': case 'ironMine': case 'crystalMine':
|
||||||
|
case 'potteryWksp': case 'furnitureWksp': case 'oilPress': case 'winery': case 'weaponSmith':
|
||||||
|
case 'forum': case 'templeJupiter': case 'templeCeres': case 'templeNeptune': case 'templeMars':
|
||||||
|
case 'oracle': case 'theater': case 'school': case 'clinic': case 'tower': case 'gatehouse':
|
||||||
|
case 'barracks': case 'mageTower': case 'golemFoundry': case 'fountain': return [2, 2];
|
||||||
|
default: return [1, 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSprite(key: string): Sprite {
|
||||||
|
const [fw, fh] = footprintOf(key);
|
||||||
|
const maxZ = 96;
|
||||||
|
const wPx = (fw + fh) * TW / 2 + PAD * 2 + 24;
|
||||||
|
const hPx = (fw + fh) * TH / 2 + maxZ + PAD * 2 + 40;
|
||||||
|
const ax = PAD + fh * TW / 2 + 12; // north corner anchor
|
||||||
|
const ay = PAD + maxZ + 12;
|
||||||
|
|
||||||
|
return makeCanvas(wPx, hPx, ax, ay, (ctx) => drawBuildingSprite(ctx, key, fw, fh));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the big dispatcher ──────────────────────────────────────────────────────
|
||||||
|
const C = { // palette shortcuts
|
||||||
|
stone: '#a89a86', stoneDark: '#7d7160', wood: '#8a6238', woodDark: '#5f4426',
|
||||||
|
plaster: '#d8cbb0', roofTile: '#a5502f', roofTileD: '#7c3a21', thatch: '#c2a04e',
|
||||||
|
cloth: '#cdb98a', marble: '#ded6c4', gold: '#c9a227', purple: '#6b4fa1',
|
||||||
|
crystal: '#59c8d8', iron: '#8d93a1', dirt: '#9b7d52',
|
||||||
|
};
|
||||||
|
|
||||||
|
function drawBuildingSprite(ctx: CanvasRenderingContext2D, key: string, fw: number, fh: number) {
|
||||||
|
const cx = 0, cy = 0;
|
||||||
|
switch (key) {
|
||||||
|
|
||||||
|
// ── housing tiers ──
|
||||||
|
case 'house_l0a': case 'house_l0b': { // tents
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 4, TW * 0.42, TH * 0.36);
|
||||||
|
const c = footCorners(0, 0, 1, 1);
|
||||||
|
poly(ctx, [down(c.n, 26), down(c.w, 2), c.w], shade(C.cloth, 0.75)); // left flap
|
||||||
|
poly(ctx, [down(c.n, 26), down(c.e, 2), c.e], shade(C.cloth, 0.55)); // right flap
|
||||||
|
poly(ctx, [down(c.n, 26), c.n, down(c.n, 30)], C.cloth);
|
||||||
|
poly(ctx, [[0, -30], down(c.w, 2), down(c.s, 2), down(c.e, 2)], shade(C.cloth, 1.05), 'rgba(60,40,20,.5)');
|
||||||
|
poly(ctx, [[0, -30], [0, 2], down(c.w, 2)], shade(C.cloth, 0.7));
|
||||||
|
ctx.strokeStyle = '#6b5023'; ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, -32); ctx.lineTo(0, 4); ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'house_l1a': case 'house_l1b': { // shacks — wood + thatch
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 4, TW * 0.48, TH * 0.4);
|
||||||
|
const c = isoBox(ctx, 0, 0, 0.82, 0.82, 18, pal(C.wood));
|
||||||
|
pyramid(ctx, c, 18, 12, C.thatch, 'x');
|
||||||
|
ctx.fillStyle = '#3a2814'; ctx.fillRect(-6, -10, 5, 9); // door hint
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'house_l2a': case 'house_l2b': { // hovels — plaster + thatch
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 4, TW * 0.5, TH * 0.44);
|
||||||
|
const c = isoBox(ctx, 0, 0, 0.92, 0.92, 22, pal(C.plaster));
|
||||||
|
pyramid(ctx, c, 22, 13, C.thatch, 'x');
|
||||||
|
isoBox(ctx, -14, 10, 0.34, 0.34, 12, pal(C.plaster)); // annex
|
||||||
|
ctx.fillStyle = '#4a3418'; ctx.fillRect(4, -12, 6, 11);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'house_l3a': case 'house_l3b': { // casas — tile roof
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 4, TW * 0.54, TH * 0.46);
|
||||||
|
const c = isoBox(ctx, 0, 0, 0.95, 0.95, 26, pal(C.plaster));
|
||||||
|
pyramid(ctx, c, 26, 15, C.roofTile, 'x');
|
||||||
|
ctx.strokeStyle = C.roofTileD; ctx.lineWidth = 1;
|
||||||
|
for (let i = 1; i < 4; i++) { ctx.beginPath(); ctx.moveTo(-i * 6, -26 - i * 4); ctx.lineTo(i * 6, -26 - i * 4 + i * 8 - 8); ctx.stroke(); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'house_l4a': case 'house_l4b': { // villas
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 6, TW * 0.58, TH * 0.5);
|
||||||
|
const c = isoBox(ctx, 0, 0, 0.95, 0.95, 38, pal('#e2d6ba'));
|
||||||
|
pyramid(ctx, c, 38, 16, C.roofTile, 'x');
|
||||||
|
// upper floor band
|
||||||
|
ctx.fillStyle = C.gold; ctx.fillRect(-16, -34, 32, 3);
|
||||||
|
// columns
|
||||||
|
for (const px of [-18, -8, 8, 18]) { ctx.fillStyle = C.marble; ctx.fillRect(px - 1.5, -16, 3, 16); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'house_l5a': { // estate
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 6, TW * 0.62, TH * 0.54);
|
||||||
|
const c = isoBox(ctx, 0, 0, 0.95, 0.95, 46, pal('#efe6cd'));
|
||||||
|
pyramid(ctx, c, 46, 18, '#b3542c', 'x');
|
||||||
|
const c2 = isoBox(ctx, -20, 14, 0.4, 0.4, 30, pal('#e6dcc2'));
|
||||||
|
pyramid(ctx, c2, 30, 10, '#b3542c', 'y');
|
||||||
|
ctx.fillStyle = C.gold; ctx.fillRect(-20, -42, 40, 3.5);
|
||||||
|
for (const px of [-22, -12, 12, 22]) { ctx.fillStyle = C.marble; ctx.fillRect(px - 2, -20, 4, 20); }
|
||||||
|
// pennant
|
||||||
|
ctx.strokeStyle = '#5a4426'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(0, -66); ctx.lineTo(0, -80); ctx.stroke();
|
||||||
|
poly(ctx, [[0, -80], [14, -76], [0, -72]], '#b03a2e');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── food chain ──
|
||||||
|
case 'farmWheat': {
|
||||||
|
// furrow field
|
||||||
|
const c = footCorners(0, 0, 3, 3);
|
||||||
|
poly(ctx, [c.n, c.e, c.s, c.w], '#b0913f', 'rgba(90,70,30,.5)');
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath(); ctx.moveTo(c.n[0], c.n[1]); ctx.lineTo(c.e[0], c.e[1]); ctx.lineTo(c.s[0], c.s[1]); ctx.lineTo(c.w[0], c.w[1]); ctx.closePath(); ctx.clip();
|
||||||
|
ctx.strokeStyle = '#8f7734'; ctx.lineWidth = 3;
|
||||||
|
for (let i = -6; i <= 6; i++) { ctx.beginPath(); ctx.moveTo(i * 14 - 40, -40); ctx.lineTo(i * 14 + 40, 80); ctx.stroke(); }
|
||||||
|
ctx.strokeStyle = '#d9b44a'; ctx.lineWidth = 1.4;
|
||||||
|
for (let i = -6; i <= 6; i++) { ctx.beginPath(); ctx.moveTo(i * 14 - 40, -40); ctx.lineTo(i * 14 + 40, 80); ctx.stroke(); }
|
||||||
|
ctx.restore();
|
||||||
|
const hc = isoBox(ctx, 34, 26, 0.8, 0.8, 20, pal(C.wood));
|
||||||
|
pyramid(ctx, hc, 20, 12, C.thatch, 'x');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'orchard': {
|
||||||
|
const c = footCorners(0, 0, 3, 3);
|
||||||
|
poly(ctx, [c.n, c.e, c.s, c.w], '#8fa04a', 'rgba(70,90,30,.5)');
|
||||||
|
const tree = (tx: number, ty: number) => {
|
||||||
|
const [px, py] = [(tx - ty) * TW / 2, (tx + ty) * TH / 2];
|
||||||
|
ctx.fillStyle = '#6b4a26'; ctx.fillRect(px - 2, py - 12, 4, 12);
|
||||||
|
ellipseShadow(ctx, px, py - 14, 9, 7, 0.15);
|
||||||
|
ctx.fillStyle = '#4f7d33'; ctx.beginPath(); ctx.arc(px, py - 17, 9, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#5f943e'; ctx.beginPath(); ctx.arc(px - 3, py - 19, 6, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#c94f3d';
|
||||||
|
for (let k = 0; k < 3; k++) { ctx.beginPath(); ctx.arc(px - 5 + k * 5, py - 16 + (k % 2) * 5, 2, 0, 7); ctx.fill(); }
|
||||||
|
};
|
||||||
|
for (const tx of [0.7, 1.7, 2.3]) for (const ty of [0.7, 1.7, 2.3]) tree(tx, ty);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'fishingWharf': {
|
||||||
|
// dock planks
|
||||||
|
const c = footCorners(0, 0, 2, 2);
|
||||||
|
poly(ctx, [c.n, c.e, c.s, c.w], '#8a6238', 'rgba(40,25,10,.6)');
|
||||||
|
ctx.strokeStyle = '#5f4426'; ctx.lineWidth = 1.5;
|
||||||
|
for (let i = 1; i < 6; i++) { ctx.beginPath(); ctx.moveTo(c.w[0] + i * (c.e[0] - c.w[0]) / 6, c.w[1] + i * (c.e[1] - c.w[1]) / 6); ctx.lineTo(c.n[0] + i * (c.s[0] - c.n[0]) / 6, c.n[1] + i * (c.s[1] - c.n[1]) / 6); ctx.stroke(); }
|
||||||
|
const hc = isoBox(ctx, 8, 6, 0.9, 0.9, 20, pal(C.wood));
|
||||||
|
pyramid(ctx, hc, 20, 12, C.thatch, 'y');
|
||||||
|
// boat
|
||||||
|
ctx.fillStyle = '#7a5230'; ctx.beginPath(); ctx.ellipse(-26, 34, 16, 6, -0.4, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#3a2814'; ctx.beginPath(); ctx.ellipse(-26, 33, 12, 3.5, -0.4, 0, 7); ctx.fill();
|
||||||
|
ctx.strokeStyle = '#4a3418'; ctx.beginPath(); ctx.moveTo(-26, 30); ctx.lineTo(-26, 12); ctx.stroke();
|
||||||
|
poly(ctx, [[-26, 12], [-10, 24], [-26, 26]], '#e8dcc0');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'granary': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 6, TW * 1.1, TH * 0.9);
|
||||||
|
const c = isoBox(ctx, 0, 0, 2.6, 2.6, 34, pal('#c8b48a'));
|
||||||
|
pyramid(ctx, c, 34, 16, C.thatch, 'x');
|
||||||
|
for (const dx of [-20, 0, 20]) { ctx.fillStyle = '#5f4426'; ctx.fillRect(dx - 3, -24, 6, 12); }
|
||||||
|
ctx.fillStyle = C.gold; ctx.font = 'bold 11px Trebuchet MS'; ctx.fillText('GRANARY'.slice(0, 0), 0, 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'warehouse': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 6, TW * 1.1, TH * 0.9);
|
||||||
|
const c = isoBox(ctx, 0, 0, 2.6, 2.6, 30, pal('#9c7a52'));
|
||||||
|
pyramid(ctx, c, 30, 14, C.roofTile, 'x');
|
||||||
|
ctx.fillStyle = '#4a3418'; // big doors
|
||||||
|
poly(ctx, [[-6, -20], [8, -27], [8, -6], [-6, -2]], '#4a3418');
|
||||||
|
// crates outside
|
||||||
|
isoBox(ctx, 40, 30, 0.4, 0.4, 10, pal(C.wood));
|
||||||
|
isoBox(ctx, 48, 24, 0.4, 0.4, 8, pal('#9c7a52'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'market': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 4, TW * 0.8, TH * 0.7);
|
||||||
|
const c = isoBox(ctx, 0, 0, 1.7, 1.7, 14, pal('#b89a68'));
|
||||||
|
// striped canopy
|
||||||
|
poly(ctx, [down(c.n, 30), down(c.e, 30), down(c.s, 34), down(c.w, 34)], '#c94f3d');
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
const cn = down(c.n, 30), ce = down(c.e, 30), cs = down(c.s, 34), cw = down(c.w, 34);
|
||||||
|
ctx.moveTo(cn[0], cn[1]); ctx.lineTo(ce[0], ce[1]); ctx.lineTo(cs[0], cs[1]); ctx.lineTo(cw[0], cw[1]); ctx.closePath();
|
||||||
|
ctx.clip();
|
||||||
|
for (let i = -4; i < 5; i += 2) { poly(ctx, [[i * 8, -60], [i * 8 + 8, -60], [i * 8 + 8, 20], [i * 8, 20]], '#e8d8ae'); }
|
||||||
|
ctx.restore();
|
||||||
|
// posts
|
||||||
|
ctx.strokeStyle = '#5f4426'; ctx.lineWidth = 2;
|
||||||
|
const cn2 = down(c.n, 30);
|
||||||
|
ctx.beginPath(); ctx.moveTo(cn2[0], cn2[1] + 16); ctx.lineTo(cn2[0], cn2[1]); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(cs[0], cs[1]); ctx.lineTo(cs[0], cs[1] - 4); ctx.stroke();
|
||||||
|
// amphora + crate props
|
||||||
|
ctx.fillStyle = '#c07a45'; ctx.beginPath(); ctx.ellipse(-20, 6, 5, 7, 0, 0, 7); ctx.fill();
|
||||||
|
isoBox(ctx, 30, 22, 0.35, 0.35, 9, pal(C.wood));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── industry ──
|
||||||
|
case 'clayPit': {
|
||||||
|
const c = footCorners(0, 0, 2, 2);
|
||||||
|
poly(ctx, [c.n, c.e, c.s, c.w], '#a3714b', 'rgba(60,40,20,.4)');
|
||||||
|
poly(ctx, [footCorners(0.3, 0.3, 1.2, 1.2).n, footCorners(0.3, 0.3, 1.2, 1.2).e, footCorners(0.3, 0.3, 1.2, 1.2).s, footCorners(0.3, 0.3, 1.2, 1.2).w], '#b3714f');
|
||||||
|
const hc = isoBox(ctx, 30, 26, 0.6, 0.6, 14, pal(C.wood));
|
||||||
|
pyramid(ctx, hc, 14, 9, C.thatch, 'x');
|
||||||
|
ctx.fillStyle = '#8a5638'; ctx.beginPath(); ctx.ellipse(-14, 30, 10, 5, 0, 0, 7); ctx.fill(); // clay heap
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'timberCamp': {
|
||||||
|
const c = isoBox(ctx, 0, 0, 1.4, 1.2, 16, pal(C.wood));
|
||||||
|
pyramid(ctx, c, 16, 10, C.thatch, 'x');
|
||||||
|
// log pile
|
||||||
|
for (let i = 0; i < 3; i++) { ctx.fillStyle = i % 2 ? '#7a5630' : '#8a6238'; ctx.beginPath(); ctx.ellipse(26 + i * 8, 30 - i * 4, 9, 4, 0, 0, 7); ctx.fill(); ctx.strokeStyle = '#5f4426'; ctx.beginPath(); ctx.ellipse(26 + i * 8, 30 - i * 4, 9, 4, 0, 0, 7); ctx.stroke(); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ironMine': {
|
||||||
|
// rock mound with adit
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 4, TW * 0.7, TH * 0.6);
|
||||||
|
const c = isoBox(ctx, 0, 0, 1.6, 1.6, 24, pal('#6f6a63'));
|
||||||
|
pyramid(ctx, c, 24, 14, '#5d584f', 'x');
|
||||||
|
poly(ctx, [[-4, -6], [6, -12], [6, -2], [-4, 2]], '#241c12'); // cave mouth
|
||||||
|
ctx.fillStyle = C.iron;
|
||||||
|
ctx.beginPath(); ctx.arc(-16, 22, 4, 0, 7); ctx.arc(20, 26, 3, 0, 7); ctx.fill();
|
||||||
|
// support beams
|
||||||
|
ctx.strokeStyle = '#5f4426'; ctx.lineWidth = 2.5;
|
||||||
|
ctx.beginPath(); ctx.moveTo(-6, 2); ctx.lineTo(-6, -12); ctx.moveTo(8, -8); ctx.lineTo(8, -18); ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'crystalMine': {
|
||||||
|
const c = isoBox(ctx, 0, 0, 1.5, 1.5, 22, pal('#5d5866'));
|
||||||
|
pyramid(ctx, c, 22, 12, '#4c4760', 'x');
|
||||||
|
// glowing crystals
|
||||||
|
const glow = ctx.createRadialGradient(0, -8, 2, 0, -8, 26);
|
||||||
|
glow.addColorStop(0, 'rgba(105,220,230,.8)'); glow.addColorStop(1, 'rgba(105,220,230,0)');
|
||||||
|
ctx.fillStyle = glow; ctx.fillRect(-30, -40, 60, 50);
|
||||||
|
const shard = (px: number, py: number, s: number) => poly(ctx, [[px, py - s * 2], [px + s, py], [px, py + s * 0.5], [px - s, py]], C.crystal);
|
||||||
|
shard(-14, 18, 5); shard(12, 24, 7); shard(2, 10, 4);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'potteryWksp': {
|
||||||
|
const c = isoBox(ctx, 0, 0, 1.6, 1.6, 20, pal('#b3714f'));
|
||||||
|
pyramid(ctx, c, 20, 11, C.roofTile, 'x');
|
||||||
|
// kiln chimney
|
||||||
|
isoBox(ctx, 26, 8, 0.35, 0.35, 34, pal('#7d7160'));
|
||||||
|
ctx.fillStyle = 'rgba(120,120,120,.55)';
|
||||||
|
ctx.beginPath(); ctx.arc(31, -40, 4, 0, 7); ctx.arc(34, -48, 5.5, 0, 7); ctx.fill();
|
||||||
|
// amphorae
|
||||||
|
ctx.fillStyle = '#c07a45';
|
||||||
|
ctx.beginPath(); ctx.ellipse(-18, 24, 5, 8, 0, 0, 7); ctx.fill();
|
||||||
|
ctx.beginPath(); ctx.ellipse(-8, 28, 4, 6, 0, 0, 7); ctx.fill();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'furnitureWksp': {
|
||||||
|
const c = isoBox(ctx, 0, 0, 1.6, 1.6, 20, pal('#8a6238'));
|
||||||
|
pyramid(ctx, c, 20, 11, C.roofTile, 'x');
|
||||||
|
// saw + timber props
|
||||||
|
ctx.strokeStyle = '#aab2c0'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(-20, 18, 7, 0.6, 5.4); ctx.stroke();
|
||||||
|
isoBox(ctx, 26, 26, 0.5, 0.3, 8, pal(C.wood));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'oilPress': {
|
||||||
|
const c = isoBox(ctx, 0, 0, 1.6, 1.6, 20, pal('#9c8a52'));
|
||||||
|
pyramid(ctx, c, 20, 11, C.roofTile, 'x');
|
||||||
|
// olive trees + press beam
|
||||||
|
ctx.fillStyle = '#5f7d3a'; ctx.beginPath(); ctx.arc(-20, 16, 7, 0, 7); ctx.fill();
|
||||||
|
ctx.strokeStyle = C.wood; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(-4, -24); ctx.lineTo(16, -10); ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'winery': {
|
||||||
|
const c = isoBox(ctx, 0, 0, 1.6, 1.6, 22, pal('#7a4a5f'));
|
||||||
|
pyramid(ctx, c, 22, 12, '#5f3a4a', 'x');
|
||||||
|
// grape vines
|
||||||
|
for (const [gx, gy] of [[-18, 22], [-8, 27], [22, 18]]) {
|
||||||
|
ctx.fillStyle = '#8e4a9e'; ctx.beginPath(); ctx.arc(gx, gy, 4, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#b06bc0'; ctx.beginPath(); ctx.arc(gx - 2, gy - 2, 2, 0, 7); ctx.fill();
|
||||||
|
}
|
||||||
|
// barrel
|
||||||
|
ctx.fillStyle = '#8a6238'; ctx.beginPath(); ctx.ellipse(16, 28, 8, 5, 0, 0, 7); ctx.fill();
|
||||||
|
ctx.strokeStyle = '#5f4426'; ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'weaponSmith': {
|
||||||
|
const c = isoBox(ctx, 0, 0, 1.6, 1.6, 20, pal('#5d5850'));
|
||||||
|
pyramid(ctx, c, 20, 11, '#3f3b34', 'x');
|
||||||
|
// forge glow
|
||||||
|
const g = ctx.createRadialGradient(-8, 6, 1, -8, 6, 16);
|
||||||
|
g.addColorStop(0, 'rgba(255,140,40,.95)'); g.addColorStop(1, 'rgba(255,80,20,0)');
|
||||||
|
ctx.fillStyle = g; ctx.fillRect(-26, -12, 36, 30);
|
||||||
|
// anvil + sword
|
||||||
|
ctx.fillStyle = '#3a3f47'; ctx.fillRect(-22, 8, 10, 4);
|
||||||
|
ctx.strokeStyle = '#dfe4ec'; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(14, 26); ctx.lineTo(26, 14); ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── civic ──
|
||||||
|
case 'well': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 3, TW * 0.34, TH * 0.3);
|
||||||
|
const c = footCorners(0.15, 0.15, 0.7, 0.7);
|
||||||
|
poly(ctx, [down(c.n, 8), down(c.e, 8), down(c.s, 8), down(c.w, 8)], C.stoneDark);
|
||||||
|
poly(ctx, [down(c.n, 8), down(c.e, 8), down(c.e, 5), down(c.n, 5)], C.stone);
|
||||||
|
ctx.fillStyle = '#1c3040'; ctx.beginPath(); ctx.ellipse((c.n[0]+c.s[0])/2, (c.n[1]+c.s[1])/2 - 6, 10, 5, 0, 0, 7); ctx.fill();
|
||||||
|
ctx.strokeStyle = C.wood; ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath(); ctx.moveTo(-8, -6); ctx.lineTo(-8, -22); ctx.lineTo(8, -14); ctx.lineTo(8, 2); ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'fountain': {
|
||||||
|
const c = footCorners(0, 0, 2, 2);
|
||||||
|
poly(ctx, [c.n, c.e, c.s, c.w], C.stoneDark, 'rgba(30,20,10,.5)');
|
||||||
|
const b = footCorners(0.25, 0.25, 1.5, 1.5);
|
||||||
|
poly(ctx, [down(b.n, 8), down(b.e, 8), down(b.s, 8), down(b.w, 8)], C.stone);
|
||||||
|
poly(ctx, [down(b.n, 8), down(b.e, 8), down(b.e, 6), down(b.n, 6)], shade(C.stone, 1.15));
|
||||||
|
ctx.fillStyle = '#3f7fa5'; poly(ctx, [down(b.n, 6), down(b.e, 6), down(b.s, 6), down(b.w, 6)], '#4f93bd');
|
||||||
|
// jet
|
||||||
|
ctx.strokeStyle = 'rgba(160,220,245,.9)'; ctx.lineWidth = 2.5;
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, -14); ctx.quadraticCurveTo(6, -26, 0, -34); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, -14); ctx.quadraticCurveTo(-7, -24, -2, -30); ctx.stroke();
|
||||||
|
isoBox(ctx, -4, -4, 0.2, 0.2, 18, pal(C.marble));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'prefecture': case 'engineerPost': {
|
||||||
|
const acc = key === 'prefecture' ? '#b03a2e' : '#3a6ea5';
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 3, TW * 0.44, TH * 0.38);
|
||||||
|
const c = isoBox(ctx, 0, 0, 0.9, 0.9, 18, pal('#c8b48a'));
|
||||||
|
pyramid(ctx, c, 18, 10, acc === '#b03a2e' ? '#8f2f26' : '#2f547f', 'x');
|
||||||
|
ctx.fillStyle = acc; ctx.fillRect(-3, -34, 6, 10); // banner
|
||||||
|
poly(ctx, [[-3, -34], [8, -31], [-3, -28]], acc);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'forum': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 6, TW * 0.9, TH * 0.75);
|
||||||
|
const c = isoBox(ctx, 0, 0, 1.7, 1.7, 24, pal('#d8cbb0'));
|
||||||
|
pyramid(ctx, c, 24, 12, C.roofTile, 'x');
|
||||||
|
for (const px of [-22, -12, -2, 8]) { ctx.fillStyle = C.marble; ctx.fillRect(px, -20, 4, 18); }
|
||||||
|
ctx.fillStyle = C.gold; ctx.fillRect(-24, -22, 36, 2.5);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'senate': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 8, TW * 1.25, TH * 1.05);
|
||||||
|
const c = isoBox(ctx, 0, 0, 2.6, 2.6, 40, pal('#e6ddc8'));
|
||||||
|
// dome
|
||||||
|
ctx.fillStyle = '#7a94a8'; ctx.beginPath(); ctx.ellipse(0, -40 - 8, 26, 16, 0, Math.PI, 0); ctx.fill();
|
||||||
|
ctx.fillStyle = shade('#7a94a8', 1.2); ctx.beginPath(); ctx.ellipse(-6, -48 - 8, 16, 10, 0, Math.PI, 0); ctx.fill();
|
||||||
|
ctx.strokeStyle = C.gold; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, -64); ctx.lineTo(0, -74); ctx.stroke();
|
||||||
|
ctx.fillStyle = C.gold; ctx.beginPath(); ctx.arc(0, -76, 3, 0, 7); ctx.fill();
|
||||||
|
for (const px of [-34, -22, -10, 2, 14, 26]) { ctx.fillStyle = C.marble; ctx.fillRect(px, -34, 5, 32); }
|
||||||
|
ctx.fillStyle = C.gold; ctx.fillRect(-38, -36, 70, 3);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── religion ──
|
||||||
|
case 'templeJupiter': case 'templeCeres': case 'templeNeptune': case 'templeMars': {
|
||||||
|
const roofC = { templeJupiter: '#c9a227', templeCeres: '#5f943e', templeNeptune: '#3a6ea5', templeMars: '#b03a2e' }[key]!;
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 5, TW * 0.85, TH * 0.7);
|
||||||
|
const c = isoBox(ctx, 0, 0, 1.7, 1.7, 22, pal(C.marble));
|
||||||
|
// stepped base
|
||||||
|
poly(ctx, [down(footCorners(-0.15, -0.15, 2, 2).n, 4), down(footCorners(-0.15, -0.15, 2, 2).e, 4), down(footCorners(-0.15, -0.15, 2, 2).s, 4), down(footCorners(-0.15, -0.15, 2, 2).w, 4)], shade(C.marble, 0.92));
|
||||||
|
pyramid(ctx, c, 22, 16, roofC, 'x');
|
||||||
|
// pediment triangle
|
||||||
|
poly(ctx, [[0, -22 - 16 - 2], [-26, -22 - 4], [0, -22 - 4]].map(([x, y]) => [x, y] as [number, number]), shade(roofC, 1.1));
|
||||||
|
// columns front-left
|
||||||
|
for (const px of [-24, -14, -4]) { ctx.fillStyle = C.marble; ctx.fillRect(px, -20, 4, 20); }
|
||||||
|
// god sigil dot
|
||||||
|
ctx.fillStyle = roofC; ctx.beginPath(); ctx.arc(0, -44, 4, 0, 7); ctx.fill();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'oracle': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 5, TW * 0.8, TH * 0.7);
|
||||||
|
// circular tholos
|
||||||
|
const g = ctx.createRadialGradient(0, -20, 4, 0, -20, 44);
|
||||||
|
g.addColorStop(0, 'rgba(180,140,255,.55)'); g.addColorStop(1, 'rgba(180,140,255,0)');
|
||||||
|
ctx.fillStyle = g; ctx.fillRect(-50, -70, 100, 90);
|
||||||
|
ctx.fillStyle = C.marble;
|
||||||
|
ctx.beginPath(); ctx.ellipse(0, -6, 30, 15, 0, 0, 7); ctx.fill();
|
||||||
|
for (let i = 0; i < 8; i++) { const a = i / 8 * Math.PI * 2; ctx.fillStyle = shade(C.marble, i % 2 ? 0.85 : 1.02); ctx.fillRect(Math.cos(a) * 24 - 2.5, -8 - 22 + Math.sin(a) * 6, 5, 24); }
|
||||||
|
poly(ctx, [[0, -46], [-30, -26], [0, -22], [30, -26]], '#8f7ac9');
|
||||||
|
poly(ctx, [[0, -46], [30, -26], [0, -22]], shade('#8f7ac9', 0.7));
|
||||||
|
ctx.fillStyle = '#d8c6ff'; ctx.beginPath(); ctx.arc(0, -34, 5, 0, 7); ctx.fill();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── entertainment ──
|
||||||
|
case 'theater': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 5, TW * 0.85, TH * 0.7);
|
||||||
|
const c = isoBox(ctx, 6, 6, 1.1, 1.1, 20, pal('#cfc3a5'));
|
||||||
|
pyramid(ctx, c, 20, 11, C.roofTile, 'y');
|
||||||
|
// semicircle seats
|
||||||
|
for (let r = 0; r < 3; r++) {
|
||||||
|
ctx.strokeStyle = shade('#b8ab88', 1 - r * 0.12); ctx.lineWidth = 4;
|
||||||
|
ctx.beginPath(); ctx.ellipse(-8 - r * 2, 8 + r * 4, 22 + r * 8, 11 + r * 4, 0, 0.3, Math.PI - 0.3); ctx.stroke();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'amphitheater': case 'colosseum': {
|
||||||
|
const big = key === 'colosseum';
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 10, big ? TW * 1.3 : TW, big ? TH * 1.1 : TH * 0.85);
|
||||||
|
// ring walls
|
||||||
|
for (let ring = 0; ring < (big ? 3 : 2); ring++) {
|
||||||
|
const rx = (big ? 46 : 32) - ring * 8, ry = (big ? 23 : 16) - ring * 4;
|
||||||
|
const hh = (big ? 30 : 22) - ring * 6;
|
||||||
|
ctx.fillStyle = ring % 2 ? '#b8ab88' : shade('#b8ab88', 0.85);
|
||||||
|
ctx.beginPath(); ctx.ellipse(0, 6, rx, ry, 0, 0, Math.PI * 2); ctx.fill();
|
||||||
|
ctx.fillStyle = shade('#cfc3a5', 0.95);
|
||||||
|
ctx.beginPath(); ctx.ellipse(0, 6 - hh, rx, ry, 0, 0, Math.PI * 2); ctx.fill();
|
||||||
|
ctx.fillStyle = '#8f7f5f';
|
||||||
|
ctx.beginPath(); ctx.ellipse(0, 6 - hh, rx * 0.8, ry * 0.8, 0, 0, Math.PI * 2); ctx.fill();
|
||||||
|
}
|
||||||
|
if (big) { ctx.fillStyle = '#b03a2e'; ctx.fillRect(-4, -78, 8, 14); poly(ctx, [[-4, -78], [12, -74], [-4, -70]], '#b03a2e'); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── education / health ──
|
||||||
|
case 'school': case 'academy': case 'library': {
|
||||||
|
const roofC = key === 'school' ? '#3f7d43' : key === 'academy' ? '#3a6ea5' : '#c9a227';
|
||||||
|
const wz = key === 'school' ? 20 : 26;
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 4, TW * 0.7, TH * 0.6);
|
||||||
|
const c = isoBox(ctx, 0, 0, 1.5, 1.5, wz, pal('#ddd2b8'));
|
||||||
|
pyramid(ctx, c, wz, 12, roofC, 'x');
|
||||||
|
// scroll banner
|
||||||
|
ctx.fillStyle = '#f2ead2'; ctx.fillRect(-4, -wz - 16, 8, 12);
|
||||||
|
ctx.fillStyle = roofC; ctx.font = 'bold 8px serif'; ctx.textAlign = 'center'; ctx.fillText(key === 'library' ? '✎' : 'A', 0, -wz - 7);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'clinic': case 'hospital': {
|
||||||
|
const wz = key === 'hospital' ? 28 : 18;
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 4, TW * 0.7, TH * 0.6);
|
||||||
|
const c = isoBox(ctx, 0, 0, 1.5, 1.5, wz, pal('#e8e2d2'));
|
||||||
|
pyramid(ctx, c, wz, 11, '#7d9c8f', 'x');
|
||||||
|
// serpent staff sigil
|
||||||
|
ctx.strokeStyle = '#4f7d5f'; ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, -wz - 14); ctx.lineTo(0, -wz - 4); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.arc(-3, -wz - 9, 3, 0.4, 4.6); ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── military ──
|
||||||
|
case 'wall': {
|
||||||
|
const p = pal('#9a9184');
|
||||||
|
const c = footCorners(0, 0, 1, 1);
|
||||||
|
isoBox(ctx, 0, 0, 1, 1, 30, p);
|
||||||
|
// crenellations
|
||||||
|
for (const [mx, my] of [[-TW / 4 + 2, TH / 4 - 6], [2, TH / 2 + 2]] as Array<[number, number]>) {
|
||||||
|
ctx.fillStyle = shade('#9a9184', 1.1); ctx.fillRect(mx, my - 34, 7, 8);
|
||||||
|
}
|
||||||
|
void c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'tower': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 4, TW * 0.7, TH * 0.55);
|
||||||
|
const p = pal('#8f867a');
|
||||||
|
const c = footCorners(0.2, 0.2, 1.5, 1.5);
|
||||||
|
isoBox(ctx, 0.2, 0.2, 1.5, 1.5, 44, p);
|
||||||
|
// battlement
|
||||||
|
poly(ctx, [down(c.n, 50), down(c.e, 50), down(c.s, 50), down(c.w, 50)], shade('#8f867a', 1.12));
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const t = i / 4, px = c.w[0] + (c.e[0] - c.w[0]) * t + 4, py = c.w[1] + (c.e[1] - c.w[1]) * t;
|
||||||
|
ctx.fillStyle = shade('#8f867a', 1.2); ctx.fillRect(px - 3, py - 56, 6, 7);
|
||||||
|
}
|
||||||
|
// arrow slit + banner
|
||||||
|
ctx.fillStyle = '#241c12'; ctx.fillRect(-2, -34, 4, 10);
|
||||||
|
ctx.strokeStyle = '#5f4426'; ctx.beginPath(); ctx.moveTo(-14, -50); ctx.lineTo(-14, -66); ctx.stroke();
|
||||||
|
poly(ctx, [[-14, -66], [-2, -62], [-14, -58]], '#b03a2e');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'gatehouse': {
|
||||||
|
const p = pal('#9a9184');
|
||||||
|
isoBox(ctx, 0, 0, 2, 2, 34, p);
|
||||||
|
// arch opening on SE face
|
||||||
|
poly(ctx, [[6, 4], [22, -4], [22, 14], [6, 22]], '#241c12');
|
||||||
|
ctx.strokeStyle = shade('#9a9184', 1.2); ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath(); ctx.moveTo(6, 12); ctx.quadraticCurveTo(14, -2, 22, 6); ctx.stroke();
|
||||||
|
// portcullis lines
|
||||||
|
ctx.strokeStyle = '#4a4438'; ctx.lineWidth = 1;
|
||||||
|
for (let i = 1; i < 4; i++) { ctx.beginPath(); ctx.moveTo(6 + i * 4, 6 + i * -1.5 + 2); ctx.lineTo(6 + i * 4, 20 + i * -1.5); ctx.stroke(); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'barracks': {
|
||||||
|
const c = isoBox(ctx, 0, 0, 1.7, 1.3, 22, pal('#a08a68'));
|
||||||
|
pyramid(ctx, c, 22, 11, '#6f5a3a', 'x');
|
||||||
|
// shield decor
|
||||||
|
ctx.fillStyle = '#b03a2e'; ctx.beginPath(); ctx.arc(-16, -8, 6, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = C.gold; ctx.beginPath(); ctx.arc(-16, -8, 3, 0, 7); ctx.fill();
|
||||||
|
// spear rack
|
||||||
|
ctx.strokeStyle = '#7a6238'; ctx.lineWidth = 1.5;
|
||||||
|
for (let i = 0; i < 3; i++) { ctx.beginPath(); ctx.moveTo(14 + i * 6, 24); ctx.lineTo(20 + i * 6, 8); ctx.stroke(); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'fort': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 8, TW * 1.15, TH * 0.95);
|
||||||
|
// palisade ring
|
||||||
|
ctx.strokeStyle = '#7a5a34'; ctx.lineWidth = 3;
|
||||||
|
for (let i = 0; i < 12; i++) { const a = i / 12 * Math.PI * 2; const px = Math.cos(a) * 40, py = 10 + Math.sin(a) * 20; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, py - 18); ctx.stroke(); }
|
||||||
|
const c = isoBox(ctx, 0, 0, 2.2, 2.2, 30, pal('#9c8a6a'));
|
||||||
|
pyramid(ctx, c, 30, 14, '#6f5a3a', 'x');
|
||||||
|
// standard
|
||||||
|
ctx.strokeStyle = '#4a3418'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, -44); ctx.lineTo(0, -64); ctx.stroke();
|
||||||
|
poly(ctx, [[0, -64], [16, -60], [0, -55]], '#b03a2e');
|
||||||
|
ctx.fillStyle = C.gold; ctx.beginPath(); ctx.arc(0, -67, 3, 0, 7); ctx.fill();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── magic ──
|
||||||
|
case 'leyPylon': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 3, TW * 0.3, TH * 0.26);
|
||||||
|
const c = footCorners(0.2, 0.2, 0.6, 0.6);
|
||||||
|
poly(ctx, [down(c.n, 10), down(c.e, 10), down(c.s, 10), down(c.w, 10)], C.stoneDark);
|
||||||
|
const g = ctx.createRadialGradient(0, -26, 2, 0, -26, 30);
|
||||||
|
g.addColorStop(0, 'rgba(110,225,240,.85)'); g.addColorStop(1, 'rgba(110,225,240,0)');
|
||||||
|
ctx.fillStyle = g; ctx.fillRect(-34, -62, 68, 70);
|
||||||
|
poly(ctx, [[0, -48], [7, -18], [0, -8], [-7, -18]], C.crystal);
|
||||||
|
poly(ctx, [[0, -48], [7, -18], [0, -8]], shade(C.crystal, 1.3));
|
||||||
|
// orbiting motes
|
||||||
|
ctx.fillStyle = 'rgba(180,245,255,.9)';
|
||||||
|
ctx.beginPath(); ctx.arc(10, -30, 2, 0, 7); ctx.fill();
|
||||||
|
ctx.beginPath(); ctx.arc(-9, -40, 1.6, 0, 7); ctx.fill();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'mageTower': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 5, TW * 0.65, TH * 0.55);
|
||||||
|
const p = pal('#6f5a8f');
|
||||||
|
isoBox(ctx, 0.3, 0.3, 1.3, 1.3, 52, p);
|
||||||
|
const c = footCorners(0.3, 0.3, 1.3, 1.3);
|
||||||
|
poly(ctx, [down(c.n, 58), down(c.e, 58), down(c.s, 58), down(c.w, 58)], shade('#6f5a8f', 1.15));
|
||||||
|
// conical hat roof
|
||||||
|
poly(ctx, [[0, -84], [down(c.e, 58)[0], down(c.e, 58)[1] - 4], [down(c.s, 58)[0], down(c.s, 58)[1] - 4], [down(c.w, 58)[0], down(c.w, 58)[1] - 4]], '#4f3f6f');
|
||||||
|
poly(ctx, [[0, -84], down(c.w, 58), down(c.n, 58)], '#5f4f83');
|
||||||
|
// floating crystal
|
||||||
|
const g = ctx.createRadialGradient(0, -96, 1, 0, -96, 22);
|
||||||
|
g.addColorStop(0, 'rgba(150,120,255,.9)'); g.addColorStop(1, 'rgba(150,120,255,0)');
|
||||||
|
ctx.fillStyle = g; ctx.fillRect(-24, -118, 48, 44);
|
||||||
|
poly(ctx, [[0, -104], [5, -96], [0, -88], [-5, -96]], '#c9b0ff');
|
||||||
|
// rune window
|
||||||
|
ctx.fillStyle = '#b49aff'; ctx.fillRect(-2, -40, 5, 9);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'arcaneLibrary': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 7, TW * 1.05, TH * 0.9);
|
||||||
|
const c = isoBox(ctx, 0, 0, 2.5, 2.5, 34, pal('#4f3f6f'));
|
||||||
|
pyramid(ctx, c, 34, 16, '#3a2d54', 'x');
|
||||||
|
const g = ctx.createRadialGradient(0, -30, 4, 0, -30, 60);
|
||||||
|
g.addColorStop(0, 'rgba(150,120,255,.4)'); g.addColorStop(1, 'rgba(150,120,255,0)');
|
||||||
|
ctx.fillStyle = g; ctx.fillRect(-70, -100, 140, 110);
|
||||||
|
// runes on walls
|
||||||
|
ctx.fillStyle = '#b49aff'; ctx.font = 'bold 10px serif';
|
||||||
|
ctx.fillText('ᚠ', -18, -20); ctx.fillText('ᛗ', 6, -14); ctx.fillText('ᚨ', -4, -30);
|
||||||
|
// floating tome
|
||||||
|
ctx.fillStyle = '#e8dcc0'; ctx.fillRect(-24, -52, 10, 7);
|
||||||
|
ctx.fillStyle = '#b03a2e'; ctx.fillRect(-24, -53, 10, 2);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'golemFoundry': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 5, TW * 0.8, TH * 0.7);
|
||||||
|
const c = isoBox(ctx, 0, 0, 1.6, 1.6, 24, pal('#6a6458'));
|
||||||
|
pyramid(ctx, c, 24, 12, '#54504a', 'x');
|
||||||
|
// twin chimneys + smoke
|
||||||
|
isoBox(ctx, 24, 6, 0.3, 0.3, 40, pal('#54504a'));
|
||||||
|
ctx.fillStyle = 'rgba(150,150,150,.5)';
|
||||||
|
ctx.beginPath(); ctx.arc(29, -46, 5, 0, 7); ctx.arc(33, -56, 7, 0, 7); ctx.fill();
|
||||||
|
// half-built golem
|
||||||
|
ctx.fillStyle = '#7f8791'; ctx.fillRect(-22, -6, 12, 14); ctx.fillRect(-20, -12, 8, 7);
|
||||||
|
ctx.fillStyle = '#59c8d8'; ctx.fillRect(-18, -10, 4, 3);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── heroes ──
|
||||||
|
case 'heroHall': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 8, TW * 1.15, TH * 0.95);
|
||||||
|
const c = isoBox(ctx, 0, 0, 2.4, 2.4, 36, pal('#efe6cd'));
|
||||||
|
pyramid(ctx, c, 36, 18, C.gold, 'x');
|
||||||
|
// statue pairs
|
||||||
|
for (const sx of [-30, -16, 16, 30]) {
|
||||||
|
ctx.fillStyle = C.marble; ctx.fillRect(sx - 2, -18, 4, 18);
|
||||||
|
ctx.beginPath(); ctx.arc(sx, -21, 3, 0, 7); ctx.fill();
|
||||||
|
}
|
||||||
|
// banners
|
||||||
|
for (const bx of [-34, 34]) {
|
||||||
|
ctx.strokeStyle = '#5a4426'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(bx, -40); ctx.lineTo(bx, -62); ctx.stroke();
|
||||||
|
poly(ctx, [[bx, -62], [bx + (bx < 0 ? -12 : 12), -57], [bx, -52]], '#b03a2e');
|
||||||
|
}
|
||||||
|
// laurel crest
|
||||||
|
ctx.strokeStyle = C.gold; ctx.lineWidth = 2.5;
|
||||||
|
ctx.beginPath(); ctx.arc(0, -60, 10, Math.PI * 0.15, Math.PI * 0.85, true); ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'tradingPost': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 6, TW * 1.05, TH * 0.85);
|
||||||
|
const c = isoBox(ctx, 0, 0, 2.3, 2.3, 26, pal('#b89a68'));
|
||||||
|
pyramid(ctx, c, 26, 13, '#4f7d8f', 'x');
|
||||||
|
// awning + crates + camel-less caravan gear
|
||||||
|
poly(ctx, [[-30, -6], [-6, 6], [-6, 16], [-30, 4]], '#c94f3d');
|
||||||
|
poly(ctx, [[-30, -6], [-6, 6], [-6, 10], [-30, -2]], '#e07050');
|
||||||
|
isoBox(ctx, 30, 26, 0.45, 0.45, 10, pal(C.wood));
|
||||||
|
isoBox(ctx, 38, 20, 0.45, 0.45, 12, pal('#b89a68'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── decor ──
|
||||||
|
case 'plaza': {
|
||||||
|
const c = footCorners(0, 0, 1, 1);
|
||||||
|
poly(ctx, [c.n, c.e, c.s, c.w], '#b5a888', 'rgba(90,80,60,.35)');
|
||||||
|
ctx.strokeStyle = 'rgba(120,108,84,.6)'; ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath(); ctx.moveTo(c.w[0], c.w[1]); ctx.lineTo(c.e[0], c.e[1]); ctx.moveTo(c.n[0], c.n[1]); ctx.lineTo(c.s[0], c.s[1]); ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'garden': {
|
||||||
|
const c = footCorners(0, 0, 1, 1);
|
||||||
|
poly(ctx, [c.n, c.e, c.s, c.w], '#5f8f3f', 'rgba(40,70,20,.4)');
|
||||||
|
ctx.fillStyle = '#6fa04a'; ctx.beginPath(); ctx.arc(-8, 10, 7, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#7fae55'; ctx.beginPath(); ctx.arc(10, 16, 6, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#c94f3d'; ctx.beginPath(); ctx.arc(-8, 8, 1.8, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#e8c93d'; ctx.beginPath(); ctx.arc(11, 14, 1.8, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#d87fd0'; ctx.beginPath(); ctx.arc(2, 22, 1.8, 0, 7); ctx.fill();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'statue': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 3, TW * 0.34, TH * 0.3);
|
||||||
|
isoBox(ctx, 0.2, 0.2, 0.6, 0.6, 8, pal(C.stone));
|
||||||
|
ctx.fillStyle = '#cfc8b8'; // figure
|
||||||
|
ctx.fillRect(-4, -26, 8, 18);
|
||||||
|
ctx.beginPath(); ctx.arc(0, -30, 4.5, 0, 7); ctx.fill();
|
||||||
|
ctx.strokeStyle = '#cfc8b8'; ctx.lineWidth = 2.5;
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, -22); ctx.lineTo(8, -32); ctx.stroke(); // raised arm
|
||||||
|
ctx.strokeStyle = C.gold; ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath(); ctx.arc(0, -34, 6, Math.PI * 1.15, Math.PI * 1.85); ctx.stroke(); // laurel
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── map features ──
|
||||||
|
case 'feat_tree': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 2, TW * 0.3, TH * 0.24);
|
||||||
|
ctx.fillStyle = '#5f4426'; ctx.fillRect(-2, -14, 4, 15);
|
||||||
|
ctx.fillStyle = '#3e6b2a'; ctx.beginPath(); ctx.arc(0, -20, 11, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#4f8236'; ctx.beginPath(); ctx.arc(-3, -23, 7, 0, 7); ctx.fill();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'feat_pine': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 2, TW * 0.28, TH * 0.22);
|
||||||
|
ctx.fillStyle = '#54401f'; ctx.fillRect(-1.5, -6, 3, 8);
|
||||||
|
poly(ctx, [[0, -34], [-9, -14], [9, -14]], '#2f5a28');
|
||||||
|
poly(ctx, [[0, -26], [-7, -8], [7, -8]], '#3c6e30');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'feat_rock': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 2, TW * 0.32, TH * 0.26);
|
||||||
|
const c = isoBox(ctx, 0.05, 0.05, 0.85, 0.85, 12, pal('#8f867a'));
|
||||||
|
pyramid(ctx, c, 12, 7, '#9a9184', 'x');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'feat_clay': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 2, TW * 0.34, TH * 0.28);
|
||||||
|
const c = footCorners(0.08, 0.08, 0.84, 0.84);
|
||||||
|
poly(ctx, [c.n, c.e, c.s, c.w], '#a3714b');
|
||||||
|
poly(ctx, [footCorners(0.25, 0.25, 0.5, 0.5).n, footCorners(0.25, 0.25, 0.5, 0.5).e, footCorners(0.25, 0.25, 0.5, 0.5).s, footCorners(0.25, 0.25, 0.5, 0.5).w], '#b3714f');
|
||||||
|
ctx.fillStyle = '#c07a45'; ctx.beginPath(); ctx.arc(-8, 8, 3, 0, 7); ctx.fill();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'feat_iron': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 2, TW * 0.32, TH * 0.26);
|
||||||
|
const c = isoBox(ctx, 0.05, 0.05, 0.8, 0.8, 10, pal('#77726a'));
|
||||||
|
pyramid(ctx, c, 10, 6, '#8d93a1', 'x');
|
||||||
|
ctx.fillStyle = '#aab2c0';
|
||||||
|
ctx.beginPath(); ctx.arc(-6, 6, 2.5, 0, 7); ctx.arc(7, 12, 2, 0, 7); ctx.fill();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'feat_crystal': {
|
||||||
|
ellipseShadow(ctx, 0, TH / 2 + 2, TW * 0.3, TH * 0.24);
|
||||||
|
const g = ctx.createRadialGradient(0, -10, 2, 0, -10, 26);
|
||||||
|
g.addColorStop(0, 'rgba(110,225,240,.75)'); g.addColorStop(1, 'rgba(110,225,240,0)');
|
||||||
|
ctx.fillStyle = g; ctx.fillRect(-28, -38, 56, 48);
|
||||||
|
poly(ctx, [[0, -26], [6, -6], [0, 0], [-6, -6]], C.crystal);
|
||||||
|
poly(ctx, [[0, -26], [6, -6], [0, 0]], shade(C.crystal, 1.35));
|
||||||
|
poly(ctx, [[-10, -12], [-5, -2], [-12, 2], [-15, -6]], shade(C.crystal, 0.85));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'feat_rubbleflat': break;
|
||||||
|
|
||||||
|
default: { // fallback: simple box
|
||||||
|
const c = isoBox(ctx, 0, 0, fw, fh, 20, pal('#a89a86'));
|
||||||
|
pyramid(ctx, c, 20, 10, C.roofTile, 'x');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rubble sprite for destroyed buildings. */
|
||||||
|
export function getRubble(): Sprite {
|
||||||
|
const key = '__rubble';
|
||||||
|
let s = cache.get(key);
|
||||||
|
if (s) return s;
|
||||||
|
s = makeCanvas(TW + PAD * 2, TH + 30 + PAD * 2, PAD + TW / 2, PAD + 20,
|
||||||
|
(ctx) => {
|
||||||
|
const c = footCorners(0, 0, 1, 1);
|
||||||
|
poly(ctx, [c.n, c.e, c.s, c.w], '#6f655a', 'rgba(30,20,10,.5)');
|
||||||
|
ctx.fillStyle = '#7d7160';
|
||||||
|
for (const [rx, ry, rr] of [[-10, 6, 5], [4, 10, 6], [12, 4, 4], [-2, 2, 4], [-14, 12, 3]] as const) {
|
||||||
|
ctx.beginPath(); ctx.arc(rx, ry, rr, 0, 7); ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = '#4f463c'; ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath(); ctx.moveTo(-8, 4); ctx.lineTo(6, 12); ctx.moveTo(-12, 12); ctx.lineTo(2, 2); ctx.stroke();
|
||||||
|
});
|
||||||
|
cache.set(key, s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
+535
@@ -0,0 +1,535 @@
|
|||||||
|
// ─── City simulation core ──────────────────────────────────────────────────
|
||||||
|
import type { Building, CityState, Good, Scenario, ServiceType, Tile, Walker } from './types';
|
||||||
|
import { def } from '../data/buildings';
|
||||||
|
import { RNG, clamp, Emitter } from '../core/util';
|
||||||
|
import { makeSandbox } from '../data/scenarios';
|
||||||
|
|
||||||
|
export const MONTH_SECONDS = 10; // real seconds per month at speed 1
|
||||||
|
export const SPEEDS = [0, 0.5, 1, 2.5];
|
||||||
|
|
||||||
|
// ── events the UI listens to ──
|
||||||
|
export interface GameEvents {
|
||||||
|
message: { text: string; icon: string; kind: 'info' | 'warn' | 'danger' | 'good' };
|
||||||
|
month: { month: number; year: number };
|
||||||
|
victory: { won: boolean; reason?: string };
|
||||||
|
fundsChanged: void;
|
||||||
|
sfx: string;
|
||||||
|
}
|
||||||
|
export const bus = new Emitter<GameEvents>();
|
||||||
|
|
||||||
|
export function addMsg(c: CityState, text: string, kind: 'info' | 'warn' | 'danger' | 'good', icon = '📜') {
|
||||||
|
const m = { id: c.nextMsgId++, month: c.month, year: c.year, icon, text, kind };
|
||||||
|
c.messages.unshift(m);
|
||||||
|
if (c.messages.length > 120) c.messages.pop();
|
||||||
|
bus.emit('message', { text, icon, kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── map generation ──────────────────────────────────────────────────────────
|
||||||
|
function valueNoise(rng: RNG, W: number, H: number, cell: number): (x: number, y: number) => number {
|
||||||
|
const gw = Math.ceil(W / cell) + 2, gh = Math.ceil(H / cell) + 2;
|
||||||
|
const g = new Float32Array(gw * gh);
|
||||||
|
for (let i = 0; i < g.length; i++) g[i] = rng.next();
|
||||||
|
return (x: number, y: number) => {
|
||||||
|
const fx = x / cell, fy = y / cell;
|
||||||
|
const x0 = fx | 0, y0 = fy | 0;
|
||||||
|
const tx = fx - x0, ty = fy - y0;
|
||||||
|
const sx = tx * tx * (3 - 2 * tx), sy = ty * ty * (3 - 2 * ty);
|
||||||
|
const a = g[y0 * gw + x0], b = g[y0 * gw + x0 + 1], cc = g[(y0 + 1) * gw + x0], d = g[(y0 + 1) * gw + x0 + 1];
|
||||||
|
return a + (b - a) * sx + (cc - a + (a - b - cc + d) * sx) * sy;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function blob(rng: RNG, cx: number, cy: number, r: number): Array<[number, number]> {
|
||||||
|
const pts: Array<[number, number]> = [];
|
||||||
|
const n = Math.round(r * r * 3.5);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = rng.next() * Math.PI * 2, rr = Math.sqrt(rng.next()) * r;
|
||||||
|
pts.push([Math.round(cx + Math.cos(a) * rr), Math.round(cy + Math.sin(a) * rr)]);
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMap(sc: Scenario): { tiles: Tile[]; entryPoints: Array<{ x: number; y: number }> } {
|
||||||
|
const rng = new RNG(sc.mapSeed);
|
||||||
|
const W = sc.mapW, H = sc.mapH;
|
||||||
|
const nWater = valueNoise(rng, W, H, 9);
|
||||||
|
const nFert = valueNoise(rng, W, H, 7);
|
||||||
|
const nTree = valueNoise(rng, W, H, 5);
|
||||||
|
const tiles: Tile[] = new Array(W * H);
|
||||||
|
|
||||||
|
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
|
||||||
|
const edge = Math.min(x, y, W - 1 - x, H - 1 - y);
|
||||||
|
let water = nWater(x, y) < 0.17 && edge > 1;
|
||||||
|
if ((x === 0 || y === 0 || x === W - 1 || y === H - 1)) water = false;
|
||||||
|
tiles[y * W + x] = {
|
||||||
|
terrain: water ? 'water' : 'grass',
|
||||||
|
bld: 0, road: false, plaza: false, garden: false, wall: 0,
|
||||||
|
fert: clamp((nFert(x, y) - 0.32) * 260, 0, 100),
|
||||||
|
desirability: 0, entPts: 0, fireFervor: 0, waterCover: 0,
|
||||||
|
variant: rng.int(0, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const at = (x: number, y: number) => tiles[y * W + x];
|
||||||
|
const setT = (x: number, y: number, t: Tile['terrain']) => {
|
||||||
|
if (x >= 0 && y >= 0 && x < W && y < H && at(x, y).terrain !== 'water') at(x, y).terrain = t;
|
||||||
|
};
|
||||||
|
// forests
|
||||||
|
for (let k = 0; k < 14; k++) {
|
||||||
|
const cx = rng.int(2, W - 3), cy = rng.int(2, H - 3);
|
||||||
|
for (const [x, y] of blob(rng, cx, cy, rng.range(2.5, 5))) setT(x, y, 'tree');
|
||||||
|
}
|
||||||
|
// rock outcrops with iron
|
||||||
|
for (let k = 0; k < 6; k++) {
|
||||||
|
const cx = rng.int(3, W - 4), cy = rng.int(3, H - 4);
|
||||||
|
const pts = blob(rng, cx, cy, rng.range(2, 4));
|
||||||
|
const hasIron = rng.chance(0.55);
|
||||||
|
for (const [x, y] of pts) setT(x, y, hasIron ? 'iron' : 'rock');
|
||||||
|
}
|
||||||
|
// clay near water
|
||||||
|
for (let k = 0; k < 8; k++) {
|
||||||
|
const cx = rng.int(2, W - 3), cy = rng.int(2, H - 3);
|
||||||
|
for (const [x, y] of blob(rng, cx, cy, rng.range(1.5, 3))) setT(x, y, 'clay');
|
||||||
|
}
|
||||||
|
// crystal seams (ley lines)
|
||||||
|
const seams = rng.int(2, 3);
|
||||||
|
for (let k = 0; k < seams; k++) {
|
||||||
|
let x = rng.int(4, W - 8), y = rng.int(4, H - 8);
|
||||||
|
const dx = rng.pick([-1, 1]), dy = rng.pick([-1, 0, 1]);
|
||||||
|
for (let s = 0; s < rng.int(4, 7); s++) {
|
||||||
|
setT(x, y, 'crystal');
|
||||||
|
if (rng.chance(0.6)) setT(x + 1, y, 'crystal');
|
||||||
|
x += dx; y += dy;
|
||||||
|
if (x < 2 || y < 2 || x >= W - 2 || y >= H - 2) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fertile meadows
|
||||||
|
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
|
||||||
|
const t = at(x, y);
|
||||||
|
if (t.terrain === 'grass' && t.fert > 45 && nFert(x + 31, y + 17) > 0.52) t.terrain = 'fertile';
|
||||||
|
}
|
||||||
|
|
||||||
|
// entry points on each landable edge
|
||||||
|
const entryPoints: Array<{ x: number; y: number }> = [];
|
||||||
|
const candEdge = (x: number, y: number) => { const t = at(x, y); if (t.terrain !== 'water') entryPoints.push({ x, y }); };
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
candEdge(Math.floor(W * (i + 1) / 4), 1);
|
||||||
|
candEdge(Math.floor(W * (i + 1) / 4), H - 2);
|
||||||
|
candEdge(1, Math.floor(H * (i + 1) / 4));
|
||||||
|
candEdge(W - 2, Math.floor(H * (i + 1) / 4));
|
||||||
|
}
|
||||||
|
return { tiles, entryPoints };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── city creation ───────────────────────────────────────────────────────────
|
||||||
|
export function createCity(sc: Scenario): CityState {
|
||||||
|
const { tiles, entryPoints } = generateMap(sc);
|
||||||
|
const c: CityState = {
|
||||||
|
scenarioId: sc.id,
|
||||||
|
seed: sc.mapSeed, W: sc.mapW, H: sc.mapH,
|
||||||
|
month: 0, year: 0,
|
||||||
|
funds: sc.funds,
|
||||||
|
mana: sc.allowSpellsFromStart ? 40 : 0,
|
||||||
|
manaMax: 50,
|
||||||
|
population: 0,
|
||||||
|
taxRate: 9, wageRate: 1,
|
||||||
|
laborers: 0, employed: 0, unemploymentPct: 0,
|
||||||
|
sentiment: 60,
|
||||||
|
ratings: { culture: 0, prosperity: 0, peace: 50, favor: 60 },
|
||||||
|
buildings: new Map(), nextBldId: 1,
|
||||||
|
walkers: new Map(), nextWalkerId: 1,
|
||||||
|
tiles,
|
||||||
|
messages: [], nextMsgId: 1,
|
||||||
|
trades: sc.openTrades.map(name => ({ name, buys: [], wants: [], exports: [], imports: [], open: true, priceFactor: 1 })),
|
||||||
|
spellState: {
|
||||||
|
unlocked: new Set(sc.allowSpellsFromStart ? ['blessing', 'heal', 'haste', 'firestorm'] : ['blessing']),
|
||||||
|
cooldowns: {}, buffs: { hasteUntil: 0, shieldUntil: 0 },
|
||||||
|
areaBuffs: [],
|
||||||
|
},
|
||||||
|
stats: { monthsSinceInvasion: 24, monstersSlain: 0, invadersSlain: 0, totalDeaths: 0 },
|
||||||
|
entryPoints,
|
||||||
|
victory: null,
|
||||||
|
paused: false,
|
||||||
|
speedIdx: 2,
|
||||||
|
dirtyCoverage: true,
|
||||||
|
pendingStartRes: sc.startRes ? { ...sc.startRes } : undefined,
|
||||||
|
};
|
||||||
|
addMsg(c, `Welcome, Governor. ${sc.name} awaits your command.`, 'good', '🏛️');
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tile helpers ────────────────────────────────────────────────────────────
|
||||||
|
export const idx = (c: CityState, x: number, y: number) => y * c.W + x;
|
||||||
|
export const inB = (c: CityState, x: number, y: number) => x >= 0 && y >= 0 && x < c.W && y < c.H;
|
||||||
|
export const tileAt = (c: CityState, x: number, y: number): Tile | null => inB(c, x, y) ? c.tiles[idx(c, x, y)] : null;
|
||||||
|
export const bldAt = (c: CityState, x: number, y: number): Building | undefined => {
|
||||||
|
const t = tileAt(c, x, y);
|
||||||
|
return t && t.bld ? c.buildings.get(t.bld) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LAND_OK: Record<string, boolean> = { grass: true, fertile: true, tree: true, clay: true };
|
||||||
|
|
||||||
|
export type PlaceCheck = true | string;
|
||||||
|
|
||||||
|
export function canPlace(c: CityState, key: string, x: number, y: number, ignoreRoadCheck = false): PlaceCheck {
|
||||||
|
const d = def(key);
|
||||||
|
if (key === 'road') {
|
||||||
|
const t = tileAt(c, x, y);
|
||||||
|
if (!t) return 'out of bounds';
|
||||||
|
if (t.terrain === 'water') return "can't build on water";
|
||||||
|
if (t.bld) return 'blocked — clear it first';
|
||||||
|
if (t.road || t.plaza) return 'already paved';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let sawWater = false, sawBadTerrain: string | null = null, sawBlocked = false, sawRuins = false;
|
||||||
|
for (let dy = 0; dy < d.h; dy++) for (let dx = 0; dx < d.w; dx++) {
|
||||||
|
const t = tileAt(c, x + dx, y + dy);
|
||||||
|
if (!t) return 'out of bounds';
|
||||||
|
if (t.terrain === 'water') sawWater = true;
|
||||||
|
else if (!LAND_OK[t.terrain] && !sawBadTerrain) {
|
||||||
|
sawBadTerrain = t.terrain === 'rock' ? 'rocky ground' : t.terrain === 'iron' ? 'an iron seam (use Iron Mine)' : t.terrain === 'crystal' ? 'a crystal seam (use Ley Pylon)' : 'this terrain';
|
||||||
|
}
|
||||||
|
if (t.bld || t.road || t.plaza || t.garden) {
|
||||||
|
const ob = t.bld ? c.buildings.get(t.bld) : null;
|
||||||
|
if (ob && ob.ruin) sawRuins = true;
|
||||||
|
else sawBlocked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sawWater && key !== 'fishingWharf') return "can't build on water";
|
||||||
|
if (sawRuins) return 'ruins here — use Clear tool first';
|
||||||
|
if (sawBlocked) {
|
||||||
|
if (d.w > 1 || d.h > 1) return `needs ${d.w}×${d.h} free land — part is occupied`;
|
||||||
|
return 'occupied — pick an empty tile';
|
||||||
|
}
|
||||||
|
if (sawBadTerrain) return `can't build on ${sawBadTerrain}`;
|
||||||
|
// resource requirements
|
||||||
|
if (key === 'clayPit') { if (!footprintHas(c, x, y, d.w, d.h, t => t.terrain === 'clay')) return 'must sit on a clay deposit'; }
|
||||||
|
else if (key === 'timberCamp') { if (!nearTiles(c, x, y, d.w, d.h, 2, t => t.terrain === 'tree')) return 'needs woodland nearby'; }
|
||||||
|
else if (key === 'ironMine') { if (!footprintHas(c, x, y, d.w, d.h, t => t.terrain === 'iron')) return 'must sit on an iron seam'; }
|
||||||
|
else if (key === 'crystalMine') { if (!footprintHas(c, x, y, d.w, d.h, t => t.terrain === 'crystal')) return 'must sit on a crystal seam'; }
|
||||||
|
else if (key === 'leyPylon') { const t = tileAt(c, x, y); if (!t || t.terrain !== 'crystal') return 'must be built on a glowing crystal seam'; }
|
||||||
|
else if (key === 'fishingWharf') { if (!nearTiles(c, x, y, d.w, d.h, 1, t => t.terrain === 'water')) return 'must touch the coastline'; }
|
||||||
|
// NOTE: road adjacency is NOT required for placement (build anywhere,
|
||||||
|
// connect later) — unconnected buildings simply receive no workers.
|
||||||
|
// unique
|
||||||
|
if (d.unique && [...c.buildings.values()].some(b => b.def === key && !b.ruin)) return `only one ${d.name} allowed`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable placement failure, or null when placeable & affordable. */
|
||||||
|
export function placementIssue(c: CityState, key: string, x: number, y: number): string | null {
|
||||||
|
const r = canPlace(c, key, x, y);
|
||||||
|
if (r !== true) return r;
|
||||||
|
if (c.funds < def(key).cost) return 'not enough denarii';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function footprintHas(c: CityState, x: number, y: number, w: number, h: number, pred: (t: Tile) => boolean): boolean {
|
||||||
|
for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) {
|
||||||
|
const t = tileAt(c, x + dx, y + dy);
|
||||||
|
if (t && pred(t)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function nearTiles(c: CityState, x: number, y: number, w: number, h: number, r: number, pred: (t: Tile) => boolean): boolean {
|
||||||
|
for (let dy = -r; dy <= h - 1 + r; dy++) for (let dx = -r; dx <= w - 1 + r; dx++) {
|
||||||
|
const inside = dx >= 0 && dy >= 0 && dx < w && dy < h;
|
||||||
|
if (inside) continue;
|
||||||
|
const t = tileAt(c, x + dx, y + dy);
|
||||||
|
if (t && pred(t)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
export function hasRoadAccess(c: CityState, x: number, y: number, w: number, h: number): boolean {
|
||||||
|
for (let dx = -1; dx <= w; dx++) {
|
||||||
|
for (const yy of [y - 1, y + h]) {
|
||||||
|
const t = tileAt(c, x + dx, yy);
|
||||||
|
if (t && (t.road || t.plaza)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let dy = -1; dy <= h; dy++) {
|
||||||
|
for (const xx of [x - 1, x + w]) {
|
||||||
|
const t = tileAt(c, xx, y + dy);
|
||||||
|
if (t && (t.road || t.plaza)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nearest road/plaza tile adjacent to footprint (walker access point). */
|
||||||
|
export function roadAccessPoint(c: CityState, b: Building): { x: number; y: number } | null {
|
||||||
|
for (let dx = -1; dx <= b.w; dx++) for (const yy of [b.y - 1, b.y + b.h]) {
|
||||||
|
const t = tileAt(c, b.x + dx, yy);
|
||||||
|
if (t && (t.road || t.plaza)) return { x: b.x + dx, y: yy };
|
||||||
|
}
|
||||||
|
for (let dy = -1; dy <= b.h; dy++) for (const xx of [b.x - 1, b.x + b.w]) {
|
||||||
|
const t = tileAt(c, xx, b.y + dy);
|
||||||
|
if (t && (t.road || t.plaza)) return { x: xx, y: b.y + dy };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── placement / removal ─────────────────────────────────────────────────────
|
||||||
|
export interface PlacedB { ok: boolean; id?: number; reason?: string }
|
||||||
|
|
||||||
|
export function placeBuilding(c: CityState, key: string, x: number, y: number, free = false): PlacedB {
|
||||||
|
if (key === 'road') {
|
||||||
|
const rr = canPlace(c, key, x, y);
|
||||||
|
if (rr !== true) return { ok: false, reason: rr };
|
||||||
|
const t = tileAt(c, x, y)!;
|
||||||
|
if (c.funds < def('road').cost && !free) return { ok: false, reason: 'No funds' };
|
||||||
|
t.road = true;
|
||||||
|
if (!free) c.funds -= def('road').cost;
|
||||||
|
markDirtyArea(c, x, y, 1, 1);
|
||||||
|
bus.emit('sfx', 'build');
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
const d = def(key);
|
||||||
|
const cr = canPlace(c, key, x, y);
|
||||||
|
if (cr !== true) return { ok: false, reason: cr };
|
||||||
|
if (!free && c.funds < d.cost) return { ok: false, reason: 'No funds' };
|
||||||
|
const b: Building = {
|
||||||
|
id: c.nextBldId++, def: key, x, y, w: d.w, h: d.h,
|
||||||
|
workersLive: 0, progress: 0, inv: {},
|
||||||
|
serviceT: {}, walkerTimers: new Array(d.walker?.count ?? 0).fill(d.walker ? d.walker.period * Math.random() : 0),
|
||||||
|
burning: 0, ruin: false, active: false, hp: 100,
|
||||||
|
houseLevel: 0, residents: 0, devolveTimer: 0,
|
||||||
|
fetchQueue: [],
|
||||||
|
};
|
||||||
|
c.buildings.set(b.id, b);
|
||||||
|
for (let dy = 0; dy < d.h; dy++) for (let dx = 0; dx < d.w; dx++) {
|
||||||
|
const t = tileAt(c, x + dx, y + dy)!;
|
||||||
|
t.bld = b.id;
|
||||||
|
if (d.cat === 'military' && key === 'wall') { t.wall = b.id; }
|
||||||
|
}
|
||||||
|
if (!free) c.funds -= d.cost;
|
||||||
|
c.dirtyCoverage = true;
|
||||||
|
reassignLabor(c);
|
||||||
|
markDirtyArea(c, x, y, d.w, d.h);
|
||||||
|
bus.emit('sfx', 'build');
|
||||||
|
// seed the first granary with the province's starting provisions
|
||||||
|
if (key === 'granary' && c.pendingStartRes) {
|
||||||
|
for (const [g, q] of Object.entries(c.pendingStartRes)) {
|
||||||
|
b.inv[g as Good] = q;
|
||||||
|
}
|
||||||
|
const tot = Object.values(c.pendingStartRes).reduce((a2, v) => a2 + (v ?? 0), 0);
|
||||||
|
addMsg(c, `${tot} units of provisions arrive from Rome.`, 'good', '🌾');
|
||||||
|
c.pendingStartRes = undefined;
|
||||||
|
}
|
||||||
|
return { ok: true, id: b.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeBuilding(c: CityState, x: number, y: number, refundPct = 0.25): boolean {
|
||||||
|
const t = tileAt(c, x, y);
|
||||||
|
if (!t) return false;
|
||||||
|
if (t.road && !t.bld) {
|
||||||
|
t.road = false; markDirtyArea(c, x, y, 1, 1); return true;
|
||||||
|
}
|
||||||
|
if (t.plaza || t.garden) {
|
||||||
|
t.plaza = t.garden = false; markDirtyArea(c, x, y, 1, 1);
|
||||||
|
if (!t.road) c.dirtyCoverage = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const b = t.bld ? c.buildings.get(t.bld) : undefined;
|
||||||
|
if (!b) return false;
|
||||||
|
clearBuildingTiles(c, b);
|
||||||
|
if (!b.ruin) c.funds += Math.round(def(b.def).cost * refundPct);
|
||||||
|
// evict
|
||||||
|
if (b.residents) c.stats.totalDeaths += 0; // they move away, not die
|
||||||
|
c.population -= b.residents; b.residents = 0;
|
||||||
|
// walkers homeless
|
||||||
|
for (const w of c.walkers.values()) if (w.homeId === b.id) w.dead = true;
|
||||||
|
c.buildings.delete(b.id);
|
||||||
|
reassignLabor(c);
|
||||||
|
c.dirtyCoverage = true;
|
||||||
|
bus.emit('sfx', 'demolish');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBuildingTiles(c: CityState, b: Building) {
|
||||||
|
for (let dy = 0; dy < b.h; dy++) for (let dx = 0; dx < b.w; dx++) {
|
||||||
|
const t = tileAt(c, b.x + dx, b.y + dy);
|
||||||
|
if (t) { t.bld = 0; t.wall = 0; }
|
||||||
|
markDirtyArea(c, b.x + dx, b.y + dy, 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearRubble(c: CityState, x: number, y: number): boolean {
|
||||||
|
const t = tileAt(c, x, y);
|
||||||
|
if (!t || !t.bld) return false;
|
||||||
|
const b = c.buildings.get(t.bld);
|
||||||
|
if (!b || !b.ruin) return false;
|
||||||
|
clearBuildingTiles(c, b);
|
||||||
|
c.buildings.delete(b.id);
|
||||||
|
c.dirtyCoverage = true;
|
||||||
|
bus.emit('sfx', 'demolish');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDirtyArea(c: CityState, x: number, y: number, w: number, h: number) {
|
||||||
|
// renderer invalidation is done via callback list
|
||||||
|
for (const cb of dirtyCbs) cb(x, y, w, h);
|
||||||
|
}
|
||||||
|
export const dirtyCbs: Array<(x: number, y: number, w: number, h: number) => void> = [];
|
||||||
|
|
||||||
|
// ── coverage caches ─────────────────────────────────────────────────────────
|
||||||
|
export function computeDesirability(c: Pick<CityState, 'tiles' | 'W' | 'H'>) {
|
||||||
|
for (const t of c.tiles as Tile[]) t.desirability = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recomputeCoverage(c: CityState) {
|
||||||
|
// desirability
|
||||||
|
for (const t of c.tiles) t.desirability = 0;
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin) continue;
|
||||||
|
const d = def(b.def);
|
||||||
|
if (!d.desirability) continue;
|
||||||
|
const cx = b.x + (b.w - 1) / 2, cy = b.y + (b.h - 1) / 2;
|
||||||
|
const R = d.desRange;
|
||||||
|
for (let y = Math.max(0, cy - R | 0); y <= Math.min(c.H - 1, cy + R); y++) {
|
||||||
|
for (let x = Math.max(0, cx - R | 0); x <= Math.min(c.W - 1, cx + R); x++) {
|
||||||
|
const dist = Math.max(Math.abs(x - cx), Math.abs(y - cy));
|
||||||
|
if (dist <= R) {
|
||||||
|
c.tiles[y * c.W + x].desirability += d.desirability * (1 - dist / (R + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const t of c.tiles) t.desirability = clamp(t.desirability, -24, 24);
|
||||||
|
// water cover
|
||||||
|
for (const t of c.tiles) t.waterCover = 0;
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin) continue;
|
||||||
|
const d = def(b.def);
|
||||||
|
if (!d.service?.includes('water') || b.workersLive < neededWorkers(d.workers, b)) continue;
|
||||||
|
const well = b.def === 'well';
|
||||||
|
const R = well ? 4 : 6, cov = well ? 1 : 2;
|
||||||
|
const cx = b.x + (b.w - 1) / 2, cy = b.y + (b.h - 1) / 2;
|
||||||
|
for (let y = Math.max(0, cy - R | 0); y <= Math.min(c.H - 1, cy + R); y++) {
|
||||||
|
for (let x = Math.max(0, cx - R | 0); x <= Math.min(c.W - 1, cx + R); x++) {
|
||||||
|
const dist = Math.max(Math.abs(x - cx), Math.abs(y - cy));
|
||||||
|
if (dist <= R) {
|
||||||
|
const t = c.tiles[y * c.W + x];
|
||||||
|
t.waterCover = Math.max(t.waterCover, cov);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.dirtyCoverage = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function neededWorkers(base: number, _b: Building): number { return base; }
|
||||||
|
|
||||||
|
/** staffed ratio 0..1 including spell/hero buffs applied by economy. */
|
||||||
|
export function staffingRatio(b: Building): number {
|
||||||
|
const d = def(b.def);
|
||||||
|
if (d.workers <= 0) return 1;
|
||||||
|
return clamp(b.workersLive / d.workers, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── labor assignment ────────────────────────────────────────────────────────
|
||||||
|
const PRIORITY: string[] = [
|
||||||
|
'farmWheat', 'orchard', 'fishingWharf', 'market', 'granary',
|
||||||
|
'prefecture', 'engineerPost', 'well', 'fountain',
|
||||||
|
'forum', 'senate',
|
||||||
|
'templeJupiter', 'templeCeres', 'templeNeptune', 'templeMars', 'oracle',
|
||||||
|
'clinic', 'hospital', 'school', 'academy', 'library',
|
||||||
|
'theater', 'amphitheater', 'colosseum',
|
||||||
|
'clayPit', 'timberCamp', 'ironMine', 'crystalMine', 'potteryWksp', 'furnitureWksp', 'oilPress', 'winery', 'weaponSmith',
|
||||||
|
'warehouse', 'mageTower', 'leyPylon', 'arcaneLibrary', 'golemFoundry',
|
||||||
|
'tower', 'gatehouse', 'barracks', 'fort', 'tradingPost', 'heroHall',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function reassignLabor(c: CityState) {
|
||||||
|
c.laborers = Math.floor(c.population * 0.6);
|
||||||
|
let pool = c.laborers;
|
||||||
|
const byDef = new Map<string, Building[]>();
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
b.workersLive = 0;
|
||||||
|
b.active = false;
|
||||||
|
const d = def(b.def);
|
||||||
|
if (d.workers > 0 && !b.ruin) {
|
||||||
|
const arr = byDef.get(b.def) ?? [];
|
||||||
|
arr.push(b); byDef.set(b.def, arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const roadOkCache = new Map<number, boolean>();
|
||||||
|
const roadOkOf = (b: Building): boolean => {
|
||||||
|
let cached = roadOkCache.get(b.id);
|
||||||
|
if (cached === undefined) { cached = hasRoadAccess(c, b.x, b.y, b.w, b.h); roadOkCache.set(b.id, cached); }
|
||||||
|
return cached;
|
||||||
|
};
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
b.noRoad = false;
|
||||||
|
if (!b.ruin && def(b.def).roadAccess) b.noRoad = !roadOkOf(b);
|
||||||
|
}
|
||||||
|
for (const cat of PRIORITY) {
|
||||||
|
const arr = byDef.get(cat);
|
||||||
|
if (!arr) continue;
|
||||||
|
for (const b of arr) {
|
||||||
|
const d = def(b.def);
|
||||||
|
let ok = true;
|
||||||
|
if (d.roadAccess && !b.ruin) ok = roadOkOf(b);
|
||||||
|
if (!ok) b.noRoad = true;
|
||||||
|
const need = ok ? d.workers : 0;
|
||||||
|
const give = Math.min(need, pool);
|
||||||
|
b.workersLive = give;
|
||||||
|
pool -= give;
|
||||||
|
if (pool <= 0 && need === 0) break;
|
||||||
|
}
|
||||||
|
if (pool <= 0) break;
|
||||||
|
}
|
||||||
|
c.employed = c.laborers - pool;
|
||||||
|
c.unemploymentPct = c.laborers > 0 ? Math.round((pool / c.laborers) * 100) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildingActive(b: Building): boolean {
|
||||||
|
const d = def(b.def);
|
||||||
|
if (b.ruin || b.burning > 0) return false;
|
||||||
|
if (d.workers > 0 && b.workersLive === 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main tick ───────────────────────────────────────────────────────────────
|
||||||
|
export interface TickCtx { scenario: Scenario }
|
||||||
|
|
||||||
|
export function tickCity(c: CityState, sc: Scenario, dtReal: number) {
|
||||||
|
if (c.victory) return;
|
||||||
|
const dt = dtReal * SPEEDS[c.speedIdx] * (c.paused ? 0 : 1);
|
||||||
|
if (dt <= 0) return;
|
||||||
|
|
||||||
|
updateWalkers(c, dt);
|
||||||
|
updateCombat(c, dt);
|
||||||
|
updateMagic(c, dt);
|
||||||
|
updateFire(c, dt);
|
||||||
|
|
||||||
|
c.monthFloat = (c.monthFloat ?? 0) + dt / MONTH_SECONDS;
|
||||||
|
while (c.monthFloat >= 1) {
|
||||||
|
c.monthFloat -= 1;
|
||||||
|
monthlyTick(c, sc);
|
||||||
|
if (c.victory) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// declared here to avoid circular imports; implementations injected by modules
|
||||||
|
export let updateWalkers: (c: CityState, dt: number) => void = () => {};
|
||||||
|
export let updateCombat: (c: CityState, dt: number) => void = () => {};
|
||||||
|
export let updateMagic: (c: CityState, dt: number) => void = () => {};
|
||||||
|
export let updateFire: (c: CityState, dt: number) => void = () => {};
|
||||||
|
export let monthlyTick: (c: CityState, sc: Scenario) => void = () => {};
|
||||||
|
export function registerSimHooks(hooks: {
|
||||||
|
updateWalkers: typeof updateWalkers;
|
||||||
|
updateCombat: typeof updateCombat;
|
||||||
|
updateMagic: typeof updateMagic;
|
||||||
|
updateFire: typeof updateFire;
|
||||||
|
monthlyTick: typeof monthlyTick;
|
||||||
|
}) {
|
||||||
|
updateWalkers = hooks.updateWalkers;
|
||||||
|
updateCombat = hooks.updateCombat;
|
||||||
|
updateMagic = hooks.updateMagic;
|
||||||
|
updateFire = hooks.updateFire;
|
||||||
|
monthlyTick = hooks.monthlyTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tiny helper used across modules
|
||||||
|
|
||||||
|
export function sandboxScenario(): Scenario { return makeSandbox(Date.now() % 100000); }
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
// ─── Military: invasions, monsters, legions, combat ────────────────────────
|
||||||
|
import type { Building, CityState, MonsterKind, UnitStats, Walker } from './types';
|
||||||
|
import { def } from '../data/buildings';
|
||||||
|
import { HEROES } from '../data/heroes';
|
||||||
|
import { RNG, clamp } from '../core/util';
|
||||||
|
import { astarPath, type Pt } from '../core/pathfind';
|
||||||
|
import { addMsg, tileAt, inB, bus } from './city';
|
||||||
|
import { ruinBuilding } from './economy';
|
||||||
|
import { spawnWalker, landPath } from './walkers';
|
||||||
|
import { FX, Sfx } from '../render/fx';
|
||||||
|
|
||||||
|
const rng = new RNG(0x5eed1234);
|
||||||
|
|
||||||
|
// base stats per single soldier; squads scale hp & dmg by squadSize
|
||||||
|
export const UNIT_STATS: Record<MonsterKind | 'soldier', UnitStats> = {
|
||||||
|
// city side
|
||||||
|
soldier: { hp: 60, dmg: 9, speed: 1.15, ranged: false, range: 1.1, atkCd: 1.0 },
|
||||||
|
golem: { hp: 340, dmg: 26, speed: 0.8, ranged: false, range: 1.2, atkCd: 1.4 },
|
||||||
|
legion: { hp: 60, dmg: 9, speed: 1.15, ranged: false, range: 1.1, atkCd: 1.0 },
|
||||||
|
// foes
|
||||||
|
barbarian: { hp: 52, dmg: 7, speed: 1.05, ranged: false, range: 1.1, atkCd: 1.1 },
|
||||||
|
skeleton: { hp: 30, dmg: 5, speed: 1.25, ranged: false, range: 1.1, atkCd: 0.9 },
|
||||||
|
harpy: { hp: 44, dmg: 6, speed: 2.1, ranged: false, range: 1.0, atkCd: 0.8 },
|
||||||
|
minotaur: { hp: 520, dmg: 34, speed: 1.0, ranged: false, range: 1.3, atkCd: 1.6 },
|
||||||
|
hydra: { hp: 900, dmg: 24, speed: 0.75, ranged: false, range: 1.4, atkCd: 1.1 },
|
||||||
|
lich: { hp: 700, dmg: 20, speed: 0.85, ranged: true, range: 6, atkCd: 1.8 },
|
||||||
|
};
|
||||||
|
export const MONSTERS: import('./types').UnitKind[] = ['hydra', 'minotaur', 'harpy', 'lich'];
|
||||||
|
|
||||||
|
function edgeSpawn(c: CityState, pref?: string): Pt {
|
||||||
|
const side = !pref || pref === 'random' ? rng.pick(['n', 's', 'e', 'w'] as const) : pref;
|
||||||
|
if (side === 'n') return { x: rng.int(1, c.W - 2), y: 1 };
|
||||||
|
if (side === 's') return { x: rng.int(1, c.W - 2), y: c.H - 2 };
|
||||||
|
if (side === 'e') return { x: c.W - 2, y: rng.int(1, c.H - 2) };
|
||||||
|
return { x: 1, y: rng.int(1, c.H - 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trySpawnInvasion(c: CityState, kind: MonsterKind, squads: number, size: number, fromEdge = 'random') {
|
||||||
|
for (let i = 0; i < squads; i++) {
|
||||||
|
const p = edgeSpawn(c, fromEdge);
|
||||||
|
spawnFoeSquad(c, kind, size, p);
|
||||||
|
}
|
||||||
|
c.stats.monthsSinceInvasion = 0;
|
||||||
|
addMsg(c, `${kind === 'barbarian' ? 'BARBARIANS' : 'ENEMIES'} have breached the province!`, 'danger', '⚔️');
|
||||||
|
Sfx.play('horn');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spawnMonsterWave(c: CityState, kind: MonsterKind, squads: number, size: number) {
|
||||||
|
for (let i = 0; i < squads; i++) {
|
||||||
|
const p = edgeSpawn(c);
|
||||||
|
spawnFoeSquad(c, kind, kind === 'harpy' || kind === 'barbarian' || kind === 'skeleton' ? size : 1, p);
|
||||||
|
}
|
||||||
|
const names: Record<string, string> = { hydra: 'A HYDRA rises!', minotaur: 'The MINOTAUR charges!', harpy: 'Harpies descend!', lich: 'The LICH KING comes!' };
|
||||||
|
addMsg(c, names[kind] ?? `A ${kind} attacks!`, 'danger', '🐲');
|
||||||
|
Sfx.play('monster');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spawnFoeSquad(c: CityState, kind: MonsterKind, size: number, at: Pt) {
|
||||||
|
const st = UNIT_STATS[kind];
|
||||||
|
const isMonster = MONSTERS.includes(kind);
|
||||||
|
const mult = isMonster ? 1 : size;
|
||||||
|
spawnWalker(c, {
|
||||||
|
kind: 'monster', x: at.x + 0.5, y: at.y + 0.5, homeId: 0,
|
||||||
|
state: 'walkOut', speed: st.speed * 0.55,
|
||||||
|
unit: {
|
||||||
|
kind, hp: st.hp * mult, maxHp: st.hp * mult,
|
||||||
|
stats: { ...st, dmg: st.dmg * (isMonster ? 1 : Math.sqrt(size)) },
|
||||||
|
cd: 0, side: 'foe', squadSize: size,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fort recruits a legion when weapons are stocked. */
|
||||||
|
export function tryRecruitLegion(c: CityState): boolean {
|
||||||
|
// consume weapons from any warehouse
|
||||||
|
let wIdx = 0;
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin) continue;
|
||||||
|
const d = def(b.def);
|
||||||
|
if (!d.storage) continue;
|
||||||
|
if ((b.inv['weapons'] ?? 0) >= 20) { wIdx = b.id; break; }
|
||||||
|
}
|
||||||
|
if (!wIdx) return false;
|
||||||
|
const wh = c.buildings.get(wIdx)!;
|
||||||
|
wh.inv['weapons'] = (wh.inv['weapons'] ?? 0) - 20;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spawnLegionAt(c: CityState, fort: Building) {
|
||||||
|
const acc = { x: fort.x + 1, y: fort.y + 2 };
|
||||||
|
spawnWalker(c, {
|
||||||
|
kind: 'soldiers', x: acc.x + 0.5, y: acc.y + 0.5, homeId: fort.id,
|
||||||
|
state: 'walkOut', speed: 1.15,
|
||||||
|
unit: {
|
||||||
|
kind: 'soldiers', hp: 60 * 16, maxHp: 60 * 16,
|
||||||
|
stats: { ...UNIT_STATS.soldier, dmg: UNIT_STATS.soldier.dmg * 4 }, cd: 0, side: 'city', squadSize: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
addMsg(c, 'A legion marches from the fortress.', 'info', '🎖️');
|
||||||
|
Sfx.play('horn');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spawnGolemAt(c: CityState, x: number, y: number) {
|
||||||
|
spawnWalker(c, {
|
||||||
|
kind: 'golemUnit', x: x + 0.5, y: y + 0.5, homeId: 0,
|
||||||
|
state: 'walkOut', speed: UNIT_STATS.golem.speed,
|
||||||
|
unit: { ...unitOf('golem'), cd: 0, side: 'city' },
|
||||||
|
});
|
||||||
|
addMsg(c, 'A stone golem awakens!', 'good', '🗿');
|
||||||
|
}
|
||||||
|
function unitOf(kind: MonsterKind) {
|
||||||
|
const st = UNIT_STATS[kind];
|
||||||
|
return { kind, hp: st.hp, maxHp: st.hp, stats: { ...st }, };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── target selection ────────────────────────────────────────────────────────
|
||||||
|
function nearestFoe(c: CityState, x: number, y: number, maxD: number): Walker | null {
|
||||||
|
let best: Walker | null = null, bd = maxD * maxD;
|
||||||
|
for (const w of c.walkers.values()) {
|
||||||
|
if (w.unit?.side !== 'foe' || w.dead) continue;
|
||||||
|
const d = (w.x - x) ** 2 + (w.y - y) ** 2;
|
||||||
|
if (d < bd) { bd = d; best = w; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
function nearestCityUnit(c: CityState, x: number, y: number, maxD: number): Walker | null {
|
||||||
|
let best: Walker | null = null, bd = maxD * maxD;
|
||||||
|
for (const w of c.walkers.values()) {
|
||||||
|
if (w.unit?.side !== 'city' || w.dead) continue;
|
||||||
|
const d = (w.x - x) ** 2 + (w.y - y) ** 2;
|
||||||
|
if (d < bd) { bd = d; best = w; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
function nearestBuildingTarget(c: CityState, x: number, y: number): Building | null {
|
||||||
|
let best: Building | null = null, bd = Infinity;
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin || b.burning > 0) continue;
|
||||||
|
if (b.def === 'wall') continue; // prefer real buildings
|
||||||
|
const d = Math.abs(b.x + b.w / 2 - x) + Math.abs(b.y + b.h / 2 - y);
|
||||||
|
if (d < bd) { bd = d; best = b; }
|
||||||
|
}
|
||||||
|
if (!best) {
|
||||||
|
for (const b of c.buildings.values()) { if (!b.ruin) { best = b; break; } }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── damage helpers ──────────────────────────────────────────────────────────
|
||||||
|
export function damageUnit(c: CityState, w: Walker, dmg: number, byHero?: Walker) {
|
||||||
|
if (!w.unit || w.dead) return;
|
||||||
|
w.unit.hp -= dmg;
|
||||||
|
FX.spark(w.x, w.y - 0.8, w.unit.side === 'foe' ? '#ff8060' : '#ffd080', 3, 30, 40);
|
||||||
|
if (w.unit.hp <= 0) {
|
||||||
|
w.dead = true;
|
||||||
|
FX.blast(w.x, w.y, 1.2, '#caa', 0.4);
|
||||||
|
if (w.unit.side === 'foe') {
|
||||||
|
if (MONSTERS.includes(w.unit.kind)) {
|
||||||
|
c.stats.monstersSlain++;
|
||||||
|
addMsg(c, `The ${w.unit.kind} is slain! The people cheer!`, 'good', '🏆');
|
||||||
|
bus.emit('sfx', 'fanfare');
|
||||||
|
} else c.stats.invadersSlain++;
|
||||||
|
if (byHero?.heroId) addHeroXp(byHero, 40 + (w.unit.maxHp > 400 ? 120 : 0));
|
||||||
|
} else {
|
||||||
|
if (w.heroId) scheduleHeroRespawn(c, w.heroId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attack(a: Walker, bW: Walker, c: CityState, dt: number) {
|
||||||
|
const ua = a.unit!;
|
||||||
|
ua.cd -= dt;
|
||||||
|
if (ua.cd > 0) return;
|
||||||
|
ua.cd = ua.stats.atkCd;
|
||||||
|
const aSide = a.unit?.side;
|
||||||
|
if (ua.stats.ranged) {
|
||||||
|
FX.projectile(a.x, a.y - 1.2, bW.x, bW.y - 0.8, aSide === 'foe' ? '#b0f' : '#fff', 300, 12);
|
||||||
|
} else {
|
||||||
|
FX.spark((a.x + bW.x) / 2, (a.y + bW.y) / 2 - 0.8, '#ffe08a', 3, 36, 50);
|
||||||
|
Sfx.play('clash');
|
||||||
|
}
|
||||||
|
damageUnit(c, bW, ua.stats.dmg, aSide === 'city' ? a : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function damageBuilding(c: CityState, b: Building, dmg: number, by?: Walker) {
|
||||||
|
b.hp = (b.hp ?? 100) - dmg;
|
||||||
|
FX.spark(b.x + b.w / 2, b.y + b.h / 2, '#d8c8a8', 3, 40, 80);
|
||||||
|
if (b.hp <= 0) {
|
||||||
|
ruinBuilding(c, b, by?.unit?.side === 'foe' ? 'enemy' : 'monster');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main combat update ──────────────────────────────────────────────────────
|
||||||
|
let recruitAcc = 0;
|
||||||
|
export function updateCombatImpl(c: CityState, dt: number) {
|
||||||
|
c_dt = dt;
|
||||||
|
setCombatCityRef(c);
|
||||||
|
const units = [...c.walkers.values()].filter(w => w.unit && !w.dead);
|
||||||
|
|
||||||
|
// building defenses (towers/mages)
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin) continue;
|
||||||
|
const d = def(b.def);
|
||||||
|
const stats = d.tower ?? d.mageTower;
|
||||||
|
if (!stats || !stats.dmg) continue;
|
||||||
|
if (d.workers > 0 && b.workersLive === 0) continue;
|
||||||
|
b.spellCooldowns ??= {};
|
||||||
|
const key = '_atk';
|
||||||
|
b.spellCooldowns[key] = (b.spellCooldowns[key] ?? rng.next() * 0.5) - dt;
|
||||||
|
if (b.spellCooldowns[key] > 0) continue;
|
||||||
|
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
|
||||||
|
const foe = nearestFoe(c, cx, cy, stats.range);
|
||||||
|
if (!foe) continue;
|
||||||
|
b.spellCooldowns[key] = stats.atkCd / (c.spellState.buffs.shieldUntil > c.month + (c.monthFloat ?? 0) ? 2 : 1);
|
||||||
|
const dmg = stats.dmg * (c.spellState.buffs.shieldUntil > c.month + (c.monthFloat ?? 0) ? 2 : 1);
|
||||||
|
FX.projectile(cx, cy - 2.2, foe.x, foe.y - 0.8, d.mageTower ? '#a87fff' : '#f4ead0', 320, d.mageTower ? 4 : 18);
|
||||||
|
Sfx.play(d.mageTower ? 'zap' : 'arrow');
|
||||||
|
damageUnit(c, foe, dmg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// unit vs unit
|
||||||
|
for (const u of units) {
|
||||||
|
if (u.dead) continue;
|
||||||
|
const uu = u.unit!;
|
||||||
|
uu.cd -= dt;
|
||||||
|
const foes = uu.side === 'foe';
|
||||||
|
const enemy = foes ? nearestCityUnit(c, u.x, u.y, 14) : nearestFoe(c, u.x, u.y, 14);
|
||||||
|
const distE = enemy ? Math.hypot(enemy.x - u.x, enemy.y - u.y) : Infinity;
|
||||||
|
|
||||||
|
if (enemy && distE <= Math.max(uu.stats.range, 1.15)) {
|
||||||
|
if (uu.cd <= 0) attack(u, enemy, c, 0); // immediate strike; cd decremented above
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foes) {
|
||||||
|
// march toward target building
|
||||||
|
u.timer -= dt;
|
||||||
|
if ((!u.path.length || u.pathI >= u.path.length || u.timer <= 0)) {
|
||||||
|
u.timer = 4;
|
||||||
|
const tb = nearestBuildingTarget(c, u.x, u.y);
|
||||||
|
if (tb) {
|
||||||
|
const gx = tb.x + (tb.w >> 1), gy = tb.y + (tb.h >> 1);
|
||||||
|
const p = landPath(c, { x: Math.round(u.x - 0.5), y: Math.round(u.y - 0.5) }, { x: gx, y: gy });
|
||||||
|
if (p) { u.path = p.slice(1); u.pathI = 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moveUnit(u, dt);
|
||||||
|
// attack building under feet
|
||||||
|
const t = tileAt(c, Math.floor(u.x), Math.floor(u.y));
|
||||||
|
if (t?.wall && t.bld) {
|
||||||
|
const wb = c.buildings.get(t.bld);
|
||||||
|
if (wb && !wb.ruin) { chewBuilding(c, u, wb, dt); continue; }
|
||||||
|
}
|
||||||
|
if (t?.bld) {
|
||||||
|
const bb = c.buildings.get(t.bld);
|
||||||
|
if (bb && !bb.ruin) chewBuilding(c, u, bb, dt);
|
||||||
|
}
|
||||||
|
// adjacent wall chewing
|
||||||
|
if (!t?.bld) {
|
||||||
|
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
|
||||||
|
const nt = tileAt(c, Math.floor(u.x) + dx, Math.floor(u.y) + dy);
|
||||||
|
if (nt?.wall && nt.bld) {
|
||||||
|
const wb = c.buildings.get(nt.bld);
|
||||||
|
if (wb && !wb.ruin) chewBuilding(c, u, wb, dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// city unit: seek foes else patrol near home/rally
|
||||||
|
if (enemy && distE < 14) {
|
||||||
|
repathTo(c, u, enemy, dt);
|
||||||
|
moveUnit(u, dt);
|
||||||
|
} else {
|
||||||
|
patrolCityUnit(c, u, dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fort recruitment every ~6s if weapons exist
|
||||||
|
recruitAcc += dt;
|
||||||
|
if (recruitAcc > 6) {
|
||||||
|
recruitAcc = 0;
|
||||||
|
for (const f of c.buildings.values()) {
|
||||||
|
if (f.ruin || f.def !== 'fort') continue;
|
||||||
|
const alive = [...c.walkers.values()].some(w => w.homeId === f.id && w.unit?.side === 'city' && !w.dead);
|
||||||
|
if (alive) continue;
|
||||||
|
if (tryRecruitLegion(c)) spawnLegionAt(c, f);
|
||||||
|
}
|
||||||
|
// golem foundry auto-build if iron available and few golems
|
||||||
|
const foundries = [...c.buildings.values()].filter(b => b.def === 'golemFoundry' && !b.ruin && b.workersLive > 0);
|
||||||
|
const golems = [...c.walkers.values()].filter(w => w.unit?.kind === 'golem' && !w.dead).length;
|
||||||
|
if (foundries.length && golems < foundries.length * 2) {
|
||||||
|
for (const f of foundries) {
|
||||||
|
let took = false;
|
||||||
|
for (const b2 of c.buildings.values()) {
|
||||||
|
if (b2.ruin) continue;
|
||||||
|
if ((b2.inv['iron'] ?? 0) >= 20) { b2.inv['iron'] = (b2.inv['iron'] ?? 0) - 20; took = true; break; }
|
||||||
|
}
|
||||||
|
if (took) { spawnGolemAt(c, f.x + 1, f.y + 2); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// purge dead
|
||||||
|
for (const [id, w] of c.walkers) if (w.dead && w.unit) c.walkers.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chewBuilding(c: CityState, u: Walker, b: Building, _dt: number) {
|
||||||
|
const uu = u.unit!;
|
||||||
|
if (uu.cd > 0) return;
|
||||||
|
uu.cd = uu.stats.atkCd * 1.4;
|
||||||
|
FX.spark(b.x + b.w / 2, b.y + b.h / 2, '#ffb060', 4, 46, 90);
|
||||||
|
Sfx.play('smash');
|
||||||
|
damageBuilding(c, b, uu.stats.dmg * (uu.squadSize && uu.squadSize > 4 ? 2 : 1), u);
|
||||||
|
}
|
||||||
|
|
||||||
|
function repathTo(c: CityState, u: Walker, tgt: Walker, _dt: number) {
|
||||||
|
u.timer = (u.timer ?? 0) - 1;
|
||||||
|
if (u.path.length && u.pathI < u.path.length && u.timer > 0) return;
|
||||||
|
u.timer = 12;
|
||||||
|
const p = landPath(c, { x: Math.round(u.x - 0.5), y: Math.round(u.y - 0.5) }, { x: Math.round(tgt.x - 0.5), y: Math.round(tgt.y - 0.5) });
|
||||||
|
if (p) { u.path = p.slice(1); u.pathI = 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function patrolCityUnit(c: CityState, u: Walker, _dt: number) {
|
||||||
|
const home = c.buildings.get(u.homeId);
|
||||||
|
const rx = u.rallyX ?? home?.x ?? c.W / 2;
|
||||||
|
const ry = u.rallyY ?? home?.y ?? c.H / 2;
|
||||||
|
if (Math.abs(u.x - rx) + Math.abs(u.y - ry) > 6) {
|
||||||
|
repathToTile(c, u, Math.round(rx), Math.round(ry));
|
||||||
|
} else if (!u.path.length || u.pathI >= u.path.length) {
|
||||||
|
// idle shuffle around rally
|
||||||
|
const nx = clamp(Math.round(rx + (Math.random() * 8 - 4)), 0, c.W - 1);
|
||||||
|
const ny = clamp(Math.round(ry + (Math.random() * 8 - 4)), 0, c.H - 1);
|
||||||
|
repathToTile(c, u, nx, ny);
|
||||||
|
}
|
||||||
|
moveUnit(u, c_dt);
|
||||||
|
}
|
||||||
|
let c_dt = 0.016;
|
||||||
|
|
||||||
|
function repathToTile(c: CityState, u: Walker, tx: number, ty: number) {
|
||||||
|
const p = landPath(c, { x: Math.round(u.x - 0.5), y: Math.round(u.y - 0.5) }, { x: tx, y: ty });
|
||||||
|
if (p) { u.path = p.slice(1); u.pathI = 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveUnit(u: Walker, dt: number) {
|
||||||
|
if (!u.unit) return;
|
||||||
|
let budget = u.unit.stats.speed * dt;
|
||||||
|
while (budget > 0 && u.pathI < u.path.length) {
|
||||||
|
const t = u.path[u.pathI];
|
||||||
|
const tx = t.x + 0.5, ty = t.y + 0.5;
|
||||||
|
const dxs = (tx - ty) - (u.x - u.y);
|
||||||
|
if (dxs !== 0) u.facing = dxs > 0 ? 1 : -1;
|
||||||
|
const dist = Math.hypot(tx - u.x, ty - u.y);
|
||||||
|
if (dist <= budget) { u.x = tx; u.y = ty; budget -= dist; u.pathI++; }
|
||||||
|
else { u.x += (tx - u.x) / dist * budget; u.y += (ty - u.y) / dist * budget; budget = 0; }
|
||||||
|
u.animT += dt * 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Heroes ──────────────────────────────────────────────────────────────────
|
||||||
|
interface HeroRecord { alive: boolean; respawnAt: number; level: number; xp: number }
|
||||||
|
const heroRecords = new Map<HeroId, HeroRecord>();
|
||||||
|
type HeroId = keyof typeof HEROES;
|
||||||
|
|
||||||
|
export function heroRecord(id: HeroId): HeroRecord {
|
||||||
|
let h = heroRecords.get(id);
|
||||||
|
if (!h) { h = { alive: false, respawnAt: 0, level: 1, xp: 0 }; heroRecords.set(id, h); }
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
export function resetHeroes() { heroRecords.clear(); }
|
||||||
|
|
||||||
|
export function summonHeroImpl(c: CityState, id: HeroId): boolean {
|
||||||
|
const hd = HEROES[id];
|
||||||
|
const hall = [...c.buildings.values()].find(b => def(b.def).heroHall && !b.ruin);
|
||||||
|
if (!hall) return false;
|
||||||
|
if (c.funds < hd.costGold) return false;
|
||||||
|
if (hd.costGood && !hasGoodStoredFor(c, hd.costGood.good, hd.costGood.qty)) return false;
|
||||||
|
const rec = heroRecord(id);
|
||||||
|
if (rec.alive) return false;
|
||||||
|
c.funds -= hd.costGold;
|
||||||
|
if (hd.costGood) takeGoodFor(c, hd.costGood.good, hd.costGood.qty);
|
||||||
|
rec.alive = true;
|
||||||
|
const acc = { x: hall.x + 1, y: hall.y + 2 };
|
||||||
|
const st = heroStats(hd.id, rec.level);
|
||||||
|
spawnWalker(c, {
|
||||||
|
kind: 'hero', x: acc.x + 0.5, y: acc.y + 0.5, homeId: hall.id,
|
||||||
|
state: 'walkOut', speed: hd.speed, heroId: id, heroLevel: rec.level, heroXp: rec.xp,
|
||||||
|
unit: { kind: 'hero', hp: st.hp, maxHp: st.hp, stats: st, cd: 0, side: 'city' },
|
||||||
|
});
|
||||||
|
addMsg(c, `${hd.name} ${hd.title} answers the call!`, 'good', hd.icon);
|
||||||
|
Sfx.play('fanfare');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function hasGoodStoredFor(c: CityState, g: import('./types').Good, qty: number): boolean {
|
||||||
|
let n = 0;
|
||||||
|
for (const b of c.buildings.values()) n += b.inv[g] ?? 0;
|
||||||
|
return n >= qty;
|
||||||
|
}
|
||||||
|
function takeGoodFor(c: CityState, g: import('./types').Good, qty: number) {
|
||||||
|
let need = qty;
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
const have = b.inv[g] ?? 0;
|
||||||
|
const take = Math.min(have, need);
|
||||||
|
if (take > 0) { b.inv[g] = have - take; need -= take; }
|
||||||
|
if (need <= 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function heroStats(id: HeroId, level: number) {
|
||||||
|
const hd = HEROES[id];
|
||||||
|
const lm = 1 + (level - 1) * 0.22;
|
||||||
|
return {
|
||||||
|
hp: Math.round(hd.hp * lm), dmg: Math.round(hd.dmg * lm),
|
||||||
|
speed: hd.speed, ranged: false, range: hd.ranged ? 6 : 1.2, atkCd: hd.ranged ? 0.9 : 0.8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function addHeroXp(heroWalker: Walker, xp: number) {
|
||||||
|
heroWalker.heroXp = (heroWalker.heroXp ?? 0) + xp;
|
||||||
|
const lvlBefore = heroWalker.heroLevel ?? 1;
|
||||||
|
const lvlNew = 1 + Math.floor(heroWalker.heroXp / 150);
|
||||||
|
if (lvlNew > lvlBefore && heroWalker.heroId) {
|
||||||
|
heroWalker.heroLevel = lvlNew;
|
||||||
|
const st = heroStats(heroWalker.heroId, lvlNew);
|
||||||
|
if (heroWalker.unit) { heroWalker.unit.maxHp = st.hp; heroWalker.unit.hp = st.hp; heroWalker.unit.stats = st; }
|
||||||
|
FX.float(heroWalker.x, heroWalker.y - 2, 'LEVEL UP!', '#ffd700');
|
||||||
|
addMsg(c_global!, `${HEROES[heroWalker.heroId].name} reaches level ${lvlNew}!`, 'good', '⭐');
|
||||||
|
heroRecord(heroWalker.heroId).level = lvlNew;
|
||||||
|
heroRecord(heroWalker.heroId).xp = heroWalker.heroXp;
|
||||||
|
} else if (heroWalker.heroId) {
|
||||||
|
heroRecord(heroWalker.heroId).xp = heroWalker.heroXp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let c_global: CityState | null = null;
|
||||||
|
export function setCombatCityRef(c: CityState | null) { c_global = c; }
|
||||||
|
|
||||||
|
function scheduleHeroRespawn(c: CityState, id: HeroId) {
|
||||||
|
const rec = heroRecord(id);
|
||||||
|
rec.alive = false;
|
||||||
|
const nowM = c.month + (c.monthFloat ?? 0);
|
||||||
|
rec.respawnAt = nowM + 2; // months
|
||||||
|
addMsg(c, `${HEROES[id].name} has fallen... legends return in time.`, 'warn', '⚰️');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-respawn heroes whose time has come; called monthly. */
|
||||||
|
export function heroMonthly(c: CityState) {
|
||||||
|
const nowM = c.month;
|
||||||
|
for (const [id, rec] of heroRecords) {
|
||||||
|
if (!rec.alive && rec.respawnAt > 0 && nowM >= rec.respawnAt && rec.respawnAt < nowM + 1) {
|
||||||
|
const hd = HEROES[id];
|
||||||
|
const hall = [...c.buildings.values()].find(b => def(b.def).heroHall && !b.ruin);
|
||||||
|
if (hall) {
|
||||||
|
rec.alive = true; rec.respawnAt = 0;
|
||||||
|
const st = heroStats(id, rec.level);
|
||||||
|
spawnWalker(c, {
|
||||||
|
kind: 'hero', x: hall.x + 1.5, y: hall.y + 2.5, homeId: hall.id,
|
||||||
|
state: 'walkOut', speed: hd.speed, heroId: id, heroLevel: rec.level, heroXp: rec.xp,
|
||||||
|
unit: { kind: 'hero', hp: st.hp, maxHp: st.hp, stats: st, cd: 0, side: 'city' },
|
||||||
|
});
|
||||||
|
addMsg(c, `${hd.name} returns from the underworld!`, 'good', hd.icon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Industry aura boost near living heroes. */
|
||||||
|
export function heroProdBoostImpl(c: CityState, x: number, y: number): number {
|
||||||
|
let boost = 1;
|
||||||
|
for (const w of c.walkers.values()) {
|
||||||
|
if (w.kind !== 'hero' || w.dead) continue;
|
||||||
|
const hd = HEROES[w.heroId!];
|
||||||
|
const d = Math.abs(w.x - x) + Math.abs(w.y - y);
|
||||||
|
if (d <= 10) boost += (hd.auraProd / 100) * (1 + (w.heroLevel ?? 1) * 0.15);
|
||||||
|
}
|
||||||
|
return boost;
|
||||||
|
}
|
||||||
@@ -0,0 +1,792 @@
|
|||||||
|
// ─── Monthly economy: production, food, taxes, trade, ratings, events ──────
|
||||||
|
import type { Building, CityState, Good, Scenario } from './types';
|
||||||
|
import { GOODS } from './types';
|
||||||
|
import { def } from '../data/buildings';
|
||||||
|
import { HOUSE_TIERS } from '../data/housing';
|
||||||
|
import { RNG, clamp } from '../core/util';
|
||||||
|
import {
|
||||||
|
addMsg, buildingActive, bus, reassignLabor, recomputeCoverage, removeBuilding } from './city';
|
||||||
|
import { simMonths, spawnWalker, trySpawnImmigrant, maxResidentsOf, landPath, incomingFor } from './walkers';
|
||||||
|
import { FX, Sfx } from '../render/fx';
|
||||||
|
import { trySpawnInvasion, spawnMonsterWave, heroMonthly } from './combat';
|
||||||
|
|
||||||
|
const rng = new RNG(0x9e3779b9);
|
||||||
|
let lastStarveMsg = -99;
|
||||||
|
function reseed(c: CityState) { (rng as unknown as { s: number }).s = (c.seed + c.month * 2654435761) >>> 0; }
|
||||||
|
|
||||||
|
// ── storage helpers ─────────────────────────────────────────────────────────
|
||||||
|
export function storagesAccepting(c: CityState, g: Good): Building[] {
|
||||||
|
const out: Building[] = [];
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin) continue;
|
||||||
|
if (b.def === 'warehouse') out.push(b);
|
||||||
|
else if (b.def === 'granary' && GOODS[g].food) out.push(b);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totalStored(c: CityState, g: Good): number {
|
||||||
|
let n = 0;
|
||||||
|
for (const b of c.buildings.values()) n += b.inv[g] ?? 0;
|
||||||
|
return Math.floor(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addGoodsToStores(c: CityState, g: Good, qty: number): number {
|
||||||
|
let left = qty;
|
||||||
|
for (const b of storagesAccepting(c, g)) {
|
||||||
|
const cap = def(b.def).storage!.capacity;
|
||||||
|
const cur = Object.values(b.inv).reduce((a, v) => a + (v ?? 0), 0) - (b.inv[g] ?? 0);
|
||||||
|
const room = cap - cur - (b.inv[g] ?? 0);
|
||||||
|
if (room <= 0) continue;
|
||||||
|
const put = Math.min(room, left);
|
||||||
|
b.inv[g] = (b.inv[g] ?? 0) + put;
|
||||||
|
left -= put;
|
||||||
|
if (left <= 0) break;
|
||||||
|
}
|
||||||
|
return qty - left; // actually stored
|
||||||
|
}
|
||||||
|
|
||||||
|
export function takeGoodsFromStores(c: CityState, g: Good, qty: number): number {
|
||||||
|
let need = qty;
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin) continue;
|
||||||
|
const have = b.inv[g] ?? 0;
|
||||||
|
if (have <= 0) continue;
|
||||||
|
const take = Math.min(have, need);
|
||||||
|
b.inv[g] = have - take;
|
||||||
|
need -= take;
|
||||||
|
if (need <= 0) break;
|
||||||
|
}
|
||||||
|
return qty - need;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasGoodStored(c: CityState, g: Good, qty: number): boolean {
|
||||||
|
return totalStored(c, g) >= qty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── production ──────────────────────────────────────────────────────────────
|
||||||
|
let heroProdBoost: (c: CityState, x: number, y: number) => number = () => 1;
|
||||||
|
export function setHeroProdBoostFn(f: typeof heroProdBoost) { heroProdBoost = f; }
|
||||||
|
let spellProdBoost: (c: CityState, x: number, y: number) => number = () => 1;
|
||||||
|
export function setSpellProdBoostFn(f: typeof spellProdBoost) { spellProdBoost = f; }
|
||||||
|
|
||||||
|
function produceMonthly(c: CityState) {
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin || b.burning > 0) continue;
|
||||||
|
const d = def(b.def);
|
||||||
|
if (!d.produces) continue;
|
||||||
|
if (!buildingActive(b)) continue;
|
||||||
|
// input availability
|
||||||
|
let okInput = true;
|
||||||
|
if (d.consumes) {
|
||||||
|
for (const [g, need] of Object.entries(d.consumes)) {
|
||||||
|
if ((b.inv[g as Good] ?? 0) < (need ?? 0)) { okInput = false; requestFetch(c, b, g as Good); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fertility factor for farms
|
||||||
|
let fertF = 1;
|
||||||
|
if (b.def === 'farmWheat') {
|
||||||
|
let f = 0, n = 0;
|
||||||
|
for (let dy = 0; dy < b.h; dy++) for (let dx = 0; dx < b.w; dx++) {
|
||||||
|
const t = c.tiles[(b.y + dy) * c.W + (b.x + dx)];
|
||||||
|
f += t?.fert ?? 40; n++;
|
||||||
|
}
|
||||||
|
fertF = 0.55 + clamp(f / n / 100, 0, 1) * 0.75; // 0.55..1.3
|
||||||
|
}
|
||||||
|
const staffed = d.workers > 0 ? b.workersLive / d.workers : 1;
|
||||||
|
const boost = heroProdBoost(c, b.x, b.y) * spellProdBoost(c, b.x, b.y);
|
||||||
|
if (okInput) {
|
||||||
|
for (const [g, need] of Object.entries(d.consumes ?? {})) {
|
||||||
|
b.inv[g as Good] = (b.inv[g as Good] ?? 0) - (need ?? 0);
|
||||||
|
}
|
||||||
|
b.progress += staffed * fertF * boost * 1.55 * (d.produces.rate / 100);
|
||||||
|
while (b.progress >= 1) {
|
||||||
|
b.progress -= 1;
|
||||||
|
b.inv[d.produces.good] = (b.inv[d.produces.good] ?? 0) + 100;
|
||||||
|
}
|
||||||
|
// ship out surplus
|
||||||
|
const stock = b.inv[d.produces.good] ?? 0;
|
||||||
|
if (stock >= 200) dispatchCart(c, b, d.produces.good, Math.min(stock, 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestFetch(c: CityState, b: Building, g: Good) {
|
||||||
|
if (b.fetchQueue.includes(g)) return;
|
||||||
|
if (totalStored(c, g) < 50) return;
|
||||||
|
b.fetchQueue.push(g);
|
||||||
|
dispatchCart(c, b, g, 100, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cart from producer/storage to destination storage. */
|
||||||
|
function dispatchCart(c: CityState, from: Building, g: Good, qty: number, isFetch = false) {
|
||||||
|
const start = roadAccessOf(c, from);
|
||||||
|
if (!start) return;
|
||||||
|
// find target
|
||||||
|
let target: Building | null = null, bestD = Infinity;
|
||||||
|
for (const s of c.buildings.values()) {
|
||||||
|
if (s.ruin || s.id === from.id) continue;
|
||||||
|
const isGranary = s.def === 'granary', isWh = s.def === 'warehouse';
|
||||||
|
if (!isGranary && !isWh) continue;
|
||||||
|
if (isGranary && !GOODS[g].food) continue;
|
||||||
|
const used = Object.entries(s.inv).reduce((a, [k, v]) => a + (k === g ? 0 : (v ?? 0)), 0);
|
||||||
|
if (used >= (def(s.def).storage?.capacity ?? 0) - 10) continue;
|
||||||
|
if (isFetch && ((s.inv[g] ?? 0) < 20)) continue;
|
||||||
|
const dd = Math.abs(s.x - from.x) + Math.abs(s.y - from.y);
|
||||||
|
if (dd < bestD) { bestD = dd; target = s; }
|
||||||
|
}
|
||||||
|
if (!target) return;
|
||||||
|
const goal = roadAccessOf(c, target);
|
||||||
|
if (!goal) return;
|
||||||
|
const path = landPath(c, start, goal);
|
||||||
|
if (!path) return;
|
||||||
|
if (isFetch) {
|
||||||
|
// fetch cart travels light; loads at target and returns home
|
||||||
|
const w = spawnWalker(c, {
|
||||||
|
kind: 'fetchCart', x: start.x + 0.5, y: start.y + 0.5, homeId: from.id,
|
||||||
|
path: path.slice(1), state: 'walkOut', speed: 2.0,
|
||||||
|
cargo: { [g]: qty }, carryCap: qty,
|
||||||
|
});
|
||||||
|
w.targetBld = target.id;
|
||||||
|
} else {
|
||||||
|
const have = from.inv[g] ?? 0;
|
||||||
|
const take = Math.min(have, qty);
|
||||||
|
from.inv[g] = have - take;
|
||||||
|
const w = spawnWalker(c, {
|
||||||
|
kind: 'cart', x: start.x + 0.5, y: start.y + 0.5, homeId: from.id,
|
||||||
|
path: path.slice(1), state: 'walkOut', speed: 1.7,
|
||||||
|
cargo: { [g]: take },
|
||||||
|
});
|
||||||
|
w.targetBld = target.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roadAccessOf(c: CityState, b: Building): { x: number; y: number } | null {
|
||||||
|
for (let dx = -1; dx <= b.w; dx++) for (const yy of [b.y - 1, b.y + b.h]) {
|
||||||
|
const t = c.tiles[yy * c.W + (b.x + dx)];
|
||||||
|
if (yy >= 0 && yy < c.H && b.x + dx >= 0 && b.x + dx < c.W && t && (t.road || t.plaza)) return { x: b.x + dx, y: yy };
|
||||||
|
}
|
||||||
|
for (let dy = -1; dy <= b.h; dy++) for (const xx of [b.x - 1, b.x + b.w]) {
|
||||||
|
if (xx < 0 || xx >= c.W || b.y + dy < 0 || b.y + dy >= c.H) continue;
|
||||||
|
const t = c.tiles[(b.y + dy) * c.W + xx];
|
||||||
|
if (t && (t.road || t.plaza)) return { x: xx, y: b.y + dy };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Market buyers fetch goods into market stock. */
|
||||||
|
function marketBuying(c: CityState) {
|
||||||
|
for (const m of c.buildings.values()) {
|
||||||
|
if (m.ruin || m.def !== 'market' || !buildingActive(m)) continue;
|
||||||
|
const buyers = [...c.walkers.values()].filter(w => w.kind === 'marketBuyer' && w.homeId === m.id && !w.dead).length;
|
||||||
|
const stockTot0 = Object.values(m.inv).reduce((a, v) => a + (v ?? 0), 0);
|
||||||
|
if (buyers >= (stockTot0 < 150 ? 2 : 1)) continue;
|
||||||
|
const wants: Good[] = [];
|
||||||
|
const stockTot = stockTot0;
|
||||||
|
if (stockTot < 300) {
|
||||||
|
for (const g of ['wheat', 'fruit', 'meat'] as Good[]) if ((m.inv[g] ?? 0) < 90) wants.push(g);
|
||||||
|
for (const g of ['pottery', 'furniture', 'oil', 'wine'] as Good[]) if ((m.inv[g] ?? 0) < 8) wants.push(g);
|
||||||
|
}
|
||||||
|
if (!wants.length) continue;
|
||||||
|
// nearest granary/warehouse with any wanted good
|
||||||
|
let target: Building | null = null, bestD = Infinity;
|
||||||
|
for (const s of c.buildings.values()) {
|
||||||
|
if (s.ruin || s.def === 'market') continue;
|
||||||
|
const sd = def(s.def);
|
||||||
|
if (!sd.storage) continue;
|
||||||
|
if (!wants.some(g => (s.inv[g] ?? 0) > 0)) continue;
|
||||||
|
const dd = Math.abs(s.x - m.x) + Math.abs(s.y - m.y);
|
||||||
|
if (dd < bestD) { bestD = dd; target = s; }
|
||||||
|
}
|
||||||
|
if (!target) continue;
|
||||||
|
const start = roadAccessOf(c, m), goal = roadAccessOf(c, target);
|
||||||
|
if (!start || !goal) continue;
|
||||||
|
const path = landPath(c, start, goal);
|
||||||
|
if (!path) continue;
|
||||||
|
const w = spawnWalker(c, {
|
||||||
|
kind: 'marketBuyer', x: start.x + 0.5, y: start.y + 0.5, homeId: m.id,
|
||||||
|
path: path.slice(1), state: 'walkOut', speed: 1.9, cargo: {}, carryCap: 220,
|
||||||
|
});
|
||||||
|
w.targetBld = target.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve cart arrivals (loading/unloading). */
|
||||||
|
export function cartArrivals(c: CityState, w: import('./types').Walker) {
|
||||||
|
const target = w.targetBld ? c.buildings.get(w.targetBld) : undefined;
|
||||||
|
if (!target || target.ruin) { w.dead = true; return; }
|
||||||
|
const home = c.buildings.get(w.homeId);
|
||||||
|
if (w.state === 'walkOut') {
|
||||||
|
// arrived at target
|
||||||
|
if (w.kind === 'cart') {
|
||||||
|
for (const [g, q] of Object.entries(w.cargo ?? {})) {
|
||||||
|
const gg = g as Good;
|
||||||
|
const sd = def(target.def).storage!;
|
||||||
|
const used = Object.values(target.inv).reduce((a, v) => a + (v ?? 0), 0);
|
||||||
|
const room = Math.max(0, sd.capacity - used);
|
||||||
|
const put = Math.min(q ?? 0, room);
|
||||||
|
target.inv[gg] = (target.inv[gg] ?? 0) + put;
|
||||||
|
if (put < (q ?? 0)) {
|
||||||
|
// overflow back home if possible
|
||||||
|
if (home) home.inv[gg] = (home.inv[gg] ?? 0) + ((q ?? 0) - put);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.cargo = {};
|
||||||
|
FX.float(w.x, w.y - 1, 'delivered', '#ffe9a0');
|
||||||
|
w.dead = true;
|
||||||
|
bus.emit('sfx', 'coin');
|
||||||
|
} else if (w.kind === 'fetchCart' || w.kind === 'marketBuyer') {
|
||||||
|
// load up to capacity from target stores
|
||||||
|
const wantList: Good[] = w.kind === 'marketBuyer'
|
||||||
|
? (['wheat', 'fruit', 'meat', 'pottery', 'furniture', 'oil', 'wine'] as Good[])
|
||||||
|
: (Object.keys(w.cargo ?? {}) as Good[]);
|
||||||
|
let space = w.carryCap ?? 300;
|
||||||
|
for (const g of wantList) {
|
||||||
|
const avail = target.inv[g] ?? 0;
|
||||||
|
const take = Math.min(avail, space, w.kind === 'marketBuyer' ? (g === 'wheat' || g === 'fruit' || g === 'meat' ? 170 : 15) : 110);
|
||||||
|
if (take > 0) {
|
||||||
|
target.inv[g] = avail - take;
|
||||||
|
w.cargo![g] = (w.cargo![g] ?? 0) + take;
|
||||||
|
space -= take;
|
||||||
|
}
|
||||||
|
if (space <= 0) break;
|
||||||
|
}
|
||||||
|
// head home
|
||||||
|
if (home) {
|
||||||
|
const goal = roadAccessOf(c, home);
|
||||||
|
if (goal) {
|
||||||
|
const p = landPath(c, { x: Math.round(w.x - 0.5), y: Math.round(w.y - 0.5) }, goal);
|
||||||
|
if (p) { w.path = p.slice(1); w.pathI = 0; w.state = 'returning'; return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.dead = true;
|
||||||
|
}
|
||||||
|
} else if (w.state === 'returning') {
|
||||||
|
if (home) {
|
||||||
|
for (const [g, q] of Object.entries(w.cargo ?? {})) {
|
||||||
|
home.inv[g as Good] = (home.inv[g as Good] ?? 0) + (q ?? 0);
|
||||||
|
}
|
||||||
|
if (w.kind === 'fetchCart' && home) {
|
||||||
|
const hq = home.fetchQueue;
|
||||||
|
for (const g of Object.keys(w.cargo ?? {})) {
|
||||||
|
const i = hq.indexOf(g as Good); if (i >= 0) hq.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.dead = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function findStaffedMarketNear(c: CityState, b: Building): Building | null {
|
||||||
|
let best: Building | null = null, bd = Infinity;
|
||||||
|
const bcx = b.x + b.w / 2, bcy = b.y + b.h / 2;
|
||||||
|
for (const m of c.buildings.values()) {
|
||||||
|
if (m.ruin || m.def !== 'market') continue;
|
||||||
|
if (!buildingActive(m)) continue;
|
||||||
|
const tot = Object.values(m.inv).reduce((a, v) => a + (v ?? 0), 0);
|
||||||
|
if (tot <= 0) continue;
|
||||||
|
const d = Math.max(Math.abs(m.x + m.w / 2 - bcx), Math.abs(m.y + m.h / 2 - bcy));
|
||||||
|
if (d <= 17 && d < bd) { bd = d; best = m; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── housing consumption & evolution ─────────────────────────────────────────
|
||||||
|
function incomePerResident(tier: number): number {
|
||||||
|
return [0.4, 0.6, 0.9, 1.2, 1.7, 2.2, 3.0, 4.0, 5.5, 7.0, 9.0][Math.min(tier, 10)];
|
||||||
|
}
|
||||||
|
const ENT_POINTS: Record<string, number> = { theater: 1, amphitheater: 2, colosseum: 3 };
|
||||||
|
|
||||||
|
function housingTick(c: CityState) {
|
||||||
|
let popTotal = 0;
|
||||||
|
let cultureSum = 0, cultureN = 0, propSum = 0, propN = 0;
|
||||||
|
const now = simMonths(c);
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin) continue;
|
||||||
|
const d = def(b.def);
|
||||||
|
if (d.housing === undefined) continue;
|
||||||
|
const tile = c.tiles[b.y * c.W + b.x];
|
||||||
|
const tierIdx = Math.min(b.houseLevel, HOUSE_TIERS.length - 1);
|
||||||
|
const tier = HOUSE_TIERS[tierIdx];
|
||||||
|
|
||||||
|
// ── central provisioning: a staffed market in range stocks the pantry ──
|
||||||
|
if (b.residents > 0 || maxResidentsOf(b) > 5) {
|
||||||
|
const mkt = findStaffedMarketNear(c, b);
|
||||||
|
if (mkt) {
|
||||||
|
const gs2 = b.goodsStock ??= {};
|
||||||
|
const capFood = maxResidentsOf(b) * 3 + 16;
|
||||||
|
let curFood = (gs2.wheat ?? 0) + (gs2.fruit ?? 0) + (gs2.meat ?? 0);
|
||||||
|
for (const g of ['wheat', 'fruit', 'meat'] as Good[]) {
|
||||||
|
const room = Math.max(0, capFood - curFood);
|
||||||
|
if (room <= 0) break;
|
||||||
|
const have = mkt.inv[g] ?? 0;
|
||||||
|
const take = Math.min(have, room, 30);
|
||||||
|
if (take > 0) { mkt.inv[g] = have - take; gs2[g] = (gs2[g] ?? 0) + take; curFood += take; }
|
||||||
|
}
|
||||||
|
// luxury needs pulled too
|
||||||
|
const needG = tier.needs.goods ?? {};
|
||||||
|
for (const key of Object.keys(needG) as Array<'pottery'|'furniture'|'oil'|'wine'>) {
|
||||||
|
const target = (needG[key] ?? 1) * 3;
|
||||||
|
const short = target - (gs2[key] ?? 0);
|
||||||
|
const have = mkt.inv[key] ?? 0;
|
||||||
|
const take = Math.min(have, Math.max(0, short));
|
||||||
|
if (take > 0) { mkt.inv[key] = have - take; gs2[key] = (gs2[key] ?? 0) + take; }
|
||||||
|
}
|
||||||
|
b.serviceT['market'] = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// consume food
|
||||||
|
const gs = b.goodsStock ?? {};
|
||||||
|
let foodAvail = (gs.wheat ?? 0) + (gs.fruit ?? 0) + (gs.meat ?? 0);
|
||||||
|
const eat = Math.min(b.residents, foodAvail, Math.ceil(b.residents * 0.85));
|
||||||
|
foodAvail -= eat;
|
||||||
|
let left = eat;
|
||||||
|
for (const g of ['wheat', 'fruit', 'meat'] as Good[]) {
|
||||||
|
const take = Math.min(gs[g] ?? 0, left);
|
||||||
|
gs[g] = (gs[g] ?? 0) - take; left -= take;
|
||||||
|
}
|
||||||
|
b.goodsStock = gs;
|
||||||
|
if (eat < b.residents) b.hungerMonths = (b.hungerMonths ?? 0) + 1;
|
||||||
|
else b.hungerMonths = 0;
|
||||||
|
|
||||||
|
// consume luxury goods per needs
|
||||||
|
const needsGoods = tier.needs.goods ?? {};
|
||||||
|
for (const key of Object.keys(needsGoods) as Array<'pottery'|'furniture'|'oil'|'wine'>) {
|
||||||
|
gs[key] = Math.max(0, (gs[key] ?? 0) - (needsGoods[key] ?? 0));
|
||||||
|
}
|
||||||
|
// spoilage slow decay of luxuries above need*3
|
||||||
|
for (const g of ['pottery', 'furniture', 'oil', 'wine'] as const) {
|
||||||
|
const cap = ((needsGoods as Record<string, number | undefined>)[g] ?? 0) * 3 + 4;
|
||||||
|
if ((gs[g] ?? 0) > cap) gs[g] = cap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluate current-tier satisfaction & next tier
|
||||||
|
const evalTier = (ti: number): { ok: boolean; unmet: string[] } => {
|
||||||
|
const t = HOUSE_TIERS[Math.min(ti, HOUSE_TIERS.length - 1)];
|
||||||
|
const unmet: string[] = [];
|
||||||
|
if ((t.needs.water ?? 0) > (tile?.waterCover ?? 0)) unmet.push('water');
|
||||||
|
const foods = (['wheat', 'fruit', 'meat'] as Good[]).filter(g => (gs[g] ?? 0) > 4).length;
|
||||||
|
if (foods < t.needs.foodTypes) unmet.push('food variety');
|
||||||
|
for (const s of t.needs.services ?? []) {
|
||||||
|
if (now - (b.serviceT[s] ?? -99) > 2.6) unmet.push(s);
|
||||||
|
}
|
||||||
|
if ((tile?.desirability ?? 0) < t.needs.desirability) unmet.push('desirability');
|
||||||
|
for (const [g, qty] of Object.entries(t.needs.goods ?? {})) {
|
||||||
|
if ((gs[g] ?? 0) < (qty ?? 1)) unmet.push(g);
|
||||||
|
}
|
||||||
|
if ((t.needs.entertainmentPts ?? 0) > (tile?.entPts ?? 0)) unmet.push('grand entertainment');
|
||||||
|
return { ok: unmet.length === 0, unmet };
|
||||||
|
};
|
||||||
|
|
||||||
|
const curOk = evalTier(tierIdx).ok;
|
||||||
|
const canUp = tierIdx < HOUSE_TIERS.length - 1 && evalTier(tierIdx + 1).ok;
|
||||||
|
|
||||||
|
if (canUp && b.residents >= Math.ceil(maxResidentsOf(b) * 0.75)) {
|
||||||
|
b.houseLevel++;
|
||||||
|
b.devolveTimer = 0;
|
||||||
|
if (b.houseLevel >= 4) addMsg(c, `A ${HOUSE_TIERS[b.houseLevel].name} has risen in your city!`, 'good', '🏠');
|
||||||
|
Sfx.play('evolve');
|
||||||
|
} else if (!curOk) {
|
||||||
|
b.devolveTimer++;
|
||||||
|
if (b.devolveTimer > tier.devolveMonthsGrace) {
|
||||||
|
b.devolveTimer = 0;
|
||||||
|
if (b.houseLevel > 0) { b.houseLevel--; addMsg(c, `Housing declined to ${HOUSE_TIERS[b.houseLevel].name}.`, 'warn', '📉'); }
|
||||||
|
else if (b.residents > 0 && (b.hungerMonths ?? 0) > 3) {
|
||||||
|
// abandon
|
||||||
|
c.stats.totalDeaths += Math.min(b.residents, 3);
|
||||||
|
b.residents = Math.max(0, b.residents - 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else b.devolveTimer = 0;
|
||||||
|
|
||||||
|
// starvation deaths
|
||||||
|
if ((b.hungerMonths ?? 0) >= 4 && b.residents > 0) {
|
||||||
|
const starve = Math.max(1, Math.round(b.residents * 0.12));
|
||||||
|
b.residents -= starve;
|
||||||
|
c.stats.totalDeaths += starve;
|
||||||
|
b.hungerMonths = 2;
|
||||||
|
if (c.month - lastStarveMsg >= 4) {
|
||||||
|
lastStarveMsg = c.month;
|
||||||
|
addMsg(c, 'Citizens are starving!', 'danger', '💀');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// emigration when angry
|
||||||
|
if (c.sentiment < 22 && b.residents > 4 && rng.chance(0.25)) b.residents -= 2;
|
||||||
|
|
||||||
|
popTotal += b.residents;
|
||||||
|
|
||||||
|
// ratings inputs
|
||||||
|
if (b.residents > 0) {
|
||||||
|
const cultParts: number[] = [];
|
||||||
|
for (const s of ['religion', 'education', 'entertainment', 'health'] as const) {
|
||||||
|
cultParts.push(now - (b.serviceT[s] ?? -99) < 2.6 ? 1 : 0);
|
||||||
|
}
|
||||||
|
cultureSum += cultParts.reduce((a, v) => a + v, 0) / 4 * b.residents;
|
||||||
|
cultureN += b.residents;
|
||||||
|
propSum += (tierIdx / (HOUSE_TIERS.length - 1)) * b.residents;
|
||||||
|
propN += b.residents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.population = popTotal;
|
||||||
|
const cultAvg = cultureN ? cultureSum / cultureN : 0;
|
||||||
|
c.ratings.culture = Math.round(clamp(cultAvg * 100, 0, 100));
|
||||||
|
const unemploymentPenalty = c.population > 60 ? clamp((c.unemploymentPct - 24) * 0.55, 0, 16) : 0;
|
||||||
|
c.ratings.prosperity = Math.round(clamp(propN ? (propSum / propN) * 115 - unemploymentPenalty : 0, 0, 100));
|
||||||
|
|
||||||
|
// immigration attempts — nobody moves to a starving town
|
||||||
|
let starvingHouses = 0;
|
||||||
|
for (const b2 of c.buildings.values()) {
|
||||||
|
if (!b2.ruin && def(b2.def).housing !== undefined && (b2.hungerMonths ?? 0) > 0) starvingHouses++;
|
||||||
|
}
|
||||||
|
const foodInStores = totalStored(c, 'wheat') + totalStored(c, 'fruit') + totalStored(c, 'meat');
|
||||||
|
if (starvingHouses === 0 || foodInStores > 100) {
|
||||||
|
const vacancies = countVacancies(c);
|
||||||
|
const attempts = clamp(Math.ceil(vacancies / 6), 0, 5);
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
if (!trySpawnImmigrant(c)) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countVacancies(c: CityState): number {
|
||||||
|
let n = 0;
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin || def(b.def).housing === undefined) continue;
|
||||||
|
n += Math.max(0, maxResidentsOf(b) - b.residents - incomingFor(c, b.id));
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── taxes & wages ───────────────────────────────────────────────────────────
|
||||||
|
function taxesAndWages(c: CityState) {
|
||||||
|
const now = simMonths(c);
|
||||||
|
let taxes = 0;
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin || def(b.def).housing === undefined || b.residents <= 0) continue;
|
||||||
|
const taxCovered = now - (b.serviceT['tax'] ?? -999) < 4;
|
||||||
|
if (!taxCovered) continue;
|
||||||
|
taxes += b.residents * incomePerResident(b.houseLevel) * (c.taxRate / 9);
|
||||||
|
}
|
||||||
|
const wages = c.employed * c.wageRate * 1.0;
|
||||||
|
c.funds += Math.round(taxes - wages);
|
||||||
|
if (Math.abs(taxes - wages) > 40) bus.emit('fundsChanged', undefined);
|
||||||
|
return { taxes: Math.round(taxes), wages: Math.round(wages) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sentiment ───────────────────────────────────────────────────────────────
|
||||||
|
function sentimentTick(c: CityState) {
|
||||||
|
let target = 62;
|
||||||
|
target += c.taxRate < 9 ? (9 - c.taxRate) * 1.6 : -(c.taxRate - 9) * 2.2;
|
||||||
|
target -= clamp(c.unemploymentPct - 15, 0, 40) * 0.8;
|
||||||
|
let starving = 0, fed = 0;
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (def(b.def).housing === undefined || b.ruin) continue;
|
||||||
|
if ((b.hungerMonths ?? 0) > 0) starving += b.residents; else fed += b.residents;
|
||||||
|
}
|
||||||
|
if (fed + starving > 0) target -= (starving / (fed + starving)) * 45;
|
||||||
|
if (c.funds < 0) target -= 10;
|
||||||
|
c.sentiment = Math.round(clamp(c.sentiment + (target - c.sentiment) * 0.25, 0, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── trade ───────────────────────────────────────────────────────────────────
|
||||||
|
function tradeTick(c: CityState) {
|
||||||
|
if (c.month % 3 !== 0) return;
|
||||||
|
const posts = [...c.buildings.values()].filter(b => b.def === 'tradingPost' && buildingActive(b) && !b.ruin);
|
||||||
|
if (!posts.length) return;
|
||||||
|
for (const tc of c.trades) {
|
||||||
|
if (!tc.open) continue;
|
||||||
|
let traded = 0;
|
||||||
|
for (const g of tc.exports) {
|
||||||
|
const surplus = totalStored(c, g) - 120;
|
||||||
|
if (surplus <= 0) continue;
|
||||||
|
const sellQty = Math.min(surplus, 150);
|
||||||
|
takeGoodsFromStores(c, g, sellQty);
|
||||||
|
const gain = Math.round(sellQty * GOODS[g].basePrice * 0.85 * tc.priceFactor / 10);
|
||||||
|
c.funds += gain;
|
||||||
|
traded += gain;
|
||||||
|
}
|
||||||
|
// imports (player-toggled)
|
||||||
|
for (const g of tc.imports) {
|
||||||
|
if (c.funds < 400) break;
|
||||||
|
const cost = Math.round(120 * GOODS[g].basePrice * 1.15 * tc.priceFactor / 10);
|
||||||
|
c.funds -= cost;
|
||||||
|
addGoodsToStores(c, g, 120);
|
||||||
|
traded -= cost;
|
||||||
|
}
|
||||||
|
if (traded !== 0) {
|
||||||
|
const post = posts[c.month % posts.length];
|
||||||
|
const acc = post;
|
||||||
|
const ep = c.entryPoints[rng.int(0, c.entryPoints.length - 1)];
|
||||||
|
const goal = roadAccessOf(c, acc) ?? { x: acc.x, y: acc.y + acc.h };
|
||||||
|
const p = landPath(c, ep, goal);
|
||||||
|
if (p) {
|
||||||
|
spawnWalker(c, {
|
||||||
|
kind: 'caravan', x: ep.x + 0.5, y: ep.y + 0.5, homeId: acc.id,
|
||||||
|
path: p.slice(1), state: 'walkOut', speed: 1.4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fire & collapse ─────────────────────────────────────────────────────────
|
||||||
|
function disasterRoll(c: CityState) {
|
||||||
|
reseed(c);
|
||||||
|
const now = simMonths(c);
|
||||||
|
for (const b of [...c.buildings.values()]) {
|
||||||
|
if (b.ruin || b.burning > 0) continue;
|
||||||
|
const d = def(b.def);
|
||||||
|
if (d.fireRisk <= 0 && d.collapseRisk <= 0) continue;
|
||||||
|
const prefectRecent = now - (b.serviceT['prefect'] ?? -99) < 2.2;
|
||||||
|
const engRecent = now - (b.serviceT['engineer'] ?? -99) < 2.2;
|
||||||
|
if (d.fireRisk > 0 && rng.chance(d.fireRisk * 0.09 * (prefectRecent ? 0.12 : 1))) {
|
||||||
|
igniteBuilding(c, b, 'fire');
|
||||||
|
} else if (d.collapseRisk > 0 && rng.chance(d.collapseRisk * 0.07 * (engRecent ? 0.1 : 1))) {
|
||||||
|
ruinBuilding(c, b, 'collapse');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function igniteBuilding(c: CityState, b: Building, cause: 'fire' | 'lightning' | 'monster') {
|
||||||
|
if (b.ruin || b.burning > 0) return;
|
||||||
|
if (def(b.def).cat === 'military' && b.def === 'wall') return; // walls don't burn
|
||||||
|
b.burning = 40 + rng.next() * 25;
|
||||||
|
const t = c.tiles[b.y * c.W + b.x];
|
||||||
|
if (t) t.fireFervor = 1;
|
||||||
|
if (cause !== 'monster') addMsg(c, `Fire at ${def(b.def).name}!`, 'danger', '🔥');
|
||||||
|
Sfx.play('fire');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ruinBuilding(c: CityState, b: Building, cause: 'collapse' | 'burned' | 'monster' | 'enemy') {
|
||||||
|
if (b.ruin) return;
|
||||||
|
b.ruin = true;
|
||||||
|
b.burning = 0;
|
||||||
|
b.residents = 0;
|
||||||
|
b.workersLive = 0;
|
||||||
|
for (const w of c.walkers.values()) if (w.homeId === b.id && !w.unit) w.dead = true;
|
||||||
|
FX.blast(b.x + b.w / 2, b.y + b.h / 2, Math.max(b.w, b.h) * 0.8, cause === 'collapse' ? '#b8ab88' : '#ff9a40', 0.7);
|
||||||
|
const label = cause === 'collapse' ? 'collapsed' : cause === 'burned' ? 'burned down' : 'was destroyed';
|
||||||
|
addMsg(c, `${def(b.def).name} ${label}!`, 'danger', '💥');
|
||||||
|
reassignLabor(c);
|
||||||
|
Sfx.play('crumble');
|
||||||
|
}
|
||||||
|
|
||||||
|
// continuous fire growth/spread — called each frame with dt
|
||||||
|
export function updateFireImpl(c: CityState, dt: number) {
|
||||||
|
for (const b of [...c.buildings.values()]) {
|
||||||
|
if (b.burning > 0 && !b.ruin) {
|
||||||
|
b.burning -= dt;
|
||||||
|
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
|
||||||
|
if (rng.chance(dt * 2)) FX.spark(cx, cy, 'rgba(255,170,60,.85)', 1, 22, -30);
|
||||||
|
if (b.burning <= 0) ruinBuilding(c, b, 'burned');
|
||||||
|
// spread fervor
|
||||||
|
const t = c.tiles[b.y * c.W + b.x];
|
||||||
|
if (t) t.fireFervor = Math.min(1.5, t.fireFervor + dt * 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fervor spread monthly-ish granularity via accumulation
|
||||||
|
fireAccum += dt;
|
||||||
|
if (fireAccum >= 2) {
|
||||||
|
fireAccum = 0;
|
||||||
|
for (let y = 0; y < c.H; y++) for (let x = 0; x < c.W; x++) {
|
||||||
|
const t = c.tiles[y * c.W + x];
|
||||||
|
if (t.fireFervor > 0.9) {
|
||||||
|
const b = t.bld ? c.buildings.get(t.bld) : undefined;
|
||||||
|
if (b && !b.ruin && b.burning <= 0 && rng.chance(0.25)) igniteBuilding(c, b, 'fire');
|
||||||
|
// spread to neighbors
|
||||||
|
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
|
||||||
|
const nt = c.tiles[(y + dy) * c.W + (x + dx)];
|
||||||
|
if (nt && nt.terrain !== 'water') nt.fireFervor = Math.min(0.85, nt.fireFervor + 0.12);
|
||||||
|
}
|
||||||
|
} else if (t.fireFervor > 0) {
|
||||||
|
t.fireFervor = Math.max(0, t.fireFervor - 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let fireAccum = 0;
|
||||||
|
|
||||||
|
// ── scheduled events ────────────────────────────────────────────────────────
|
||||||
|
function scheduledEvents(c: CityState, sc: Scenario) {
|
||||||
|
const absMonth = c.month;
|
||||||
|
for (const inv of sc.invasions) {
|
||||||
|
const at = inv.atYearMonth[0] * 12 + inv.atYearMonth[1];
|
||||||
|
if (absMonth === at - inv.warnMonths) {
|
||||||
|
addMsg(c, `Scouts warn: raiders approach in ${inv.warnMonths} months!`, 'warn', '🛡️');
|
||||||
|
}
|
||||||
|
if (absMonth === at) {
|
||||||
|
trySpawnInvasion(c, inv.kind, inv.squads, inv.size, inv.fromEdge ?? 'random');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const me of sc.monsterEvents) {
|
||||||
|
const at = me.atYearMonth[0] * 12 + me.atYearMonth[1];
|
||||||
|
if (absMonth === at - me.warnMonths) {
|
||||||
|
addMsg(c, `The auguries scream: a monster stirs (${me.kind}).`, 'danger', '🐲');
|
||||||
|
}
|
||||||
|
if (absMonth === at) spawnMonsterWave(c, me.kind, me.squads, me.size);
|
||||||
|
}
|
||||||
|
// sandbox scaling waves
|
||||||
|
if (sc.goals.length === 0 && sc.invasions.length === 0 && c.month > 16 && c.month % 14 === 0) {
|
||||||
|
const waveN = 1 + Math.floor(c.ratings.prosperity / 25);
|
||||||
|
spawnMonsterWave(c, rng.pick(['barbarian', 'harpy', 'minotaur', 'hydra', 'lich'] as const), waveN, 6 + Math.floor(c.population / 80));
|
||||||
|
addMsg(c, 'The wilds stir against your prosperity...', 'warn', '⚠️');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mana & research ─────────────────────────────────────────────────────────
|
||||||
|
function manaTick(c: CityState) {
|
||||||
|
let gen = 0;
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin) continue;
|
||||||
|
const d = def(b.def);
|
||||||
|
if (d.manaGen && buildingActive(b)) gen += d.manaGen;
|
||||||
|
}
|
||||||
|
c.manaMax = 50 + gen * 6;
|
||||||
|
c.mana = clamp(c.mana + gen * 0.35, 0, c.manaMax);
|
||||||
|
// research unlocks from library/arcanum
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin) continue;
|
||||||
|
const d = def(b.def);
|
||||||
|
if (d.research && buildingActive(b)) {
|
||||||
|
for (const sp of d.research) {
|
||||||
|
if (!c.spellState.unlocked.has(sp)) {
|
||||||
|
c.spellState.unlocked.add(sp);
|
||||||
|
addMsg(c, `Your scholars unlocked the spell "${sp}"!`, 'good', '✨');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── victory / defeat ────────────────────────────────────────────────────────
|
||||||
|
function checkVictory(c: CityState, sc: Scenario) {
|
||||||
|
if (c.victory) return;
|
||||||
|
if (c.population <= 0 && c.month > 8) {
|
||||||
|
c.victory = 'lost';
|
||||||
|
c.defeatReason = 'Your city stands empty.';
|
||||||
|
addMsg(c, c.defeatReason, 'danger', '☠️');
|
||||||
|
bus.emit('victory', { won: false, reason: c.defeatReason });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (c.funds < -1500) {
|
||||||
|
c.victory = 'lost';
|
||||||
|
c.defeatReason = 'Bankrupt! The Senate recalls you in disgrace.';
|
||||||
|
addMsg(c, c.defeatReason, 'danger', '☠️');
|
||||||
|
bus.emit('victory', { won: false, reason: c.defeatReason });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sc.goals.length) return;
|
||||||
|
const done = sc.goals.every(g => goalMet(c, g.kind, g.amount ?? 0));
|
||||||
|
if (done) {
|
||||||
|
c.victory = 'won';
|
||||||
|
addMsg(c, 'VICTORY! The Senate showers you in laurels!', 'good', '🏆');
|
||||||
|
bus.emit('victory', { won: true });
|
||||||
|
Sfx.play('fanfare');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import type { ScenarioGoal } from './types';
|
||||||
|
export function goalMet(c: CityState, kind: ScenarioGoal['kind'], amount: number): boolean {
|
||||||
|
switch (kind) {
|
||||||
|
case 'population': return c.population >= amount;
|
||||||
|
case 'culture': return c.ratings.culture >= amount;
|
||||||
|
case 'prosperity': return c.ratings.prosperity >= amount;
|
||||||
|
case 'peace': return c.ratings.peace >= amount;
|
||||||
|
case 'favor': return c.ratings.favor >= amount;
|
||||||
|
case 'treasury': return c.funds >= amount;
|
||||||
|
case 'surviveYears': return c.month >= amount * 12;
|
||||||
|
case 'slayMonsters': return c.stats.monstersSlain >= amount;
|
||||||
|
case 'buildWonder': return [...c.buildings.values()].some(b => b.def === 'colosseum' && !b.ruin);
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function goalProgress(c: CityState, kind: ScenarioGoal['kind'], amount: number): number {
|
||||||
|
switch (kind) {
|
||||||
|
case 'population': return c.population;
|
||||||
|
case 'culture': return c.ratings.culture;
|
||||||
|
case 'prosperity': return c.ratings.prosperity;
|
||||||
|
case 'peace': return c.ratings.peace;
|
||||||
|
case 'favor': return c.ratings.favor;
|
||||||
|
case 'treasury': return c.funds;
|
||||||
|
case 'surviveYears': return Math.floor(c.month / 12);
|
||||||
|
case 'slayMonsters': return c.stats.monstersSlain;
|
||||||
|
case 'buildWonder': return [...c.buildings.values()].some(b => b.def === 'colosseum' && !b.ruin) ? 1 : 0;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the monthly orchestrator ────────────────────────────────────────────────
|
||||||
|
export function monthlyTickImpl(c: CityState, sc: Scenario) {
|
||||||
|
c.month++;
|
||||||
|
if (c.month % 12 === 0) c.year++;
|
||||||
|
if (c.dirtyCoverage) recomputeCoverage(c);
|
||||||
|
reassignLabor(c);
|
||||||
|
produceMonthly(c);
|
||||||
|
cartWalkersResolve(c);
|
||||||
|
marketBuying(c);
|
||||||
|
housingTick(c);
|
||||||
|
const { taxes } = taxesAndWages(c);
|
||||||
|
sentimentTick(c);
|
||||||
|
tradeTick(c);
|
||||||
|
manaTick(c);
|
||||||
|
// ruins fade away on their own after 6 months
|
||||||
|
for (const b of [...c.buildings.values()]) {
|
||||||
|
if (b.ruin && c.month - (b.ruinedAt ?? c.month) >= 6) removeBuilding(c, b.id, 0);
|
||||||
|
}
|
||||||
|
scheduledEvents(c, sc);
|
||||||
|
peaceFavorTick(c);
|
||||||
|
heroMonthly(c);
|
||||||
|
checkVictory(c, sc);
|
||||||
|
|
||||||
|
// entertainment points cache refresh (venues coverage)
|
||||||
|
entPointsRefresh(c);
|
||||||
|
|
||||||
|
c.autosavePending = true;
|
||||||
|
bus.emit('month', { month: c.month % 12, year: c.year });
|
||||||
|
if (taxes > 0 && c.month % 6 === 0) FX.float(-1, -1, '', ''); // no-op keepalive
|
||||||
|
}
|
||||||
|
|
||||||
|
function cartWalkersResolve(c: CityState) {
|
||||||
|
// carts that could not path get resolved instantly (fallback)
|
||||||
|
for (const w of [...c.walkers.values()]) {
|
||||||
|
if (w.kind !== 'cart' && w.kind !== 'fetchCart' && w.kind !== 'marketBuyer') continue;
|
||||||
|
if (w.path.length === 0 && w.state === 'walkOut' && !w.dead) cartArrivals(c, w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function peaceFavorTick(c: CityState) {
|
||||||
|
const foes = [...c.walkers.values()].filter(w => w.unit?.side === 'foe').length;
|
||||||
|
if (foes === 0) c.stats.monthsSinceInvasion++;
|
||||||
|
else { c.stats.monthsSinceInvasion = 0; c.ratings.favor = clamp(c.ratings.favor - 0.5, 0, 100); }
|
||||||
|
c.ratings.peace = Math.round(clamp(c.stats.monthsSinceInvasion * 2, 0, 100));
|
||||||
|
c.ratings.favor = clamp(c.ratings.favor + 0.15, 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entPointsRefresh(c: CityState) {
|
||||||
|
for (const t of c.tiles) t.entPts = 0;
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin) continue;
|
||||||
|
const pts = ENT_POINTS[b.def];
|
||||||
|
if (!pts || !buildingActive(b)) continue;
|
||||||
|
const R = def(b.def).walker?.range ?? 20;
|
||||||
|
const cx = b.x + (b.w - 1) / 2, cy = b.y + (b.h - 1) / 2;
|
||||||
|
for (let y = Math.max(0, cy - R | 0); y <= Math.min(c.H - 1, cy + R); y++) {
|
||||||
|
for (let x = Math.max(0, cx - R | 0); x <= Math.min(c.W - 1, cx + R); x++) {
|
||||||
|
const dist = Math.abs(x - cx) + Math.abs(y - cy);
|
||||||
|
if (dist <= R) c.tiles[y * c.W + x].entPts += pts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gift torome → favor (UI action)
|
||||||
|
export function sendGiftToRome(c: CityState): boolean {
|
||||||
|
const cost = 800;
|
||||||
|
if (c.funds < cost) return false;
|
||||||
|
c.funds -= cost;
|
||||||
|
c.ratings.favor = clamp(c.ratings.favor + 8, 0, 100);
|
||||||
|
addMsg(c, 'A lavish gift was sent to Rome. Favor rises.', 'good', '🎁');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// ─── Wires simulation module implementations into the city tick ────────────
|
||||||
|
import { registerSimHooks } from './city';
|
||||||
|
import { updateWalkersImpl } from './walkers';
|
||||||
|
import { updateCombatImpl, heroProdBoostImpl } from './combat';
|
||||||
|
import { updateMagicImpl, spellProdBoostImpl } from './magic';
|
||||||
|
import { updateFireImpl, monthlyTickImpl, setHeroProdBoostFn, setSpellProdBoostFn } from './economy';
|
||||||
|
|
||||||
|
let registered = false;
|
||||||
|
export function ensureSimHooks() {
|
||||||
|
if (registered) return;
|
||||||
|
registered = true;
|
||||||
|
registerSimHooks({
|
||||||
|
updateWalkers: updateWalkersImpl,
|
||||||
|
updateCombat: updateCombatImpl,
|
||||||
|
updateMagic: updateMagicImpl,
|
||||||
|
updateFire: updateFireImpl,
|
||||||
|
monthlyTick: monthlyTickImpl,
|
||||||
|
});
|
||||||
|
setHeroProdBoostFn(heroProdBoostImpl);
|
||||||
|
setSpellProdBoostFn(spellProdBoostImpl);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// ─── Magic: mana, spell casting, arcane buffs ──────────────────────────────
|
||||||
|
import type { CityState, SpellId } from './types';
|
||||||
|
import { SPELLS } from './types';
|
||||||
|
import { def } from '../data/buildings';
|
||||||
|
import { clamp } from '../core/util';
|
||||||
|
import { addMsg, tileAt } from './city';
|
||||||
|
import { FX, Sfx } from '../render/fx';
|
||||||
|
import { spawnGolemAt } from './combat';
|
||||||
|
|
||||||
|
export const simNow = (c: CityState) => c.month + (c.monthFloat ?? 0);
|
||||||
|
|
||||||
|
export function spellReady(c: CityState, id: SpellId): boolean {
|
||||||
|
if (!c.spellState.unlocked.has(id)) return false;
|
||||||
|
return simNow(c) >= (c.spellState.cooldowns[id] ?? 0);
|
||||||
|
}
|
||||||
|
export function spellCooldownLeft(c: CityState, id: SpellId): number {
|
||||||
|
return Math.max(0, (c.spellState.cooldowns[id] ?? 0) - simNow(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function castSpellImpl(c: CityState, id: SpellId, tx: number, ty: number): boolean {
|
||||||
|
const sd = SPELLS[id];
|
||||||
|
if (!spellReady(c, id)) return false;
|
||||||
|
if (c.mana < sd.mana) return false;
|
||||||
|
|
||||||
|
switch (id) {
|
||||||
|
case 'blessing': {
|
||||||
|
c.spellState.areaBuffs.push({ x: tx, y: ty, r: sd.radius, until: simNow(c) + 3 }); // ~3 months
|
||||||
|
FX.blast(tx, ty, sd.radius, '#8fe88a', 1.2);
|
||||||
|
for (let i = 0; i < 24; i++) FX.spark(tx + Math.sin(i) * sd.radius * 0.6, ty + Math.cos(i * 1.7) * sd.radius * 0.6, '#aaffaa', 1, 30, -20);
|
||||||
|
addMsg(c, 'Ceres blesses the fields!', 'good', '🌿');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'firestorm': {
|
||||||
|
let hits = 0;
|
||||||
|
for (const w of [...c.walkers.values()]) {
|
||||||
|
if (w.unit?.side !== 'foe' || w.dead) continue;
|
||||||
|
const d = Math.hypot(w.x - tx, w.y - ty);
|
||||||
|
if (d <= sd.radius + 0.4) {
|
||||||
|
w.unit.hp -= 160;
|
||||||
|
hits++;
|
||||||
|
FX.spark(w.x, w.y - 1, '#ff9050', 10, 60, 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const w of [...c.walkers.values()]) if ((w.unit?.side === 'foe') && w.unit.hp <= 0) w.dead = true;
|
||||||
|
FX.blast(tx, ty, sd.radius, '#ff7040', 0.9);
|
||||||
|
Sfx.play('firestorm');
|
||||||
|
addMsg(c, `Firestorm engulfs ${hits} foes!`, hits ? 'good' : 'info', '🔥');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'heal': {
|
||||||
|
for (const w of c.walkers.values()) {
|
||||||
|
if (w.unit?.side === 'city' && !w.dead) {
|
||||||
|
const d = Math.hypot(w.x - tx, w.y - ty);
|
||||||
|
if (d <= sd.radius) {
|
||||||
|
w.unit.hp = Math.min(w.unit.maxHp, w.unit.hp + w.unit.maxHp * 0.5);
|
||||||
|
FX.spark(w.x, w.y - 1.5, '#7fd0ff', 6, 26, -40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.burning > 0 && Math.hypot(b.x + b.w / 2 - tx, b.y + b.h / 2 - ty) <= sd.radius) {
|
||||||
|
b.burning = 0;
|
||||||
|
FX.float(b.x + b.w / 2, b.y, 'doused', '#7fd0ff');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FX.blast(tx, ty, sd.radius, '#7fd0ff', 1.1);
|
||||||
|
Sfx.play('heal');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'haste': {
|
||||||
|
c.spellState.buffs.hasteUntil = simNow(c) + 4;
|
||||||
|
FX.float(tx, ty, "Hermes' blessing!", '#ffe9a0');
|
||||||
|
Sfx.play('zap');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'shield': {
|
||||||
|
c.spellState.buffs.shieldUntil = simNow(c) + 5;
|
||||||
|
FX.float(tx, ty, 'The Aegis rises!', '#ffe9a0');
|
||||||
|
Sfx.play('fanfare');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'insight': {
|
||||||
|
const gain = Math.round(clamp(c.population * 0.4 + c.employed * 1.2, 20, 800));
|
||||||
|
c.funds += gain;
|
||||||
|
FX.float(tx, ty, `+${gain} denarii`, '#ffe9a0');
|
||||||
|
Sfx.play('coin');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'golem': {
|
||||||
|
// requires iron
|
||||||
|
let took = false;
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if ((b.inv['iron'] ?? 0) >= 20) { b.inv['iron'] = (b.inv['iron'] ?? 0) - 20; took = true; break; }
|
||||||
|
}
|
||||||
|
if (!took) {
|
||||||
|
addMsg(c, 'A golem needs 20 iron in storage.', 'warn', '⛏️');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const t = tileAt(c, Math.round(tx), Math.round(ty));
|
||||||
|
if (!t || t.terrain === 'water' || t.wall) return false;
|
||||||
|
spawnGolemAt(c, Math.round(tx), Math.round(ty));
|
||||||
|
FX.blast(tx, ty, 1.6, '#9ab', 0.8);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mana -= sd.mana;
|
||||||
|
c.spellState.cooldowns[id] = simNow(c) + sd.cooldown / 10; // cooldown seconds → ~months scale
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-frame magic upkeep (buff expiry visuals). */
|
||||||
|
export function updateMagicImpl(_c: CityState, _dt: number) {
|
||||||
|
// area buffs expire naturally by timestamp; nothing needed per-frame yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Production boost from blessing areas — injected into economy. */
|
||||||
|
export function spellProdBoostImpl(c: CityState, x: number, y: number): number {
|
||||||
|
let boost = 1;
|
||||||
|
const now = simNow(c);
|
||||||
|
for (const a of c.spellState.areaBuffs) {
|
||||||
|
if (a.until < now) continue;
|
||||||
|
const d = Math.hypot(a.x + 0.5 - x, a.y + 0.5 - y);
|
||||||
|
if (d <= a.r) boost += 0.8;
|
||||||
|
}
|
||||||
|
void def;
|
||||||
|
return boost;
|
||||||
|
}
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
// ─── Save / Load ────────────────────────────────────────────────────────────
|
||||||
|
import type { Building, CityState, Scenario, ServiceType } from './types';
|
||||||
|
import { def } from '../data/buildings';
|
||||||
|
import { generateMap, addMsg } from './city';
|
||||||
|
import { reassignLabor, recomputeCoverage } from './city';
|
||||||
|
import { resetHeroes } from './combat';
|
||||||
|
|
||||||
|
const KEY = 'ia_save_slots_v1';
|
||||||
|
export interface SaveSlotMeta { name: string; at: number; scenarioName: string; population: number; funds: number }
|
||||||
|
|
||||||
|
export function listSaves(): Array<{ slot: string; meta: SaveSlotMeta }> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
return Object.entries(JSON.parse(raw) as Record<string, SaveSlotMeta>)
|
||||||
|
.map(([slot, meta]) => ({ slot, meta }))
|
||||||
|
.sort((a, b) => b.meta.at - a.meta.at);
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function allSlots(): Record<string, { meta: SaveSlotMeta; data: unknown }> {
|
||||||
|
try { return JSON.parse(localStorage.getItem(KEY) ?? '{}'); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCity(c: CityState, sc: Scenario, slot: string): boolean {
|
||||||
|
const buildings = [...c.buildings.values()].map(b => ({
|
||||||
|
id: b.id, def: b.def, x: b.x, y: b.y,
|
||||||
|
hp: b.hp ?? 100, ruin: b.ruin ? 1 : 0, burning: b.burning > 0 ? 1 : 0,
|
||||||
|
residents: b.residents, houseLevel: b.houseLevel,
|
||||||
|
inv: Object.entries(b.inv).filter(([, v]) => (v ?? 0) > 0),
|
||||||
|
goodsStock: b.goodsStock ? Object.entries(b.goodsStock).filter(([, v]) => (v ?? 0) > 0) : null,
|
||||||
|
}));
|
||||||
|
const data = {
|
||||||
|
v: 3, scenario: sc,
|
||||||
|
state: {
|
||||||
|
seed: c.seed, W: c.W, H: c.H, month: c.month, year: c.year, monthFloat: c.monthFloat ?? 0,
|
||||||
|
funds: c.funds, mana: c.mana, manaMax: c.manaMax, population: c.population,
|
||||||
|
taxRate: c.taxRate, wageRate: c.wageRate, sentiment: c.sentiment,
|
||||||
|
ratings: c.ratings, stats: c.stats,
|
||||||
|
spells: [...c.spellState.unlocked], cooldowns: c.spellState.cooldowns,
|
||||||
|
buffs: c.spellState.buffs,
|
||||||
|
trades: c.trades,
|
||||||
|
nextMsgId: c.nextMsgId,
|
||||||
|
messages: c.messages.slice(0, 30),
|
||||||
|
},
|
||||||
|
buildings,
|
||||||
|
};
|
||||||
|
const slots = allSlots();
|
||||||
|
slots[slot] = {
|
||||||
|
meta: {
|
||||||
|
name: sc.name, at: Date.now(),
|
||||||
|
scenarioName: sc.name,
|
||||||
|
population: c.population, funds: Math.round(c.funds),
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
try { localStorage.setItem(KEY, JSON.stringify(slots)); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSave(slot: string) {
|
||||||
|
const slots = allSlots();
|
||||||
|
delete slots[slot];
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(slots));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoadedCity = { city: CityState; scenario: Scenario };
|
||||||
|
|
||||||
|
export function loadCity(slot: string): LoadedCity | null {
|
||||||
|
const rec = allSlots()[slot];
|
||||||
|
if (!rec) return null;
|
||||||
|
try {
|
||||||
|
const d = rec.data as {
|
||||||
|
v: number; scenario: Scenario;
|
||||||
|
state: Record<string, unknown> & {
|
||||||
|
ratings: CityState['ratings']; stats: CityState['stats'];
|
||||||
|
spells: string[]; cooldowns: Record<string, number>;
|
||||||
|
buffs: { hasteUntil: number; shieldUntil: number };
|
||||||
|
trades: CityState['trades']; messages: CityState['messages'];
|
||||||
|
spellAreas?: Array<{ x: number; y: number; r: number; until: number }>;
|
||||||
|
};
|
||||||
|
buildings: Array<Partial<Building> & { inv: [string, number][]; goodsStock: [string, number][] | null }>;
|
||||||
|
flags?: string;
|
||||||
|
};
|
||||||
|
const sc = d.scenario;
|
||||||
|
|
||||||
|
// fresh base
|
||||||
|
const gen = generateMap(sc);
|
||||||
|
const tiles = gen.tiles;
|
||||||
|
// restore surface flags
|
||||||
|
if (d.flags) {
|
||||||
|
const fl = d.flags;
|
||||||
|
for (let i = 0; i < Math.min(fl.length, tiles.length); i++) {
|
||||||
|
const ch = fl[i];
|
||||||
|
if (ch === '1') tiles[i].road = true;
|
||||||
|
else if (ch === '2') tiles[i].plaza = true;
|
||||||
|
else if (ch === '3') tiles[i].garden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const city: CityState = {
|
||||||
|
scenarioId: sc.id,
|
||||||
|
seed: d.state.seed as number, W: d.state.W as number, H: d.state.H as number,
|
||||||
|
month: d.state.month as number, year: d.state.year as number, monthFloat: d.state.monthFloat as number,
|
||||||
|
funds: d.state.funds as number, mana: d.state.mana as number, manaMax: d.state.manaMax as number,
|
||||||
|
population: 0,
|
||||||
|
taxRate: d.state.taxRate as number, wageRate: d.state.wageRate as number,
|
||||||
|
laborers: 0, employed: 0, unemploymentPct: 0,
|
||||||
|
sentiment: d.state.sentiment as number,
|
||||||
|
ratings: { ...d.state.ratings },
|
||||||
|
buildings: new Map(), nextBldId: 1,
|
||||||
|
walkers: new Map(), nextWalkerId: 1,
|
||||||
|
tiles, messages: [], nextMsgId: d.state.nextMsgId as number,
|
||||||
|
trades: d.state.trades.map(t => ({ ...t })),
|
||||||
|
spellState: {
|
||||||
|
unlocked: new Set(d.state.spells as import('./types').SpellId[]),
|
||||||
|
cooldowns: { ...d.state.cooldowns },
|
||||||
|
buffs: { ...d.state.buffs },
|
||||||
|
areaBuffs: d.state.spellAreas ?? [],
|
||||||
|
},
|
||||||
|
stats: { ...d.state.stats },
|
||||||
|
entryPoints: gen.entryPoints,
|
||||||
|
victory: null, paused: false, speedIdx: 2,
|
||||||
|
dirtyCoverage: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// restore buildings
|
||||||
|
for (const rb of d.buildings) {
|
||||||
|
const dd = def(rb.def!);
|
||||||
|
const b: Building = {
|
||||||
|
id: rb.id!, def: rb.def!, x: rb.x!, y: rb.y!, w: dd.w, h: dd.h,
|
||||||
|
workersLive: 0, progress: 0, inv: {},
|
||||||
|
serviceT: {}, walkerTimers: new Array(dd.walker?.count ?? 0).fill(dd.walker?.period ?? 0),
|
||||||
|
burning: rb.burning ? 20 : 0, ruin: !!rb.ruin, active: false, hp: rb.hp ?? 100,
|
||||||
|
houseLevel: rb.houseLevel ?? 0, residents: rb.ruin ? 0 : rb.residents ?? 0,
|
||||||
|
devolveTimer: 0, fetchQueue: [], goodsStock: {},
|
||||||
|
};
|
||||||
|
for (const [g, q] of rb.inv ?? []) (b.inv as Record<string, number>)[g] = q;
|
||||||
|
if (rb.goodsStock) for (const [g, q] of rb.goodsStock) b.goodsStock![g] = q;
|
||||||
|
city.buildings.set(b.id, b);
|
||||||
|
city.nextBldId = Math.max(city.nextBldId, b.id + 1);
|
||||||
|
for (let dy = 0; dy < dd.h; dy++) for (let dx = 0; dx < dd.w; dx++) {
|
||||||
|
const t = gen.tiles[(rb.y! + dy) * sc.mapW + (rb.x! + dx)];
|
||||||
|
if (t) t.bld = b.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
city.population = [...city.buildings.values()].reduce((a, b) => a + b.residents, 0);
|
||||||
|
recomputeCoverage(city);
|
||||||
|
reassignLabor(city);
|
||||||
|
resetHeroes();
|
||||||
|
city.messages = d.state.messages.map(m => ({ ...m }));
|
||||||
|
addMsg(city, 'The chronicle resumes...', 'info', '📜');
|
||||||
|
return { city, scenario: sc };
|
||||||
|
} catch (e) {
|
||||||
|
console.error('load failed', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
// ─── Imperium Arcanum — core simulation types ──────────────────────────────
|
||||||
|
|
||||||
|
export type Good =
|
||||||
|
| 'wheat' | 'fruit' | 'olives' | 'grapes' | 'meat'
|
||||||
|
| 'clay' | 'timber' | 'iron' | 'crystal'
|
||||||
|
| 'pottery' | 'furniture' | 'oil' | 'wine' | 'weapons';
|
||||||
|
|
||||||
|
export interface GoodInfo { name: string; icon: string; color: string; food: boolean; basePrice: number; }
|
||||||
|
export const GOODS: Record<Good, GoodInfo> = {
|
||||||
|
wheat: { name: 'Wheat', icon: '🌾', color: '#d9b44a', food: true, basePrice: 22 },
|
||||||
|
fruit: { name: 'Fruit', icon: '🍎', color: '#c94f3d', food: true, basePrice: 24 },
|
||||||
|
olives: { name: 'Olives', icon: '🫒', color: '#7a8b3f', food: false, basePrice: 26 },
|
||||||
|
grapes: { name: 'Grapes', icon: '🍇', color: '#8e4a9e', food: false, basePrice: 28 },
|
||||||
|
meat: { name: 'Meat', icon: '🍖', color: '#b0563f', food: true, basePrice: 34 },
|
||||||
|
clay: { name: 'Clay', icon: '🧱', color: '#b3714f', food: false, basePrice: 18 },
|
||||||
|
timber: { name: 'Timber', icon: '🪵', color: '#8a6238', food: false, basePrice: 20 },
|
||||||
|
iron: { name: 'Iron', icon: '⛏️', color: '#8d93a1', food: false, basePrice: 40 },
|
||||||
|
crystal: { name: 'Crystal', icon: '💎', color: '#6fd6e0', food: false, basePrice: 55 },
|
||||||
|
pottery: { name: 'Pottery', icon: '🏺', color: '#c07a45', food: false, basePrice: 60 },
|
||||||
|
furniture:{ name: 'Furniture',icon: '🪑', color: '#a97c3f', food: false, basePrice: 65 },
|
||||||
|
oil: { name: 'Oil', icon: '🫗', color: '#c8b23c', food: false, basePrice: 70 },
|
||||||
|
wine: { name: 'Wine', icon: '🍷', color: '#93325f', food: false, basePrice: 75 },
|
||||||
|
weapons: { name: 'Weapons', icon: '⚔️', color: '#aab2c0', food: false, basePrice: 90 },
|
||||||
|
};
|
||||||
|
export const ALL_GOODS = Object.keys(GOODS) as Good[];
|
||||||
|
export const FOODS: Good[] = ['wheat', 'fruit', 'meat'];
|
||||||
|
|
||||||
|
export type Terrain = 'grass' | 'fertile' | 'water' | 'tree' | 'rock' | 'iron' | 'clay' | 'crystal';
|
||||||
|
|
||||||
|
export type ServiceType =
|
||||||
|
| 'water' | 'market' | 'religion' | 'entertainment' | 'education' | 'health'
|
||||||
|
| 'tax' | 'prefect' | 'engineer';
|
||||||
|
|
||||||
|
export type BuildCategory =
|
||||||
|
| 'housing' | 'food' | 'industry' | 'storage' | 'civic' | 'religion'
|
||||||
|
| 'entertainment' | 'education' | 'health' | 'military' | 'magic' | 'heroes' | 'trade' | 'decor';
|
||||||
|
|
||||||
|
export type SpellId =
|
||||||
|
| 'blessing' | 'firestorm' | 'heal' | 'haste' | 'golem' | 'shield' | 'insight';
|
||||||
|
|
||||||
|
export interface SpellDef {
|
||||||
|
id: SpellId; name: string; icon: string; mana: number; cooldown: number; // seconds
|
||||||
|
range: number; radius: number; desc: string; needsResearch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SPELLS: Record<SpellId, SpellDef> = {
|
||||||
|
blessing: { id:'blessing', name:"Blessing of Ceres", icon:'🌿', mana:30, cooldown:20, range:12, radius:5,
|
||||||
|
desc:'+80% farm & workshop output in area for 90 days.' },
|
||||||
|
firestorm:{ id:'firestorm', name:'Firestorm', icon:'🔥', mana:55, cooldown:30, range:14, radius:3.2,
|
||||||
|
desc:'Rains arcane fire on enemies and monsters in the target area.' },
|
||||||
|
heal: { id:'heal', name:'Healing Rain', icon:'💧', mana:35, cooldown:25, range:12, radius:4,
|
||||||
|
desc:'Heals your soldiers & heroes and douses fires in the area.' },
|
||||||
|
haste: { id:'haste', name:'Hermes\' Haste', icon:'💨', mana:25, cooldown:18, range:99, radius:0,
|
||||||
|
desc:'All citizens walk +50% faster for 120 days.' },
|
||||||
|
golem: { id:'golem', name:'Summon Golem', icon:'🗿', mana:70, cooldown:60, range:10, radius:0,
|
||||||
|
desc:'Raises a stone golem (needs 20 iron in a warehouse) that defends the city.' , needsResearch:true},
|
||||||
|
shield: { id:'shield', name:'Aegis Ward', icon:'🛡️', mana:50, cooldown:45, range:99, radius:0,
|
||||||
|
desc:'City walls & towers deal double damage for 150 days.' , needsResearch:true},
|
||||||
|
insight: { id:'insight', name:'Divine Insight', icon:'🔮', mana:20, cooldown:15, range:99, radius:0,
|
||||||
|
desc:'Instantly collects pending taxes and reveals the whole empire map.' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HeroId = 'champion' | 'huntress' | 'stormcaller';
|
||||||
|
export interface HeroDef {
|
||||||
|
id: HeroId; name: string; title: string; icon: string;
|
||||||
|
costGold: number; costGood?: { good: Good; qty: number };
|
||||||
|
hp: number; dmg: number; ranged: boolean; speed: number;
|
||||||
|
auraProd: number; // % industry boost nearby
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
export const HEROES: Record<HeroId, HeroDef> = {
|
||||||
|
champion: { id:'champion', name:'Aurelius', title:'the Champion', icon:'⚔️',
|
||||||
|
costGold:800, hp:600, dmg:34, ranged:false, speed:1.05, auraProd:10,
|
||||||
|
desc:'An unbreakable swordsman. Holds the line against monsters and invaders.' },
|
||||||
|
huntress: { id:'huntress', name:'Lyra', title:'the Huntress', icon:'🏹',
|
||||||
|
costGold:650, costGood:{good:'weapons',qty:10}, hp:380, dmg:22, ranged:true, speed:1.35, auraProd:8,
|
||||||
|
desc:'Deadly at range, swift as the wind. Picks monsters apart from afar.' },
|
||||||
|
stormcaller:{ id:'stormcaller', name:'Magnus', title:'the Stormcaller', icon:'⚡',
|
||||||
|
costGold:900, costGood:{good:'crystal',qty:15}, hp:300, dmg:46, ranged:true, speed:0.95, auraProd:15,
|
||||||
|
desc:'Wields the storm itself. Devastating area blasts — fragile but glorious.' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MonsterKind = 'hydra' | 'minotaur' | 'harpy' | 'lich' | 'skeleton' | 'barbarian' | 'legion' | 'golem';
|
||||||
|
export type UnitKind = MonsterKind | 'soldiers' | 'hero' | 'caravan';
|
||||||
|
export interface UnitStats { hp:number; dmg:number; speed:number; ranged:boolean; range:number; atkCd:number; }
|
||||||
|
|
||||||
|
// ── Buildings ───────────────────────────────────────────────────────────────
|
||||||
|
export interface BDef {
|
||||||
|
key: string; // unique
|
||||||
|
name: string;
|
||||||
|
cat: BuildCategory;
|
||||||
|
w: number; h: number;
|
||||||
|
cost: number; // denarii
|
||||||
|
desirability: number; // -10..+10 emitted at center, decays over distance
|
||||||
|
desRange: number;
|
||||||
|
workers: number; // jobs provided (0 = none)
|
||||||
|
fireRisk: number; // 0..1 monthly chance factor
|
||||||
|
collapseRisk: number;
|
||||||
|
spriteKey: string;
|
||||||
|
hotkey?: string;
|
||||||
|
// production / storage / service:
|
||||||
|
produces?: { good: Good; rate: number }; // units per month at full staff (rate=units)
|
||||||
|
consumes?: Partial<Record<Good, number>>; // per produced batch
|
||||||
|
storage?: { goods: Good[]; capacity: number };// granary/warehouse/market stock
|
||||||
|
service?: ServiceType[]; // walkers provide these when staffed
|
||||||
|
walker?: { kind: WalkerKind; count: number; period: number; range?: number };
|
||||||
|
housing?: { level: number }; // is a house of tier
|
||||||
|
maxLevel?: number; // house evolution cap by building itself? (unused)
|
||||||
|
roadAccess: boolean; // requires adjacency to road
|
||||||
|
unique?: boolean;
|
||||||
|
blurb: string;
|
||||||
|
// military / magic:
|
||||||
|
tower?: UnitStats;
|
||||||
|
fort?: boolean;
|
||||||
|
heroHall?: boolean;
|
||||||
|
mageTower?: UnitStats;
|
||||||
|
manaGen?: number; // +mana capacity/month
|
||||||
|
research?: SpellId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WalkerKind =
|
||||||
|
| 'immigrant' | 'emigrant' | 'cart' | 'fetchCart' | 'marketBuyer' | 'peddler'
|
||||||
|
| 'priest' | 'actor' | 'teacher' | 'doctor' | 'prefect' | 'engineer'
|
||||||
|
| 'taxman' | 'laborSeeker' | 'soldiers' | 'enemy' | 'monster' | 'hero' | 'golemUnit' | 'caravan' | 'mage';
|
||||||
|
|
||||||
|
export interface Walker {
|
||||||
|
id: number;
|
||||||
|
kind: WalkerKind;
|
||||||
|
x: number; y: number; // float tile coords
|
||||||
|
homeId: number; // building id
|
||||||
|
path: Array<{ x: number; y: number }>;
|
||||||
|
pathI: number;
|
||||||
|
speed: number; // tiles/sec
|
||||||
|
state: 'walkOut' | 'wander' | 'returning' | 'despawn' | 'fighting';
|
||||||
|
timer: number; // generic seconds
|
||||||
|
wanderStepsLeft: number;
|
||||||
|
cargo?: Partial<Record<Good, number>>;
|
||||||
|
carryCap?: number;
|
||||||
|
group?: number; // immigrants in group
|
||||||
|
rallyX?: number; rallyY?: number; // city-unit patrol point
|
||||||
|
targetBld?: number; // destination building id
|
||||||
|
// combat units:
|
||||||
|
unit?: { kind: UnitKind; hp: number; maxHp: number; stats: UnitStats; cd: number; side: 'city' | 'foe'; squadSize?: number; targetId?: number };
|
||||||
|
heroId?: HeroId; heroLevel?: number; heroXp?: number;
|
||||||
|
animT: number; // animation phase
|
||||||
|
facing: 1 | -1;
|
||||||
|
dead?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Building {
|
||||||
|
id: number;
|
||||||
|
def: string; // BuildingDef.key
|
||||||
|
x: number; y: number; w: number; h: number;
|
||||||
|
workersLive: number; // assigned workers
|
||||||
|
progress: number; // 0..1 production progress toward output
|
||||||
|
inv: Partial<Record<Good, number>>;
|
||||||
|
serviceT: Partial<Record<ServiceType, number>>; // last time each service walker left
|
||||||
|
walkerTimers: number[]; // per-walker spawn timers
|
||||||
|
burning: number; // >0 = on fire (seconds of fuel left)
|
||||||
|
ruin: boolean;
|
||||||
|
active: boolean; // has needed input/road/workers to run
|
||||||
|
hp?: number; // military integrity 0..100
|
||||||
|
noRoad?: boolean; // placed but not connected to a road yet
|
||||||
|
ruinedAt?: number; // sim-month when it became a ruin
|
||||||
|
houseLevel: number; // current evolution tier (houses)
|
||||||
|
residents: number;
|
||||||
|
devolveTimer: number;
|
||||||
|
foodStock?: number; // house pantry
|
||||||
|
goodsStock?: Partial<Record<string, number>>; // house luxury goods
|
||||||
|
hungerMonths?: number;
|
||||||
|
fetchQueue: Good[]; // raw goods requested
|
||||||
|
lastTaxedMonth?: number;
|
||||||
|
heroCooldowns?: Partial<Record<HeroId, number>>;
|
||||||
|
spellCooldowns?: Partial<Record<string, number>>;
|
||||||
|
rallyX?: number; rallyY?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tile {
|
||||||
|
terrain: Terrain;
|
||||||
|
bld: number; // building id or 0
|
||||||
|
road: boolean;
|
||||||
|
plaza: boolean;
|
||||||
|
garden: boolean;
|
||||||
|
wall: number; // wall building id (also bld)
|
||||||
|
fert: number; // 0..100 farm fertility
|
||||||
|
desirability: number; // cached
|
||||||
|
entPts: number; // cached entertainment venue points covering
|
||||||
|
fireFervor: number; // spreading fire fuel
|
||||||
|
waterCover: number; // well/fountain coverage cache
|
||||||
|
variant: number; // random visual variant
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: number; month: number; year: number;
|
||||||
|
icon: string; text: string; kind: 'info' | 'warn' | 'danger' | 'good';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenarioGoal {
|
||||||
|
kind: 'population' | 'culture' | 'prosperity' | 'peace' | 'favor' | 'treasury' | 'surviveYears' | 'slayMonsters' | 'buildWonder';
|
||||||
|
amount?: number; label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvasionEvent { atYearMonth: [number, number]; kind: MonsterKind; squads: number; size: number; warnMonths: number; fromEdge?: 'n'|'s'|'e'|'w'|'random'; }
|
||||||
|
|
||||||
|
export interface Scenario {
|
||||||
|
id: string; name: string; subtitle: string; brief: string;
|
||||||
|
mapSeed: number; mapW: number; mapH: number;
|
||||||
|
funds: number; startRes?: Partial<Record<Good, number>>;
|
||||||
|
goals: ScenarioGoal[];
|
||||||
|
invasions: InvasionEvent[]; monsterEvents: InvasionEvent[];
|
||||||
|
openTrades: string[]; // city names available
|
||||||
|
allowSpellsFromStart: boolean;
|
||||||
|
hints: string[];
|
||||||
|
reward?: string; // unlock text
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradeCity {
|
||||||
|
name: string;
|
||||||
|
buys: Good[]; // goods the partner city offers to sell us (flavor/UI)
|
||||||
|
wants: Good[]; // goods the partner city will buy from us (flavor/UI)
|
||||||
|
exports: Good[]; // our toggled export list
|
||||||
|
imports: Good[]; // our toggled import list
|
||||||
|
open: boolean; priceFactor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CityState {
|
||||||
|
scenarioId: string;
|
||||||
|
seed: number; W: number; H: number;
|
||||||
|
month: number; year: number; // absolute months since founding; display year = year
|
||||||
|
funds: number;
|
||||||
|
mana: number; manaMax: number;
|
||||||
|
population: number;
|
||||||
|
taxRate: number; wageRate: number;
|
||||||
|
laborers: number; employed: number; unemploymentPct: number;
|
||||||
|
sentiment: number; // 0..100
|
||||||
|
ratings: { culture: number; prosperity: number; peace: number; favor: number };
|
||||||
|
buildings: Map<number, Building>;
|
||||||
|
nextBldId: number;
|
||||||
|
walkers: Map<number, Walker>;
|
||||||
|
nextWalkerId: number;
|
||||||
|
tiles: Tile[]; // W*H
|
||||||
|
messages: Message[];
|
||||||
|
nextMsgId: number;
|
||||||
|
trades: TradeCity[];
|
||||||
|
spellState: {
|
||||||
|
unlocked: Set<SpellId>;
|
||||||
|
cooldowns: Record<string, number>; // spellId -> absolute sim-month when ready
|
||||||
|
buffs: { hasteUntil: number; shieldUntil: number };
|
||||||
|
areaBuffs: Array<{ x: number; y: number; r: number; until: number }>;
|
||||||
|
};
|
||||||
|
stats: {
|
||||||
|
monthsSinceInvasion: number; monstersSlain: number; invadersSlain: number;
|
||||||
|
totalDeaths: number;
|
||||||
|
};
|
||||||
|
entryPoints: Array<{ x: number; y: number }>;
|
||||||
|
victory: null | 'won' | 'lost';
|
||||||
|
defeatReason?: string;
|
||||||
|
paused: boolean;
|
||||||
|
speedIdx: number; // 0..3 → multipliers
|
||||||
|
monthFloat?: number;
|
||||||
|
dirtyCoverage: boolean; // recompute desirability/water caches
|
||||||
|
autosavePending?: boolean;
|
||||||
|
pendingStartRes?: Partial<Record<Good, number>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
// ─── Walkers: spawning, movement, service delivery ─────────────────────────
|
||||||
|
import type { Building, CityState, ServiceType, Walker } from './types';
|
||||||
|
import { GOODS, type Good } from './types';
|
||||||
|
import { def } from '../data/buildings';
|
||||||
|
import { HOUSE_TIERS } from '../data/housing';
|
||||||
|
import { astarPath, bfsPath, type Pt } from '../core/pathfind';
|
||||||
|
import { bldAt, buildingActive, hasRoadAccess, inB, roadAccessPoint, tileAt, bus } from './city';
|
||||||
|
import { cartArrivals } from './economy';
|
||||||
|
import { FX } from '../render/fx';
|
||||||
|
|
||||||
|
export const simMonths = (c: CityState) => c.month + (c.monthFloat ?? 0);
|
||||||
|
|
||||||
|
export function spawnWalker(c: CityState, w: Partial<Walker> & { kind: Walker['kind']; x: number; y: number }): Walker {
|
||||||
|
const walker: Walker = {
|
||||||
|
id: c.nextWalkerId++, kind: w.kind, x: w.x, y: w.y,
|
||||||
|
homeId: w.homeId ?? 0,
|
||||||
|
path: w.path ?? [], pathI: 0,
|
||||||
|
speed: w.speed ?? 1.6,
|
||||||
|
state: w.state ?? 'walkOut',
|
||||||
|
timer: 0, wanderStepsLeft: w.wanderStepsLeft ?? 0,
|
||||||
|
animT: Math.random() * 10, facing: 1,
|
||||||
|
cargo: w.cargo, carryCap: w.carryCap,
|
||||||
|
targetBld: w.targetBld,
|
||||||
|
unit: w.unit, heroId: w.heroId, heroLevel: w.heroLevel ?? 1, heroXp: w.heroXp ?? 0,
|
||||||
|
dead: false,
|
||||||
|
};
|
||||||
|
c.walkers.set(walker.id, walker);
|
||||||
|
return walker;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVICE_KINDS = new Set(['prefect', 'engineer', 'priest', 'actor', 'teacher', 'doctor', 'taxman', 'peddler']);
|
||||||
|
const isService = (k: string) => SERVICE_KINDS.has(k);
|
||||||
|
|
||||||
|
// ── passability ─────────────────────────────────────────────────────────────
|
||||||
|
function roadPassable(c: CityState) {
|
||||||
|
return (x: number, y: number) => {
|
||||||
|
if (!inB(c, x, y)) return false;
|
||||||
|
const t = tileAt(c, x, y)!;
|
||||||
|
return t.road || t.plaza;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function landPassable(c: CityState, allowWalls = false) {
|
||||||
|
return (x: number, y: number) => {
|
||||||
|
if (!inB(c, x, y)) return false;
|
||||||
|
const t = tileAt(c, x, y)!;
|
||||||
|
if (t.terrain === 'water') return false;
|
||||||
|
if (t.wall && !allowWalls) return false;
|
||||||
|
if (t.bld && !t.wall) return false; // buildings block overland travel
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path a walker along roads from its current tile to target tile. */
|
||||||
|
export function roadPath(c: CityState, from: Pt, to: Pt): Pt[] | null {
|
||||||
|
return bfsPath(c.W, c.H, from, to, roadPassable(c));
|
||||||
|
}
|
||||||
|
export function landPath(c: CityState, from: Pt, to: Pt): Pt[] | null {
|
||||||
|
return astarPath(c.W, c.H, from, to, landPassable(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── service walkers ─────────────────────────────────────────────────────────
|
||||||
|
export function tickServiceSpawns(c: CityState, dt: number) {
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin) continue;
|
||||||
|
const d = def(b.def);
|
||||||
|
if (!d.walker || !d.walker.count || !d.service?.length) continue;
|
||||||
|
const nAlive = aliveWalkersFor(c, b.id).length;
|
||||||
|
for (let i = 0; i < d.walker.count; i++) {
|
||||||
|
if (nAlive >= d.walker.count) break;
|
||||||
|
if (i >= b.walkerTimers.length) b.walkerTimers[i] = d.walker.period * Math.random();
|
||||||
|
b.walkerTimers[i] -= dt * (buildingActive(b) ? 1 : 0.15); // unstaffed buildings rarely send walkers
|
||||||
|
if (b.walkerTimers[i] <= 0) {
|
||||||
|
b.walkerTimers[i] = d.walker.period;
|
||||||
|
spawnServiceWalker(c, b, d.service);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aliveWalkersFor(c: CityState, homeId: number): Walker[] {
|
||||||
|
const out: Walker[] = [];
|
||||||
|
for (const w of c.walkers.values()) if (w.homeId === homeId && !w.dead && isService(w.kind)) out.push(w);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnServiceWalker(c: CityState, b: Building, services: ServiceType[]) {
|
||||||
|
const acc = roadAccessPoint(c, b);
|
||||||
|
if (!acc || !hasRoadAccess(c, b.x, b.y, b.w, b.h)) return;
|
||||||
|
const kindMap: Record<string, Walker['kind']> = {
|
||||||
|
prefect: 'prefect', engineer: 'engineer', religion: 'priest', entertainment: 'actor',
|
||||||
|
education: 'teacher', health: 'doctor', tax: 'taxman', market: 'peddler', water: 'laborSeeker',
|
||||||
|
};
|
||||||
|
let kind: Walker['kind'] = 'laborSeeker';
|
||||||
|
for (const s of services) { kind = kindMap[s] ?? 'laborSeeker'; break; }
|
||||||
|
const d = def(b.def);
|
||||||
|
const w = spawnWalker(c, {
|
||||||
|
kind, x: acc.x + 0.5, y: acc.y + 0.5, homeId: b.id,
|
||||||
|
state: 'wander',
|
||||||
|
wanderStepsLeft: (d.walker?.range ?? 20) * 2,
|
||||||
|
speed: 1.55,
|
||||||
|
});
|
||||||
|
// peddler loads goods
|
||||||
|
if (kind === 'peddler') loadPeddler(c, b, w);
|
||||||
|
void services;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPeddler(c: CityState, mkt: Building, w: Walker) {
|
||||||
|
const cargo: Partial<Record<Good, number>> = {};
|
||||||
|
let foodLeft = 80;
|
||||||
|
for (const g of ['wheat', 'fruit', 'meat'] as Good[]) {
|
||||||
|
const have = mkt.inv[g] ?? 0;
|
||||||
|
if (have > 0 && foodLeft > 0) { const take = Math.min(have, foodLeft, 45); cargo[g] = take; foodLeft -= take; }
|
||||||
|
}
|
||||||
|
for (const g of ['pottery', 'furniture', 'oil', 'wine'] as Good[]) {
|
||||||
|
const have = mkt.inv[g] ?? 0;
|
||||||
|
if (have > 0) cargo[g] = Math.min(have, 6);
|
||||||
|
}
|
||||||
|
w.cargo = cargo;
|
||||||
|
for (const [g, q] of Object.entries(cargo)) mkt.inv[g as Good] = (mkt.inv[g as Good] ?? 0) - q!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── movement core ───────────────────────────────────────────────────────────
|
||||||
|
function moveAlong(c: CityState, w: Walker, dt: number): boolean {
|
||||||
|
// returns true when path finished this frame
|
||||||
|
let budget = w.speed * dt * (c.spellState.buffs.hasteUntil > simMonths(c) ? 1.5 : 1);
|
||||||
|
while (budget > 0 && w.pathI < w.path.length) {
|
||||||
|
const target = w.path[w.pathI];
|
||||||
|
const tx = target.x + 0.5, ty = target.y + 0.5;
|
||||||
|
const dxs = (tx - ty) - (w.x - w.y);
|
||||||
|
if (dxs !== 0) w.facing = dxs > 0 ? 1 : -1;
|
||||||
|
const dist = Math.hypot(tx - w.x, ty - w.y);
|
||||||
|
if (dist <= budget) {
|
||||||
|
w.x = tx; w.y = ty; budget -= dist; w.pathI++;
|
||||||
|
} else {
|
||||||
|
w.x += (tx - w.x) / dist * budget;
|
||||||
|
w.y += (ty - w.y) / dist * budget;
|
||||||
|
budget = 0;
|
||||||
|
}
|
||||||
|
w.animT += dt;
|
||||||
|
}
|
||||||
|
return w.pathI >= w.path.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDestTile(c: CityState, w: Walker, tx: number, ty: number): boolean {
|
||||||
|
const from = { x: Math.round(w.x - 0.5), y: Math.round(w.y - 0.5) };
|
||||||
|
const p = roadPassable(c)(from.x, from.y)
|
||||||
|
? roadPath(c, from, { x: tx, y: ty })
|
||||||
|
: landPath(c, from, { x: tx, y: ty });
|
||||||
|
if (p && p.length > 1) { w.path = p.slice(1); w.pathI = 0; return true; }
|
||||||
|
if (p && p.length === 1) { w.path = []; w.pathI = 0; return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wanderStep(c: CityState, w: Walker): boolean {
|
||||||
|
const cx = Math.round(w.x - 0.5), cy = Math.round(w.y - 0.5);
|
||||||
|
const opts: Array<[number, number]> = [];
|
||||||
|
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
|
||||||
|
if (roadPassable(c)(cx + dx, cy + dy)) opts.push([cx + dx, cy + dy]);
|
||||||
|
}
|
||||||
|
if (!opts.length) return false;
|
||||||
|
// prefer continuing direction
|
||||||
|
const pick = opts[Math.floor(Math.random() * opts.length)];
|
||||||
|
w.path = [{ x: pick[0], y: pick[1] }];
|
||||||
|
w.pathI = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── painting services on houses passed ──────────────────────────────────────
|
||||||
|
function paintServices(c: CityState, w: Walker) {
|
||||||
|
const b = c.buildings.get(w.homeId);
|
||||||
|
if (!b) return;
|
||||||
|
const d = def(b.def);
|
||||||
|
if (!d.service) return;
|
||||||
|
const now = simMonths(c);
|
||||||
|
const wx = Math.floor(w.x), wy = Math.floor(w.y);
|
||||||
|
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
|
||||||
|
if (Math.abs(dx) + Math.abs(dy) > 3) continue;
|
||||||
|
const t = tileAt(c, wx + dx, wy + dy);
|
||||||
|
if (!t || !t.bld) continue;
|
||||||
|
const hb = c.buildings.get(t.bld);
|
||||||
|
if (!hb || hb.ruin) continue;
|
||||||
|
const hd = def(hb.def);
|
||||||
|
if (!hd.housing) continue;
|
||||||
|
for (const s of d.service) hb.serviceT[s] = now;
|
||||||
|
// peddler unloads into house pantry
|
||||||
|
if (w.kind === 'peddler' && w.cargo) {
|
||||||
|
hb.goodsStock ??= {};
|
||||||
|
const capFood = maxResidentsOf(hb) * 3 + 16;
|
||||||
|
let foodTotal = 0;
|
||||||
|
for (const g of ['wheat', 'fruit', 'meat'] as Good[]) foodTotal += hb.goodsStock[g] ?? 0;
|
||||||
|
for (const [gStr, q] of Object.entries(w.cargo)) {
|
||||||
|
const g = gStr as Good;
|
||||||
|
const isFood = GOODS[g].food;
|
||||||
|
if (!q) continue;
|
||||||
|
if (isFood) {
|
||||||
|
const room = Math.max(0, capFood - foodTotal);
|
||||||
|
const give = Math.min(q, room);
|
||||||
|
hb.goodsStock[g] = (hb.goodsStock[g] ?? 0) + give;
|
||||||
|
w.cargo[g] = q - give;
|
||||||
|
foodTotal += give;
|
||||||
|
} else {
|
||||||
|
const give = Math.min(q, 6);
|
||||||
|
hb.goodsStock[g] = Math.min((hb.goodsStock[g] ?? 0) + give, 10);
|
||||||
|
w.cargo[g] = q - give;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// prefect & engineer special effects on any building nearby
|
||||||
|
if (w.kind === 'prefect' || w.kind === 'engineer') {
|
||||||
|
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
const t = tileAt(c, wx + dx, wy + dy);
|
||||||
|
if (t && t.bld) {
|
||||||
|
const bb = c.buildings.get(t.bld);
|
||||||
|
if (bb) {
|
||||||
|
if (w.kind === 'prefect' && bb.burning > 0) {
|
||||||
|
bb.burning = Math.max(0, bb.burning - dtGlobal * 8);
|
||||||
|
if (bb.burning === 0) FX.float(bb.x + bb.w / 2, bb.y + bb.h / 2 - 1, 'extinguished!', '#7fd0ff');
|
||||||
|
}
|
||||||
|
if (w.kind === 'prefect') {
|
||||||
|
const tt = tileAt(c, wx + dx, wy + dy)!;
|
||||||
|
tt.fireFervor = Math.max(0, tt.fireFervor - dtGlobal * 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dtGlobal = 0; // small hack for paint effects needing dt
|
||||||
|
|
||||||
|
// ── arrivals ────────────────────────────────────────────────────────────────
|
||||||
|
function handleArrival(c: CityState, w: Walker) {
|
||||||
|
const home = c.buildings.get(w.homeId);
|
||||||
|
switch (w.kind) {
|
||||||
|
case 'immigrant': {
|
||||||
|
const t = w.targetBld ? c.buildings.get(w.targetBld) : undefined;
|
||||||
|
if (t && !t.ruin && def(t.def).housing !== undefined) {
|
||||||
|
const room = maxResidentsOf(t) - t.residents;
|
||||||
|
const move = Math.max(0, Math.min(w.group ?? 4, room));
|
||||||
|
t.residents += move;
|
||||||
|
if (move > 0) FX.float(t.x + t.w / 2, t.y + t.h / 2 - 1, `+${move}`, '#9fe89f');
|
||||||
|
}
|
||||||
|
w.dead = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'emigrant': {
|
||||||
|
w.dead = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'cart': case 'fetchCart': case 'marketBuyer': {
|
||||||
|
cartArrivals(c, w);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'caravan': {
|
||||||
|
// pause at post then leave handled by timer state
|
||||||
|
if (w.state === 'walkOut') { w.state = 'wander'; w.timer = 6; w.path = []; w.pathI = 0; }
|
||||||
|
else if (w.state === 'wander') {
|
||||||
|
w.timer -= 1;
|
||||||
|
if (w.timer <= 0) {
|
||||||
|
w.state = 'returning';
|
||||||
|
const ep = c.entryPoints[Math.floor(Math.random() * c.entryPoints.length)];
|
||||||
|
const p = landPath(c, { x: Math.round(w.x - 0.5), y: Math.round(w.y - 0.5) }, ep);
|
||||||
|
if (p) { w.path = p.slice(1); w.pathI = 0; } else w.dead = true;
|
||||||
|
}
|
||||||
|
} else w.dead = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
switch (w.state) {
|
||||||
|
case 'wander': {
|
||||||
|
if (home && !home.ruin) {
|
||||||
|
if (w.wanderStepsLeft > 0) {
|
||||||
|
w.wanderStepsLeft--;
|
||||||
|
if (!wanderStep(c, w)) w.state = 'returning';
|
||||||
|
} else w.state = 'returning';
|
||||||
|
if (w.state === 'returning') {
|
||||||
|
const acc = home ? roadAccessPoint(c, home) : null;
|
||||||
|
if (acc) setDestTile(c, w, acc.x, acc.y);
|
||||||
|
else w.dead = true;
|
||||||
|
}
|
||||||
|
} else w.dead = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'returning': {
|
||||||
|
if (w.kind === 'peddler' && home && w.cargo) {
|
||||||
|
for (const [g, q] of Object.entries(w.cargo)) {
|
||||||
|
home.inv[g as Good] = (home.inv[g as Good] ?? 0) + (q ?? 0);
|
||||||
|
}
|
||||||
|
w.cargo = undefined;
|
||||||
|
}
|
||||||
|
w.dead = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
w.dead = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main update ─────────────────────────────────────────────────────────────
|
||||||
|
export function updateWalkersImpl(c: CityState, dt: number) {
|
||||||
|
dtGlobal = dt;
|
||||||
|
tickServiceSpawns(c, dt);
|
||||||
|
for (const w of c.walkers.values()) {
|
||||||
|
if (w.dead) continue;
|
||||||
|
if (w.unit) continue; // combat units handled elsewhere
|
||||||
|
// homeless?
|
||||||
|
const home = c.buildings.get(w.homeId);
|
||||||
|
if ((isService(w.kind)) && (!home || home.ruin)) { w.dead = true; continue; }
|
||||||
|
if (w.pathI < w.path.length) {
|
||||||
|
moveAlong(c, w, dt);
|
||||||
|
if (isService(w.kind)) paintServices(c, w);
|
||||||
|
if (w.pathI >= w.path.length) handleArrival(c, w);
|
||||||
|
} else {
|
||||||
|
handleArrival(c, w);
|
||||||
|
}
|
||||||
|
// safety despawn far outside map
|
||||||
|
if (w.x < -3 || w.y < -3 || w.x > c.W + 3 || w.y > c.H + 3) w.dead = true;
|
||||||
|
}
|
||||||
|
for (const [id, w] of c.walkers) if (w.dead) c.walkers.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── immigrants / emigrants ──────────────────────────────────────────────────
|
||||||
|
export function trySpawnImmigrant(c: CityState): boolean {
|
||||||
|
// find house with capacity
|
||||||
|
let target: Building | null = null;
|
||||||
|
let cap = 0;
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
if (b.ruin || def(b.def).housing === undefined) continue;
|
||||||
|
if (b.noRoad || !roadAccessPoint(c, b)) continue; // nobody moves to an unreachable house
|
||||||
|
const room = maxResidentsOf(b) - b.residents - incomingFor(c, b.id);
|
||||||
|
if (room > cap) { cap = room; target = b; }
|
||||||
|
}
|
||||||
|
if (!target || cap <= 0) return false;
|
||||||
|
if (c.sentiment < 32) return false;
|
||||||
|
const ep = c.entryPoints[Math.floor(Math.random() * c.entryPoints.length)];
|
||||||
|
const start = { x: ep.x, y: ep.y };
|
||||||
|
const goal = roadAccessPoint(c, target) ?? { x: target.x, y: target.y + target.h };
|
||||||
|
const path = landPath(c, start, goal);
|
||||||
|
if (!path) return false;
|
||||||
|
const group = Math.min(cap, 4 + Math.floor(Math.random() * 4));
|
||||||
|
const w = spawnWalker(c, {
|
||||||
|
kind: 'immigrant', x: start.x + 0.5, y: start.y + 0.5,
|
||||||
|
homeId: target.id, path: path.slice(1), state: 'walkOut', speed: 1.9,
|
||||||
|
group,
|
||||||
|
});
|
||||||
|
w.targetBld = target.id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function incomingFor(c: CityState, bldId: number): number {
|
||||||
|
let n = 0;
|
||||||
|
for (const w of c.walkers.values()) {
|
||||||
|
if (w.kind === 'immigrant' && w.targetBld === bldId) n += w.group ?? 4;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maxResidentsOf(b: Building): number {
|
||||||
|
return HOUSE_TIERS[Math.min(b.houseLevel, HOUSE_TIERS.length - 1)].maxResidents;
|
||||||
|
}
|
||||||
+363
@@ -0,0 +1,363 @@
|
|||||||
|
// ─── In-game HUD ───────────────────────────────────────────────────────────
|
||||||
|
import type { Building, CityState } from '../sim/types';
|
||||||
|
import { GOODS, SPELLS, type Good, type SpellId } from '../sim/types';
|
||||||
|
import { DEFS, BUILD_ORDER, def } from '../data/buildings';
|
||||||
|
import { HOUSE_TIERS } from '../data/housing';
|
||||||
|
import { HEROES } from '../data/heroes';
|
||||||
|
import { monthName, roman, fmt } from '../core/util';
|
||||||
|
import { goalProgress } from '../sim/economy';
|
||||||
|
import { goalMet } from '../sim/economy';
|
||||||
|
import { removeBuilding } from '../sim/city';
|
||||||
|
import { spellCooldownLeft } from '../sim/magic';
|
||||||
|
import { makeMinimap } from '../render/renderer';
|
||||||
|
import { Camera } from '../render/camera';
|
||||||
|
import { playSfx } from '../audio/audio';
|
||||||
|
import type { App } from '../app';
|
||||||
|
|
||||||
|
|
||||||
|
const CAT_ICONS: Record<string, string> = {
|
||||||
|
housing: '🏠', food: '🌾', industry: '⚒️', storage: '🏺', civic: '🏛️',
|
||||||
|
religion: '⛩️', entertainment: '🎭', education: '📜', health: '⚕️',
|
||||||
|
military: '🛡️', magic: '🔮', heroes: '⚔️', trade: '🐫', decor: '🌳',
|
||||||
|
};
|
||||||
|
const CAT_ORDER = ['housing', 'food', 'industry', 'storage', 'civic', 'religion', 'entertainment', 'education', 'health', 'military', 'magic', 'heroes', 'trade', 'decor'];
|
||||||
|
|
||||||
|
let appRef: App;
|
||||||
|
let els: Record<string, HTMLElement> = {};
|
||||||
|
|
||||||
|
export function el(tag: string, cls?: string, html?: string): HTMLElement {
|
||||||
|
const e = document.createElement(tag);
|
||||||
|
if (cls) e.className = cls;
|
||||||
|
if (html !== undefined) e.innerHTML = html;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSS injection (kept here so index.html stays slim) ──
|
||||||
|
const CSS = `
|
||||||
|
#hud-root{position:absolute;inset:0;pointer-events:none;font-family:var(--font);user-select:none;}
|
||||||
|
#hud-root > *{pointer-events:auto;}
|
||||||
|
.panelbox{background:linear-gradient(180deg,#3a2a1c,#2a1d10);border:2px solid #6b5233;border-radius:10px;box-shadow:0 4px 18px rgba(0,0,0,.5);}
|
||||||
|
#topbar{position:absolute;top:8px;left:50%;transform:translateX(-50%);display:flex;gap:14px;align-items:center;padding:6px 16px;font-size:14px;color:#ecd07d;z-index:20;}
|
||||||
|
#topbar .stat{display:flex;gap:5px;align-items:center;white-space:nowrap;}
|
||||||
|
#topbar b{color:#ffe9a0}
|
||||||
|
#mana-wrap{width:90px;height:12px;background:#191225;border:1px solid #55402a;border-radius:6px;overflow:hidden;}
|
||||||
|
#mana-fill{height:100%;background:linear-gradient(90deg,#7a5cff,#59c8d8);width:50%;}
|
||||||
|
.speedbtn{background:#241a10;color:#c9b083;border:1px solid #6b5233;border-radius:6px;width:26px;height:24px;cursor:pointer;font-size:12px;line-height:1;}
|
||||||
|
.speedbtn.active{background:#6b5233;color:#ffe9a0;}
|
||||||
|
#menu-btn{background:#241a10;color:#ecd07d;border:1px solid #6b5233;border-radius:6px;padding:3px 10px;cursor:pointer;}
|
||||||
|
#sidebar{position:absolute;top:64px;left:8px;display:flex;gap:8px;z-index:15;max-height:calc(100vh - 150px);}
|
||||||
|
#cattabs{display:flex;flex-direction:column;gap:4px;padding:6px;overflow-y:auto;}
|
||||||
|
.cattab{width:40px;height:40px;font-size:20px;display:flex;align-items:center;justify-content:center;background:#241a10;border:1px solid #55402a;border-radius:8px;cursor:pointer;opacity:.75;}
|
||||||
|
.cattab:hover{opacity:1}
|
||||||
|
.cattab.active{background:#6b4a22;border-color:#c9a227;opacity:1;box-shadow:0 0 8px rgba(201,162,39,.35)}
|
||||||
|
#buildcards{padding:10px;display:flex;flex-direction:column;gap:6px;overflow-y:auto;min-width:210px;max-height:inherit;}
|
||||||
|
.bcard{display:flex;gap:8px;align-items:center;padding:7px 8px;background:#33251a;border:1px solid #55402a;border-radius:8px;cursor:pointer;color:#e8d8ae;font-size:13px;text-align:left;width:100%;}
|
||||||
|
.bcard:hover{border-color:#c9a227;background:#452f1c}
|
||||||
|
.bcard.active{border-color:#ffd700;background:#5a3d20}
|
||||||
|
.bcard .icn{font-size:22px;width:28px;text-align:center}
|
||||||
|
.bcard .cost{margin-left:auto;color:#ffd700;font-size:12px;white-space:nowrap}
|
||||||
|
.bcard small{color:#b09b72;display:block;font-size:11px}
|
||||||
|
#overlay-row{position:absolute;right:12px;top:62px;display:flex;gap:6px;z-index:14;}
|
||||||
|
.ovbtn{background:#241acc00;background:#241a10;color:#c9b083;border:1px solid #55402a;border-radius:6px;padding:4px 9px;cursor:pointer;font-size:12px;}
|
||||||
|
.ovbtn.active{background:#6b5233;color:#ffe9a0;border-color:#c9a227;}
|
||||||
|
#spellbar{position:absolute;bottom:14px;left:50%;transform:translateX(-50%);display:flex;gap:8px;padding:8px;z-index:16;}
|
||||||
|
.spell{width:52px;height:52px;border-radius:10px;background:#241a10;border:2px solid #55402a;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;position:relative;color:#e8d8ae;font-size:22px;}
|
||||||
|
.spell:hover{border-color:#a87fff}
|
||||||
|
.spell.locked{opacity:.25;cursor:not-allowed}
|
||||||
|
.spell .cd{position:absolute;inset:0;background:rgba(10,6,20,.65);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;}
|
||||||
|
.spell .mcost{position:absolute;bottom:-4px;right:-4px;background:#191225;border:1px solid #55402a;border-radius:6px;font-size:9px;padding:0 3px;color:#9f8fff}
|
||||||
|
#minimap{position:absolute;right:12px;bottom:12px;border:2px solid #6b5233;border-radius:8px;z-index:14;image-rendering:pixelated;cursor:crosshair;}
|
||||||
|
#info-panel{position:absolute;right:12px;top:104px;width:270px;padding:12px;z-index:17;color:#e8d8ae;font-size:13px;display:none;max-height:70vh;overflow-y:auto;}
|
||||||
|
#toasts{position:absolute;top:60px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;gap:6px;align-items:center;z-index:40;pointer-events:none;}
|
||||||
|
.toast{background:rgba(30,20,10,.92);border:1px solid #6b5233;border-radius:8px;padding:6px 14px;color:#f0e2be;font-size:13px;animation:toastin .25s ease-out;}
|
||||||
|
@keyframes toastin{from{opacity:0;transform:translateY(-8px)}to{opacity:1}}
|
||||||
|
.msg-badge{cursor:pointer}
|
||||||
|
#msglog-panel{position:absolute;top:110px;left:50%;transform:translateX(-50%);width:420px;max-height:46vh;overflow-y:auto;padding:12px;z-index:30;display:none;color:#d8c8a0;font-size:13px;}
|
||||||
|
.advisor-grid{display:flex;flex-direction:column;gap:8px}
|
||||||
|
.goalrow{display:flex;justify-content:space-between;font-size:13px;padding:3px 0}
|
||||||
|
.bar{height:10px;background:#191225;border-radius:5px;overflow:hidden;border:1px solid #55402a}
|
||||||
|
.bar>div{height:100%}
|
||||||
|
`;
|
||||||
|
(function injectCSS() {
|
||||||
|
const st = document.createElement('style');
|
||||||
|
st.textContent = CSS;
|
||||||
|
document.head.appendChild(st);
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function initHUD(app: App) {
|
||||||
|
appRef = app;
|
||||||
|
const old = document.getElementById('hud-root');
|
||||||
|
if (old) old.remove();
|
||||||
|
const root = el('div');
|
||||||
|
root.id = 'hud-root';
|
||||||
|
document.getElementById('ui-root')!.appendChild(root);
|
||||||
|
|
||||||
|
// ── topbar ──
|
||||||
|
const tb = el('div', 'panelbox'); tb.id = 'topbar';
|
||||||
|
tb.innerHTML = `
|
||||||
|
<div class="stat" title="Treasury">💰 <b id="t-funds"></b></div>
|
||||||
|
<div class="stat" title="Population">👥 <b id="t-pop"></b></div>
|
||||||
|
<div class="stat" title="Date">📅 <span id="t-date"></span></div>
|
||||||
|
<div class="stat" title="Public sentiment">😊 <span id="t-sent"></span></div>
|
||||||
|
<div class="stat" title="Culture / Prosperity / Peace / Favor">🏅 <span id="t-ratings"></span></div>
|
||||||
|
<div class="stat" title="Mana">🔮 <div id="mana-wrap"><div id="mana-fill"></div></div></div>
|
||||||
|
<div class="stat" title="Unemployment">⚖️ <span id="t-unemp"></span></div>
|
||||||
|
`;
|
||||||
|
const spd = el('div'); spd.style.display = 'flex'; spd.style.gap = '3px';
|
||||||
|
['⏸', '▶', '⏩', '⚡'].forEach((s, i) => {
|
||||||
|
const b = el('button', 'speedbtn', s);
|
||||||
|
b.onclick = () => { app.city.paused = i === 0; if (i > 0) app.city.speedIdx = i - 1 + 1; playSfx('click'); };
|
||||||
|
b.dataset.i = String(i);
|
||||||
|
spd.appendChild(b);
|
||||||
|
});
|
||||||
|
tb.appendChild(spd);
|
||||||
|
const mb = el('button', '', '☰ Menu'); mb.id = 'menu-btn';
|
||||||
|
mb.onclick = () => import('./menus').then(m => m.openPauseMenu(app));
|
||||||
|
tb.appendChild(mb);
|
||||||
|
const msgBtn = el('button', 'speedbtn', '📜');
|
||||||
|
msgBtn.title = 'Message archive';
|
||||||
|
msgBtn.style.width = 'auto';
|
||||||
|
msgBtn.style.padding = '0 8px';
|
||||||
|
msgBtn.onclick = () => import('./menus').then(m => m.showMessages(app));
|
||||||
|
tb.appendChild(msgBtn);
|
||||||
|
root.appendChild(tb);
|
||||||
|
|
||||||
|
// ── sidebar ──
|
||||||
|
const sb = el('div'); sb.id = 'sidebar'; sb.className = 'panelbox';
|
||||||
|
const tabs = el('div'); tabs.id = 'cattabs';
|
||||||
|
const cards = el('div'); cards.id = 'buildcards';
|
||||||
|
sb.appendChild(tabs); sb.appendChild(cards);
|
||||||
|
root.appendChild(sb);
|
||||||
|
|
||||||
|
let activeCat = '';
|
||||||
|
const renderCards = (cat: string) => {
|
||||||
|
activeCat = cat;
|
||||||
|
[...tabs.children].forEach(c => c.classList.toggle('active', (c as HTMLElement).dataset.cat === cat));
|
||||||
|
cards.innerHTML = '';
|
||||||
|
for (const key of BUILD_ORDER) {
|
||||||
|
const d = DEFS[key];
|
||||||
|
if (!d || d.cat !== cat) continue;
|
||||||
|
const btn = el('button', 'bcard');
|
||||||
|
const icn = d.housing !== undefined ? '🏠' : (key === 'road' ? '🛣️' : CAT_ICONS[d.cat]);
|
||||||
|
btn.innerHTML = `<span class="icn">${icn}</span><span>${d.name}<small>${sizeLabel(d.w)} · ${d.workers ? d.workers + ' workers' : 'no workers'}${d.unique ? ' · unique' : ''}</small></span><span class="cost">${d.cost}💰</span>`;
|
||||||
|
btn.title = d.blurb + (d.hotkey ? ` [${d.hotkey.toUpperCase()}]` : '');
|
||||||
|
btn.onclick = () => {
|
||||||
|
app.setTool({ kind: 'build', key });
|
||||||
|
playSfx('click');
|
||||||
|
renderCards(cat);
|
||||||
|
};
|
||||||
|
btn.classList.toggle('active', app.tool?.kind === 'build' && (app.tool as { key: string }).key === key);
|
||||||
|
cards.appendChild(btn);
|
||||||
|
}
|
||||||
|
// erase tool at end
|
||||||
|
const er = el('button', 'bcard', `<span class="icn">❌</span><span>Clear / Demolish<small>removes buildings & rubble</small></span><span class="cost">+25%</span>`);
|
||||||
|
er.onclick = () => { app.setTool({ kind: 'erase' }); playSfx('click'); renderCards(cat); };
|
||||||
|
cards.appendChild(er);
|
||||||
|
};
|
||||||
|
for (const cat of CAT_ORDER) {
|
||||||
|
if (!BUILD_ORDER.some(k => DEFS[k]?.cat === cat)) continue;
|
||||||
|
const t = el('div', 'cattab', CAT_ICONS[cat]);
|
||||||
|
t.dataset.cat = cat;
|
||||||
|
t.title = cat[0].toUpperCase() + cat.slice(1);
|
||||||
|
t.onclick = () => { playSfx('click'); renderCards(cat); };
|
||||||
|
tabs.appendChild(t);
|
||||||
|
}
|
||||||
|
renderCards('housing');
|
||||||
|
|
||||||
|
// ── overlay row ──
|
||||||
|
const orow = el('div'); orow.id = 'overlay-row';
|
||||||
|
const ovs: Array<[string, string, OverlayModeLite]> = [
|
||||||
|
['🌐', 'None', null], ['🔥', 'Fire risk', 'fire'], ['💧', 'Water', 'water'],
|
||||||
|
['✨', 'Desirability', 'desirability'], ['🌱', 'Fertility', 'fertility'],
|
||||||
|
];
|
||||||
|
for (const [ic, label, mode] of ovs) {
|
||||||
|
const b = el('button', 'ovbtn' + (mode === null ? ' active' : ''), `${ic} ${label}`);
|
||||||
|
b.onclick = () => {
|
||||||
|
app.view.overlay = mode;
|
||||||
|
[...orow.children].forEach(c => c.classList.remove('active'));
|
||||||
|
b.classList.add('active');
|
||||||
|
playSfx('click');
|
||||||
|
};
|
||||||
|
orow.appendChild(b);
|
||||||
|
}
|
||||||
|
root.appendChild(orow);
|
||||||
|
void activeCat;
|
||||||
|
|
||||||
|
// ── spellbar ──
|
||||||
|
const spb = el('div', 'panelbox'); spb.id = 'spellbar';
|
||||||
|
root.appendChild(spb);
|
||||||
|
for (const sid of Object.keys(SPELLS) as SpellId[]) {
|
||||||
|
const sd = SPELLS[sid];
|
||||||
|
const s = el('div', 'spell', `${sd.icon}<span class="mcost">${sd.mana}</span>`);
|
||||||
|
s.title = `${sd.name} — ${sd.desc}`;
|
||||||
|
s.dataset.spell = sid;
|
||||||
|
s.onclick = () => app.castFromBar(sid);
|
||||||
|
spb.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── minimap ──
|
||||||
|
app.minimapCv = makeMinimap(app.city, 176, 176);
|
||||||
|
app.minimapCv.id = 'minimap';
|
||||||
|
root.appendChild(app.minimapCv);
|
||||||
|
|
||||||
|
// ── info panel ──
|
||||||
|
const ip = el('div', 'panelbox'); ip.id = 'info-panel';
|
||||||
|
root.appendChild(ip);
|
||||||
|
|
||||||
|
// ── toasts ──
|
||||||
|
const toasts = el('div'); toasts.id = 'toasts';
|
||||||
|
root.appendChild(toasts);
|
||||||
|
els.toasts = toasts;
|
||||||
|
els['t-funds'] = tb.querySelector('#t-funds')!;
|
||||||
|
els['t-pop'] = tb.querySelector('#t-pop')!;
|
||||||
|
els['t-date'] = tb.querySelector('#t-date')!;
|
||||||
|
els['t-sent'] = tb.querySelector('#t-sent')!;
|
||||||
|
els['t-ratings'] = tb.querySelector('#t-ratings')!;
|
||||||
|
els['mana-fill'] = tb.querySelector('#mana-fill')!;
|
||||||
|
els['t-unemp'] = tb.querySelector('#t-unemp')!;
|
||||||
|
els.topbar = tb;
|
||||||
|
}
|
||||||
|
type OverlayModeLite = null | 'fire' | 'water' | 'desirability' | 'fertility';
|
||||||
|
|
||||||
|
function sizeLabel(w: number) { return w === 1 ? '1×1' : w === 2 ? '2×2' : '3×3'; }
|
||||||
|
|
||||||
|
const SENT_FACES: Array<[number, string]> = [[20, '😡'], [40, '😠'], [55, '😐'], [70, '🙂'], [101, '😄']];
|
||||||
|
|
||||||
|
let hudAcc = 0;
|
||||||
|
export function refreshHUD(app: App, dt: number) {
|
||||||
|
hudAcc += dt;
|
||||||
|
if (hudAcc < 0.2) return;
|
||||||
|
hudAcc = 0;
|
||||||
|
const c = app.city;
|
||||||
|
els['t-funds']!.textContent = fmt.format(Math.floor(c.funds));
|
||||||
|
els['t-pop']!.textContent = fmt.format(c.population);
|
||||||
|
els['t-date']!.textContent = `${monthName(c.month)} Yr ${roman(c.year + 1)}`;
|
||||||
|
const face = SENT_FACES.find(([v]) => c.sentiment < v)?.[1] ?? '😐';
|
||||||
|
els['t-sent']!.textContent = `${face} ${Math.round(c.sentiment)}%`;
|
||||||
|
els['t-ratings']!.textContent = `C${c.ratings.culture} P${c.ratings.prosperity} Pe${c.ratings.peace} F${c.ratings.favor}`;
|
||||||
|
els['mana-fill']!.style.width = `${(c.mana / Math.max(1, c.manaMax)) * 100}%`;
|
||||||
|
els['t-unemp']!.textContent = `${c.unemploymentPct}%`;
|
||||||
|
|
||||||
|
// speed buttons active state
|
||||||
|
[...els.topbar.querySelectorAll('.speedbtn')].forEach((b, i) => {
|
||||||
|
const idx = i === 0 ? 0 : i; // paused=0 else speedIdx mapping 1..3
|
||||||
|
const active = i === 0 ? c.paused : (!c.paused && c.speedIdx === i - 1 + 1);
|
||||||
|
b.classList.toggle('active', !!active);
|
||||||
|
void idx;
|
||||||
|
});
|
||||||
|
|
||||||
|
// spells
|
||||||
|
for (const n of document.getElementById('spellbar')?.children ?? []) {
|
||||||
|
const s = n as HTMLElement;
|
||||||
|
const sid = s.dataset.spell as SpellId;
|
||||||
|
const unlocked = c.spellState.unlocked.has(sid);
|
||||||
|
s.classList.toggle('locked', !unlocked);
|
||||||
|
let cdEl = s.querySelector('.cd') as HTMLElement | null;
|
||||||
|
if (!unlocked) continue;
|
||||||
|
const leftM = spellCooldownLeft(c, sid);
|
||||||
|
if (leftM > 0) {
|
||||||
|
if (!cdEl) { cdEl = el('div', 'cd'); s.appendChild(cdEl); }
|
||||||
|
cdEl.textContent = Math.ceil(leftM * 10) + 's';
|
||||||
|
} else if (cdEl) cdEl.remove();
|
||||||
|
else if (c.mana < SPELLS[sid].mana) {
|
||||||
|
if (!cdEl) { cdEl = el('div', 'cd'); cdEl.style.background = 'rgba(80,20,90,.45)'; s.appendChild(cdEl); }
|
||||||
|
cdEl.textContent = '';
|
||||||
|
} else { (cdEl as HTMLElement | null)?.remove(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// scenario goals mini-display in topbar title attr
|
||||||
|
const goals = app.scenario.goals;
|
||||||
|
if (goals.length) {
|
||||||
|
els['t-ratings']!.title = 'Goals:\n' + goals.map(g => `${GOAL_LABEL[g.kind] ?? g.kind}: ${goalProgress(c, g.kind, g.amount ?? 0)} / ${g.amount ?? ''}${goalMet(c, g.kind, g.amount ?? 0) ? ' ✓' : ''}`).join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GOAL_LABEL: Record<string, string> = {
|
||||||
|
population: 'Population', culture: 'Culture', prosperity: 'Prosperity',
|
||||||
|
peace: 'Peace', favor: 'Favor', treasury: 'Treasury', surviveYears: 'Survive years',
|
||||||
|
slayMonsters: 'Monsters slain', buildWonder: 'Build Colosseum',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── toasts ──
|
||||||
|
export function toast(text: string, _kind: string = 'info') {
|
||||||
|
const t = el('div', 'toast', text);
|
||||||
|
els.toasts?.appendChild(t);
|
||||||
|
setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .4s'; }, 2600);
|
||||||
|
setTimeout(() => t.remove(), 3100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── building info panel ──
|
||||||
|
export function showBuildingInfo(app: App, b: Building) {
|
||||||
|
const ip = document.getElementById('info-panel')!;
|
||||||
|
const d = def(b.def);
|
||||||
|
const c = app.city;
|
||||||
|
ip.style.display = 'block';
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(`<h3 style="margin:0 0 6px;color:#ffe9a0">${d.name}</h3>`);
|
||||||
|
parts.push(`<div style="color:#b09b72;margin-bottom:8px">${d.blurb}</div>`);
|
||||||
|
if (b.ruin) {
|
||||||
|
parts.push(`<div style="color:#e07050">☠️ Ruins — clear them to rebuild.</div>`);
|
||||||
|
} else {
|
||||||
|
if (d.workers) parts.push(`<div>👷 Workers: <b>${b.workersLive}/${d.workers}</b>${b.workersLive === 0 ? ' — <span style="color:#e07050">no staff!</span>' : ''}</div>`);
|
||||||
|
if (b.noRoad) parts.push(`<div style="color:#ffb454">⛔ Not connected to a road — build a road next to it to put it to work.</div>`);
|
||||||
|
if (d.produces) {
|
||||||
|
const g = GOODS[d.produces.good];
|
||||||
|
parts.push(`<div>${g.icon} Output stored here: <b>${Math.floor(b.inv[d.produces.good] ?? 0)}</b></div>`);
|
||||||
|
for (const [k, v] of Object.entries(d.consumes ?? {})) {
|
||||||
|
parts.push(`<div style="font-size:12px;color:#b09b72">needs ${GOODS[k as Good].icon} ${GOODS[k as Good].name}: ${Math.floor(b.inv[k as Good] ?? 0)}/${v} buffered</div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (d.storage) {
|
||||||
|
const tot = Object.values(b.inv).reduce((a, v) => a + (v ?? 0), 0);
|
||||||
|
parts.push(`<div>🏺 Contents (${tot}/${d.storage.capacity}):</div>`);
|
||||||
|
for (const [k, v] of Object.entries(b.inv)) {
|
||||||
|
if ((v ?? 0) > 0) parts.push(`<span style="font-size:12px;margin-right:8px">${GOODS[k as Good].icon} ${Math.floor(v ?? 0)}</span>`);
|
||||||
|
}
|
||||||
|
parts.push('<div></div>');
|
||||||
|
}
|
||||||
|
if (d.housing !== undefined) {
|
||||||
|
const tier = HOUSE_TIERS[Math.min(b.houseLevel, HOUSE_TIERS.length - 1)];
|
||||||
|
parts.push(`<div>🏠 <b>${tier.name}</b> — 👥 ${b.residents}/${tier.maxResidents}</div>`);
|
||||||
|
if (b.goodsStock) {
|
||||||
|
const foods = (['wheat', 'fruit', 'meat'] as const).filter(g => (b.goodsStock![g] ?? 0) > 0).map(g => GOODS[g].icon).join('');
|
||||||
|
if (foods) parts.push(`<div style="font-size:12px">pantry: ${foods}</div>`);
|
||||||
|
}
|
||||||
|
if ((b.hungerMonths ?? 0) > 0) parts.push(`<div style="color:#e07050">⚠ Hunger for ${b.hungerMonths} months!</div>`);
|
||||||
|
}
|
||||||
|
if (b.burning > 0) parts.push(`<div style="color:#ff9040">🔥 ON FIRE!</div>`);
|
||||||
|
if (d.tower || d.mageTower) parts.push(`<div>🎯 Defenses manned${b.workersLive ? '' : ' — <span style="color:#e07050">unstaffed!</span>'}</div>`);
|
||||||
|
if (d.heroHall) {
|
||||||
|
parts.push(`<div style="margin-top:8px"><b>Summon a legend:</b></div>`);
|
||||||
|
for (const hid of Object.keys(HEROES) as Array<keyof typeof HEROES>) {
|
||||||
|
const hd = HEROES[hid];
|
||||||
|
parts.push(`<button class="ovbtn" style="margin:3px 3px 0 0;width:100%;text-align:left" onclick="(window.__ia_summon||(()=>{}))('${hid}')">${hd.icon} ${hd.name} ${hd.title} — ${hd.costGold}💰${hd.costGood ? ` + ${hd.costGood.qty} ${GOODS[hd.costGood.good].name}` : ''}</button>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (d.fort || b.def === 'barracks') {
|
||||||
|
parts.push(`<button class="ovbtn" style="margin-top:8px;width:100%" onclick="(window.__ia_rally||(()=>{}))()">🎯 Set rally point</button>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(`<div style="display:flex;gap:6px;margin-top:10px">
|
||||||
|
<button class="ovbtn" style="flex:1" onclick="(window.__ia_close||(()=>{}))()">Close</button>
|
||||||
|
${!b.ruin ? `<button class="ovbtn" style="flex:1;color:#ff9080" onclick="(window.__ia_demolish||(()=>{}))()">Demolish</button>` : ''}
|
||||||
|
</div>`);
|
||||||
|
ip.innerHTML = parts.join('');
|
||||||
|
|
||||||
|
(window as unknown as Record<string, unknown>).__ia_close = () => { ip.style.display = 'none'; app.view.selectedBld = 0; };
|
||||||
|
(window as unknown as Record<string, unknown>).__ia_demolish = () => { removeBuilding(app.city, b.x, b.y); hideInfoPanel(); };
|
||||||
|
(window as unknown as Record<string, unknown>).__ia_summon = (hid: string) => app.summonHero(hid as never);
|
||||||
|
(window as unknown as Record<string, unknown>).__ia_rally = () => { app.setTool({ kind: 'rally', bldId: b.id }); hideInfoPanel(); toast('Click where troops should gather.', 'info'); };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minimapClickToTile(city: CityState, _cam: Camera, mx: number, my: number, w: number, h: number): [number, number] {
|
||||||
|
const tx = clampN(mx / w * city.W, 0, city.W - 1);
|
||||||
|
const ty = clampN(my / h * city.H, 0, city.H - 1);
|
||||||
|
return [(tx - ty) * 32, (tx + ty) * 16];
|
||||||
|
}
|
||||||
|
function clampN(v: number, a: number, b: number) { return v < a ? a : v > b ? b : v; }
|
||||||
|
|
||||||
|
export function hideInfoPanel() {
|
||||||
|
const ip = document.getElementById('info-panel');
|
||||||
|
if (ip) ip.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
+347
@@ -0,0 +1,347 @@
|
|||||||
|
// ─── Menus: title screen, pause, advisors, victory ─────────────────────────
|
||||||
|
import type { Good } from '../sim/types';
|
||||||
|
import { GOODS, SPELLS } from '../sim/types';
|
||||||
|
import { CAMPAIGN, makeSandbox } from '../data/scenarios';
|
||||||
|
import { HEROES } from '../data/heroes';
|
||||||
|
import { fmt } from '../core/util';
|
||||||
|
import type { App } from '../app';
|
||||||
|
import { launchScenario } from '../app';
|
||||||
|
import { listSaves, deleteSave } from '../sim/save';
|
||||||
|
import { goalMet, goalProgress } from '../sim/economy';
|
||||||
|
import { audioState, setVolume, startMusic, unlockAudio } from '../audio/audio';
|
||||||
|
import { GOAL_LABEL } from './hud';
|
||||||
|
|
||||||
|
function overlay(id: string): HTMLDivElement {
|
||||||
|
document.getElementById(id)?.remove();
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.id = id;
|
||||||
|
d.style.cssText = 'position:absolute;inset:0;background:rgba(10,6,4,.72);display:flex;align-items:center;justify-content:center;z-index:100;';
|
||||||
|
const inner = document.createElement('div');
|
||||||
|
inner.className = 'panelbox menu-inner';
|
||||||
|
inner.style.cssText = 'padding:26px 30px;max-width:min(720px,92vw);max-height:88vh;overflow-y:auto;color:#e8d8ae;font-size:14px;line-height:1.5;';
|
||||||
|
d.appendChild(inner);
|
||||||
|
document.getElementById('ui-root')!.appendChild(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
const MENU_CSS = `
|
||||||
|
.menu-inner h1{font-size:34px;margin:0 0 2px;color:#ffd700;letter-spacing:2px;text-shadow:0 2px 8px rgba(0,0,0,.6)}
|
||||||
|
.menu-inner h2{margin:14px 0 6px;color:#ffe9a0}
|
||||||
|
.menu-inner .sub{color:#b09b72;margin-bottom:16px}
|
||||||
|
.mbtn{display:block;width:100%;text-align:left;padding:9px 12px;margin:5px 0;background:#33251a;border:1px solid #55402a;border-radius:8px;color:#e8d8ae;font-size:15px;cursor:pointer}
|
||||||
|
.mbtn:hover{border-color:#c9a227;background:#452f1c}
|
||||||
|
.mbtn small{color:#b09b72;display:block;font-size:12px}
|
||||||
|
.chip{display:inline-flex;align-items:center;gap:4px;background:#241a10;border:1px solid #55402a;border-radius:12px;padding:2px 9px;margin:2px;cursor:pointer;font-size:12px}
|
||||||
|
.chip.on{background:#3f5a2f;border-color:#7fa04a;color:#d8f0c0}
|
||||||
|
.scen-card{border:1px solid #55402a;border-radius:10px;padding:10px 12px;margin:8px 0;cursor:pointer;background:#33251a}
|
||||||
|
.scen-card:hover{border-color:#c9a227;background:#452f1c}
|
||||||
|
.kbd{background:#241a10;border:1px solid #55402a;border-radius:4px;padding:0 5px;font-family:monospace;font-size:12px}
|
||||||
|
`;
|
||||||
|
(function () {
|
||||||
|
const st = document.createElement('style');
|
||||||
|
st.textContent = MENU_CSS;
|
||||||
|
document.head.appendChild(st);
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function anyMenuOpen(): boolean {
|
||||||
|
return !!document.querySelector('.menu-inner') && !document.getElementById('main-menu')?.classList.contains('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeAllMenus() {
|
||||||
|
for (const id of ['main-menu', 'pause-menu', 'advisor', 'victory-modal', 'help-modal', 'settings-modal']) {
|
||||||
|
document.getElementById(id)?.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Title / main menu ──────────────────────────────────────────────────────
|
||||||
|
let currentApp: App | null = null;
|
||||||
|
export function openMainMenus(app: App) {
|
||||||
|
currentApp = app;
|
||||||
|
closeAllMenus();
|
||||||
|
const d = overlay('main-menu');
|
||||||
|
const inner = d.firstChild as HTMLElement;
|
||||||
|
inner.innerHTML = `
|
||||||
|
<h1>IMPERIUM ARCANUM</h1>
|
||||||
|
<div class="sub">A city-builder of legends — roads, legions, heroes and spells. Governor, the province awaits.</div>
|
||||||
|
<button class="mbtn" id="mm-campaign">⚔️ Campaign<small>Five scenarios from farming village to eternal city of magic</small></button>
|
||||||
|
<button class="mbtn" id="mm-sandbox">🌍 Free Province (sandbox)<small>Endless map, scaling monster waves</small></button>
|
||||||
|
<button class="mbtn" id="mm-continue">📜 Continue / Load<small>Autosave & manual chronicles</small></button>
|
||||||
|
<button class="mbtn" id="mm-help">❓ How to play</button>
|
||||||
|
<button class="mbtn" id="mm-settings">🎵 Settings</button>
|
||||||
|
<div style="margin-top:14px;color:#8f7a5a;font-size:11px">Runs entirely in your browser · saves in local storage · inspired by Caesar 3 & Augustus</div>
|
||||||
|
`;
|
||||||
|
(inner.querySelector('#mm-campaign') as HTMLElement).onclick = showCampaignList;
|
||||||
|
(inner.querySelector('#mm-sandbox') as HTMLElement).onclick = () => {
|
||||||
|
unlockAudio(); startMusic();
|
||||||
|
launchScenario(app, makeSandbox(Math.floor(Math.random() * 99999)));
|
||||||
|
};
|
||||||
|
(inner.querySelector('#mm-continue') as HTMLElement).onclick = () => showLoadList();
|
||||||
|
(inner.querySelector('#mm-help') as HTMLElement).onclick = () => showHelp(true);
|
||||||
|
(inner.querySelector('#mm-settings') as HTMLElement).onclick = () => showSettings();
|
||||||
|
|
||||||
|
function showCampaignList() {
|
||||||
|
inner.innerHTML = '<h2>Campaign</h2>';
|
||||||
|
CAMPAIGN.forEach((sc, i) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'scen-card';
|
||||||
|
card.innerHTML = `<b style="color:#ffd700">${sc.name}</b> — ${sc.subtitle}<br><span style="font-size:12px;color:#b09b72">${sc.brief.slice(0, 110)}…</span><br><span style="font-size:11px;color:#8f7a5a">Goals: ${sc.goals.map(g => `${GOAL_LABEL[g.kind] ?? g.kind} ${g.amount ?? ''}`).join(' · ') || 'sandbox'}</span>`;
|
||||||
|
card.onclick = () => { unlockAudio(); startMusic(); launchScenario(app, sc); };
|
||||||
|
if (i > 0) {
|
||||||
|
// light progression gate: allow all but mark suggested order
|
||||||
|
card.style.opacity = '.85';
|
||||||
|
}
|
||||||
|
inner.appendChild(card);
|
||||||
|
});
|
||||||
|
addBackButton(inner, renderHome);
|
||||||
|
}
|
||||||
|
function showLoadList() {
|
||||||
|
inner.innerHTML = '<h2>Chronicles</h2>';
|
||||||
|
const saves = listSaves();
|
||||||
|
if (!saves.length) inner.innerHTML += '<div class="sub">No chronicles found yet. They are written automatically every few months.</div>';
|
||||||
|
for (const { slot, meta } of saves) {
|
||||||
|
const row = document.createElement('button');
|
||||||
|
row.className = 'mbtn';
|
||||||
|
row.innerHTML = `📜 <b>${meta.scenarioName}</b> — pop ${fmt.format(meta.population)}, treasury ${fmt.format(meta.funds)}<small>${new Date(meta.at).toLocaleString()}</small>`;
|
||||||
|
row.onclick = () => {
|
||||||
|
unlockAudio(); startMusic();
|
||||||
|
const loaded = loadCitySafe(slot);
|
||||||
|
if (!loaded) return;
|
||||||
|
launchScenario(app, loaded.scenario, slot);
|
||||||
|
};
|
||||||
|
inner.appendChild(row);
|
||||||
|
const del = document.createElement('button');
|
||||||
|
del.className = 'chip'; del.textContent = '🗑 delete';
|
||||||
|
del.onclick = () => { deleteSave(slot); showLoadList(); };
|
||||||
|
row.appendChild(del);
|
||||||
|
}
|
||||||
|
addBackButton(inner, renderHome);
|
||||||
|
}
|
||||||
|
function renderHome() { openMainMenus(currentApp!); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCitySafe(slot: string) {
|
||||||
|
try {
|
||||||
|
const mod = require_load();
|
||||||
|
return mod(slot);
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
import { loadCity } from '../sim/save';
|
||||||
|
function require_load() { return loadCity; }
|
||||||
|
|
||||||
|
export function openPauseMenu(_app: App) {
|
||||||
|
const app = _app;
|
||||||
|
if (document.getElementById('pause-menu')) { closeAllMenus(); return; }
|
||||||
|
const d = overlay('pause-menu');
|
||||||
|
(d.firstChild as HTMLElement).innerHTML = `
|
||||||
|
<h2>Paused</h2>
|
||||||
|
<button class="mbtn" id="pm-resume">▶ Resume</button>
|
||||||
|
<button class="mbtn" id="pm-save">💾 Save chronicle</button>
|
||||||
|
<button class="mbtn" id="pm-advisor-labor">👷 Labor advisor</button>
|
||||||
|
<button class="mbtn" id="pm-advisor-trade">🐫 Trade & empire</button>
|
||||||
|
<button class="mbtn" id="pm-advisor-goal">🏅 Ratings & goals</button>
|
||||||
|
<button class="mbtn" id="pm-hero">⚔️ Hall of Heroes</button>
|
||||||
|
<button class="mbtn" id="pm-msgs">📜 Message archive</button>
|
||||||
|
<button class="mbtn" id="pm-help">❓ How to play</button>
|
||||||
|
<button class="mbtn" id="pm-settings">🎵 Settings</button>
|
||||||
|
<button class="mbtn" id="pm-quit">🚪 Quit to menu</button>
|
||||||
|
`;
|
||||||
|
(d.querySelector('#pm-resume') as HTMLElement).onclick = () => closeAllMenus();
|
||||||
|
(d.querySelector('#pm-save') as HTMLElement).onclick = () => { app.saveTo('slot_' + Date.now()); closeAllMenus(); };
|
||||||
|
(d.querySelector('#pm-advisor-labor') as HTMLElement).onclick = () => showLabor(app);
|
||||||
|
(d.querySelector('#pm-advisor-trade') as HTMLElement).onclick = () => showTrade(app);
|
||||||
|
(d.querySelector('#pm-advisor-goal') as HTMLElement).onclick = () => showGoals(app);
|
||||||
|
(d.querySelector('#pm-hero') as HTMLElement).onclick = () => showHeroes(app);
|
||||||
|
(d.querySelector('#pm-msgs') as HTMLElement).onclick = () => showMessages(app);
|
||||||
|
(d.querySelector('#pm-help') as HTMLElement).onclick = () => showHelp(false);
|
||||||
|
(d.querySelector('#pm-settings') as HTMLElement).onclick = () => showSettings();
|
||||||
|
(d.querySelector('#pm-quit') as HTMLElement).onclick = () => app.quitToMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function victoryModal(app: App, won: boolean, reason?: string) {
|
||||||
|
const d = overlay('victory-modal');
|
||||||
|
(d.firstChild as HTMLElement).innerHTML = won ? `
|
||||||
|
<h1 style="color:#ffd700">🏆 VICTORY</h1>
|
||||||
|
<p>The Senate showers you in laurels. ${app.scenario.reward ?? ''}</p>
|
||||||
|
<button class="mbtn" id="vm-cont">Keep governing</button>
|
||||||
|
<button class="mbtn" id="vm-menu">Return to Rome (menu)</button>
|
||||||
|
` : `
|
||||||
|
<h1 style="color:#e07050">☠️ DEFEAT</h1>
|
||||||
|
<p>${reason ?? 'The province has fallen.'}</p>
|
||||||
|
<button class="mbtn" id="vm-menu">Return to Rome (menu)</button>
|
||||||
|
`;
|
||||||
|
d.querySelector('#vm-menu')?.addEventListener('click', () => app.quitToMenu());
|
||||||
|
d.querySelector('#vm-cont')?.addEventListener('click', () => { d.remove(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBackButton(inner: HTMLElement, fn: () => void) {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = 'mbtn'; b.textContent = '← Back';
|
||||||
|
b.onclick = fn;
|
||||||
|
inner.appendChild(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Advisors ───────────────────────────────────────────────────────────────
|
||||||
|
export function showLabor(app: App) {
|
||||||
|
const c = app.city;
|
||||||
|
const d = overlay('advisor');
|
||||||
|
const byCat: Record<string, number> = {};
|
||||||
|
let totalNeed = 0, totalHave = 0;
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
const dd = b.def;
|
||||||
|
void dd;
|
||||||
|
}
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
const defn = (window as unknown as { __defs: Record<string, { workers: number; cat: string }> }).__defs?.[b.def];
|
||||||
|
void defn;
|
||||||
|
}
|
||||||
|
import('../data/buildings').then(({ def }) => {
|
||||||
|
for (const b of c.buildings.values()) {
|
||||||
|
const bd = def(b.def);
|
||||||
|
if (bd.workers > 0 && !b.ruin) {
|
||||||
|
byCat[bd.cat] = (byCat[bd.cat] ?? 0) + b.workersLive;
|
||||||
|
totalNeed += bd.workers; totalHave += b.workersLive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(d.firstChild as HTMLElement).innerHTML = `
|
||||||
|
<h2>Labor Advisor</h2>
|
||||||
|
<div>Workforce: <b>${c.laborers}</b> · Employed: <b>${c.employed}</b> · Unemployed: <b>${Math.round(c.unemploymentPct)}%</b></div>
|
||||||
|
<div class="bar" style="margin:8px 0"><div style="width:${totalNeed ? (totalHave / totalNeed) * 100 : 0}%;background:#7fa04a"></div></div>
|
||||||
|
<div class="advisor-grid">
|
||||||
|
${Object.entries(byCat).sort((a, b) => b[1] - a[1]).map(([cat, n]) => `<div class="goalrow"><span>${cat}</span><b>${n} workers</b></div>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:10px;color:#b09b72;font-size:12px">Tip: unemployment above ~18% hurts Prosperity, but keep some slack or new industry will starve.</div>
|
||||||
|
<button class="mbtn" onclick="this.closest('.menu-inner').parentElement.remove()">Close</button>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
// immediate fallback content
|
||||||
|
(d.firstChild as HTMLElement).textContent = 'Gathering census…';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showTrade(app: App) {
|
||||||
|
const c = app.city;
|
||||||
|
const d = overlay('advisor');
|
||||||
|
const goodsList = Object.keys(GOODS) as Good[];
|
||||||
|
const render = () => {
|
||||||
|
(d.firstChild as HTMLElement).innerHTML = `
|
||||||
|
<h2>Trade & Empire</h2>
|
||||||
|
<div style="margin-bottom:6px">Requires a staffed <b>Trading Post</b>. Caravans arrive each quarter. Exports sell surplus above 120 units.</div>
|
||||||
|
<button class="mbtn" id="gift-btn" style="width:auto">🎁 Send gift to Rome (+8 Favor, 800💰)</button>
|
||||||
|
${c.trades.map((tc, i) => `
|
||||||
|
<div class="scen-card" style="cursor:default">
|
||||||
|
<b style="color:#ffd700">🏛 ${tc.name}</b> — route ${tc.open ? 'open' : 'closed'} · prices ×${tc.priceFactor.toFixed(2)}
|
||||||
|
<div style="margin-top:4px">Exports: ${goodsList.map(g =>
|
||||||
|
`<span class="chip ${tc.exports.includes(g) ? 'on' : ''}" data-t="${i}" data-k="exports" data-g="${g}">${GOODS[g].icon} ${GOODS[g].name} ▸${Math.round(GOODS[g].basePrice * tc.priceFactor * 0.85)}</span>`).join('')}</div>
|
||||||
|
<div>Imports: ${goodsList.map(g =>
|
||||||
|
`<span class="chip ${tc.imports.includes(g) ? 'on' : ''}" data-t="${i}" data-k="imports" data-g="${g}">${GOODS[g].icon} ◂${Math.round(GOODS[g].basePrice * tc.priceFactor * 1.15)}</span>`).join('')}</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
<button class="mbtn" onclick="this.closest('.menu-inner').parentElement.remove()">Close</button>
|
||||||
|
`;
|
||||||
|
d.querySelectorAll('.chip').forEach(ch => ch.addEventListener('click', () => {
|
||||||
|
const tI = parseInt((ch as HTMLElement).dataset.t!);
|
||||||
|
const k = (ch as HTMLElement).dataset.k as 'exports' | 'imports';
|
||||||
|
const g = (ch as HTMLElement).dataset.g as Good;
|
||||||
|
const arr = c.trades[tI][k];
|
||||||
|
const ix = arr.indexOf(g);
|
||||||
|
if (ix >= 0) arr.splice(ix, 1); else arr.push(g);
|
||||||
|
render();
|
||||||
|
}));
|
||||||
|
d.querySelector('#gift-btn')?.addEventListener('click', () => { app.sendGift(); render(); });
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showGoals(app: App) {
|
||||||
|
const c = app.city;
|
||||||
|
const sc = app.scenario;
|
||||||
|
const d = overlay('advisor');
|
||||||
|
(d.firstChild as HTMLElement).innerHTML = `
|
||||||
|
<h2>Ratings & Goals</h2>
|
||||||
|
<div class="advisor-grid">
|
||||||
|
${(['culture', 'prosperity', 'peace', 'favor'] as const).map(k => `
|
||||||
|
<div><div class="goalrow"><span>${k[0].toUpperCase() + k.slice(1)}</span><b>${c.ratings[k]}</b></div>
|
||||||
|
<div class="bar"><div style="width:${c.ratings[k]}%;background:${k === 'peace' ? '#3a6ea5' : k === 'favor' ? '#c9a227' : '#7fa04a'}"></div></div></div>`).join('')}
|
||||||
|
</div>
|
||||||
|
<h2>Mission goals</h2>
|
||||||
|
${(sc.goals.length ? sc.goals : []).map(g => {
|
||||||
|
const p = goalProgress(c, g.kind, g.amount ?? 0);
|
||||||
|
const done = goalMet(c, g.kind, g.amount ?? 0);
|
||||||
|
return `<div class="goalrow"><span>${done ? '✅' : '⬜'} ${GOAL_LABEL[g.kind] ?? g.kind}</span><b>${Math.floor(p)} / ${g.amount ?? ''}</b></div>`;
|
||||||
|
}).join('') || '<div class="sub">Free province — no fixed goals. Prosper!</div>'}
|
||||||
|
<div style="margin-top:8px;font-size:12px;color:#b09b72">Monsters slain: ${c.stats.monstersSlain} · Invaders slain: ${c.stats.invadersSlain} · Deaths: ${c.stats.totalDeaths}</div>
|
||||||
|
<button class="mbtn" onclick="this.closest('.menu-inner').parentElement.remove()">Close</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showHeroes(app: App) {
|
||||||
|
const c = app.city;
|
||||||
|
const hasHall = [...c.buildings.values()].some(b => b.def === 'heroHall' && !b.ruin);
|
||||||
|
const d = overlay('advisor hero-dlg'); (d.firstChild as HTMLElement).id = 'hero-dialog';
|
||||||
|
(d.firstChild as HTMLElement).innerHTML = `
|
||||||
|
<h2>Hall of Heroes</h2>
|
||||||
|
${hasHall ? '' : '<div class="sub" style="color:#e07050">You need a built Hall of Heroes to summon legends.</div>'}
|
||||||
|
${Object.values(HEROES).map(hd => `
|
||||||
|
<div class="scen-card" style="cursor:default">
|
||||||
|
<b style="color:#ffd700">${hd.icon} ${hd.name} ${hd.title}</b><br>
|
||||||
|
<span style="font-size:12px">${hd.desc}</span><br>
|
||||||
|
<span style="font-size:12px;color:#b09b72">HP ${hd.hp} · DMG ${hd.dmg} · ${hd.ranged ? 'ranged' : 'melee'} · aura +${hd.auraProd}% nearby production</span><br>
|
||||||
|
<button class="mbtn" style="width:auto;margin-top:6px" data-h="${hd.id}">Summon — ${hd.costGold}💰${hd.costGood ? ` + ${hd.costGood.qty} ${GOODS[hd.costGood.good].name}` : ''}</button>
|
||||||
|
</div>`).join('')}
|
||||||
|
<button class="mbtn" onclick="this.closest('.menu-inner').parentElement.remove()">Close</button>
|
||||||
|
`;
|
||||||
|
d.querySelectorAll('[data-h]').forEach(b => b.addEventListener('click', () => {
|
||||||
|
app.summonHero((b as HTMLElement).dataset.h as never);
|
||||||
|
d.remove();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
export function closeHeroDialog() {
|
||||||
|
document.getElementById('hero-dialog')?.closest('.menu-inner')?.parentElement?.remove();
|
||||||
|
document.querySelectorAll('.menu-inner').forEach(m => {
|
||||||
|
if ((m as HTMLElement).id === 'hero-dialog' || m.querySelector('#hero-dialog')) m.parentElement?.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showMessages(app: App) {
|
||||||
|
const d = overlay('advisor');
|
||||||
|
(d.firstChild as HTMLElement).innerHTML = `
|
||||||
|
<h2>Message Archive</h2>
|
||||||
|
${app.city.messages.slice(0, 60).map(m => `<div class="goalrow"><span>${m.icon} ${m.text}</span><span style="color:#8f7a5a">Yr ${m.year + 1}</span></div>`).join('') || '<div class="sub">No messages yet.</div>'}
|
||||||
|
<button class="mbtn" onclick="this.closest('.menu-inner').parentElement.remove()">Close</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showHelp(fromMain: boolean) {
|
||||||
|
const d = overlay('help-modal');
|
||||||
|
(d.firstChild as HTMLElement).innerHTML = `
|
||||||
|
<h2>How to play</h2>
|
||||||
|
<div style="max-width:600px">
|
||||||
|
<p><b>Found a town:</b> place buildings anywhere you like — a ⛔ badge means "connect me!": lay 🛣️ ROADS so every building touches one. Drop 🏠 HOUSES beside roads, then dig a ⛲ WELL. Farms go on dark green fertile soil; granaries store their harvest; markets send peddlers to feed homes.</p>
|
||||||
|
<p><b>Growth:</b> houses evolve when given water → food → religion → entertainment → education → health and luxury goods (pottery, furniture, oil, wine). Higher housing pays more taxes.</p>
|
||||||
|
<p><b>Defense:</b> prefects stop fires, engineers stop collapses. Walls + towers + forts (which train legions from weapons) hold back raiders. Monsters come with the scenarios — slay them via heroes and spells.</p>
|
||||||
|
<p><b>Magic:</b> temples trickle mana; Ley Pylons on glowing crystal seams and the Arcanum generate plenty. Spells cast from the bottom bar; the Great Library & Arcanum research more.</p>
|
||||||
|
<p><b>Heroes:</b> build the Hall of Heroes to summon Aurelius, Lyra or Magnus. They fight monsters, level up, and boost nearby industry.</p>
|
||||||
|
<p><b>Controls:</b> <span class="kbd">LMB</span> place/select · drag to paint roads/walls · <span class="kbd">RMB drag</span> pan · wheel zoom · <span class="kbd">WASD</span> pan · <span class="kbd">Space</span> pause · <span class="kbd">1–4</span> speed · <span class="kbd">Esc</span> cancel tool.</p>
|
||||||
|
</div>
|
||||||
|
<button class="mbtn" id="help-close">Close</button>
|
||||||
|
`;
|
||||||
|
d.querySelector('#help-close')?.addEventListener('click', () => d.remove());
|
||||||
|
void fromMain;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showSettings() {
|
||||||
|
const d = overlay('settings-modal');
|
||||||
|
const inner = d.firstChild as HTMLElement;
|
||||||
|
const render = () => {
|
||||||
|
inner.innerHTML = `
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<label class="goalrow"><span>Sound effects</span><input type="checkbox" id="st-sfx" ${audioState.enabled ? 'checked' : ''}></label>
|
||||||
|
<label class="goalrow"><span>Music</span><input type="checkbox" id="st-music" ${audioState.music ? 'checked' : ''}></label>
|
||||||
|
<label class="goalrow"><span>Volume</span><input type="range" id="st-vol" min="0" max="100" value="${audioState.volume * 100}"></label>
|
||||||
|
<button class="mbtn" id="st-close">Done</button>
|
||||||
|
`;
|
||||||
|
inner.querySelector('#st-sfx')?.addEventListener('change', e => { audioState.enabled = (e.target as HTMLInputElement).checked; });
|
||||||
|
inner.querySelector('#st-music')?.addEventListener('change', e => { audioState.music = (e.target as HTMLInputElement).checked; });
|
||||||
|
inner.querySelector('#st-vol')?.addEventListener('input', e => setVolume(parseInt((e.target as HTMLInputElement).value) / 100));
|
||||||
|
inner.querySelector('#st-close')?.addEventListener('click', () => d.remove());
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
void SPELLS;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
|
build: { target: 'es2022', chunkSizeWarningLimit: 1500 },
|
||||||
|
server: { port: 5173, host: true, allowedHosts: ['.trycloudflare.com'] },
|
||||||
|
preview: { port: 4173, host: true, allowedHosts: ['.trycloudflare.com'] }
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user