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>
121 lines
5.2 KiB
JavaScript
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));
|
|
}
|