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
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.vite/
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## ✨ 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. 🏗️
|
||||||
|
After Width: | Height: | Size: 326 KiB |
@@ -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>
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 |
@@ -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 |
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
@@ -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'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 232 KiB |
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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'];
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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"/>')
|
||||||
|
};
|
||||||
@@ -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">Let’s 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||