A landed the plansrc seam (52eb109); F owns the shell bootstrap. index.html: ?plansrc=osm
-> citygen.generatePlanFor(seed,'osm') (real-data 'Melbourne' 95 shops); default synthetic
'Boolarra Heads' unchanged (prime law). scaffold_check.mjs: pin BOTH goldens keyed by
(seed,plansrc) — synthetic 0x3fa36874, osm 0x34cfdec0 (deterministic) — + assert the selector
routes synthetic|osm. flags_check auto-detects + smokes plansrc + the all-on combo (4 flags).
interior_mode.js: restored binUnderAim's nearest-bin fallback (Lane C test-page parity) so E
riffles a nearby bin without perfect aim / before the bin GLB async-loads — fixes the dig
smoke's raycast-miss under fast headless AND improves real UX.
qa.sh --strict GREEN; flags_check GREEN (all 4 flags + all-on combo, 0 console errors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
291 lines
16 KiB
HTML
291 lines
16 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
|
<title>PROCITY — walkable town</title>
|
|
<style>
|
|
html, body { margin: 0; height: 100%; background: #0c0e0a; overflow: hidden; color: #eee;
|
|
font: 14px -apple-system, Segoe UI, Roboto, sans-serif; }
|
|
#c { display: block; width: 100vw; height: 100vh; }
|
|
#pc-start { position: fixed; inset: 0; z-index: 40; display: flex; flex-direction: column;
|
|
align-items: center; justify-content: center; text-align: center; gap: 14px;
|
|
background: radial-gradient(circle at 50% 40%, #24281e, #0c0e0a); }
|
|
#pc-start h1 { font-size: 34px; margin: 0; letter-spacing: 2px; color: #ffd75e; text-shadow: 0 2px 8px #000; }
|
|
#pc-start .sub { opacity: .85; max-width: 420px; line-height: 1.5; }
|
|
#pc-start button { pointer-events: auto; cursor: pointer; border: 0; border-radius: 10px;
|
|
padding: 12px 26px; font-size: 16px; background: #ffd75e; color: #241d06; font-weight: 700; }
|
|
#pc-start .seed { opacity: .55; font-size: 12px; }
|
|
#pc-load { position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%); opacity: .7; }
|
|
</style>
|
|
<script type="importmap">
|
|
{ "imports": {
|
|
"three": "./vendor/three.module.js",
|
|
"three/addons/": "./vendor/addons/"
|
|
} }
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<canvas id="c"></canvas>
|
|
|
|
<div id="pc-start">
|
|
<h1>PROCITY</h1>
|
|
<div class="sub">A procedurally generated, walkable 90s-Australian shopping town.
|
|
Every door opens (soon). Walk the strip, watch the day turn.</div>
|
|
<button id="pc-go">walk into town</button>
|
|
<div class="seed" id="pc-startseed"></div>
|
|
<div class="sub" style="font-size:12px;opacity:.7">WASD move · shift run · mouse look · click a door · [ ] time of day · M map · P screenshot</div>
|
|
</div>
|
|
|
|
<script type="module">
|
|
import * as THREE from 'three';
|
|
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
|
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
|
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
|
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
|
|
|
|
import { fixturePlan } from './js/world/fixture_plan.js';
|
|
import { createSkinLibrary } from './js/world/skins.js';
|
|
import { buildGround } from './js/world/ground.js';
|
|
import { createLighting } from './js/world/lighting.js';
|
|
import { createChunkManager } from './js/world/chunks.js';
|
|
import { createPlayer } from './js/world/player.js';
|
|
import { createHUD } from './js/world/hud.js';
|
|
import { createMinimap } from './js/world/minimap.js';
|
|
import { createInteriorMode } from './js/world/interior_mode.js'; // [Lane F integration] Lane C interior bridge
|
|
import { CitizenSim } from './js/citizens/sim.js'; // [Lane F integration] Lane D street pedestrians
|
|
import { loadPedFleet } from './js/citizens/rigs.js'; // [Lane F §3.4] Lane D rig fleet (GLB peds/keepers)
|
|
import { preloadManifest } from './js/interiors/interiors.js'; // [Lane F §3.4] Lane E manifest → interior GLB upgrades
|
|
|
|
main().catch((err) => {
|
|
console.error('[procity] init failed:', err);
|
|
const el = document.getElementById('pc-startseed');
|
|
if (el) el.textContent = 'INIT ERROR: ' + (err && err.message || err);
|
|
});
|
|
|
|
async function main() {
|
|
// ── plan: prefer Lane A's generatePlan if it has landed, else the fixture ──
|
|
const params = new URLSearchParams(location.search);
|
|
const seed = (parseInt(params.get('seed'), 10) || 20261990) >>> 0;
|
|
// [Lane F §3.4] asset-free gate: ?noassets=1 → skip the manifest + rig fleet + interior GLB upgrades so
|
|
// the whole town runs 100% on primitives/flat-colour with ZERO network. Everything below is fail-soft
|
|
// anyway (primitives persist if a GLB never lands); this flag makes "no network at all" explicit + testable.
|
|
const NOASSETS = params.has('noassets') && params.get('noassets') !== '0';
|
|
// [Lane F §F2] plan-source selector (v2, ?plansrc=osm). Default 'synthetic' = the byte-identical
|
|
// golden-hash generator (prime law); 'osm' = Lane A's real-data fixture importer (generatePlanFor,
|
|
// LANE_A_NOTES F2). Zero network either way. Guarded: falls back to synthetic generatePlan, then fixture.
|
|
const PLANSRC = params.get('plansrc') === 'osm' ? 'osm' : 'synthetic';
|
|
let plan;
|
|
try {
|
|
const citygen = await import('./js/citygen/index.js');
|
|
const gen = citygen.generatePlanFor
|
|
? (s) => citygen.generatePlanFor(s, PLANSRC) // A's selector (synthetic | osm)
|
|
: (citygen.generatePlan || citygen.default); // pre-R6 fallback (synthetic only)
|
|
plan = gen ? gen(seed) : fixturePlan(seed);
|
|
console.log(`[procity] plan source: ${PLANSRC} — ${plan.name}, ${plan.shops.length} shops`);
|
|
} catch (e) {
|
|
plan = fixturePlan(seed);
|
|
console.log('[procity] Lane A citygen absent — running on fixture_plan');
|
|
}
|
|
|
|
// ── renderer + post ──
|
|
// Adaptive to plan density: a large generated city (Lane A) has content in nearly every chunk, so
|
|
// a smaller radius + no sun-shadow pass keeps draw calls near the ≤300 gate. The sparse fixture
|
|
// gets the fuller radius 3 + shadows (measured ~140 draws). Override via ?r= / ?shadows=0|1.
|
|
const BIG_CITY = (plan.shops?.length || 0) > 120;
|
|
const RADIUS = parseInt(params.get('r'), 10) || (BIG_CITY ? 2 : 3);
|
|
const SHADOWS = params.has('shadows') ? params.get('shadows') !== '0' : !BIG_CITY;
|
|
const canvas = document.getElementById('c');
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
|
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
renderer.setSize(innerWidth, innerHeight);
|
|
renderer.shadowMap.enabled = SHADOWS;
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.info.autoReset = false; // EffectComposer renders many passes/frame; we reset once per frame
|
|
|
|
const scene = new THREE.Scene();
|
|
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 800);
|
|
|
|
const composer = new EffectComposer(renderer);
|
|
composer.addPass(new RenderPass(scene, camera));
|
|
const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.55, 0.5, 0.9);
|
|
composer.addPass(bloom);
|
|
composer.addPass(new OutputPass());
|
|
|
|
// ── world systems ──
|
|
const skins = createSkinLibrary();
|
|
const ground = buildGround(plan, skins);
|
|
scene.add(ground.group);
|
|
|
|
const lighting = createLighting(scene, plan, skins, { radius: RADIUS, shadows: SHADOWS });
|
|
lighting.attachRenderer(renderer);
|
|
|
|
const ctx = { skins, citySeed: plan.citySeed, plan, night: lighting.isNight(), radius: RADIUS };
|
|
const chunks = createChunkManager(plan, scene, ctx);
|
|
lighting.onNightChange = (night) => { chunks.setNight(night); bloom.strength = night ? 0.9 : 0.55; };
|
|
|
|
const player = createPlayer(camera, canvas, { getColliders: (x, z) => chunks.getColliders(x, z) });
|
|
|
|
const hud = createHUD({
|
|
camera, renderer, plan,
|
|
getDoorMeshes: () => chunks.getDoorMeshes(),
|
|
onEnterShop: (shopId, name) => enterShop(shopId, name),
|
|
});
|
|
const minimap = createMinimap(plan);
|
|
// [Lane F §3.4] asset upgrades — OFF entirely under ?noassets. Fail-soft: primitives persist until (and
|
|
// unless) a GLB lands, so the town is fully playable the instant it boots.
|
|
// • rig fleet: models/peds/*.glb → shared GLB actors; CitizenSim + keepers upgrade from placeholders.
|
|
// • manifest: Lane E's assets/manifest.json → interior fitting GLBs (loaded from the 3GOD depot).
|
|
const fleet = NOASSETS ? null : loadPedFleet('models/peds/');
|
|
if (!NOASSETS) preloadManifest(); // prime the manifest so the first interior can upgrade its fittings
|
|
// [Lane F integration] interior bridge — owns the 'interior' branch (Lane C buildInterior + Lane D keeper)
|
|
// [Lane F §F2] ?dig=1 enables Lane C's crate-riffle inside record interiors (v2). Off ⇒ byte-identical to v1.
|
|
const DIG_ON = params.has('dig') && params.get('dig') !== '0';
|
|
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS, dig: DIG_ON });
|
|
// [Lane F §F2] the riffle is cursor-driven (DOM buttons + click-a-sleeve), so release pointer-lock while it's
|
|
// open and re-lock (on click) after. digActive gates the unlock→leaveShop guard below so opening a bin
|
|
// doesn't read as "walked out of the shop".
|
|
window.addEventListener('procity:digOpen', () => player.unlock());
|
|
// [Lane F integration] street pedestrians (Lane D). graph = plan.streets directly; with the rig fleet the
|
|
// near tier shares GLB meshes (cheap). `?pop=` tunable. setTimeOfDay/update in the street loop.
|
|
const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, graph: plan.streets, fleet });
|
|
citizens.setPopulation((parseInt(params.get('pop'), 10) || 140) >>> 0); // midday ≈96 active
|
|
document.addEventListener('visibilitychange', () => citizens.setPaused(document.hidden));
|
|
|
|
// spawn on the footpath near the west end of the strip, looking east down main street
|
|
player.teleport(-96, 9, -Math.PI / 2);
|
|
chunks.warmup(player.position);
|
|
lighting.update(0.0001, player.position);
|
|
|
|
// ── mode state machine: map | street | interior ──
|
|
let MODE = 'street';
|
|
function setMode(m) {
|
|
MODE = m;
|
|
hud.setVisible(m === 'street');
|
|
minimap.setVisible(m === 'map');
|
|
keys.clear();
|
|
if (m === 'map') player.unlock(); // [Lane F] interior keeps first-person lock; street re-locks on click
|
|
}
|
|
|
|
// ── shop hours (Lane F §3.5) — Lane A ships hours:[open,close] as 24h ints + exactly one
|
|
// openLate:true landmark per town (always the video rental, closes ≥22). Current hour comes from
|
|
// lighting's 6-segment clock (getClock().hour is 'HH:MM'); at night only the openLate shop is open.
|
|
function currentHour() { const [h, m] = lighting.getClock().hour.split(':').map(Number); return h + (m || 0) / 60; }
|
|
function isOpen(shop) { return shop && shop.hours && currentHour() >= shop.hours[0] && currentHour() < shop.hours[1]; }
|
|
|
|
// ── door → interior hand-off (Lane B seam → Lane F bridge → Lane C buildInterior) ──
|
|
function enterShop(shopId, name) {
|
|
window.dispatchEvent(new CustomEvent('procity:enterShop', { detail: { shopId } }));
|
|
const shop = plan.shops.find((s) => s.id === shopId);
|
|
if (!shop) { hud.showToast(`🚪 ${name}`); return; }
|
|
// [Lane F §3.5] closed shops are locked; at night the openLate video shop is the one place still open
|
|
if (!isOpen(shop)) { hud.showToast(`🔒 ${name} — CLOSED · opens ${shop.hours[0]}:00`); return; }
|
|
// [Lane F integration] attach lot dims + open Lane C's interior, switch to interior mode
|
|
const lot = plan.lots.find((l) => l.id === shop.lot);
|
|
interiorMode.enter({ ...shop, lot: lot ? { w: lot.w, d: lot.d } : null }, name);
|
|
setMode('interior');
|
|
}
|
|
function leaveShop() { // [Lane F] dispose the interior + restore the player to the street door
|
|
interiorMode.exit();
|
|
setMode('street'); // pointer stayed locked while inside, so walking out resumes at once; Esc-exit re-locks on click
|
|
hud.showToast('🚪 back on the street');
|
|
}
|
|
window.addEventListener('procity:exitShop', () => { if (MODE === 'interior') leaveShop(); });
|
|
|
|
// ── input ──
|
|
const keys = new Set();
|
|
addEventListener('keydown', (e) => {
|
|
if (e.repeat) return;
|
|
keys.add(e.code);
|
|
if (e.code === 'BracketRight') lighting.stepSegment(1);
|
|
else if (e.code === 'BracketLeft') lighting.stepSegment(-1);
|
|
else if (e.code === 'KeyP') { if (MODE === 'street') takeShots(); } // [Lane F] street-only
|
|
else if (e.code === 'KeyM') { if (MODE !== 'interior') setMode(MODE === 'map' ? 'street' : 'map'); } // [Lane F] not while inside a shop
|
|
else if (e.code === 'KeyT') lighting.togglePaused();
|
|
});
|
|
addEventListener('keyup', (e) => keys.delete(e.code));
|
|
addEventListener('blur', () => keys.clear());
|
|
|
|
const startPanel = document.getElementById('pc-start');
|
|
document.getElementById('pc-startseed').textContent = `${plan.name} · seed ${plan.citySeed}`;
|
|
document.getElementById('pc-go').addEventListener('click', () => { startPanel.style.display = 'none'; player.lock(); });
|
|
player.controls.addEventListener('unlock', () => { keys.clear(); if (MODE === 'interior' && !interiorMode.digActive) leaveShop(); }); // [Lane F] Esc leaves the shop — but not when the unlock is us opening a dig (§F2)
|
|
// click the world to (re)grab the pointer when walking
|
|
canvas.addEventListener('click', () => { if (!player.isLocked && !interiorMode.digActive && (MODE === 'street' || MODE === 'interior')) player.lock(); }); // [Lane F §F2] re-lock for interior walk after closing a dig
|
|
|
|
addEventListener('resize', () => {
|
|
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
|
|
renderer.setSize(innerWidth, innerHeight);
|
|
composer.setSize(innerWidth, innerHeight);
|
|
});
|
|
|
|
// ── screenshot harness (P): three fixed cameras → PNG downloads into the browser ──
|
|
const SHOTS = [
|
|
{ name: 'main-street', pos: [-96, 1.7, 6], look: [0, 1.7, 6] },
|
|
{ name: 'plaza', pos: [0, 22, 40], look: [0, 0, 0] },
|
|
{ name: 'backstreets', pos: [3, 1.7, 30], look: [3, 1.7, 95] },
|
|
];
|
|
function takeShots() {
|
|
const savePos = camera.position.clone(), saveQuat = camera.quaternion.clone();
|
|
const wasMap = MODE; if (MODE !== 'street') setMode('street');
|
|
SHOTS.forEach((s, i) => {
|
|
camera.position.set(...s.pos); camera.lookAt(new THREE.Vector3(...s.look));
|
|
chunks.update(camera.position);
|
|
composer.render();
|
|
const a = document.createElement('a');
|
|
a.download = `procity-laneB-${String(i + 1).padStart(2, '0')}-${s.name}.png`;
|
|
a.href = canvas.toDataURL('image/png'); a.click();
|
|
});
|
|
camera.position.copy(savePos); camera.quaternion.copy(saveQuat);
|
|
if (wasMap !== 'street') setMode(wasMap); // restore the mode P was pressed in
|
|
hud.showToast('📸 3 shots saved to downloads');
|
|
}
|
|
|
|
// ── main loop ──
|
|
const clock = new THREE.Clock();
|
|
function frame() {
|
|
requestAnimationFrame(frame);
|
|
const dt = Math.min(clock.getDelta(), 0.05);
|
|
|
|
if (MODE === 'street') {
|
|
player.update(dt, player.isLocked ? keys : EMPTY);
|
|
chunks.update(player.position);
|
|
lighting.update(dt, player.position);
|
|
const clk = lighting.getClock();
|
|
citizens.setTimeOfDay((clk.seg + clk.frac) / 6); // [Lane F] 0..1 day fraction → density (busy midday, empty at night)
|
|
// [Lane F R4] no setExposure call: D3 (a5e4b64) made the impostor toneMapped:true, so three /
|
|
// OutputPass tone-map it globally via renderer.toneMappingExposure — the passthrough is now a no-op.
|
|
citizens.update(dt); // [Lane F] reads camera pos internally for LOD tiers
|
|
hud.tickToast(dt);
|
|
hud.update(dt, { clock: clk, chunks: chunks.count }); // reads prev frame's total
|
|
renderer.info.reset(); // zero the counters, then let the composer's passes accumulate this frame
|
|
composer.render();
|
|
} else if (MODE === 'interior') {
|
|
if (interiorMode.update(dt, player.isLocked ? keys : EMPTY)) leaveShop(); // [Lane F] walk to the door → back to street
|
|
} else if (MODE === 'map') {
|
|
camera.getWorldDirection(_fwd);
|
|
minimap.draw(player.position, { x: _fwd.x, z: _fwd.z });
|
|
}
|
|
}
|
|
const EMPTY = new Set();
|
|
const _fwd = new THREE.Vector3();
|
|
frame();
|
|
|
|
// expose for debugging / Lane F
|
|
window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins,
|
|
interiorMode, citizens, enterShop, leaveShop, isOpen, currentHour, fleet, noassets: NOASSETS,
|
|
THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks
|
|
// [Lane B] QA debug harness (window.DBG) — loaded only with ?dbg=1 (LANE_F_NOTES §4). Logic lives
|
|
// in js/world/dbg.js; the shell just hands it the systems it owns so shots.py/soak.py can drive it.
|
|
if (params.get('dbg')) {
|
|
import('./js/world/dbg.js')
|
|
.then((m) => m.installDBG({ plan, camera, renderer, composer, chunks, lighting, player, hud,
|
|
setMode, getMode: () => MODE, enterShop }))
|
|
.catch((e) => console.warn('[procity] DBG harness load failed:', e));
|
|
}
|
|
} // end main()
|
|
</script>
|
|
</body>
|
|
</html>
|