- B1: per-shop closed-facade visuals — dark windows + red CLOSED plate + door tooltip, batched via a per-vertex aHours attr + two shared shaders + one uHour/uNight uniform pair on a procity:segment event (facade atlas untouched, no per-shop materials, no per-frame poll). 22:00 → only the openLate video shop lit, all others CLOSED; ~200 draws worst-view with citizens. - B2: the shots 'letterbox' was camera-jammed-into-a-wall poses, not a composer bug (composer proven correctly sized headless). Reframed street_noon/arcade/warehouse to look down a street (streetViewPose), added the 3 missing bookmarks, night_neon now frames the lit video shop amid CLOSED neighbours, + a defensive composer resize. shots.py: 10 un-letterboxed shots, 0 errors. - B3: furniture GLB use-if-ready upgrade (bench + food_cart, sit on their footprints) + novelty_record placed at record stores. novelty_record GLB is 26.5k tris (5x glb_law) → stays low-poly primitive until Lane E decimates it; ?noassets verified 0 network. qa.sh --strict GREEN (4/4). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
128 lines
5.7 KiB
JavaScript
128 lines
5.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';
|
|
|
|
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}
|
|
`;
|
|
|
|
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-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;
|
|
|
|
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 };
|
|
}
|