327 lines
19 KiB
JavaScript
327 lines
19 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)}
|
|
#pc-game{position:absolute;right:10px;bottom:12px;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}
|
|
#pc-gamebar button:hover{border-color:rgba(255,215,94,.7);color:#ffd75e}
|
|
#pc-crate{width:344px;max-height:min(58vh,470px);overflow-y:auto;background:rgba(20,16,10,.93);border:1px solid rgba(255,215,94,.35);border-radius:12px;padding:10px 12px;pointer-events:auto;display:none}
|
|
#pc-crate h3{font-size:13px;color:#ffd75e;margin:0 0 6px;font-weight:600;letter-spacing:.04em}
|
|
.pc-crate-row{display:flex;gap:9px;align-items:center;padding:5px 0;border-top:1px solid rgba(255,244,230,.08)}
|
|
.pc-thumb{width:44px;height:44px;flex:none;border-radius:4px;background-color:#3a3128;background-repeat:no-repeat;display:flex;align-items:center;justify-content:center;color:rgba(255,244,230,.55);font-size:17px;box-shadow:0 1px 3px rgba(0,0,0,.5)}
|
|
.pc-crate-main{min-width:0}
|
|
.pc-crate-title{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.pc-crate-artist{opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.pc-crate-meta{font-size:11px;opacity:.62}
|
|
.pc-crate-empty{opacity:.7;padding:4px 0 2px}
|
|
`;
|
|
|
|
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
|
|
})();
|
|
|
|
// ── v7 THE GAME (R30, ledger #4): the collection panel + the SLEEP surface + day/cash readout.
|
|
// Built LAZILY on the first update() frame that sees a truthy `window.PROCITY.game` — the shell
|
|
// constructs the game layer AFTER the HUD, and under ?classic=1 / ?game=0 it is null forever, so
|
|
// NONE of this DOM (bar, panel, KeyC listener, help-line suffix) ever exists on those boots: the
|
|
// R19 selector's construction-gating, applied to the game (the classic-pure law, ledger #5's gate
|
|
// measures it). Consumes ONLY the LANE_F_NOTES §30.1 contract: day / cash / collection (read-only
|
|
// — this UI never mutates it) / sleep(). Cover thumbs resolve the entry's sku against its pack
|
|
// index (per-shop godverse base when godverseShopId is set, else the town-wide base) — fail-soft:
|
|
// missing pack / unknown sku / remote atlas / ?noassets ⇒ the type-glyph placeholder stands,
|
|
// zero errors (the R21 towns-index discipline). Parody finds (slotId, no sku) have no thumb by
|
|
// design (§30.4). ──
|
|
let gameUI = null; // built once; null forever when the game layer is absent
|
|
let onGameKey = null; // KeyC crate toggle — registered only when the game UI exists
|
|
const shopById = new Map((plan.shops || []).map((s) => [s.id, s]));
|
|
const GAME_NOASSETS = (() => { try { const v = new URLSearchParams(location.search).get('noassets'); return v != null && v !== '0'; } catch { return false; } })();
|
|
const TYPE_GLYPH = { record: '♪', book: '¶', toy: '✦' };
|
|
|
|
const _packIdx = new Map(); // `${base}|${type}` → Promise<index.json|null> — B's own read-only cache
|
|
function packIndexFor(entry) {
|
|
if (GAME_NOASSETS || !entry || !entry.sku || !entry.type) return Promise.resolve(null);
|
|
const base = entry.godverseShopId != null ? `assets/stock_godverse/${entry.godverseShopId}/` : 'assets/models/';
|
|
const key = base + '|' + entry.type;
|
|
if (!_packIdx.has(key))
|
|
_packIdx.set(key, fetch(`${base}stock_${entry.type}_index.json`).then((r) => (r.ok ? r.json() : null)).catch(() => null));
|
|
return _packIdx.get(key).then((idx) => (idx && Array.isArray(idx.items) ? { idx, base } : null));
|
|
}
|
|
function applyThumb(el, entry) {
|
|
packIndexFor(entry).then((p) => {
|
|
if (!p || !el.isConnected) return;
|
|
const it = p.idx.items.find((i) => i && String(i.id) === entry.sku);
|
|
if (!it || !it.atlas || !Array.isArray(it.uv) || /^depot:|^https?:/.test(it.atlas)) return;
|
|
const [u0, v0, u1, v1] = it.uv, du = u1 - u0, dv = v1 - v0;
|
|
if (!(du > 0) || !(dv > 0)) return;
|
|
el.textContent = ''; // atlas crop via CSS background — uv origin is top-left (image-natural), same as CSS
|
|
el.style.backgroundImage = `url("${p.base + it.atlas}")`;
|
|
el.style.backgroundSize = `${100 / du}% ${100 / dv}%`;
|
|
el.style.backgroundPosition = `${du < 1 ? (u0 / (1 - du)) * 100 : 0}% ${dv < 1 ? (v0 / (1 - dv)) * 100 : 0}%`;
|
|
});
|
|
}
|
|
// where the find happened: this boot's town → the shop's name off the plan; another town's find →
|
|
// the town part of its townKey (`plansrc/town@seed`) — honest, never guessed.
|
|
function foundAt(entry, game) {
|
|
if (entry.townKey === game.townKey) { const s = shopById.get(entry.shopId); if (s && s.name) return s.name; }
|
|
const m = /^[^/]+\/([^@]+)@/.exec(entry.townKey || '');
|
|
return m ? m[1].replace(/_/g, ' ') : '?';
|
|
}
|
|
|
|
function buildGameUI(game) {
|
|
const wrap = document.createElement('div'); wrap.id = 'pc-game';
|
|
const panel = document.createElement('div'); panel.id = 'pc-crate';
|
|
const bar = document.createElement('div'); bar.id = 'pc-gamebar';
|
|
bar.innerHTML = `<span>day <b id="pc-day">—</b> · $<b id="pc-cash">—</b></span>
|
|
<button id="pc-crate-btn" title="The collection (C toggles; Esc frees the pointer first)">CRATE 0</button>
|
|
<button id="pc-sleep-btn" title="Sleep — save, wake tomorrow">☾ SLEEP</button>`;
|
|
wrap.append(panel, bar); root.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;
|
|
|
|
function rebuildList() {
|
|
const col = game.collection || [];
|
|
panel.textContent = '';
|
|
const h = document.createElement('h3');
|
|
const spent = col.reduce((a, e) => a + (e.pricePaid || 0), 0);
|
|
h.textContent = `THE CRATE — ${col.length} find${col.length === 1 ? '' : 's'} · $${spent} spent`;
|
|
panel.appendChild(h);
|
|
if (!col.length) {
|
|
const d = document.createElement('div'); d.className = 'pc-crate-empty';
|
|
d.textContent = 'Empty — go dig.'; panel.appendChild(d); return;
|
|
}
|
|
for (let i = col.length - 1; i >= 0; i--) { // newest find first
|
|
const e = col[i];
|
|
const row = document.createElement('div'); row.className = 'pc-crate-row';
|
|
const th = document.createElement('div'); th.className = 'pc-thumb';
|
|
th.textContent = TYPE_GLYPH[e.type] || '♦';
|
|
applyThumb(th, e);
|
|
const main = document.createElement('div'); main.className = 'pc-crate-main';
|
|
const t = document.createElement('div'); t.className = 'pc-crate-title';
|
|
t.textContent = e.title || e.sku || e.slotId || 'unknown'; // C's fallback rule (§30.1)
|
|
const a = document.createElement('div'); a.className = 'pc-crate-artist';
|
|
a.textContent = e.artist || '';
|
|
const meta = document.createElement('div'); meta.className = 'pc-crate-meta';
|
|
meta.textContent = `paid $${e.pricePaid} · ${foundAt(e, game)} · day ${e.dayFound}`;
|
|
main.append(t, a, meta);
|
|
row.append(th, main); panel.appendChild(row);
|
|
}
|
|
}
|
|
function setOpen(v) {
|
|
open = v; panel.style.display = v ? 'block' : 'none';
|
|
if (v) { shownCount = (game.collection || []).length; rebuildList(); }
|
|
}
|
|
btnCrate.onclick = () => setOpen(!open);
|
|
// SLEEP — enabled any time (alpha; home-base gating is beta). The shell's onDay hook owns the
|
|
// wake toast; the HUD just refreshes its readouts off the contract getters.
|
|
btnSleep.onclick = () => {
|
|
try { game.sleep(); } catch (err) { console.warn('[hud] sleep failed:', err); }
|
|
refresh();
|
|
};
|
|
function refresh() {
|
|
const col = game.collection || [];
|
|
if (game.day !== shownDay) { shownDay = game.day; elDay.textContent = shownDay; }
|
|
if (game.cash !== shownCash) { shownCash = game.cash; elCash.textContent = shownCash; }
|
|
if (col.length !== shownCount) {
|
|
shownCount = col.length; btnCrate.textContent = `CRATE ${shownCount}`;
|
|
if (open) rebuildList();
|
|
}
|
|
}
|
|
refresh();
|
|
onGameKey = (e) => { // C toggles the crate on the street (not inside a shop — E/dig territory)
|
|
if (e.code !== 'KeyC') return;
|
|
const P = window.PROCITY;
|
|
if (P && P.mode === 'interior') return;
|
|
setOpen(!open);
|
|
};
|
|
addEventListener('keydown', onGameKey);
|
|
const help = root.querySelector('#pc-help');
|
|
if (help) help.textContent += ' · C crate';
|
|
return { refresh };
|
|
}
|
|
|
|
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();
|
|
|
|
// v7 game surface — lazy construction (the shell builds the game after the HUD; null under
|
|
// ?classic=1 / ?game=0 ⇒ this never fires), then cheap getter polls offset from the raycast frames.
|
|
if (!gameUI) {
|
|
if (frame % 30 === 0) {
|
|
const g = window.PROCITY && window.PROCITY.game;
|
|
if (g) gameUI = buildGameUI(g);
|
|
}
|
|
} else if (frame % 6 === 3) gameUI.refresh();
|
|
|
|
// 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);
|
|
if (onGameKey) removeEventListener('keydown', onGameKey);
|
|
root.remove();
|
|
}
|
|
|
|
return { update, tickToast, showToast, setVisible, dispose, getHovered: () => hovered,
|
|
getFps: () => fpsShown };
|
|
}
|