PROCITY/web/js/world/hud.js
m3ultra 4b0144989b Lane B round 21 (v4.0-beta): town selector reads E's towns index (ledger #2, the missed R20 #5)
Closes the gap Fable caught (hud.js:11 still hardcoded REAL_TOWNS). The real-town list now comes from E's
web/assets/towns/index.json (procity-towns-index/1), so it grows with the town pack instead of being edited
in hud.js. Verified fresh Chromium. Nothing else touched -- tram/ground work is done and verified.

Fail-soft by construction: createHUD is synchronous, so the picker is built immediately from the fallback
literal and UPGRADED IN PLACE when the index resolves -- if the index never arrives, a working list is
already on screen.
- index present -> real names/states/shop counts: "Katoomba, NSW · 80 shops", "Fremantle, WA · 80 shops",
  "Newtown, NSW · 72 shops", "Bendigo, VIC · 36 shops", "Castlemaine, VIC · 24 shops" (roads:false -> "· marched").
- ?noassets -> the fetch is SKIPPED entirely (asset law; the shell doesn't load town caches there either).
  Measured: 0 index fetches, fallback list, 0 errors.
- anything wrong with the index -> the fallback simply stands. Exercised the exact chain: 404, network error,
  malformed JSON, towns:[], towns-not-an-array, junk entries (no key) -> none throws, none rebuilds from junk;
  only a good index rebuilds.

REAL_TOWNS_FALLBACK is retained ONLY as that fallback (comment says so), no longer the source of truth.
Verified live: booting katoomba_real shows "Katoomba, NSW · 80 shops" pre-selected; fits (193px of 230px max);
0 page errors; navigation unchanged (onchange is set once on the <select>, so rebuilding options never clears it).

-> F: unchanged from R19 -- NO shell seam (selector is entirely hud.js, already built by createHUD) and it
   lives inside #pc-hud so setVisible(false) hides it for the beta money shot. The gating call is still yours.

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

183 lines
9.7 KiB
JavaScript

// PROCITY Lane B — hud.js
// The street HUD: crosshair, shop-name tooltip (throttled centre-screen raycast, far = 6m),
// time-of-day + seed + live perf readout (renderer.info: draw calls & triangles), and the
// door-click → `procity:enterShop` hand-off. Creates its own DOM overlay so index.html stays thin.
import * as THREE from 'three';
import { osmTownKeys } from '../citygen/index.js'; // R19 town selector: enumerate OSM fixtures
// R21 (v4.0-beta, ledger #2): the real-roads towns come from E's index (web/assets/towns/index.json), so the
// list grows with the town pack instead of being edited here. This literal is now only the FAIL-SOFT fallback
// — used when the index is missing/unparseable, or under ?noassets (where the shell doesn't fetch town caches
// either, so reading the index would be both pointless and against the asset law). Zero errors either way.
const REAL_TOWNS_FALLBACK = ['katoomba_real', 'newtown_real', 'fremantle_real', 'bendigo_real', 'castlemaine_real'];
const TOWNS_INDEX = 'assets/towns/index.json';
const CSS = `
#pc-hud{position:fixed;inset:0;pointer-events:none;font:13px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;color:#f4efe6;z-index:10}
#pc-cross{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px 0 0 -3px;border-radius:50%;background:#fff;opacity:.7;box-shadow:0 0 3px #000}
#pc-tip{position:absolute;left:50%;top:calc(50% + 22px);transform:translateX(-50%);background:rgba(20,16,10,.82);padding:6px 12px;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity .12s;text-shadow:0 1px 2px #000}
#pc-dbg{position:absolute;right:10px;top:10px;text-align:right;background:rgba(20,16,10,.55);padding:8px 11px;border-radius:8px;min-width:150px}
#pc-dbg b{color:#ffd75e;font-weight:600}
#pc-dbg .warn{color:#ff9a6a}
#pc-clock{font-size:15px;margin-bottom:3px}
#pc-toast{position:absolute;left:50%;bottom:64px;transform:translateX(-50%);background:rgba(20,16,10,.9);padding:10px 18px;border-radius:10px;font-size:15px;opacity:0;transition:opacity .25s;text-shadow:0 1px 2px #000}
#pc-help{position:absolute;left:12px;bottom:12px;background:rgba(20,16,10,.5);padding:7px 11px;border-radius:8px;opacity:.85}
#pc-townpick{position:absolute;left:10px;top:10px;pointer-events:auto;opacity:.8;transition:opacity .12s}
#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)}
`;
export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }) {
if (!document.getElementById('pc-hud-style')) {
const st = document.createElement('style'); st.id = 'pc-hud-style'; st.textContent = CSS;
document.head.appendChild(st);
}
const root = document.createElement('div'); root.id = 'pc-hud';
root.innerHTML = `
<div id="pc-cross"></div>
<div id="pc-tip"></div>
<div id="pc-dbg">
<div id="pc-clock">—</div>
<div>town <b id="pc-town">—</b></div>
<div>seed <b id="pc-seed">—</b></div>
<div>fps <b id="pc-fps">—</b></div>
<div>draws <b id="pc-draws">—</b> · tris <b id="pc-tris">—</b></div>
<div>chunks <b id="pc-chunks">—</b></div>
</div>
<div id="pc-toast"></div>
<div id="pc-townpick"><select id="pc-town-select" title="Load a different town (Esc to release the pointer first)"></select></div>
<div id="pc-help">WASD move · shift run · mouse look · click a door · [ ] time · P shot · Esc release</div>`;
document.body.appendChild(root);
const $ = (id) => root.querySelector('#' + id);
const elTip = $('pc-tip'), elClock = $('pc-clock'), elFps = $('pc-fps'), elDraws = $('pc-draws'),
elTris = $('pc-tris'), elChunks = $('pc-chunks'), elToast = $('pc-toast');
$('pc-town').textContent = plan.name || '—';
$('pc-seed').textContent = plan.citySeed;
// ── town selector (R19; R21 reads E's towns index) — synthetic + OSM fixtures + real-road towns.
// Navigates to the matching URL params, preserving everything else (seed, flags). Lives in the HUD
// overlay, so it hides with the HUD for money shots; usable when the pointer is free (Esc). No
// index.html seam. The real-town list is built SYNCHRONOUSLY from the fallback (so the picker always
// works, including under ?noassets and if the index 404s), then upgraded in place once E's index.json
// resolves — which also gives real names, states and shop counts. ──
(() => {
const sel = $('pc-town-select'); if (!sel) return;
const params = new URLSearchParams(location.search);
const cur = params.get('plansrc') === 'osm' ? (params.get('town') || '') : '';
const NOASSETS = params.get('noassets') != null && params.get('noassets') !== '0';
const cap = (k) => k.replace(/_real$/, '').replace(/(^|[\s_])\w/g, (c) => c.toUpperCase()).replace(/_/g, ' ');
const opt = (key, label) => { const o = document.createElement('option'); o.value = key; o.textContent = label; if (key === cur) o.selected = true; return o; };
const build = (realTowns) => {
sel.textContent = '';
sel.appendChild(opt('', '▸ Synthetic — procedural'));
for (const k of osmTownKeys().filter((k) => !k.endsWith('_real'))) sel.appendChild(opt(k, `${cap(k)} · OSM fixture`));
for (const t of realTowns) sel.appendChild(opt(t.key, t.label));
if (cur && !Array.from(sel.options).some((o) => o.value === cur)) sel.appendChild(opt(cur, `${cap(cur)} · (current)`)); // unknown current town → still shown
};
build(REAL_TOWNS_FALLBACK.map((k) => ({ key: k, label: `${cap(k)} · real roads` })));
sel.onchange = () => { // set once; rebuilding the options below never clears it
const v = sel.value, p = new URLSearchParams(location.search);
if (!v) { p.delete('plansrc'); p.delete('town'); } else { p.set('plansrc', 'osm'); p.set('town', v); }
location.search = p.toString(); // reload into the chosen town
};
if (NOASSETS) return; // asset law: no fetch — the fallback list stands
fetch(TOWNS_INDEX, { cache: 'force-cache' })
.then((r) => (r.ok ? r.json() : null))
.then((j) => {
const towns = j && Array.isArray(j.towns) ? j.towns.filter((t) => t && t.key) : null;
if (!towns || !towns.length) return; // keep the fallback, silently
build(towns.map((t) => ({
key: t.key,
label: `${t.town || cap(t.key)}${t.state ? ', ' + t.state : ''}`
+ (t.shops != null ? ` · ${t.shops} shops` : '')
+ (t.roads === false ? ' · marched' : ''),
})));
})
.catch(() => {}); // index missing/blocked → fallback stands, zero errors
})();
const ray = new THREE.Raycaster();
ray.far = 6;
const centre = new THREE.Vector2(0, 0);
let hovered = null; // { shopId, name }
let frame = 0, fpsAcc = 0, fpsCount = 0, fpsShown = 0;
function nearestDoor(rects, point) {
let best = null, bd = Infinity;
for (const r of rects) {
const d = (r.x - point.x) ** 2 + (r.z - point.z) ** 2;
if (d < bd) { bd = d; best = r; }
}
return best;
}
function raycastDoor() {
const meshes = getDoorMeshes ? getDoorMeshes() : [];
if (!meshes.length) { hovered = null; return; }
ray.setFromCamera(centre, camera);
const hits = ray.intersectObjects(meshes, false);
if (hits.length) {
const h = hits[0];
const rects = h.object.userData.doorRects || [];
const door = nearestDoor(rects, h.point);
hovered = door ? { shopId: door.shopId, name: door.name, hours: door.hours } : null;
} else {
hovered = null;
}
}
function update(dt, info) {
// fps (smoothed over ~0.4s)
fpsAcc += dt; fpsCount++;
if (fpsAcc >= 0.4) { fpsShown = Math.round(fpsCount / fpsAcc); fpsAcc = 0; fpsCount = 0; }
frame++;
if (frame % 6 === 0) raycastDoor();
// tooltip — shows CLOSED (with opening hour) when the shop is shut at the current hour (§3.5)
if (hovered) {
const h = hovered.hours;
const now = window.PROCITY && window.PROCITY.currentHour ? window.PROCITY.currentHour() : null;
const closed = h && now != null && !(now >= h[0] && now < h[1]);
elTip.textContent = closed
? `🔒 ${hovered.name} — CLOSED · opens ${String(h[0]).padStart(2, '0')}:00`
: `🚪 ${hovered.name} — click to enter`;
elTip.style.opacity = 1;
} else elTip.style.opacity = 0;
// debug readout
if (info) {
if (info.clock) elClock.textContent = `${info.clock.label} ${info.clock.hour}${info.clock.night ? ' 🌙' : ''}`;
elFps.textContent = fpsShown;
const draws = renderer.info.render.calls, tris = renderer.info.render.triangles;
elDraws.innerHTML = draws > 300 ? `<span class="warn">${draws}</span>` : draws;
elTris.innerHTML = tris > 200000 ? `<span class="warn">${(tris / 1000 | 0)}k</span>` : `${(tris / 1000 | 0)}k`;
if (info.chunks != null) elChunks.textContent = info.chunks;
}
}
let toastT = 0;
function showToast(msg, secs = 2.6) {
elToast.textContent = msg; elToast.style.opacity = 1; toastT = secs;
}
function tickToast(dt) { if (toastT > 0) { toastT -= dt; if (toastT <= 0) elToast.style.opacity = 0; } }
// door click → enter shop (only when hovering one)
function onClick() {
if (hovered && onEnterShop) onEnterShop(hovered.shopId, hovered.name);
}
renderer.domElement.addEventListener('click', onClick);
function setVisible(v) { root.style.display = v ? 'block' : 'none'; }
function dispose() {
renderer.domElement.removeEventListener('click', onClick);
root.remove();
}
return { update, tickToast, showToast, setVisible, dispose, getHovered: () => hovered,
getFps: () => fpsShown };
}