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