Implements the R3 design note (was design-only). Default-off; the v1 whole-graph path is byte-identical when the flag is off (shell citizen-0 signature matches the R4 golden exactly). - chunk-local identity keyed (seed, chunkKey, i) — same seed + chunk -> same residents, independent of town size / visit order; home edge picked from that chunk's edges. - windowed spawn/despawn: hook-driven via Lane B's onChunkBuilt/onChunkDisposed (sim auto- detects and stops polling) + a camera-poll fallback that needs zero Lane B changes. - global near-cap + mixer stagger + rig pool + impostor layer unchanged, run across live chunks. - owner-chunk despawn (no cross-boundary re-keying); per-chunk time-of-day thinning (hours-aware). Proven on a 9x9 fixture grid: constant far-field density (~58-129 within 70m at spawn AND 150m out, vs v1's uniform ~12-15); deterministic (507 chunk-keyed identities re-derive from seed); leak-free (2 full grid walks / ~380 chunk cycles -> 0 geom/tex delta, pool capped 30); budget unchanged (near-cap 24 + 1 impostor draw, 2.31ms/frame). qa.sh --strict GREEN. Only web/js/citizens/sim.js + web/citizens_test.html changed. Flag + shell call-site documented in LANE_D_NOTES for Lane F to wire web/index.html. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
322 lines
16 KiB
HTML
322 lines
16 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>PROCITY · Lane D — Citizens test</title>
|
||
<style>
|
||
:root { color-scheme: dark; }
|
||
* { box-sizing: border-box; }
|
||
html, body { margin: 0; height: 100%; overflow: hidden; background: #12100e;
|
||
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; color: #e8e2d6; }
|
||
#app { position: fixed; inset: 0; }
|
||
#hud { position: fixed; top: 10px; left: 10px; width: 264px; padding: 12px 14px;
|
||
background: rgba(20,17,14,.82); border: 1px solid #3a3128; border-radius: 8px;
|
||
backdrop-filter: blur(6px); user-select: none; }
|
||
#hud h1 { margin: 0 0 8px; font-size: 12px; letter-spacing: .08em; color: #d8b23a; text-transform: uppercase; }
|
||
.row { display: flex; justify-content: space-between; gap: 8px; margin: 3px 0; }
|
||
.row .k { color: #9a8f7d; }
|
||
.row .v { color: #f0e8d0; font-variant-numeric: tabular-nums; }
|
||
.ctl { margin: 10px 0 4px; }
|
||
.ctl label { display: flex; justify-content: space-between; color: #c7bca8; margin-bottom: 3px; }
|
||
input[type=range] { width: 100%; accent-color: #d8b23a; }
|
||
.btns { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
|
||
button { flex: 1; min-width: 84px; padding: 6px 8px; cursor: pointer; color: #efe7d6;
|
||
background: #2c2620; border: 1px solid #4a3f31; border-radius: 6px; font: inherit; }
|
||
button:hover { background: #38302666; border-color: #6a5a44; }
|
||
button.on { background: #4a3d1f; border-color: #d8b23a; color: #ffe9a8; }
|
||
#det { margin-top: 8px; min-height: 15px; color: #8fd88f; }
|
||
#warn { color: #e0a24a; margin-top: 6px; }
|
||
hr { border: 0; border-top: 1px solid #3a3128; margin: 10px 0 6px; }
|
||
#help { position: fixed; bottom: 8px; left: 10px; color: #6f665a; font-size: 11px; }
|
||
.barwrap { height: 6px; background: #2a241d; border-radius: 3px; overflow: hidden; margin-top: 2px; }
|
||
.bar { height: 100%; background: linear-gradient(90deg,#6db36d,#d8b23a,#c8102e); width: 0%; }
|
||
</style>
|
||
<script type="importmap">
|
||
{ "imports": {
|
||
"three": "./vendor/three.module.js",
|
||
"three/addons/": "./vendor/addons/"
|
||
} }
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<div id="app"></div>
|
||
<div id="hud">
|
||
<h1>Lane D · Citizens</h1>
|
||
<div class="row"><span class="k">fps</span><span class="v" id="fps">–</span></div>
|
||
<div class="barwrap"><div class="bar" id="fpsbar"></div></div>
|
||
<div class="row"><span class="k">rigged (near)</span><span class="v" id="s-rig">0</span></div>
|
||
<div class="row"><span class="k">impostor (mid)</span><span class="v" id="s-mid">0</span></div>
|
||
<div class="row"><span class="k">culled (far)</span><span class="v" id="s-far">0</span></div>
|
||
<div class="row"><span class="k">active</span><span class="v" id="s-active">0</span></div>
|
||
<hr>
|
||
<div class="row"><span class="k">mixer ms</span><span class="v" id="s-mixer">0</span></div>
|
||
<div class="row"><span class="k">draw calls</span><span class="v" id="s-draws">0</span></div>
|
||
<div class="row"><span class="k">triangles</span><span class="v" id="s-tris">0</span></div>
|
||
<div class="row"><span class="k">rig pool</span><span class="v" id="s-pool">0</span></div>
|
||
<div class="row"><span class="k">mode</span><span class="v" id="s-mode">–</span></div>
|
||
|
||
<div class="ctl">
|
||
<label>population <span class="v" id="l-pop">120</span></label>
|
||
<input type="range" id="pop" min="0" max="200" value="120">
|
||
</div>
|
||
<div class="ctl">
|
||
<label>time of day <span class="v" id="l-tod">12:00</span></label>
|
||
<input type="range" id="tod" min="0" max="1000" value="500">
|
||
</div>
|
||
<div class="btns">
|
||
<button id="btn-tiers">Tier colours</button>
|
||
<button id="btn-det">Determinism ✓</button>
|
||
</div>
|
||
<div id="det"></div>
|
||
<div id="warn"></div>
|
||
</div>
|
||
<div id="help">drag = look · wheel = zoom · WASD = move focus · Q/E = up/down</div>
|
||
|
||
<script type="module">
|
||
import * as THREE from 'three';
|
||
import { loadPedFleet } from './js/citizens/rigs.js';
|
||
import { CitizenSim, identityOf, signatureOf } from './js/citizens/sim.js';
|
||
import { pickRig } from './js/citizens/rigs.js';
|
||
import { KeeperManager } from './js/citizens/keepers.js';
|
||
|
||
const qs = new URLSearchParams(location.search);
|
||
const SEED = (parseInt(qs.get('seed'), 10) || 20261990) >>> 0;
|
||
const NOASSETS = qs.get('noassets') === '1';
|
||
const STREAM = qs.get('roster') === 'stream'; // v2: chunk-streamed roster (default off = v1)
|
||
|
||
// ---------- renderer / scene ----------
|
||
const app = document.getElementById('app');
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||
renderer.setSize(innerWidth, innerHeight);
|
||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||
renderer.toneMappingExposure = 1.05;
|
||
app.appendChild(renderer.domElement);
|
||
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0xbcC6cf);
|
||
scene.fog = new THREE.Fog(0xbcC6cf, 90, 220);
|
||
|
||
const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.1, 600);
|
||
|
||
// warm cinematic key + cool sky fill (the POLY lock)
|
||
const sun = new THREE.DirectionalLight(0xfff0d8, 2.2); sun.position.set(40, 70, 25); scene.add(sun);
|
||
scene.add(new THREE.HemisphereLight(0xbcd0f0, 0x4a4034, 1.15));
|
||
scene.add(new THREE.AmbientLight(0x2a2620, 0.4));
|
||
|
||
// neutral PMREM environment — the peds are metallic-PBR (metalness≈0.5) and render dark with no
|
||
// environment to reflect. Lane B's shell will provide the real sky env; the test scene makes its own.
|
||
// (Set BEFORE the sim is created so the rig-atlas bake reads scene.environment too.)
|
||
function makeEnv() {
|
||
const c = document.createElement('canvas'); c.width = 16; c.height = 64;
|
||
const x = c.getContext('2d'), g = x.createLinearGradient(0, 0, 0, 64);
|
||
g.addColorStop(0, '#e6eef7'); g.addColorStop(0.5, '#c2cad0'); g.addColorStop(1, '#726552');
|
||
x.fillStyle = g; x.fillRect(0, 0, 16, 64);
|
||
const tex = new THREE.CanvasTexture(c);
|
||
tex.mapping = THREE.EquirectangularReflectionMapping; tex.colorSpace = THREE.SRGBColorSpace;
|
||
const pm = new THREE.PMREMGenerator(renderer); const env = pm.fromEquirectangular(tex).texture;
|
||
tex.dispose(); pm.dispose(); return env;
|
||
}
|
||
scene.environment = makeEnv();
|
||
|
||
// ---------- fixture street graph (LOCAL — no Lane A/B imports) ----------
|
||
// a GRID×GRID lattice (≈±GRID·G/2 m) → a big walkable loop network with turns at every junction.
|
||
// Deliberately large so ?roster=stream can be walked far from spawn: v1's fixed roster thins out over
|
||
// the whole grid, chunk-streaming keeps constant density around the camera. Centre cross = main streets.
|
||
const GRID = 9, G = 52, MID = (GRID - 1) / 2;
|
||
const nodes = [];
|
||
for (let r = 0; r < GRID; r++) for (let c = 0; c < GRID; c++)
|
||
nodes.push({ id: `n${r}_${c}`, x: (c - MID) * G, z: (r - MID) * G });
|
||
const nid = (r, c) => `n${r}_${c}`;
|
||
const edges = [];
|
||
let eid = 0;
|
||
for (let r = 0; r < GRID; r++) for (let c = 0; c < GRID; c++) {
|
||
if (c < GRID - 1) edges.push({ id: `e${eid++}`, a: nid(r, c), b: nid(r, c + 1), width: r === MID ? 9 : 6, kind: r === MID ? 'main' : 'side' });
|
||
if (r < GRID - 1) edges.push({ id: `e${eid++}`, a: nid(r, c), b: nid(r + 1, c), width: c === MID ? 9 : 6, kind: c === MID ? 'main' : 'side' });
|
||
}
|
||
const graph = { nodes, edges };
|
||
const GRID_SPAN = GRID * G; // used to size the ground plane + fog
|
||
|
||
// ground + road/footpath strips so the streets read
|
||
const groundMat = new THREE.MeshStandardMaterial({ color: 0x6f7a52, roughness: 1 });
|
||
const ground = new THREE.Mesh(new THREE.PlaneGeometry(GRID_SPAN + 200, GRID_SPAN + 200), groundMat);
|
||
ground.rotation.x = -Math.PI / 2; ground.position.y = -0.02; scene.add(ground);
|
||
|
||
function addStrip(width, len, cx, cz, beta, color, y) {
|
||
const m = new THREE.Mesh(new THREE.PlaneGeometry(width, len),
|
||
new THREE.MeshStandardMaterial({ color, roughness: 1 }));
|
||
m.rotation.x = -Math.PI / 2; m.rotation.z = -beta; // z-rotation in the flattened plane aligns length
|
||
m.position.set(cx, y, cz);
|
||
scene.add(m);
|
||
return m;
|
||
}
|
||
const nodeById = id => nodes.find(n => n.id === id);
|
||
for (const e of edges) {
|
||
const A = nodeById(e.a), B = nodeById(e.b);
|
||
const dx = B.x - A.x, dz = B.z - A.z, len = Math.hypot(dx, dz);
|
||
const beta = Math.atan2(dx, dz);
|
||
const cx = (A.x + B.x) / 2, cz = (A.z + B.z) / 2;
|
||
addStrip(e.width, len, cx, cz, beta, 0x2f2c29, 0.0); // asphalt
|
||
const foot = e.width / 2 + 0.9, px = Math.cos(beta), pz = -Math.sin(beta);
|
||
addStrip(1.8, len, cx + px * foot, cz + pz * foot, beta, 0x8b8375, 0.01); // footpath +side
|
||
addStrip(1.8, len, cx - px * foot, cz - pz * foot, beta, 0x8b8375, 0.01); // footpath -side
|
||
}
|
||
// junction pads
|
||
for (const n of nodes) {
|
||
const pad = new THREE.Mesh(new THREE.CircleGeometry(5.5, 20),
|
||
new THREE.MeshStandardMaterial({ color: 0x323029, roughness: 1 }));
|
||
pad.rotation.x = -Math.PI / 2; pad.position.set(n.x, 0.005, n.z); scene.add(pad);
|
||
}
|
||
|
||
// ---------- fleet + sim ----------
|
||
const fleet = loadPedFleet(NOASSETS ? 'models/peds/__none__/' : 'models/peds/');
|
||
const sim = new CitizenSim({ renderer, scene, camera, citySeed: SEED, graph, fleet,
|
||
chunkStream: STREAM ? { radius: 2, perChunk: 16 } : null });
|
||
|
||
// ---------- keeper demo (mock counter, since Lane C hasn't landed) ----------
|
||
const counter = new THREE.Mesh(new THREE.BoxGeometry(3, 1.05, 1),
|
||
new THREE.MeshStandardMaterial({ color: 0x6b4a2e, roughness: 0.8 }));
|
||
counter.position.set(6, 0.525, 10); scene.add(counter);
|
||
const keepers = new KeeperManager({ camera, citySeed: SEED, fleet });
|
||
let keeper = null;
|
||
fleet.whenReady.then(() => { keeper = keepers.spawn(scene, { x: 6, z: 11.2, ry: 0, shopId: 'demo', type: 'record' }); });
|
||
if (NOASSETS) keeper = keepers.spawn(scene, { x: 6, z: 11.2, ry: 0, shopId: 'demo', type: 'record' });
|
||
|
||
// ---------- debug tier markers ----------
|
||
const TIER_COL = { near: new THREE.Color(0x66dd88), mid: new THREE.Color(0xffcc44), far: new THREE.Color(0x884466) };
|
||
const ring = new THREE.RingGeometry(0.4, 0.62, 18); ring.rotateX(-Math.PI / 2);
|
||
const markers = new THREE.InstancedMesh(ring,
|
||
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0.85 }), 512);
|
||
markers.frustumCulled = false; markers.visible = false; markers.count = 0; scene.add(markers);
|
||
const _mm = new THREE.Matrix4(), _mp = new THREE.Vector3(), _mq = new THREE.Quaternion(), _ms = new THREE.Vector3(1, 1, 1);
|
||
function paintTierMarkers() {
|
||
const act = sim.activeCitizens(); let n = 0;
|
||
for (let i = 0; i < act.length && n < 512; i++) {
|
||
const c = act[i];
|
||
_mp.set(c.x, 0.06, c.z); _mm.compose(_mp, _mq, _ms);
|
||
markers.setMatrixAt(n, _mm); markers.setColorAt(n, TIER_COL[c.tier] || TIER_COL.far); n++;
|
||
}
|
||
markers.count = n; markers.instanceMatrix.needsUpdate = true;
|
||
if (markers.instanceColor) markers.instanceColor.needsUpdate = true;
|
||
}
|
||
|
||
// ---------- minimal orbit camera ----------
|
||
const cam = { target: new THREE.Vector3(0, 1, 0), theta: 0.6, phi: 1.15, radius: 55 };
|
||
function applyCam() {
|
||
const r = cam.radius, sp = Math.sin(cam.phi), cp = Math.cos(cam.phi);
|
||
camera.position.set(
|
||
cam.target.x + r * sp * Math.sin(cam.theta),
|
||
cam.target.y + r * cp,
|
||
cam.target.z + r * sp * Math.cos(cam.theta));
|
||
camera.lookAt(cam.target);
|
||
}
|
||
let drag = false, lx = 0, ly = 0;
|
||
renderer.domElement.addEventListener('pointerdown', e => { drag = true; lx = e.clientX; ly = e.clientY; });
|
||
addEventListener('pointerup', () => drag = false);
|
||
addEventListener('pointermove', e => {
|
||
if (!drag) return;
|
||
cam.theta -= (e.clientX - lx) * 0.005; cam.phi = Math.max(0.15, Math.min(1.5, cam.phi - (e.clientY - ly) * 0.005));
|
||
lx = e.clientX; ly = e.clientY;
|
||
});
|
||
addEventListener('wheel', e => { cam.radius = Math.max(4, Math.min(240, cam.radius * (1 + Math.sign(e.deltaY) * 0.08))); }, { passive: true });
|
||
const keys = {};
|
||
addEventListener('keydown', e => keys[e.key.toLowerCase()] = true);
|
||
addEventListener('keyup', e => keys[e.key.toLowerCase()] = false);
|
||
function moveTarget(dt) {
|
||
const sp = 26 * dt, fx = Math.sin(cam.theta), fz = Math.cos(cam.theta);
|
||
if (keys['w']) { cam.target.x -= fx * sp; cam.target.z -= fz * sp; }
|
||
if (keys['s']) { cam.target.x += fx * sp; cam.target.z += fz * sp; }
|
||
if (keys['a']) { cam.target.x -= fz * sp; cam.target.z += fx * sp; }
|
||
if (keys['d']) { cam.target.x += fz * sp; cam.target.z -= fx * sp; }
|
||
if (keys['q']) cam.target.y = Math.max(0.2, cam.target.y - sp * 0.5);
|
||
if (keys['e']) cam.target.y += sp * 0.5;
|
||
}
|
||
|
||
// ---------- UI ----------
|
||
const $ = id => document.getElementById(id);
|
||
let population = 120, debugTiers = false;
|
||
sim.setPopulation(population);
|
||
$('pop').oninput = e => { population = +e.target.value; $('l-pop').textContent = population; sim.setPopulation(population); };
|
||
function fmtTod(t) { const h = Math.floor(t * 24), m = Math.floor((t * 24 % 1) * 60); return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; }
|
||
$('tod').oninput = e => { const t = +e.target.value / 1000; $('l-tod').textContent = fmtTod(t); sim.setTimeOfDay(t); };
|
||
sim.setTimeOfDay(0.5);
|
||
$('btn-tiers').onclick = () => {
|
||
debugTiers = !debugTiers; sim.setDebugTiers(debugTiers);
|
||
markers.visible = debugTiers; $('btn-tiers').classList.toggle('on', debugTiers);
|
||
};
|
||
function freshPed(idn) { if (fleet.ready) { const pk = pickRig(fleet, idn.pedRoll); if (pk) return pk.index; } return -1; }
|
||
$('btn-det').onclick = () => {
|
||
let ok, msg;
|
||
if (STREAM) {
|
||
// stream: every live chunk's residents are a pure function of (seed, chunkKey, i) — re-derive + compare
|
||
let checked = 0, bad = null;
|
||
for (const [key, arr] of sim.chunkRosters) {
|
||
const el = sim.chunkEdges.get(key) || [];
|
||
for (let i = 0; i < arr.length; i++) {
|
||
const idn = identityOf(SEED, el.length, `${key}#${i}`);
|
||
const expect = signatureOf(`${key}#${i}`, idn, freshPed(idn));
|
||
checked++;
|
||
if (expect !== sim._sig(arr[i])) { bad = `${key}#${i}`; break; }
|
||
}
|
||
if (bad) break;
|
||
}
|
||
ok = !bad;
|
||
msg = ok ? `✓ deterministic — ${checked} chunk-keyed identities match seed ${SEED} (${sim.chunkRosters.size} chunks)`
|
||
: `✗ MISMATCH at ${bad}`;
|
||
} else {
|
||
const active = sim.stats.active, live = sim.identitySignature(), fresh = [];
|
||
for (let i = 0; i < active; i++) { const idn = identityOf(SEED, edges.length, i); fresh.push(signatureOf(i, idn, freshPed(idn))); }
|
||
ok = live.length === fresh.length && live.every((s, i) => s === fresh[i]);
|
||
msg = ok ? `✓ deterministic — ${active} identities match seed ${SEED}` : `✗ MISMATCH at ${live.findIndex((s, i) => s !== fresh[i])}`;
|
||
}
|
||
$('det').style.color = ok ? '#8fd88f' : '#e06a5a';
|
||
$('det').textContent = msg;
|
||
};
|
||
if (NOASSETS) $('warn').textContent = '?noassets — placeholder figures only';
|
||
|
||
// ---------- loop ----------
|
||
let last = performance.now(), fpsSmooth = 60;
|
||
function frame(now) {
|
||
const dt = Math.min(0.05, (now - last) / 1000); last = now;
|
||
moveTarget(dt); applyCam();
|
||
|
||
renderer.info.reset();
|
||
const st = sim.update(dt);
|
||
keepers.update(dt);
|
||
if (debugTiers) paintTierMarkers();
|
||
renderer.render(scene, camera);
|
||
|
||
const fps = 1 / Math.max(1e-3, dt); fpsSmooth += (fps - fpsSmooth) * 0.1;
|
||
$('fps').textContent = fpsSmooth.toFixed(0);
|
||
$('fpsbar').style.width = Math.min(100, fpsSmooth / 60 * 100) + '%';
|
||
$('s-rig').textContent = st.rigged;
|
||
$('s-mid').textContent = st.mid;
|
||
$('s-far').textContent = st.far;
|
||
$('s-active').textContent = st.active;
|
||
$('s-mixer').textContent = st.mixerMs.toFixed(2);
|
||
$('s-draws').textContent = renderer.info.render.calls;
|
||
$('s-tris').textContent = renderer.info.render.triangles.toLocaleString();
|
||
$('s-pool').textContent = st.poolTotal;
|
||
$('s-mode').textContent = st.mode + (STREAM ? ` · stream ${st.chunks}ch` : '') + (NOASSETS ? ' (noassets)' : '');
|
||
requestAnimationFrame(frame);
|
||
}
|
||
requestAnimationFrame(frame);
|
||
|
||
addEventListener('resize', () => {
|
||
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
|
||
renderer.setSize(innerWidth, innerHeight);
|
||
});
|
||
// pause mixers only on a real visibility change (never fires in the preview pane, which is
|
||
// permanently "hidden" yet visible to us) — satisfies "all mixers pause when tab hidden".
|
||
document.addEventListener('visibilitychange', () => sim.setPaused(document.hidden));
|
||
window._sim = sim; // dev hook
|
||
// dev harness: drive N sim+render frames deterministically (the preview pane throttles rAF)
|
||
window._dbg = { renderer, scene, camera, cam, keepers, applyCam, paintTierMarkers,
|
||
step(n = 1, dt = 0.016) { for (let i = 0; i < n; i++) { applyCam(); const st = sim.update(dt); keepers.update(dt); if (debugTiers) paintTierMarkers(); renderer.render(scene, camera); } },
|
||
look(tx, ty, tz, radius, theta = cam.theta, phi = cam.phi) { cam.target.set(tx, ty, tz); cam.radius = radius; cam.theta = theta; cam.phi = phi; applyCam(); } };
|
||
</script>
|
||
</body>
|
||
</html>
|