commit b64272642814707bd6b15cb5401201d1668cb75b Author: deepseek Date: Sun Aug 23 07:00:05 2026 +0000 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cdcf8ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +.DS_Store +/tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfcba06 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# IMPERIUM ARCANUM โš”๏ธ๐Ÿ”ฎ + +**A Caesar 3โ€“style city builder with heroes, magic and monsters โ€” running entirely in your browser.** + +Inspired by [Caesar 3](https://en.wikipedia.org/wiki/Caesar_III) and its open-source remaster +[Augustus / Julius](https://github.com/keriew/augustus), rebuilt from scratch as a modern web game โ€” then +extended with a full **heroes & magic** fantasy layer. + +--- + +## โ–ถ Play + +```bash +pnpm install +pnpm dev # โ†’ http://localhost:5173 +``` + +Production build: + +```bash +pnpm build # outputs dist/ (single-page, static hosting ready) +pnpm preview # serve the production bundle +``` + +No backend. No accounts. Saves live in your browser's local storage. + +--- + +## ๐Ÿ›๏ธ Classic city-builder depth + +| System | Details | +| --- | --- | +| **Roads & walkers** | Buildings may be placed anywhere; connect them with roads to employ workers. Prefects, priests, actors, teachers, doctors and tax collectors patrol as animated walkers | +| **Housing evolution** | 11 tiers from Small Tent to Estate โ€” water โ†’ food variety โ†’ religion โ†’ entertainment โ†’ education โ†’ health + luxury goods + desirability | +| **Food chain** | Farms/orchards/fisheries โ†’ carts โ†’ granary โ†’ market buyers โ†’ peddlers feed homes; famine starves citizens and stalls growth | +| **Industry chains** | Clayโ†’Pottery ยท Timberโ†’Furniture ยท Olivesโ†’Oil ยท Grapesโ†’Wine ยท Iron+Timberโ†’Weapons โ€” warehouses store, markets distribute | +| **Trade** | Open routes with foreign cities, toggle per-good import/export lists, quarterly caravans, price factors per partner | +| **Treasury** | Tax rates, wages, forums/senate collection coverage, unemployment and sentiment feedback | +| **Ratings & victory** | Culture / Prosperity / Peace / Favor ratings drive scenario goals across the 5-mission campaign | +| **Disasters** | Fire spreads tile-to-tile until prefects douse it; buildings collapse without engineers | + +## โš”๏ธ Heroes + +Build the **Hall of Heroes** and summon legends: + +- **Aurelius the Champion** โ€” unbreakable melee tank +- **Lyra the Huntress** โ€” swift ranged killer +- **Magnus the Stormcaller** โ€” area devastation, +industry aura + +Heroes gain XP, level up (more HP/damage), passively boost nearby industry, +fall in battle and return from the underworld after a few months. + +## ๐Ÿ”ฎ Magic + +Temples trickle mana; **Ley Pylons on crystal seams**, mage towers and the **Arcanum** make you rich in it. + +Spells (bottom bar): ๐ŸŒฟ Blessing of Ceres ยท ๐Ÿ”ฅ Firestorm ยท ๐Ÿ’ง Healing Rain ยท ๐Ÿ’จ Hermes' Haste ยท +๐Ÿ—ฟ Summon Golem ยท ๐Ÿ›ก๏ธ Aegis Ward ยท ๐Ÿ”ฎ Divine Insight. +The Great Library and Arcanum research advanced spells. + +## ๐Ÿ‘น Monsters + +Hydra, Minotaur, Harpy flocks and the Lich King (who raises skeletons) attack in scripted campaign waves +and scaling sandbox raids. Answer with walls, towers, legions trained from weapons, golems, heroes and spells. + +## ๐Ÿ—บ๏ธ Content + +- **5 campaign scenarios** โ€” farming village โ†’ breadbasket โ†’ ley-line awakening โ†’ iron frontier โ†’ eternal city of magic +- **Endless sandbox** with prosperity-scaled monster waves +- Procedural maps: rivers, forests, clay, iron, crystal ley lines, fertile meadows + +--- + +## ๐ŸŽฎ Controls + +| Input | Action | +| --- | --- | +| Left click | Place / select ยท drag paints roads, walls, plazas, gardens | +| Right-drag / WASD | Pan camera | +| Mouse wheel | Zoom | +| `Space` | Pause | +| `1` โ€“ `4` | Game speed | +| `Esc` | Cancel tool / close panel | + +Overlays: fire risk, water coverage, desirability, farm fertility. + +## ๐Ÿ› ๏ธ Tech + +- TypeScript + Vite, zero runtime dependencies +- Canvas2D isometric renderer with procedurally drawn sprites (no art assets) +- Deterministic seeded maps, BFS/A* pathfinding, monthly-tick economy simulation +- WebAudio synthesized music & SFX +- Test suite: headless sim test (`scripts/simtest.ts`) + Playwright E2E (`scripts/e2e-test.mjs`, `scripts/combat-e2e.mjs`) + +```bash +node scripts/build-simtest.mjs && node /tmp/simtest.mjs # sim balance smoke test +BASE=http://localhost:5173/ node scripts/e2e-test.mjs # full browser E2E +``` + +*Made with โš–๏ธ by an open hand โ€” have fun, Governor.* diff --git a/index.html b/index.html new file mode 100644 index 0000000..b85eca3 --- /dev/null +++ b/index.html @@ -0,0 +1,34 @@ + + + + + +Imperium Arcanum โ€” City Builder of Legends + + + + +
+ +
+
+
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..4a47ef6 --- /dev/null +++ b/package.json @@ -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" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..687d78d --- /dev/null +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..d508289 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + esbuild: set this to true or false +onlyBuiltDependencies: + - esbuild diff --git a/scripts/browser-test.mjs b/scripts/browser-test.mjs new file mode 100644 index 0000000..0048685 --- /dev/null +++ b/scripts/browser-test.mjs @@ -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); diff --git a/scripts/build-simtest.mjs b/scripts/build-simtest.mjs new file mode 100644 index 0000000..1715efa --- /dev/null +++ b/scripts/build-simtest.mjs @@ -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'); diff --git a/scripts/combat-e2e.mjs b/scripts/combat-e2e.mjs new file mode 100644 index 0000000..1f5b433 --- /dev/null +++ b/scripts/combat-e2e.mjs @@ -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); diff --git a/scripts/dbg.ts b/scripts/dbg.ts new file mode 100644 index 0000000..df424a7 --- /dev/null +++ b/scripts/dbg.ts @@ -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{ + 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`); } diff --git a/scripts/e2e-test.mjs b/scripts/e2e-test.mjs new file mode 100644 index 0000000..7e50714 --- /dev/null +++ b/scripts/e2e-test.mjs @@ -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); diff --git a/scripts/repro-build.mjs b/scripts/repro-build.mjs new file mode 100644 index 0000000..4ed6a31 --- /dev/null +++ b/scripts/repro-build.mjs @@ -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(); diff --git a/scripts/simtest.ts b/scripts/simtest.ts new file mode 100644 index 0000000..72aa493 --- /dev/null +++ b/scripts/simtest.ts @@ -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 = [ + [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'); diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..e026eea --- /dev/null +++ b/src/app.ts @@ -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(); + 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).__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)); +} diff --git a/src/audio/audio.ts b/src/audio/audio.ts new file mode 100644 index 0000000..be653d7 --- /dev/null +++ b/src/audio/audio.ts @@ -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 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; +} diff --git a/src/core/pathfind.ts b/src/core/pathfind.ts new file mode 100644 index 0000000..d5f48a5 --- /dev/null +++ b/src/core/pathfind.ts @@ -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; + } +} diff --git a/src/core/util.ts b/src/core/util.ts new file mode 100644 index 0000000..39c215c --- /dev/null +++ b/src/core/util.ts @@ -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(arr: readonly T[]): T { return arr[Math.floor(this.next() * arr.length)]; } + chance(p: number): boolean { return this.next() < p; } + shuffle(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> { + private map = new Map void>>(); + on(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: K, p: E[K]) { + const s = this.map.get(k); + if (s) for (const fn of [...s]) fn(p as never); + } +} diff --git a/src/data/buildings.ts b/src/data/buildings.ts new file mode 100644 index 0000000..579e8fd --- /dev/null +++ b/src/data/buildings.ts @@ -0,0 +1,97 @@ +// โ”€โ”€โ”€ Building definitions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +import type { BDef, BuildCategory } from '../sim/types'; + +const D = (d: Partial & { 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 = 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]; } diff --git a/src/data/heroes.ts b/src/data/heroes.ts new file mode 100644 index 0000000..f43522a --- /dev/null +++ b/src/data/heroes.ts @@ -0,0 +1,2 @@ +// โ”€โ”€โ”€ Heroes roster โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export { HEROES } from '../sim/types'; diff --git a/src/data/housing.ts b/src/data/housing.ts new file mode 100644 index 0000000..8caa067 --- /dev/null +++ b/src/data/housing.ts @@ -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', +]; diff --git a/src/data/scenarios.ts b/src/data/scenarios.ts new file mode 100644 index 0000000..047867e --- /dev/null +++ b/src/data/scenarios.ts @@ -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: '', + }; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..c9b464d --- /dev/null +++ b/src/main.ts @@ -0,0 +1,3 @@ +import { bootApp } from './app'; + +bootApp(); diff --git a/src/render/camera.ts b/src/render/camera.ts new file mode 100644 index 0000000..61b592a --- /dev/null +++ b/src/render/camera.ts @@ -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(); + } +} diff --git a/src/render/fx.ts b/src/render/fx.ts new file mode 100644 index 0000000..7253990 --- /dev/null +++ b/src/render/fx.ts @@ -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 }) => {}, +}; diff --git a/src/render/renderer.ts b/src/render/renderer.ts new file mode 100644 index 0000000..6c0040b --- /dev/null +++ b/src/render/renderer.ts @@ -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 = { + 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(); + 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, 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; +} diff --git a/src/render/sprites.ts b/src/render/sprites.ts new file mode 100644 index 0000000..e71ae95 --- /dev/null +++ b/src/render/sprites.ts @@ -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(); + +// โ”€โ”€ 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; +} diff --git a/src/sim/city.ts b/src/sim/city.ts new file mode 100644 index 0000000..a6d6918 --- /dev/null +++ b/src/sim/city.ts @@ -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(); + +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 = { 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) { + 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(); + 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(); + 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); } diff --git a/src/sim/combat.ts b/src/sim/combat.ts new file mode 100644 index 0000000..88020ff --- /dev/null +++ b/src/sim/combat.ts @@ -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 = { + // 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 = { 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(); +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; +} diff --git a/src/sim/economy.ts b/src/sim/economy.ts new file mode 100644 index 0000000..33897d9 --- /dev/null +++ b/src/sim/economy.ts @@ -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 = { 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)[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; +} diff --git a/src/sim/hooks.ts b/src/sim/hooks.ts new file mode 100644 index 0000000..df984e6 --- /dev/null +++ b/src/sim/hooks.ts @@ -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); +} diff --git a/src/sim/magic.ts b/src/sim/magic.ts new file mode 100644 index 0000000..baac296 --- /dev/null +++ b/src/sim/magic.ts @@ -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; +} diff --git a/src/sim/save.ts b/src/sim/save.ts new file mode 100644 index 0000000..9d43ae3 --- /dev/null +++ b/src/sim/save.ts @@ -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) + .map(([slot, meta]) => ({ slot, meta })) + .sort((a, b) => b.meta.at - a.meta.at); + } catch { return []; } +} + +function allSlots(): Record { + 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 & { + ratings: CityState['ratings']; stats: CityState['stats']; + spells: string[]; cooldowns: Record; + buffs: { hasteUntil: number; shieldUntil: number }; + trades: CityState['trades']; messages: CityState['messages']; + spellAreas?: Array<{ x: number; y: number; r: number; until: number }>; + }; + buildings: Array & { 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)[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; + } +} diff --git a/src/sim/types.ts b/src/sim/types.ts new file mode 100644 index 0000000..ed4616e --- /dev/null +++ b/src/sim/types.ts @@ -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 = { + 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 = { + 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 = { + 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>; // 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>; + 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>; + serviceT: Partial>; // 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>; // house luxury goods + hungerMonths?: number; + fetchQueue: Good[]; // raw goods requested + lastTaxedMonth?: number; + heroCooldowns?: Partial>; + spellCooldowns?: Partial>; + 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>; + 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; + nextBldId: number; + walkers: Map; + nextWalkerId: number; + tiles: Tile[]; // W*H + messages: Message[]; + nextMsgId: number; + trades: TradeCity[]; + spellState: { + unlocked: Set; + cooldowns: Record; // 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>; +} diff --git a/src/sim/walkers.ts b/src/sim/walkers.ts new file mode 100644 index 0000000..bd71375 --- /dev/null +++ b/src/sim/walkers.ts @@ -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 & { 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 = { + 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> = {}; + 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; +} diff --git a/src/ui/hud.ts b/src/ui/hud.ts new file mode 100644 index 0000000..2fe0cef --- /dev/null +++ b/src/ui/hud.ts @@ -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 = { + 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 = {}; + +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 = ` +
๐Ÿ’ฐ
+
๐Ÿ‘ฅ
+
๐Ÿ“…
+
๐Ÿ˜Š
+
๐Ÿ…
+
๐Ÿ”ฎ
+
โš–๏ธ
+ `; + 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 = `${icn}${d.name}${sizeLabel(d.w)} ยท ${d.workers ? d.workers + ' workers' : 'no workers'}${d.unique ? ' ยท unique' : ''}${d.cost}๐Ÿ’ฐ`; + 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', `โŒClear / Demolishremoves buildings & rubble+25%`); + 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}${sd.mana}`); + 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 = { + 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(`

${d.name}

`); + parts.push(`
${d.blurb}
`); + if (b.ruin) { + parts.push(`
โ˜ ๏ธ Ruins โ€” clear them to rebuild.
`); + } else { + if (d.workers) parts.push(`
๐Ÿ‘ท Workers: ${b.workersLive}/${d.workers}${b.workersLive === 0 ? ' โ€” no staff!' : ''}
`); + if (b.noRoad) parts.push(`
โ›” Not connected to a road โ€” build a road next to it to put it to work.
`); + if (d.produces) { + const g = GOODS[d.produces.good]; + parts.push(`
${g.icon} Output stored here: ${Math.floor(b.inv[d.produces.good] ?? 0)}
`); + for (const [k, v] of Object.entries(d.consumes ?? {})) { + parts.push(`
needs ${GOODS[k as Good].icon} ${GOODS[k as Good].name}: ${Math.floor(b.inv[k as Good] ?? 0)}/${v} buffered
`); + } + } + if (d.storage) { + const tot = Object.values(b.inv).reduce((a, v) => a + (v ?? 0), 0); + parts.push(`
๐Ÿบ Contents (${tot}/${d.storage.capacity}):
`); + for (const [k, v] of Object.entries(b.inv)) { + if ((v ?? 0) > 0) parts.push(`${GOODS[k as Good].icon} ${Math.floor(v ?? 0)}`); + } + parts.push('
'); + } + if (d.housing !== undefined) { + const tier = HOUSE_TIERS[Math.min(b.houseLevel, HOUSE_TIERS.length - 1)]; + parts.push(`
๐Ÿ  ${tier.name} โ€” ๐Ÿ‘ฅ ${b.residents}/${tier.maxResidents}
`); + 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(`
pantry: ${foods}
`); + } + if ((b.hungerMonths ?? 0) > 0) parts.push(`
โš  Hunger for ${b.hungerMonths} months!
`); + } + if (b.burning > 0) parts.push(`
๐Ÿ”ฅ ON FIRE!
`); + if (d.tower || d.mageTower) parts.push(`
๐ŸŽฏ Defenses manned${b.workersLive ? '' : ' โ€” unstaffed!'}
`); + if (d.heroHall) { + parts.push(`
Summon a legend:
`); + for (const hid of Object.keys(HEROES) as Array) { + const hd = HEROES[hid]; + parts.push(``); + } + } + if (d.fort || b.def === 'barracks') { + parts.push(``); + } + } + parts.push(`
+ + ${!b.ruin ? `` : ''} +
`); + ip.innerHTML = parts.join(''); + + (window as unknown as Record).__ia_close = () => { ip.style.display = 'none'; app.view.selectedBld = 0; }; + (window as unknown as Record).__ia_demolish = () => { removeBuilding(app.city, b.x, b.y); hideInfoPanel(); }; + (window as unknown as Record).__ia_summon = (hid: string) => app.summonHero(hid as never); + (window as unknown as Record).__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'; +} + diff --git a/src/ui/menus.ts b/src/ui/menus.ts new file mode 100644 index 0000000..1dd2333 --- /dev/null +++ b/src/ui/menus.ts @@ -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 = ` +

IMPERIUM ARCANUM

+
A city-builder of legends โ€” roads, legions, heroes and spells. Governor, the province awaits.
+ + + + + +
Runs entirely in your browser ยท saves in local storage ยท inspired by Caesar 3 & Augustus
+ `; + (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 = '

Campaign

'; + CAMPAIGN.forEach((sc, i) => { + const card = document.createElement('div'); + card.className = 'scen-card'; + card.innerHTML = `${sc.name} โ€” ${sc.subtitle}
${sc.brief.slice(0, 110)}โ€ฆ
Goals: ${sc.goals.map(g => `${GOAL_LABEL[g.kind] ?? g.kind} ${g.amount ?? ''}`).join(' ยท ') || 'sandbox'}`; + 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 = '

Chronicles

'; + const saves = listSaves(); + if (!saves.length) inner.innerHTML += '
No chronicles found yet. They are written automatically every few months.
'; + for (const { slot, meta } of saves) { + const row = document.createElement('button'); + row.className = 'mbtn'; + row.innerHTML = `๐Ÿ“œ ${meta.scenarioName} โ€” pop ${fmt.format(meta.population)}, treasury ${fmt.format(meta.funds)}${new Date(meta.at).toLocaleString()}`; + 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 = ` +

Paused

+ + + + + + + + + + + `; + (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 ? ` +

๐Ÿ† VICTORY

+

The Senate showers you in laurels. ${app.scenario.reward ?? ''}

+ + + ` : ` +

โ˜ ๏ธ DEFEAT

+

${reason ?? 'The province has fallen.'}

+ + `; + 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 = {}; + 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 }).__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 = ` +

Labor Advisor

+
Workforce: ${c.laborers} ยท Employed: ${c.employed} ยท Unemployed: ${Math.round(c.unemploymentPct)}%
+
+
+ ${Object.entries(byCat).sort((a, b) => b[1] - a[1]).map(([cat, n]) => `
${cat}${n} workers
`).join('')} +
+
Tip: unemployment above ~18% hurts Prosperity, but keep some slack or new industry will starve.
+ + `; + }); + // 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 = ` +

Trade & Empire

+
Requires a staffed Trading Post. Caravans arrive each quarter. Exports sell surplus above 120 units.
+ + ${c.trades.map((tc, i) => ` +
+ ๐Ÿ› ${tc.name} โ€” route ${tc.open ? 'open' : 'closed'} ยท prices ร—${tc.priceFactor.toFixed(2)} +
Exports: ${goodsList.map(g => + `${GOODS[g].icon} ${GOODS[g].name} โ–ธ${Math.round(GOODS[g].basePrice * tc.priceFactor * 0.85)}`).join('')}
+
Imports: ${goodsList.map(g => + `${GOODS[g].icon} โ—‚${Math.round(GOODS[g].basePrice * tc.priceFactor * 1.15)}`).join('')}
+
`).join('')} + + `; + 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 = ` +

Ratings & Goals

+
+ ${(['culture', 'prosperity', 'peace', 'favor'] as const).map(k => ` +
${k[0].toUpperCase() + k.slice(1)}${c.ratings[k]}
+
`).join('')} +
+

Mission goals

+ ${(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 `
${done ? 'โœ…' : 'โฌœ'} ${GOAL_LABEL[g.kind] ?? g.kind}${Math.floor(p)} / ${g.amount ?? ''}
`; + }).join('') || '
Free province โ€” no fixed goals. Prosper!
'} +
Monsters slain: ${c.stats.monstersSlain} ยท Invaders slain: ${c.stats.invadersSlain} ยท Deaths: ${c.stats.totalDeaths}
+ + `; +} + +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 = ` +

Hall of Heroes

+ ${hasHall ? '' : '
You need a built Hall of Heroes to summon legends.
'} + ${Object.values(HEROES).map(hd => ` +
+ ${hd.icon} ${hd.name} ${hd.title}
+ ${hd.desc}
+ HP ${hd.hp} ยท DMG ${hd.dmg} ยท ${hd.ranged ? 'ranged' : 'melee'} ยท aura +${hd.auraProd}% nearby production
+ +
`).join('')} + + `; + 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 = ` +

Message Archive

+ ${app.city.messages.slice(0, 60).map(m => `
${m.icon} ${m.text}Yr ${m.year + 1}
`).join('') || '
No messages yet.
'} + + `; +} + +export function showHelp(fromMain: boolean) { + const d = overlay('help-modal'); + (d.firstChild as HTMLElement).innerHTML = ` +

How to play

+
+

Found a town: 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.

+

Growth: houses evolve when given water โ†’ food โ†’ religion โ†’ entertainment โ†’ education โ†’ health and luxury goods (pottery, furniture, oil, wine). Higher housing pays more taxes.

+

Defense: 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.

+

Magic: 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.

+

Heroes: build the Hall of Heroes to summon Aurelius, Lyra or Magnus. They fight monsters, level up, and boost nearby industry.

+

Controls: LMB place/select ยท drag to paint roads/walls ยท RMB drag pan ยท wheel zoom ยท WASD pan ยท Space pause ยท 1โ€“4 speed ยท Esc cancel tool.

+
+ + `; + 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 = ` +

Settings

+ + + + + `; + 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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..01065d4 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..6264949 --- /dev/null +++ b/vite.config.ts @@ -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'] } +});