PROCITY/web/index.html
m3ultra 046668e5af feat(gig): club dancers play real dance clips, not procedural sway
loadPedFleet gains dance:!CLASSIC (mirrors the sit/look gate) → fleet.danceClips
= 4 mesh-free mixamorig club dances (party/medium/drink/sway). _make picks one
per dancer off a fresh gigdance seeded stream (zero impact on existing draws /
the same-seed-same-crowd covenant), spawnRig plays it, and update() drops the
fake bounce for real-clip dancers (procedural sway stays as the classic/noassets
fallback). ?classic fetches nothing, byte-identical.

Verified: default boot fetches all 4 dance GLBs (gated load runs), both modules
parse + export clean, page boots. Same clips + _canon/_rotOnly/spawnRig proven
animating in 90sDJsim (its crowd/rigs.js is this file ported).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 02:11:55 +10:00

558 lines
38 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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. Dig the crates, sell the flips, sleep — the town remembers.</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 · E riffle a bin / sell at the counter · C your crate · SLEEP to save · [ ] time · M map</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
import { createGame } from './js/world/save.js'; // [Lane F R30] v7 THE SAVE CORE (contract: LANE_F_NOTES §30)
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, dance: !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 R31 — THE DIG FLIP] The crate-riffle is DEFAULT-ON: `?dig=0` opts out, `?classic=1` forces it off
// — the exact flagOn pattern the other flipped flags ride. The v2-era opt-in (`params.has('dig')`) survived
// here six epochs after the README's flags table claimed "dig on", and every R30 game-loop gate booted
// `dig=1` explicitly — so the whole suite was green while the plain boot (the boot every player gets) had
// no dig at all: E at a bin did nothing (Fable's R31 wave-0 playtest, the alpha-blocker). The default-boot
// gate now asserts dig REACHABILITY on a naked boot, so un-flipping this line goes red in qa, not in a
// player's hands. ?noassets composes unchanged: the dig falls to parody canvas (existing law).
const DIG_ON = flagOn('dig');
// [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.
// [Lane F R30 — v7 THE SAVE CORE] The game layer, default-ON (the v3.1 flip precedent): ?game=0 opts out,
// ?classic=1 forces its total absence (createGame never called ⇒ ZERO localStorage touches — the covenant
// stays a pure v2 boot). When the game is on, PROCITY.wallet IS game.wallet — Lane C's exact v0 interface
// (buy/sell/canBuy/…) backed by game-owned cash, so a loaded save's cash is authoritative and every
// existing consumer (dig onBuy, shelf buy, the cover charge, C's onSell) rides the proven seam unchanged.
// A fresh game opens with Lane C's seeded start cash, so day 1 is byte-identical money to a ?game=0 boot.
const GAME_ON = flagOn('game');
const TOWNKEY = `${PLANSRC}/${TOWN || 'default'}@${seed}`;
const baseWallet = createWallet(seed);
const game = GAME_ON ? createGame({
townKey: TOWNKEY, startCash: baseWallet.start(),
// fired after sleep()/import() moves the day: re-key the gig week (day % 7 into the existing schedule),
// wake at dawn (segment 0 — fires procity:segment, so every latch observes the morning), tell John.
onDay: (day) => {
if (gigState) gigState.setWeekNight(weekNightOf(day)); // (day 1) % 7 — see the seam note below
lighting.setSegment(0);
hud.showToast(`🌅 day ${day}`);
},
}) : null;
const wallet = game ? game.wallet : baseWallet;
// [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 R30 — SLEEP=TOMORROW] key tonight's gigs off the game day: weekNight = (day 1) % 7, so a
// loaded save wakes mid-week and every sleep walks the EXISTING seeded week schedule. Measured against
// the brief's literal `day % 7`: day starts at 1, so the literal form would boot a FRESH game on night 1
// — flipping the default boot's gigs off night 0, breaking the R13 cover/band gates and B's night-0-keyed
// frontage before the player ever sleeps. Day 1 IS night 0 (the pre-v7 tonight), same convention as the
// day-1 stock streams (§30.3). Skipped entirely when either layer is off.
const weekNightOf = (day) => (Math.max(1, day) - 1) % 7;
if (game && gigState) gigState.setWeekNight(weekNightOf(game.day));
// [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
game }); // [R30] the collection (buy seam records finds) + the day salt (stock rotation, §30.3)
// [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 R30 §9.4] the sell card is cursor-driven too (SELL button, cycling) — same unlock contract.
window.addEventListener('procity:sellOpen', () => 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 && !interiorMode.sellActive) leaveShop(); }); // [Lane F] Esc leaves the shop — but not when the unlock is us opening a dig (§F2) or the sell card (R30 §9.4)
// click the world to (re)grab the pointer when walking
canvas.addEventListener('click', () => { if (!player.isLocked && !interiorMode.digActive && !interiorMode.sellActive && (MODE === 'street' || MODE === 'interior')) player.lock(); }); // [Lane F §F2] re-lock for interior walk after closing a dig/sell card
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 (cash/buy/sell/count) — game-backed facade when the game is on
game, // [Lane F R30] THE GAME (LANE_F_NOTES §30 contract) — NULL under ?classic=1 / ?game=0
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'),
game: GAME_ON, // [R30] the v7 game layer (default-on; ?game=0 / ?classic=1 → off)
dig: DIG_ON }, // [R31] the dig flip (default-on; ?dig=0 / ?classic=1 → off) — gate reads intent here
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>