The spike reaches the game. E's look.glb (mesh-free, 65 joints, 6.5s 'Look') is bound
through the fleet gate; street peds now glance around during window-shop stops. `lean`
is absent and never existed (E's R28 measurement). Goldens untouched — clips sit outside
every plan hash; the synthetic anchor stays R27's amended 0x5f76e76.
WIRED: loadPedFleet(base, {sit, look}) -> fleet.lookClip, the exact twin of the R16 sit
gate; the shell passes look:!CLASSIC so ?classic fetches NOTHING (a raw fetch would have
breached the zero-fetch-delta covenant — E flagged it, the gate is the answer).
makeActor gains lookA, played only on setLooking(true) — crossfaded (0.3s), not snapped
like sit, since idle->idle-variant reads naturally and there's no replant for a fade to
fight. sim.js gains a dedicated `glance` rng stream, rolled ALWAYS (deterministic) but
only flipped when a clip exists => classic inert at the source. Mutually exclusive with
bench-sit (sit rolls first at 35%, glance takes 40% of the rest). RigPool threads
lookClip; release snaps the glance off so no rig is pooled mid-look.
THE FINDING — my own prediction was wrong, and the measurement caught it. I wrote that a
STANDING clip needs no foot replant ("no hip descent to recover — that trap was sit's
alone"). Measured over the full loop: idle keeps the lowest bone within 4cm of the ground
(-0.04..0.00); look.glb ranges -0.009..+0.205 — the ped HOVERS up to 20.5cm, drifting
through the loop. Same trap, different coat: _rotOnly drops the Hips POSITION track for
rig-height independence, so the clip's authored weight-shift dip can't move the hips and
comes out as the FEET RISING. Sit's one-time replant can't fix it — the float varies per
frame.
FIX: plantFeet() — the per-frame twin of sit's replant. Reset to bind plant -> measure
posed feet -> drop inner by float/scale. ABSOLUTE (reset->measure->correct), never
incremental, so it cannot drift. Reproduces the authored motion exactly: the hips dip
relative to the ground instead of the feet leaving it. Scale-aware like sit's. Foot bones
cached once; cost paid only by glancing peds, on frames their mixer ran (both mixer sites
— the drummer's post-mix lean taught that coupling rule).
PROOFS: foot float 0.205m -> 0.000m (pinned); head yaw sweep span 1.735 (-0.85..+0.89) so
the glance still animates; ?classic — no look fetch, no Look action on any rig, glance
never flips (0); identity signature unchanged, no glance term; sit AND glance never
simultaneous (0); 0 NaN; goldens 0x5f76e76 untouched. A glancing ped is NOT tagged
seated/posed — standing pose at standing stature, so F's no-giants gate needs no arm.
D->F: the pose gate HAS ITS SUBJECT — un-SKIP it. Fresh ?localdepot=1 boot shows a ped
glancing on the footpath, head sweeping, feet planted.
Spike 2 readiness: plantFeet() is the general machinery the brief asked for ("data, not
new code") — riffle and wall-lean should land as clip + roll; the lean's wall-contact
placement is the only genuinely new part.
FLAGGED (A's file, A rests): selfcheck.js:301 still says "the synthetic golden above stays
0x3fa36874" while line 267 pins the amended 0x5f76e76. A stale comment, not a rule — but
the same species as the epoch's five holds, so: flagged, not touched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
518 lines
34 KiB
HTML
518 lines
34 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, godverseBaseFor } 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 { createGigState } from './js/world/gig_state.js'; // [Lane F R12] the Friday-night state machine (?gigs=1)
|
|
import { createVenuePresentation } from './js/world/venue.js'; // [Lane F R12] Lane B venue frontage + posters (?gigs=1)
|
|
import { loadPedFleet } from './js/citizens/rigs.js'; // [Lane F §3.4] Lane D rig fleet (GLB peds/keepers)
|
|
import { VenueQueue } from './js/citizens/queue.js'; // [Lane F R13] Lane D outdoor gig queue (?gigs=1)
|
|
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)
|
|
// [Lane F R16 — THE FLIP (v3.1)] The town shows its best by DEFAULT: gigs / weather / winmap / tram are ON
|
|
// unless individually opted out (?gigs=0, ?weather=0, …) or ?classic=1 forces the exact v2 boot. The prime
|
|
// law is REWRITTEN, not repealed: the byte-identical covenant (synthetic fingerprint 0x3fa36874, zero gig
|
|
// layer, zero fetch delta) moved from "flags off" to "?classic=1". `flagOn(name)`: default-on, `=0` opts
|
|
// out, classic is the master off. winmap flips the same way in buildings.js; the drummer's sit clip rides !CLASSIC.
|
|
const CLASSIC = params.has('classic') && params.get('classic') !== '0';
|
|
const flagOn = (name) => !CLASSIC && params.get(name) !== '0';
|
|
// [Lane F R12/16] The gig layer — now DEFAULT-ON: Lane A's post-hoc augmentation (venues, plan.gigs,
|
|
// plan.posters) + everything F wires off it (the state machine, B's frontage/spill, C's rooms, D's
|
|
// band/crowd/queue, the cover charge). ?gigs=0 or ?classic=1 ⇒ generatePlanFor returns the byte-identical
|
|
// v2 plan and NONE of the gig modules are constructed (the classic-regression gate enforces exactly that).
|
|
const GIGS_ON = flagOn('gigs');
|
|
// John's OPTIONAL band-name drop-in (web/assets/custom_bands.json — Lane A owns the file + schema; edit it
|
|
// and reload, the names get priority on the town's posters). Absent file → pure generator, zero errors.
|
|
// ZERO fetch when the flag is off or under ?noassets=1 (asset law). `no-cache` on purpose: this file exists
|
|
// to be hand-edited between reloads, so a stale 304 would make John's edit look broken.
|
|
async function loadCustomBands() {
|
|
if (!GIGS_ON || NOASSETS) return [];
|
|
try {
|
|
const r = await fetch('assets/custom_bands.json', { cache: 'no-cache' });
|
|
if (!r.ok) return []; // 404 = John deleted the file = generator takes over. Not an error.
|
|
const j = await r.json();
|
|
return Array.isArray(j && j.bands) ? j.bands : [];
|
|
} catch { return []; } // malformed / blocked → generator. Never breaks the boot.
|
|
}
|
|
const customBands = await loadCustomBands();
|
|
let plan;
|
|
try {
|
|
const citygen = await import('./js/citygen/index.js');
|
|
// [Lane F R17 — real-town caches] a ?plansrc=osm&town=<key> whose key is NOT a checked-in fixture is one of
|
|
// Lane E's real AU town caches (web/assets/towns/<key>.json). Fetch + register it before the plan generates,
|
|
// so real towns boot in the engine (the v4 scout). Fail-soft: 404 / invalid cache / ?noassets → plan_osm
|
|
// falls back to the default fixture. The scout data is opt-in — never on the default or ?classic path.
|
|
if (PLANSRC === 'osm' && TOWN && !NOASSETS && citygen.registerTownCache
|
|
&& !(citygen.osmTownKeys && citygen.osmTownKeys().includes(TOWN))) {
|
|
try {
|
|
const r = await fetch(`assets/towns/${TOWN}.json`, { cache: 'force-cache' });
|
|
if (r.ok) { citygen.registerTownCache(TOWN, await r.json()); console.log(`[procity] real town cache registered: ${TOWN}`); }
|
|
} catch (e) { console.warn(`[procity] town cache '${TOWN}' not loaded (${e && e.message || e}) — falling back to a fixture`); }
|
|
}
|
|
const gen = citygen.generatePlanFor
|
|
? (s) => citygen.generatePlanFor(s, PLANSRC, { town: TOWN, gigs: GIGS_ON, customBands }) // A's selector (synthetic | osm[+town]) + the v3 gig layer
|
|
: (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`
|
|
+ (GIGS_ON ? ` · gigs: ${(plan.gigs || []).length} nights, ${(plan.posters || []).length} posters`
|
|
+ `, ${customBands.length} custom band name(s)` : ''));
|
|
} 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.
|
|
// [Lane F R19 — B's proven one-liner] a real town can have FEW shops but a HUGE road graph (real Katoomba:
|
|
// 20 shops / 799 edges / 5 km) — the shop-count heuristic alone left it on small-town streaming and blew the
|
|
// tri budget (280k > 200k). Extend the trigger to the graph size so big-graph towns stream like big cities.
|
|
const BIG_CITY = (plan.shops?.length || 0) > 120 || (plan.streets?.edges?.length || 0) > 200;
|
|
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/', { sit: !CLASSIC, look: !CLASSIC }); // [R16] sit.glb (the seated drummer) + [R29] look.glb (the street glance) on the default boot; ?classic keeps the zero-fetch-delta covenant
|
|
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 R27 §2 — TIER 2, OPT-IN] `?live=1` → the default GODVERSE base; `?live=<url>` → that base;
|
|
// absent → NO tier 2 at all, byte-identical to the beta. Opt-in is deliberate and is the R26 lesson
|
|
// applied to a server: a default boot that probed for a GODVERSE host would ask a question with no
|
|
// answer on every machine that isn't John's, and "it only logs an error when the server's away" is
|
|
// exactly the blind probe the manifest was built to end. Tier 2 is a place you go, not a thing that
|
|
// happens to you. Auto-discovery, if ever, is a v5.x question with a reachability contract behind it.
|
|
const LIVE_ARG = NOASSETS ? null : params.get('live');
|
|
const LIVE_BASE = !LIVE_ARG || LIVE_ARG === '0' ? null
|
|
: (LIVE_ARG === '1' ? 'http://127.0.0.1:8778' : LIVE_ARG.replace(/\/$/, ''));
|
|
let STOCK_SOURCING = null; // (godverseShopId) → 'real'|'mint'|null, from G's manifest (set below)
|
|
// [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.
|
|
// [Lane F R24 §6a — THE REAL CRATE] The town-wide packs stay exactly as they were (every shop without a
|
|
// GODVERSE identity keeps them, byte-identically). On TOP of them, every shop carrying A's `godverseShopId`
|
|
// preloads its OWN pack under its OWN base — because `getStockPack` is sync and the room is built the
|
|
// instant you open the door, so a pack first requested at enter() would not be resolved in time and the
|
|
// real shop would show parody on the walk-in. Cache identity is (type, base) since C's R24 fix, so these
|
|
// coexist with the town-wide packs rather than clobbering them. A keyed shop with no atlas 404s → null →
|
|
// parody: fail-soft runs through the same path, and that is the tier-0 rung of the ladder, live.
|
|
if (STOCK_REAL) {
|
|
preloadStockPack('record'); preloadStockPack('book'); preloadStockPack('toy');
|
|
// [Lane F R26 §7 — THE MANIFEST KILLS THE PROBES] R24 preloaded a pack for EVERY keyed shop and ate a
|
|
// 404 for each one that had no atlas. That was fail-soft working, but it was also console errors on
|
|
// every godverse boot, and F's smoke could only tolerate them by NAMING them — a tolerance is a
|
|
// confession, not a fix. G's manifest (F's R24 finding #4/#7) says exactly which (shop, type) pairs
|
|
// exist, so now we ask for nothing else: zero 404s because there is nothing absent to ask for, not
|
|
// because we stopped looking. Fail-soft still covers the real case — manifest says yes, file is gone.
|
|
// Only fetched when the town actually has keyed shops, so a plain town stays byte-identical (no fetch).
|
|
if ((plan.shops || []).some((s) => s.godverseShopId)) {
|
|
try {
|
|
const r = await fetch('assets/stock_godverse/index.json', { cache: 'force-cache' });
|
|
if (r.ok) {
|
|
const man = await r.json();
|
|
const byId = new Map((man.shops || []).map((s) => [s.godverseShopId, s]));
|
|
for (const s of (plan.shops || [])) {
|
|
const e = byId.get(s.godverseShopId);
|
|
if (e && (e.types || []).includes(s.type)) preloadStockPack(s.type, { base: godverseBaseFor(s) });
|
|
}
|
|
// [R27 §2] the same manifest already knows real vs mint — the tier-2 reader asks only real shops.
|
|
STOCK_SOURCING = (id) => (byId.get(id) || {}).sourcing || null;
|
|
}
|
|
} catch (e) { /* no manifest ⇒ no per-shop packs ⇒ parody. The ladder's bottom rung, unchanged. */ }
|
|
}
|
|
}
|
|
// [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 R12] the gig state machine (F-owned, js/world/gig_state.js): quiet → doors (DUSK) → on (NIGHT)
|
|
// → done, off Lane B's clock and Lane A's plan.gigs[0] (alpha's "tonight"). Constructed ONLY under ?gigs=1
|
|
// with a real schedule, so window.PROCITY.gigs stays undefined flags-off — which is precisely what Lane B's
|
|
// audio engine tests to decide there is no gig layer at all (byte-identical, no spill).
|
|
const gigState = (GIGS_ON && plan.gigs && plan.gigs.length) ? createGigState({ plan, lighting }) : null;
|
|
// [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),
|
|
rosterOf: (id) => citizens.tonightRoster(id), gigState, // [R15] identity continuity: the crowd IS tonight's roster
|
|
liveBase: LIVE_BASE, sourcingOf: (id) => (STOCK_SOURCING ? STOCK_SOURCING(id) : null) }); // [R27] tier 2
|
|
// [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.
|
|
// [Lane F R16 flip] weather DEFAULT-ON (seeded — the default town rolls its own state); ?weather=rain|overcast|
|
|
// clear forces one; ?weather=0 or ?classic=1 → off (byte-identical). Self-contained; D reads window.PROCITY.weather.
|
|
const _wp = params.get('weather');
|
|
const weather = flagOn('weather')
|
|
? 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 = flagOn('tram') ? createTram({ scene, plan, camera, lighting }) : null; // [R16 flip] default-on; ?tram=0 / ?classic=1 → off
|
|
// [Lane F R12 — Lane B venue.js] the street side of the gig: E's poster skins at A's plan.posters (band name
|
|
// overprinted like a shop sign) + the lit pub frontage, whose marquee/sign glow F ramps from the gig state.
|
|
// Self-contained module; never constructed flags-off (byte-identical), like weather/tram.
|
|
const venuePresentation = gigState ? createVenuePresentation(plan, skins, scene) : null;
|
|
|
|
// [Lane F R13 — the outdoor queue] Lane D's VenueQueue, one per venue, wired from the street-side gig state
|
|
// (D shipped the class and left the street-side lifecycle to F — its documented seam). A seeded line forms at
|
|
// the frontage when the doors open, idle-shuffles + auto-drains each street frame, and is disposed by 'done'.
|
|
// B publishes the zone (venuePresentation.queueZoneFor); D's rig path is gate-protected (no giants) + a
|
|
// placeholder line under ?noassets. Built lazily per venue when its doors open; never touched flags-off.
|
|
const venueQueues = gigState ? new Map() : null; // venueShopId → VenueQueue
|
|
const queueCountOf = (id) => { const q = venueQueues && venueQueues.get(id); return q ? q.count() : 0; };
|
|
|
|
// 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 R13 — cover charge, PER VENUE] John's ruling: free AND paid nights. A's `cover` is seeded per
|
|
// gig (~half 0). Paid → debit the R8 session wallet on the way in with a toast; too skint → a polite
|
|
// knockback and NO entry (the wallet stays honest); free → walk straight in. Re-entry the same night is
|
|
// free: the stamp lives in gigState, runtime only, so nothing persists and no plan/golden moves. THE
|
|
// DISTRICT: keyed by the venue being ENTERED (…Of(shopId)), so a night at two venues is two covers. Zero
|
|
// new UI — it rides the round-8 buy seam; the venue is a normal shop in every other respect.
|
|
if (gigState && gigState.openOf(shopId) && gigState.coverOf(shopId) > 0 && !gigState.paidOf(shopId)) {
|
|
const cover = gigState.coverOf(shopId), BAND = String(gigState.bandNameOf(shopId) || 'live').toUpperCase();
|
|
if (!wallet.canBuy(cover)) {
|
|
hud.showToast(`🚫 $${cover} cover — ${BAND} · you've got $${wallet.cash()}`);
|
|
return;
|
|
}
|
|
wallet.buy({ title: `cover — ${gigState.bandNameOf(shopId)}`, price: cover });
|
|
gigState.markPaidOf(shopId);
|
|
hud.showToast(`$${cover} cover — ${BAND}`);
|
|
}
|
|
// [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 R13 — THE DISTRICT] Friday night, off the clock: tick EVERY venue's latch, ramp Lane B's
|
|
// per-venue marquee/sign glow off the byVenue map, and point Lane D's patronage surge at each venue whose
|
|
// doors are open — several concurrent gigs each pull their own block (setGig(id,false) ends one → normal
|
|
// day patronage there). Lane B's audio engine reads window.PROCITY.gigs.byVenue on its own rAF for the
|
|
// per-genre muffled spill.
|
|
if (gigState) {
|
|
gigState.update(); // tick all latches
|
|
if (venuePresentation) venuePresentation.update(gigState.byVenue); // per-venue frontage/spill
|
|
for (const id of gigState.venueShopIds) {
|
|
const open = gigState.openOf(id);
|
|
citizens.setGig(id, open); // concurrent patronage surges
|
|
if (venueQueues) { // the outdoor line: forms while the doors are open, drains + disposes by 'done'
|
|
let q = venueQueues.get(id);
|
|
if (open && !q) {
|
|
q = new VenueQueue({ citySeed: plan.citySeed, fleet });
|
|
q.spawn(scene, { queueZone: venuePresentation && venuePresentation.queueZoneFor(id),
|
|
gigId: (gigState.gigOf(id) || {}).gigId | 0,
|
|
// [R15] identity continuity: relay each queue admit → D's tonight roster, so the
|
|
// punter who queued turns up in the interior crowd (clear is automatic at setGig(id,false)).
|
|
onAdmit: (entry) => citizens.recordVenueEntry(id, entry) });
|
|
venueQueues.set(id, q);
|
|
} else if (!open && q) { q.disposeAll(); venueQueues.delete(id); }
|
|
if (q) q.update(dt);
|
|
}
|
|
}
|
|
}
|
|
// [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)
|
|
gigs: gigState, // [Lane F R12] the gig state machine — NULL flags-off (Lane B's audio tests this to
|
|
// decide there's no gig layer at all). `.state` is a live getter: quiet|doors|on|done
|
|
venuePresentation, // [Lane F R12] Lane B frontage + posters (null flags-off)
|
|
venueQueues, queueCountOf, // [Lane F R13] Lane D outdoor queue per venue + count accessor (F's smoke reads it)
|
|
tram, // [Lane F R21] Lane B's tram — `.routeInfo` carries the R20 shop-adjacency verdict
|
|
// ({fenced, reason, shopsFronted}) + `.stops`; B built it FOR F's smoke. Null flags-off.
|
|
// [Lane F R16 — THE FLIP] the flag-intent surface: what SHOULD be on this boot (the gates verify intent vs
|
|
// reality). Default boot = all four flipped flags true; ?classic=1 = all false + classic true; each `=0` opts out.
|
|
flags: { classic: CLASSIC, gigs: GIGS_ON, weather: flagOn('weather'), winmap: flagOn('winmap'), tram: flagOn('tram') },
|
|
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, interiorMode })) // [R24] interiorMode → DBG.stockInfo()
|
|
.catch((e) => console.warn('[procity] DBG harness load failed:', e));
|
|
}
|
|
} // end main()
|
|
</script>
|
|
</body>
|
|
</html>
|