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:
deepseek
2026-08-23 07:00:05 +00:00
commit b642726428
38 changed files with 7288 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.log
.DS_Store
/tmp
+100
View File
@@ -0,0 +1,100 @@
# IMPERIUM ARCANUM ⚔️🔮
**A Caesar 3style 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
View File
@@ -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>
+23
View File
@@ -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"
]
}
}
+615
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
esbuild: set this to true or false
onlyBuiltDependencies:
- esbuild
+35
View File
@@ -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);
+8
View File
@@ -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');
+126
View File
@@ -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);
+42
View File
@@ -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`); }
+89
View File
@@ -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);
+98
View File
@@ -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();
+107
View File
@@ -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
View File
@@ -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));
}
+129
View File
@@ -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;
}
+93
View File
@@ -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;
}
}
+53
View File
@@ -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);
}
}
+97
View File
@@ -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]; }
+2
View File
@@ -0,0 +1,2 @@
// ─── Heroes roster ──────────────────────────────────────────────────────────
export { HEROES } from '../sim/types';
+38
View File
@@ -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',
];
+88
View File
@@ -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: '',
};
}
+3
View File
@@ -0,0 +1,3 @@
import { bootApp } from './app';
bootApp();
+39
View File
@@ -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();
}
}
+47
View File
@@ -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 }) => {},
};
+810
View File
@@ -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;
}
+793
View File
@@ -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
View File
@@ -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); }
+475
View File
@@ -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;
}
+792
View File
@@ -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;
}
+21
View File
@@ -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);
}
+128
View File
@@ -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
View File
@@ -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;
}
}
+264
View File
@@ -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>>;
}
+361
View File
@@ -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
View File
@@ -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
View File
@@ -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">14</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;
}
+17
View File
@@ -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"]
}
+7
View File
@@ -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'] }
});