diff --git a/web/index.html b/web/index.html
index 1894d4a..82504fe 100644
--- a/web/index.html
+++ b/web/index.html
@@ -168,7 +168,8 @@ const skins = createSkinLibrary();
const ground = buildGround(plan, skins);
scene.add(ground.group);
-const lighting = createLighting(scene, plan, skins, { radius: RADIUS, shadows: SHADOWS });
+const lighting = createLighting(scene, plan, skins, { radius: RADIUS, shadows: SHADOWS,
+ dawnSky: CLASSIC ? null : 'high-cirrus' }); // [R32 §31.6-3] dawn reads dawn; classic keeps the frozen dome
lighting.attachRenderer(renderer);
const ctx = { skins, citySeed: plan.citySeed, plan, night: lighting.isNight(), radius: RADIUS };
@@ -354,8 +355,37 @@ const venuePresentation = gigState ? createVenuePresentation(plan, skins, scene)
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);
+// [R32 §31.6-1 — THE CLUSTER-POSE SPAWN] The frozen pose (-96, 9, looking east) was tuned for the
+// synthetic fixture; on real OSM towns it faced dirt and building-backs (the R31 playtest's #1).
+// Non-classic boots now spawn at one END of the shop cluster's dominant axis, ON the footpath door-line
+// (a point every shop already computes for patronage — known walkable), looking down the strip toward
+// the cluster's heart. Deterministic (pure plan math, no draws — no stream moves), and ?classic=1
+// keeps the frozen pose forever (the covenant is a POSE here, not just a plan).
+let SPAWN = { x: -96, z: 9, yaw: -Math.PI / 2 };
+if (!CLASSIC && plan.shops && plan.shops.length >= 2) {
+ // main-street shop doors only (block kind — both generators stamp it); every-door fallback
+ const msBlocks = new Set((plan.blocks || []).filter((b) => b.kind === 'mainstreet').map((b) => b.id));
+ const doorOf = (s) => {
+ const l = plan.lots.find((x) => x.id === s.lot); if (!l) return null;
+ const ry = l.ry || 0;
+ return { x: l.x - Math.sin(ry) * (l.d / 2 + 2.6), z: l.z - Math.cos(ry) * (l.d / 2 + 2.6), block: l.block };
+ };
+ let doors = plan.shops.map(doorOf).filter(Boolean);
+ const ms = doors.filter((d) => msBlocks.has(d.block));
+ if (ms.length >= 2) doors = ms;
+ if (doors.length >= 2) {
+ let cx = 0, cz = 0; for (const d of doors) { cx += d.x; cz += d.z; } cx /= doors.length; cz /= doors.length;
+ let vx = 0, vz = 0; for (const d of doors) { vx += (d.x - cx) ** 2; vz += (d.z - cz) ** 2; }
+ const ax = vx >= vz ? 'x' : 'z'; // the strip's dominant axis
+ let stand = doors[0], bd = Infinity; // stand at the door nearest the cluster's heart
+ for (const d of doors) { const dd = (d.x - cx) ** 2 + (d.z - cz) ** 2; if (dd < bd) { bd = dd; stand = d; } }
+ let lo = Infinity, hi = -Infinity;
+ for (const d of doors) { lo = Math.min(lo, d[ax]); hi = Math.max(hi, d[ax]); }
+ const dir = (hi - stand[ax] >= stand[ax] - lo) ? 1 : -1; // face the longer run of shopfronts
+ SPAWN = { x: stand.x, z: stand.z, yaw: ax === 'x' ? Math.atan2(-dir, 0) : Math.atan2(0, -dir) };
+ }
+}
+player.teleport(SPAWN.x, SPAWN.z, SPAWN.yaw);
chunks.warmup(player.position);
lighting.update(0.0001, player.position);
@@ -508,8 +538,10 @@ function frame() {
renderer.info.reset(); // zero the counters, then let the composer's passes accumulate this frame
composer.render();
} else if (MODE === 'interior') {
+ hud.tickGame(); // [R32 §31.6-2] the crate/day/cash surface lives in shops too (yields to the cards)
if (interiorMode.update(dt, player.isLocked ? keys : EMPTY)) leaveShop(); // [Lane F] walk to the door → back to street
} else if (MODE === 'map') {
+ hud.tickGame(); // [R32] map mode: the same check hides the surface (the map owns the screen)
camera.getWorldDirection(_fwd);
minimap.draw(player.position, { x: _fwd.x, z: _fwd.z });
}
diff --git a/web/js/world/hud.js b/web/js/world/hud.js
index 2f15452..155c458 100644
--- a/web/js/world/hud.js
+++ b/web/js/world/hud.js
@@ -27,7 +27,7 @@ const CSS = `
#pc-townpick:hover{opacity:1}
#pc-townpick select{background:rgba(20,16,10,.7);color:#f4efe6;border:1px solid rgba(255,215,94,.35);border-radius:8px;padding:5px 9px;font:12px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;max-width:230px;cursor:pointer}
#pc-townpick select:focus{outline:1px solid rgba(255,215,94,.6)}
-#pc-game{position:absolute;right:10px;bottom:12px;display:flex;flex-direction:column;align-items:flex-end;gap:8px}
+#pc-game{position:fixed;right:10px;bottom:12px;z-index:30;display:flex;flex-direction:column;align-items:flex-end;gap:8px}
#pc-gamebar{background:rgba(20,16,10,.72);padding:6px 10px;border-radius:10px;display:flex;gap:10px;align-items:center;pointer-events:auto;text-shadow:0 1px 2px #000}
#pc-gamebar b{color:#ffd75e;font-weight:600}
#pc-gamebar button{background:rgba(20,16,10,.7);color:#f4efe6;border:1px solid rgba(255,215,94,.35);border-radius:8px;padding:4px 10px;font:12px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;cursor:pointer}
@@ -166,7 +166,11 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
bar.innerHTML = `day — · $—
`;
- wrap.append(panel, bar); root.appendChild(wrap);
+ // [R32 §31.6-2 — the crate at the counter] The game surface rides document.body, NOT the street-HUD
+ // root: the street root hides in interior mode (by design), but your day/cash/crate are YOURS in a
+ // shop too — the playtest's "why can't I check the crate at the counter". It yields the screen to
+ // the dig/sell cards and the map (visibility below); ?classic=1 / ?game=0 never build any of this.
+ wrap.append(panel, bar); document.body.appendChild(wrap);
const elDay = bar.querySelector('#pc-day'), elCash = bar.querySelector('#pc-cash'),
btnCrate = bar.querySelector('#pc-crate-btn'), btnSleep = bar.querySelector('#pc-sleep-btn');
let shownDay = null, shownCash = null, shownCount = null, open = false;
@@ -218,18 +222,25 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
shownCount = col.length; btnCrate.textContent = `CRATE ${shownCount}`;
if (open) rebuildList();
}
+ // [R32] the surface yields to the map and to the dig/sell cards (they own the screen), and shows
+ // everywhere else — street AND interior. Cheap: a style write only when the answer changes.
+ const P = window.PROCITY, im = P && P.interiorMode;
+ const yield_ = (P && P.mode === 'map') || !!(im && (im.digActive || im.sellActive));
+ const want = yield_ ? 'none' : 'flex';
+ if (wrap.style.display !== want) wrap.style.display = want;
}
refresh();
- onGameKey = (e) => { // C toggles the crate on the street (not inside a shop — E/dig territory)
+ onGameKey = (e) => { // [R32 §31.6-2] C toggles the crate on the street AND in a shop (not over a card)
if (e.code !== 'KeyC') return;
- const P = window.PROCITY;
- if (P && P.mode === 'interior') return;
+ const P = window.PROCITY, im = P && P.interiorMode;
+ if (P && P.mode === 'map') return;
+ if (im && (im.digActive || im.sellActive)) return; // the cards own the keys while open
setOpen(!open);
};
addEventListener('keydown', onGameKey);
const help = root.querySelector('#pc-help');
if (help) help.textContent += ' · C crate';
- return { refresh };
+ return { refresh, wrap }; // wrap rides document.body → dispose() must remove it itself
}
const ray = new THREE.Raycaster();
@@ -318,9 +329,14 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
function dispose() {
renderer.domElement.removeEventListener('click', onClick);
if (onGameKey) removeEventListener('keydown', onGameKey);
+ if (gameUI && gameUI.wrap) gameUI.wrap.remove(); // [R32] the game surface lives on document.body now
root.remove();
}
- return { update, tickToast, showToast, setVisible, dispose, getHovered: () => hovered,
+ // [R32] the game surface ticks in EVERY mode (the street path reaches it via update(); interior/map
+ // call this directly) — day/cash/crate stay live while you shop, and the card-yield check runs there.
+ function tickGame() { if (gameUI) gameUI.refresh(); }
+
+ return { update, tickToast, showToast, setVisible, tickGame, dispose, getHovered: () => hovered,
getFps: () => fpsShown };
}
diff --git a/web/js/world/lighting.js b/web/js/world/lighting.js
index 0cdb5c2..42bf670 100644
--- a/web/js/world/lighting.js
+++ b/web/js/world/lighting.js
@@ -38,17 +38,29 @@ function gradientEquirect(THREE) {
return tex;
}
-export function createLighting(scene, plan, skins, { radius = 3, shadows = true } = {}) {
+export function createLighting(scene, plan, skins, { radius = 3, shadows = true, dawnSky = null } = {}) {
const group = new THREE.Group();
group.name = 'lighting';
scene.add(group);
// sky dome — seeded photo skin, darkened at night via material colour
+ // [R32 §31.6-3] `dawnSky` (option, shell passes null under ?classic=1): the default dome is a
+ // golden-SUNSET photo, so DAWN read as dusk-orange (the R31 playtest's cosmetic #3). During the DAWN
+ // segment only, the dome swaps to a cool morning skin, then back. A weather setSky() override
+ // (rain/overcast) OWNS the dome outright — the dawn swap never fights it.
const skyName = plan.sky || 'golden-arvo';
const skyMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.BackSide, depthWrite: false, fog: false });
+ let externalSky = null; // set by setSky() (weather) — wins over base + dawn forever after
+ let shownSky = skyName;
skins.skyTex(skyName, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
const dome = new THREE.Mesh(new THREE.SphereGeometry(650, 24, 14), skyMat);
group.add(dome);
+ function applySkyFor(segIdx) {
+ const want = externalSky || (segIdx === 0 && dawnSky ? dawnSky : skyName);
+ if (want === shownSky) return;
+ shownSky = want;
+ skins.skyTex(want, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
+ }
const hemi = new THREE.HemisphereLight(0xd8e8ff, 0xa89878, 1.0);
group.add(hemi);
@@ -76,6 +88,7 @@ export function createLighting(scene, plan, skins, { radius = 3, shadows = true
function apply(i) {
const [, sunI, sunC, hSky, hGnd, hI, skyTint, fogC, exposure, isNight] = SEG[i];
+ applySkyFor(i); // [R32] dawn dome swap (no-op under classic / weather override / other segments)
sun.intensity = sunI; sun.color.set(sunC);
hemi.intensity = hI; hemi.color.set(hSky); hemi.groundColor.set(hGnd);
_skyTint.set(skyTint); skyMat.color.copy(_skyTint);
@@ -95,7 +108,11 @@ export function createLighting(scene, plan, skins, { radius = 3, shadows = true
// Swap the sky-dome skin at runtime (weather.js uses this for a matching rainy/overcast sky).
// The per-segment day/night colour tint (skyMat.color in apply) still rides on top of the new map.
- function setSky(name) { skins.skyTex(name, (t) => { skyMat.map = t; skyMat.needsUpdate = true; }); }
+ // [R32] a caller-set sky is an OVERRIDE: it wins over the base skin and the dawn swap from then on.
+ function setSky(name) {
+ externalSky = name; shownSky = name;
+ skins.skyTex(name, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
+ }
const _sunOff = new THREE.Vector3();
function update(dt, playerPos) {