Kit thumbnails + prop colliders
js/kitthumbs.js renders each kit GLB to a 128x96 sprite once (offscreen renderer, framed by bounding sphere), POSTs to /api/kit-thumb, and all 52 are committed under assets/kit_thumbs/ so every checkout gets instant thumbs; the generator only runs for props without one (future kit drops). KIT drawer shows them with category groups and filter. js/colliders.js derives world-space colliders from kit manifest hints + a table for builtin props (bins, planters, trunks, fences...). Test ride resolves circle-vs-box/circle push-out — you scrub and stop instead of riding through furniture. Exports gain an additive COLLIDERS array for bookquoy to adopt when its player is ready; existing contract untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BIN
assets/kit_thumbs/bank_12.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/kit_thumbs/bench_concrete.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
assets/kit_thumbs/bench_park.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
assets/kit_thumbs/bin_council.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
assets/kit_thumbs/bleachers_3.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
assets/kit_thumbs/bollard_steel.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
assets/kit_thumbs/box_low.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
assets/kit_thumbs/box_table.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
assets/kit_thumbs/drain_grate.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
assets/kit_thumbs/euro_gap.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
assets/kit_thumbs/fence_chain_3m.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
assets/kit_thumbs/floodlight.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/kit_thumbs/funbox_ply.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
assets/kit_thumbs/grindbox_ply.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
assets/kit_thumbs/hubba_down.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
assets/kit_thumbs/hydrant.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
assets/kit_thumbs/jersey_barrier.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
assets/kit_thumbs/kerb_3m.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/kit_thumbs/kicker.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/kit_thumbs/launch_high.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/kit_thumbs/ledge_concrete.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
assets/kit_thumbs/manny_incline.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
assets/kit_thumbs/manual_pad.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
assets/kit_thumbs/mound_dirt.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
assets/kit_thumbs/parking_block.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
assets/kit_thumbs/picnic_table.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
assets/kit_thumbs/pipe_full.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/kit_thumbs/planter_box.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
assets/kit_thumbs/pole_jam.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
assets/kit_thumbs/pool_edge.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
assets/kit_thumbs/pyramid.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
assets/kit_thumbs/qp_18.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/kit_thumbs/qp_24.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/kit_thumbs/qp_vert.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/kit_thumbs/rail_aframe.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/kit_thumbs/rail_donkey.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/kit_thumbs/rail_down.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/kit_thumbs/rail_flat_3m.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/kit_thumbs/rail_flat_6m.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/kit_thumbs/rail_kink.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/kit_thumbs/rail_kink2.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/kit_thumbs/rail_rainbow.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/kit_thumbs/railing_ped_2m.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
assets/kit_thumbs/roller.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
assets/kit_thumbs/shade_sail.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
assets/kit_thumbs/spine_ply.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
assets/kit_thumbs/square_flatbar.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
assets/kit_thumbs/tree_fig.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
assets/kit_thumbs/tree_gum.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
assets/kit_thumbs/volcano.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
assets/kit_thumbs/wall_graffiti.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
assets/kit_thumbs/wallride_qp.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
@ -74,6 +74,7 @@
|
||||
.mbitem{display:flex;align-items:center;gap:6px;background:var(--panel);
|
||||
border:1px solid var(--line);border-radius:5px;padding:5px 8px}
|
||||
.mbname{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px}
|
||||
.kitthumb{width:44px;height:33px;object-fit:contain;border-radius:3px;background:#141a21;flex-shrink:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
86
js/colliders.js
Normal file
@ -0,0 +1,86 @@
|
||||
// SKATEMAKER PRO — prop colliders. Furniture you can't ride through: bins, planters,
|
||||
// tree trunks, bollards. Derived (never stored): kit props carry a `collider` hint in
|
||||
// the manifest; builtin props have a table here. Test ride resolves rider-vs-collider
|
||||
// as 2D circle-vs-shape push-out, and exports embed the world-space list as COLLIDERS
|
||||
// so the game can adopt the same rule when it's ready (additive — nothing existing
|
||||
// in the level.js contract changes).
|
||||
//
|
||||
// world entry: { type:'box', x, z, rot, hw, hd, h } | { type:'circle', x, z, r, h }
|
||||
|
||||
export const BUILTIN_COLLIDERS = {
|
||||
jersey: { type: 'box', hw: 0.31, hd: 1.0, h: 0.94 },
|
||||
picnic: { type: 'box', hw: 0.8, hd: 0.77, h: 0.78 },
|
||||
bench: { type: 'box', hw: 0.8, hd: 0.22, h: 0.75 },
|
||||
trashcan: { type: 'circle', r: 0.36, h: 0.96 },
|
||||
hydrant: { type: 'circle', r: 0.18, h: 0.78 },
|
||||
fence: { type: 'box', hw: 1.2, hd: 0.06, h: 1.85 },
|
||||
floodlight: { type: 'circle', r: 0.13, h: 6.5 },
|
||||
bleachers: { type: 'box', hw: 1.5, hd: 0.84, h: 1.0 },
|
||||
fountain: { type: 'circle', r: 0.23, h: 0.9 },
|
||||
fig: { type: 'circle', r: 0.45, h: 2.6 },
|
||||
// parkingblock / grate / shadesail posts: low or sparse — ride over/through is fine
|
||||
};
|
||||
|
||||
// kit manifest collider -> normalized local shape
|
||||
function kitShape(c) {
|
||||
if (!c || c.type === 'none') return null;
|
||||
if (c.type === 'cylinder') return { type: 'circle', r: c.r, h: c.h };
|
||||
if (c.type === 'box') return { type: 'box', hw: c.hw, hd: c.hd, h: c.h };
|
||||
return null;
|
||||
}
|
||||
|
||||
// all world-space colliders for a park. kitMap: id -> manifest def (or null/empty)
|
||||
export function propColliders(park, kitMap = {}) {
|
||||
const out = [];
|
||||
for (const pr of park.props || []) {
|
||||
let shape = null;
|
||||
if (pr.src.startsWith('builtin:')) shape = BUILTIN_COLLIDERS[pr.src.slice(8)];
|
||||
else if (pr.src.startsWith('kit:')) shape = kitShape(kitMap[pr.src.slice(4)]?.collider);
|
||||
if (!shape) continue;
|
||||
const sc = pr.scale || 1;
|
||||
const e = { ...shape, x: pr.x, z: pr.z, rot: pr.rot || 0, h: shape.h * sc };
|
||||
if (e.type === 'circle') e.r *= sc; else { e.hw *= sc; e.hd *= sc; }
|
||||
out.push(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// push a rider circle (px, pz, radius R, foot height y) out of colliders.
|
||||
// returns null (no contact) or the corrected [x, z].
|
||||
export function resolveCollision(colliders, px, pz, R, y) {
|
||||
let hit = null;
|
||||
for (const c of colliders) {
|
||||
if (y > c.h) continue; // sailing over it
|
||||
if (c.type === 'circle') {
|
||||
const dx = px - c.x, dz = pz - c.z;
|
||||
const d = Math.hypot(dx, dz), min = c.r + R;
|
||||
if (d < min) {
|
||||
const k = d > 1e-4 ? (min / d) : 1, nx = d > 1e-4 ? dx / d : 1;
|
||||
px = c.x + (d > 1e-4 ? dx * k : min); pz = c.z + (d > 1e-4 ? dz * k : 0);
|
||||
hit = true;
|
||||
}
|
||||
} else {
|
||||
const co = Math.cos(c.rot), sn = Math.sin(c.rot);
|
||||
const dx = px - c.x, dz = pz - c.z;
|
||||
const lu = dx * co - dz * sn, lv = dx * sn + dz * co; // three Y-rot inverse
|
||||
const cu = Math.max(-c.hw, Math.min(c.hw, lu));
|
||||
const cv = Math.max(-c.hd, Math.min(c.hd, lv));
|
||||
const du = lu - cu, dv = lv - cv;
|
||||
const d = Math.hypot(du, dv);
|
||||
if (d < R) {
|
||||
let nu, nv;
|
||||
if (d > 1e-4) { nu = du / d; nv = dv / d; }
|
||||
else { // inside: push out shortest face
|
||||
const eu = c.hw - Math.abs(lu), ev = c.hd - Math.abs(lv);
|
||||
if (eu < ev) { nu = Math.sign(lu) || 1; nv = 0; }
|
||||
else { nu = 0; nv = Math.sign(lv) || 1; }
|
||||
}
|
||||
const ou = cu + nu * R, ov = cv + nv * R;
|
||||
px = c.x + ou * co + ov * sn;
|
||||
pz = c.z - ou * sn + ov * co;
|
||||
hit = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return hit ? [px, pz] : null;
|
||||
}
|
||||
11
js/export.js
@ -19,7 +19,10 @@ export function downloadParkJSON(park) {
|
||||
download(slug(park) + '.json', JSON.stringify(park, null, 1), 'application/json');
|
||||
}
|
||||
|
||||
export function generateLevelJS(park) {
|
||||
import { propColliders } from './colliders.js';
|
||||
|
||||
export function generateLevelJS(park, kitMap = {}) {
|
||||
const colliders = propColliders(park, kitMap);
|
||||
return `// ${park.name} — generated by SKATEMAKER PRO ${new Date().toISOString().slice(0, 10)}
|
||||
// Drop-in for BOOKQUOY: copy this file + parkmath.js + parkbuild.js (+ assets/uploads/)
|
||||
// into bookquoy/js/ and point main.js/player.js imports at it. Same contract as level.js:
|
||||
@ -36,6 +39,8 @@ export const LETTERS = PARK.letters;
|
||||
export const SPAWN = PARK.spawn;
|
||||
export const heightAt = makeHeightAt(PARK);
|
||||
export const normalAt = makeNormalAt(heightAt);
|
||||
// world-space prop colliders (bins, planters, trunks) — adopt when the player is ready
|
||||
export const COLLIDERS = ${JSON.stringify(colliders)};
|
||||
|
||||
export function buildLevel(scene) {
|
||||
const { ground, letterMeshes } = buildParkScene(scene, PARK);
|
||||
@ -44,6 +49,6 @@ export function buildLevel(scene) {
|
||||
`;
|
||||
}
|
||||
|
||||
export function downloadLevelJS(park) {
|
||||
download('level.' + slug(park) + '.js', generateLevelJS(park), 'text/javascript');
|
||||
export function downloadLevelJS(park, kitMap) {
|
||||
download('level.' + slug(park) + '.js', generateLevelJS(park, kitMap), 'text/javascript');
|
||||
}
|
||||
|
||||
58
js/kitthumbs.js
Normal file
@ -0,0 +1,58 @@
|
||||
// SKATEMAKER PRO — kit thumbnails. Each GLB renders to a 128x96 sprite ONCE, is
|
||||
// POSTed to serve.py, saved under assets/kit_thumbs/ and committed — so every
|
||||
// machine gets instant thumbs and the offscreen renderer only ever runs for
|
||||
// props that don't have one yet (new kit drops).
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
import { KIT_BASE } from './parkbuild.js';
|
||||
|
||||
const W = 128, H = 96;
|
||||
let ctx = null; // lazy shared renderer
|
||||
|
||||
function getCtx() {
|
||||
if (ctx) return ctx;
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true,
|
||||
preserveDrawingBuffer: true });
|
||||
renderer.setSize(W, H);
|
||||
const scene = new THREE.Scene();
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x445544, 1.2));
|
||||
const sun = new THREE.DirectionalLight(0xfff3d9, 1.6);
|
||||
sun.position.set(3, 6, 4); scene.add(sun);
|
||||
const cam = new THREE.PerspectiveCamera(38, W / H, 0.05, 100);
|
||||
ctx = { renderer, scene, cam, loader: new GLTFLoader() };
|
||||
return ctx;
|
||||
}
|
||||
|
||||
async function renderOne(def) {
|
||||
const { renderer, scene, cam, loader } = getCtx();
|
||||
const glb = await new Promise((res, rej) =>
|
||||
loader.load(KIT_BASE + 'props/' + def.id + '.glb', res, undefined, rej));
|
||||
const obj = glb.scene;
|
||||
scene.add(obj);
|
||||
const box = new THREE.Box3().setFromObject(obj);
|
||||
const c = box.getCenter(new THREE.Vector3()), s = box.getSize(new THREE.Vector3());
|
||||
const rad = Math.max(s.x, s.y, s.z) * 0.62 / Math.tan(cam.fov * Math.PI / 360);
|
||||
cam.position.set(c.x + rad * 0.72, c.y + rad * 0.5, c.z + rad * 0.72);
|
||||
cam.lookAt(c);
|
||||
renderer.render(scene, cam);
|
||||
scene.remove(obj);
|
||||
const blob = await new Promise(res => renderer.domElement.toBlob(res, 'image/png'));
|
||||
await fetch('/api/kit-thumb?name=' + def.id + '.png', { method: 'POST', body: blob });
|
||||
return true;
|
||||
}
|
||||
|
||||
// ensure every kit prop has a thumb; returns ids rendered this pass
|
||||
export async function ensureKitThumbs(kitList, onProgress) {
|
||||
const missing = [];
|
||||
await Promise.all(kitList.map(async d => {
|
||||
const r = await fetch('assets/kit_thumbs/' + d.id + '.png', { method: 'HEAD' });
|
||||
if (!r.ok) missing.push(d);
|
||||
}));
|
||||
const done = [];
|
||||
for (const def of missing) {
|
||||
try { await renderOne(def); done.push(def.id); onProgress?.(done.length, missing.length); }
|
||||
catch (e) { console.warn('thumb failed', def.id, e); }
|
||||
}
|
||||
if (ctx && done.length) { ctx.renderer.dispose(); ctx = null; }
|
||||
return done;
|
||||
}
|
||||
@ -3,12 +3,13 @@
|
||||
// park data — same heightAt, same constants, so speed and pop feel like the game.
|
||||
import * as THREE from 'three';
|
||||
import { makeHeightAt, makeNormalAt, flattenRails } from './parkmath.js';
|
||||
import { propColliders, resolveCollision } from './colliders.js';
|
||||
|
||||
const GRAV = 18, MAX_SPEED = 10.5, PUSH_ACC = 6.5, FRICTION = 0.28, STEER = 2.6;
|
||||
|
||||
export function attachTestRide(ed) {
|
||||
const keys = new Set();
|
||||
let rider = null, heightAt = null, normalAt = null, rails = [];
|
||||
let rider = null, heightAt = null, normalAt = null, rails = [], colliders = [];
|
||||
let pos, heading = 0, speed = 0, vy = 0, state = 'roll';
|
||||
let grind = null; // {seg, t, dir, len}
|
||||
const N = new THREE.Vector3();
|
||||
@ -38,6 +39,7 @@ export function attachTestRide(ed) {
|
||||
heightAt = makeHeightAt(ed.park);
|
||||
normalAt = makeNormalAt(heightAt);
|
||||
rails = flattenRails(ed.park);
|
||||
colliders = propColliders(ed.park, ed.kit || {});
|
||||
const s = ed.park.spawn;
|
||||
pos = new THREE.Vector3(s.x, heightAt(s.x, s.z), s.z);
|
||||
heading = s.heading; speed = 0; vy = 0; state = 'roll';
|
||||
@ -120,6 +122,13 @@ export function attachTestRide(ed) {
|
||||
if (pos.y <= h) { pos.y = h; state = 'roll'; vy = 0; }
|
||||
}
|
||||
}
|
||||
if (state !== 'grind') { // can't ride through furniture
|
||||
const fix = resolveCollision(colliders, pos.x, pos.z, 0.28, pos.y - heightAt(pos.x, pos.z));
|
||||
if (fix) {
|
||||
pos.x = fix[0]; pos.z = fix[1];
|
||||
if (state === 'roll') speed *= Math.pow(0.02, dt); // scrubbing along a bin
|
||||
}
|
||||
}
|
||||
if (keys.has('KeyR')) {
|
||||
const s = ed.park.spawn;
|
||||
pos.set(s.x, heightAt(s.x, s.z), s.z); heading = s.heading; speed = 0; state = 'roll';
|
||||
|
||||
11
js/ui.js
@ -5,6 +5,7 @@ import { checkPark } from './parkcheck.js';
|
||||
import { downloadParkJSON, downloadLevelJS } from './export.js';
|
||||
import * as MB from './modelbeast.js';
|
||||
import { PROP_LABELS } from './props3d.js';
|
||||
import { ensureKitThumbs } from './kitthumbs.js';
|
||||
|
||||
const $ = (sel, root = document) => root.querySelector(sel);
|
||||
const el = (tag, cls, html) => {
|
||||
@ -455,6 +456,10 @@ export function initUI(ed) {
|
||||
list.append(el('div', 'ptitle', cat.toUpperCase()));
|
||||
for (const p of items) {
|
||||
const item = el('div', 'mbitem');
|
||||
const im = el('img', 'kitthumb');
|
||||
im.src = 'assets/kit_thumbs/' + p.id + '.png';
|
||||
im.onerror = () => { im.style.visibility = 'hidden'; };
|
||||
item.append(im);
|
||||
const badges = [p.element ? '⛰' : '', p.grinds?.length ? '≡' : ''].join('');
|
||||
item.append(el('span', 'mbname', `${p.id} ${badges} <span class="dim">${p.desc || ''}</span>`));
|
||||
const b = el('button', 'pbtn', 'PLACE');
|
||||
@ -471,6 +476,10 @@ export function initUI(ed) {
|
||||
};
|
||||
search.oninput = render;
|
||||
render();
|
||||
ensureKitThumbs(ed.kitList, (n, total) => {
|
||||
setStatus(`rendering kit thumbnails ${n}/${total}…`);
|
||||
if (n === total) { setStatus('kit thumbnails ready'); render(); }
|
||||
}).then(done => { if (done.length) render(); });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- MODELBEAST
|
||||
@ -590,7 +599,7 @@ export function initUI(ed) {
|
||||
};
|
||||
bar.append(loadSel);
|
||||
mk('⬇ JSON', () => downloadParkJSON(ed.park));
|
||||
mk('⬇ LEVEL.JS', () => downloadLevelJS(ed.park));
|
||||
mk('⬇ LEVEL.JS', () => downloadLevelJS(ed.park, ed.kit));
|
||||
mk('TOP', () => ed.topView());
|
||||
const test = mk('▶ TEST RIDE', () => ed.toggleTest?.(), 'accent test');
|
||||
ed.on('test', on => { test.textContent = on ? '■ STOP (Enter)' : '▶ TEST RIDE'; });
|
||||
|
||||
8
serve.py
@ -128,6 +128,14 @@ class H(SimpleHTTPRequestHandler):
|
||||
os.makedirs(PARKS, exist_ok=True)
|
||||
open(os.path.join(PARKS, name + '.json'), 'wb').write(body)
|
||||
return self._json({'saved': name})
|
||||
if path == '/api/kit-thumb': # rendered-once kit thumbnails (committed)
|
||||
name = urllib.parse.unquote(q.get('name', ''))
|
||||
if not re.match(r'^[\w\-]+\.png$', name):
|
||||
return self._err('bad name', 400)
|
||||
d = os.path.join(ROOT, 'assets', 'kit_thumbs')
|
||||
os.makedirs(d, exist_ok=True)
|
||||
open(os.path.join(d, name), 'wb').write(body)
|
||||
return self._json({'path': 'assets/kit_thumbs/' + name})
|
||||
if path == '/api/upload':
|
||||
name = urllib.parse.unquote(q.get('name', ''))
|
||||
if not SAFE.match(name):
|
||||
|
||||