PROCITY/web/index.html
m3ultra f7dd44f55a Lane B (Streetscape): round-11 audio — WebAudio engine (street side)
- web/js/world/audio.js: one AudioContext unlocked on first gesture; day/night ambience crossfade, rain (weather.intensity), footsteps, shop-door music spill (nearest open music-shop, distance-gain), tram rumble+bell, enterShop SFX. Consumes Lane E manifest.audio (3x retry). Self-ticks off window.PROCITY.
- House audio law verified live: silent pre-gesture (0 fetches), ?mute=1 silent surface (0 fetches), ?noassets=1 zero audio/manifest fetches, lazy-load (8.7MB<=25MB), no per-frame alloc.
- window.PROCITY.audio for Lane F: playInterior({musicKey,toneKey})/stopInterior (street beds duck while inside), playSfx, setMasterGain, mute, state diag. Confirms Lane C room.audio={musicKey,toneKey} contract.
- Small guarded [Lane B R11 audio] loader in index.html.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:00:31 +10:00

360 lines
21 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 { createWallet } from './js/interiors/wallet.js'; // [Lane F R8] Lane C buy loop v0 (session wallet)
import { CitizenSim } from './js/citizens/sim.js'; // [Lane F integration] Lane D street pedestrians
import { createWeather } from './js/world/weather.js'; // [Lane F R8] Lane B seeded weather (?weather=1)
import { createTram } from './js/world/tram.js'; // [Lane F R9] Lane B tram (?tram=1, non-blocking)
import { loadPedFleet } from './js/citizens/rigs.js'; // [Lane F §3.4] Lane D rig fleet (GLB peds/keepers)
import { preloadManifest, preloadStockPack } from './js/interiors/interiors.js'; // [Lane F] Lane E manifest + Lane C/E stock pack
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';
const TOWN = params.get('town') || undefined; // [Lane F R8] &town=<key> selects an OSM town (A: melbourne|katoomba)
let plan;
try {
const citygen = await import('./js/citygen/index.js');
const gen = citygen.generatePlanFor
? (s) => citygen.generatePlanFor(s, PLANSRC, { town: TOWN }) // A's selector (synthetic | osm[+town])
: (citygen.generatePlan || citygen.default); // pre-R6 fallback (synthetic only)
plan = gen ? gen(seed) : fixturePlan(seed);
console.log(`[procity] plan source: ${PLANSRC}${TOWN ? '/' + TOWN : ''}${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';
// [Lane F §F2 stock=real] real GODVERSE sleeves in the crates (v2). Preload the record pack at boot so
// the first interior/dig can batch real covers; fail-soft (missing pack → parody canvas). Off under ?noassets.
const STOCK_REAL = params.get('stock') === 'real' && !NOASSETS;
// [Lane F R9] preload ALL three packs so record dig sleeves AND book-spine/toy-box shelves (buy-anywhere)
// are real. Fail-soft per pack (missing → parody canvas). getStockPack(type) is what buildInterior reads.
if (STOCK_REAL) { preloadStockPack('record'); preloadStockPack('book'); preloadStockPack('toy'); }
// [Lane F R8 — Lane C buy loop v0] ONE session wallet (seeded start cash) bound to the dig BUY across shops.
// Runtime-only: never writes back into the plan/room build, so goldens + draw-counts are unaffected.
const wallet = createWallet(seed);
// [Lane F R9] occupancyOf closure defers to `citizens` (declared just below); only invoked at enter()
// time (door click), long after citizens is initialized — so the forward reference is safe.
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,
dig: DIG_ON, stockReal: STOCK_REAL, wallet, occupancyOf: (id) => citizens.occupancyOf(id) });
// [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 §F1 — ROUND 7 ROSTER FLIP · prime-law amendment] The chunk-streamed roster is now the
// DEFAULT: the town is permanently busier. `?roster=v1` restores the old fixed roster (escape hatch);
// `?roster=stream` is still accepted (now a no-op — stream is default). Gated on Lane D's GO: E1's
// ped sub-mesh merge makes each near-rig ~1 draw, so full density (perChunk 16) = 241 worst-street
// draws ≤300. Plan goldens do NOT move (roster doesn't touch the plan). See LANE_D_NOTES → Lane F.
const rosterV1 = params.get('roster') === 'v1';
const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, graph: plan.streets,
fleet, chunkStream: rosterV1 ? null : { radius: 2 } }); // default-on
citizens.setPopulation((parseInt(params.get('pop'), 10) || 140) >>> 0); // fixed-roster (?roster=v1) size; stream = per-chunk density
// [Lane F] hours-aware: keep the open-late block lively at night (Lane D3 setNightLivelyChunks). Keys via
// sim.chunkKeyAt so they match sim's own chunk-key format; openLate shop = hours close ≥22 (Lane A contract).
if (!rosterV1) {
const late = plan.shops.find((s) => s.hours && s.hours[1] >= 22);
const lot = late && plan.lots.find((l) => l.id === late.lot);
if (lot) {
const keys = [];
for (let dx = -1; dx <= 1; dx++) for (let dz = -1; dz <= 1; dz++) keys.push(citizens.chunkKeyAt(lot.x + dx * 64, lot.z + dz * 64));
citizens.setNightLivelyChunks(keys);
}
// [Lane F R8 — Lane D patronage] shop door points per chunk (facade normal → the street), fed once to the
// sim so streamed peds can duck into open shops they pass. Computed from the plan (sim stays graph-only).
const shopsByChunk = new Map();
for (const s of plan.shops) {
const l = plan.lots.find((x) => x.id === s.lot); if (!l) continue;
const ry = l.ry || 0, fx = -Math.sin(ry), fz = -Math.cos(ry);
const x = l.x + fx * (l.d / 2 + 0.6), z = l.z + fz * (l.d / 2 + 0.6);
const k = citizens.chunkKeyAt(x, z);
if (!shopsByChunk.has(k)) shopsByChunk.set(k, []);
shopsByChunk.get(k).push({ x, z, hours: s.hours, shopId: s.id }); // [R9] shopId → occupancy truth (Lane D)
}
citizens.setShops(shopsByChunk);
if (params.get('patronage') === '0') citizens.setPatronage(false); // off-switch
}
document.addEventListener('visibilitychange', () => citizens.setPaused(document.hidden));
// [Lane F R8 — Lane B weather] ?weather=1 seeded (seed 20261990 default town rolls 'clear'); ?weather=rain|
// overcast|clear forces a state. Self-contained module; flag-off never constructs it (byte-identical). D reads
// window.PROCITY.weather (always a valid {state,intensity} — {clear,0} when off), it does NOT import weather.js.
const _wp = params.get('weather');
const weather = (_wp && _wp !== '0')
? createWeather({ scene, plan, skins, lighting, camera, force: /^(clear|overcast|rain)$/.test(_wp) ? _wp : null })
: null;
const weatherState = weather ? weather.state : { state: 'clear', intensity: 0 };
// [Lane F R9 — Lane B tram] ?tram=1: one seeded tram looping the main-street spine, door-dwell at the
// bus shelters. Self-contained (2 draws); flag-off never constructs it (byte-identical). Non-blocking for v2.0.
const tram = (params.get('tram') && params.get('tram') !== '0') ? createTram({ scene, plan, camera, lighting }) : null;
// 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.
if (weather) weather.update(dt); // [Lane F R8] Lane B rain particles + wet ground
if (tram) tram.update(dt); // [Lane F R9] Lane B tram loop
citizens.setWeather(weatherState); // [Lane F R8] Lane D rain reaction (thin + shelter)
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,
weather: weatherState, // [Lane F R8] Lane B contract: {state,intensity}, {clear,0} when off (Lane D reads this)
wallet, // [Lane F R8] Lane C buy loop v0 (session wallet: cash/buy/count)
THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks
// [Lane B R11 audio] street WebAudio engine — self-unlocking on the first gesture; silent with
// zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so
// Lane F can play interior beds through it (playInterior/stopInterior via Lane C's room.audio).
import('./js/world/audio.js')
.then((m) => { window.PROCITY.audio = m.createAudioEngine(window.PROCITY, { noassets: NOASSETS }); })
.catch((e) => console.warn('[procity] audio engine load failed:', e));
// [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>