// 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 = `
town
seed
fps
draws · tris
chunks
WASD move · shift run · mouse look · click a door · [ ] time · P shot · Esc release
`; 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 } : 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 if (hovered) { elTip.textContent = `🚪 ${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 ? `${draws}` : draws; elTris.innerHTML = tris > 200000 ? `${(tris / 1000 | 0)}k` : `${(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 }; }