RECORDGOD/webstore/index.html
type-two 8c25b65d7e feat(virtual): finish the 3D store port — per-room scoping, decals, portals
Scene was dumping all 3 rooms' racks/crates/lights into one room, and the
served decals + portals were never rendered (trapped in booth-room).

- /virtual/scene scopes to ONE room (?space=, default = is_default space);
  filters racks/crates/lights/cameras/portals/decals/covers by space_id
- render decals (image + canvas-text planes) and portal doorways; walk into
  a portal -> loads the linked room
- portal links_to_id is the PAIRED portal's id, not a space — resolve through
  it to the destination room (to_space)
- copy the 29 referenced decal webp/svg into webstore/assets (self-contained,
  served /store/assets); rewrite WP /wp-content/uploads/ paths in the scene API
- skip the 17MB logo .glb mesh decal (flat logo-blue.webp covers branding)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:00:02 +10:00

240 lines
12 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RecordGod — virtual store</title>
<style>
html,body{margin:0;height:100%;background:#111;color:#eee;font:14px/1.5 system-ui,sans-serif;overflow:hidden}
#c{display:block}
#hud{position:fixed;top:10px;left:10px;background:rgba(0,0,0,.55);padding:8px 12px;border-radius:8px;pointer-events:none}
#hud b{color:#ff5db1}
#start{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.75);cursor:pointer}
#start div{text-align:center;max-width:420px}
#start h1{color:#ff5db1;font-weight:500;margin:0 0 8px}
#dig{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:rgba(0,0,0,.7);padding:6px 14px;border-radius:20px;display:none}
#cross{position:fixed;left:50%;top:50%;width:6px;height:6px;margin:-3px 0 0 -3px;background:#fff;border-radius:50%;opacity:.6;pointer-events:none}
</style>
<script type="importmap">
{ "imports": {
"three": "https://unpkg.com/three@0.175.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.175.0/examples/jsm/"
}}
</script>
</head>
<body>
<canvas id="c"></canvas>
<div id="cross"></div>
<div id="hud">RecordGod store — <span id="stat">loading…</span></div>
<div id="dig"></div>
<div id="start"><div><h1>RecordGod</h1>click to enter — <b>WASD</b> move · <b>mouse</b> look · <b>click</b> a crate to dig · <b>Esc</b> to exit</div></div>
<script type="module">
import * as THREE from 'three';
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
import { createDig } from './dig.js';
const num = (v, d = 0) => (v == null || isNaN(+v) ? d : +v);
const rad = THREE.MathUtils.degToRad;
const stat = document.getElementById('stat');
const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('c'), antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111114);
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.05, 200);
camera.position.set(0, 1.6, 3);
const controls = new PointerLockControls(camera, renderer.domElement);
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
const dig = createDig(renderer, { onClose: () => start.style.display = 'flex' });
const start = document.getElementById('start');
start.onclick = () => controls.lock();
controls.addEventListener('lock', () => start.style.display = 'none');
controls.addEventListener('unlock', () => { if (!dig.active) start.style.display = 'flex'; });
// --- movement ---
const keys = {};
addEventListener('keydown', e => keys[e.code] = true);
addEventListener('keyup', e => keys[e.code] = false);
const vel = new THREE.Vector3();
// --- build the scene from /virtual/scene ---
// One room at a time. roomGroup holds everything space-scoped so a portal walk-through clears
// it and rebuilds the linked room. crateMeshes/portals reset per load.
let crateMeshes = [], portals = [], roomGroup = null, switching = false;
const texLoader = new THREE.TextureLoader();
texLoader.crossOrigin = 'anonymous';
function material(color, fallback) {
return new THREE.MeshStandardMaterial({ color: new THREE.Color(color || fallback), roughness: 0.85 });
}
// text → CanvasTexture (shared by text decals + portal labels)
function textTexture(text, { color = '#fff', bg = null, font = 64, pad = 24 } = {}) {
const lines = String(text).split('\n');
const cv = document.createElement('canvas'); const ctx = cv.getContext('2d');
ctx.font = `600 ${font}px system-ui, sans-serif`;
const w = Math.max(...lines.map(l => ctx.measureText(l).width)) + pad * 2;
const lh = font * 1.25;
cv.width = Math.ceil(w); cv.height = Math.ceil(lines.length * lh + pad * 2);
ctx.font = `600 ${font}px system-ui, sans-serif`; ctx.textBaseline = 'top';
if (bg) { ctx.fillStyle = bg; ctx.fillRect(0, 0, cv.width, cv.height); }
ctx.fillStyle = color;
lines.forEach((l, i) => ctx.fillText(l, pad, pad + i * lh));
const tx = new THREE.CanvasTexture(cv); tx.colorSpace = THREE.SRGBColorSpace;
return { tx, aspect: cv.width / cv.height };
}
function buildScene(data) {
if (roomGroup) scene.remove(roomGroup);
roomGroup = new THREE.Group(); scene.add(roomGroup);
crateMeshes = []; portals = [];
const sp = data.space || {};
const W = num(sp.room_width, 12), D = num(sp.room_depth, 12), H = num(sp.ceiling_height, 3.2);
// room
const floor = new THREE.Mesh(new THREE.PlaneGeometry(W, D), material(sp.floor_color, '#2a2a30'));
floor.rotation.x = -Math.PI / 2; roomGroup.add(floor);
const ceil = new THREE.Mesh(new THREE.PlaneGeometry(W, D), material(sp.ceiling_color, '#1a1a1f'));
ceil.rotation.x = Math.PI / 2; ceil.position.y = H; roomGroup.add(ceil);
const wallMat = material(sp.wall_color, '#3a3a42');
const walls = [[0, -D / 2, 0], [0, D / 2, Math.PI], [-W / 2, 0, Math.PI / 2], [W / 2, 0, -Math.PI / 2]];
walls.forEach(([x, z, ry], i) => {
const w = (i < 2) ? W : D;
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, H), wallMat.clone());
m.position.set(x, H / 2, z); m.rotation.y = ry; roomGroup.add(m);
});
// lights (data-driven + a soft ambient so it's never pitch black)
roomGroup.add(new THREE.AmbientLight(0xffffff, num(sp.ambient_light_intensity, 0.6)));
(data.lights || []).forEach(L => {
const pl = new THREE.PointLight(new THREE.Color(L.color || '#ffffff'), num(L.intensity, 0.8), num(L.distance, 0) || 0);
pl.position.set(num(L.pos_x), num(L.pos_y, H - 0.3), num(L.pos_z)); roomGroup.add(pl);
});
if (!(data.lights || []).length) { const d = new THREE.DirectionalLight(0xffffff, 0.7); d.position.set(3, 6, 2); roomGroup.add(d); }
// racks (box per rack, sized by its rack_type)
const rackTypes = Object.fromEntries((data.rack_types || []).map(t => [t.id, t]));
(data.racks || []).forEach(rk => {
const t = rackTypes[rk.rack_type_id] || {};
const w = num(t.width, 1), h = num(t.height, 1.8), dp = num(t.depth, 0.4);
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, dp), material(t.material_color, '#5a4634'));
m.position.set(num(rk.pos_x), num(rk.pos_y, h / 2), num(rk.pos_z));
m.rotation.set(rad(num(rk.rotation_x)), rad(num(rk.rotation_y)), rad(num(rk.rotation_z)));
roomGroup.add(m);
});
// crates (box per crate, sized by crate_type) + front-cover texture
const crateTypes = Object.fromEntries((data.crate_types || []).map(t => [t.id, t]));
const frontByCrate = Object.fromEntries((data.records || []).filter(r => r.thumb).map(r => [r.crate_id, r]));
(data.crates || []).forEach(cr => {
const t = crateTypes[cr.crate_type_id] || {};
const w = num(t.width, 0.35), h = num(t.height, 0.2), dp = num(t.depth, 0.5);
const body = material(t.material_color, '#c9c9cf');
const mats = [body, body, body, body, body, body];
const front = frontByCrate[cr.id];
if (front) {
const mat = new THREE.MeshStandardMaterial({ roughness: 0.6 });
texLoader.load(front.thumb, tx => { mat.map = tx; mat.needsUpdate = true; }, undefined, () => {});
mats[4] = mat; // +Z face shows the front cover
}
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, dp), mats);
m.position.set(num(cr.pos_x), num(cr.pos_y, h / 2), num(cr.pos_z));
m.rotation.set(rad(num(cr.rotation_x)), rad(num(cr.rotation_y)), rad(num(cr.rotation_z)));
m.userData = { crateId: cr.id, name: cr.label_text || cr.name };
roomGroup.add(m); crateMeshes.push(m);
});
// decals — wall art / signage as textured planes (image) or canvas text. mesh decals skipped.
(data.decals || []).forEach(d => {
const w = num(d.width, 1), h = num(d.height, 1), op = num(d.opacity, 1);
let mat;
if (d.content_type === 'text' && d.text_value) {
const { tx } = textTexture(d.text_value, { color: d.text_color || '#fff', bg: d.background_color || null });
mat = new THREE.MeshBasicMaterial({ map: tx, transparent: true, opacity: op, side: THREE.DoubleSide });
} else if (d.content_type === 'image' && (d.image_url || d.asset_url)) {
mat = new THREE.MeshBasicMaterial({ transparent: true, opacity: op, side: THREE.DoubleSide });
texLoader.load(d.image_url || d.asset_url, tx => { tx.colorSpace = THREE.SRGBColorSpace; mat.map = tx; mat.needsUpdate = true; }, undefined, () => {});
} else { return; } // mesh / empty — skipped (ponytail: 17MB logo .glb left out; webp art covers the look)
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
m.position.set(num(d.pos_x), num(d.pos_y, 1.5), num(d.pos_z));
m.rotation.set(rad(num(d.rotation_x)), rad(num(d.rotation_y)), rad(num(d.rotation_z)));
roomGroup.add(m);
});
// portals — doorways to linked rooms. A translucent labelled curtain you walk through.
(data.portals || []).forEach(p => {
if (!p.links_to_id) return;
const w = num(p.width, 1.6), h = 2.2;
const { tx } = textTexture(p.label || 'door', { color: '#fff', bg: '#ff5db1', font: 48 });
const mat = new THREE.MeshBasicMaterial({ map: tx, transparent: true, opacity: 0.82, side: THREE.DoubleSide });
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
m.position.set(num(p.x), h / 2, num(p.z));
m.rotation.y = rad(num(p.facing_deg));
roomGroup.add(m);
if (p.to_space) portals.push({ x: num(p.x), z: num(p.z), to: p.to_space, label: p.label });
});
// spawn at the configured camera position if present
if (sp.camera_pos_x != null) camera.position.set(num(sp.camera_pos_x), num(sp.camera_pos_y, 1.6), num(sp.camera_pos_z, 3));
stat.innerHTML = `<b>${sp.name || 'store'}</b> · ${(data.racks||[]).length} racks · ${(data.crates||[]).length} crates · ${(data.records||[]).length} covers`;
}
function loadSpace(id) {
switching = true;
const q = id ? `?space=${id}` : '';
return fetch('/virtual/scene' + q).then(r => r.json())
.then(data => { buildScene(data); switching = false; })
.catch(e => { stat.textContent = 'scene load failed: ' + e; switching = false; });
}
loadSpace();
// --- dig: click a crate (raycast from screen centre) ---
const ray = new THREE.Raycaster();
renderer.domElement.addEventListener('click', () => {
if (!controls.isLocked || dig.active) return;
ray.setFromCamera(new THREE.Vector2(0, 0), camera);
const hit = ray.intersectObjects(crateMeshes, false)[0];
if (hit && hit.distance < 3.5) {
dig.open(hit.object.userData.crateId); // enter the spring-physics dig view
controls.unlock();
}
});
// --- render loop ---
const clock = new THREE.Clock();
let portalArmed = false; // must be clear of every portal before one can fire (no bounce-loop if you spawn on a doorway)
function tick() {
requestAnimationFrame(tick);
const dt = Math.min(clock.getDelta(), 0.1);
if (dig.active) { dig.update(dt); renderer.render(dig.scene, dig.camera); return; }
if (controls.isLocked) {
const speed = 3 * dt;
vel.set((keys.KeyD ? 1 : 0) - (keys.KeyA ? 1 : 0), 0, (keys.KeyS ? 1 : 0) - (keys.KeyW ? 1 : 0));
if (vel.lengthSq()) {
vel.normalize();
controls.moveRight(vel.x * speed);
controls.moveForward(-vel.z * speed);
}
// walk into a portal → load the linked room (must have stepped clear of all portals first)
if (!switching) {
let near = false;
for (const p of portals) {
const dx = camera.position.x - p.x, dz = camera.position.z - p.z;
if (dx * dx + dz * dz < 0.9 * 0.9) { near = true; if (portalArmed) { portalArmed = false; loadSpace(p.to); } break; }
}
if (!near) portalArmed = true;
}
}
renderer.render(scene, camera);
}
tick();
</script>
</body>
</html>