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>
167 lines
7.3 KiB
JavaScript
167 lines
7.3 KiB
JavaScript
// SKATEMAKER PRO — test ride. The Create-A-Park lesson: the instant build↔skate
|
|
// toggle is what makes an editor sing. Simplified bookquoy physics over the live
|
|
// 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 = [], colliders = [];
|
|
let pos, heading = 0, speed = 0, vy = 0, state = 'roll';
|
|
let grind = null; // {seg, t, dir, len}
|
|
const N = new THREE.Vector3();
|
|
const camTarget = new THREE.Vector3();
|
|
let savedCam = null;
|
|
|
|
function makeRider() {
|
|
const g = new THREE.Group();
|
|
const deck = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.03, 0.82),
|
|
new THREE.MeshStandardMaterial({ color: 0xd8a13a, roughness: 0.6 }));
|
|
deck.position.y = 0.1; g.add(deck);
|
|
const wheelM = new THREE.MeshStandardMaterial({ color: 0xf5f0e6, roughness: 0.4 });
|
|
for (const [dx, dz] of [[-0.08, 0.3], [0.08, 0.3], [-0.08, -0.3], [0.08, -0.3]]) {
|
|
const w = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.04, 10), wheelM);
|
|
w.rotation.z = Math.PI / 2; w.position.set(dx, 0.05, dz); g.add(w);
|
|
}
|
|
const body = new THREE.Mesh(new THREE.CapsuleGeometry(0.16, 0.75, 4, 10),
|
|
new THREE.MeshStandardMaterial({ color: 0x3a86ff, roughness: 0.7 }));
|
|
body.position.y = 0.75; g.add(body);
|
|
const head = new THREE.Mesh(new THREE.SphereGeometry(0.13, 12, 10),
|
|
new THREE.MeshStandardMaterial({ color: 0xe8b98a, roughness: 0.8 }));
|
|
head.position.y = 1.42; g.add(head);
|
|
return g;
|
|
}
|
|
|
|
function start() {
|
|
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';
|
|
rider = makeRider();
|
|
ed.scene.add(rider);
|
|
savedCam = { p: ed.cam.position.clone(), t: ed.controls.target.clone() };
|
|
ed.controls.enabled = false;
|
|
ed.testing = true;
|
|
ed.emit('test', true);
|
|
ed.emit('status', 'TEST RIDE — W push · A/D steer · S brake · SPACE ollie · land on steel to grind · R respawn · ENTER back to editing');
|
|
}
|
|
function stop() {
|
|
ed.grassPlayer?.(0, 0, false);
|
|
grind = null;
|
|
if (rider) ed.scene.remove(rider);
|
|
rider = null;
|
|
ed.testing = false;
|
|
ed.controls.enabled = true;
|
|
if (savedCam) { ed.cam.position.copy(savedCam.p); ed.controls.target.copy(savedCam.t); }
|
|
ed.setExtraUpdate(null);
|
|
ed.emit('test', false);
|
|
ed.emit('status', 'back to building');
|
|
}
|
|
|
|
function update(dt) {
|
|
const fwd = new THREE.Vector3(Math.sin(heading), 0, Math.cos(heading));
|
|
if (state === 'roll') {
|
|
if (keys.has('KeyA') || keys.has('ArrowLeft')) heading += STEER * dt;
|
|
if (keys.has('KeyD') || keys.has('ArrowRight')) heading -= STEER * dt;
|
|
if (keys.has('KeyW') || keys.has('ArrowUp')) speed = Math.min(speed + PUSH_ACC * dt, MAX_SPEED);
|
|
if (keys.has('KeyS') || keys.has('ArrowDown')) speed = Math.max(speed - 14 * dt, 0);
|
|
normalAt(pos.x, pos.z, N);
|
|
speed += -(N.x * fwd.x + N.z * fwd.z) * GRAV * dt * 1.4; // slope pulls
|
|
speed -= FRICTION * dt * speed * 0.4;
|
|
speed = Math.max(0, Math.min(speed, 16));
|
|
pos.x += fwd.x * speed * dt; pos.z += fwd.z * speed * dt;
|
|
const h = heightAt(pos.x, pos.z);
|
|
const dh = h - pos.y;
|
|
if (dh < -0.12) { // rolled off something
|
|
state = 'air'; vy = Math.max(-2, dh / dt * 0.2);
|
|
} else {
|
|
pos.y = h;
|
|
if (keys.has('Space')) { state = 'air'; vy = 4.6 + speed * 0.12; }
|
|
}
|
|
} else if (state === 'grind') {
|
|
grind.t += grind.dir * speed * dt / grind.len;
|
|
const g = grind.seg;
|
|
if (!keys.has('Space')) grind.latch = false; // must re-press to pop off
|
|
if (grind.t <= 0 || grind.t >= 1 || (keys.has('Space') && !grind.latch)) {
|
|
state = 'air'; vy = 2.6; grind = null;
|
|
} else {
|
|
pos.x = g.a[0] + (g.b[0] - g.a[0]) * grind.t;
|
|
pos.z = g.a[1] + (g.b[1] - g.a[1]) * grind.t;
|
|
pos.y = g.ya + (g.yb - g.ya) * grind.t + 0.06;
|
|
heading = Math.atan2((g.b[0] - g.a[0]) * grind.dir, (g.b[1] - g.a[1]) * grind.dir);
|
|
}
|
|
} else {
|
|
vy -= GRAV * dt;
|
|
pos.x += fwd.x * speed * dt; pos.z += fwd.z * speed * dt; pos.y += vy * dt;
|
|
if (vy < 0) { // falling: try to lock a grind
|
|
for (const g of rails) {
|
|
const dx = g.b[0] - g.a[0], dz = g.b[1] - g.a[1];
|
|
const len = Math.hypot(dx, dz) || 0.01;
|
|
const t = ((pos.x - g.a[0]) * dx + (pos.z - g.a[1]) * dz) / (len * len);
|
|
if (t < 0.02 || t > 0.98) continue;
|
|
const px = g.a[0] + dx * t, pz = g.a[1] + dz * t;
|
|
const railY = g.ya + (g.yb - g.ya) * t;
|
|
if (Math.hypot(pos.x - px, pos.z - pz) < 0.4 &&
|
|
pos.y > railY - 0.05 && pos.y < railY + 0.4) {
|
|
const dir = (Math.sin(heading) * dx + Math.cos(heading) * dz) >= 0 ? 1 : -1;
|
|
grind = { seg: g, t, dir, len, latch: keys.has('Space') };
|
|
state = 'grind'; vy = 0;
|
|
speed = Math.max(speed, 3.5);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (state === 'air') {
|
|
const h = heightAt(pos.x, pos.z);
|
|
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';
|
|
}
|
|
rider.position.copy(pos);
|
|
rider.rotation.y = heading;
|
|
ed.grassPlayer?.(pos.x, pos.z, true);
|
|
if (state === 'roll') {
|
|
normalAt(pos.x, pos.z, N);
|
|
const tilt = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), N);
|
|
rider.quaternion.slerp(tilt.multiply(new THREE.Quaternion()
|
|
.setFromAxisAngle(new THREE.Vector3(0, 1, 0), heading)), 0.25);
|
|
}
|
|
// bookquoy chase cam
|
|
const back = 5.2, up = 2.4;
|
|
const cx = pos.x - Math.sin(heading) * back, cz = pos.z - Math.cos(heading) * back;
|
|
const cy = Math.max(pos.y + up, heightAt(cx, cz) + 1.2);
|
|
ed.cam.position.lerp(new THREE.Vector3(cx, cy, cz), 1 - Math.pow(0.0015, dt));
|
|
camTarget.lerp(new THREE.Vector3(pos.x, pos.y + 1.1, pos.z), 1 - Math.pow(0.0005, dt));
|
|
ed.cam.lookAt(camTarget);
|
|
}
|
|
|
|
ed.toggleTest = () => {
|
|
if (ed.testing) stop();
|
|
else { start(); ed.setExtraUpdate(update); }
|
|
};
|
|
|
|
addEventListener('keydown', e => {
|
|
if (e.code === 'Enter' && !['INPUT', 'SELECT', 'TEXTAREA'].includes(e.target.tagName)) {
|
|
ed.toggleTest(); return;
|
|
}
|
|
if (ed.testing) { keys.add(e.code); if (e.code === 'Space') e.preventDefault(); }
|
|
});
|
|
addEventListener('keyup', e => keys.delete(e.code));
|
|
}
|