skatemakerpro/js/testride.js
type-two d68fa84f92 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>
2026-08-03 21:13:36 +10:00

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));
}