PolyCity v1.0 — full-featured 3D city-builder (Three.js + Vite)

- Procedural island maps, RCI zoning, demand-driven growth
- City-wide power grid with brownouts; wind coastal bonus
- Land value, upgrades, services, pollution, fires & fire spread
- Budget/taxes, happiness, milestones, quest onboarding
- Traffic agents, day/night cycle, adaptive render scale
- Saves: autosave + 3 slots + JSON export/import
- PWA (offline), GitHub Pages/Netlify deploy configs
- Tests: 40-assertion engine suite + headless E2E
This commit is contained in:
PolyCity
2026-08-22 19:17:10 +00:00
commit 56bf3fa2a2
37 changed files with 4996 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
import './style.css';
import { City } from './game/city.js';
import { Renderer } from './render/renderer.js';
import { Input } from './input.js';
import { UI } from './ui/ui.js';
import { Audio } from './audio.js';
import { SaveManager } from './save.js';
import { SETTINGS_KEY, START_MONEY } from './config.js';
import { clamp } from './utils.js';
function loadSettings() {
try {
return { sound: true, shadows: true, autosave: true, minimap: true, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') };
} catch {
return { sound: true, shadows: true, autosave: true, minimap: true };
}
}
class Game {
constructor() {
this.settings = loadSettings();
this.audio = new Audio(this.settings);
this.saves = null;
this.speedIdx = 1;
this.paused = false;
this.lastNonzeroSpeed = 1;
this._simAcc = 0;
this._lastT = performance.now();
this._raf = null;
this._parts = null; // {renderer, input, ui}
this._unsubs = [];
// pick up autosaved city if present
let initial = null;
const probeSaves = new SaveManager(null);
if (probeSaves.hasAutosave()) {
initial = probeSaves.loadAutosave();
}
this.city = initial || new City();
this.start(this.city);
document.addEventListener('visibilitychange', () => {
if (document.hidden && !this.paused) this.togglePause();
});
}
// ---------- lifecycle ----------
start(city) {
this.teardown();
this.city = city;
const container = document.getElementById('app');
let renderer;
try {
renderer = new Renderer(container, city, this.settings);
} catch (err) {
console.error(err);
container.innerHTML = `<div style="display:flex;height:100%;align-items:center;justify-content:center;text-align:center;padding:20px">
<div><h2>WebGL unavailable 😢</h2><p>PolyCity needs hardware-accelerated WebGL.<br>Please enable it in your browser settings or try another browser.</p></div></div>`;
throw err;
}
this.saves = new SaveManager(city);
const ui = new UI(this);
const input = new Input({ city, renderer, ui, audio: this.audio, game: this });
this._parts = { renderer, input, ui };
this._unsubs.push(city.on('autosave', () => {
if (this.settings.autosave && this.saves.autosave()) {
console.info('PolyCity autosaved.');
}
}));
this._unsubs.push(city.on('milestone', () => {
// extra flourish handled by ui toast + sound
}));
city.markTilesChanged();
this._syncSoon();
if (!this._raf) this.loop();
}
teardown() {
for (const off of this._unsubs) off();
this._unsubs = [];
if (this._parts) {
this._parts.input.destroy?.();
this._parts.ui.destroy?.();
this._parts.renderer.dispose?.();
this._parts = null;
}
}
newCity({ name, seed, money }) {
const c = new City(seed, name);
c.money = money ?? START_MONEY;
this.speedIdx = 1;
this.paused = false;
this.start(c);
this.ui().toast(`Welcome to ${name}, Mayor!`, 'success');
this.ui().resetQuests();
}
loadFrom(cityInstance) {
this.start(cityInstance);
this.ui().toast('City loaded.', 'success');
}
ui() { return this._parts?.ui; }
get input() { return this._parts?.input; }
get renderer() { return this._parts?.renderer; }
// ---------- controls ----------
setSpeed(idx) {
this.speedIdx = idx;
this.paused = idx === 0;
if (idx > 0) this.lastNonzeroSpeed = idx;
this.ui()?.setSpeedActive(idx);
}
togglePause() {
if (this.paused) this.setSpeed(this.lastNonzeroSpeed);
else this.setSpeed(0);
}
applySettings(patch) {
Object.assign(this.settings, patch);
localStorage.setItem(SETTINGS_KEY, JSON.stringify(this.settings));
if ('shadows' in patch && this._parts) this._parts.renderer.setShadows(patch.shadows);
}
queryTile(tile, x, y) {
const info = this.city.getTileInfo(tile.x, tile.z);
if (info) this.ui()?.showInfoPopup(info, x, y);
}
checkQuests() { this.ui()?.checkQuests(); }
_syncSoon() {
if (this.city.tilesDirty && this._parts) {
this._parts.renderer.sync();
this.city.tilesDirty = false;
this.ui()?.scheduleMinimap();
}
}
// ---------- main loop ----------
loop() {
const tick = (now) => {
this._raf = requestAnimationFrame(tick);
const dt = Math.min(0.05, (now - this._lastT) / 1000);
this._lastT = now;
// simulation stepping
const msPerMonth = [0, 5000, 1800, 700][this.speedIdx] || 0;
if (!this.paused && msPerMonth > 0) {
this._simAcc += dt * 1000;
let guard = 0;
while (this._simAcc >= msPerMonth && guard < 4) {
this.city.tick();
this._simAcc -= msPerMonth;
guard++;
}
if (guard >= 4) this._simAcc = 0; // avoid runaway catch-up
}
this._syncSoon();
if (this._parts) {
this._parts.input.updatePan(dt);
this._parts.renderer.frame(dt, this.speedIdx, this.paused);
}
};
this._raf = requestAnimationFrame(tick);
}
}
// ---------------- boot ----------------
window.addEventListener('DOMContentLoaded', () => {
try {
window.POLYCITY = new Game();
} catch (e) {
console.error(e);
return;
}
const boot = document.getElementById('bootScreen');
setTimeout(() => {
boot.classList.add('off');
setTimeout(() => boot.remove(), 700);
}, 450);
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js').catch(() => {});
});
}
});
void clamp;