// 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'; 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 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); 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 (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)); }