3GOD was "three.js god" — a quick way to eyeball a GLB and drop it into a game. wardrobegod's stage, grid and publish flow cover that far better now, so the bench is retired. What survives is the half that quietly became infrastructure. Absorbed into depot/: server.py (271 lines, verbatim), its hardening tests, and viewer.html kept as superseded reference only. Retiring the REPO is safe; retiring the SERVICE is not. The live instance on the dealgod VPS holds 773 assets and two shipping games fetch from it at runtime — thriftgod web/index.html:1176 and procity loaders.js:7 both hardcode https://digalot.fyi/3god. So the /3god URL stays even though the name is gone: changing it means editing two shipping codebases for no benefit, and the name now survives only as a URL path nobody looks at. The VPS service is deployed, not sourced live from the Gitea checkout, so archiving that repo changes nothing at runtime. Checked before discarding the UI: the depot's /api/meta tag store had 0 tags and 0 notes across all 773 assets, so there was nothing to migrate. Tagging lives in library/index.json now. depot/README.md records the two things that are easy to get wrong and expensive to rediscover: publishing must go direct over the tailnet (the Cloudflare front 403s because auth trusts the raw socket peer — thriftgod's own prop_campaign.py --publish is broken for exactly this), and clean_name DELETES illegal characters so 'shop!-cat.glb' silently resolves to an existing different mesh. Verified after the change: both the public and tailnet endpoints still answer 200. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
543 lines
31 KiB
HTML
543 lines
31 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>3GOD — the prop house</title>
|
||
<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>
|
||
<style>
|
||
:root { --bg:#141210; --panel:#1d1a14; --line:#4a4130; --gold:#ffd75e; --dim:#c9b478; --fg:#eee; }
|
||
* { box-sizing: border-box; }
|
||
body { margin:0; background:var(--bg); color:var(--fg); font:14px system-ui; overflow:hidden; }
|
||
#side { position:fixed; left:0; top:0; bottom:0; width:320px; background:var(--panel);
|
||
border-right:1px solid var(--line); display:flex; flex-direction:column; z-index:2; }
|
||
#side h1 { margin:0; padding:16px 16px 4px; font:800 22px system-ui; color:var(--gold); letter-spacing:1px; }
|
||
#side h1 small { display:block; font:400 11px system-ui; color:var(--dim); letter-spacing:0; }
|
||
#shelf { flex:1; overflow-y:auto; padding:8px; }
|
||
.prop { padding:9px 10px; border:1px solid transparent; border-radius:10px; cursor:pointer; }
|
||
.prop:hover { background:#26221a; }
|
||
.prop.sel { border-color:var(--gold); background:#2c2820; }
|
||
.prop b { color:var(--fg); }
|
||
.prop .sub { color:var(--dim); font-size:12px; }
|
||
.prop .tag { display:inline-block; background:#2c2820; border:1px solid var(--line); border-radius:8px;
|
||
padding:0 6px; font-size:11px; color:var(--dim); margin:2px 3px 0 0; }
|
||
#dropzone { margin:8px; padding:14px; border:2px dashed var(--line); border-radius:12px; text-align:center;
|
||
color:var(--dim); font-size:13px; cursor:pointer; }
|
||
#dropzone.hot { border-color:var(--gold); color:var(--gold); }
|
||
#urlrow { display:flex; gap:6px; margin:0 8px 10px; }
|
||
#urlrow input { flex:1; background:var(--bg); border:1px solid var(--line); border-radius:8px;
|
||
color:var(--fg); padding:7px 9px; font:13px system-ui; outline:none; }
|
||
button { background:#2c2820; color:var(--gold); border:1px solid var(--line); border-radius:10px;
|
||
padding:7px 12px; font:600 13px system-ui; cursor:pointer; }
|
||
button:hover { background:#3a3426; }
|
||
button.warn { color:#ff8a7a; }
|
||
#stage { position:fixed; left:320px; right:0; top:0; bottom:0; }
|
||
canvas { display:block; }
|
||
#stats { position:fixed; right:16px; top:16px; background:rgba(20,18,16,.88); border:1px solid var(--line);
|
||
border-radius:12px; padding:12px 14px; font:12px ui-monospace,monospace; color:var(--dim);
|
||
display:none; max-width:280px; z-index:3; white-space:pre-line; }
|
||
#stats b { color:var(--fg); }
|
||
#stats .bad { color:#ff8a7a; font-weight:700; }
|
||
#stats .good { color:#8fe08f; }
|
||
#animbar { position:fixed; left:340px; right:16px; bottom:16px; background:rgba(20,18,16,.88);
|
||
border:1px solid var(--line); border-radius:14px; padding:10px 14px; display:none;
|
||
gap:10px; align-items:center; flex-wrap:wrap; z-index:3; }
|
||
#animbar select { background:var(--bg); color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:6px; }
|
||
#animbar input[type=range] { width:140px; accent-color:var(--gold); }
|
||
#animbar label { color:var(--dim); font-size:12px; }
|
||
#gizmobar button, #spacebar button { padding:5px 9px; font-size:12px; }
|
||
.gz.on, #gzSpace.on { background:var(--gold); color:var(--bg); }
|
||
#login { position:fixed; inset:0; background:var(--bg); z-index:10; display:flex; align-items:center; justify-content:center; }
|
||
#login .box { background:var(--panel); border:1px solid var(--line); border-radius:16px; padding:32px; text-align:center; }
|
||
#login h1 { color:var(--gold); font:800 34px system-ui; margin:0 0 2px; letter-spacing:2px; }
|
||
#login p { color:var(--dim); margin:0 0 18px; font-size:13px; }
|
||
#login input { background:var(--bg); border:1px solid var(--line); border-radius:10px; color:var(--fg);
|
||
padding:10px 12px; font:15px system-ui; outline:none; width:220px; text-align:center; }
|
||
#login .err { color:#ff8a7a; font-size:13px; height:18px; margin-top:8px; }
|
||
#metarow { padding:8px; border-top:1px solid var(--line); display:none; gap:6px; flex-direction:column; }
|
||
#metarow input { background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--fg);
|
||
padding:6px 9px; font:13px system-ui; outline:none; }
|
||
#metarow .btns { display:flex; gap:6px; }
|
||
#toast { position:fixed; left:50%; bottom:70px; transform:translateX(-50%); background:#2c2820;
|
||
border:1px solid var(--gold); color:var(--fg); border-radius:12px; padding:10px 18px;
|
||
display:none; z-index:11; font-size:14px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="login"><div class="box">
|
||
<h1>3GOD</h1><p>the prop house · say the magic word</p>
|
||
<input id="pw" type="password" placeholder="password" autofocus>
|
||
<div class="err" id="loginErr"></div>
|
||
</div></div>
|
||
|
||
<div id="side">
|
||
<h1>3GOD<small>the prop house · one shelf, three worlds</small></h1>
|
||
<div id="dropzone">⬆ drop a .glb or image here<br>(or click to pick)</div>
|
||
<input id="filepick" type="file" accept=".glb,.png,.jpg,.jpeg,.webp" multiple style="display:none">
|
||
<div id="urlrow"><input id="url" placeholder="…or paste a URL (Drive/Dropbox ok)"><button id="fetchBtn">pull</button></div>
|
||
<div id="shelf"></div>
|
||
<div id="metarow">
|
||
<input id="tagsIn" placeholder="tags, comma,separated">
|
||
<input id="noteIn" placeholder="note (who made it, licence…)">
|
||
<div class="btns">
|
||
<button id="saveMeta">💾 save</button>
|
||
<button id="dlBtn">⬇ download</button>
|
||
<button id="delBtn" class="warn">🗑 delete</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="stage"></div>
|
||
<div id="spacebar" style="position:fixed;left:340px;top:16px;z-index:3;display:flex;gap:8px;align-items:center;flex-wrap:wrap;
|
||
background:rgba(20,18,16,.88);border:1px solid var(--line);border-radius:12px;padding:8px 12px">
|
||
<label style="color:var(--dim);font-size:12px">🏬 test space</label>
|
||
<select id="spaceSel" style="background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:6px">
|
||
<option value="none">empty grid</option>
|
||
<option value="shop">op shop (game)</option>
|
||
<option value="street">street</option>
|
||
<option value="home">home base</option>
|
||
</select>
|
||
<button id="rerollBtn" title="another shop's look" style="display:none">🎲 reroll</button>
|
||
</div>
|
||
<div id="gizmobar" style="position:fixed;left:340px;top:60px;z-index:3;display:flex;gap:6px;align-items:center;
|
||
background:rgba(20,18,16,.88);border:1px solid var(--line);border-radius:12px;padding:7px 10px">
|
||
<label style="color:var(--dim);font-size:12px">gizmo</label>
|
||
<button class="gz" data-m="off">none</button>
|
||
<button class="gz" data-m="rotate">🔄 rotate</button>
|
||
<button class="gz" data-m="translate">✥ move</button>
|
||
<button class="gz" data-m="scale">⤢ scale</button>
|
||
<button id="gzSpace" title="local / world axes">local</button>
|
||
<button id="gzReset" title="back to as-loaded">⟲ reset</button>
|
||
<button id="gzBake" title="save this orientation for the games" style="color:var(--gold)">💾 bake</button>
|
||
</div>
|
||
<div id="stats"></div>
|
||
<div id="animbar">
|
||
<label>🎬 clip</label><select id="clipSel"></select>
|
||
<button id="playBtn">⏸</button>
|
||
<label>speed</label><input id="speed" type="range" min="0" max="2" step="0.05" value="1">
|
||
<span id="speedVal" style="color:var(--dim);font-size:12px">1.00×</span>
|
||
<label style="margin-left:12px"><input id="refToggle" type="checkbox" checked> 1.8m digger</label>
|
||
<label><input id="gridToggle" type="checkbox" checked> grid</label>
|
||
</div>
|
||
<div id="toast"></div>
|
||
|
||
<script type="module">
|
||
import * as THREE from 'three';
|
||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||
import { TransformControls } from 'three/addons/controls/TransformControls.js';
|
||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
|
||
|
||
const $ = id => document.getElementById(id);
|
||
const api = (p, body) => fetch(p, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body) } : {}).then(r => r.json());
|
||
function toast(msg, ms = 3000) { const t = $('toast'); t.textContent = msg; t.style.display = 'block';
|
||
clearTimeout(t._h); t._h = setTimeout(() => t.style.display = 'none', ms); }
|
||
|
||
// ---------- login ----------
|
||
$('pw').addEventListener('keydown', async e => {
|
||
if (e.key !== 'Enter') return;
|
||
const r = await api('api/login', { password: $('pw').value });
|
||
if (r.ok) { $('login').style.display = 'none'; loadShelf(); }
|
||
else $('loginErr').textContent = r.error || 'nope';
|
||
});
|
||
|
||
// ---------- the stage ----------
|
||
const stage = $('stage');
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||
renderer.setPixelRatio(devicePixelRatio);
|
||
stage.appendChild(renderer.domElement);
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x141210);
|
||
const camera = new THREE.PerspectiveCamera(55, 1, 0.01, 500);
|
||
camera.position.set(2.2, 1.6, 3);
|
||
const controls = new OrbitControls(camera, renderer.domElement);
|
||
controls.target.set(0, 0.8, 0);
|
||
controls.screenSpacePanning = true; // pan moves in the screen plane (feels right)
|
||
// hold Shift or Option/Alt = pan instead of orbit (trackpad-friendly). Two-finger scroll still zooms.
|
||
const setPan = on => { if (!controls.enabled) return; controls.mouseButtons.LEFT = on ? THREE.MOUSE.PAN : THREE.MOUSE.ROTATE; };
|
||
addEventListener('keydown', e => { if (e.key === 'Shift' || e.key === 'Alt' || e.altKey) setPan(true); });
|
||
addEventListener('keyup', e => { if (!e.shiftKey && !e.altKey) setPan(false); });
|
||
addEventListener('blur', () => setPan(false)); // don't get stuck in pan if focus leaves mid-hold
|
||
scene.add(new THREE.HemisphereLight(0xfff2dd, 0x40382a, 1.1));
|
||
const key = new THREE.DirectionalLight(0xffffff, 1.6); key.position.set(4, 6, 3); scene.add(key);
|
||
|
||
// ---------- Blender-style transform gizmo (rotate / move / scale on any axis) ----------
|
||
const gizmo = new TransformControls(camera, renderer.domElement);
|
||
gizmo.setSize(0.8); gizmo.setSpace('local');
|
||
gizmo.addEventListener('dragging-changed', e => controls.enabled = !e.value); // don't orbit while dragging a ring
|
||
scene.add(gizmo.getHelper ? gizmo.getHelper() : gizmo); // r169+ needs the helper
|
||
gizmo.getHelper && (gizmo.getHelper().visible = false);
|
||
function gzMode(m) {
|
||
document.querySelectorAll('.gz').forEach(b => b.classList.toggle('on', b.dataset.m === m));
|
||
const helper = gizmo.getHelper ? gizmo.getHelper() : gizmo;
|
||
if (m === 'off' || !model) { gizmo.detach(); helper.visible = false; return; }
|
||
gizmo.setMode(m); gizmo.attach(model); helper.visible = true;
|
||
}
|
||
document.querySelectorAll('.gz').forEach(b => b.onclick = () => gzMode(b.dataset.m));
|
||
$('gzSpace').onclick = () => { const w = gizmo.space === 'local'; gizmo.setSpace(w ? 'world' : 'local');
|
||
$('gzSpace').textContent = w ? 'world' : 'local'; $('gzSpace').classList.toggle('on', w); };
|
||
$('gzReset').onclick = () => { if (!model) return;
|
||
model.rotation.set(0, 0, 0); model.scale.set(1, 1, 1); reground(); };
|
||
$('gzBake').onclick = async () => {
|
||
if (!current || !model) return;
|
||
const e = model.rotation, s = model.scale;
|
||
await api('api/meta', { file: current, rot: [+e.x.toFixed(4), +e.y.toFixed(4), +e.z.toFixed(4)],
|
||
scale: [+s.x.toFixed(4), +s.y.toFixed(4), +s.z.toFixed(4)] });
|
||
toast('💾 orientation baked — the games will load it this way'); loadShelf();
|
||
};
|
||
function reground() { // keep the model planted after a transform
|
||
if (!model) return;
|
||
const b = new THREE.Box3().setFromObject(model);
|
||
model.position.y -= b.min.y;
|
||
}
|
||
|
||
const grid = new THREE.GridHelper(20, 20, 0x4a4130, 0x2c2820);
|
||
scene.add(grid);
|
||
|
||
// the 1.8m reference digger — scale truth, checklist item 3
|
||
const ref = new THREE.Group();
|
||
{
|
||
const mat = new THREE.MeshStandardMaterial({ color: 0xffd75e, transparent: true, opacity: 0.28, roughness: 0.9 });
|
||
const body = new THREE.Mesh(new THREE.CapsuleGeometry(0.22, 1.05, 6, 12), mat);
|
||
body.position.y = 0.75;
|
||
const head = new THREE.Mesh(new THREE.SphereGeometry(0.13, 12, 10), mat);
|
||
head.position.y = 1.63;
|
||
ref.add(body, head);
|
||
const ring = new THREE.Mesh(new THREE.RingGeometry(0.3, 0.34, 24),
|
||
new THREE.MeshBasicMaterial({ color: 0xffd75e, transparent: true, opacity: 0.25, side: THREE.DoubleSide }));
|
||
ring.rotation.x = -Math.PI / 2; ring.position.y = 0.01;
|
||
ref.add(ring);
|
||
ref.position.x = -1.2;
|
||
scene.add(ref);
|
||
}
|
||
$('refToggle').onchange = e => ref.visible = e.target.checked;
|
||
$('gridToggle').onchange = e => grid.visible = e.target.checked;
|
||
|
||
// ---------- context test spaces: see the prop where it'll actually live ----------
|
||
let space = null;
|
||
function wallpaperTex(c1, c2) { // little canvas dot-pattern — period-right, zero downloads
|
||
const c = document.createElement('canvas'); c.width = c.height = 128;
|
||
const g = c.getContext('2d');
|
||
g.fillStyle = c1; g.fillRect(0, 0, 128, 128);
|
||
g.fillStyle = c2;
|
||
for (let y = 8; y < 128; y += 24) for (let x = 8; x < 128; x += 24) {
|
||
g.beginPath(); g.arc(x + (y % 48 ? 6 : 0), y, 3, 0, 7); g.fill();
|
||
}
|
||
const t = new THREE.CanvasTexture(c);
|
||
t.wrapS = t.wrapT = THREE.RepeatWrapping; t.colorSpace = THREE.SRGBColorSpace;
|
||
return t;
|
||
}
|
||
const smat = (c, r = 0.85) => new THREE.MeshStandardMaterial({ color: c, roughness: r });
|
||
function sbox(w, h, d, m, x, y, z, g) { const b = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); b.position.set(x, y, z); g.add(b); return b; }
|
||
function wallsOf(g, W, D, H, wp) {
|
||
wp.repeat.set(W / 2.5, H / 2.5);
|
||
const wm = new THREE.MeshStandardMaterial({ map: wp, roughness: 0.95 });
|
||
sbox(W, H, 0.08, wm, 0, H / 2, -D / 2, g);
|
||
sbox(0.08, H, D, wm.clone(), -W / 2, H / 2, 0, g);
|
||
sbox(0.08, H, D, wm.clone(), W / 2, H / 2, 0, g);
|
||
sbox(W, 0.06, D, smat(0xd8d2c4, 1), 0, H, 0, g); // ceiling
|
||
}
|
||
// ---------- the ACTUAL game op-shop shell (ported from THRIFTGOD buildShop; real textures) ----------
|
||
const ASSET = 'https://digalot.fyi/assets/gen/'; // same origin in prod; 404→colour fallback locally
|
||
const gtex = new THREE.TextureLoader();
|
||
const G_WALLPAPERS = ['floral-cream', 'stripe-sage', 'damask-mauve', 'geo-orange', 'trellis-blue', 'woodchip-white', 'floral-gold', 'diamond-green'];
|
||
const G_FLOORS = ['carpet-swirl', 'carpet-mustard', 'carpet-greygreen', 'lino-check', 'lino-cork'];
|
||
const G_ART = ['velvet-elvis', 'sad-clown', 'big-eye-kid', 'tapestry-horse', 'brown-landscape', 'macrame-owl', 'ship', 'blue-boy', 'last-supper', 'kookaburra', 'gumtree', 'cat-portrait', 'crying-boy', 'spanish-lady', 'poker-dogs', 'sunset-beach'];
|
||
const G_CARPETS = ['#6b5b45', '#5d6b45', '#75565a', '#4f6270', '#6b6045'];
|
||
const G_WALLS = ['#cfc4ae', '#c9d2c4', '#d6c9c9', '#c4ccd2', '#d2cbb8'];
|
||
const G_SHAPES = [[7, 9, 8, 10, 3.0], [6, 7, 11, 14, 3.0], [10, 12, 7, 9, 3.2], [9, 11, 10, 13, 3.5], [6, 7, 7, 8, 2.6]];
|
||
function mul32(a) { return () => { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
|
||
let shopSeed = 12345;
|
||
function gameShop(seed, g) {
|
||
const rnd = mul32(seed);
|
||
const sh = G_SHAPES[Math.floor(rnd() * G_SHAPES.length)];
|
||
const W = sh[0] + Math.round(rnd() * (sh[1] - sh[0])), D = sh[2] + Math.round(rnd() * (sh[3] - sh[2])), H = sh[4];
|
||
const carpet = G_CARPETS[Math.floor(rnd() * G_CARPETS.length)], wallC = G_WALLS[Math.floor(rnd() * G_WALLS.length)];
|
||
const floorName = G_FLOORS[Math.floor(rnd() * G_FLOORS.length)], paperName = G_WALLPAPERS[Math.floor(rnd() * G_WALLPAPERS.length)];
|
||
const floorMat = smat(carpet, 0.95);
|
||
gtex.load(ASSET + 'tex-' + floorName + '.jpg?v=1', tx => { tx.wrapS = tx.wrapT = THREE.RepeatWrapping; tx.repeat.set(Math.ceil(W / 3), Math.ceil(D / 3)); tx.colorSpace = THREE.SRGBColorSpace; floorMat.map = tx; floorMat.color.set(0xbbbbbb); floorMat.needsUpdate = true; }, undefined, () => {});
|
||
const floor = new THREE.Mesh(new THREE.PlaneGeometry(W, D), floorMat); floor.rotation.x = -Math.PI / 2; g.add(floor);
|
||
const ceil = new THREE.Mesh(new THREE.PlaneGeometry(W, D), smat('#d9d4c8', 1)); ceil.rotation.x = Math.PI / 2; ceil.position.y = H; g.add(ceil);
|
||
[[0, -D / 2, 0, W], [0, D / 2, Math.PI, W], [-W / 2, 0, Math.PI / 2, D], [W / 2, 0, -Math.PI / 2, D]].forEach(([x, z, ry, w]) => {
|
||
const m = new THREE.MeshStandardMaterial({ color: new THREE.Color(wallC), roughness: 0.9 });
|
||
gtex.load(ASSET + 'wall-' + paperName + '.jpg?v=1', tx => { tx.wrapS = tx.wrapT = THREE.RepeatWrapping; tx.repeat.set(w / 2.5, H / 2.5); tx.colorSpace = THREE.SRGBColorSpace; m.map = tx; m.color.set(0xcccccc); m.needsUpdate = true; }, undefined, () => {});
|
||
const me = new THREE.Mesh(new THREE.PlaneGeometry(w, H), m); me.position.set(x, H / 2, z); me.rotation.y = ry; g.add(me);
|
||
});
|
||
const nArt = 2 + Math.floor(rnd() * 3); // seeded op-shop kitsch (velvet elvis, sad clown…)
|
||
for (let i = 0; i < nArt; i++) {
|
||
const west = rnd() < 0.5, zz = -D / 2 + 2 + rnd() * (D - 4);
|
||
const am = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.7 });
|
||
gtex.load(ASSET + 'art-' + G_ART[Math.floor(rnd() * G_ART.length)] + '.jpg?v=1', tx => { tx.colorSpace = THREE.SRGBColorSpace; am.map = tx; am.needsUpdate = true; }, undefined, () => {});
|
||
const sz = 0.8 + rnd() * 0.5;
|
||
const pl = new THREE.Mesh(new THREE.PlaneGeometry(sz, sz), am);
|
||
pl.position.set(west ? -W / 2 + 0.03 : W / 2 - 0.03, 1.5 + rnd() * 0.5, zz);
|
||
pl.rotation.y = west ? Math.PI / 2 : -Math.PI / 2; pl.rotation.z = (rnd() - 0.5) * 0.06; g.add(pl);
|
||
}
|
||
sbox(0.7, 1.0, 2.2, smat('#5d7a8c', 0.6), W / 2 - 0.9, 0.5, D / 4, g); // the counter, east wall
|
||
g.add(new THREE.AmbientLight(0xfff4de, 0.85));
|
||
[[-W / 4, -D / 4], [W / 4, -D / 4], [-W / 4, D / 4], [W / 4, D / 4]].forEach(([x, z]) => {
|
||
const l = new THREE.PointLight(0xfff0d0, 22, 10, 1.4); l.position.set(x, H - 0.25, z); g.add(l);
|
||
sbox(0.7, 0.06, 0.18, smat('#f5f2ea', 0.4), x, H - 0.06, z, g);
|
||
});
|
||
}
|
||
function setSpace(kind) {
|
||
if (space) { scene.remove(space); space = null; }
|
||
grid.visible = kind === 'none' && $('gridToggle').checked;
|
||
scene.background = new THREE.Color(kind === 'street' ? 0x87b5d5 : 0x141210);
|
||
$('rerollBtn').style.display = kind === 'shop' ? 'inline-block' : 'none';
|
||
if (kind === 'none') return;
|
||
space = new THREE.Group();
|
||
if (kind === 'shop') {
|
||
gameShop(shopSeed, space); // the real game shell, real wallpaper/carpet/art
|
||
} else if (kind === 'street') {
|
||
sbox(30, 0.06, 4, smat(0x9a938a, 0.95), 0, -0.031, 0.6, space); // footpath under the prop
|
||
sbox(30, 0.05, 7, smat(0x45454a, 0.95), 0, -0.04, -4.9, space); // the road
|
||
sbox(30, 0.06, 3, smat(0x9a938a, 0.95), 0, -0.031, -9.9, space); // far footpath
|
||
[[-4.5, 0x8a4a3a], [0, 0x4a6a5a], [4.5, 0x7a6a3a]].forEach(([x, c]) => {
|
||
sbox(4.2, 3.4, 0.4, smat(c, 0.9), x, 1.7, -11.6, space); // shops across the road — a backdrop, not a blindfold
|
||
sbox(4.4, 0.12, 1.5, smat(0xd8d2c4, 0.8), x, 2.5, -10.7, space); // awning
|
||
sbox(3.0, 1.4, 0.05, new THREE.MeshStandardMaterial({ color: 0x223038, roughness: 0.1, metalness: 0.4 }), x, 1.15, -11.38, space);
|
||
});
|
||
const sun = new THREE.DirectionalLight(0xfff2dd, 1.2); sun.position.set(-6, 10, 4); space.add(sun);
|
||
} else if (kind === 'home') {
|
||
sbox(5, 0.05, 4, smat(0x5c6a4a, 0.95), 0, -0.026, 0, space); // that green carpet
|
||
wallsOf(space, 5, 4, 2.5, wallpaperTex('#7a8a6a', '#5a6a4c'));
|
||
sbox(1.9, 0.5, 1.0, smat(0x6a4a3a), -1.4, 0.25, -1.3, space); // bed
|
||
sbox(1.9, 0.16, 1.0, smat(0xb08a8a, 0.9), -1.4, 0.58, -1.3, space); // doona
|
||
sbox(0.45, 0.1, 0.7, smat(0xf0e8dc, 0.9), -2.05, 0.71, -1.3, space);// pillow
|
||
sbox(1.2, 0.75, 0.6, smat(0x7a5c46), 1.6, 0.375, -1.55, space); // desk
|
||
const lamp = new THREE.PointLight(0xffd9a0, 8, 6); lamp.position.set(1.6, 1.4, -1.4); space.add(lamp);
|
||
}
|
||
scene.add(space);
|
||
}
|
||
$('spaceSel').onchange = e => setSpace(e.target.value);
|
||
$('rerollBtn').onclick = () => { shopSeed = (Math.random() * 1e9) | 0; setSpace('shop'); };
|
||
|
||
function fit() {
|
||
const w = innerWidth - 320, h = innerHeight;
|
||
renderer.setSize(w, h);
|
||
camera.aspect = w / h; camera.updateProjectionMatrix();
|
||
}
|
||
addEventListener('resize', fit); fit();
|
||
|
||
const clock = new THREE.Clock();
|
||
let mixer = null, action = null, current = null, model = null, easel = null;
|
||
const isImg = f => /\.(png|jpe?g|webp)$/i.test(f);
|
||
(function tick() {
|
||
requestAnimationFrame(tick);
|
||
if (mixer) mixer.update(clock.getDelta()); else clock.getDelta();
|
||
controls.update();
|
||
renderer.render(scene, camera);
|
||
})();
|
||
|
||
// ---------- loading a prop ----------
|
||
const draco = new DRACOLoader();
|
||
draco.setDecoderPath('https://unpkg.com/three@0.175.0/examples/jsm/libs/draco/');
|
||
const loader = new GLTFLoader();
|
||
loader.setDRACOLoader(draco);
|
||
|
||
async function show(file) {
|
||
if (isImg(file)) return showImg(file);
|
||
current = file;
|
||
document.querySelectorAll('.prop').forEach(el => el.classList.toggle('sel', el.dataset.f === file));
|
||
if (model) { scene.remove(model); model = null; }
|
||
if (easel) { scene.remove(easel); easel = null; }
|
||
gzMode('off'); // drop the gizmo off the old model
|
||
mixer = action = null;
|
||
$('animbar').style.display = 'none';
|
||
$('stats').style.display = 'block';
|
||
$('stats').innerHTML = 'loading…';
|
||
let gltf;
|
||
try { gltf = await loader.loadAsync('a/' + encodeURIComponent(file)); }
|
||
catch (e) { $('stats').innerHTML = `<span class="bad">failed to load: ${e.message || e}</span>`; return; }
|
||
model = gltf.scene;
|
||
const saved = SHELF.find(a => a.file === file) || {};
|
||
if (saved.rot) model.rotation.set(saved.rot[0], saved.rot[1], saved.rot[2]); // baked orientation
|
||
if (saved.scale) model.scale.set(saved.scale[0], saved.scale[1], saved.scale[2]);
|
||
scene.add(model);
|
||
|
||
// measure — the acceptance checklist, automated
|
||
const box = new THREE.Box3().setFromObject(model);
|
||
const dim = box.getSize(new THREE.Vector3());
|
||
let tris = 0, texes = new Set();
|
||
model.traverse(o => {
|
||
if (o.isMesh) {
|
||
tris += (o.geometry.index ? o.geometry.index.count : o.geometry.attributes.position.count) / 3;
|
||
const m = o.material;
|
||
[].concat(m).forEach(mm => ['map', 'normalMap', 'roughnessMap', 'metalnessMap'].forEach(k => {
|
||
if (mm && mm[k] && mm[k].image) texes.add(`${mm[k].image.width}×${mm[k].image.height}`);
|
||
}));
|
||
}
|
||
});
|
||
tris = Math.round(tris);
|
||
const clips = gltf.animations || [];
|
||
const isChar = clips.length > 0;
|
||
const triCap = isChar ? 15000 : 5000;
|
||
const originOff = Math.abs(box.min.y) > dim.y * 0.15 && Math.abs(box.min.y) > 0.05;
|
||
const fmt = n => n >= 1000 ? (n / 1000).toFixed(1) + 'k' : n;
|
||
const m = v => v.toFixed(2) + 'm';
|
||
$('stats').innerHTML =
|
||
`<b>${file}</b>\n` +
|
||
`size ${m(dim.x)} × ${m(dim.y)} × ${m(dim.z)} ` +
|
||
(dim.y > 12 || Math.max(dim.x, dim.y, dim.z) < 0.03 ? '<span class="bad">scale sus — check §0.3</span>' : '<span class="good">✓</span>') + '\n' +
|
||
`tris ${fmt(tris)} / ${fmt(triCap)} ` + (tris <= triCap ? '<span class="good">✓</span>' : '<span class="bad">heavy — decimate</span>') + '\n' +
|
||
`origin at base ` + (originOff ? '<span class="bad">off — floats/sinks ' + m(box.min.y) + '</span>' : '<span class="good">✓</span>') + '\n' +
|
||
`textures ${texes.size ? [...texes].join(', ') : 'none/vertex colours'}\n` +
|
||
`clips ${clips.length ? clips.map(c => c.name).join(', ') : 'none (static prop)'}`;
|
||
|
||
// sit it on the floor next to the reference digger
|
||
model.position.y -= box.min.y;
|
||
|
||
// frame the camera
|
||
const r = Math.max(dim.x, dim.y, dim.z);
|
||
camera.position.set(r * 1.4 + 0.8, r * 0.9 + 0.6, r * 1.6 + 1);
|
||
controls.target.set(0, dim.y / 2, 0);
|
||
|
||
// animations — the three.js-example experience
|
||
if (clips.length) {
|
||
mixer = new THREE.AnimationMixer(model);
|
||
const sel = $('clipSel');
|
||
sel.innerHTML = clips.map((c, i) => `<option value="${i}">${c.name || 'clip ' + i}</option>`).join('');
|
||
const play = i => { if (action) action.stop(); action = mixer.clipAction(clips[i]); action.play(); $('playBtn').textContent = '⏸'; };
|
||
sel.onchange = () => play(+sel.value);
|
||
$('playBtn').onclick = () => { if (!action) return;
|
||
action.paused = !action.paused; $('playBtn').textContent = action.paused ? '▶' : '⏸'; };
|
||
$('speed').oninput = e => { mixer.timeScale = +e.target.value; $('speedVal').textContent = (+e.target.value).toFixed(2) + '×'; };
|
||
mixer.timeScale = +$('speed').value;
|
||
play(0);
|
||
$('animbar').style.display = 'flex';
|
||
}
|
||
|
||
// cache the measurements on the shelf card
|
||
api('api/meta', { file, tris, dims: [+dim.x.toFixed(2), +dim.y.toFixed(2), +dim.z.toFixed(2)],
|
||
clips: clips.map(c => c.name) }).then(loadShelf);
|
||
|
||
// meta editor
|
||
$('metarow').style.display = 'flex';
|
||
const row = SHELF.find(a => a.file === file) || {};
|
||
$('tagsIn').value = (row.tags || []).join(', ');
|
||
$('noteIn').value = row.note || '';
|
||
|
||
// auto-thumbnail: give textures a beat to decode, then snapshot the framed view
|
||
setTimeout(() => {
|
||
if (current !== file || !model) return; // already moved on
|
||
renderer.render(scene, camera);
|
||
const src = renderer.domElement, sq = Math.min(src.width, src.height);
|
||
const c = document.createElement('canvas'); c.width = c.height = 256;
|
||
c.getContext('2d').drawImage(src, (src.width - sq) / 2, (src.height - sq) / 2, sq, sq, 0, 0, 256, 256);
|
||
c.toBlob(b => b && fetch('api/thumb?name=' + encodeURIComponent(file), { method: 'POST', body: b })
|
||
.then(r => r.json()).then(r => { if (r.ok && !row.thumb) loadShelf(); }).catch(() => {}), 'image/jpeg', 0.75);
|
||
}, 700);
|
||
}
|
||
|
||
// ---------- images: skins & the easel ----------
|
||
async function showImg(file) {
|
||
current = file;
|
||
document.querySelectorAll('.prop').forEach(el => el.classList.toggle('sel', el.dataset.f === file));
|
||
let tex;
|
||
try { tex = await new THREE.TextureLoader().loadAsync('a/' + encodeURIComponent(file)); }
|
||
catch (e) { toast('⚠️ image failed to load'); return; }
|
||
tex.colorSpace = THREE.SRGBColorSpace;
|
||
tex.flipY = false; // glTF UV convention — skins land right-side-up
|
||
if (model) { // paint the loaded model
|
||
let n = 0;
|
||
model.traverse(o => { if (o.isMesh) [].concat(o.material).forEach(m => {
|
||
m.map = tex; m.needsUpdate = true; n++; }); });
|
||
toast(`🎨 skinned ${n} material${n === 1 ? '' : 's'} — reclick the model to reset`);
|
||
} else { // nothing loaded → the easel
|
||
if (easel) scene.remove(easel);
|
||
const et = tex.clone(); et.flipY = true; et.needsUpdate = true;
|
||
const a = tex.image.width / tex.image.height;
|
||
easel = new THREE.Mesh(new THREE.PlaneGeometry(a, 1),
|
||
new THREE.MeshBasicMaterial({ map: et, side: THREE.DoubleSide }));
|
||
easel.position.set(0, 0.75, 0);
|
||
scene.add(easel);
|
||
controls.target.set(0, 0.75, 0);
|
||
}
|
||
$('stats').style.display = 'block';
|
||
$('stats').innerHTML = `<b>${file}</b>\n${tex.image.width}×${tex.image.height}px · ` +
|
||
(model ? 'applied as skin' : 'on the easel — load a model then reclick to skin it');
|
||
$('metarow').style.display = 'flex';
|
||
const row = SHELF.find(x => x.file === file) || {};
|
||
$('tagsIn').value = (row.tags || []).join(', ');
|
||
$('noteIn').value = row.note || '';
|
||
}
|
||
|
||
// ---------- the shelf ----------
|
||
let SHELF = [];
|
||
async function loadShelf() {
|
||
const r = await api('api/list');
|
||
if (!r.authed) return; // list is public; the page isn't useful till login
|
||
SHELF = r.assets;
|
||
const kb = n => n > 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.round(n / 1024) + ' KB';
|
||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
$('shelf').innerHTML = SHELF.map(a => `
|
||
<div class="prop${a.file === current ? ' sel' : ''}" data-f="${a.file}">
|
||
${isImg(a.file) ? `<img src="a/${encodeURIComponent(a.file)}" style="height:46px;border-radius:6px;display:block;margin-bottom:3px">`
|
||
: a.thumb ? `<img src="t/${encodeURIComponent(a.file)}?v=${Date.now()}" style="height:46px;border-radius:6px;display:block;margin-bottom:3px">` : ''}
|
||
<b>${a.file}</b>
|
||
<div class="sub">${kb(a.size)}${isImg(a.file) ? ' · 🎨 skin' : ''}${a.tris ? ' · ' + (a.tris >= 1000 ? (a.tris / 1000).toFixed(1) + 'k' : a.tris) + ' tris' : ''}${a.clips && a.clips.length ? ' · 🎬 ' + a.clips.length : ''}</div>
|
||
<div>${(a.tags || []).map(t => `<span class="tag">${esc(t)}</span>`).join('')}</div>
|
||
</div>`).join('') || '<div style="color:var(--dim);padding:14px;text-align:center">the shelf is bare —<br>drop your first .glb up top</div>';
|
||
document.querySelectorAll('.prop').forEach(el => el.onclick = () => show(el.dataset.f));
|
||
}
|
||
|
||
// ---------- upload: drag-drop / picker / URL ----------
|
||
async function upload(file) {
|
||
if (!/\.(glb|png|jpe?g|webp)$/i.test(file.name)) { toast('.glb or .png/.jpg/.webp only, digger'); return; }
|
||
toast(`uploading ${file.name}…`, 60000);
|
||
const r = await fetch('api/upload?name=' + encodeURIComponent(file.name), { method: 'POST', body: file }).then(r => r.json());
|
||
toast(r.error ? '⚠️ ' + r.error : `✓ ${r.file} is on the shelf`);
|
||
if (!r.error) { await loadShelf(); show(r.file); }
|
||
}
|
||
const dz = $('dropzone');
|
||
dz.onclick = () => $('filepick').click();
|
||
$('filepick').onchange = e => [...e.target.files].forEach(upload);
|
||
dz.ondragover = e => { e.preventDefault(); dz.classList.add('hot'); };
|
||
dz.ondragleave = () => dz.classList.remove('hot');
|
||
dz.ondrop = e => { e.preventDefault(); dz.classList.remove('hot'); [...e.dataTransfer.files].forEach(upload); };
|
||
addEventListener('dragover', e => e.preventDefault());
|
||
addEventListener('drop', e => e.preventDefault());
|
||
|
||
$('fetchBtn').onclick = async () => {
|
||
const url = $('url').value.trim();
|
||
if (!url) return;
|
||
const name = prompt('name it (something.glb):', url.split('/').pop().split('?')[0] || 'prop.glb');
|
||
if (!name) return;
|
||
toast('pulling from the cloud…', 60000);
|
||
const r = await api('api/fetch', { url, name });
|
||
toast(r.error ? '⚠️ ' + r.error : `✓ ${r.file} is on the shelf`);
|
||
if (!r.error) { $('url').value = ''; await loadShelf(); show(r.file); }
|
||
};
|
||
|
||
// ---------- meta / download / delete ----------
|
||
$('saveMeta').onclick = async () => {
|
||
if (!current) return;
|
||
await api('api/meta', { file: current, tags: $('tagsIn').value.split(',').map(s => s.trim()).filter(Boolean),
|
||
note: $('noteIn').value.trim() });
|
||
toast('💾 saved'); loadShelf();
|
||
};
|
||
$('dlBtn').onclick = () => current && (location.href = 'dl/' + encodeURIComponent(current));
|
||
$('delBtn').onclick = async () => {
|
||
if (!current || !confirm(`really bin ${current}?`)) return;
|
||
await api('api/del', { file: current });
|
||
toast(`🗑 ${current} binned`);
|
||
if (model) { scene.remove(model); model = null; }
|
||
if (easel) { scene.remove(easel); easel = null; }
|
||
$('stats').style.display = $('animbar').style.display = 'none'; $('metarow').style.display = 'none';
|
||
current = null; loadShelf();
|
||
};
|
||
|
||
// already logged in? (cookie survives)
|
||
api('api/list').then(r => { if (r.authed) { $('login').style.display = 'none'; loadShelf(); } });
|
||
</script>
|
||
</body>
|
||
</html>
|