skatemakerpro/js/testride.js
type-two db40f433fe SKATEMAKER PRO v1 — stand-alone park builder/editor for BOOKQUOY
Data-driven parks: one heightAt(x,z) evaluated from element JSON drives the
editor viewport, collision, test ride, and exported game levels. Ships with
Paddo ported from bookquoy, a MODELBEAST panel (gen images, cut bg, image->3D,
place farm GLBs as props), image decals + reference underlay tracing, a park
design linter (docs/DESIGN_PRINCIPLES.md), THPS-style instant test ride, and
a level.js codegen that drops straight into bookquoy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 19:34:45 +10:00

121 lines
5.2 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 } from './parkmath.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;
let pos, heading = 0, speed = 0, vy = 0, state = 'roll';
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);
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 · R respawn · ENTER back to editing');
}
function stop() {
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 {
vy -= GRAV * dt;
pos.x += fwd.x * speed * dt; pos.z += fwd.z * speed * dt; pos.y += vy * dt;
const h = heightAt(pos.x, pos.z);
if (pos.y <= h) { pos.y = h; state = 'roll'; vy = 0; }
}
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;
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));
}