feat(virtual): lift the 3D store out of WordPress — scene API + raw Three.js renderer
- migrate_virtual.py: all 15 wp_rmp_disc_virtual_* tables → Postgres (~700 rows).
- app/virtual.py (public): GET /virtual/scene (geometry + slim front covers enriched
from discogs_full), /virtual/crate/{id}/records (digging), /virtual/locate (locate-stock).
Replaces payload.php — no WordPress bootstrap.
- webstore/index.html: walkable raw Three.js store (room/racks/crates from the data,
pointer-lock FPS, click-to-dig hook), served same-origin at /store.
- Proven: 51 racks, 346 crates, 277 covers; locate release→crate works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d75f27a691
commit
8b48010b50
10
app/main.py
10
app/main.py
@ -1,10 +1,20 @@
|
|||||||
|
import pathlib
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .wowplatter_v1 import router as wowplatter_v1_router
|
from .wowplatter_v1 import router as wowplatter_v1_router
|
||||||
|
from .virtual import router as virtual_router
|
||||||
|
|
||||||
app = FastAPI(title="RECORDGOD", version=__version__)
|
app = FastAPI(title="RECORDGOD", version=__version__)
|
||||||
app.include_router(wowplatter_v1_router)
|
app.include_router(wowplatter_v1_router)
|
||||||
|
app.include_router(virtual_router)
|
||||||
|
|
||||||
|
# Serve the raw Three.js storefront same-origin (so /store can fetch /virtual/scene, no CORS).
|
||||||
|
WEBSTORE = pathlib.Path(__file__).resolve().parent.parent / "webstore"
|
||||||
|
if WEBSTORE.is_dir():
|
||||||
|
app.mount("/store", StaticFiles(directory=str(WEBSTORE), html=True), name="store")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
|
|||||||
97
app/virtual.py
Normal file
97
app/virtual.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from .db import get_db, MirrorSession
|
||||||
|
|
||||||
|
# The virtual store API — PUBLIC read (it's the customer-facing storefront). Replaces
|
||||||
|
# WowPlatter's payload.php: same scene shape, but from Postgres instead of a full WP bootstrap.
|
||||||
|
router = APIRouter(prefix="/virtual", tags=["virtual-store"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _all(db, table, where=""):
|
||||||
|
rows = await db.execute(text(f"SELECT * FROM {table} {where}"))
|
||||||
|
return [dict(r) for r in rows.mappings()]
|
||||||
|
|
||||||
|
|
||||||
|
async def _enrich(release_ids):
|
||||||
|
"""Pull title/artist/cover from the discogs_full mirror for a set of release ids."""
|
||||||
|
ids = [i for i in release_ids if i is not None]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
async with MirrorSession() as m:
|
||||||
|
rows = await m.execute(text(
|
||||||
|
"SELECT id, title, artists_sort, COALESCE(thumb_local_url, thumb) AS thumb "
|
||||||
|
"FROM release WHERE id = ANY(:ids)"), {"ids": ids})
|
||||||
|
return {r["id"]: dict(r) for r in rows.mappings()}
|
||||||
|
except Exception:
|
||||||
|
return {} # mirror down → scene still renders, just without covers/titles
|
||||||
|
|
||||||
|
|
||||||
|
def _attach(recs, meta):
|
||||||
|
for r in recs:
|
||||||
|
m = meta.get(r["release_id"], {})
|
||||||
|
r["title"], r["artist"], r["thumb"] = m.get("title"), m.get("artists_sort"), m.get("thumb")
|
||||||
|
return recs
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scene")
|
||||||
|
async def scene(db=Depends(get_db)):
|
||||||
|
"""Everything the renderer needs at boot. Records are SLIM — one front cover per crate;
|
||||||
|
the full per-crate list is lazy-loaded on dig via /virtual/crate/{id}/records."""
|
||||||
|
spaces = await _all(db, "virtual_space", "WHERE visible='y'")
|
||||||
|
active = spaces[0] if len(spaces) == 1 else next(
|
||||||
|
(s for s in spaces if s.get("is_default") == "y"), spaces[0] if spaces else None)
|
||||||
|
|
||||||
|
recs = [dict(r) for r in (await db.execute(text("""
|
||||||
|
SELECT DISTINCT ON (crate_id) crate_id, slot_number, release_id, sku, price, condition
|
||||||
|
FROM inventory
|
||||||
|
WHERE in_stock AND crate_id IS NOT NULL AND release_id IS NOT NULL
|
||||||
|
ORDER BY crate_id, slot_number NULLS LAST"""))).mappings()]
|
||||||
|
recs = _attach(recs, await _enrich({r["release_id"] for r in recs}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"space": active, "spaces": spaces,
|
||||||
|
"racks": await _all(db, "virtual_rack", "WHERE visible='y'"),
|
||||||
|
"rack_types": await _all(db, "virtual_rack_type"),
|
||||||
|
"rack_type_levels": await _all(db, "virtual_rack_type_level"),
|
||||||
|
"crates": await _all(db, "virtual_crate", "WHERE visible='y'"),
|
||||||
|
"crate_types": await _all(db, "virtual_crate_type"),
|
||||||
|
"cameras": await _all(db, "virtual_camera"),
|
||||||
|
"lights": await _all(db, "virtual_light"),
|
||||||
|
"decals": await _all(db, "virtual_decal", "WHERE visible <> 'n'"),
|
||||||
|
"portals": await _all(db, "virtual_portal"),
|
||||||
|
"records": recs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/crate/{crate_id}/records")
|
||||||
|
async def crate_records(crate_id: int, db=Depends(get_db)):
|
||||||
|
"""Full contents of one crate — loaded when the shopper starts digging it."""
|
||||||
|
recs = [dict(r) for r in (await db.execute(text("""
|
||||||
|
SELECT release_id, slot_number, sku, price, condition
|
||||||
|
FROM inventory WHERE crate_id = :c AND in_stock AND release_id IS NOT NULL
|
||||||
|
ORDER BY slot_number NULLS LAST"""), {"c": crate_id})).mappings()]
|
||||||
|
recs = _attach(recs, await _enrich({r["release_id"] for r in recs}))
|
||||||
|
return {"ok": True, "crate_id": crate_id, "records": recs}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/locate")
|
||||||
|
async def locate(release_id: int, db=Depends(get_db)):
|
||||||
|
"""Locate-stock: where in the physical store is this record? Returns the crate + position
|
||||||
|
so the UI can fly the camera there and flash the bin."""
|
||||||
|
row = (await db.execute(text("""
|
||||||
|
SELECT i.crate_id, i.slot_number, i.sku,
|
||||||
|
c.name, c.label_text, c.pos_x, c.pos_y, c.pos_z
|
||||||
|
FROM inventory i LEFT JOIN virtual_crate c ON c.id = i.crate_id
|
||||||
|
WHERE i.release_id = :r AND i.in_stock AND i.crate_id IS NOT NULL
|
||||||
|
ORDER BY i.slot_number NULLS LAST LIMIT 1"""), {"r": release_id})).mappings().first()
|
||||||
|
if not row:
|
||||||
|
return {"ok": True, "located": False, "release_id": release_id}
|
||||||
|
d = dict(row)
|
||||||
|
return {
|
||||||
|
"ok": True, "located": True, "release_id": release_id,
|
||||||
|
"crate_id": d["crate_id"], "slot_number": d["slot_number"], "sku": d["sku"],
|
||||||
|
"crate": {"name": d["name"], "label_text": d["label_text"],
|
||||||
|
"pos": [float(d["pos_x"] or 0), float(d["pos_y"] or 0), float(d["pos_z"] or 0)]},
|
||||||
|
}
|
||||||
82
migrate_virtual.py
Normal file
82
migrate_virtual.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Lift the WowPlatter 3D-store geometry (wp_rmp_disc_virtual_*) into RecordGod Postgres.
|
||||||
|
|
||||||
|
These tables are read-mostly scene config (~700 rows total) — we do NOT redesign them, just
|
||||||
|
relocate verbatim so the RecordGod /virtual/scene endpoint can serve the same payload payload.php
|
||||||
|
built, but from Postgres instead of a full WordPress bootstrap. Auto-discovers every
|
||||||
|
wp_rmp_disc_virtual* table and mirrors it as virtual_* in recordgod.
|
||||||
|
|
||||||
|
python migrate_virtual.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
WP_CONFIG = os.getenv("WP_CONFIG", "/opt/homebrew/var/www/monsterrobot.localsite/wp-config.php")
|
||||||
|
PG_DSN = os.getenv("RECORDGOD_DSN", "postgresql://localhost/recordgod")
|
||||||
|
SOCK = os.getenv("MARIA_SOCK", "/opt/homebrew/var/mysql/mysql.sock")
|
||||||
|
|
||||||
|
|
||||||
|
def wp_creds():
|
||||||
|
t = pathlib.Path(WP_CONFIG).read_text()
|
||||||
|
g = lambda k: re.search(rf"'{k}',\s*'([^']*)'", t).group(1)
|
||||||
|
return g("DB_NAME"), g("DB_USER"), g("DB_PASSWORD")
|
||||||
|
|
||||||
|
|
||||||
|
def pgtype(mysql_type):
|
||||||
|
t = mysql_type.lower()
|
||||||
|
if t in ("tinyint", "smallint", "mediumint", "int", "integer", "bigint"):
|
||||||
|
return "bigint"
|
||||||
|
if t in ("decimal", "numeric"):
|
||||||
|
return "numeric"
|
||||||
|
if t in ("float", "double"):
|
||||||
|
return "double precision"
|
||||||
|
if t in ("datetime", "timestamp"):
|
||||||
|
return "timestamptz"
|
||||||
|
if t == "date":
|
||||||
|
return "date"
|
||||||
|
return "text" # varchar/char/text/longtext/enum/json — kept as-is; geometry config, not redesigned
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
name, user, pw = wp_creds()
|
||||||
|
my = pymysql.connect(unix_socket=SOCK, user=user, password=pw, database=name,
|
||||||
|
cursorclass=pymysql.cursors.DictCursor)
|
||||||
|
pg = psycopg.connect(PG_DSN, autocommit=False)
|
||||||
|
c = my.cursor()
|
||||||
|
|
||||||
|
c.execute("""SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema=%s AND table_name LIKE 'wp_rmp_disc_virtual%%'
|
||||||
|
ORDER BY table_name""", (name,))
|
||||||
|
tables = [r["table_name"] for r in c.fetchall()]
|
||||||
|
|
||||||
|
for src in tables:
|
||||||
|
dst = src.replace("wp_rmp_disc_", "") # virtual_crate, virtual_rack, ...
|
||||||
|
c.execute("""SELECT column_name, data_type FROM information_schema.columns
|
||||||
|
WHERE table_schema=%s AND table_name=%s ORDER BY ordinal_position""",
|
||||||
|
(name, src))
|
||||||
|
cols = c.fetchall()
|
||||||
|
coldefs = ", ".join(f'"{col["column_name"]}" {pgtype(col["data_type"])}' for col in cols)
|
||||||
|
names = [col["column_name"] for col in cols]
|
||||||
|
|
||||||
|
pg.execute(f'DROP TABLE IF EXISTS "{dst}"')
|
||||||
|
pg.execute(f'CREATE TABLE "{dst}" ({coldefs})')
|
||||||
|
|
||||||
|
c.execute(f"SELECT * FROM `{src}`")
|
||||||
|
rows = [tuple(r[n] for n in names) for r in c.fetchall()]
|
||||||
|
if rows:
|
||||||
|
ph = ",".join(["%s"] * len(names))
|
||||||
|
quoted = ",".join(f'"{n}"' for n in names)
|
||||||
|
with pg.cursor() as cur:
|
||||||
|
cur.executemany(f'INSERT INTO "{dst}" ({quoted}) VALUES ({ph})', rows)
|
||||||
|
print(f" {dst:28} {len(rows):>5}")
|
||||||
|
|
||||||
|
pg.commit()
|
||||||
|
print("done.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
173
webstore/index.html
Normal file
173
webstore/index.html
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
<!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';
|
||||||
|
|
||||||
|
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 start = document.getElementById('start');
|
||||||
|
start.onclick = () => controls.lock();
|
||||||
|
controls.addEventListener('lock', () => start.style.display = 'none');
|
||||||
|
controls.addEventListener('unlock', () => 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 ---
|
||||||
|
const crateMeshes = [];
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/virtual/scene').then(r => r.json()).then(data => {
|
||||||
|
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; scene.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; scene.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; scene.add(m);
|
||||||
|
});
|
||||||
|
|
||||||
|
// lights (data-driven + a soft ambient so it's never pitch black)
|
||||||
|
scene.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)); scene.add(pl);
|
||||||
|
});
|
||||||
|
if (!(data.lights || []).length) { const d = new THREE.DirectionalLight(0xffffff, 0.7); d.position.set(3, 6, 2); scene.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)));
|
||||||
|
scene.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 };
|
||||||
|
scene.add(m); crateMeshes.push(m);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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>${(data.racks||[]).length}</b> racks · <b>${(data.crates||[]).length}</b> crates · <b>${(data.records||[]).length}</b> covers`;
|
||||||
|
}).catch(e => { stat.textContent = 'scene load failed: ' + e; });
|
||||||
|
|
||||||
|
// --- dig: click a crate (raycast from screen centre) ---
|
||||||
|
const ray = new THREE.Raycaster();
|
||||||
|
const digEl = document.getElementById('dig');
|
||||||
|
renderer.domElement.addEventListener('click', () => {
|
||||||
|
if (!controls.isLocked) return;
|
||||||
|
ray.setFromCamera(new THREE.Vector2(0, 0), camera);
|
||||||
|
const hit = ray.intersectObjects(crateMeshes, false)[0];
|
||||||
|
if (hit && hit.distance < 3) {
|
||||||
|
const { crateId, name } = hit.object.userData;
|
||||||
|
digEl.style.display = 'block';
|
||||||
|
digEl.textContent = `digging crate #${crateId} ${name ? '· ' + name : ''}…`;
|
||||||
|
fetch(`/virtual/crate/${crateId}/records`).then(r => r.json()).then(d => {
|
||||||
|
digEl.textContent = `crate #${crateId}: ${d.records.length} records — ${d.records.slice(0,3).map(r=>r.title||r.sku).join(' · ')}`;
|
||||||
|
setTimeout(() => digEl.style.display = 'none', 4000);
|
||||||
|
// TODO(next): spring-physics flip view — see RECORDGOD_PLAN §digging
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- render loop ---
|
||||||
|
const clock = new THREE.Clock();
|
||||||
|
function tick() {
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
const dt = Math.min(clock.getDelta(), 0.1);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue
Block a user