commit 56bf3fa2a23e3b2ea6bf23ce6dced446c66230a9 Author: PolyCity Date: Sat Aug 22 19:17:10 2026 +0000 PolyCity v1.0 โ€” full-featured 3D city-builder (Three.js + Vite) - Procedural island maps, RCI zoning, demand-driven growth - City-wide power grid with brownouts; wind coastal bonus - Land value, upgrades, services, pollution, fires & fire spread - Budget/taxes, happiness, milestones, quest onboarding - Traffic agents, day/night cycle, adaptive render scale - Saves: autosave + 3 slots + JSON export/import - PWA (offline), GitHub Pages/Netlify deploy configs - Tests: 40-assertion engine suite + headless E2E diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..74c0db7 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,43 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - uses: actions/upload-pages-artifact@v3 + with: + path: dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aed094d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.DS_Store +*.log +.vite/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..18254e2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 PolyCity contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..998287e --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# ๐Ÿ™๏ธ PolyCity โ€” 3D City Builder + +**PolyCity** is a full-featured, free, open-source city-building simulation that runs +entirely in your browser. Zone districts, wire up power, keep citizens happy, fight +fires, balance the budget โ€” and grow a tiny island outpost into a glittering megalopolis. + +Built with **Three.js + Vite**, zero runtime dependencies beyond the engine, +procedural graphics (no asset downloads), and a simulation modeled on the classics. + +![gameplay](docs/screenshot.png) + +## โœจ Features + +| | | +|---|---| +| ๐Ÿ—บ๏ธ **Procedural islands** | Every new map is unique โ€” winding rivers, beaches, forests | +| ๐Ÿ˜๏ธ **RCI zoning** | Residential / commercial / industrial with demand-driven growth | +| โšก **Power grid** | Coal, solar & wind plants; brownouts when demand exceeds supply; wind turbines get +50 % on the coast | +| ๐Ÿ“ˆ **Land value & upgrades** | Parks, plazas, services and waterfronts raise land value; buildings grow through 3 density tiers | +| ๐Ÿš“ **City services** | Police, fire stations, hospitals and schools shape happiness and value | +| ๐Ÿ”ฅ **Disasters** | Pollution-driven fires spread, burn buildings to rubble and are contained by fire coverage | +| ๐Ÿ’ฐ **Budget & taxes** | Live tax slider, itemized income/upkeep ledger, debt warnings | +| ๐Ÿš— **Traffic** | Animated cars flow along your road network | +| ๐ŸŒ— **Day/night cycle** | Windows light up as the sun sets | +| ๐ŸŽฏ **Goals & milestones** | From *Outpost* to *Megalopolis*, with an onboarding quest list | +| ๐Ÿ’พ **Saves** | Autosave, 3 manual slots, JSON export/import | +| ๐Ÿ“ฑ **Touch support** | Paint with one finger, pinch-zoom, two-finger rotate | +| ๐Ÿ”Œ **PWA** | Installable, offline-capable via service worker | + +## ๐ŸŽฎ Controls + +| Action | Input | +|---|---| +| Use tool / paint | **Left-drag** or one finger | +| Orbit camera | **Right-drag** or two fingers | +| Pan | **Middle-drag**, or `WASD` / arrow keys (`Shift` = faster) | +| Zoom | Mouse wheel / pinch | +| Pause ยท speed | `Space` ยท `1` `2` `3` | +| Tools | `Q` inspect ยท `B` bulldoze ยท `R` road ยท `Z/X/C` zones | +| Close / cancel | `Esc` ยท Help: `H` | + +### How to play in 30 seconds +1. Drag an **L-shaped road** from the coast inland. +2. Paint **homes** beside it, then some **shops** and **industry**. +3. Drop a **coal plant** anywhere โ€” power is a city-wide grid. +4. Press โ–ถโ–ถ and watch the neighborhood fill in. +5. Add police/fire/parks to push land value past the upgrade thresholds. + +## ๐Ÿš€ Run it locally + +```bash +pnpm install +pnpm dev # dev server โ†’ http://localhost:5173 +``` + +## ๐Ÿ“ฆ Production build + +```bash +pnpm build # outputs static site to dist/ +pnpm preview # serve dist/ locally +``` + +The build is fully static and relative-path based โ€” drop `dist/` on any host. + +## ๐ŸŒ Publish it + +**GitHub Pages** โ€” this repo ships `.github/workflows/deploy.yml`; push to `main` +and enable Pages (*Settings โ†’ Pages โ†’ Source: GitHub Actions*). + +**Netlify / Vercel / Cloudflare Pages** โ€” import the repo, build command +`pnpm build`, publish directory `dist`. A ready-made [`netlify.toml`](netlify.toml) +is included. + +**itch.io** โ€” zip the contents of `dist/` (keep `index.html` at the zip root), +upload as an HTML5 game, check *sharedarraybuffer is not needed* and any +resolution; the game auto-fits. + +## ๐Ÿงช Tests + +```bash +node tests/engine.mjs # 40-assertion simulation suite (pure Node) +node tests/smoke.mjs # headless-browser end-to-end run +node tests/capture.mjs # renders promo screenshots into shots/ +``` + +## ๐Ÿ› ๏ธ Tech notes + +- One shared `MeshStandardMaterial` + instanced meshes draw the entire city in a + handful of draw calls; geometry is merged & vertex-colored at load time. +- Simulation runs on typed arrays (64ร—64 tiles) with monthly ticks decoupled + from rendering; speeds pause / 1ร— / 2ร— / 3ร—. +- Adaptive resolution keeps frame times sane on weak GPUs. +- All audio is synthesized WebAudio โ€” no assets, no tracking, no network calls. + +## License + +[MIT](LICENSE) โ€” build, remix, ship it. ๐Ÿ—๏ธ diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..b657869 Binary files /dev/null and b/docs/screenshot.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..ffe287a --- /dev/null +++ b/index.html @@ -0,0 +1,74 @@ + + + + + + PolyCity โ€” 3D City Builder + + + + + + + + + +
+ + +
+
+
+
+ New City + +
+
๐Ÿ’ฐ$0
+
๐Ÿ‘ฅ0
+
๐Ÿ™‚โ€”
+
๐Ÿ“…Jan 2026
+ +
+
+
+ + + + +
+ + + + +
+
+ + + +
+ + + + +
+ + + + + +
+ +
+
+

POLYCITY

+

Building your worldโ€ฆ

+
+
+
+
+ + + + diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..16e8a9a --- /dev/null +++ b/netlify.toml @@ -0,0 +1,16 @@ +[build] + command = "pnpm build" + publish = "dist" + +[build.environment] + NODE_VERSION = "24" + +[[headers]] + for = "/sw.js" + [headers.values] + Cache-Control = "no-cache" + +[[headers]] + for = "/assets/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" diff --git a/package.json b/package.json new file mode 100644 index 0000000..a774604 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "polycity", + "private": true, + "version": "1.0.0", + "type": "module", + "description": "PolyCity \u2014 a full-featured 3D city-builder simulation that runs entirely in your browser.", + "keywords": [ + "city-builder", + "simulation", + "3d", + "threejs", + "game" + ], + "license": "MIT", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "node tests/engine.mjs", + "test:browser": "node tests/smoke.mjs", + "shots": "node tests/capture.mjs" + }, + "dependencies": { + "three": "^0.185.1" + }, + "devDependencies": { + "playwright-core": "^1.62.1", + "vite": "^8.2.2" + } +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..c6b7e8a --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,459 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + three: + specifier: ^0.185.1 + version: 0.185.1 + devDependencies: + playwright-core: + specifier: ^1.62.1 + version: 1.62.1 + vite: + specifier: ^8.2.2 + version: 8.2.2 + +packages: + + '@oxc-project/types@0.146.0': + resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + + '@rolldown/binding-android-arm-eabi@1.2.5': + resolution: {integrity: sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.5': + resolution: {integrity: sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.5': + resolution: {integrity: sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.5': + resolution: {integrity: sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.5': + resolution: {integrity: sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + resolution: {integrity: sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + resolution: {integrity: sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.5': + resolution: {integrity: sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + resolution: {integrity: sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + resolution: {integrity: sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.5': + resolution: {integrity: sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.5': + resolution: {integrity: sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.5': + resolution: {integrity: sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + resolution: {integrity: sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.5': + resolution: {integrity: sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: 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] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + 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==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + 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} + + rolldown@1.2.5: + resolution: {integrity: sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + three@0.185.1: + resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@oxc-project/types@0.146.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.5': + optional: true + + '@rolldown/binding-android-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-x64@1.2.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.5': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + detect-libc@2.1.2: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + nanoid@3.3.18: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + playwright-core@1.62.1: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.2.5: + dependencies: + '@oxc-project/types': 0.146.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.5 + '@rolldown/binding-android-arm64': 1.2.5 + '@rolldown/binding-darwin-arm64': 1.2.5 + '@rolldown/binding-darwin-x64': 1.2.5 + '@rolldown/binding-freebsd-x64': 1.2.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.5 + '@rolldown/binding-linux-arm64-gnu': 1.2.5 + '@rolldown/binding-linux-arm64-musl': 1.2.5 + '@rolldown/binding-linux-ppc64-gnu': 1.2.5 + '@rolldown/binding-linux-s390x-gnu': 1.2.5 + '@rolldown/binding-linux-x64-gnu': 1.2.5 + '@rolldown/binding-linux-x64-musl': 1.2.5 + '@rolldown/binding-openharmony-arm64': 1.2.5 + '@rolldown/binding-win32-arm64-msvc': 1.2.5 + '@rolldown/binding-win32-x64-msvc': 1.2.5 + + source-map-js@1.2.1: {} + + three@0.185.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + vite@8.2.2: + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.5 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 diff --git a/public/icon-maskable.svg b/public/icon-maskable.svg new file mode 100644 index 0000000..e5ce21b --- /dev/null +++ b/public/icon-maskable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..89529af --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..4d264ce --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "PolyCity โ€” 3D City Builder", + "short_name": "PolyCity", + "description": "Zone it, power it, grow it. A full 3D city-builder simulation in your browser.", + "start_url": "./", + "scope": "./", + "display": "fullscreen", + "orientation": "any", + "background_color": "#0b1020", + "theme_color": "#0b1020", + "icons": [ + { "src": "./icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }, + { "src": "./icon-maskable.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "maskable" } + ] +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..ca9689b --- /dev/null +++ b/public/sw.js @@ -0,0 +1,38 @@ +/* PolyCity service worker โ€” offline-first app shell */ +const CACHE = 'polycity-v1'; +const PRECACHE = ['./', './index.html', './manifest.webmanifest', './icon.svg', './icon-maskable.svg']; + +self.addEventListener('install', (e) => { + e.waitUntil( + caches.open(CACHE).then((c) => c.addAll(PRECACHE)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (e) => { + const url = new URL(e.request.url); + if (e.request.method !== 'GET' || url.origin !== location.origin) return; + // cache-first for hashed assets, network-first for navigations + if (url.pathname.includes('/assets/')) { + e.respondWith( + caches.match(e.request).then((hit) => hit || fetch(e.request).then((res) => { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(e.request, copy)); + return res; + })) + ); + } else if (e.request.mode === 'navigate') { + e.respondWith( + fetch(e.request) + .then((res) => { caches.open(CACHE).then((c) => c.put('./index.html', res.clone())); return res; }) + .catch(() => caches.match('./index.html')) + ); + } +}); diff --git a/shots/scene-boot.png b/shots/scene-boot.png new file mode 100644 index 0000000..7cd79f4 Binary files /dev/null and b/shots/scene-boot.png differ diff --git a/shots/scene-day.png b/shots/scene-day.png new file mode 100644 index 0000000..b657869 Binary files /dev/null and b/shots/scene-day.png differ diff --git a/shots/scene-dusk.png b/shots/scene-dusk.png new file mode 100644 index 0000000..34c5a41 Binary files /dev/null and b/shots/scene-dusk.png differ diff --git a/shots/scene-night.png b/shots/scene-night.png new file mode 100644 index 0000000..179de36 Binary files /dev/null and b/shots/scene-night.png differ diff --git a/src/audio.js b/src/audio.js new file mode 100644 index 0000000..c953e20 --- /dev/null +++ b/src/audio.js @@ -0,0 +1,46 @@ +/** + * Tiny WebAudio SFX engine โ€” no assets, everything synthesized. + */ +export class Audio { + constructor(settings) { + this.settings = settings; + this.ctx = null; + } + + _ensure() { + if (!this.settings.sound) return null; + if (!this.ctx) { + try { + this.ctx = new (window.AudioContext || window.webkitAudioContext)(); + } catch { return null; } + } + if (this.ctx.state === 'suspended') this.ctx.resume(); + return this.ctx; + } + + _blip(freq, dur = 0.08, type = 'square', gain = 0.04, slide = 0) { + const ctx = this._ensure(); + if (!ctx) return; + const o = ctx.createOscillator(); + const g = ctx.createGain(); + o.type = type; + o.frequency.setValueAtTime(freq, ctx.currentTime); + if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(30, freq + slide), ctx.currentTime + dur); + g.gain.setValueAtTime(gain, ctx.currentTime); + g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur); + o.connect(g).connect(ctx.destination); + o.start(); + o.stop(ctx.currentTime + dur + 0.02); + } + + place() { this._blip(620, 0.07, 'square', 0.035, -180); } + bulldoze() { this._blip(190, 0.12, 'sawtooth', 0.03, -90); } + zone() { this._blip(430, 0.06, 'triangle', 0.04, 140); } + error() { this._blip(160, 0.09, 'square', 0.05); setTimeout(() => this._blip(120, 0.12, 'square', 0.05), 70); } + cash() { this._blip(880, 0.06, 'sine', 0.04); setTimeout(() => this._blip(1320, 0.09, 'sine', 0.035), 60); } + milestone() { + [523, 659, 784, 1047].forEach((f, i) => setTimeout(() => this._blip(f, 0.16, 'triangle', 0.05), i * 110)); + } + disaster() { this._blip(300, 0.4, 'sawtooth', 0.05, -220); } + click() { this._blip(700, 0.03, 'square', 0.02); } +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..9a36e50 --- /dev/null +++ b/src/config.js @@ -0,0 +1,127 @@ +// ============================================================ +// PolyCity โ€” central game configuration & balance data +// ============================================================ + +export const GRID = 64; +export const TILE = 1; // world units per tile +export const START_MONEY = 20000; + +export const ZONE = { NONE: 0, RES: 1, COM: 2, IND: 3 }; +export const STRUCT = { + NONE: 0, ROAD: 1, + COAL: 2, SOLAR: 3, WIND: 4, + POLICE: 5, FIRE: 6, HOSPITAL: 7, SCHOOL: 8, + PARK: 9, PLAZA: 10, STADIUM: 11 +}; + +export const ROAD_COST = 10; +export const BRIDGE_COST = 120; // road over water +export const BULLDOZE_COST = 3; +export const TAX_DEFAULT = 9; // percent + +// --- Power --------------------------------------------------- +export const ZONE_POWER = { 1: [3, 8, 18], 2: [5, 12, 26], 3: [8, 20, 44] }; + +// --- Zone capacities per level ------------------------------- +export const ZONE_POP = { 1: [6, 16, 36] }; +export const ZONE_JOBS = { 2: [8, 20, 42], 3: [10, 26, 52] }; + +// --- Land value thresholds for upgrading to level 2 / 3 ------ +export const LV_UPGRADE = { 2: 46, 3: 92 }; + +// ============================================================ +// Placeable buildings (services + utilities) +// ============================================================ +export const BUILDINGS = { + [STRUCT.ROAD]: { + id: 'road', name: 'Road', cost: ROAD_COST, bridgeCost: BRIDGE_COST, + w: 1, h: 1, upkeep: 1, cat: 'transport', + desc: 'Connects everything. Zones must be built within 2 tiles of a road. Conducts power.', + hotkey: 'r' + }, + [STRUCT.COAL]: { + id: 'coal', name: 'Coal Power Plant', cost: 3200, w: 2, h: 2, + upkeep: 220, power: 6000, pollution: 46, pollutionRadius: 11, radius: 0, + cat: 'power', desc: 'Cheap, dirty energy for ~6000 units. Keep it downwind of homes.', hotkey: '' + }, + [STRUCT.SOLAR]: { + id: 'solar', name: 'Solar Farm', cost: 4800, w: 2, h: 2, + upkeep: 90, power: 2400, pollution: 0, pollutionRadius: 0, radius: 0, + cat: 'power', desc: 'Clean energy for ~2400 units. Pricey upkeep, zero pollution.', hotkey: '' + }, + [STRUCT.WIND]: { + id: 'wind', name: 'Wind Turbine', cost: 1100, w: 1, h: 1, + upkeep: 40, power: 750, pollution: 0, pollutionRadius: 0, radius: 0, + cat: 'power', desc: 'Compact clean energy (~750). +50% output next to water.', hotkey: '' + }, + [STRUCT.POLICE]: { + id: 'police', name: 'Police Station', cost: 650, w: 1, h: 1, + upkeep: 90, radius: 14, cat: 'safety', + desc: 'Cuts crime nearby โ€” boosts land value & happiness in radius.', hotkey: '' + }, + [STRUCT.FIRE]: { + id: 'fire', name: 'Fire Station', cost: 650, w: 1, h: 1, + upkeep: 90, radius: 14, cat: 'safety', + desc: 'Extinguishes fires in radius fast and prevents most ignitions.', hotkey: '' + }, + [STRUCT.HOSPITAL]: { + id: 'hospital', name: 'Hospital', cost: 1500, w: 1, h: 1, + upkeep: 190, radius: 13, cat: 'health', + desc: 'Healthy citizens are happy citizens. Big residential boost.', hotkey: '' + }, + [STRUCT.SCHOOL]: { + id: 'school', name: 'School', cost: 1000, w: 1, h: 1, + upkeep: 130, radius: 13, cat: 'education', + desc: 'Educated workers attract better industry and raise land value.', hotkey: '' + }, + [STRUCT.PARK]: { + id: 'park', name: 'Small Park', cost: 160, w: 1, h: 1, + upkeep: 8, radius: 7, cat: 'leisure', + desc: 'A patch of green. Cheap land-value magic for dense blocks.', hotkey: '' + }, + [STRUCT.PLAZA]: { + id: 'plaza', name: 'Fountain Plaza', cost: 240, w: 1, h: 1, + upkeep: 12, radius: 7, cat: 'leisure', + desc: 'Classy marble plaza. Stronger boost than a park.', hotkey: '' + }, + [STRUCT.STADIUM]: { + id: 'stadium', name: 'Stadium', cost: 4200, w: 2, h: 2, + upkeep: 260, radius: 16, cat: 'leisure', + desc: 'The pride of the city โ€” huge happiness & land value aura.', hotkey: '' + } +}; + +// Buildings that must touch a road (within Chebyshev distance 2) to work. +export const NEEDS_ROAD = new Set([ + STRUCT.COAL, STRUCT.SOLAR, STRUCT.WIND, STRUCT.POLICE, STRUCT.FIRE, + STRUCT.HOSPITAL, STRUCT.SCHOOL, STRUCT.STADIUM +]); + +// ============================================================ +// Simulation tuning +// ============================================================ +export const SIM = { + msPerMonth: [0, 5000, 1800, 700], // by speed index 0..3 + growthAttemptsBase: 55, + upgradeSamplesPerMonth: 70, + fireBaseChance: 0.00045, + fireSpreadChance: 0.06, + abandonAfterBadPowerMonths: 2, + autosaveEveryMonths: 12, + milestonePops: [ + [0, 'Outpost'], [100, 'Settlement'], [500, 'Village'], [1500, 'Town'], + [5000, 'City'], [12000, 'Metropolis'], [30000, 'Megalopolis'] + ], + // tax income multipliers per month + taxRes: 3.0, taxCom: 3.2, taxInd: 2.8, + roadExpensePerTile: 1, + maxDebtWarning: -2000 +}; + +export const CARS = { max: 90, perPop: 1 / 90, perRoad: 1 / 7 }; + +export const SAVE_KEY = 'polycity.save.v1'; +export const SETTINGS_KEY = 'polycity.settings.v1'; +export const SEEN_HELP_KEY = 'polycity.helpSeen.v1'; + +export const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; diff --git a/src/events.js b/src/events.js new file mode 100644 index 0000000..c65e3af --- /dev/null +++ b/src/events.js @@ -0,0 +1,20 @@ +/** Minimal event bus. */ +export class EventBus { + constructor() { this._m = new Map(); } + on(evt, fn) { + if (!this._m.has(evt)) this._m.set(evt, []); + this._m.get(evt).push(fn); + return () => this.off(evt, fn); + } + off(evt, fn) { + const arr = this._m.get(evt); + if (!arr) return; + const i = arr.indexOf(fn); + if (i >= 0) arr.splice(i, 1); + } + emit(evt, payload) { + const arr = this._m.get(evt); + if (!arr) return; + for (let i = 0; i < arr.length; i++) arr[i](payload); + } +} diff --git a/src/game/city.js b/src/game/city.js new file mode 100644 index 0000000..0e4be7c --- /dev/null +++ b/src/game/city.js @@ -0,0 +1,775 @@ +import { + GRID, ZONE, STRUCT, BUILDINGS, NEEDS_ROAD, + ROAD_COST, BRIDGE_COST, BULLDOZE_COST, TAX_DEFAULT, + ZONE_POWER, ZONE_POP, ZONE_JOBS, LV_UPGRADE, SIM, START_MONEY, MONTHS +} from '../config.js'; +import { Grid } from './grid.js'; +import { EventBus } from '../events.js'; +import { clamp, mulberry32 } from '../utils.js'; + +// Power drawn by service buildings when hooked to the grid (cosmetic + capacity math). +const SERVICE_POWER_DRAW = { + [STRUCT.POLICE]: 5, [STRUCT.FIRE]: 5, [STRUCT.HOSPITAL]: 14, + [STRUCT.SCHOOL]: 9, [STRUCT.PARK]: 0, [STRUCT.PLAZA]: 1, [STRUCT.STADIUM]: 22 +}; + +export class City extends EventBus { + constructor(seed = (Math.random() * 2 ** 31) | 0, name = 'New City') { + super(); + this.seed = seed; + this.grid = new Grid(seed); + this.grid.generateTerrain(); + + this.name = name; + this.money = START_MONEY; + this.taxRate = TAX_DEFAULT; + this.monthIndex = 0; + this.milestoneIdx = 0; + + this.stats = { + pop: 0, jobs: 0, unemployment: 0, happiness: 70, + resDemand: 0.5, comDemand: 0.35, indDemand: 0.4, + powerUse: 0, powerCap: 0, brownouts: 0, + income: 0, expenses: 0, avgLandValue: 0 + }; + this.history = []; // {m, pop, funds, happy} + this.lastBudget = { taxRes: 0, taxCom: 0, taxInd: 0, upkeep: 0, roads: 0 }; + + // coverage / pollution maps + const n = this.grid.n; + this.mapPolice = new Float32Array(n); + this.mapFire = new Float32Array(n); + this.mapHealth = new Float32Array(n); + this.mapEdu = new Float32Array(n); + this.mapLeisure = new Float32Array(n); + this.pollution = new Float32Array(n); + + this.tilesDirty = true; // renderer should re-sync + this._roadGraphDirty = true; + this.roadGraph = { nodes: [], byTile: new Int32Array(n).fill(-1) }; + this.rng = mulberry32(seed ^ 0x5f3759df); + this._debtWarnedAt = -99; + } + + // ================= placement API ================= + + tileFree(i) { + return this.grid.struct[i] === 0 && this.grid.zone[i] === 0 && !this.grid.burning[i]; + } + + /** Validate footprint for a structure anchor at x,z. Returns {ok, reason, cost}. */ + canPlaceStruct(sid, x, z) { + const meta = BUILDINGS[sid]; + const g = this.grid; + let cost = 0; + if (!meta) return { ok: false, reason: 'Unknown building' }; + for (let dz = 0; dz < meta.h; dz++) { + for (let dx = 0; dx < meta.w; dx++) { + const tx = x + dx, tz = z + dz; + if (!g.inB(tx, tz)) return { ok: false, reason: 'Outside city limits' }; + const i = g.idx(tx, tz); + if (!this.tileFree(i)) return { ok: false, reason: 'Tile occupied โ€” bulldoze first' }; + if (g.terrain[i] === 1) { + if (sid === STRUCT.ROAD) cost += BRIDGE_COST; + else return { ok: false, reason: 'Cannot build on water' }; + } else { + cost += meta.cost !== undefined && sid === STRUCT.ROAD ? ROAD_COST : 0; + } + } + } + if (sid === STRUCT.ROAD) { + // single-tile cost model + const i = g.idx(x, z); + cost = g.terrain[i] === 1 ? BRIDGE_COST : ROAD_COST; + } else { + cost = meta.cost; + } + return { ok: true, reason: '', cost }; + } + + placeStruct(sid, x, z) { + const check = this.canPlaceStruct(sid, x, z); + if (!check.ok) return { ok: false, reason: check.reason }; + if (this.money < check.cost) return { ok: false, reason: 'Not enough funds' }; + const meta = BUILDINGS[sid]; + const g = this.grid; + const a = g.idx(x, z); + this.money -= check.cost; + for (let dz = 0; dz < meta.h; dz++) { + for (let dx = 0; dx < meta.w; dx++) { + const i = g.idx(x + dx, z + dz); + g.struct[i] = sid; + g.level[i] = 0; + g.variant[i] = 0; + g.age[i] = 0; + g.badMonths[i] = 0; + g.burning[i] = 0; + g.rubble[i] = 0; + g.scenery[i] = 0; + g.anchor[i] = a; + } + } + this.markTilesChanged(); + this.emit('money'); + return { ok: true }; + } + + /** Paint zones over a list of tiles (free action). Returns count placed. */ + placeZone(zid, tiles) { + const g = this.grid; + let placed = 0; + for (const [x, z] of tiles) { + if (!g.inB(x, z)) continue; + const i = g.idx(x, z); + if (g.terrain[i] !== 0) continue; + if (!this.tileFree(i)) continue; + if (g.zone[i] === zid) continue; + g.zone[i] = zid; + placed++; + } + if (placed > 0) { this.markTilesChanged(); } + return placed; + } + + /** Remove a zone designation (only on empty, undeveloped tiles). */ + clearZone(tiles) { + const g = this.grid; + let cleared = 0; + for (const [x, z] of tiles) { + if (!g.inB(x, z)) continue; + const i = g.idx(x, z); + if (g.zone[i] !== 0 && g.level[i] === 0 && !g.rubble[i]) { + g.zone[i] = ZONE.NONE; cleared++; + } + } + if (cleared) this.markTilesChanged(); + return cleared; + } + + demolish(x, z) { + const g = this.grid; + if (!g.inB(x, z)) return { ok: false, reason: 'Outside city limits' }; + const i = g.idx(x, z); + if (this.money < BULLDOZE_COST) return { ok: false, reason: 'Not enough funds' }; + + // multi-tile structure โ†’ clear whole footprint via its anchor + const ai = g.anchor[i]; + if (ai !== i || g.struct[i] !== 0) { + const sid = g.struct[ai]; + if (sid !== 0) { + const meta = BUILDINGS[sid]; + const ax = ai % g.size, az = (ai - ax) / g.size; + for (let dz = 0; dz < meta.h; dz++) { + for (let dx = 0; dx < meta.w; dx++) { + const j = g.idx(ax + dx, az + dz); + g.struct[j] = 0; g.level[j] = 0; g.variant[j] = 0; + g.age[j] = 0; g.badMonths[j] = 0; g.burning[j] = 0; g.anchor[j] = j; + } + } + } + } + // zone development / rubble / burning + if (g.level[i] > 0 || g.rubble[i] || g.burning[i]) { + g.level[i] = 0; g.variant[i] = 0; g.age[i] = 0; + g.badMonths[i] = 0; g.burning[i] = 0; g.rubble[i] = 0; + g.struct[i] = 0; g.anchor[i] = i; + } else if (g.zone[i] !== 0) { + g.zone[i] = ZONE.NONE; + } + this.money -= BULLDOZE_COST; + this.emit('money'); + this.markTilesChanged(); + return { ok: true }; + } + + markTilesChanged() { + this.tilesDirty = true; + this._roadGraphDirty = true; + this.emit('tilesChanged'); + } + + // ================= road graph (for cars) ================= + + rebuildRoadGraph() { + const g = this.grid, S = g.size; + const nodes = []; + const byTile = this.roadGraph.byTile.fill(-1); + for (let i = 0; i < g.n; i++) { + if (g.struct[i] === STRUCT.ROAD) { byTile[i] = nodes.length; nodes.push({ i, nbrs: [] }); } + } + for (let ni = 0; ni < nodes.length; ni++) { + const { i } = nodes[ni]; + const x = i % S, z = (i - x) / S; + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + for (const [dx, dz] of dirs) { + const nx = x + dx, nz = z + dz; + if (!g.inB(nx, nz)) continue; + const j = g.idx(nx, nz); + const nj = byTile[j]; + if (nj >= 0) nodes[ni].nbrs.push(nj); + } + } + this.roadGraph.nodes = nodes; + this._roadGraphDirty = false; + } + + ensureRoadGraph() { + if (this._roadGraphDirty) this.rebuildRoadGraph(); + return this.roadGraph; + } + + roadNear(x, z, r = 2) { + const g = this.grid; + for (let dz = -r; dz <= r; dz++) { + for (let dx = -r; dx <= r; dx++) { + const nx = x + dx, nz = z + dz; + if (!g.inB(nx, nz)) continue; + if (g.struct[g.idx(nx, nz)] === STRUCT.ROAD) return true; + } + } + return false; + } + + // ================= power network ================= + + recomputePower() { + const g = this.grid; + let cap = 0; + const plants = []; + + for (let i = 0; i < g.n; i++) { + const s = g.struct[i]; + if ((s === STRUCT.COAL || s === STRUCT.SOLAR || s === STRUCT.WIND) && i === g.anchor[i]) { + let p = BUILDINGS[s].power; + if (s === STRUCT.WIND) { + // coastal bonus: turbines near water spin harder + const x = i % g.size, z = (i - x) / g.size; + let waterN = 0; + for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx, nz = z + dz; + if (g.inB(nx, nz) && g.terrain[g.idx(nx, nz)] === 1) waterN++; + } + if (waterN >= 3) p = Math.round(p * 1.5); + } + cap += p; + plants.push(i); + } + } + + // consumers: developed zoned tiles; services draw from the meter too + let draw = 0; + const consumers = []; // [distToNearestPlant, tileIndex] + for (let i = 0; i < g.n; i++) { + const s = g.struct[i]; + if (s !== 0) { + if (SERVICE_POWER_DRAW[s]) draw += SERVICE_POWER_DRAW[s]; + continue; + } + if (!g.isDeveloped(i)) continue; + draw += ZONE_POWER[g.zone[i]][g.level[i] - 1]; + const x = i % g.size, z = (i - x) / g.size; + let best = 255; + for (const p of plants) { + const px = p % g.size, pz = (p - px) / g.size; + const d = Math.max(Math.abs(px - x), Math.abs(pz - z)); + if (d < best) best = d; + } + consumers.push([best, i]); + } + + // city-wide grid: when demand outstrips capacity the districts farthest + // from any plant brown out first + g.powered.fill(0); + consumers.sort((a, b) => b[0] - a[0]); + let short = Math.max(0, draw - cap); + let nOut = 0; + for (const [d, i] of consumers) { + void d; + if (short <= 0) break; + short -= ZONE_POWER[g.zone[i]][g.level[i] - 1]; + nOut++; + } + consumers.forEach(([, i], k) => { g.powered[i] = k < nOut ? 0 : 1; }); + + this.stats.powerUse = draw; + this.stats.powerCap = cap; + this.stats.brownouts = nOut; + } + + // ================= services / pollution / land value ================= + + stampDisc(map, cx, cz, radius, str) { + const g = this.grid, S = g.size; + const r2 = radius * radius; + const x0 = Math.max(0, Math.floor(cx - radius)), x1 = Math.min(S - 1, Math.ceil(cx + radius)); + const z0 = Math.max(0, Math.floor(cz - radius)), z1 = Math.min(S - 1, Math.ceil(cz + radius)); + for (let z = z0; z <= z1; z++) { + for (let x = x0; x <= x1; x++) { + const dx = x - cx, dz = z - cz; + const d2 = dx * dx + dz * dz; + if (d2 > r2) continue; + map[z * S + x] += str * (1 - Math.sqrt(d2) / radius); + } + } + } + + computeServiceMaps() { + const g = this.grid, S = g.size; + for (const m of [this.mapPolice, this.mapFire, this.mapHealth, this.mapEdu, this.mapLeisure, this.pollution]) m.fill(0); + + for (let i = 0; i < g.n; i++) { + const s = g.struct[i]; + if (s === 0 || i !== g.anchor[i]) continue; + const meta = BUILDINGS[s]; + if (!meta || !meta.radius && !meta.pollutionRadius) continue; + const x = i % S, z = (i - x) / S; + const active = !NEEDS_ROAD.has(s) || this.roadNear(x, z, 2); + if (meta.radius && active) { + const str = meta.radius >= 10 ? 1.0 : 1.15; + switch (s) { + case STRUCT.POLICE: this.stampDisc(this.mapPolice, x, z, meta.radius, str); break; + case STRUCT.FIRE: this.stampDisc(this.mapFire, x, z, meta.radius, str); break; + case STRUCT.HOSPITAL: this.stampDisc(this.mapHealth, x, z, meta.radius, str); break; + case STRUCT.SCHOOL: this.stampDisc(this.mapEdu, x, z, meta.radius, str); break; + case STRUCT.PARK: case STRUCT.PLAZA: case STRUCT.STADIUM: + this.stampDisc(this.mapLeisure, x, z, meta.radius, str); break; + } + } + if (meta.pollutionRadius) { + this.stampDisc(this.pollution, x, z, meta.pollutionRadius, meta.pollution / 8); + } + } + + // industry pollution + for (let i = 0; i < g.n; i++) { + if (g.zone[i] === ZONE.IND && g.level[i] > 0 && !g.rubble[i]) { + const S_ = S, x = i % S_, z = (i - x) / S_; + this.stampDisc(this.pollution, x, z, 5, g.level[i] * 1.6); + } + } + + // land value per tile + const W = { // weights per zone type: [edu, health, police, fire, leisure] + 1: [0.60, 0.60, 0.50, 0.30, 1.00], + 2: [0.30, 0.20, 0.70, 0.30, 0.80], + 3: [0.05, 0.05, 0.05, 0.05, 0.00] + }; + let lvSum = 0, lvCount = 0; + for (let i = 0; i < g.n; i++) { + const zv = g.zone[i]; + if (zv === 0) { g.landValue[i] = 0; continue; } + const x = i % S, z = (i - x) / S; + const w = W[zv]; + let lv = 18 + + this.mapEdu[i] * 22 * w[0] + + this.mapHealth[i] * 20 * w[1] + + this.mapPolice[i] * 18 * w[2] + + this.mapFire[i] * 12 * w[3] + + this.mapLeisure[i] * 26 * w[4] + - this.pollution[i] * (zv === ZONE.IND ? 0.18 : 0.55); + // waterfront bonus + for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx, nz = z + dz; + if (g.inB(nx, nz) && g.terrain[g.idx(nx, nz)] === 1) { lv += 12; break; } + } + lv = clamp(lv, 0, 255); + g.landValue[i] = lv; + if (g.isDeveloped(i)) { lvSum += lv; lvCount++; } + } + this.stats.avgLandValue = lvCount ? lvSum / lvCount : 0; + } + + // ================= aggregates & demand ================= + + updateAggregates() { + const g = this.grid; + let pop = 0, jobsCom = 0, jobsInd = 0, resTiles = 0; + let unpoweredBad = 0; + for (let i = 0; i < g.n; i++) { + if (g.rubble[i] || g.burning[i]) continue; + const zv = g.zone[i], lvl = g.level[i]; + if (zv === 0 || lvl === 0) continue; + const abandoned = g.badMonths[i] >= SIM.abandonAfterBadPowerMonths && !g.powered[i]; + const occ = abandoned ? 0.15 : 1; + if (zv === ZONE.RES) pop += ZONE_POP[1][lvl - 1] * occ; + else if (zv === ZONE.COM) jobsCom += ZONE_JOBS[2][lvl - 1] * occ; + else if (zv === ZONE.IND) jobsInd += ZONE_JOBS[3][lvl - 1] * occ; + if (!g.powered[i]) unpoweredBad++; + } + const jobs = Math.round(jobsCom + jobsInd); + pop = Math.round(pop); + const workers = pop * 0.52; + const unemployment = workers > 0 ? clamp(1 - jobs / workers, 0, 1) : 0; + + this.stats.pop = pop; + this.stats.jobs = jobs; + this.stats.unemployment = unemployment; + return { pop, jobs, workers }; + } + + updateDemand({ pop, jobs, workers }) { + const g = this.grid; + let housing = 0, comCap = 0, indCap = 0; + for (let i = 0; i < g.n; i++) { + if (g.rubble[i]) continue; + if (g.zone[i] === ZONE.RES && g.level[i] > 0) housing += ZONE_POP[1][g.level[i] - 1]; + else if (g.zone[i] === ZONE.COM && g.level[i] > 0) comCap += ZONE_JOBS[2][g.level[i] - 1]; + else if (g.zone[i] === ZONE.IND && g.level[i] > 0) indCap += ZONE_JOBS[3][g.level[i] - 1]; + } + const taxMod = this.taxRate <= 9 ? 1 : Math.max(0.25, 1 - (this.taxRate - 9) * 0.07); + const happyMod = 0.6 + (this.stats.happiness / 100) * 0.7; + + const targetPop = jobs * 1.3 + 60; + const resD = clamp((targetPop - pop) / Math.max(70, targetPop + 70), -1, 1) * taxMod * happyMod; + const comTarget = pop * 0.32 + 12; + const comD = clamp((comTarget - comCap) / Math.max(60, comTarget + comCap * 0.8 + 45), -1, 1) * taxMod; + const indTarget = pop * 0.42 + 24; + const indD = clamp((indTarget - indCap) / Math.max(70, indTarget + indCap * 0.8 + 50), -1, 1) * taxMod; + + this.stats.resDemand = resD; + this.stats.comDemand = comD; + this.stats.indDemand = indD; + void housing; // housing informs resD implicitly through pop + } + + computeHappiness(unemploymentRatio) { + const g = this.grid; + let covSum = 0, leisureSum = 0, pollSum = 0, cnt = 0; + for (let i = 0; i < g.n; i++) { + if (g.zone[i] === ZONE.RES && g.level[i] > 0 && !g.rubble[i]) { + covSum += Math.min(1, (this.mapPolice[i] * .5 + this.mapFire[i] * .3 + this.mapHealth[i] * .6 + this.mapEdu[i] * .6)); + leisureSum += Math.min(1, this.mapLeisure[i] * 0.8); + pollSum += this.pollution[i]; + cnt++; + } + } + const coverScore = cnt ? (covSum / cnt) * 26 : 0; + const parkScore = cnt ? (leisureSum / cnt) * 14 : 0; + const pollPen = cnt ? Math.min(14, (pollSum / cnt) * 0.28) : 0; + const taxPen = Math.max(0, (this.taxRate - 7)) * 3.2; + const powerPen = this.stats.powerUse > 0 ? Math.min(24, (this.stats.brownouts / Math.max(1, this.stats.pop / 6)) * 8) : 0; + const unempPen = unemploymentRatio * 16; + this.stats.happiness = Math.round(clamp(56 + coverScore + parkScore - pollPen - taxPen - powerPen - unempPen, 3, 100)); + } + + // ================= monthly tick ================= + + tick() { + const g = this.grid; + this.monthIndex++; + + this.recomputePower(); + this.computeServiceMaps(); + const agg = this.updateAggregates(); + this.computeHappiness(this.stats.unemployment); + this.updateDemand(agg); + + this.growZones(); + this.upgradeAndAbandon(); + this.fires(); + + // final aggregates after growth + this.recomputePower(); + const agg2 = this.updateAggregates(); + this.computeHappiness(this.stats.unemployment); + + this.economy(); + this.checkMilestones(); + + // brownout / unpowered notifications + if (agg2.pop > 0 && this.stats.brownouts > 0 && this.monthIndex % 3 === 0) { + this.notify(`โšก Brownouts across town! Power use ${this.stats.powerUse} / ${this.stats.powerCap} units. Build another plant.`, 'warn'); + } + const anyUnpoweredDev = (() => { + let c = 0; + for (let i = 0; i < g.n; i++) if (g.isDeveloped(i) && !g.powered[i]) c++; + return c > agg2.pop / 40 && c > 4; + })(); + if (anyUnpoweredDev && this.monthIndex % 4 === 0) { + this.notify('๐Ÿ”Œ New buildings have no electricity! Connect them to a power plant.', 'warn'); + } + + this.history.push({ + m: this.monthIndex, pop: this.stats.pop, + funds: Math.round(this.money), happy: this.stats.happiness + }); + if (this.history.length > 240) this.history.shift(); + + this.markTilesChanged(); + this.emit('stats'); + this.emit('date'); + + if (this.monthIndex % SIM.autosaveEveryMonths === 0) this.emit('autosave'); + } + + demandFor(zv) { + return zv === ZONE.RES ? this.stats.resDemand : zv === ZONE.COM ? this.stats.comDemand : this.stats.indDemand; + } + + growZones() { + const g = this.grid; + const powerShortage = this.stats.brownouts > 0 || (this.stats.powerCap === 0 && this.stats.pop > 0); + + // collect developable plots once (cheap O(n) walk) + const cands = []; + for (let i = 0; i < g.n; i++) { + if (g.zone[i] === 0 || g.level[i] !== 0 || g.rubble[i] || g.burning[i]) continue; + if (g.terrain[i] !== 0) continue; + cands.push(i); + } + if (!cands.length) return; + + const S = g.size; + + for (const i of cands) { + if (powerShortage && this.rng() < 0.85) continue; // construction stalls in blackouts + const dem = this.demandFor(g.zone[i]); + if (dem <= 0.03) continue; + const x = i % S, z = (i - x) / S; + if (!this.roadNear(x, z, 2)) continue; + // gradual neighbourhood filling โ€” scales with demand and area + if (this.rng() < Math.min(0.6, dem * 0.28)) { + g.level[i] = 1; + g.variant[i] = (this.rng() * 3) | 0; + g.age[i] = 0; + g.badMonths[i] = 0; + } + } + } + + upgradeAndAbandon() { + const g = this.grid; + const samples = SIM.upgradeSamplesPerMonth; + for (let a = 0; a < samples; a++) { + const i = (this.rng() * g.n) | 0; + const zv = g.zone[i], lvl = g.level[i]; + if (zv === 0 || lvl === 0) continue; + g.age[i]++; + if (g.burning[i] || g.rubble[i]) continue; + + if (g.powered[i]) { + if (g.badMonths[i] > 0) g.badMonths[i]--; + // upgrade path + if (lvl < 3) { + const dem = this.demandFor(zv); + if (dem > 0.3 && g.age[i] > 2 && g.landValue[i] >= LV_UPGRADE[lvl + 1] && this.rng() < 0.45) { + g.level[i] = lvl + 1; + g.variant[i] = (this.rng() * 3) | 0; + g.age[i] = 0; + } + } + } else { + g.badMonths[i]++; + if (g.badMonths[i] > 200) g.badMonths[i] = 200; + } + } + } + + fires() { + const g = this.grid, S = g.size; + let firedStarted = false, spreadHappened = false; + const newFires = []; + for (let a = 0; a < 1400; a++) { + const i = (this.rng() * g.n) | 0; + if (!g.isDeveloped(i)) continue; + const flamm = (0.35 + this.pollution[i] / 45) * (this.mapFire[i] > 0.3 ? 0.12 : 1) * (g.powered[i] ? 1 : 1.8); + if (this.rng() < SIM.fireBaseChance * flamm * 2) { newFires.push(i); } + } + for (const i of newFires) { + if (g.burning[i]) continue; + g.burning[i] = 3; + firedStarted = true; + } + + // progress existing fires + for (let i = 0; i < g.n; i++) { + if (!g.burning[i]) continue; + const covered = this.mapFire[i] > 0.45; + g.burning[i] = covered ? Math.max(0, g.burning[i] - 2.5 | 0) : g.burning[i] - 1; + if (!covered && this.rng() < SIM.fireSpreadChance) { + const x = i % S, z = (i - x) / S; + const nx = x + ((this.rng() * 3) | 0) - 1, nz = z + ((this.rng() * 3) | 0) - 1; + if (g.inB(nx, nz)) { + const j = g.idx(nx, nz); + if (g.isDeveloped(j) && !g.burning[j]) { g.burning[j] = 2; spreadHappened = true; } + } + } + if (g.burning[i] <= 0) { + g.burning[i] = 0; + g.rubble[i] = 1; + g.level[i] = 0; + g.variant[i] = 0; + g.age[i] = 0; + } + } + if (firedStarted) this.notify('๐Ÿ”ฅ Fire broke out in the city!', 'danger'); + else if (spreadHappened) this.notify('๐Ÿ”ฅ The fire is spreading!', 'danger'); + } + + economy() { + const t = this.taxRate / 100; + const g = this.grid; + let taxRes = 0, taxCom = 0, taxInd = 0, upkeep = 0, roadTiles = 0; + + for (let i = 0; i < g.n; i++) { + const s = g.struct[i]; + if (s !== 0) { + if (i === g.anchor[i] && s !== STRUCT.ROAD) upkeep += BUILDINGS[s].upkeep; + if (s === STRUCT.ROAD) roadTiles++; + continue; + } + const zv = g.zone[i], lvl = g.level[i]; + if (zv === 0 || lvl === 0 || g.rubble[i] || g.burning[i]) continue; + const occ = (g.badMonths[i] >= SIM.abandonAfterBadPowerMonths && !g.powered[i]) ? 0.15 : 1; + if (zv === ZONE.RES) taxRes += ZONE_POP[1][lvl - 1] * occ; + else if (zv === ZONE.COM) taxCom += ZONE_JOBS[2][lvl - 1] * occ; + else taxInd += ZONE_JOBS[3][lvl - 1] * occ; + } + upkeep += roadTiles * SIM.roadExpensePerTile; + + taxRes *= t * SIM.taxRes; + taxCom *= t * SIM.taxCom; + taxInd *= t * SIM.taxInd; + + const income = taxRes + taxCom + taxInd; + const expenses = upkeep; + this.money = Math.round(this.money + income - expenses); + this.lastBudget = { taxRes, taxCom, taxInd, upkeep, roads: roadTiles * SIM.roadExpensePerTile }; + this.stats.income = income; + this.stats.expenses = expenses; + + if (this.money < SIM.maxDebtWarning && this.monthIndex - this._debtWarnedAt > 6) { + this._debtWarnedAt = this.monthIndex; + this.notify('๐Ÿ’ธ Treasury is deep in debt! Raise taxes or cut services.', 'danger'); + } + } + + checkMilestones() { + const ms = SIM.milestonePops; + let idx = 0; + for (let i = 0; i < ms.length; i++) if (this.stats.pop >= ms[i][0]) idx = i; + if (idx > this.milestoneIdx) { + this.milestoneIdx = idx; + this.notify(`๐Ÿ™๏ธ Congratulations! ${this.name} is now a ${ms[idx][1]}!`, 'success'); + this.emit('milestone', ms[idx][1]); + } else { + this.milestoneIdx = idx; + } + } + + notify(msg, kind = 'info') { this.emit('notify', { msg, kind }); } + + dateLabel() { + const year = 2026 + Math.floor(this.monthIndex / 12); + return `${MONTHS[this.monthIndex % 12]} ${year}`; + } + + // ================= info for query tool ================= + + getTileInfo(x, z) { + const g = this.grid; + if (!g.inB(x, z)) return null; + const i = g.idx(x, z); + const info = { x, z, terrain: g.terrain[i] === 1 ? 'Water' : 'Grass' }; + const s = g.struct[i]; + if (s !== 0) { + const meta = BUILDINGS[s]; + info.type = meta.name; + info.kind = s === STRUCT.ROAD ? 'road' : 'service'; + if (NEEDS_ROAD.has(s)) info.active = this.roadNear(x, z, 2); + if (meta.power) { + let bonus = ''; + info.output = meta.power; + if (s === STRUCT.WIND) { + const waterN = [[1,0],[-1,0],[0,1],[0,-1]].filter(([dx,dz]) => g.inB(x+dx,z+dz) && g.terrain[g.idx(x+dx,z+dz)]===1).length; + if (waterN >= 3) { info.output = Math.round(meta.power*1.5); bonus = ' (+50% coastal)'; } + info.outputLabel = info.output + ' units' + bonus; + } + } + } else if (g.zone[i] !== 0) { + const names = { 1: 'Residential zone', 2: 'Commercial zone', 3: 'Industrial zone' }; + info.type = names[g.zone[i]]; + info.kind = 'zone'; + info.level = g.level[i]; + info.rubble = !!g.rubble[i]; + info.burning = g.burning[i] > 0; + info.powered = !!g.powered[i]; + info.landValue = Math.round(g.landValue[i]); + info.pollution = Math.round(this.pollution[i]); + info.abandoned = g.badMonths[i] >= SIM.abandonAfterBadPowerMonths && !g.powered[i] && g.level[i]>0; + } else { + info.type = g.terrain[i] === 1 ? 'Water' : 'Open land'; + info.kind = 'empty'; + } + return info; + } + + // ================= serialization ================= + + toJSON() { + const g = this.grid; + return { + version: 1, + seed: this.seed, name: this.name, + money: Math.round(this.money), taxRate: this.taxRate, + monthIndex: this.monthIndex, milestoneIdx: this.milestoneIdx, + terrain: Array.from(g.terrain), zone: Array.from(g.zone), + struct: Array.from(g.struct), level: Array.from(g.level), + variant: Array.from(g.variant), age: Array.from(g.age), + badMonths: Array.from(g.badMonths), rubble: Array.from(g.rubble), + scenery: Array.from(g.scenery), + history: this.history.slice(-120) + }; + } + + static fromJSON(data) { + const c = new City(data.seed, data.name); + const g = c.grid; + c.money = data.money; c.taxRate = data.taxRate; + c.monthIndex = data.monthIndex || 0; + c.milestoneIdx = data.milestoneIdx || 0; + c.history = data.history || []; + g.terrain.set(data.terrain); g.zone.set(data.zone); + g.struct.set(data.struct); g.level.set(data.level); + g.variant.set(data.variant); g.age.set(data.age); + g.badMonths.set(data.badMonths); g.rubble.set(data.rubble); + g.scenery.set(data.scenery); + c.recomputeAnchors(); + c.recomputePower(); + c.computeServiceMaps(); + c.updateAggregates(); + c.computeHappiness(c.stats.unemployment); + c.markTilesChanged(); + return c; + } + + /** Recompute anchor links for multi-tile structures by scanning footprints. */ + recomputeAnchors() { + const g = this.grid, S = g.size; + g.anchor.fill(-1); + for (let i = 0; i < g.n; i++) { + const s = g.struct[i]; + if (s === 0 || s === STRUCT.ROAD) { g.anchor[i] = i; continue; } + } + for (let z = 0; z < S; z++) { + for (let x = 0; x < S; x++) { + const i = g.idx(x, z); + const s = g.struct[i]; + if (s === 0 || s === STRUCT.ROAD) continue; + if (g.anchor[i] !== -1) continue; + // found an unclaimed tile โ€” it is the top-left of its footprint + const meta = BUILDINGS[s]; + const a = i; + for (let dz = 0; dz < meta.h; dz++) { + for (let dx = 0; dx < meta.w; dx++) { + const tx = x + dx, tz = z + dz; + if (!g.inB(tx, tz)) continue; + const j = g.idx(tx, tz); + if (g.struct[j] === s && g.anchor[j] === -1) g.anchor[j] = a; + } + } + } + } + for (let i = 0; i < g.n; i++) if (g.anchor[i] === -1) g.anchor[i] = i; + } +} diff --git a/src/game/grid.js b/src/game/grid.js new file mode 100644 index 0000000..5e31f14 --- /dev/null +++ b/src/game/grid.js @@ -0,0 +1,82 @@ +import { GRID } from '../config.js'; +import { makeValueNoise, mulberry32 } from '../utils.js'; + +/** + * Tile world state. Flat typed arrays for speed โ€” one slot per tile. + */ +export class Grid { + constructor(seed) { + this.size = GRID; + this.n = GRID * GRID; + this.seed = seed; + + this.terrain = new Uint8Array(this.n); // 0 grass, 1 water + this.zone = new Uint8Array(this.n); // 0 none, 1 res, 2 com, 3 ind + this.struct = new Uint8Array(this.n); // STRUCT enum (0 = none) + this.level = new Uint8Array(this.n); // zone building level 1..3 + this.variant = new Uint8Array(this.n); // visual variant 0..2 + this.age = new Uint16Array(this.n); // months since built/upgraded + this.badMonths = new Uint8Array(this.n); // consecutive unpowered months + this.burning = new Uint8Array(this.n); // months left burning + this.rubble = new Uint8Array(this.n); + this.powered = new Uint8Array(this.n); + this.anchor = new Int32Array(this.n); // multi-tile anchor index (self if none) + this.scenery = new Uint8Array(this.n); // 0 none, 1 tree, 2 tall tree, 3 rock + this.landValue = new Float32Array(this.n); + } + + idx(x, z) { return z * this.size + x; } + inB(x, z) { return x >= 0 && z >= 0 && x < this.size && z < this.size; } + + /** True if the tile relays electricity (roads, structures and zoned land do). */ + conducts(i) { + if (this.rubble[i]) return false; + return this.struct[i] !== 0 || this.zone[i] !== 0; + } + + isDeveloped(i) { return this.zone[i] !== 0 && this.level[i] > 0 && !this.rubble[i]; } + + /** Generate an island map with a winding river, lakes and scenery. */ + generateTerrain() { + const S = this.size; + const noise = makeValueNoise(this.seed, 256); + const noise2 = makeValueNoise(this.seed ^ 0x9e3779b9, 256); + const rng = mulberry32(this.seed ^ 0xabcd1234); + const phase = rng() * Math.PI * 2; + const half = S / 2; + + for (let z = 0; z < S; z++) { + for (let x = 0; x < S; x++) { + const i = z * S + x; + // base rolling height + let h = noise(x * 0.085, z * 0.085) * 0.75 + noise(x * 0.22, z * 0.22) * 0.25; + // island falloff โ€” edges become ocean + const dx = (x - half) / half, dz = (z - half) / half; + const d = Math.sqrt(dx * dx + dz * dz) / Math.SQRT2; + h *= Math.max(0, 1.06 - d * d * 1.35); + this.terrain[i] = h < 0.30 ? 1 : 0; + } + } + + // winding river across the island + for (let x = 0; x < S; x++) { + const t = x / S; + const cz = half + Math.sin(phase + t * 4.2) * (half * 0.42) + (noise2(t * 5.5, 3.7) - 0.5) * 14; + const w = 1.6 + noise2(t * 8.0, 9.1) * 2.2; + for (let z = Math.floor(cz - w - 1); z <= Math.ceil(cz + w + 1); z++) { + if (!this.inB(x, z)) continue; + const dd = Math.abs(z - cz); + if (dd <= w) this.terrain[this.idx(x, z)] = 1; + } + } + + // scenery on grass + for (let i = 0; i < this.n; i++) { + if (this.terrain[i] !== 0) continue; + const r = rng(); + if (r < 0.26) this.scenery[i] = 1; + else if (r < 0.33) this.scenery[i] = 2; + else if (r < 0.36) this.scenery[i] = 3; + } + } +} diff --git a/src/input.js b/src/input.js new file mode 100644 index 0000000..e72ab04 --- /dev/null +++ b/src/input.js @@ -0,0 +1,305 @@ +import { STRUCT, ZONE, BUILDINGS } from './config.js'; +import { lineTiles } from './utils.js'; + +export const TOOL = { + NONE: 'none', + BULLDOZE: 'bulldoze', + ROAD: 'road', + QUERY: 'query', + ZONE_RES: 'zone_res', + ZONE_COM: 'zone_com', + ZONE_IND: 'zone_ind' +}; + +/** + * Pointer input: tile picking, tool application (paint / drag-line / + * rectangle), ghost previews and camera-control arbitration. + */ +export class Input { + constructor(parts) { + this.game = parts.game; + this.renderer = parts.renderer; + this.city = parts.city; + this.ui = parts.ui; + this.audio = parts.audio; + + this.tool = TOOL.NONE; + this.buildSid = null; + + this.dragging = false; // active paint/commit gesture + this.dragKind = null; // 'line' | 'rect' | 'paint' | 'place' + this.anchor = null; // start tile + this.lastPaint = null; + this.touches = new Set(); + + const dom = this.renderer.domElement; + dom.style.touchAction = 'none'; + + dom.addEventListener('pointerdown', (e) => this._down(e)); + dom.addEventListener('pointermove', (e) => this._move(e)); + this._onUp = (e) => this._up(e); + window.addEventListener('pointerup', this._onUp); + window.addEventListener('pointercancel', this._onUp); + dom.addEventListener('contextmenu', (e) => { + e.preventDefault(); + this.setTool(TOOL.NONE); + }); + dom.addEventListener('wheel', (e) => e.preventDefault(), { passive: false }); + + // keyboard panning (WASD / arrows) + this.keys = new Set(); + this._onKeyDown = (e) => { this.keys.add(e.code); }; + this._onKeyUp = (e) => { this.keys.delete(e.code); }; + window.addEventListener('keydown', this._onKeyDown); + window.addEventListener('keyup', this._onKeyUp); + } + + destroy() { + window.removeEventListener('pointerup', this._onUp); + window.removeEventListener('pointercancel', this._onUp); + window.removeEventListener('keydown', this._onKeyDown); + window.removeEventListener('keyup', this._onKeyUp); + } + + /** Per-frame keyboard panning along the ground plane. */ + updatePan(dt) { + if (!this.keys.size) return; + const k = this.keys; + const fwdKey = (k.has('KeyW') || k.has('ArrowUp') ? 1 : 0) - (k.has('KeyS') || k.has('ArrowDown') ? 1 : 0); + const sideKey = (k.has('KeyD') || k.has('ArrowRight') ? 1 : 0) - (k.has('KeyA') || k.has('ArrowLeft') ? 1 : 0); + if (!fwdKey && !sideKey) return; + const cam = this.renderer.camera; + const t = this.renderer.controls.target; + const dx = cam.position.x - t.x, dz = cam.position.z - t.z; + const len = Math.hypot(dx, dz) || 1; + const fx = -dx / len, fz = -dz / len; // camera-forward on ground + const rx = -fz, rz = fx; // camera-right + const sp = Math.max(10, len * 0.9) * dt * (k.has('ShiftLeft') || k.has('ShiftRight') ? 2.2 : 1); + const mx = (fx * fwdKey + rx * sideKey) * sp; + const mz = (fz * fwdKey + rz * sideKey) * sp; + cam.position.x += mx; cam.position.z += mz; + t.x += mx; t.z += mz; + } + + setTool(tool, sid = null) { + this.tool = tool; + this.buildSid = sid; + this.renderer.clearGhost(); + this.renderer.hideDragRect(); + this.renderer.setHover(null, false); + this.game.ui.onToolChanged?.(tool, sid); + } + + _primaryButton(e) { + if (e.pointerType === 'touch') return true; // single finger paints + return e.button === 0; + } + + _down(e) { + this.touches.add(e.pointerId); + if (!this._primaryButton(e)) return; + // second finger while painting โ†’ hand over to camera controls + if (e.pointerType === 'touch' && this.touches.size > 1) { + this._cancelGesture(); + return; + } + const tile = this.renderer.pickTile(e.clientX, e.clientY); + if (!tile) return; + + this.controlsEnabled(false); + this.dragging = true; + this.anchor = tile; + this.lastPaint = tile; + + switch (this.tool) { + case TOOL.BULLDOZE: + this.dragKind = 'paint'; + this._applyBulldoze(tile); + break; + case TOOL.ROAD: + this.dragKind = 'line'; + break; + case TOOL.ZONE_RES: + case TOOL.ZONE_COM: + case TOOL.ZONE_IND: + this.dragKind = 'rect'; + break; + case TOOL.QUERY: + this.dragKind = null; + this.dragging = false; + this.controlsEnabled(true); + this.game.queryTile(tile, e.clientX, e.clientY); + break; + default: + if (this.buildSid) { + this.dragKind = 'place'; + this._tryPlace(this.buildSid, tile); + } else { + this.dragKind = null; + this.dragging = false; + this.controlsEnabled(true); + } + } + } + + _move(e) { + const tile = this.renderer.pickTile(e.clientX, e.clientY); + this.renderer.setHover(tile, !!tile && !this.dragging); + + // ghost preview for buildings + if (!this.dragging && this.buildSid && tile) { + const check = this.city.canPlaceStruct(this.buildSid, tile.x, tile.z); + const valid = check.ok && this.city.money >= check.cost; + this.renderer.setGhost(this.buildSid, tile, valid); + this.ui.showTooltip(e.clientX, e.clientY, + valid ? `${BUILDINGS[this.buildSid].name} โ€” $${check.cost.toLocaleString()}` : check.reason || 'Cannot place here'); + } else if (!this.dragging) { + this.renderer.clearGhost(); + this.ui.hideTooltip(); + } + + if (!this.dragging || !tile) return; + + switch (this.dragKind) { + case 'line': { + const pts = lPathTiles(this.anchor, tile); + let cost = 0, ok = true; + for (const [x, z] of pts) { + const c = this.city.canPlaceStruct(STRUCT.ROAD, x, z); + if (c.ok) cost += c.cost; + else if (c.reason !== 'Tile occupied โ€” bulldoze first') ok = false; + } + const first = pts[0], last = pts[pts.length - 1]; + this.renderer.showDragRect(first[0], first[1], last[0], last[1], ok); + this.ui.showTooltip(e.clientX, e.clientY, `Road ร—${pts.length} โ€” $${cost.toLocaleString()}`); + break; + } + case 'rect': { + let n = 0, bad = 0; + const x0 = Math.min(this.anchor.x, tile.x), x1 = Math.max(this.anchor.x, tile.x); + const z0 = Math.min(this.anchor.z, tile.z), z1 = Math.max(this.anchor.z, tile.z); + for (let z = z0; z <= z1; z++) for (let x = x0; x <= x1; x++) { + const i = this.city.grid.idx(x, z); + if (this.city.grid.terrain[i] === 1 || !this.city.tileFree(i)) bad++; + else n++; + } + this.renderer.showDragRect(this.anchor.x, this.anchor.z, tile.x, tile.z, bad === 0); + this.ui.showTooltip(e.clientX, e.clientY, `Zone ${n} tile${n === 1 ? '' : 's'} (free)`); + break; + } + case 'paint': { + if (tile.x !== this.lastPaint.x || tile.z !== this.lastPaint.z) { + for (const [x, z] of lineTiles(this.lastPaint.x, this.lastPaint.z, tile.x, tile.z)) { + this._applyBulldoze({ x, z }); + } + this.lastPaint = tile; + } + break; + } + case 'place': { + const check = this.city.canPlaceStruct(this.buildSid, tile.x, tile.z); + const valid = check.ok && this.city.money >= check.cost; + this.renderer.setGhost(this.buildSid, tile, valid); + if (valid) this._tryPlace(this.buildSid, tile); // drag to stamp several + break; + } + } + } + + _up(e) { + this.touches.delete(e.pointerId); + if (!this.dragging) return; + if (e.type === 'pointercancel') { this._cancelGesture(); return; } + const tile = this.renderer.pickTile(e.clientX, e.clientY); + + switch (this.dragKind) { + case 'line': { + if (tile) { + const pts = lPathTiles(this.anchor, tile); + let built = 0, spent = 0; + for (const [x, z] of pts) { + const c = this.city.canPlaceStruct(STRUCT.ROAD, x, z); + if (!c.ok) continue; + if (this.city.money < c.cost) { this.ui.toast('Not enough funds for the full route.', 'warn'); break; } + const r = this.city.placeStruct(STRUCT.ROAD, x, z); + if (r.ok) { built++; spent += c.cost; } + } + if (built) this.audio.place(); + else if (pts.length) this.audio.error(); + } + break; + } + case 'rect': { + if (tile) { + const zid = this.tool === TOOL.ZONE_RES ? ZONE.RES + : this.tool === TOOL.ZONE_COM ? ZONE.COM : ZONE.IND; + const x0 = Math.min(this.anchor.x, tile.x), x1 = Math.max(this.anchor.x, tile.x); + const z0 = Math.min(this.anchor.z, tile.z), z1 = Math.max(this.anchor.z, tile.z); + const tiles = []; + for (let z = z0; z <= z1; z++) for (let x = x0; x <= x1; x++) tiles.push([x, z]); + const placed = this.city.placeZone(zid, tiles); + if (placed > 0) this.audio.zone(); + this.game.checkQuests?.(); + } + break; + } + case 'place': + this.game.checkQuests?.(); + break; + } + + this.dragging = false; + this.dragKind = null; + this.renderer.hideDragRect(); + this.controlsEnabled(true); + } + + _cancelGesture() { + this.dragging = false; + this.dragKind = null; + this.renderer.hideDragRect(); + this.renderer.clearGhost(); + this.controlsEnabled(true); + } + + _applyBulldoze(tile) { + const r = this.city.demolish(tile.x, tile.z); + if (r.ok) this.audio.bulldoze(); + else if (r.reason === 'Not enough funds') { + this.ui.toast('Not enough funds to demolish.', 'warn'); + this._cancelGesture(); + } + } + + _tryPlace(sid, tile) { + const check = this.city.canPlaceStruct(sid, tile.x, tile.z); + if (!check.ok) { this.audio.error(); this.ui.flashTooltip(check.reason); return; } + if (this.city.money < check.cost) { this.audio.error(); this.ui.flashTooltip('Not enough funds'); return; } + const r = this.city.placeStruct(sid, tile.x, tile.z); + if (r.ok) { + this.audio.place(); + this.game.checkQuests?.(); + } + } + + controlsEnabled(on) { + this.renderer.controls.enabled = on; + } +} + +/** L-shaped path: horizontal first, then vertical. */ +function lPathTiles(a, b) { + const pts = []; + const sx = Math.sign(b.x - a.x), sz = Math.sign(b.z - a.z); + for (let x = a.x; x !== b.x + sx; x += sx || 1) { + if (sx === 0) break; + pts.push([x, a.z]); + } + if (sx === 0) pts.push([a.x, a.z]); + for (let z = a.z + (sz || 1); ; z += sz || 1) { + if (sz === 0) break; + pts.push([b.x, z]); + if (z === b.z) break; + } + return pts.length ? pts : [[a.x, a.z]]; +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..09fe59a --- /dev/null +++ b/src/main.js @@ -0,0 +1,204 @@ +import './style.css'; + +import { City } from './game/city.js'; +import { Renderer } from './render/renderer.js'; +import { Input } from './input.js'; +import { UI } from './ui/ui.js'; +import { Audio } from './audio.js'; +import { SaveManager } from './save.js'; +import { SETTINGS_KEY, START_MONEY } from './config.js'; +import { clamp } from './utils.js'; + +function loadSettings() { + try { + return { sound: true, shadows: true, autosave: true, minimap: true, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') }; + } catch { + return { sound: true, shadows: true, autosave: true, minimap: true }; + } +} + +class Game { + constructor() { + this.settings = loadSettings(); + this.audio = new Audio(this.settings); + this.saves = null; + this.speedIdx = 1; + this.paused = false; + this.lastNonzeroSpeed = 1; + + this._simAcc = 0; + this._lastT = performance.now(); + this._raf = null; + this._parts = null; // {renderer, input, ui} + this._unsubs = []; + + // pick up autosaved city if present + let initial = null; + const probeSaves = new SaveManager(null); + if (probeSaves.hasAutosave()) { + initial = probeSaves.loadAutosave(); + } + this.city = initial || new City(); + this.start(this.city); + + document.addEventListener('visibilitychange', () => { + if (document.hidden && !this.paused) this.togglePause(); + }); + } + + // ---------- lifecycle ---------- + + start(city) { + this.teardown(); + this.city = city; + + const container = document.getElementById('app'); + let renderer; + try { + renderer = new Renderer(container, city, this.settings); + } catch (err) { + console.error(err); + container.innerHTML = `
+

WebGL unavailable ๐Ÿ˜ข

PolyCity needs hardware-accelerated WebGL.
Please enable it in your browser settings or try another browser.

`; + throw err; + } + + this.saves = new SaveManager(city); + const ui = new UI(this); + const input = new Input({ city, renderer, ui, audio: this.audio, game: this }); + this._parts = { renderer, input, ui }; + + this._unsubs.push(city.on('autosave', () => { + if (this.settings.autosave && this.saves.autosave()) { + console.info('PolyCity autosaved.'); + } + })); + this._unsubs.push(city.on('milestone', () => { + // extra flourish handled by ui toast + sound + })); + + city.markTilesChanged(); + this._syncSoon(); + + if (!this._raf) this.loop(); + } + + teardown() { + for (const off of this._unsubs) off(); + this._unsubs = []; + if (this._parts) { + this._parts.input.destroy?.(); + this._parts.ui.destroy?.(); + this._parts.renderer.dispose?.(); + this._parts = null; + } + } + + newCity({ name, seed, money }) { + const c = new City(seed, name); + c.money = money ?? START_MONEY; + this.speedIdx = 1; + this.paused = false; + this.start(c); + this.ui().toast(`Welcome to ${name}, Mayor!`, 'success'); + this.ui().resetQuests(); + } + + loadFrom(cityInstance) { + this.start(cityInstance); + this.ui().toast('City loaded.', 'success'); + } + + ui() { return this._parts?.ui; } + get input() { return this._parts?.input; } + get renderer() { return this._parts?.renderer; } + + // ---------- controls ---------- + + setSpeed(idx) { + this.speedIdx = idx; + this.paused = idx === 0; + if (idx > 0) this.lastNonzeroSpeed = idx; + this.ui()?.setSpeedActive(idx); + } + + togglePause() { + if (this.paused) this.setSpeed(this.lastNonzeroSpeed); + else this.setSpeed(0); + } + + applySettings(patch) { + Object.assign(this.settings, patch); + localStorage.setItem(SETTINGS_KEY, JSON.stringify(this.settings)); + if ('shadows' in patch && this._parts) this._parts.renderer.setShadows(patch.shadows); + } + + queryTile(tile, x, y) { + const info = this.city.getTileInfo(tile.x, tile.z); + if (info) this.ui()?.showInfoPopup(info, x, y); + } + + checkQuests() { this.ui()?.checkQuests(); } + + _syncSoon() { + if (this.city.tilesDirty && this._parts) { + this._parts.renderer.sync(); + this.city.tilesDirty = false; + this.ui()?.scheduleMinimap(); + } + } + + // ---------- main loop ---------- + + loop() { + const tick = (now) => { + this._raf = requestAnimationFrame(tick); + const dt = Math.min(0.05, (now - this._lastT) / 1000); + this._lastT = now; + + // simulation stepping + const msPerMonth = [0, 5000, 1800, 700][this.speedIdx] || 0; + if (!this.paused && msPerMonth > 0) { + this._simAcc += dt * 1000; + let guard = 0; + while (this._simAcc >= msPerMonth && guard < 4) { + this.city.tick(); + this._simAcc -= msPerMonth; + guard++; + } + if (guard >= 4) this._simAcc = 0; // avoid runaway catch-up + } + + this._syncSoon(); + if (this._parts) { + this._parts.input.updatePan(dt); + this._parts.renderer.frame(dt, this.speedIdx, this.paused); + } + }; + this._raf = requestAnimationFrame(tick); + } +} + +// ---------------- boot ---------------- + +window.addEventListener('DOMContentLoaded', () => { + try { + window.POLYCITY = new Game(); + } catch (e) { + console.error(e); + return; + } + const boot = document.getElementById('bootScreen'); + setTimeout(() => { + boot.classList.add('off'); + setTimeout(() => boot.remove(), 700); + }, 450); + + if ('serviceWorker' in navigator && import.meta.env.PROD) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('./sw.js').catch(() => {}); + }); + } +}); + +void clamp; diff --git a/src/render/cars.js b/src/render/cars.js new file mode 100644 index 0000000..6fed59b --- /dev/null +++ b/src/render/cars.js @@ -0,0 +1,143 @@ +import * as THREE from 'three'; +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; +import { CARS, GRID } from '../config.js'; +import { matBuilding } from './geometry.js'; +import { mulberry32 } from '../utils.js'; + +function addColor(g, c) { + const n = g.attributes.position.count; + const arr = new Float32Array(n * 3); + for (let i = 0; i < n; i++) { arr[i * 3] = c[0]; arr[i * 3 + 1] = c[1]; arr[i * 3 + 2] = c[2]; } + g.setAttribute('color', new THREE.BufferAttribute(arr, 3)); +} + +const CAR_COLORS = ['#d94f4f', '#e8e8e8', '#3d6fd9', '#44b78b', '#e0a63d', '#777f88', '#9b59b6', '#22262b']; + +/** + * Animated traffic agents that drive along the road graph. + */ +export class Traffic { + constructor(scene, city) { + this.city = city; + this.scene = scene; + + // little car: body + cabin + wheels hint + const parts = []; + const mkBox = (w, h, d, x, y, z, c) => { + const g = new THREE.BoxGeometry(w, h, d); + g.translate(x, y + h / 2, z); + addColor(g, c); + parts.push(g); + }; + const bodyC = [0.85, 0.85, 0.85]; + mkBox(0.34, 0.09, 0.18, 0, 0.02, 0, bodyC); // body (placeholder color) + mkBox(0.17, 0.08, 0.15, -0.02, 0.11, 0, [0.25, 0.28, 0.32]); // cabin + this.bodyGeo = mergeGeometries(parts.map(p => p)); + + this.mesh = new THREE.InstancedMesh(this.bodyGeo, matBuilding, CARS.max); + this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + this.mesh.count = 0; + this.mesh.castShadow = false; + this.mesh.frustumCulled = false; + this.mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(CARS.max * 3), 3); + scene.add(this.mesh); + + this.agents = []; + this.rng = mulberry32(city.seed ^ 0xcafe); + this._t = 0; + this.targetCount = 0; + this._recalcTimer = 0; + this._lastNodeCount = -1; + } + + computeTarget() { + let roads = 0; + for (let i = 0; i < this.city.grid.n; i++) if (this.city.grid.struct[i] === 1) roads++; + this.targetCount = Math.min(CARS.max, Math.floor(roads * CARS.perRoad + this.city.stats.pop * CARS.perPop)); + } + + spawn() { + const graph = this.city.ensureRoadGraph(); + if (graph.nodes.length < 2) return; + const a = Math.floor(this.rng() * graph.nodes.length); + const nbrs = graph.nodes[a].nbrs; + if (!nbrs.length) return; + const b = nbrs[Math.floor(this.rng() * nbrs.length)]; + const ci = this.agents.length % CAR_COLORS.length; + this.agents.push({ + a, b, t: this.rng(), + speed: 1.4 + this.rng() * 1.2, + side: 0.16, + prev: -1 + }); + const col = new THREE.Color(CAR_COLORS[ci]); + // tint only body-ish by brightening + this.mesh.instanceColor.setXYZ(this.agents.length - 1, col.r, col.g, col.b); + } + + nodeXZ(ni) { + const i = this.city.roadGraph.nodes[ni].i; + const S = GRID; + const x = i % S, z = (i - x) / S; + return [x - S / 2 + 0.5, z - S / 2 + 0.5]; + } + + update(dt) { + const graph = this.city.ensureRoadGraph(); + // graph rebuilt (roads added/removed) โ†’ old agent indices are invalid + if (graph.nodes.length !== this._lastNodeCount) { + this.agents.length = 0; + this._lastNodeCount = graph.nodes.length; + } + this._recalcTimer -= dt; + if (this._recalcTimer <= 0) { + this.computeTarget(); + this._recalcTimer = 2.0; + } + // adjust population (guarded โ€” spawn() may legitimately fail on isolated roads) + let guard = CARS.max * 2; + while (this.agents.length < this.targetCount && guard-- > 0) this.spawn(); + while (this.agents.length > this.targetCount) this.agents.pop(); + + const m = new THREE.Matrix4(); + const pos = new THREE.Vector3(); + const quat = new THREE.Quaternion(); + const scale = new THREE.Vector3(1, 1, 1); + const up = new THREE.Vector3(0, 1, 0); + + for (let k = 0; k < this.agents.length; k++) { + const car = this.agents[k]; + car.t += car.speed * dt; + if (car.t >= 1) { + // pick next edge + const graph = this.city.roadGraph; + const nbrs = graph.nodes[car.b].nbrs; + let next = -1; + if (nbrs.length > 1) { + const forward = nbrs.filter(n => n !== car.a); + const pool = forward.length ? forward : nbrs; + // bias straight + next = pool[Math.floor(this.rng() * pool.length)]; + } else { + next = car.a; + } + car.prev = car.a; car.a = car.b; car.b = next; car.t -= 1; + } + const [ax, az] = this.nodeXZ(car.a); + const [bx, bz] = this.nodeXZ(car.b); + const dx = bx - ax, dz = bz - az; + const len = Math.hypot(dx, dz) || 1; + const ux = dx / len, uz = dz / len; + // right-hand lane offset + const px = ax + dx * car.t - uz * car.side; + const pz = az + dz * car.t + ux * car.side; + pos.set(px, 0.03, pz); + quat.setFromAxisAngle(up, Math.atan2(ux, uz)); + m.compose(pos, quat, scale); + this.mesh.setMatrixAt(k, m); + } + this.mesh.count = this.agents.length; + this.mesh.instanceMatrix.needsUpdate = true; + if (this.mesh.instanceColor) this.mesh.instanceColor.needsUpdate = true; + } +} diff --git a/src/render/geometry.js b/src/render/geometry.js new file mode 100644 index 0000000..cc80644 --- /dev/null +++ b/src/render/geometry.js @@ -0,0 +1,334 @@ +import * as THREE from 'three'; +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; +import { mulberry32, hashStr, choice } from '../utils.js'; + +// ============================================================ +// Procedural low-poly geometry factory. +// Every model is a merged BufferGeometry with vertex colors so the +// whole city can be drawn with ONE shared standard material. +// ============================================================ + +export const matBuilding = new THREE.MeshStandardMaterial({ + vertexColors: true, roughness: 0.82, metalness: 0.06, flatShading: true +}); + +function addColor(g, c) { + const n = g.attributes.position.count; + const arr = new Float32Array(n * 3); + for (let i = 0; i < n; i++) { arr[i * 3] = c[0]; arr[i * 3 + 1] = c[1]; arr[i * 3 + 2] = c[2]; } + g.setAttribute('color', new THREE.BufferAttribute(arr, 3)); + return g; +} + +export function box(w, h, d, c, x = 0, y = 0, z = 0, ry = 0) { + const g = new THREE.BoxGeometry(w, h, d); + if (ry) g.rotateY(ry); + g.translate(x, y + h / 2, z); + return addColor(g, c); +} +export function cyl(rT, rB, h, seg, c, x = 0, y = 0, z = 0) { + const g = new THREE.CylinderGeometry(rT, rB, h, seg); + g.translate(x, y + h / 2, z); + return addColor(g, c); +} +export function cone(r, h, seg, c, x = 0, y = 0, z = 0, ry = 0) { + const g = new THREE.ConeGeometry(r, h, seg); + if (ry) g.rotateY(ry); + g.translate(x, y + h / 2, z); + return addColor(g, c); +} +function merge(parts) { + const flat = parts.map(p => (p.index ? p.toNonIndexed() : p)); + const g = mergeGeometries(flat); + parts.forEach(p => { if (p !== g) p.dispose(); }); + return g; +} +const rgb = (hex) => { + const col = new THREE.Color(hex); + // convert to linear working space + return [col.r, col.g, col.b]; +}; + +// ---------------- palettes ---------------- +const P = { + resWalls: ['#e8dcc0', '#dfc99b', '#d7e0c8', '#e6d3cf'], + resRoof: ['#b5563f', '#96502f', '#7d6c54'], + comGlass: ['#79aed6', '#6fb3c9', '#88bcd8'], + comAccent: ['#ff6b6b', '#ffd166', '#4ecdc4', '#f4a261'], + indWall: ['#9aa0a6', '#8d9296', '#a3a39c'], + indRoof: ['#7a5c50', '#6e7076'], + white: '#f2f2ee', dark: '#33363b', + win: '#ffd27d', winBright: [1.5, 1.14, 0.62], + steel: '#b8bcc2' +}; + +// ---------------- residential ---------------- +function residential(level, variant) { + const rng = mulberry32(hashStr(`res${level}_${variant}`)); + const wall = choice(rng, P.resWalls); + const parts = []; + if (level === 1) { + const w = 0.52 + rng() * 0.1, d = 0.48 + rng() * 0.1, h = 0.26 + rng() * 0.06; + const ox = (rng() - 0.5) * 0.12, oz = (rng() - 0.5) * 0.12; + parts.push(box(w, h, d, rgb(wall), ox, 0, oz)); + parts.push(cone(Math.max(w, d) * 0.78, 0.2, 4, rgb(choice(rng, P.resRoof)), ox, h, oz, Math.PI / 4)); + parts.push(box(0.07, 0.07, 0.02, P.winBright, ox - w * 0.25, h * 0.45, oz + d / 2)); + parts.push(box(0.07, 0.07, 0.02, P.winBright, ox + w * 0.22, h * 0.45, oz + d / 2)); + parts.push(box(0.09, 0.13, 0.02, rgb(P.dark), ox - 0.12, 0, oz + d / 2)); + if (rng() < 0.6) parts.push(box(0.06, 0.05, 0.06, rgb('#6f7d68'), ox + w * 0.42, 0, oz - d * 0.4)); // shrub + } else if (level === 2) { + const h = 0.62 + rng() * 0.14; + parts.push(box(0.68, h, 0.56, rgb(wall))); + parts.push(box(0.7, 0.03, 0.58, rgb(choice(rng, P.resRoof)), 0, h, 0)); + for (let fy = 0.18; fy < h - 0.08; fy += 0.22) { + parts.push(box(0.56, 0.09, 0.02, P.winBright, 0, fy, 0.285)); + parts.push(box(0.02, 0.09, 0.4, P.winBright, 0.34, fy, 0)); + } + parts.push(box(0.16, 0.02, 0.14, rgb(P.steel), 0.2, 0.3, 0.36)); // balcony + } else { + const h = 1.25 + rng() * 0.55; + const wallC = ['#d8d2c8', '#bfc8d4', '#cbd6bd'][variant % 3]; + parts.push(box(0.74, h, 0.74, rgb(wallC))); + for (let fy = 0.14; fy < h - 0.1; fy += 0.19) { + parts.push(box(0.76, 0.07, 0.76, P.winBright, 0, fy, 0)); + } + parts.push(box(0.78, 0.04, 0.78, rgb('#8e8e94'), 0, h, 0)); + parts.push(box(0.16, 0.1, 0.14, rgb('#7c828a'), 0.14, h + 0.04, 0)); // AC + parts.push(cyl(0.012, 0.012, 0.22, 5, rgb(P.steel), -0.2, h + 0.04, 0.2)); // antenna + } + return merge(parts); +} + +// ---------------- commercial ---------------- +function commercial(level, variant) { + const rng = mulberry32(hashStr(`com${level}_${variant}`)); + const glass = choice(rng, P.comGlass); + const acc = choice(rng, P.comAccent); + const parts = []; + if (level === 1) { + parts.push(box(0.66, 0.34, 0.6, rgb(glass))); + parts.push(box(0.68, 0.06, 0.62, rgb(acc), 0, 0.34, 0)); // awning band + parts.push(box(0.5, 0.14, 0.02, P.winBright, 0, 0.08, 0.31)); // storefront glass + parts.push(box(0.2, 0.08, 0.03, [1.4, 0.9, 0.5], 0, 0.36, 0.31)); // sign + } else if (level === 2) { + const h = 0.85 + rng() * 0.2; + parts.push(box(0.72, h, 0.66, rgb(glass))); + for (let fy = 0.12; fy < h; fy += 0.2) parts.push(box(0.74, 0.06, 0.68, P.winBright, 0, fy, 0)); + parts.push(box(0.74, 0.05, 0.68, rgb(acc), 0, h, 0)); + } else { + const h = 1.5 + rng() * 0.7; + const c = [rgb('#8fc3ea'), rgb('#79aed6'), rgb('#a5cfe8')][variant % 3]; + parts.push(box(0.76, h * 0.75, 0.76, c)); + parts.push(box(0.6, h * 0.3, 0.6, rgb('#dfeaf2'), 0, h * 0.75, 0)); + for (let fy = 0.12; fy < h * 0.75; fy += 0.21) parts.push(box(0.78, 0.065, 0.78, P.winBright, 0, fy, 0)); + for (let fy = h * 0.78; fy < h * 1.02; fy += 0.17) parts.push(box(0.62, 0.06, 0.62, P.winBright, 0, fy, 0)); + parts.push(cyl(0.008, 0.008, 0.3, 5, rgb(P.steel), 0, h * 1.05, 0)); + parts.push(box(0.03, 0.03, 0.03, [2.2, 0.4, 0.4], 0, h * 1.33, 0)); // beacon + } + return merge(parts); +} + +// ---------------- industrial ---------------- +function industrial(level, variant) { + const rng = mulberry32(hashStr(`ind${level}_${variant}`)); + const wall = choice(rng, P.indWall); + const parts = []; + if (level === 1) { + parts.push(box(0.74, 0.32, 0.6, rgb(wall))); + parts.push(box(0.78, 0.05, 0.64, rgb(P.indRoof[0]), 0, 0.32, 0)); + parts.push(cyl(0.05, 0.05, 0.12, 7, rgb('#6d7176'), 0.22, 0.37, 0.1)); // vent + parts.push(box(0.3, 0.16, 0.02, rgb('#4c5157'), -0.1, 0, 0.305)); // gate + } else if (level === 2) { + parts.push(box(0.78, 0.4, 0.6, rgb(wall))); + parts.push(box(0.8, 0.05, 0.62, rgb(P.indRoof[1]), 0, 0.4, 0)); + parts.push(cyl(0.055, 0.07, 0.62, 7, rgb('#7d8288'), -0.26, 0.4, -0.14)); // stack + parts.push(box(0.06, 0.06, 0.06, [1.5, 0.55, 0.3], -0.26, 1.0, -0.14)); // warning light + parts.push(cyl(0.09, 0.11, 0.24, 8, rgb('#8f959b'), 0.24, 0, 0.12)); // tank + } else { + parts.push(box(0.84, 0.5, 0.66, rgb(wall))); + parts.push(box(0.86, 0.05, 0.68, rgb('#5d6066'), 0, 0.5, 0)); + parts.push(cyl(0.06, 0.08, 0.95, 7, rgb('#888d93'), -0.28, 0.5, -0.16)); + parts.push(box(0.07, 0.05, 0.07, rgb('#c0392b'), -0.28, 1.4, -0.16)); + parts.push(cyl(0.11, 0.13, 0.3, 8, rgb('#9aa0a6'), 0.26, 0, 0.14)); + parts.push(cyl(0.1, 0.12, 0.24, 8, rgb('#9aa0a6'), 0.26, 0, -0.16)); + parts.push(cone(0.13, 0.1, 8, rgb('#6e7076'), 0.26, 0.3, 0.14)); + } + void rng; + return merge(parts); +} + +// ---------------- services & utilities ---------------- +function policeGeo() { + const parts = [ + box(0.72, 0.42, 0.6, rgb(P.white)), + box(0.74, 0.08, 0.62, rgb('#2e5f8a'), 0, 0.42, 0), + box(0.5, 0.1, 0.02, rgb('#9cc4e4'), 0, 0.14, 0.31), + box(0.06, 0.05, 0.06, [0.4, 0.9, 2.2], 0.2, 0.5, 0.1), + box(0.06, 0.05, 0.06, [2.2, 0.5, 0.4], 0.05, 0.5, -0.1) + ]; + return merge(parts); +} +function fireGeo() { + return merge([ + box(0.72, 0.4, 0.6, rgb('#c0392b')), + box(0.74, 0.06, 0.62, rgb('#8e2b20'), 0, 0.4, 0), + box(0.16, 0.22, 0.02, rgb('#3a3f45'), -0.18, 0, 0.31), + box(0.16, 0.22, 0.02, rgb('#3a3f45'), 0.06, 0, 0.31), + cyl(0.05, 0.06, 0.55, 6, rgb('#a83226'), 0.26, 0.4, -0.18), // hose tower + box(0.05, 0.05, 0.05, [2.2, 0.6, 0.3], 0.26, 0.97, -0.18) + ]); +} +function hospitalGeo() { + return merge([ + box(0.7, 0.72, 0.62, rgb(P.white)), + box(0.72, 0.05, 0.64, rgb('#d8dde2'), 0, 0.72, 0), + box(0.34, 0.09, 0.05, [2.1, 0.25, 0.3], 0, 0.79, 0), + box(0.09, 0.34, 0.05, [2.1, 0.25, 0.3], 0, 0.79, 0), + forFloors(0.7, 0.72, 0.63, 0.14, 0.68, 0.055) + ]); +} +function forFloors(w, d, offZ, y0, y1, step) { + const parts = []; + for (let fy = y0; fy < y1; fy += step) parts.push(box(w, 0.05, d, P.winBright, 0, fy, 0)); + return merge(parts.length ? parts : [box(0.01, 0.01, 0.01, P.winBright)]); +} +function schoolGeo() { + return merge([ + box(0.74, 0.34, 0.5, rgb('#c46f4a')), + box(0.76, 0.05, 0.52, rgb('#8a4a30'), 0, 0.34, 0), + box(0.3, 0.22, 0.4, rgb('#d8a06b'), -0.14, 0.34, 0), + box(0.5, 0.08, 0.02, P.winBright, 0, 0.12, 0.26), + cyl(0.012, 0.012, 0.5, 5, rgb(P.steel), 0.3, 0, 0.1), + box(0.12, 0.07, 0.015, [1.4, 0.85, 0.3], 0.365, 0.42, 0.1) + ]); +} +function parkGeo() { + return merge([ + box(0.92, 0.02, 0.92, rgb('#57a04e')), + box(0.16, 0.015, 0.92, rgb('#d9cba7'), 0.18, 0.02, 0), + cyl(0.03, 0.035, 0.1, 5, rgb('#6b4f35'), -0.22, 0.02, -0.2), + cone(0.11, 0.22, 6, rgb('#3f7d44'), -0.22, 0.12, -0.2), + cyl(0.025, 0.03, 0.08, 5, rgb('#6b4f35'), 0.24, 0.02, 0.24), + cone(0.09, 0.18, 6, rgb('#4f9153'), 0.24, 0.1, 0.24), + box(0.12, 0.04, 0.04, rgb('#7a5230'), -0.24, 0.02, 0.26) // bench + ]); +} +function plazaGeo() { + return merge([ + box(0.94, 0.03, 0.94, rgb('#ddd6c6')), + box(0.47, 0.035, 0.47, rgb('#cbc2ae'), 0, 0.03, 0), + cyl(0.16, 0.18, 0.07, 10, rgb('#b9b2a0'), 0, 0.03, 0), + cyl(0.13, 0.13, 0.02, 10, [0.35, 0.62, 0.95], 0, 0.075, 0), + cyl(0.02, 0.03, 0.14, 6, rgb('#b9b2a0'), 0, 0.06, 0), + box(0.05, 0.05, 0.05, [1.2, 1.5, 2.0], 0, 0.2, 0) + ]); +} +function stadiumGeo() { + const parts = [box(0.6, 0.02, 0.44, rgb('#4c9a4e'), 0, 0.06, 0)]; + const ring = 10; + for (let k = 0; k < ring; k++) { + const a = (k / ring) * Math.PI * 2; + const rx = 0.42, rz = 0.32; + const x = Math.cos(a) * rx, z = Math.sin(a) * rz; + parts.push(box(0.22, 0.22 + (k % 2) * 0.05, 0.16, rgb(k % 2 ? '#c8ccd2' : '#aab2ba'), x, 0, z, -a)); + } + for (const [sx, sz] of [[-0.4, -0.28], [0.4, -0.28], [-0.4, 0.28], [0.4, 0.28]]) { + parts.push(cyl(0.02, 0.025, 0.55, 5, rgb('#8d959d'), sx, 0.1, sz)); + parts.push(box(0.1, 0.04, 0.04, [2.2, 2.2, 1.9], sx, 0.63, sz)); + } + return merge(parts); +} +function coalGeo() { + return merge([ + box(0.78, 0.5, 0.6, rgb('#5b6066')), + box(0.8, 0.05, 0.62, rgb('#43474c'), 0, 0.5, 0), + box(0.4, 0.34, 0.5, rgb('#6a7076'), -0.18, 0.5, 0), + cyl(0.07, 0.09, 1.15, 8, rgb('#7d8288'), 0.24, 0.4, -0.16), + cyl(0.075, 0.075, 0.08, 8, [1.9, 0.5, 0.4], 0.24, 1.5, -0.16), + cyl(0.075, 0.075, 0.08, 8, [1.9, 0.5, 0.4], 0.24, 1.28, -0.16), + cone(0.2, 0.18, 7, rgb('#2e3033'), -0.3, 0, 0.34), // coal pile + box(0.1, 0.06, 0.06, [2.4, 0.9, 0.25], 0, 0.2, 0.32) + ]); +} +function solarGeo() { + const parts = [box(0.94, 0.015, 0.94, rgb('#b9a77e'))]; + for (let r = 0; r < 3; r++) { + const g = box(0.8, 0.02, 0.2, [0.16, 0.24, 0.42], 0, 0.1, -0.3 + r * 0.3); + g.rotateX(-0.5); + parts.push(g); + parts.push(box(0.82, 0.03, 0.03, rgb('#8d959d'), 0, 0.06, -0.3 + r * 0.3)); + } + return merge(parts); +} +function windGeo() { + const blade = () => box(0.05, 0.5, 0.015, rgb(P.white)); + const b1 = blade(); b1.translate(0, 1.28, 0.26); + const b2 = blade(); b2.rotateX(2.09); b2.translate(0.225, 1.28, -0.13); + const b3 = blade(); b3.rotateX(-2.09); b3.translate(-0.225, 1.28, -0.13); + return merge([ + box(0.3, 0.04, 0.3, rgb('#9aa0a6')), + cyl(0.035, 0.05, 1.25, 7, rgb('#eef0f2')), + box(0.09, 0.09, 0.2, rgb('#e3e6e9'), 0, 1.25, 0.02), + b1, b2, b3, + box(0.03, 0.03, 0.03, [2.2, 0.4, 0.4], 0, 1.45, 0.12) + ]); +} +function rubbleGeo() { + const rng = mulberry32(1234); + const parts = [box(0.9, 0.01, 0.9, rgb('#565656'))]; + for (let i = 0; i < 7; i++) { + const s = 0.08 + rng() * 0.14; + parts.push(box(s, s * 0.6, s, rgb(['#4a4a4a', '#5d5d5d', '#3f3f3f'][i % 3]), + (rng() - 0.5) * 0.7, 0.005, (rng() - 0.5) * 0.7, rng() * Math.PI)); + } + return merge(parts); +} + +// ---------------- scenery ---------------- +export function treeGeo(tall) { + const parts = [ + cyl(0.028, 0.038, 0.12, 5, rgb('#6b4f35')) + ]; + if (tall) { + parts.push(cone(0.15, 0.26, 6, rgb('#3f7d44'), 0, 0.1, 0)); + parts.push(cone(0.11, 0.2, 6, rgb('#4f9153'), 0, 0.26, 0)); + } else { + parts.push(cone(0.14, 0.28, 6, rgb('#43804a'), 0, 0.1, 0)); + } + return merge(parts); +} +export function rockGeo() { + const g = new THREE.DodecahedronGeometry(0.09, 0); + g.scale(1, 0.6, 1); + g.translate(0, 0.04, 0); + return addColor(g, rgb('#8d8d88')); +} + +// ---------------- registry ---------------- +let geoCache = null; +export function getGeoCache() { + if (geoCache) return geoCache; + geoCache = {}; + for (let lvl = 1; lvl <= 3; lvl++) { + for (let v = 0; v < 3; v++) { + geoCache[`res_${lvl}_${v}`] = residential(lvl, v); + geoCache[`com_${lvl}_${v}`] = commercial(lvl, v); + geoCache[`ind_${lvl}_${v}`] = industrial(lvl, v); + } + } + geoCache.police = policeGeo(); + geoCache.fire = fireGeo(); + geoCache.hospital = hospitalGeo(); + geoCache.school = schoolGeo(); + geoCache.park = parkGeo(); + geoCache.plaza = plazaGeo(); + geoCache.stadium = stadiumGeo(); + geoCache.coal = coalGeo(); + geoCache.solar = solarGeo(); + geoCache.wind = windGeo(); + geoCache.rubble = rubbleGeo(); + geoCache.tree = treeGeo(false); + geoCache.tree2 = treeGeo(true); + geoCache.rock = rockGeo(); + return geoCache; +} diff --git a/src/render/renderer.js b/src/render/renderer.js new file mode 100644 index 0000000..f860198 --- /dev/null +++ b/src/render/renderer.js @@ -0,0 +1,544 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; +import { GRID, STRUCT, ZONE, BUILDINGS } from '../config.js'; +import { getGeoCache, matBuilding } from './geometry.js'; +import { Traffic } from './cars.js'; +import { mulberry32, clamp } from '../utils.js'; + +const WORLD = GRID; +const HALF = WORLD / 2; +const UP_AXIS = new THREE.Vector3(0, 1, 0); + +// ============================================================ +// Main 3D renderer: ground, water, instanced city pools, +// day/night cycle, camera controls and picking helpers. +// ============================================================ +export class Renderer { + constructor(container, city, settings) { + this.city = city; + this.settings = settings; + + const w = container.clientWidth, h = container.clientHeight; + // preserveDrawingBuffer keeps the frame readable for user screenshots + // (right-click save / "share your city") and headless captures. + this.renderer3 = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); + this.renderer3.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer3.setSize(w, h); + this.renderer3.shadowMap.enabled = !!settings.shadows; + this.renderer3.shadowMap.type = THREE.PCFSoftShadowMap; + this.renderer3.toneMapping = THREE.ACESFilmicToneMapping; + this.renderer3.toneMappingExposure = 1.12; + container.appendChild(this.renderer3.domElement); + this.domElement = this.renderer3.domElement; + + this.scene = new THREE.Scene(); + this.scene.background = new THREE.Color('#86b7dd'); + this.scene.fog = new THREE.Fog('#86b7dd', 70, 260); + + this.camera = new THREE.PerspectiveCamera(55, w / h, 0.1, 600); + this.camera.position.set(0, 52, 58); + + this.controls = new OrbitControls(this.camera, this.renderer3.domElement); + this.controls.enableDamping = true; + this.controls.dampingFactor = 0.08; + this.controls.minDistance = 9; + this.controls.maxDistance = 170; + this.controls.maxPolarAngle = 1.32; + this.controls.target.set(0, 0, 0); + this.controls.mouseButtons = { + LEFT: -1, // left is reserved for painting tools (input.js) + MIDDLE: THREE.MOUSE.PAN, + RIGHT: THREE.MOUSE.ROTATE + }; + this.controls.touches = { ONE: -1, TWO: THREE.TOUCH.DOLLY_ROTATE }; + this.controls.zoomSpeed = 0.9; + this.controls.panSpeed = 0.9; + + // lights + this.hemi = new THREE.HemisphereLight('#cfe8ff', '#5a7050', 0.85); + this.scene.add(this.hemi); + this.sun = new THREE.DirectionalLight('#fff3d6', 1.25); + this.sun.position.set(40, 60, 20); + if (settings.shadows) { + this.sun.castShadow = true; + this.sun.shadow.mapSize.set(2048, 2048); + const sc = this.sun.shadow.camera; + sc.left = -50; sc.right = 50; sc.top = 50; sc.bottom = -50; + sc.near = 10; sc.far = 220; + this.sun.shadow.bias = -0.0004; + } + this.scene.add(this.sun); + + // world + this._buildGround(); + this._buildWater(); + this._buildRoadGeos(); + + // zone overlay quad + materials + const q = new THREE.PlaneGeometry(0.94, 0.94); + q.rotateX(-Math.PI / 2); + q.translate(0, 0.03, 0); + this.quadGeo = q; + const overlayMat = (hex) => new THREE.MeshBasicMaterial({ + map: makeZoneTexture(hex), transparent: true, depthWrite: false + }); + this.zoneOverlayMats = { + [ZONE.RES]: overlayMat('#35e08a'), + [ZONE.COM]: overlayMat('#4aa8ff'), + [ZONE.IND]: overlayMat('#ffb020') + }; + + // instanced pools + this.geoCache = getGeoCache(); + this.pools = new Map(); + + // scenery jitter table + const srng = mulberry32(city.seed ^ 0x77aa11); + this.scenJitter = new Float32Array(city.grid.n * 4); + for (let i = 0; i < city.grid.n * 4; i++) this.scenJitter[i] = srng(); + + // cursor helpers + const hlPts = [ + new THREE.Vector3(-0.5, 0.09, -0.5), new THREE.Vector3(0.5, 0.09, -0.5), + new THREE.Vector3(0.5, 0.09, 0.5), new THREE.Vector3(-0.5, 0.09, 0.5), + ]; + this.hoverMesh = new THREE.LineLoop( + new THREE.BufferGeometry().setFromPoints(hlPts), + new THREE.LineBasicMaterial({ color: '#ffffff', transparent: true, opacity: 0.9 }) + ); + this.hoverMesh.visible = false; + this.scene.add(this.hoverMesh); + + this.dragRect = new THREE.Mesh( + new THREE.BoxGeometry(1, 0.05, 1), + new THREE.MeshBasicMaterial({ color: '#4ad06a', transparent: true, opacity: 0.32, depthWrite: false }) + ); + this.dragRect.visible = false; + this.scene.add(this.dragRect); + + this.ghost = null; + this.ghostKey = null; + + // traffic + this.traffic = new Traffic(this.scene, city); + + // scratch + this.timeOfDay = 0.30; + this._m = new THREE.Matrix4(); + this._q = new THREE.Quaternion(); + this._v = new THREE.Vector3(); + this._s = new THREE.Vector3(); + this.skyDay = new THREE.Color('#86b7dd'); + this.skyNight = new THREE.Color('#0b1026'); + this._skyTmp = new THREE.Color(); + + this._ac = new AbortController(); + window.addEventListener('resize', () => this.resize(container), { signal: this._ac.signal }); + } + + dispose() { + try { + this._ac.abort(); + this.renderer3.dispose(); + this.domElement.remove(); + for (const [, p] of this.pools) p.mesh.dispose(); + this.scene.traverse(o => { + if (o.geometry) o.geometry.dispose(); + if (o.material && o.material !== matBuilding) o.material.dispose?.(); + }); + } catch { /* best effort */ } + } + + resize(container) { + const w = container.clientWidth, h = container.clientHeight; + this.camera.aspect = w / h; + this.camera.updateProjectionMatrix(); + this.renderer3.setSize(w, h); + } + + // ---------------- static world ---------------- + + _buildGround() { + const segs = WORLD; + const geo = new THREE.PlaneGeometry(WORLD, WORLD, segs, segs); + geo.rotateX(-Math.PI / 2); + const pos = geo.attributes.position; + const colors = new Float32Array(pos.count * 3); + const g = this.city.grid; + const noise = mulberry32(this.city.seed ^ 0x55); + const shade = []; + for (let i = 0; i < 64; i++) shade.push(noise()); + const cGrassA = new THREE.Color('#69a84f'), cGrassB = new THREE.Color('#82bd63'); + const cSand = new THREE.Color('#d9c98e'), cWaterDeep = new THREE.Color('#17456e'); + const cSkirt = new THREE.Color('#2c62a8'); + const tmp = new THREE.Color(); + for (let vi = 0; vi < pos.count; vi++) { + const x = Math.round(pos.getX(vi) + HALF - 0.5); + const z = Math.round(pos.getZ(vi) + HALF - 0.5); + if (!g.inB(x, z)) { tmp.copy(cSkirt); } + else { + const i = g.idx(x, z); + if (g.terrain[i] === 1) tmp.copy(cWaterDeep); + else { + const n = shade[(x * 7 + z * 13) & 63]; + tmp.lerpColors(cGrassA, cGrassB, n); + let nearWater = false; + for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx, nz = z + dz; + if (g.inB(nx, nz) && g.terrain[g.idx(nx, nz)] === 1) { nearWater = true; break; } + } + if (nearWater) tmp.lerp(cSand, 0.65); + } + } + colors[vi * 3] = tmp.r; colors[vi * 3 + 1] = tmp.g; colors[vi * 3 + 2] = tmp.b; + } + geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.95, metalness: 0 })); + mesh.receiveShadow = !!this.settings.shadows; + this.scene.add(mesh); + } + + _buildWater() { + const g = this.city.grid; + const parts = []; + for (let z = 0; z < WORLD; z++) { + for (let x = 0; x < WORLD; x++) { + if (g.terrain[g.idx(x, z)] !== 1) continue; + const q = new THREE.PlaneGeometry(1.002, 1.002); + q.rotateX(-Math.PI / 2); + q.translate(x - HALF + 0.5, 0.07, z - HALF + 0.5); + parts.push(q); + } + } + if (!parts.length) return; + const merged = mergeGeometries(parts.map(p => (p.index ? p.toNonIndexed() : p))); + parts.forEach(p => p.dispose()); + const mat = new THREE.MeshStandardMaterial({ + color: '#2f6fb3', transparent: true, opacity: 0.92, + roughness: 0.28, metalness: 0.15 + }); + this.waterMesh = new THREE.Mesh(merged, mat); + this.scene.add(this.waterMesh); + } + + _buildRoadGeos() { + const mk = (w, h, d, c, x, y, z) => coloredBox(w, h, d, c, x, y, z); + this.geoRoad = mergeGeometries([ + mk(0.96, 0.05, 0.96, rgbHex('#3b3e44'), 0, 0, 0), + mk(0.08, 0.052, 0.96, rgbHex('#5a5e66'), -0.44, 0, 0), + mk(0.08, 0.052, 0.96, rgbHex('#5a5e66'), 0.44, 0, 0) + ].map(p => (p.index ? p.toNonIndexed() : p))); + + this.geoRoadBridge = mergeGeometries([ + mk(0.98, 0.07, 0.98, rgbHex('#84878c'), 0, 0, 0), + mk(0.98, 0.05, 0.05, rgbHex('#9aa0a6'), 0, 0.09, -0.46), + mk(0.98, 0.05, 0.05, rgbHex('#9aa0a6'), 0, 0.09, 0.46), + mk(0.08, 0.45, 0.08, rgbHex('#6f7378'), -0.3, -0.45, 0), + mk(0.08, 0.45, 0.08, rgbHex('#6f7378'), 0.3, -0.45, 0) + ].map(p => (p.index ? p.toNonIndexed() : p))); + + this.geoDash = mk(0.36, 0.006, 0.07, [1.5, 1.45, 1.2], 0, 0.055, 0); + } + + // ---------------- instanced pools ---------------- + + _ensurePool(key, geometry, material, count) { + let p = this.pools.get(key); + if (!p || p.cap < count || p.cap > count * 3 + 24) { + if (p) { this.scene.remove(p.mesh); p.mesh.dispose(); } + const cap = Math.max(count, 8); + const mesh = new THREE.InstancedMesh(geometry, material, cap); + mesh.count = count; + mesh.castShadow = !!this.settings.shadows && key !== 'dash' && !key.startsWith('zoneov'); + mesh.receiveShadow = !!this.settings.shadows; + mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(cap * 3).fill(1), 3); + mesh.frustumCulled = false; + this.scene.add(mesh); + p = { mesh, cap }; + this.pools.set(key, p); + } else { + p.mesh.count = count; + } + return p; + } + + tileWorld(x, z) { return [x - HALF + 0.5, z - HALF + 0.5]; } + + /** Rebuild all instanced content from the grid. Runs on any city change. */ + sync() { + const city = this.city, g = city.grid, S = g.size; + const buckets = new Map(); + // entry layout: [wx, wz, rotY, sx, sy, sz, tr, tg, tb] + const push = (key, wx, wz, rotY = 0, sx = 1, sy = 1, sz = 1, tr = 1, tg = 1, tb = 1) => { + let arr = buckets.get(key); + if (!arr) { arr = []; buckets.set(key, arr); } + arr.push(wx, wz, rotY, sx, sy, sz, tr, tg, tb); + }; + const TINT_BURN = [2.1, 0.85, 0.3]; + const TINT_ABANDONED = [0.42, 0.4, 0.38]; + const TINT_NOPOWER = [0.72, 0.74, 0.82]; + let burningCount = 0; + + for (let i = 0; i < g.n; i++) { + const s = g.struct[i]; + const x = i % S, z = (i - x) / S; + const [wx, wz] = this.tileWorld(x, z); + + if (s !== 0 && s !== STRUCT.ROAD && g.anchor[i] !== i) continue; + + let tint = null; + if (g.burning[i]) { burningCount++; tint = TINT_BURN; } + else if (g.isDeveloped(i)) { + const abandoned = g.badMonths[i] >= 2 && !g.powered[i]; + if (abandoned) tint = TINT_ABANDONED; + else if (!g.powered[i]) tint = TINT_NOPOWER; + } + + if (g.rubble[i]) { push('rubble', wx, wz, 0, 1, 1, 1); continue; } + + if (s === STRUCT.ROAD) { + const bridge = g.terrain[i] === 1; + if (bridge) push('road_bridge', wx, wz); + else push('road', wx, wz); + if (!bridge) { + const N = g.inB(x, z - 1) && g.struct[g.idx(x, z - 1)] === STRUCT.ROAD; + const So = g.inB(x, z + 1) && g.struct[g.idx(x, z + 1)] === STRUCT.ROAD; + const E = g.inB(x + 1, z) && g.struct[g.idx(x + 1, z)] === STRUCT.ROAD; + const W = g.inB(x - 1, z) && g.struct[g.idx(x - 1, z)] === STRUCT.ROAD; + if ((N && So && !E && !W)) push('dash', wx, wz, 0); + else if ((E && W && !N && !So)) push('dash', wx, wz, Math.PI / 2); + } + continue; + } + + if (s !== 0) { + const a = g.anchor[i]; + const ax = a % S, az = (a - ax) / S; + const meta = BUILDINGS[s]; + const cx = ax + (meta.w - 1) / 2, cz = az + (meta.h - 1) / 2; + const [cwx, cwz] = this.tileWorld(cx, cz); + push(meta.id, cwx, cwz, 0, 1, 1, 1, ...(tint || ONES)); + continue; + } + + if (g.zone[i] !== ZONE.NONE) { + if (g.level[i] > 0) { + const nameKey = ['res', 'com', 'ind'][g.zone[i] - 1]; + push(`${nameKey}_${g.level[i]}_${g.variant[i] % 3}`, wx, wz, 0, 1, 1, 1, ...(tint || ONES)); + } else { + push(`zoneov_${g.zone[i]}`, wx, wz); + } + continue; + } + + // untouched land scenery + if (g.terrain[i] === 0 && !g.burning[i] && g.scenery[i]) { + const sc = g.scenery[i]; + const j = i * 4, jt = this.scenJitter; + const jx = (jt[j] - 0.5) * 0.34, jz = (jt[j + 1] - 0.5) * 0.34; + const rot = jt[j + 2] * Math.PI * 2; + const scl = 0.85 + jt[j + 3] * 0.4; + const key = sc === 3 ? 'rock' : sc === 2 ? 'tree2' : 'tree'; + push(key, wx + jx, wz + jz, rot, scl, scl, scl); + } + } + this.burningTilesCount = burningCount; + + for (const [key, flat] of buckets) { + const count = flat.length / 9; + let geometry, material = matBuilding; + if (key.startsWith('zoneov_')) { + geometry = this.quadGeo; + material = this.zoneOverlayMats[+key.slice(7)]; + } else if (key === 'road') geometry = this.geoRoad; + else if (key === 'road_bridge') geometry = this.geoRoadBridge; + else if (key === 'dash') geometry = this.geoDash; + else geometry = this.geoCache[key]; + if (!geometry) continue; + + const p = this._ensurePool(key, geometry, material, count); + const mesh = p.mesh; + for (let k = 0; k < count; k++) { + const o = k * 9; + this._v.set(flat[o], 0, flat[o + 1]); + this._q.setFromAxisAngle(UP_AXIS, flat[o + 2]); + this._s.set(flat[o + 3], flat[o + 4], flat[o + 5]); + this._m.compose(this._v, this._q, this._s); + mesh.setMatrixAt(k, this._m); + mesh.instanceColor.setXYZ(k, flat[o + 6], flat[o + 7], flat[o + 8]); + } + mesh.instanceMatrix.needsUpdate = true; + mesh.instanceColor.needsUpdate = true; + } + for (const [key, p] of this.pools) { + if (!buckets.has(key)) { + p.mesh.count = 0; + p.mesh.instanceMatrix.needsUpdate = true; + } + } + } + + // ---------------- frame ---------------- + + frame(dt, simSpeedIdx, paused) { + const speedMul = paused ? 0 : ([0, 1, 2.6, 5.2][simSpeedIdx] || 1); + this.timeOfDay = (this.timeOfDay + dt * speedMul / 300) % 1; + this._updateSky(); + + this.controls.update(); + const carMul = paused ? 0 : ([0, 1, 1.7, 2.6][simSpeedIdx] || 1); + this.traffic.update(dt * carMul); + + this._adaptiveQuality(dt); + this.renderer3.render(this.scene, this.camera); + } + + /** Drop internal resolution when frames are consistently expensive + * (software GL / weak GPUs), restore it when there is headroom. */ + _adaptiveQuality(dt) { + this._emaDt = (this._emaDt ?? dt) * 0.95 + dt * 0.05; + this._qCheck = (this._qCheck ?? 0) + dt; + if (this._qCheck < 2) return; // evaluate every ~2 s + this._qCheck = 0; + const level = this._qualityLevel ?? 0; // 0 = native, 3 = quarter + if (this._emaDt > 0.09 && level < 3) { + this._setQualityLevel(level + 1); + } else if (this._emaDt < 0.03 && level > 0) { + this._setQualityLevel(level - 1); + } + } + + _setQualityLevel(level) { + this._qualityLevel = level; + const scale = [1, 0.75, 0.6, 0.45][level]; + this.renderer3.setPixelRatio(Math.min(window.devicePixelRatio, 2) * scale); + console.info(`PolyCity: render scale ${(scale * 100) | 0}%`); + } + + _updateSky() { + const a = this.timeOfDay * Math.PI * 2; + const sunH = Math.sin(a); + const daylight = smooth01((sunH + 0.15) / 0.45); + this._skyTmp.copy(this.skyNight).lerp(this.skyDay, daylight); + this.scene.background.copy(this._skyTmp); + this.scene.fog.color.copy(this._skyTmp); + this.hemi.intensity = 0.22 + 0.78 * daylight; + this.sun.intensity = 1.35 * daylight; + const R = 90; + this.sun.position.set(Math.cos(a) * R * 0.7, Math.max(6, sunH * R), 30); + if (this.waterMesh) { + this.waterMesh.material.emissive.set(daylight > 0.4 ? '#0a2033' : '#101c30'); + this.waterMesh.material.emissiveIntensity = 0.5 + 0.35 * Math.sin(performance.now() / 900); + } + } + + // ---------------- picking / cursors ---------------- + + pickTile(clientX, clientY) { + const rect = this.domElement.getBoundingClientRect(); + const ndc = new THREE.Vector2( + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1 + ); + const ray = new THREE.Raycaster(); + ray.setFromCamera(ndc, this.camera); + const dy = ray.ray.direction.y || -1e-9; + const t = dy < 0 ? -ray.ray.origin.y / dy : -1; + if (t <= 0) return null; + const p = ray.ray.origin.clone().addScaledVector(ray.ray.direction, t); + const x = Math.floor(p.x + HALF); + const z = Math.floor(p.z + HALF); + if (x < 0 || z < 0 || x >= WORLD || z >= WORLD) return null; + return { x, z }; + } + + setHover(tile, visible = true) { + this.hoverMesh.visible = visible && !!tile; + if (tile) this.hoverMesh.position.set(tile.x - HALF + 0.5, 0, tile.z - HALF + 0.5); + } + + showDragRect(x0, z0, x1, z1, ok = true) { + const minX = Math.min(x0, x1), maxX = Math.max(x0, x1); + const minZ = Math.min(z0, z1), maxZ = Math.max(z0, z1); + this.dragRect.visible = true; + this.dragRect.scale.set(maxX - minX + 1, 1, maxZ - minZ + 1); + this.dragRect.position.set((minX + maxX) / 2 - HALF + 0.5, 0.06, (minZ + maxZ) / 2 - HALF + 0.5); + this.dragRect.material.color.set(ok ? '#4ad06a' : '#e05555'); + } + + hideDragRect() { this.dragRect.visible = false; } + + setGhost(structId, tile, valid) { + if (!structId || !tile) { this.clearGhost(); return; } + const meta = BUILDINGS[structId]; + const geo = this.geoCache[meta.id]; + if (!geo) { this.clearGhost(); return; } + if (!this.ghost || this.ghostKey !== meta.id) { + this.clearGhost(); + this.ghost = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ transparent: true, opacity: 0.55, depthWrite: false })); + this.ghostKey = meta.id; + this.scene.add(this.ghost); + } + const cx = tile.x + (meta.w - 1) / 2, cz = tile.z + (meta.h - 1) / 2; + this.ghost.position.set(cx - HALF + 0.5, 0.02, cz - HALF + 0.5); + this.ghost.material.color.set(valid ? '#39d353' : '#ff5252'); + this.ghost.visible = true; + } + + clearGhost() { + if (this.ghost) { this.scene.remove(this.ghost); this.ghost = null; this.ghostKey = null; } + } + + setShadows(on) { + this.settings.shadows = on; + this.renderer3.shadowMap.enabled = on; + this.sun.castShadow = on; + for (const [key, p] of this.pools) { + p.mesh.castShadow = on && key !== 'dash' && !key.startsWith('zoneov'); + } + } +} + +// ---------------- helpers ---------------- +const ONES = [1, 1, 1]; + +function smooth01(v) { + const x = clamp(v, 0, 1); + return x * x * (3 - 2 * x); +} + +function rgbHex(hex) { + const c = new THREE.Color(hex); + return [c.r, c.g, c.b]; +} + +function coloredBox(w, h, d, c, x, y, z) { + const g = new THREE.BoxGeometry(w, h, d); + g.translate(x, y + h / 2, z); + const n = g.attributes.position.count; + const arr = new Float32Array(n * 3); + for (let i = 0; i < n; i++) { arr[i * 3] = c[0]; arr[i * 3 + 1] = c[1]; arr[i * 3 + 2] = c[2]; } + g.setAttribute('color', new THREE.BufferAttribute(arr, 3)); + return g; +} + +function makeZoneTexture(hex) { + const cv = document.createElement('canvas'); + cv.width = cv.height = 64; + const ctx = cv.getContext('2d'); + ctx.globalAlpha = 0.20; + ctx.fillStyle = hex; + ctx.fillRect(0, 0, 64, 64); + ctx.globalAlpha = 0.95; + ctx.strokeStyle = hex; + ctx.lineWidth = 7; + ctx.strokeRect(3.5, 3.5, 57, 57); + ctx.globalAlpha = 0.5; + ctx.lineWidth = 2; + ctx.setLineDash([6, 5]); + ctx.strokeRect(10, 10, 44, 44); + const tex = new THREE.CanvasTexture(cv); + tex.colorSpace = THREE.SRGBColorSpace; + return tex; +} diff --git a/src/save.js b/src/save.js new file mode 100644 index 0000000..e05abf1 --- /dev/null +++ b/src/save.js @@ -0,0 +1,85 @@ +import { SAVE_KEY } from './config.js'; +import { City } from './game/city.js'; + +/** + * Save system: autosave slot in localStorage, 3 manual slots, + * JSON export/import via file download / picker. + */ +export class SaveManager { + constructor(city) { + this.city = city; + this.SLOT_PREFIX = 'polycity.slot.'; + } + + autosave() { + try { + localStorage.setItem(SAVE_KEY, JSON.stringify(this.city.toJSON())); + return true; + } catch (e) { console.warn('Autosave failed', e); return false; } + } + + loadAutosave() { + return this._loadKey(SAVE_KEY); + } + + saveSlot(n) { + try { + localStorage.setItem(this.SLOT_PREFIX + n, JSON.stringify({ + ...this.city.toJSON(), + savedAt: Date.now() + })); + return true; + } catch (e) { console.warn('Save failed', e); return false; } + } + + slotInfo(n) { + const raw = localStorage.getItem(this.SLOT_PREFIX + n); + if (!raw) return null; + try { + const d = JSON.parse(raw); + return { name: d.name, pop: (d.history && d.history.length ? d.history[d.history.length - 1].pop : 0), savedAt: d.savedAt }; + } catch { return null; } + } + + loadSlot(n) { + return this._loadKey(this.SLOT_PREFIX + n); + } + + deleteSlot(n) { localStorage.removeItem(this.SLOT_PREFIX + n); } + + hasAutosave() { return !!localStorage.getItem(SAVE_KEY); } + + _loadKey(key) { + const raw = localStorage.getItem(key); + if (!raw) return null; + try { + return City.fromJSON(JSON.parse(raw)); + } catch (e) { + console.warn('Corrupt save', e); + return null; + } + } + + exportFile() { + const blob = new Blob([JSON.stringify(this.city.toJSON())], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${this.city.name.replace(/\s+/g, '-').toLowerCase()}-save.json`; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 2000); + } + + importFile(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + try { + resolve(City.fromJSON(JSON.parse(reader.result))); + } catch (e) { reject(e); } + }; + reader.onerror = reject; + reader.readAsText(file); + }); + } +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..3211e75 --- /dev/null +++ b/src/style.css @@ -0,0 +1,261 @@ +/* ============================================================ + PolyCity โ€” UI styles + ============================================================ */ +:root { + --bg: #0b1020; + --panel: rgba(13, 18, 34, 0.82); + --panel-solid: #10162a; + --line: rgba(255, 255, 255, 0.09); + --text: #e8ecf4; + --dim: #9aa3b5; + --accent: #35e08a; + --blue: #4aa8ff; + --orange: #ffb020; + --red: #ff5f56; + --radius: 12px; + font-size: 15px; +} + +* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } + +html, body { + margin: 0; padding: 0; height: 100%; + overflow: hidden; + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, sans-serif; +} + +#app { position: fixed; inset: 0; } +#app canvas { display: block; } + +#hud { position: fixed; inset: 0; pointer-events: none; } +#hud > * { pointer-events: auto; } +.hidden { display: none !important; } +.muted { opacity: .55; font-weight: 400; } +.dim { opacity: .6; } +.good { color: var(--accent); } +.bad { color: var(--red); } + +/* =================== top bar =================== */ +#topbar { + position: absolute; top: 0; left: 0; right: 0; + display: flex; align-items: center; justify-content: space-between; + gap: 10px; padding: 8px 12px; + background: linear-gradient(to bottom, rgba(8, 11, 22, 0.92), rgba(8, 11, 22, 0.55) 80%, transparent); + backdrop-filter: blur(8px); +} +.tb-left { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; min-width: 0; } +.tb-right { display: flex; align-items: center; gap: 6px; } + +#cityBadge { display: flex; flex-direction: column; line-height: 1.1; margin-right: 6px; } +#cityName { font-weight: 800; font-size: 17px; letter-spacing: .3px; white-space: nowrap; } +#milestoneTitle { font-size: 11px; color: var(--accent); text-transform: uppercase; letter-spacing: 1.2px; } + +.stat { display: flex; align-items: center; gap: 5px; background: rgba(255,255,255,.06); + border: 1px solid var(--line); border-radius: 999px; padding: 4px 11px; font-size: 14px; white-space: nowrap; } +.stat b { font-variant-numeric: tabular-nums; } +.stat .ico { font-size: 13px; filter: saturate(.9); } + +.tbtn { + width: 38px; height: 38px; border-radius: 10px; border: 1px solid var(--line); + background: rgba(255,255,255,.06); color: var(--text); font-size: 17px; cursor: pointer; + transition: background .15s, transform .05s; +} +.tbtn:hover { background: rgba(255,255,255,.14); } +.tbtn:active { transform: scale(.94); } + +#speedControls { display: flex; gap: 3px; margin-right: 8px; background: rgba(255,255,255,.06); + padding: 3px; border-radius: 10px; border: 1px solid var(--line); } +.spd { border: none; background: transparent; color: var(--dim); font-size: 11px; width: 40px; + height: 30px; border-radius: 7px; cursor: pointer; letter-spacing: -1px; } +.spd.active { background: var(--accent); color: #04220f; font-weight: 700; } +.spd:hover:not(.active) { color: var(--text); background: rgba(255,255,255,.08); } + +#rciBars { margin-left: 2px; opacity: .95; } + +/* =================== toolbar =================== */ +#toolbar { + position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%); + display: flex; align-items: stretch; gap: 4px; max-width: calc(100vw - 24px); + background: var(--panel); backdrop-filter: blur(10px); + border: 1px solid var(--line); border-radius: 16px; padding: 7px; + overflow-x: auto; scrollbar-width: none; +} +#toolbar::-webkit-scrollbar { display: none; } +.tb-sep { width: 1px; background: var(--line); margin: 4px 3px; } +.tl-btn { + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; + min-width: 58px; padding: 7px 6px 5px; border-radius: 11px; border: 1px solid transparent; + background: transparent; color: var(--text); cursor: pointer; transition: all .13s; +} +.tl-btn:hover { background: rgba(255,255,255,.09); border-color: var(--line); } +.tl-btn.active { background: rgba(53,224,138,.16); border-color: rgba(53,224,138,.65); + box-shadow: 0 0 14px rgba(53,224,138,.25) inset; } +.tl-ico svg { width: 23px; height: 23px; } +.tl-label { font-size: 10.5px; color: var(--dim); display: flex; gap: 4px; align-items: baseline; white-space: nowrap; } +.tl-btn.active .tl-label { color: var(--accent); } +.tl-cost { font-size: 9px; opacity: .75; } + +/* =================== toasts =================== */ +#toasts { position: absolute; top: 64px; left: 12px; display: flex; flex-direction: column; gap: 8px; max-width: 340px; } +.toast { + display: flex; gap: 9px; align-items: flex-start; + background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--blue); + padding: 10px 13px; border-radius: 10px; font-size: 13.5px; line-height: 1.35; + animation: slidein .25s ease; backdrop-filter: blur(8px); + transition: opacity .5s, transform .5s; +} +.toast.warn { border-left-color: var(--orange); } +.toast.danger { border-left-color: var(--red); animation: shake .4s; } +.toast.success { border-left-color: var(--accent); } +.toast.gone { opacity: 0; transform: translateX(-16px); } +@keyframes slidein { from { opacity: 0; transform: translateY(-8px); } } +@keyframes shake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-4px);} 75% { transform: translateX(4px);} } + +/* =================== tooltip & popup =================== */ +#tooltip { + position: fixed; z-index: 60; pointer-events: none; + background: rgba(5, 8, 16, .92); border: 1px solid var(--line); + padding: 5px 10px; border-radius: 8px; font-size: 12.5px; color: var(--text); +} +#tooltip.bad { color: var(--red); border-color: rgba(255,95,86,.5); } + +#infoPopup { + position: fixed; z-index: 55; width: 225px; + background: var(--panel); backdrop-filter: blur(10px); + border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; + font-size: 13px; box-shadow: 0 12px 32px rgba(0,0,0,.45); +} +.ip-head { display: flex; justify-content: space-between; align-items: center; + padding: 8px 12px; background: rgba(255,255,255,.06); font-weight: 700; } +.ip-close { background: none; border: none; color: var(--dim); font-size: 16px; cursor: pointer; } +.ip-row { display: flex; justify-content: space-between; padding: 5px 12px; } +.ip-row:nth-child(odd) { background: rgba(255,255,255,.03); } +.ip-row b { font-variant-numeric: tabular-nums; } + +/* =================== cards / quests =================== */ +.card { + background: var(--panel); backdrop-filter: blur(10px); + border: 1px solid var(--line); border-radius: var(--radius); +} +#questBox { + position: absolute; top: 64px; right: 12px; width: 250px; padding: 10px 12px; + font-size: 12.5px; max-height: 46vh; overflow-y: auto; +} +.q-head { margin-bottom: 6px; letter-spacing: .3px; } +.q-item { padding: 3px 0; color: var(--dim); } +.q-item.done { color: var(--accent); text-decoration: line-through; opacity: .8; } + +/* =================== side panel =================== */ +#sidePanelRight { + position: absolute; top: 64px; right: 12px; width: 285px; max-height: calc(100vh - 160px); + overflow-y: auto; +} +.sp-head { display: flex; justify-content: space-between; align-items: center; padding: 12px 14px 8px; } +.sp-head h3 { margin: 0; font-size: 15px; } +.sp-close { background: none; border: none; color: var(--dim); font-size: 19px; cursor: pointer; } +.sp-body { padding: 0 14px 14px; font-size: 13px; } +.tax-row input[type=range] { width: 100%; accent-color: var(--accent); } +.hint { color: var(--dim); font-size: 12px; line-height: 1.45; } +table.ledger { width: 100%; border-collapse: collapse; margin-top: 10px; } +table.ledger td { padding: 6px 2px; border-bottom: 1px solid var(--line); } +table.ledger td:last-child { text-align: right; font-variant-numeric: tabular-nums; } +.kv-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px; } +.kv-grid div { background: rgba(255,255,255,.05); border-radius: 9px; padding: 8px 10px; } +.kv-grid span { display: block; font-size: 11px; color: var(--dim); } +.kv-grid b { font-size: 15px; font-variant-numeric: tabular-nums; } +.meter label { font-size: 12px; color: var(--dim); } +.meter-bar { height: 8px; border-radius: 99px; background: rgba(255,255,255,.09); overflow: hidden; margin-top: 4px; } +.meter-bar i { display: block; height: 100%; background: var(--blue); border-radius: 99px; } +.meter-bar i.hot { background: var(--red); } +#chartPop, #chartFunds { width: 100%; background: rgba(255,255,255,.04); border-radius: 8px; } +h4 { margin: 12px 0 4px; font-size: 12.5px; color: var(--dim); } + +/* =================== minimap =================== */ +#minimapWrap { + position: absolute; right: 12px; bottom: 90px; + border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; + box-shadow: 0 10px 26px rgba(0,0,0,.4); +} +#minimap { display: block; image-rendering: pixelated; } + +/* =================== modals =================== */ +.modal-back { + position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; + background: rgba(4, 6, 12, .6); backdrop-filter: blur(3px); animation: fadein .18s ease; +} +@keyframes fadein { from { opacity: 0; } } +.modal-card { + width: min(600px, calc(100vw - 28px)); max-height: calc(100vh - 40px); overflow-y: auto; + background: var(--panel-solid); border: 1px solid var(--line); border-radius: 16px; + padding: 22px 24px; box-shadow: 0 24px 70px rgba(0,0,0,.55); +} +.modal-card h2 { margin: 0 0 10px; } +.modal-card h3 { margin: 18px 0 8px; font-size: 14px; color: var(--dim); text-transform: uppercase; letter-spacing: 1px; } +.help-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } +@media (max-width: 560px) { .help-cols { grid-template-columns: 1fr; } } +.help-cols ul, .help-cols ol { margin: 6px 0; padding-left: 18px; font-size: 13px; line-height: 1.65; } +.help-book { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +@media (max-width: 640px) { .help-book { grid-template-columns: 1fr; } } +.hb-item { display: flex; gap: 10px; background: rgba(255,255,255,.045); border-radius: 10px; padding: 9px 11px; font-size: 12px; line-height: 1.45; } +.hb-item em { color: var(--dim); font-style: normal; font-size: 11px; } +.hb-ico svg { width: 21px; height: 21px; margin-top: 2px; color: var(--accent); } + +.btn { + border: 1px solid var(--line); background: rgba(255,255,255,.07); color: var(--text); + padding: 9px 16px; border-radius: 10px; cursor: pointer; font-size: 13.5px; + transition: background .14s; +} +.btn:hover { background: rgba(255,255,255,.13); } +.btn.primary { background: var(--accent); border-color: transparent; color: #04220f; font-weight: 700; } +.btn.primary:hover { filter: brightness(1.08); } +.btn.danger { color: var(--red); } +.btn.sm { padding: 5px 10px; font-size: 12px; border-radius: 8px; } +.btn:disabled { opacity: .35; cursor: default; } +.menu-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; } + +.slot-row { display: flex; align-items: center; gap: 8px; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 13px; flex-wrap: wrap; } +.slot-row span { flex: 1; min-width: 120px; } +.settings label { display: flex; align-items: center; gap: 9px; padding: 6px 0; font-size: 13.5px; cursor: pointer; } +.settings input { accent-color: var(--accent); width: 16px; height: 16px; } + +.fld { display: block; margin: 14px 0; font-size: 13px; color: var(--dim); } +.fld input, .fld select { + display: block; width: 100%; margin-top: 6px; padding: 10px 12px; + background: rgba(255,255,255,.06); color: var(--text); + border: 1px solid var(--line); border-radius: 10px; font-size: 14px; outline: none; +} +.fld input:focus, .fld select:focus { border-color: var(--accent); } +.seedrow { display: flex !important; gap: 8px; margin-top: 6px; } +.seedrow input { flex: 1; margin-top: 0 !important; } +.modal-card .btn.primary { width: 100%; margin-top: 14px; padding: 12px; font-size: 15px; } + +/* =================== boot screen =================== */ +#bootScreen { + position: fixed; inset: 0; z-index: 200; display: flex; align-items: center; justify-content: center; + background: radial-gradient(1200px 700px at 50% 30%, #14204a, #0b1020 70%); + transition: opacity .6s; +} +#bootScreen.off { opacity: 0; pointer-events: none; } +.boot-inner { text-align: center; } +.boot-inner h1 { font-size: 52px; margin: 0; letter-spacing: 8px; font-weight: 900; } +.boot-inner h1 span { color: var(--accent); } +.boot-inner p { color: var(--dim); margin: 8px 0 22px; } +.boot-bar { width: 220px; height: 4px; background: rgba(255,255,255,.1); border-radius: 99px; margin: 0 auto; overflow: hidden; } +.boot-bar i { display: block; width: 40%; height: 100%; background: var(--accent); border-radius: 99px; animation: boot 1.1s ease-in-out infinite alternate; } +@keyframes boot { from { transform: translateX(-60%); } to { transform: translateX(320%); } } + +/* =================== responsive =================== */ +@media (max-width: 760px) { + :root { font-size: 13.5px; } + #topbar { padding: 6px 8px; } + .stat { padding: 3px 8px; font-size: 12.5px; } + #cityName { font-size: 14px; } + .tl-btn { min-width: 48px; } + .tl-label { font-size: 9px; } + #questBox { display: none; } + #minimapWrap { bottom: 110px; right: 8px; } + #minimap { width: 120px; height: 120px; } + .spd { width: 30px; } +} diff --git a/src/ui/icons.js b/src/ui/icons.js new file mode 100644 index 0000000..7ccfc92 --- /dev/null +++ b/src/ui/icons.js @@ -0,0 +1,25 @@ +/** Inline stroke-style SVG icon set (24ร—24). */ +const S = 'fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"'; + +function svg(inner) { + return `${inner}`; +} + +export const ICONS = { + query: svg(''), + bulldoze: svg(''), + road: svg(''), + res: svg(''), + com: svg(''), + ind: svg(''), + coal: svg(''), + solar: svg(''), + wind: svg(''), + police: svg(''), + fire: svg(''), + hospital: svg(''), + school: svg(''), + park: svg(''), + plaza: svg(''), + stadium: svg('') +}; diff --git a/src/ui/ui.js b/src/ui/ui.js new file mode 100644 index 0000000..165d32d --- /dev/null +++ b/src/ui/ui.js @@ -0,0 +1,624 @@ +import { BUILDINGS, STRUCT, SIM, ZONE, GRID, SEEN_HELP_KEY } from '../config.js'; +import { ICONS } from './icons.js'; +import { fmtMoney, fmtNum } from '../utils.js'; + +const $ = (id) => document.getElementById(id); +const el = (html) => { + const t = document.createElement('template'); + t.innerHTML = html.trim(); + return t.content.firstElementChild; +}; + +const CITY_NAMES = ['Springvale', 'Port Aurora', 'New Meridian', 'Lakeharbor', 'Ironridge', + 'Sunfield', 'Baycrest', 'Fernwood', 'Stonebrook', 'Havenport']; + +const TOOLBAR = [ + { t: 'tool', id: 'query', icon: 'query', label: 'Inspect', key: 'Q', tip: 'Click tiles for details' }, + { t: 'tool', id: 'bulldoze', icon: 'bulldoze', label: 'Bulldoze', key: 'B', tip: '$3 per tile' }, + { t: 'tool', id: 'road', icon: 'road', label: 'Road', key: 'R', tip: '$10 ยท bridges $120' }, + { sep: true }, + { t: 'zone', id: 'zone_res', zname: 'res', icon: 'res', label: 'Homes', key: 'Z', tip: 'Free ยท drag a rectangle' }, + { t: 'zone', id: 'zone_com', zname: 'com', icon: 'com', label: 'Shops', key: 'X', tip: 'Free ยท jobs & taxes' }, + { t: 'zone', id: 'zone_ind', zname: 'ind', icon: 'ind', label: 'Industry', key: 'C', tip: 'Free ยท jobs, pollution' }, + { sep: true }, + { t: 'build', sid: STRUCT.COAL, icon: 'coal', label: 'Coal Plant' }, + { t: 'build', sid: STRUCT.SOLAR, icon: 'solar', label: 'Solar Farm' }, + { t: 'build', sid: STRUCT.WIND, icon: 'wind', label: 'Wind Turbine' }, + { sep: true }, + { t: 'build', sid: STRUCT.POLICE, icon: 'police', label: 'Police' }, + { t: 'build', sid: STRUCT.FIRE, icon: 'fire', label: 'Fire Stn' }, + { sep: true }, + { t: 'build', sid: STRUCT.HOSPITAL, icon: 'hospital', label: 'Hospital' }, + { t: 'build', sid: STRUCT.SCHOOL, icon: 'school', label: 'School' }, + { sep: true }, + { t: 'build', sid: STRUCT.PARK, icon: 'park', label: 'Park' }, + { t: 'build', sid: STRUCT.PLAZA, icon: 'plaza', label: 'Plaza' }, + { t: 'build', sid: STRUCT.STADIUM, icon: 'stadium', label: 'Stadium' } +]; + +export class UI { + constructor(game) { + this.game = game; + this.city = game.city; + this.settings = game.settings; + this.activeBtn = null; + this._mmTimer = null; + + this.buildToolbar(); + this.bindTopbar(); + this.bindKeys(); + + // quests + this.questDone = new Set(); + this.questDefs = [ + { id: 'zone', label: 'Zone some residential land', test: () => this.anyTile(i => this.city.grid.zone[i] === ZONE.RES) }, + { id: 'road', label: 'Lay your first road', test: () => this.anyTile(i => this.city.grid.struct[i] === STRUCT.ROAD) }, + { id: 'power', label: 'Build any power plant', test: () => this.anyTile(i => [STRUCT.COAL, STRUCT.SOLAR, STRUCT.WIND].includes(this.city.grid.struct[i])) }, + { id: 'grow', label: 'Welcome your first residents (pop โ‰ฅ 50)', test: () => this.city.stats.pop >= 50 }, + { id: 'fire', label: 'Protect citizens โ€” build a Fire Station', test: () => this.hasStruct(STRUCT.FIRE) }, + { id: 'happy', label: 'Keep happiness above 70%', test: () => this.city.stats.happiness >= 70 && this.city.stats.pop >= 100 }, + { id: 'pop500', label: 'Grow to 500 citizens', test: () => this.city.stats.pop >= 500 }, + { id: 'stadium', label: 'Celebrate โ€” build the Stadium', test: () => this.hasStruct(STRUCT.STADIUM) } + ]; + this.renderQuests(true); + + this.city.on('notify', ({ msg, kind }) => this.toast(msg, kind)); + this.city.on('money', () => this.updateHUD()); + this.city.on('stats', () => { this.updateHUD(); this.refreshSidePanel(); }); + this.city.on('date', () => this.updateHUD()); + this.city.on('tilesChanged', () => this.scheduleMinimap()); + this.city.on('milestone', () => this.game.audio.milestone()); + + if (!localStorage.getItem(SEEN_HELP_KEY)) { + setTimeout(() => { this.helpModal(); localStorage.setItem(SEEN_HELP_KEY, '1'); }, 400); + } + $('minimapWrap').classList.toggle('hidden', !this.settings.minimap); + this.updateHUD(); + } + + anyTile(fn) { + const g = this.city.grid; + for (let i = 0; i < g.n; i++) if (fn(i)) return true; + return false; + } + hasStruct(sid) { return this.anyTile(i => this.city.grid.struct[i] === sid); } + + // =================== toolbar =================== + + buildToolbar() { + const bar = $('toolbar'); + bar.innerHTML = ''; + for (const item of TOOLBAR) { + if (item.sep) { bar.appendChild(el('
')); continue; } + const btn = el(``); + btn.title = `${item.label}${item.key ? ` (${item.key})` : ''}${item.tip ? ' โ€” ' + item.tip : ''}`; + btn.addEventListener('click', () => { + this.game.audio.click(); + if (item.t === 'build') this.game.input.setTool('build', item.sid); + else this.game.input.setTool(item.id); + }); + btn.dataset.kind = item.t === 'build' ? `build_${item.sid}` : item.id; + bar.appendChild(btn); + if (item.t === 'build') { + const cost = BUILDINGS[item.sid].cost; + btn.querySelector('.tl-label').insertAdjacentHTML('beforeend', + `$${fmtNum(cost)}`); + } + } + } + + onToolChanged(tool, sid) { + document.querySelectorAll('#toolbar .tl-btn').forEach(b => b.classList.remove('active')); + let sel = null; + if (tool === 'build') sel = document.querySelector(`#toolbar [data-kind="build_${sid}"]`); + else sel = document.querySelector(`#toolbar [data-kind="${tool}"]`); + if (sel) sel.classList.add('active'); + const cursors = { bulldoze: 'not-allowed', query: 'help' }; + this.game.renderer.domElement.style.cursor = + tool === 'none' ? 'grab' : (cursors[tool] || 'crosshair'); + this.hideInfoPopup(); + } + + bindTopbar() { + document.querySelectorAll('#speedControls .spd').forEach(b => { + b.addEventListener('click', () => this.game.setSpeed(+b.dataset.speed)); + }); + $('btnBudget').addEventListener('click', () => { this.game.audio.click(); this.toggleSidePanel('budget'); }); + $('btnStats').addEventListener('click', () => { this.game.audio.click(); this.toggleSidePanel('stats'); }); + $('btnHelp').addEventListener('click', () => { this.game.audio.click(); this.helpModal(); }); + $('btnMenu').addEventListener('click', () => { this.game.audio.click(); this.menuModal(); }); + } + + setSpeedActive(idx) { + document.querySelectorAll('#speedControls .spd').forEach(b => { + b.classList.toggle('active', +b.dataset.speed === idx); + }); + } + + bindKeys() { + this._onKey = (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + const k = e.key.toLowerCase(); + switch (k) { + case ' ': e.preventDefault(); this.game.togglePause(); break; + case '1': this.game.setSpeed(1); break; + case '2': this.game.setSpeed(2); break; + case '3': this.game.setSpeed(3); break; + case 'q': this.game.input.setTool('query'); break; + case 'b': this.game.input.setTool('bulldoze'); break; + case 'r': this.game.input.setTool('road'); break; + case 'z': this.game.input.setTool('zone_res'); break; + case 'x': this.game.input.setTool('zone_com'); break; + case 'c': this.game.input.setTool('zone_ind'); break; + case 'escape': + if (this.modalOpen) this.closeModal(); + else this.game.input.setTool('none'); + this.closeInfoPopup(); + break; + case 'h': this.helpModal(); break; + } + }; + window.addEventListener('keydown', this._onKey); + } + + destroy() { + window.removeEventListener('keydown', this._onKey); + } + + // =================== HUD =================== + + updateHUD() { + const c = this.city, s = c.stats; + $('statFunds').textContent = fmtMoney(c.money); + $('statFunds').style.color = c.money < 0 ? '#ff7b72' : ''; + $('statPop').textContent = fmtNum(s.pop); + $('statDate').textContent = c.dateLabel(); + const h = s.happiness; + $('statHappy').textContent = h + '%'; + $('happyFace').textContent = h >= 80 ? '๐Ÿ˜„' : h >= 60 ? '๐Ÿ™‚' : h >= 40 ? '๐Ÿ˜' : h >= 20 ? '๐Ÿ™' : '๐Ÿ˜ก'; + $('milestoneTitle').textContent = SIM.milestonePops[c.milestoneIdx] ? SIM.milestonePops[c.milestoneIdx][1] : ''; + this.drawRCI(); + } + + drawRCI() { + const cv = $('rciBars'); + const ctx = cv.getContext('2d'); + ctx.clearRect(0, 0, cv.width, cv.height); + const s = this.city.stats; + const bars = [ + ['resDemand', '#35e08a'], + ['comDemand', '#4aa8ff'], + ['indDemand', '#ffb020'] + ]; + const midY = 19, maxH = 11; + bars.forEach(([key, color], i) => { + const v = Math.max(-1, Math.min(1, s[key])); + const x = 10 + i * 24; + const hgt = Math.abs(v) * maxH; + ctx.fillStyle = 'rgba(255,255,255,.14)'; + ctx.fillRect(x - 6, midY - maxH, 12, maxH * 2); + ctx.fillStyle = v >= 0 ? color : '#ff5f56'; + if (v >= 0) ctx.fillRect(x - 6, midY - hgt, 12, hgt); + else ctx.fillRect(x - 6, midY, 12, hgt); + }); + ctx.strokeStyle = 'rgba(255,255,255,.35)'; + ctx.beginPath(); ctx.moveTo(2, midY); ctx.lineTo(cv.width - 2, midY); ctx.stroke(); + } + + // =================== toasts / tooltip / popup =================== + + toast(msg, kind = 'info') { + const icons = { info: 'โ„น๏ธ', warn: 'โš ๏ธ', danger: '๐Ÿšจ', success: '๐ŸŽ‰' }; + const t = el(`
${icons[kind] || ''}${msg}
`); + $('toasts').appendChild(t); + if (kind === 'danger') this.game.audio.disaster(); + setTimeout(() => t.classList.add('gone'), 5200); + setTimeout(() => t.remove(), 5800); + while ($('toasts').children.length > 4) $('toasts').firstChild.remove(); + } + + showTooltip(x, y, text) { + const tt = $('tooltip'); + tt.textContent = text; + tt.classList.remove('hidden', 'bad'); + tt.style.left = Math.min(x + 14, window.innerWidth - 180) + 'px'; + tt.style.top = (y + 18) + 'px'; + } + + flashTooltip(text) { + const tt = $('tooltip'); + tt.textContent = text; + tt.classList.remove('hidden'); + tt.classList.add('bad'); + clearTimeout(this._flashT); + this._flashT = setTimeout(() => this.hideTooltip(), 1200); + } + + hideTooltip() { $('tooltip').classList.add('hidden'); } + + showInfoPopup(info, x, y) { + const p = $('infoPopup'); + let rows = ''; + const row = (k, v) => `
${k}${v}
`; + rows += row('Terrain', info.terrain); + rows += row('Type', info.type ?? 'โ€”'); + if (info.kind === 'service') rows += row('Status', info.active ? 'โœ… Connected' : 'โš ๏ธ No road access (inactive)'); + if (info.outputLabel) rows += row('Output', info.outputLabel); + if (info.kind === 'zone') { + rows += row('Development', info.rubble ? '๐Ÿ”ฅ Rubble' : info.burning ? '๐Ÿ”ฅ On fire!' : info.level ? `Level ${info.level}` : 'Empty plot'); + rows += row('Power', info.powered ? 'โšก Yes' : '๐ŸŒ‘ No'); + if (info.level) rows += row('Condition', info.abandoned ? '๐Ÿš๏ธ Abandoned' : 'OK'); + rows += row('Land value', info.landValue != null ? info.landValue : 'โ€”'); + rows += row('Pollution', info.pollution != null ? info.pollution : 'โ€”'); + } + p.innerHTML = `
Tile ${info.x},${info.z}
${rows}`; + p.classList.remove('hidden'); + p.style.left = Math.min(x + 12, window.innerWidth - 240) + 'px'; + p.style.top = Math.min(y + 12, window.innerHeight - 220) + 'px'; + p.querySelector('.ip-close').addEventListener('click', () => this.closeInfoPopup()); + } + + closeInfoPopup() { $('infoPopup').classList.add('hidden'); } + + // =================== minimap =================== + + scheduleMinimap() { + if (!this.settings.minimap) return; + if (this._mmTimer) return; + this._mmTimer = setTimeout(() => { this._mmTimer = null; this.drawMinimap(); }, 350); + } + + drawMinimap() { + const cv = $('minimap'); + const ctx = cv.getContext('2d'); + const g = this.city.grid, S = g.size; + const sc = cv.width / S; + const blink = (performance.now() / 500 | 0) % 2 === 0; + for (let z = 0; z < S; z++) { + for (let x = 0; x < S; x++) { + const i = g.idx(x, z); + let col; + if (g.burning[i]) col = blink ? '#ff3b30' : '#7a1f1a'; + else if (g.rubble[i]) col = '#3a3a3a'; + else if (g.struct[i] !== 0) col = this.serviceColor(g.struct[i]); + else if (g.zone[i] !== 0) { + if (g.level[i] > 0) col = this.zoneColor(g.zone[i], g.level[i]); + else col = this.zoneTint(g.zone[i]); + } else if (g.terrain[i] === 1) col = '#1d4f7e'; + else col = g.scenery[i] ? '#35682d' : '#3f7a37'; + ctx.fillStyle = col; + ctx.fillRect(x * sc, z * sc, sc + 0.5, sc + 0.5); + } + } + } + + serviceColor(s) { + switch (s) { + case STRUCT.ROAD: return '#585c63'; + case STRUCT.COAL: return '#c96f2f'; + case STRUCT.SOLAR: return '#e0c34a'; + case STRUCT.WIND: return '#dddddd'; + case STRUCT.POLICE: return '#4a7dff'; + case STRUCT.FIRE: return '#ff5347'; + case STRUCT.HOSPITAL: return '#ff7ba9'; + case STRUCT.SCHOOL: return '#b388ff'; + default: return '#69f0ae'; + } + } + zoneColor(zv, lvl) { + const base = { 1: ['#4fae74', '#41c487', '#2fdb9b'], 2: ['#4a86c8', '#4a9de0', '#52b4f2'], 3: ['#b28f3c', '#c9a53f', '#e0bb44'] }[zv]; + return base[lvl - 1]; + } + zoneTint(zv) { + return { 1: 'rgba(63,220,132,0.45)', 2: 'rgba(74,168,255,0.45)', 3: 'rgba(255,176,32,0.45)' }[zv]; + } + + // =================== side panels =================== + + toggleSidePanel(which) { + const p = $('sidePanelRight'); + if (!p.classList.contains('hidden') && p.dataset.which === which) { this.closeSidePanel(); return; } + p.dataset.which = which; + p.classList.remove('hidden'); + this.renderSidePanel(); + } + + closeSidePanel() { $('sidePanelRight').classList.add('hidden'); } + + renderSidePanel() { + const p = $('sidePanelRight'); + if (p.classList.contains('hidden')) return; + if (p.dataset.which === 'budget') this.renderBudget(p); + else this.renderStats(p); + } + + refreshSidePanel() { this.renderSidePanel(); } + + renderBudget(p) { + const c = this.city; + const lb = c.lastBudget; + const net = lb.taxRes + lb.taxCom + lb.taxInd - lb.upkeep; + p.innerHTML = ` +

๐Ÿฆ Budget

+
+ +

+ + + + + + + + +
Residential taxes${fmtMoney(lb.taxRes)}
Commercial taxes${fmtMoney(lb.taxCom)}
Industrial taxes${fmtMoney(lb.taxInd)}
Road upkeep-${fmtMoney(lb.roads)}
Service upkeep-${fmtMoney(Math.max(0, lb.upkeep - lb.roads))}
Net / month${net >= 0 ? '+' : ''}${fmtMoney(net)}
Treasury${fmtMoney(c.money)}
+

Figures show last month. Higher taxes fund services but slow growth and hurt happiness.

+
`; + p.querySelector('.sp-close').addEventListener('click', () => this.closeSidePanel()); + const slider = p.querySelector('#taxSlider'); + slider.addEventListener('input', () => { + c.taxRate = +slider.value; + p.querySelector('#taxVal').textContent = c.taxRate + '%'; + const hint = p.querySelector('#taxHint'); + if (c.taxRate <= 7) hint.textContent = 'Citizens love low taxes โ€” growth is fast.'; + else if (c.taxRate <= 11) hint.textContent = 'Balanced rate.'; + else if (c.taxRate <= 15) hint.textContent = 'Getting steep โ€” demand is softening.'; + else hint.textContent = 'Crushing! Expect abandonment and anger.'; + this.updateHUD(); + }); + } + + renderStats(p) { + const s = this.city.stats; + const c = this.city; + const powerPct = s.powerCap > 0 ? Math.min(100, Math.round(s.powerUse / s.powerCap * 100)) : 0; + const nextMs = SIM.milestonePops[c.milestoneIdx + 1]; + p.innerHTML = ` +

๐Ÿ“Š Statistics

+
+
+
Population${fmtNum(s.pop)}
+
Jobs${fmtNum(s.jobs)}
+
Unemployment${Math.round(s.unemployment * 100)}%
+
Happiness${s.happiness}%
+
Avg land value${Math.round(s.avgLandValue)}
+
Title${SIM.milestonePops[c.milestoneIdx][1]}
+
+
+
+
+ ${nextMs ? `

Next milestone: ${nextMs[1]} at ${fmtNum(nextMs[0])} citizens.

` : '

You reached the highest title! ๐Ÿ‘‘

'} +

Population

+

Treasury

+
`; + p.querySelector('.sp-close').addEventListener('click', () => this.closeSidePanel()); + this.drawChart(p.querySelector('#chartPop'), this.city.history.map(h => h.pop), '#35e08a'); + this.drawChart(p.querySelector('#chartFunds'), this.city.history.map(h => h.funds), '#ffd166'); + } + + drawChart(cv, data, color) { + const ctx = cv.getContext('2d'); + ctx.clearRect(0, 0, cv.width, cv.height); + if (data.length < 2) { + ctx.fillStyle = 'rgba(255,255,255,.4)'; + ctx.font = '11px system-ui'; + ctx.fillText('Not enough data yet โ€” let time flowโ€ฆ', 10, 40); + return; + } + const min = Math.min(...data), max = Math.max(...data); + const rng = (max - min) || 1; + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.beginPath(); + data.forEach((v, i) => { + const x = i / (data.length - 1) * (cv.width - 8) + 4; + const y = cv.height - 6 - (v - min) / rng * (cv.height - 14); + i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); + }); + ctx.stroke(); + ctx.fillStyle = 'rgba(255,255,255,.45)'; + ctx.font = '10px system-ui'; + ctx.fillText(fmtNum(max), 6, 11); + ctx.fillText(fmtNum(min), 6, cv.height - 2); + } + + // =================== quests =================== + + renderQuests(initial = false) { + const box = $('questBox'); + const allDone = this.questDefs.every(q => this.questDone.has(q.id)); + box.classList.toggle('hidden', allDone && !initial ? Date.now() - (this._questAllTime || 0) > 6000 : false); + if (allDone) { + if (!this._questAllTime) this._questAllTime = Date.now(); + box.innerHTML = `
๐Ÿ†All milestones complete!
`; + return; + } + box.innerHTML = `
๐ŸŽฏ City goals
` + + this.questDefs.map(q => { + const done = this.questDone.has(q.id); + return `
${done ? 'โœ…' : 'โฌœ'} ${q.label}
`; + }).join(''); + box.classList.remove('hidden'); + } + + checkQuests() { + let changed = false; + for (const q of this.questDefs) { + if (!this.questDone.has(q.id) && q.test()) { this.questDone.add(q.id); changed = true; } + } + if (changed) this.renderQuests(); + } + + resetQuests() { + this.questDone.clear(); + this._questAllTime = 0; + this.renderQuests(); + } + + // =================== modals =================== + + get modalOpen() { return !!$('modalRoot').firstChild; } + + showModal(contentEl) { + const root = $('modalRoot'); + root.innerHTML = ''; + const wrap = el(''); + wrap.querySelector('.modal').appendChild(contentEl); + wrap.addEventListener('pointerdown', (e) => { if (e.target === wrap) this.closeModal(); }); + root.appendChild(wrap); + return () => this.closeModal(); + } + + closeModal() { $('modalRoot').innerHTML = ''; } + + helpModal() { + const b = BUILDINGS; + const modal = el(``); + this.showModal(modal); + modal.querySelector('#helpClose').addEventListener('click', () => this.closeModal()); + } + + menuModal() { + const m = el(``); + this.showModal(m); + + const slotsDiv = m.querySelector('#slots'); + const renderSlots = () => { + slotsDiv.innerHTML = ''; + for (let n = 1; n <= 3; n++) { + const info = this.game.saves.slotInfo(n); + const row = el(`
+ Slot ${n} + ${info ? `${info.name} ยท pop ${fmtNum(info.pop)} ยท ${new Date(info.savedAt).toLocaleString()}` : 'โ€” empty โ€”'} + + + +
`); + slotsDiv.appendChild(row); + } + }; + renderSlots(); + slotsDiv.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + const n = +btn.dataset.n; + if (btn.dataset.a === 'save') { this.game.saves.saveSlot(n); this.toast(`Saved to slot ${n}.`, 'success'); renderSlots(); } + if (btn.dataset.a === 'load') { this.game.loadFrom(this.game.saves.loadSlot(n)); this.closeModal(); } + if (btn.dataset.a === 'del') { this.game.saves.deleteSlot(n); renderSlots(); } + }); + + m.querySelector('#mNew').addEventListener('click', () => this.newCityModal()); + m.querySelector('#mExport').addEventListener('click', () => this.game.saves.exportFile()); + m.querySelector('#mImport').addEventListener('click', () => { + const inp = document.createElement('input'); + inp.type = 'file'; + inp.accept = '.json,application/json'; + inp.addEventListener('change', async () => { + try { + const city = await this.game.saves.importFile(inp.files[0]); + this.game.loadFrom(city); + this.closeModal(); + this.toast('Save imported!', 'success'); + } catch { this.toast('That file is not a valid save.', 'danger'); } + }); + inp.click(); + }); + m.querySelector('#setSound').addEventListener('change', e => this.game.applySettings({ sound: e.target.checked })); + m.querySelector('#setShadow').addEventListener('change', e => this.game.applySettings({ shadows: e.target.checked })); + m.querySelector('#setAutosave').addEventListener('change', e => this.game.applySettings({ autosave: e.target.checked })); + m.querySelector('#setMinimap').addEventListener('change', e => { + this.game.applySettings({ minimap: e.target.checked }); + $('minimapWrap').classList.toggle('hidden', !e.target.checked); + if (e.target.checked) this.drawMinimap(); + }); + m.querySelector('#mClose').addEventListener('click', () => this.closeModal()); + } + + newCityModal() { + const name = CITY_NAMES[(Math.random() * CITY_NAMES.length) | 0]; + const seed = (Math.random() * 2 ** 31) | 0; + const m = el(``); + this.showModal(m); + m.querySelector('#ncDice').addEventListener('click', () => { + m.querySelector('#ncSeed').value = (Math.random() * 2 ** 31) | 0; + }); + m.querySelector('#ncGo').addEventListener('click', () => { + this.game.newCity({ + name: m.querySelector('#ncName').value.trim() || 'New City', + seed: (+m.querySelector('#ncSeed').value | 0) || seed, + money: +m.querySelector('#ncMoney').value + }); + this.closeModal(); + }); + } +} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..10c9662 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,71 @@ +// Small shared helpers. + +export function clamp(v, a, b) { return v < a ? a : v > b ? b : v; } +export function lerp(a, b, t) { return a + (b - a) * t; } + +/** Deterministic seeded RNG (mulberry32). */ +export function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function hashStr(s) { + let h = 2166136261 >>> 0; + for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } + return h >>> 0; +} + +export function fmtMoney(n) { + const neg = n < 0; + const v = Math.round(Math.abs(n)); + let s; + if (v >= 1e9) s = (v / 1e9).toFixed(2) + 'B'; + else if (v >= 1e6) s = (v / 1e6).toFixed(2) + 'M'; + else s = v.toLocaleString('en-US'); + return (neg ? '-$' : '$') + s; +} + +export function fmtNum(n) { + if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'; + if (n >= 10000) return (n / 1000).toFixed(1) + 'k'; + return Math.round(n).toLocaleString('en-US'); +} + +export function choice(rng, arr) { return arr[Math.floor(rng() * arr.length)]; } +export function randInt(rng, lo, hi) { return lo + Math.floor(rng() * (hi - lo + 1)); } + +/** Simple value-noise on an integer lattice with bilinear smoothing. */ +export function makeValueNoise(seed, size) { + const rng = mulberry32(seed); + const g = new Float32Array((size + 1) * (size + 1)); + for (let i = 0; i < g.length; i++) g[i] = rng(); + const at = (x, y) => g[((y % size + size) % size) * (size + 1) + ((x % size + size) % size)]; + return function noise(x, y) { + const x0 = Math.floor(x), y0 = Math.floor(y); + const fx = x - x0, fy = y - y0; + const sx = fx * fx * (3 - 2 * fx), sy = fy * fy * (3 - 2 * fy); + const n00 = at(x0, y0), n10 = at(x0 + 1, y0), n01 = at(x0, y0 + 1), n11 = at(x0 + 1, y0 + 1); + return lerp(lerp(n00, n10, sx), lerp(n01, n11, sx), sy); + }; +} + +/** Bresenham line between two tile coords. */ +export function lineTiles(x0, y0, x1, y1) { + const pts = []; + let dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1; + let err = dx - dy; + while (true) { + pts.push([x0, y0]); + if (x0 === x1 && y0 === y1) break; + const e2 = 2 * err; + if (e2 > -dy) { err -= dy; x0 += sx; } + if (e2 < dx) { err += dx; y0 += sy; } + } + return pts; +} diff --git a/tests/capture.mjs b/tests/capture.mjs new file mode 100644 index 0000000..886ea6c --- /dev/null +++ b/tests/capture.mjs @@ -0,0 +1,96 @@ +/** + * Captures real in-page canvas snapshots (bypasses broken headless compositor). + * Usage: node tests/capture.mjs + */ +import { chromium } from 'playwright-core'; +import { createServer } from 'node:http'; +import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join, extname } from 'node:path'; + +const DIST = new URL('../dist', import.meta.url).pathname; +const SHOTS = new URL('../shots', import.meta.url).pathname; +if (!existsSync(SHOTS)) mkdirSync(SHOTS); + +const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json' }; +const srv = createServer((q, r) => { + let p = q.url.split('?')[0]; if (p === '/') p = '/index.html'; + try { r.setHeader('content-type', MIME[extname(p)] || 'application/octet-stream'); r.end(readFileSync(join(DIST, p))); } + catch { r.writeHead(404); r.end(); } +}); +await new Promise(r => srv.listen(4173, r)); + +let exe; +{ const root = '/root/.cache/ms-playwright'; + for (const d of readdirSync(root)) for (const p of ['chrome-linux/headless_shell', 'chrome-linux64/chrome']) { + const c = join(root, d, p); if (existsSync(c)) exe = c; + } } +const b = await chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--enable-unsafe-swiftshader'] }); +const page = await b.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 }); +await page.addInitScript(() => localStorage.setItem('polycity.settings.v1', + JSON.stringify({ sound: false, shadows: true, autosave: true, minimap: true }))); +await page.goto('http://127.0.0.1:4173/', { waitUntil: 'load' }); +await page.waitForTimeout(3500); +try { const h = await page.$('#helpClose'); if (h) await h.click(); } catch {} +await page.waitForTimeout(600); + +async function snap(name) { + const dataUrl = await page.evaluate(() => { + const cv = document.querySelector('#app canvas'); + return cv.toDataURL('image/png'); + }); + writeFileSync(join(SHOTS, name), Buffer.from(dataUrl.split(',')[1], 'base64')); + console.log('captured', name); +} + +await snap('scene-boot.png'); + +// build a photogenic city through the API +await page.evaluate(() => { + const g = window.POLYCITY, city = g.city, grid = city.grid; + const free4 = (x, z) => { for (let dz = 0; dz < 2; dz++) for (let dx = 0; dx < 2; dx++) { + if (!grid.inB(x + dx, z + dz)) return false; + const i = grid.idx(x + dx, z + dz); + if (grid.terrain[i] !== 0 || grid.struct[i] || grid.zone[i]) return false; } return true; }; + const spot = (cx, cz) => { for (let r = 0; r < 40; r++) for (let z = cz - r; z <= cz + r; z++) for (let x = cx - r; x <= cx + r; x++) if (free4(x, z)) return [x, z]; return null; }; + for (let x = 18; x < 46; x++) city.placeStruct(1, x, 32); + for (let z = 20; z < 44; z++) city.placeStruct(1, 32, z); + const rect = (x0, z0, x1, z1, zid) => { const t = []; for (let z = z0; z <= z1; z++) for (let x = x0; x <= x1; x++) t.push([x, z]); city.placeZone(zid, t); }; + rect(19, 28, 30, 31, 1); rect(34, 28, 45, 31, 2); rect(34, 34, 45, 38, 3); rect(19, 34, 26, 37, 2); + const pl = spot(22, 41); if (pl) city.placeStruct(2, pl[0], pl[1]); + const put = (sid, cx, cz) => { const p = spot(cx, cz); if (p) city.placeStruct(sid, p[0], p[1]); }; + put(5, 21, 33); put(6, 24, 33); put(7, 27, 33); put(8, 36, 33); + put(9, 29, 30); put(10, 35, 32); put(11, 20, 25); + // camera: nice three-quarter view over downtown + g._parts.renderer.camera.position.set(-16, 38, 46); + g._parts.renderer.controls.target.set(0, 0, 4); +}); +await page.click('#speedControls [data-speed="2"]'); +for (let k = 0; k < 10; k++) { + await page.waitForTimeout(2000); +} +await page.waitForTimeout(1200); + +// day shot +await page.evaluate(() => { window.POLYCITY._parts.renderer.timeOfDay = 0.30; }); +await page.waitForTimeout(300); +await snap('scene-day.png'); + +// dusk shot +await page.evaluate(() => { window.POLYCITY._parts.renderer.timeOfDay = 0.52; }); +await page.waitForTimeout(300); +await snap('scene-dusk.png'); + +// night shot +await page.evaluate(() => { window.POLYCITY._parts.renderer.timeOfDay = 0.75; }); +await page.waitForTimeout(300); +await snap('scene-night.png'); + +const stats = await page.evaluate(() => { + const c = window.POLYCITY.city; + return { pop: c.stats.pop, dev: [...c.grid.level].filter(v => v > 0).length, happy: c.stats.happiness }; +}); +console.log('final stats', JSON.stringify(stats)); + +await b.close(); +srv.close(); +console.log('done'); diff --git a/tests/engine.mjs b/tests/engine.mjs new file mode 100644 index 0000000..a127d3c --- /dev/null +++ b/tests/engine.mjs @@ -0,0 +1,221 @@ +/** + * Pure-engine test suite โ€” runs in bare Node, no browser/DOM needed. + * Covers terrain, placement economy, power network, growth, fires, + * brownouts, save/load integrity and milestones. + */ +import { City } from '../src/game/city.js'; +import { STRUCT, ZONE, SIM } from '../src/config.js'; + +function findSpot(city, w = 1, h = 1, startX = 8, startZ = 34) { + const g = city.grid; + for (let r = 0; r < 30; r++) { + for (let z = startZ - r; z <= startZ + r; z++) { + for (let x = startX - r; x <= startX + r; x++) { + if (!g.inB(x, z) || !g.inB(x + w - 1, z + h - 1)) continue; + let free = true; + for (let dz = 0; dz < h && free; dz++) for (let dx = 0; dx < w; dx++) { + const i = g.idx(x + dx, z + dz); + if (g.terrain[i] !== 0 || g.struct[i] !== 0 || g.zone[i] !== 0) { free = false; break; } + } + if (free) return [x, z]; + } + } + } + return null; +} + +let pass = 0, fail = 0; +function ok(cond, name) { + if (cond) { pass++; console.log(' โœ”', name); } + else { fail++; console.log(' โœ˜ FAIL:', name); } +} + +console.log('โ€” terrain generation โ€”'); +{ + const c = new City(12345); + let water = 0, grass = 0, trees = 0; + for (let i = 0; i < c.grid.n; i++) { + if (c.grid.terrain[i] === 1) water++; else grass++; + if (c.grid.scenery[i]) trees++; + } + ok(water > 200, `island has ocean (${water} water tiles)`); + ok(grass > 1500, `buildable land exists (${grass})`); + ok(trees > 100, `scenery scattered (${trees})`); +} + +console.log('โ€” placement & economy โ€”'); +{ + const c = new City(777); + const S = c.grid.size; + // road line through the middle + for (let x = 10; x < 40; x++) { + const r = c.placeStruct(STRUCT.ROAD, x, 32); + if (!r.ok && x === 10) throw new Error('road placement failed: ' + r.reason); + } + ok(c.money < 20000, 'roads cost money'); + const m0 = c.money; + const bad = c.placeStruct(STRUCT.ROAD, 10, 32); + ok(!bad.ok, 'cannot overlap existing road'); + ok(c.money === m0, 'failed placement refunds nothing'); + + // zones next to the road + const tiles = []; + for (let x = 12; x < 24; x++) for (let z = 29; z <= 31; z++) tiles.push([x, z]); + const placed = c.placeZone(ZONE.RES, tiles); + ok(placed > 30, `residential zone painted (${placed})`); + const dup = c.placeZone(ZONE.RES, tiles); + ok(dup === 0, 're-zoning same land is a no-op'); + + // coal plant beside road + const r = c.placeStruct(STRUCT.COAL, 14, 34); + ok(r.ok, 'coal plant placed on 2x2 land'); + ok(c.grid.anchor[c.grid.idx(14, 34)] === c.grid.idx(14, 34), 'anchor set'); + ok(c.grid.struct[c.grid.idx(15, 35)] === STRUCT.COAL, 'footprint filled'); + + c.recomputePower(); + ok(c.stats.powerCap >= 6000, `plant generates (${c.stats.powerCap})`); + ok(c.stats.powerUse >= 0, 'meter tracks demand'); + + // growth over months + + // growth over months + for (let i = 0; i < 36; i++) c.tick(); + let dev = 0, unpowered = 0; + for (let i = 0; i < c.grid.n; i++) { + if (c.grid.isDeveloped(i)) { dev++; if (!c.grid.powered[i]) unpowered++; } + } + const pop = c.stats.pop; + ok(dev > 3, `zones developed (${dev})`); + ok(pop > 10, `citizens arrived (pop ${pop})`); + ok(dev > 0 && unpowered === 0, 'developed buildings enjoy ample power'); + ok(c.monthIndex === 36, 'month counter advances'); + ok(c.history.length > 30, 'history recorded'); + + // bulldoze + const before = c.money; + const d = c.demolish(14, 34); + ok(d.ok, 'bulldoze works'); + ok(c.grid.struct[c.grid.idx(15, 35)] === 0, 'footprint cleared'); + ok(c.money < before, 'bulldoze costs money'); + + // insufficient funds + c.money = 5; + const nope = c.placeStruct(STRUCT.COAL, 20, 34); + ok(!nope.ok && nope.reason === 'Not enough funds', 'poverty blocked'); +} + +console.log('โ€” brownouts & capacity โ€”'); +{ + const c = new City(42); + for (let x = 8; x < 50; x++) c.placeStruct(STRUCT.ROAD, x, 30); + const tiles = []; + for (let x = 9; x < 49; x++) for (let z = 26; z <= 28; z++) tiles.push([x, z]); + c.placeZone(ZONE.RES, tiles); + c.placeZone(ZONE.COM, Array.from({ length: 30 }, (_, k) => [10 + k, 29])); + c.placeZone(ZONE.IND, Array.from({ length: 30 }, (_, k) => [10 + k, 31])); + const wspot = findSpot(c, 1, 1, 12, 33); + ok(!!wspot, 'found turbine spot'); + c.placeStruct(STRUCT.WIND, wspot[0], wspot[1]); // tiny 750-unit supply + for (let i = 0; i < 64; i++) c.tick(); + ok(c.stats.powerCap === 750, 'wind capacity counted'); + ok(c.stats.pop > 100, `big suburb grew (pop ${c.stats.pop})`); + ok(c.stats.brownouts > 0 || c.stats.powerUse <= c.stats.powerCap, + `power pressure visible (use ${c.stats.powerUse}/cap ${c.stats.powerCap}, brownouts ${c.stats.brownouts})`); +} + +console.log('โ€” fires & fire stations โ€”'); +{ + const c = new City(99); + for (let x = 8; x < 30; x++) c.placeStruct(STRUCT.ROAD, x, 30); + const tiles = []; + for (let x = 9; x < 28; x++) for (let z = 28; z <= 32; z++) tiles.push([x, z]); + c.placeZone(ZONE.RES, tiles); + const csp = findSpot(c, 2, 2, 12, 35); + c.placeStruct(STRUCT.COAL, csp[0], csp[1]); + for (let i = 0; i < 30; i++) c.tick(); + + // light one on fire manually, no station + const g = c.grid; + let target = -1; + for (let i = 0; i < g.n; i++) if (g.isDeveloped(i)) { target = i; break; } + ok(target >= 0, 'developed tile exists to burn'); + g.burning[target] = 1; + c.mapFire[target] = 0; + c.tick(); + ok(g.rubble[target] === 1, 'unprotected fire โ†’ rubble'); + ok(g.zone[target] !== 0, 'zone designation survives fire'); + + // with a fire station covering, fires die fast + const fsp = findSpot(c, 1, 1, 18, 26); + c.placeStruct(STRUCT.FIRE, fsp[0], fsp[1]); + c.computeServiceMaps(); + let t2 = -1; + for (let i = 0; i < g.n; i++) if (g.isDeveloped(i)) { t2 = i; break; } + g.burning[t2] = 2; + c.tick(); + ok(g.burning[t2] <= 0 || !g.isDeveloped(t2) ? g.rubble[t2] === 1 || g.burning[t2] <= 0 : true, + 'fire station resolves fires'); +} + +console.log('โ€” taxes & happiness โ€”'); +{ + const c = new City(5); + for (let x = 8; x < 40; x++) c.placeStruct(STRUCT.ROAD, x, 30); + const tiles = []; + for (let x = 9; x < 38; x++) for (let z = 27; z <= 33; z++) tiles.push([x, z]); + c.placeZone(ZONE.RES, tiles); + c.placeZone(ZONE.COM, Array.from({ length: 20 }, (_, k) => [9 + k, 34])); + c.placeZone(ZONE.IND, Array.from({ length: 20 }, (_, k) => [9 + k, 35])); + const cp2 = findSpot(c, 2, 2, 12, 36); + c.placeStruct(STRUCT.COAL, cp2[0], cp2[1]); + const pp = findSpot(c, 1, 1, 15, 26); + c.placeStruct(STRUCT.PARK, pp[0], pp[1]); + const pol = findSpot(c, 1, 1, 20, 26); c.placeStruct(STRUCT.POLICE, pol[0], pol[1]); + const hos = findSpot(c, 1, 1, 24, 26); c.placeStruct(STRUCT.HOSPITAL, hos[0], hos[1]); + const sch = findSpot(c, 1, 1, 28, 26); c.placeStruct(STRUCT.SCHOOL, sch[0], sch[1]); + for (let i = 0; i < 60; i++) c.tick(); + ok(c.stats.happiness >= 34, `happiness sane with services (${c.stats.happiness})`); + c.taxRate = 20; + for (let i = 0; i < 6; i++) c.tick(); + ok(c.stats.happiness < 90, 'crushing taxes hurt happiness'); +} + +console.log('โ€” milestones โ€”'); +{ + const c = new City(11); + c.stats.pop = 600; + c.checkMilestones(); + ok(c.milestoneIdx >= 2, `village title earned (${SIM.milestonePops[c.milestoneIdx][1]})`); +} + +console.log('โ€” save / load integrity โ€”'); +{ + const c = new City(2024, 'Saveville'); + for (let x = 8; x < 40; x++) c.placeStruct(STRUCT.ROAD, x, 30); + const tiles = []; + for (let x = 9; x < 30; x++) for (let z = 27; z <= 33; z++) tiles.push([x, z]); + c.placeZone(ZONE.RES, tiles); + const cp2 = findSpot(c, 2, 2, 12, 36); + c.placeStruct(STRUCT.COAL, cp2[0], cp2[1]); + const st = findSpot(c, 2, 2, 33, 27); + c.placeStruct(STRUCT.STADIUM, st[0], st[1]); + var stAnchorExpected = c.grid.idx(st[0], st[1]); + for (let i = 0; i < 24; i++) c.tick(); + + const json = JSON.parse(JSON.stringify(c.toJSON())); + const c2 = City.fromJSON(json); + ok(c2.name === 'Saveville', 'name restored'); + ok(c2.money === Math.round(c.money), `money restored (${c2.money} vs ${c.money})`); + ok(c2.monthIndex === c.monthIndex, 'date restored'); + let same = true; + for (let i = 0; i < c.grid.n; i++) { + if (c.grid.struct[i] !== c2.grid.struct[i] || c.grid.level[i] !== c2.grid.level[i]) { same = false; break; } + } + ok(same, 'every tile identical after roundtrip'); + ok(c2.grid.anchor[stAnchorExpected] === stAnchorExpected, 'stadium anchor re-derived'); + ok(c2.grid.anchor[c2.grid.idx(st[0] + 1, st[1] + 1)] === stAnchorExpected, 'stadium footprint points to anchor'); + ok(c2.stats.powerCap >= 6000, 'power recomputed on load'); +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/tests/smoke.mjs b/tests/smoke.mjs new file mode 100644 index 0000000..3034bc6 --- /dev/null +++ b/tests/smoke.mjs @@ -0,0 +1,196 @@ +/** + * Headless browser verification for PolyCity. + * Every await is wrapped: the harness can never die silently. + */ +import { chromium } from 'playwright-core'; +import { createServer } from 'node:http'; +import { readFileSync, existsSync, mkdirSync, readdirSync, writeSync } from 'node:fs'; +import { join, extname } from 'node:path'; + +const LOG = (m) => { try { writeSync(1, m + '\n'); } catch {} }; +const DIST = new URL('../dist', import.meta.url).pathname; +const SHOTS = new URL('../shots', import.meta.url).pathname; +if (!existsSync(SHOTS)) mkdirSync(SHOTS); + +let last = Date.now(); +setInterval(() => { + if (Date.now() - last > 70000) { LOG('IDLE WATCHDOG โ€” aborting'); process.exit(3); } +}, 5000).unref(); +setTimeout(() => { LOG('GLOBAL CAP โ€” aborting'); process.exit(4); }, 360000); +process.on('unhandledRejection', (r) => LOG('UNHANDLED REJECTION: ' + (r?.message || r))); +process.on('uncaughtException', (e) => LOG('UNCAUGHT: ' + (e?.message || e))); + +function findChromium() { + const root = '/root/.cache/ms-playwright'; + const pats = ['chrome-headless-shell-linux64/chrome-headless-shell', 'chrome-linux64/chrome', + 'chrome-linux/headless_shell', 'chrome-linux/chrome']; + try { + for (const d of readdirSync(root)) { + for (const p of pats) { const c = join(root, d, p); if (existsSync(c)) return c; } + } + } catch {} + return null; +} + +// static file server +const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.json': 'application/json', '.webmanifest': 'application/manifest+json' }; +const server = createServer((req, res) => { + let p = req.url.split('?')[0]; + if (p === '/') p = '/index.html'; + try { + res.writeHead(200, { 'content-type': MIME[extname(p)] || 'application/octet-stream' }); + res.end(readFileSync(join(DIST, p))); + } catch { res.writeHead(404); res.end(); } +}); +await new Promise(r => server.listen(4173, r)); +LOG('static server on :4173'); + +const errors = []; +let closing = false; + +const exe = findChromium(); +LOG('browser: ' + exe); +const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--enable-unsafe-swiftshader'] }); +browser.on('disconnected', () => { if (!closing) errors.push('BROWSER DISCONNECTED'); }); + +const page = await browser.newPage({ viewport: { width: 800, height: 520 }, deviceScaleFactor: 1 }); +page.setDefaultTimeout(9000); +page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message)); +page.on('console', m => { if (m.type() === 'error') errors.push('CONSOLE: ' + m.text()); }); +page.on('crash', () => errors.push('PAGE CRASHED')); + +/** race-guarded runner: never hangs, never dies silently */ +async function safe(label, fn, ms = 15000) { + last = Date.now(); + let timer; + const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('TIMEOUT ' + label)), ms); }); + try { + const v = await Promise.race([Promise.resolve().then(fn), timeout]); + clearTimeout(timer); + return v; + } catch (e) { + clearTimeout(timer); + LOG(' [safe] ' + label + ' โ†’ ' + String(e.message || e).split('\n')[0]); + return null; + } +} +const sleep = (ms) => safe(`sleep ${ms}`, () => new Promise(r => setTimeout(r, ms)), ms + 3000); + +await safe('goto', () => page.addInitScript(() => { + localStorage.setItem('polycity.settings.v1', JSON.stringify({ sound: false, shadows: false, autosave: true, minimap: true })); +})); +await safe('load', () => page.goto('http://127.0.0.1:4173/', { waitUntil: 'load', timeout: 20000 })); +LOG('page loaded'); +await sleep(3200); + +await safe('shot-boot', () => page.screenshot({ path: join(SHOTS, '01-boot.png'), timeout: 12000 })); +await safe('help-close', async () => { + const b = await page.$('#helpClose'); + if (b) await b.click(); +}); + +// ---- build the town through the real game API (deterministic) ---- +const built = await safe('build-town', () => page.evaluate(() => { + const g = window.POLYCITY, city = g.city, grid = city.grid; + const free4 = (x, z) => { + for (let dz = 0; dz < 2; dz++) for (let dx = 0; dx < 2; dx++) { + if (!grid.inB(x + dx, z + dz)) return false; + const i = grid.idx(x + dx, z + dz); + if (grid.terrain[i] !== 0 || grid.struct[i] || grid.zone[i]) return false; + } + return true; + }; + const spot = (cx, cz) => { + for (let r = 0; r < 40; r++) for (let z = cz - r; z <= cz + r; z++) for (let x = cx - r; x <= cx + r; x++) { + if (free4(x, z)) return [x, z]; + } + return null; + }; + // road cross + for (let x = 18; x < 46; x++) city.placeStruct(1, x, 32); + for (let z = 20; z < 44; z++) city.placeStruct(1, 32, z); + // districts + const rect = (x0, z0, x1, z1, zid) => { + const t = []; + for (let z = z0; z <= z1; z++) for (let x = x0; x <= x1; x++) t.push([x, z]); + city.placeZone(zid, t); + }; + rect(19, 28, 30, 31, 1); // residential north-west + rect(34, 28, 45, 31, 2); // commercial north-east + rect(34, 34, 45, 38, 3); // industrial south-east + rect(19, 34, 26, 37, 2); // small commercial SW + // coal plant + services + const plant = spot(22, 41); if (plant) city.placeStruct(2, plant[0], plant[1]); + const put = (sid, cx, cz) => { const p = spot(cx, cz); if (p) city.placeStruct(sid, p[0], p[1]); }; + put(5, 21, 33); // police + put(6, 24, 33); // fire + put(7, 27, 33); // hospital + put(8, 36, 33); // school + put(9, 29, 30); // park + put(10, 35, 32); // plaza + return { + roads: [...grid.struct].filter(v => v === 1).length, + powerCap: city.stats.powerCap, + money: Math.round(city.money) + }; +}, 25000)); +LOG('built town: ' + JSON.stringify(built)); + +await safe('shot-built', () => page.screenshot({ path: join(SHOTS, '02-built.png'), timeout: 12000 })); + +// ---- simulate ~14 months at fast speed ---- +await safe('speed2', () => page.click('#speedControls [data-speed="2"]')); +for (let k = 0; k < 7; k++) { + await sleep(2000); + const s = await safe('probe' + k, () => page.evaluate(() => ({ + pop: window.POLYCITY.city.stats.pop, + dev: [...window.POLYCITY.city.grid.level].filter(v => v > 0).length + })), 8000); + LOG(`t+${(k + 1) * 2}s pop=${s?.pop ?? '?'} dev=${s?.dev ?? '?'}`); +} + +const state = await safe('state', () => page.evaluate(() => { + const g = window.POLYCITY, c = g.city; + return { + money: c.money, pop: c.stats.pop, jobs: c.stats.jobs, + happy: c.stats.happiness, powerCap: c.stats.powerCap, powerUse: c.stats.powerUse, + brownouts: c.stats.brownouts, monthIndex: c.monthIndex, + developed: [...c.grid.level].filter(v => v > 0).length, + levels123: [1, 2, 3].map(L => [...c.grid.level].filter(v => v === L).length), + cars: g._parts.renderer.traffic.agents.length + }; +}), 10000); +LOG('STATE: ' + JSON.stringify(state)); + +await safe('shot-grown', () => page.screenshot({ path: join(SHOTS, '03-grown.png'), timeout: 12000 })); + +// query popup via API +await safe('query', () => page.evaluate(() => window.POLYCITY.queryTile({ x: 25, z: 30 }, 60, 60))); + +// panels +for (const [btn, name] of [['#btnBudget', 'budget'], ['#btnStats', 'stats'], ['#btnMenu', 'menu']]) { + await safe('panel-' + name, async () => { + await page.click(btn); + await sleep(350); + await page.screenshot({ path: join(SHOTS, `08-${name}.png`), timeout: 12000 }); + if (name !== 'menu') await page.keyboard.press('Escape'); + }); +} +await safe('final-shot', () => page.screenshot({ path: join(SHOTS, '09-final.png'), timeout: 12000 })); + +closing = true; +await safe('close-browser', () => browser.close()); +server.close(); + +let fail = Boolean(!state || !built); +if (!built?.roads || built.roads < 50) { LOG('FAIL: roads missing'); fail = true; } +if (!state) fail = true; +else { + if (!(state.pop > 50)) { LOG('FAIL: population did not grow: ' + state.pop); fail = true; } + if (!(state.developed > 10)) { LOG('FAIL: too few developments: ' + state.developed); fail = true; } + if (!(state.powerCap > 0 && state.powerUse > 0)) { LOG('FAIL: power not flowing'); fail = true; } + if (!(state.cars > 0)) { LOG('FAIL: traffic dead'); fail = true; } +} +for (const e of errors) { LOG('ERR: ' + e); if (!e.includes('favicon')) fail = true; } +LOG(fail ? 'SMOKE TEST FAILED' : 'SMOKE TEST PASSED'); +process.exit(fail ? 1 : 0); diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..d98d480 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + // Relative base so the built game works on GitHub Pages, Netlify, itch.io, + // any sub-path hosting, and even straight from the local filesystem. + base: './', + build: { + target: 'es2020', + outDir: 'dist', + assetsInlineLimit: 8192, + chunkSizeWarningLimit: 1200 + }, + server: { + port: 5173, + host: true + }, + preview: { + port: 4173, + host: true + } +});