231 lines
9.9 KiB
HTML
231 lines
9.9 KiB
HTML
<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Virtual Viewport — Newbury Exhibit</title></head>
|
|
<body>
|
|
<div id="app"></div>
|
|
<script type="importmap">
|
|
{ "imports": {
|
|
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
|
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
|
} }
|
|
</script>
|
|
<script type="module">
|
|
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
import { anchorWorldMatrix } from '/js/ar/pose.js';
|
|
import { buildGhost } from '/js/ghosts/loader.js';
|
|
import { ghostTransform } from '/js/ghosts/behavior.js';
|
|
import { ExhibitNet } from '/js/net.js';
|
|
import { requireAuth, styles, nav } from './admin.js';
|
|
|
|
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
|
#wrap { display:grid; grid-template-columns: 1fr 260px; height: calc(100vh - 44px); }
|
|
#view { position:relative; }
|
|
#gl { width:100%; height:100%; display:block; touch-action:none; }
|
|
#side { background:#111730; padding:12px; overflow:auto; }
|
|
#side h2 { font-size:.9rem; margin:0 0 8px; color:#8fb8ff; }
|
|
.g { background:#0d1226; border-radius:8px; padding:8px 10px; margin-bottom:8px; font-size:.8rem; cursor:pointer; }
|
|
.g:hover { background:#1a2244; }
|
|
.g .nm { font-weight:600; }
|
|
.g .meta { opacity:.65; margin-top:2px; }
|
|
.dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:6px; }
|
|
#legend { position:absolute; left:10px; bottom:10px; font-size:.72rem; background:rgba(6,8,15,.6);
|
|
padding:6px 10px; border-radius:8px; pointer-events:none; line-height:1.5; }
|
|
</style>`);
|
|
await requireAuth();
|
|
|
|
const COLORS = { Red: '#ff2678', Yellow: '#fff35d', Blue: '#51eaf1' };
|
|
const app = document.getElementById('app');
|
|
app.innerHTML = `${nav('preview')}
|
|
<div id="wrap">
|
|
<div id="view">
|
|
<canvas id="gl"></canvas>
|
|
<div id="legend"><b>Virtual viewport</b> — live playlist, same deterministic motion the phones compute.<br>
|
|
Drag to orbit · scroll to zoom · click a ghost in the list to follow it</div>
|
|
</div>
|
|
<div id="side"><h2>Active ghosts</h2><div id="list"><em style="opacity:.6">Waiting for server…</em></div></div>
|
|
</div>`;
|
|
|
|
// ---------- data ----------
|
|
const g = await (await fetch('/api/ghosts')).json();
|
|
const gradients = g.gradients.gradients || g.gradients;
|
|
const modelManifest = await (await fetch('/api/models')).json();
|
|
|
|
// ---------- three.js ----------
|
|
const cvs = document.getElementById('gl');
|
|
const renderer = new THREE.WebGLRenderer({ canvas: cvs, antialias: true });
|
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
const scn = new THREE.Scene();
|
|
scn.background = new THREE.Color(0x0a0e1c);
|
|
scn.fog = new THREE.Fog(0x0a0e1c, 400, 1200);
|
|
const cam = new THREE.PerspectiveCamera(55, 1, 1, 5000);
|
|
cam.position.set(160, 110, -80);
|
|
scn.add(new THREE.AmbientLight(0xffffff, 1.1));
|
|
const dl = new THREE.DirectionalLight(0x9db8ff, 0.8); dl.position.set(100, 250, -100); scn.add(dl);
|
|
|
|
const orbit = new OrbitControls(cam, cvs);
|
|
orbit.target.set(55, 15, 55);
|
|
orbit.enableDamping = true;
|
|
|
|
function textSprite(txt, color = '#e8ecff', px = 44) {
|
|
const c = document.createElement('canvas'); c.width = 512; c.height = 128;
|
|
const x = c.getContext('2d'); x.font = `bold ${px}px system-ui`; x.fillStyle = color;
|
|
x.shadowColor = '#000'; x.shadowBlur = 8;
|
|
x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText(txt, 256, 64);
|
|
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c), transparent: true, depthTest: false }));
|
|
sp.scale.set(40, 10, 1); return sp;
|
|
}
|
|
|
|
// ---------- static scene ----------
|
|
const statics = new THREE.Group(); scn.add(statics);
|
|
function buildStatics(scene) {
|
|
statics.clear();
|
|
statics.add(new THREE.GridHelper(400, 16, 0x2a3a6e, 0x151b36));
|
|
|
|
const t = scene.table;
|
|
if (t && t.show !== false) {
|
|
const x0 = t.offsetX || 0, z0 = t.offsetZ || 0;
|
|
const slab = new THREE.Mesh(new THREE.BoxGeometry(t.width, 2, t.depth),
|
|
new THREE.MeshStandardMaterial({ color: 0x5a4630, roughness: 0.9 }));
|
|
slab.position.set(x0 + t.width / 2, -1.2, z0 + t.depth / 2);
|
|
statics.add(slab);
|
|
const nLbl = textSprite('N ↑ back', '#51eaf1'); nLbl.position.set(x0 + t.width / 2, 4, z0 + t.depth + 8); statics.add(nLbl);
|
|
}
|
|
for (const b of scene.buildings || []) {
|
|
const m = new THREE.Mesh(new THREE.BoxGeometry(...b.size),
|
|
new THREE.MeshStandardMaterial({ color: 0x3a4f80, transparent: true, opacity: 0.85, roughness: 0.7 }));
|
|
m.position.set(b.position[0], b.position[1] + b.size[1] / 2, b.position[2]);
|
|
m.rotation.y = THREE.MathUtils.degToRad(b.yawDeg || 0);
|
|
statics.add(m);
|
|
const e = new THREE.LineSegments(new THREE.EdgesGeometry(m.geometry),
|
|
new THREE.LineBasicMaterial({ color: 0x7fa4e8 }));
|
|
e.position.copy(m.position); e.rotation.copy(m.rotation);
|
|
statics.add(e);
|
|
}
|
|
for (const a of scene.anchors || []) {
|
|
if (a.enabled === false) continue;
|
|
const size = (a.sizeMM || 60) / 10;
|
|
const p = new THREE.Mesh(new THREE.PlaneGeometry(size, size),
|
|
new THREE.MeshBasicMaterial({ color: a.mount === 'wall' ? 0xf65151 : 0x3bc9a7, side: THREE.DoubleSide }));
|
|
anchorWorldMatrix(a).decompose(p.position, p.quaternion, p.scale);
|
|
statics.add(p);
|
|
}
|
|
for (const pth of scene.paths || []) {
|
|
if (pth.enabled === false || (pth.points || []).length < 2) continue;
|
|
const pts = pth.points.map(q => new THREE.Vector3(...q));
|
|
const lp = pth.mode === 'loop' ? [...pts, pts[0]] : pts;
|
|
statics.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(lp),
|
|
new THREE.LineBasicMaterial({ color: 0xc77dff, transparent: true, opacity: 0.5 })));
|
|
}
|
|
for (const s of scene.spawns || []) {
|
|
if (s.enabled === false) continue;
|
|
const m = new THREE.Mesh(new THREE.SphereGeometry(1.5, 10, 8),
|
|
new THREE.MeshBasicMaterial({ color: 0xb57f0b, transparent: true, opacity: 0.35 }));
|
|
m.position.set(...s.position);
|
|
statics.add(m);
|
|
}
|
|
}
|
|
|
|
// ---------- live ghosts ----------
|
|
const active = new Map(); // uid -> { rec, group, label }
|
|
let followUid = null;
|
|
let ghostHeight = 4; // cm, from scene.ghostHeightCm
|
|
const clockSamples = []; let clockOffset = 0;
|
|
|
|
async function addGhost(rec) {
|
|
if (active.has(rec.uid)) return;
|
|
const group = await buildGhost(rec, gradients, modelManifest);
|
|
group.userData.setHeight(ghostHeight);
|
|
const label = textSprite(rec.name, COLORS[rec.color] || '#fff');
|
|
label.scale.set(20, 5, 1);
|
|
scn.add(group); scn.add(label); // label stays world-scale, outside the scaled group
|
|
active.set(rec.uid, { rec, group, label });
|
|
renderList();
|
|
}
|
|
function removeGhost(uid) {
|
|
const e = active.get(uid);
|
|
if (!e) return;
|
|
scn.remove(e.group); scn.remove(e.label);
|
|
active.delete(uid);
|
|
if (followUid === uid) followUid = null;
|
|
renderList();
|
|
}
|
|
|
|
const net = new ExhibitNet();
|
|
net.on('scene', m => {
|
|
buildStatics(m.scene);
|
|
ghostHeight = m.scene.ghostHeightCm || 4;
|
|
for (const e of active.values()) e.group.userData.setHeight(ghostHeight);
|
|
})
|
|
.on('active', async m => {
|
|
for (const uid of [...active.keys()]) removeGhost(uid);
|
|
for (const rec of m.ghosts) await addGhost(rec);
|
|
})
|
|
.on('spawn', m => addGhost(m.ghost))
|
|
.on('despawn', m => {
|
|
const e = active.get(m.uid);
|
|
const until = e?.rec.until ?? Date.now() + clockOffset;
|
|
const wait = Math.max(0, until - (Date.now() + clockOffset)) + ((e?.rec.crossfade || 3) * 1000);
|
|
setTimeout(() => removeGhost(m.uid), Math.min(wait, 8000));
|
|
})
|
|
.on('pong', m => {
|
|
const rtt = performance.now() - m.t;
|
|
clockSamples.push(m.server + rtt / 2 - Date.now());
|
|
if (clockSamples.length > 5) clockSamples.shift();
|
|
clockOffset = clockSamples.reduce((a, b) => a + b, 0) / clockSamples.length;
|
|
});
|
|
net.connect();
|
|
setInterval(() => { try { net.ws.send(JSON.stringify({ type: 'ping', t: performance.now() })); } catch {} }, 5000);
|
|
|
|
// ---------- side list ----------
|
|
const list = document.getElementById('list');
|
|
function renderList() {
|
|
if (!active.size) { list.innerHTML = '<em style="opacity:.6">No ghosts active</em>'; return; }
|
|
list.innerHTML = [...active.values()].map(({ rec }) => `
|
|
<div class="g" data-uid="${rec.uid}">
|
|
<div class="nm"><span class="dot" style="background:${COLORS[rec.color] || '#fff'}"></span>${rec.name}</div>
|
|
<div class="meta">${rec.rarity} · ${rec.behavior.type}${rec.pathId ? ' on ' + rec.pathId : rec.spawnId ? ' @ ' + rec.spawnId : ''}
|
|
· <span class="ttl" data-uid="${rec.uid}"></span>${followUid === rec.uid ? ' · <b>following</b>' : ''}</div>
|
|
</div>`).join('');
|
|
list.querySelectorAll('.g').forEach(el => el.onclick = () => {
|
|
followUid = followUid === el.dataset.uid ? null : el.dataset.uid;
|
|
renderList();
|
|
});
|
|
}
|
|
setInterval(() => {
|
|
const now = Date.now() + clockOffset;
|
|
document.querySelectorAll('.ttl').forEach(el => {
|
|
const e = active.get(el.dataset.uid);
|
|
if (!e) return;
|
|
el.textContent = e.rec.until == null ? 'resident ∞' : Math.max(0, Math.ceil((e.rec.until - now) / 1000)) + 's left';
|
|
});
|
|
}, 1000);
|
|
|
|
// ---------- render loop ----------
|
|
const _tmp = { position: new THREE.Vector3(), rotationY: 0, opacity: 1 };
|
|
function fit() {
|
|
renderer.setSize(cvs.clientWidth, cvs.clientHeight, false);
|
|
cam.aspect = cvs.clientWidth / cvs.clientHeight; cam.updateProjectionMatrix();
|
|
}
|
|
new ResizeObserver(fit).observe(cvs);
|
|
fit();
|
|
|
|
(function loop(t) {
|
|
requestAnimationFrame(loop);
|
|
const now = Date.now() + clockOffset;
|
|
for (const { rec, group, label } of active.values()) {
|
|
ghostTransform(rec, now, _tmp);
|
|
group.position.copy(_tmp.position);
|
|
group.rotation.y = _tmp.rotationY;
|
|
group.userData.setOpacity(_tmp.opacity * 0.92);
|
|
group.userData.tick(t / 1000);
|
|
label.position.set(_tmp.position.x, _tmp.position.y + ghostHeight + 3, _tmp.position.z);
|
|
label.material.opacity = _tmp.opacity;
|
|
}
|
|
if (followUid && active.has(followUid)) orbit.target.lerp(active.get(followUid).group.position, 0.08);
|
|
orbit.update();
|
|
renderer.render(scn, cam);
|
|
})(0);
|
|
</script>
|
|
</body></html>
|