The store renderer placed every decal at raw pos_x/y/z with raw rotation, ignoring preferred_wall/snap/ object_type — so wall art (pos with one in-plane axis, perpendicular=0, rotation 0) floated in mid-room on the wrong axis. Ported WowPlatter public/js/virtual/decals.js snapToWallAndOrient: a space decal (rotation all-zero or snap=wall) keeps its in-plane axis, the perpendicular axis is pushed flat to its named wall (north z=-D/2 yaw0 · south z=D/2 yawpi · east x=W/2 yaw-pi/2 · west x=-W/2 yaw+pi/2 · floor/ceiling lie flat), yawed to face into the room; non-zero rotation = free manual placement, respected. Wall conventions already matched the room's walls. Fixes booth (31 decals) + entry-hall (6) automatically. Verified the snap math against real data (all land on-axis, in-bounds, facing in). Still TODO: crate_type(5)/rack(4) logo decals (backend only fetches object_type=space; need the face-snap port) + hiphop room has no decals authored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
388 lines
22 KiB
HTML
388 lines
22 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 clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
// WowPlatter facing convention (utils.js directionYaw): front/forward=0, back=π, left=−π/2, right=+π/2
|
|
const dirYaw = d => { d = String(d || '').toLowerCase(); return d === 'back' ? Math.PI : d === 'left' ? -Math.PI / 2 : d === 'right' ? Math.PI / 2 : 0; };
|
|
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 });
|
|
}
|
|
|
|
const yn = (v, def = 'y') => String(v ?? def).toLowerCase() !== 'n';
|
|
|
|
// rack: bench (slab) | rack (posts + frame + shelves + panels) | cupboard. Built base-at-0 then
|
|
// centered on the group origin so it's a drop-in for the old centered box (identical placement).
|
|
function buildRack(t, levels) {
|
|
const inner = new THREE.Group();
|
|
const w = num(t.width, 1), h = num(t.height, 0.9), d = num(t.depth, 0.4);
|
|
const baseH = num(t.base_height, 0), th = Math.max(0.01, num(t.thickness, 0.02));
|
|
const c = t.material_color || '#7a6a55', rough = num(t.material_roughness, 0.6);
|
|
const m = new THREE.MeshStandardMaterial({ color: new THREE.Color(c), roughness: rough });
|
|
const px = w / 2 - th / 2, pz = d / 2 - th / 2;
|
|
const add = (geo, x, y, z) => { const me = new THREE.Mesh(geo, m); me.position.set(x, y, z); inner.add(me); };
|
|
const type = t.type || 'bench';
|
|
if (type === 'rack') {
|
|
[[-px, -pz], [-px, pz], [px, -pz], [px, pz]].forEach(([x, z]) => add(new THREE.BoxGeometry(th, h, th), x, baseH + h / 2, z));
|
|
[baseH, baseH + h].forEach(y => { add(new THREE.BoxGeometry(w - 2 * th, th, th), 0, y, -pz); add(new THREE.BoxGeometry(w - 2 * th, th, th), 0, y, pz); add(new THREE.BoxGeometry(th, th, d - 2 * th), -px, y, 0); add(new THREE.BoxGeometry(th, th, d - 2 * th), px, y, 0); });
|
|
if (yn(t.left_panel, 'n')) add(new THREE.BoxGeometry(th, h, d), -px, baseH + h / 2, 0);
|
|
if (yn(t.right_panel, 'n')) add(new THREE.BoxGeometry(th, h, d), px, baseH + h / 2, 0);
|
|
if (yn(t.back_panel, 'n')) add(new THREE.BoxGeometry(w, h, th), 0, baseH + h / 2, -pz);
|
|
(levels || []).forEach(lv => {
|
|
const lt = Math.max(0.005, num(lv.thickness, 0.02)), ly = baseH + num(lv.y_pos, 0);
|
|
const lw = num(lv.width, 0) || (w - 2 * th), ld = num(lv.depth, 0) || (d - 2 * th);
|
|
const lm = new THREE.MeshStandardMaterial({ color: new THREE.Color(lv.material_color || c), roughness: rough });
|
|
const p = new THREE.Mesh(new THREE.BoxGeometry(lw, lt, ld), lm); p.position.set(0, ly, 0); inner.add(p);
|
|
});
|
|
} else if (type === 'cupboard') {
|
|
add(new THREE.BoxGeometry(w, th, d), 0, baseH + h - th / 2, 0); add(new THREE.BoxGeometry(w, th, d), 0, baseH + th / 2, 0);
|
|
if (yn(t.left_panel)) add(new THREE.BoxGeometry(th, h, d), -px, baseH + h / 2, 0);
|
|
if (yn(t.right_panel)) add(new THREE.BoxGeometry(th, h, d), px, baseH + h / 2, 0);
|
|
if (yn(t.back_panel)) add(new THREE.BoxGeometry(w, h, th), 0, baseH + h / 2, -pz);
|
|
} else {
|
|
const tt = Math.max(0.02, h); add(new THREE.BoxGeometry(w, tt, d), 0, baseH + tt / 2, 0);
|
|
}
|
|
inner.position.y = -(baseH + h / 2);
|
|
const g = new THREE.Group(); g.add(inner); return g;
|
|
}
|
|
|
|
// crate: open record bin — floor + short front + full back + sloped sides (front-low/back-high).
|
|
// Naturally centered on the origin → drop-in for the old centered box.
|
|
function slopedSide(x, ch, cd, hf, th, m) {
|
|
const geom = new THREE.BufferGeometry();
|
|
const yB = -ch / 2 + th / 2, yFT = -ch / 2 + hf - th / 2, yBT = ch / 2 - th / 2, zF = cd / 2 - th / 2, zB = -cd / 2 + th / 2;
|
|
geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array([x, yB, zF, x, yFT, zF, x, yB, zB, x, yBT, zB]), 3));
|
|
geom.setIndex([0, 1, 2, 2, 1, 3]); geom.computeVertexNormals();
|
|
return new THREE.Mesh(geom, m);
|
|
}
|
|
function buildCrate(t, front) {
|
|
const g = new THREE.Group();
|
|
const w = num(t.width, 0.34), h = num(t.height, 0.2), d = num(t.depth, 0.53), th = num(t.wall_thickness, 0.01);
|
|
const hf = (t.front_height != null && +t.front_height > 0) ? +t.front_height : null;
|
|
const m = new THREE.MeshStandardMaterial({ color: new THREE.Color(t.material_color || '#b9b9c2'), roughness: num(t.material_roughness, 0.6), metalness: num(t.material_metalness, 0.1), side: THREE.DoubleSide });
|
|
const add = (geo, x, y, z) => { const me = new THREE.Mesh(geo, m); me.position.set(x, y, z); g.add(me); };
|
|
add(new THREE.BoxGeometry(w, th, d), 0, -h / 2 + th / 2, 0);
|
|
if (yn(t.back_panel)) add(new THREE.BoxGeometry(w, h, th), 0, 0, -d / 2 + th / 2);
|
|
if (yn(t.front_panel)) add(new THREE.BoxGeometry(w, hf || h, th), 0, hf ? (-h / 2 + hf / 2) : 0, d / 2 - th / 2);
|
|
if (yn(t.left_panel)) { if (hf) g.add(slopedSide(-(w / 2 - th / 2), h, d, hf, th, m)); else add(new THREE.BoxGeometry(th, h, d), -(w / 2 - th / 2), 0, 0); }
|
|
if (yn(t.right_panel)) { if (hf) g.add(slopedSide(w / 2 - th / 2, h, d, hf, th, m)); else add(new THREE.BoxGeometry(th, h, d), w / 2 - th / 2, 0, 0); }
|
|
if (front && front.thumb) {
|
|
const pm = new THREE.MeshStandardMaterial({ roughness: 0.55 });
|
|
texLoader.load(front.thumb, tx => { pm.map = tx; pm.needsUpdate = true; }, undefined, () => {});
|
|
const s = Math.min(w, d) * 0.92;
|
|
const cov = new THREE.Mesh(new THREE.PlaneGeometry(s, s), pm);
|
|
cov.position.set(0, hf ? (-h / 2 + hf * 0.55) : 0, 0); cov.rotation.x = -Math.PI / 2.3; g.add(cov);
|
|
}
|
|
return g;
|
|
}
|
|
|
|
// 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 + crates — WowPlatter coordinate convention (racks.js / crates.js):
|
|
// • racks: pos_x/pos_z are CORNER-relative (0..room) → centered scene space; facing = rack.direction + rotation_y; attach_wall snaps to a wall
|
|
// • rack crates: CHILD of the rack, dropped into their rack_level's slot grid (cell 0.34m, row-major); facing = crate.direction + rotation_y (composes on the rack's)
|
|
// • free crates (no rack): pos_x/pos_z are CENTERED, like decals/lights
|
|
const rackTypes = Object.fromEntries((data.rack_types || []).map(t => [t.id, t]));
|
|
const crateTypes = Object.fromEntries((data.crate_types || []).map(t => [t.id, t]));
|
|
const levelsByType = {}; (data.rack_type_levels || []).forEach(l => { (levelsByType[l.rack_type_id] ||= []).push(l); });
|
|
const frontByCrate = Object.fromEntries((data.records || []).filter(r => r.thumb).map(r => [r.crate_id, r]));
|
|
const cratesByRack = {}; (data.crates || []).forEach(c => { if (c.rack_id != null) (cratesByRack[c.rack_id] ||= []).push(c); });
|
|
|
|
function placeCrateOnRack(rackGroup, levels, rackW, rackD, rackTopY, cr) {
|
|
const t = crateTypes[cr.crate_type_id] || {};
|
|
const cw = num(t.width, 0.34), ch = num(t.height, 0.2), cd = num(t.depth, 0.53);
|
|
const lvl = levels.find(l => cr.rack_type_level_id != null && +l.id === +cr.rack_type_level_id)
|
|
|| levels.find(l => cr.rack_level_index != null && +l.level_index === +cr.rack_level_index)
|
|
|| levels[0] || null;
|
|
const levelW = lvl ? (num(lvl.width, 0) || rackW) : rackW;
|
|
const levelD = lvl ? (num(lvl.depth, 0) || rackD) : rackD;
|
|
const levelY = lvl ? num(lvl.y_pos, 0) : rackTopY; // shelf-surface height above floor
|
|
let lx, lz;
|
|
if (cr.slot_number != null && +cr.slot_number > 0) { // slot → grid cell
|
|
const cell = cw > 0.1 ? cw : 0.34;
|
|
const cols = Math.max(1, Math.floor(levelW / cell)), rows = Math.max(1, Math.round(levelD / cell));
|
|
const csx = levelW / cols, csz = levelD / rows, si = +cr.slot_number - 1;
|
|
lx = -levelW / 2 + csx / 2 + (si % cols) * csx;
|
|
lz = -levelD / 2 + csz / 2 + Math.floor(si / cols) * csz;
|
|
} else { lx = num(cr.pos_x); lz = num(cr.pos_z); }
|
|
lx = clamp(lx, -(levelW / 2 - cw / 2), levelW / 2 - cw / 2);
|
|
lz = clamp(lz, -(levelD / 2 - cd / 2), levelD / 2 - cd / 2);
|
|
const g = buildCrate(t, frontByCrate[cr.id]);
|
|
g.position.set(lx, levelY + ch / 2, lz);
|
|
g.rotation.set(rad(num(cr.rotation_x)), dirYaw(cr.direction) + rad(num(cr.rotation_y)), rad(num(cr.rotation_z)));
|
|
g.userData = { crateId: cr.id, name: cr.label_text || cr.name };
|
|
rackGroup.add(g); crateMeshes.push(g);
|
|
}
|
|
|
|
(data.racks || []).forEach(rk => {
|
|
const t = rackTypes[rk.rack_type_id] || {};
|
|
const w = num(rk.width, num(t.width, 1)), h = num(rk.height, num(t.height, 0.9)), dp = num(rk.depth, num(t.depth, 0.5));
|
|
const halfX = w / 2, halfZ = dp / 2, rpx = num(rk.pos_x), rpz = num(rk.pos_z);
|
|
let yaw = dirYaw(rk.direction) + rad(num(rk.rotation_y));
|
|
const attach = String(rk.attach_wall || '').toLowerCase();
|
|
let cx, cz;
|
|
if (attach === 'north') { yaw = rad(num(rk.rotation_y)); cz = -D / 2 + halfZ + Math.max(0, rpz); cx = -W / 2 + halfX + rpx; }
|
|
else if (attach === 'south') { yaw = Math.PI + rad(num(rk.rotation_y)); cz = D / 2 - halfZ - Math.max(0, rpz); cx = -W / 2 + halfX + rpx; }
|
|
else if (attach === 'east') { yaw = -Math.PI / 2 + rad(num(rk.rotation_y)); cx = W / 2 - halfZ - Math.max(0, rpz); cz = -D / 2 + halfX + rpx; }
|
|
else if (attach === 'west') { yaw = Math.PI / 2 + rad(num(rk.rotation_y)); cx = -W / 2 + halfZ + Math.max(0, rpz); cz = -D / 2 + halfX + rpx; }
|
|
else { cx = -W / 2 + rpx + halfX * Math.cos(yaw) - halfZ * Math.sin(yaw); cz = -D / 2 + rpz + halfX * Math.sin(yaw) + halfZ * Math.cos(yaw); }
|
|
const wallish = attach === 'east' || attach === 'west';
|
|
cx = clamp(cx, -W / 2 + (wallish ? halfZ : halfX), W / 2 - (wallish ? halfZ : halfX));
|
|
cz = clamp(cz, -D / 2 + (wallish ? halfX : halfZ), D / 2 - (wallish ? halfX : halfZ));
|
|
|
|
const rackGroup = new THREE.Group();
|
|
rackGroup.position.set(cx, num(rk.pos_y, 0), cz);
|
|
rackGroup.rotation.set(rad(num(rk.rotation_x)), yaw, rad(num(rk.rotation_z)));
|
|
const levels = levelsByType[rk.rack_type_id] || [];
|
|
const geo = buildRack({ ...t, width: w, height: h, depth: dp }, levels);
|
|
geo.position.y = h / 2; rackGroup.add(geo); // bottom on the floor
|
|
roomGroup.add(rackGroup);
|
|
(cratesByRack[rk.id] || []).forEach(cr => placeCrateOnRack(rackGroup, levels, w, dp, h, cr));
|
|
});
|
|
|
|
// free-standing crates (no rack) — centered coords like decals/lights
|
|
(data.crates || []).filter(c => c.rack_id == null).forEach(cr => {
|
|
const t = crateTypes[cr.crate_type_id] || {};
|
|
const cw = num(t.width, 0.34), cd = num(t.depth, 0.53);
|
|
const g = buildCrate(t, frontByCrate[cr.id]);
|
|
g.position.set(clamp(num(cr.pos_x), -W / 2 + cw / 2, W / 2 - cw / 2), num(cr.pos_y, num(t.height, 0.2) / 2), clamp(num(cr.pos_z), -D / 2 + cd / 2, D / 2 - cd / 2));
|
|
g.rotation.set(rad(num(cr.rotation_x)), dirYaw(cr.direction) + rad(num(cr.rotation_y)), rad(num(cr.rotation_z)));
|
|
g.userData = { crateId: cr.id, name: cr.label_text || cr.name };
|
|
roomGroup.add(g); crateMeshes.push(g);
|
|
});
|
|
|
|
// decals — wall art / signage as textured planes (image) or canvas text. mesh decals skipped.
|
|
// Placement is wall-RELATIVE (ported from WowPlatter public/js/virtual/decals.js snapToWallAndOrient):
|
|
// a space decal keeps its in-plane axis, the perpendicular axis is pushed flat to its preferred_wall,
|
|
// and the plane is yawed to face into the room (floor/ceiling lie flat). Data has pos with one axis 0
|
|
// + all-zero rotation = "snap me to the wall"; a non-zero rotation or snap≠wall = free manual placement.
|
|
const wallOf = p => { p = String(p || '').trim().toLowerCase();
|
|
if (p === 'north' || p === 'front') return 'north';
|
|
if (p === 'south' || p === 'back') return 'south';
|
|
if (p === 'east' || p === 'right') return 'east';
|
|
if (p === 'west' || p === 'left') return 'west';
|
|
if (p === 'floor' || p === 'ceiling') return p;
|
|
return null; };
|
|
function placeDecal(m, d) {
|
|
const rx = num(d.rotation_x), ry = num(d.rotation_y), rz = num(d.rotation_z);
|
|
const isSpace = String(d.object_type || 'space').toLowerCase() === 'space';
|
|
const snapMode = String(d.snap || '').toLowerCase();
|
|
if (!(isSpace && (snapMode === 'wall' || (rx === 0 && ry === 0 && rz === 0)))) {
|
|
m.position.set(num(d.pos_x), num(d.pos_y, 1.5), num(d.pos_z));
|
|
m.rotation.set(rad(rx), rad(ry), rad(rz)); return;
|
|
}
|
|
const eps = 0.001;
|
|
let x = num(d.pos_x), y = num(d.pos_y, H * 0.5), z = num(d.pos_z);
|
|
y = Math.max(Math.min(y, H - 0.05), 0.05);
|
|
let wall = wallOf(d.preferred_wall || d.wall);
|
|
if (!wall) { // no wall set → nearest one
|
|
const a = [['north', Math.abs(z + D / 2)], ['south', Math.abs(z - D / 2)],
|
|
['east', Math.abs(x - W / 2)], ['west', Math.abs(x + W / 2)]];
|
|
a.sort((p, q) => p[1] - q[1]); wall = a[0][0];
|
|
}
|
|
let yaw = 0, pitch = 0;
|
|
if (wall === 'north') { z = -D / 2 + eps; yaw = 0; }
|
|
else if (wall === 'south') { z = D / 2 - eps; yaw = Math.PI; }
|
|
else if (wall === 'east') { x = W / 2 - eps; yaw = -Math.PI / 2; }
|
|
else if (wall === 'west') { x = -W / 2 + eps; yaw = Math.PI / 2; }
|
|
else if (wall === 'floor') { y = 0.02; pitch = -Math.PI / 2; }
|
|
else if (wall === 'ceiling') { y = H - eps; pitch = Math.PI / 2; }
|
|
m.position.set(x, y, z); m.rotation.set(pitch, yaw, 0);
|
|
}
|
|
(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);
|
|
placeDecal(m, d);
|
|
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);
|
|
const wx = -W / 2 + num(p.x), wz = -D / 2 + num(p.z); // portal x/z are corner-relative
|
|
m.position.set(wx, h / 2, wz);
|
|
m.rotation.y = rad(num(p.facing_deg));
|
|
roomGroup.add(m);
|
|
if (p.to_space) portals.push({ x: wx, z: wz, to: p.to_space, label: p.label });
|
|
});
|
|
|
|
// spawn at the configured camera position (corner-relative) if present
|
|
if (sp.camera_pos_x != null) camera.position.set(-W / 2 + num(sp.camera_pos_x), num(sp.camera_pos_y, 1.6), -D / 2 + num(sp.camera_pos_z));
|
|
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, true)[0];
|
|
if (hit && hit.distance < 3.5) {
|
|
let o = hit.object; while (o && !(o.userData && o.userData.crateId)) o = o.parent;
|
|
if (o) { dig.open(o.userData.crateId); 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>
|