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
This commit is contained in:
PolyCity
2026-08-22 19:17:10 +00:00
commit 56bf3fa2a2
37 changed files with 4996 additions and 0 deletions
+43
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.DS_Store
*.log
.vite/
+21
View File
@@ -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.
+97
View File
@@ -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. 🏗️
Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>PolyCity — 3D City Builder</title>
<meta name="description" content="PolyCity is a free full-featured 3D city-builder simulation: zone districts, manage power and services, balance the budget, survive fires and grow a tiny village into a sprawling metropolis — right in your browser." />
<meta name="theme-color" content="#0b1020" />
<meta property="og:title" content="PolyCity — 3D City Builder" />
<meta property="og:description" content="Zone it, power it, grow it. A full SimCity-style simulation running in your browser." />
<meta property="og:type" content="website" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%230b1020'/%3E%3Crect x='12' y='30' width='10' height='22' fill='%2348d597'/%3E%3Crect x='26' y='20' width='12' height='32' fill='%235aa9ff'/%3E%3Crect x='42' y='34' width='10' height='18' fill='%23ffb454'/%3E%3Crect x='28' y='24' width='3' height='3' fill='%230b1020'/%3E%3Crect x='33' y='24' width='3' height='3' fill='%230b1020'/%3E%3Crect x='28' y='31' width='3' height='3' fill='%230b1020'/%3E%3Crect x='33' y='31' width='3' height='3' fill='%230b1020'/%3E%3C/svg%3E" />
<link rel="manifest" href="./manifest.webmanifest" />
</head>
<body>
<div id="app"></div>
<!-- ============ HUD skeleton (populated by ui.js) ============ -->
<div id="hud">
<header id="topbar">
<div class="tb-left">
<div id="cityBadge">
<span id="cityName">New City</span>
<span id="milestoneTitle" class="muted"></span>
</div>
<div class="stat" title="City funds"><span class="ico">💰</span><b id="statFunds">$0</b></div>
<div class="stat" title="Population"><span class="ico">👥</span><b id="statPop">0</b></div>
<div class="stat" title="Happiness"><span class="ico" id="happyFace">🙂</span><b id="statHappy"></b></div>
<div class="stat" title="Date"><span class="ico">📅</span><b id="statDate">Jan 2026</b></div>
<canvas id="rciBars" width="66" height="30" title="Demand: Residential / Commercial / Industrial"></canvas>
</div>
<div class="tb-right">
<div id="speedControls">
<button class="spd" data-speed="0" title="Pause (Space)"></button>
<button class="spd active" data-speed="1" title="Normal speed"></button>
<button class="spd" data-speed="2" title="Fast speed">▶▶</button>
<button class="spd" data-speed="3" title="Fastest speed">▶▶▶</button>
</div>
<button id="btnBudget" class="tbtn" title="Budget & taxes">🏦</button>
<button id="btnStats" class="tbtn" title="City statistics">📊</button>
<button id="btnHelp" class="tbtn" title="Help / guide"></button>
<button id="btnMenu" class="tbtn" title="Menu (save / load / new city / settings)"></button>
</div>
</header>
<div id="questBox" class="card hidden"></div>
<div id="toasts"></div>
<div id="tooltip" class="hidden"></div>
<div id="infoPopup" class="card hidden"></div>
<div id="toolbar"></div>
<aside id="sidePanelRight" class="hidden"></aside>
<div id="minimapWrap" class="hidden">
<canvas id="minimap" width="160" height="160"></canvas>
</div>
<div id="modalRoot"></div>
<div id="bootScreen">
<div class="boot-inner">
<h1>POLY<span>CITY</span></h1>
<p>Building your world…</p>
<div class="boot-bar"><i></i></div>
</div>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+16
View File
@@ -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"
+30
View File
@@ -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"
}
}
+459
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" fill="#0b1020"/>
<rect x="16" y="34" width="8" height="18" fill="#48d597"/>
<rect x="28" y="26" width="10" height="26" fill="#5aa9ff"/>
<rect x="42" y="38" width="8" height="14" fill="#ffb454"/>
</svg>

After

Width:  |  Height:  |  Size: 300 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#0b1020"/>
<rect x="12" y="30" width="10" height="22" fill="#48d597"/>
<rect x="26" y="20" width="12" height="32" fill="#5aa9ff"/>
<rect x="42" y="34" width="10" height="18" fill="#ffb454"/>
<rect x="28" y="24" width="3" height="3" fill="#0b1020"/>
<rect x="33" y="24" width="3" height="3" fill="#0b1020"/>
<rect x="28" y="31" width="3" height="3" fill="#0b1020"/>
<rect x="33" y="31" width="3" height="3" fill="#0b1020"/>
</svg>

After

Width:  |  Height:  |  Size: 550 B

+15
View File
@@ -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" }
]
}
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
+38
View File
@@ -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'))
);
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

+46
View File
@@ -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); }
}
+127
View File
@@ -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'];
+20
View File
@@ -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);
}
}
+775
View File
@@ -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;
}
}
+82
View File
@@ -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;
}
}
}
+305
View File
@@ -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]];
}
+204
View File
@@ -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 = `<div style="display:flex;height:100%;align-items:center;justify-content:center;text-align:center;padding:20px">
<div><h2>WebGL unavailable 😢</h2><p>PolyCity needs hardware-accelerated WebGL.<br>Please enable it in your browser settings or try another browser.</p></div></div>`;
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;
+143
View File
@@ -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;
}
}
+334
View File
@@ -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;
}
+544
View File
@@ -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;
}
+85
View File
@@ -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);
});
}
}
+261
View File
@@ -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; }
}
+25
View File
@@ -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 `<svg viewBox="0 0 24 24" ${S}>${inner}</svg>`;
}
export const ICONS = {
query: svg('<circle cx="10" cy="10" r="6"/><path d="M14.5 14.5L20 20"/>'),
bulldoze: svg('<rect x="3" y="12" width="8" height="6" rx="1"/><path d="M11 13h4l4 4v1h-8"/><circle cx="7" cy="19.5" r="1.8"/><circle cx="16.5" cy="19.5" r="1.5"/><path d="M5 12V9h4v3"/>'),
road: svg('<path d="M9 3L5 21"/><path d="M15 3l4 18"/><path d="M12 4v3"/><path d="M12 11v3"/><path d="M12 18v3"/>'),
res: svg('<path d="M4 11l8-7 8 7"/><path d="M6 10v10h12V10"/><path d="M10 20v-5h4v5"/>'),
com: svg('<rect x="4" y="8" width="16" height="12" rx="1"/><path d="M4 8l2-4h12l2 4"/><path d="M9 20v-6h6v6"/><path d="M4 12h16"/>'),
ind: svg('<path d="M4 20V10l5 3v-3l5 3v-3l6 3v7z"/><rect x="16.5" y="4" width="3" height="6"/>'),
coal: svg('<circle cx="12" cy="12" r="9"/><path d="M13 6l-5 7h4l-1 5 5-7h-4z"/>'),
solar: svg('<path d="M4 14h16l-2 6H6z"/><path d="M8 14l1.5 6"/><path d="M16 14l-1.5 6"/><path d="M5.5 17h13"/><circle cx="18" cy="5" r="2.4"/>'),
wind: svg('<path d="M12 21v-8"/><path d="M12 13V4"/><path d="M12 13l7.8 4.5"/><path d="M12 13L4.2 17.5"/><circle cx="12" cy="13" r="1.6"/>'),
police: svg('<path d="M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z"/><path d="M9 12l2 2 4-4"/>'),
fire: svg('<path d="M12 3c1 3 4.5 4.5 4.5 8.5a4.5 4.5 0 01-9 0c0-2 .9-3.2 1.8-4.3C10.2 9 12 6.5 12 3z"/><path d="M12 21a3 3 0 003-3c0-1.5-1.5-2.5-3-4-1.5 1.5-3 2.5-3 4a3 3 0 003 3z"/>'),
hospital: svg('<rect x="4" y="4" width="16" height="16" rx="2"/><path d="M12 8v8"/><path d="M8 12h8"/>'),
school: svg('<path d="M3 9l9-4 9 4-9 4z"/><path d="M7 11.5v3.5c0 1.6 2.2 3 5 3s5-1.4 5-3v-3.5"/><path d="M21 9v5"/>'),
park: svg('<path d="M12 3l5 7h-3l4 6H6l4-6H7z"/><path d="M12 16v5"/>'),
plaza: svg('<path d="M12 4v4"/><path d="M12 8c-2 0-3.2 1-3.2 3h6.4c0-2-1.2-3-3.2-3z"/><path d="M5 14h14l-1.5 6h-11z"/><path d="M8.5 11.5h7"/>'),
stadium: svg('<ellipse cx="12" cy="13" rx="8.5" ry="5.5"/><ellipse cx="12" cy="13" rx="4" ry="2.2"/><path d="M3.8 10.5L2 7.5M20.2 10.5L22 7.5"/>')
};
+624
View File
@@ -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('<div class="tb-sep"></div>')); continue; }
const btn = el(`<button class="tl-btn" data-id="${item.id}">
<span class="tl-ico">${ICONS[item.icon]}</span>
<span class="tl-label">${item.label}</span>
</button>`);
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',
`<span class="tl-cost">$${fmtNum(cost)}</span>`);
}
}
}
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(`<div class="toast ${kind}"><span>${icons[kind] || ''}</span><span>${msg}</span></div>`);
$('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) => `<div class="ip-row"><span>${k}</span><b>${v}</b></div>`;
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 = `<div class="ip-head">Tile ${info.x},${info.z}<button class="ip-close">×</button></div>${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 = `
<div class="sp-head"><h3>🏦 Budget</h3><button class="sp-close">×</button></div>
<div class="sp-body">
<label class="tax-row">Tax rate <b id="taxVal">${c.taxRate}%</b>
<input type="range" id="taxSlider" min="0" max="20" step="1" value="${c.taxRate}">
</label>
<p class="hint" id="taxHint"></p>
<table class="ledger">
<tr><td>Residential taxes</td><td>${fmtMoney(lb.taxRes)}</td></tr>
<tr><td>Commercial taxes</td><td>${fmtMoney(lb.taxCom)}</td></tr>
<tr><td>Industrial taxes</td><td>${fmtMoney(lb.taxInd)}</td></tr>
<tr class="dim"><td>Road upkeep</td><td>-${fmtMoney(lb.roads)}</td></tr>
<tr class="dim"><td>Service upkeep</td><td>-${fmtMoney(Math.max(0, lb.upkeep - lb.roads))}</td></tr>
<tr class="${net >= 0 ? 'good' : 'bad'}"><td><b>Net / month</b></td><td><b>${net >= 0 ? '+' : ''}${fmtMoney(net)}</b></td></tr>
<tr><td>Treasury</td><td><b>${fmtMoney(c.money)}</b></td></tr>
</table>
<p class="hint">Figures show last month. Higher taxes fund services but slow growth and hurt happiness.</p>
</div>`;
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 = `
<div class="sp-head"><h3>📊 Statistics</h3><button class="sp-close">×</button></div>
<div class="sp-body">
<div class="kv-grid">
<div><span>Population</span><b>${fmtNum(s.pop)}</b></div>
<div><span>Jobs</span><b>${fmtNum(s.jobs)}</b></div>
<div><span>Unemployment</span><b>${Math.round(s.unemployment * 100)}%</b></div>
<div><span>Happiness</span><b>${s.happiness}%</b></div>
<div><span>Avg land value</span><b>${Math.round(s.avgLandValue)}</b></div>
<div><span>Title</span><b>${SIM.milestonePops[c.milestoneIdx][1]}</b></div>
</div>
<div class="meter"><label>Power ${s.powerUse}/${s.powerCap}${s.brownouts ? ` ⚠️ ${s.brownouts} brownouts` : ''}</label>
<div class="meter-bar"><i style="width:${powerPct}%" class="${powerPct >= 95 ? 'hot' : ''}"></i></div>
</div>
${nextMs ? `<p class="hint">Next milestone: <b>${nextMs[1]}</b> at ${fmtNum(nextMs[0])} citizens.</p>` : '<p class="hint">You reached the highest title! 👑</p>'}
<h4>Population</h4><canvas id="chartPop" width="252" height="72"></canvas>
<h4>Treasury</h4><canvas id="chartFunds" width="252" height="72"></canvas>
</div>`;
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 = `<div class="q-head">🏆<b>All milestones complete!</b></div>`;
return;
}
box.innerHTML = `<div class="q-head"><b>🎯 City goals</b></div>` +
this.questDefs.map(q => {
const done = this.questDone.has(q.id);
return `<div class="q-item ${done ? 'done' : ''}">${done ? '✅' : '⬜'} ${q.label}</div>`;
}).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('<div class="modal-back"><div class="modal"></div></div>');
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(`<div class="modal-card">
<h2>🏙️ Welcome to PolyCity</h2>
<p>You are the mayor. Zone land, provide power and services, keep the budget alive, and grow a village into a megalopolis.</p>
<div class="help-cols">
<div>
<h3>Quick start</h3>
<ol>
<li>Lay a <b>Road</b> (R) — drag an L-shape.</li>
<li>Paint <b>Homes</b> (Z), <b>Shops</b> (X), <b>Industry</b> (C) beside it.</li>
<li>Build a <b>power plant</b> and connect it near your zones.</li>
<li>Zones develop on their own when there is demand!</li>
<li>Add police/fire/schools/parks to raise land value.</li>
</ol>
</div>
<div>
<h3>Controls</h3>
<ul>
<li><b>Left-drag</b> — use tool · <b>Right-drag</b> — orbit camera</li>
<li><b>Middle-drag / WASD</b> — pan · <b>Wheel / pinch</b> — zoom</li>
<li><b>Space</b> pause · <b>1·2·3</b> speed · <b>Esc</b> cancel</li>
<li><b>Q</b> inspect · <b>B</b> bulldoze · <b>R</b> road · <b>Z/X/C</b> zones</li>
</ul>
</div>
</div>
<h3>Handbook</h3>
<div class="help-book">
${[STRUCT.ROAD, STRUCT.COAL, STRUCT.SOLAR, STRUCT.WIND, STRUCT.POLICE, STRUCT.FIRE, STRUCT.HOSPITAL, STRUCT.SCHOOL, STRUCT.PARK, STRUCT.PLAZA, STRUCT.STADIUM]
.map(sid => `<div class="hb-item"><span class="hb-ico">${ICONS[b[sid].id === 'road' ? 'road' : b[sid].id]}</span><div><b>${b[sid].name}</b> <em>$${fmtNum(b[sid].cost)}${b[sid].upkeep ? ` · $${b[sid].upkeep}/mo` : ''}</em><br>${b[sid].desc}</div></div>`).join('')}
</div>
<button class="btn primary" id="helpClose">Lets build! 🔨</button>
</div>`);
this.showModal(modal);
modal.querySelector('#helpClose').addEventListener('click', () => this.closeModal());
}
menuModal() {
const m = el(`<div class="modal-card">
<h2>☰ Menu</h2>
<div class="menu-grid">
<button class="btn" id="mNew">🌍 New City</button>
<button class="btn" id="mExport">💾 Export Save (.json)</button>
<button class="btn" id="mImport">📂 Import Save</button>
</div>
<h3>Save slots</h3>
<div id="slots"></div>
<h3>Settings</h3>
<div class="settings">
<label><input type="checkbox" id="setSound" ${this.settings.sound ? 'checked' : ''}> Sound effects</label>
<label><input type="checkbox" id="setShadow" ${this.settings.shadows ? 'checked' : ''}> Shadows (pretty, costs FPS)</label>
<label><input type="checkbox" id="setAutosave" ${this.settings.autosave ? 'checked' : ''}> Autosave every year</label>
<label><input type="checkbox" id="setMinimap" ${this.settings.minimap ? 'checked' : ''}> Minimap</label>
</div>
<p class="hint">PolyCity v1.0 — built with Three.js. Everything runs locally in your browser.</p>
<button class="btn primary" id="mClose">Back to city</button>
</div>`);
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(`<div class="slot-row">
<b>Slot ${n}</b>
<span class="dim">${info ? `${info.name} · pop ${fmtNum(info.pop)} · ${new Date(info.savedAt).toLocaleString()}` : '— empty —'}</span>
<button class="btn sm" data-a="save" data-n="${n}">Save</button>
<button class="btn sm" data-a="load" data-n="${n}" ${info ? '' : 'disabled'}>Load</button>
<button class="btn sm danger" data-a="del" data-n="${n}" ${info ? '' : 'disabled'}>✕</button>
</div>`);
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(`<div class="modal-card">
<h2>🌍 Found a New City</h2>
<label class="fld">Mayor & city name
<input id="ncName" type="text" maxlength="24" value="${name}">
</label>
<label class="fld">Map seed
<span class="seedrow"><input id="ncSeed" type="number" value="${seed}">
<button class="btn sm" id="ncDice">🎲</button></span>
</label>
<label class="fld">Starting funds
<select id="ncMoney">
<option value="8000">💰 $8,000 — Hard</option>
<option value="20000" selected>💰💰 $20,000 — Classic</option>
<option value="45000">💰💰💰 $45,000 — Casual</option>
</select>
</label>
<button class="btn primary" id="ncGo">Break ground! 🚧</button>
</div>`);
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();
});
}
}
+71
View File
@@ -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;
}
+96
View File
@@ -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');
+221
View File
@@ -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);
+196
View File
@@ -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);
+21
View File
@@ -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
}
});