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:
+204
@@ -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;
|
||||
Reference in New Issue
Block a user