bookquoy/js/level.js
type-two f62d83f488 kit props go physical: colliders, rideable tops, grinds, gaps, bonks
level.js adopts skatemakerpro's COLLIDERS export shape (box|circle) so
editor parks drop in: box tops join heightAt (land on the picnic table),
circles push out (bins, trunks, hydrants). player.js: hard-edge step
block in roll + bail, blocker resolve in fall, bonk -> stumble + flash
('STRAIGHT INTO THE BIN'). Kit grinds append to RAILS (bench boardslides,
trolley 50-50s); props declare gap triggers vs terrain height (y0).
New Paddo street set: two trolleys, slappy kerbs, planters, parking
block, jersey barrier. Verified: bin stop at contact, planter stand
y=0.50, bench + trolley grinds, 'Tre Flip + TROLLEY GAP + 50-50
(trolley) x3 (2109)'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:40:57 +10:00

336 lines
16 KiB
JavaScript

// BOOKQUOY level 01 — "PADDO"
// Paddington Skatepark, 113 Caxton St, Brisbane (Neal Macrossan Park, opposite Suncorp).
// Stylised from the council upgrade plan + skateboard.com.au notes: looping island layout
// around the Moreton Bay figs, green fast concrete, 5ft square bowl west, 6ft lumpy
// quarter south, escalating transitions on the walls, street plaza east (funbox + rails,
// tiered down-ledges, stairset with blocks, long metal-capped ledge, P-rail, moguls with
// a rainbow rail, spine transfer).
//
// EVERYTHING RIDES ON ONE FUNCTION: heightAt(x,z) is the single source of truth for the
// visual mesh AND the collision — the same lesson as the skateboard deck's concave.
import * as THREE from 'three';
// ---------------------------------------------------------------- park bounds
export const BOUNDS = { x0: -40, x1: 40, z0: -26, z1: 26 };
const IN = (x, a, b) => x >= a && x <= b;
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const sstep = t => { t = clamp(t, 0, 1); return t * t * (3 - 2 * t); };
// quarter-pipe transition: d = distance from the wall, r = radius. h(0)=r, h(r)=0.
function tranny(d, r) {
if (d >= r) return 0;
if (d <= 0) return r;
const q = r - d;
return r - Math.sqrt(Math.max(r * r - q * q, 0));
}
// ---------------------------------------------------------------- elements
// Each entry contributes height; concrete = max(all), bowl carves negative space.
function funbox(x, z, cx, cz, hw, hd, h, skirt) {
const dx = Math.abs(x - cx) - hw, dz = Math.abs(z - cz) - hd;
const d = Math.max(dx, dz);
if (d <= 0) return h;
if (d >= skirt) return 0;
return h * (1 - d / skirt);
}
function plateau(x, z, cx, cz, hw, hd, h) { // sharp-sided ledge / pad
return (Math.abs(x - cx) <= hw && Math.abs(z - cz) <= hd) ? h : 0;
}
function mogul(x, z, cx, cz, h, s) {
const r2 = (x - cx) ** 2 + (z - cz) ** 2;
return h * Math.exp(-r2 / (s * s));
}
function spine(x, z, cx0, cx1, cz, r) { // back-to-back quarters along x
if (!IN(x, cx0, cx1)) return 0;
const d = Math.abs(z - cz);
return d >= r ? 0 : tranny(d, r);
}
// ------------------------------------------------------------ prop physics
// COLLIDERS uses skatemakerpro's export shape verbatim ({type:'box'|'circle',
// x, z, rot, hw/hd/r, h}) so editor-exported parks drop in unchanged. Boxes are
// RIDEABLE: their tops join heightAt (land on the picnic table); circles are
// blockers only (bins, trunks, hydrants — resolveBlocker pushes you out).
// main.js fills this from placed kit props; exported levels ship it pre-filled.
export const COLLIDERS = [];
export function registerColliders(list) {
for (const c of list) {
c.top = terrainAt(c.x, c.z) + c.h; // world top, precomputed once
if (c.type === 'box') { c.c = Math.cos(c.rot || 0); c.s = Math.sin(c.rot || 0); }
COLLIDERS.push(c);
}
}
function boxTop(c, x, z) {
const dx = x - c.x, dz = z - c.z;
const lx = dx * c.c - dz * c.s, lz = dx * c.s + dz * c.c;
return (Math.abs(lx) <= c.hw && Math.abs(lz) <= c.hd) ? c.top : -Infinity;
}
// circle-vs-player push-out. Mutates pos; returns the collider hit (for bonks).
export function resolveBlocker(pos, pr = 0.3) {
for (const c of COLLIDERS) {
if (c.type !== 'circle' || pos.y > c.top) continue;
const dx = pos.x - c.x, dz = pos.z - c.z;
const d = Math.hypot(dx, dz), min = c.r + pr;
if (d < min) {
const f = d > 1e-4 ? min / d : 1;
pos.x = c.x + dx * (d > 1e-4 ? f : min);
pos.z = c.z + (d > 1e-4 ? dz * f : 0);
return c;
}
}
return null;
}
export function heightAt(x, z) {
let h = terrainAt(x, z);
for (const c of COLLIDERS)
if (c.type === 'box' && c.top > h) h = Math.max(h, boxTop(c, x, z));
return h;
}
function terrainAt(x, z) {
// outside the slab: grass, rising gently away from the park
const inPark = IN(x, BOUNDS.x0, BOUNDS.x1) && IN(z, BOUNDS.z0, BOUNDS.z1);
if (!inPark) {
const dx = Math.max(BOUNDS.x0 - x, x - BOUNDS.x1, 0);
const dz = Math.max(BOUNDS.z0 - z, z - BOUNDS.z1, 0);
return 0.25 + 0.09 * Math.hypot(dx, dz);
}
let h = 0;
// --- walls: escalating transitions (west corner 0.9->1.8, north 0.5->1.1) ---
const rW = 0.9 + 0.9 * sstep((10 - z) / 26); // west wall, bigger toward NW
h = Math.max(h, tranny(x - BOUNDS.x0, rW));
const rN = 0.5 + 0.6 * sstep((x + 10) / 40); // north (Hale St) wall
h = Math.max(h, tranny(z - BOUNDS.z0, rN));
// --- south: the 6ft lumpy quarter, x -16..18, plus wide bank west of it ---
if (IN(x, -16, 18)) {
const lump = 1.8 + 0.12 * Math.sin(x * 0.9); // "lumpy" is literal at Paddo
h = Math.max(h, tranny(BOUNDS.z1 - z, lump));
}
if (IN(x, -26, -16)) { // 900H bank
const d = BOUNDS.z1 - z;
if (d < 2.4) h = Math.max(h, 0.9 * (1 - d / 2.4));
}
if (IN(x, 20, 34)) { // NE flatbank (existing #22)
const d = z - BOUNDS.z0;
if (d < 2.2) h = Math.max(h, 0.8 * (1 - d / 2.2));
}
// --- west: 5ft square bowl (carved DOWN, full-transition walls) ---
const BX = -27, BZ = -6, BH = 4.2, BD = 1.5;
if (Math.abs(x - BX) < BH && Math.abs(z - BZ) < BH) {
// dw = distance in from the rim; full-transition walls: 0 at the rim, -BD at the flat
const dw = Math.min(BH - Math.abs(x - BX), BH - Math.abs(z - BZ));
return -BD + tranny(Math.min(dw, BD), BD);
}
// --- volcano + moguls (191-450H + 595-800H with rainbow rail) ---
h = Math.max(h, mogul(x, z, -18, 4, 0.7, 2.6)); // volcano cone
h = Math.max(h, mogul(x, z, -2, 6, 0.45, 2.2));
h = Math.max(h, mogul(x, z, 2, 9, 0.8, 2.5)); // rainbow-rail mogul
// --- spine transfer (1200H) ---
h = Math.max(h, spine(x, z, -12, -4, 12, 1.2));
// --- central island: kerbed grass pad with the fig trees ---
const ex = (x - 2) / 7, ez = (z + 2) / 4.5;
if (ex * ex + ez * ez < 1) h = Math.max(h, 0.14);
// --- east street plaza ---
h = Math.max(h, funbox(x, z, 16, -2, 2.5, 1.2, 0.9, 1.6)); // centre funbox
h = Math.max(h, plateau(x, z, 8.5, -11, 1.5, 0.8, 0.6)); // down ledges (tiers)
h = Math.max(h, plateau(x, z, 11.5, -11, 1.5, 0.8, 0.45));
h = Math.max(h, plateau(x, z, 14.5, -11, 1.5, 0.8, 0.3));
h = Math.max(h, funbox(x, z, 26, -14, 2.2, 1.2, 0.35, 1.1)); // bank-to-bank manual pad
h = Math.max(h, plateau(x, z, 33.5, -2, 0.5, 12, 0.42)); // long metal ledge
// --- stairset: 4 x 150mm steps dropping south from a 600H platform ---
if (IN(x, 3.6, 8.4)) {
if (z <= 12.2 && z > 9.0) h = Math.max(h, 0.6); // top platform
else if (IN(z, 12.2, 14.6)) {
const step = Math.floor((z - 12.2) / 0.6); // 0.6m going, 0.15 rise
h = Math.max(h, 0.6 - 0.15 * (step + 1));
}
}
return h;
}
export function normalAt(x, z, out) {
const e = 0.18;
const hx = heightAt(x + e, z) - heightAt(x - e, z);
const hz = heightAt(x, z + e) - heightAt(x, z - e);
out.set(-hx, 2 * e, -hz).normalize();
return out;
}
// ---------------------------------------------------------------- grindables
// { a, b, y (top height), kind } — kind picks the grind clip + scoring
export const RAILS = [
{ name: 'Funbox Flat Rail', a: [13.5, -2.9], b: [18.5, -2.9], ya: 1.25, yb: 1.25, kind: 'rail' },
{ name: 'Funbox Down Rail', a: [18.5, -1.0], b: [21.8, -1.0], ya: 1.20, yb: 0.35, kind: 'rail' },
{ name: 'P-Rail', a: [22.0, 8.0], b: [26.0, 8.0], ya: 0.35, yb: 0.35, kind: 'rail' },
{ name: 'Rainbow Rail', a: [-0.4, 9.0], b: [2.0, 9.0], ya: 0.45, yb: 1.35, kind: 'rail' },
{ name: 'Rainbow Rail', a: [2.0, 9.0], b: [4.4, 9.0], ya: 1.35, yb: 0.45, kind: 'rail' },
{ name: 'Long Metal Ledge', a: [33.0, -13.5], b: [33.0, 9.5], ya: 0.42, yb: 0.42, kind: 'ledge' },
{ name: 'Down Ledge', a: [7.0, -11.9], b: [10.0, -11.9], ya: 0.6, yb: 0.6, kind: 'ledge' },
{ name: 'Down Ledge', a: [10.0, -11.9], b: [13.0, -11.9], ya: 0.45, yb: 0.45, kind: 'ledge' },
{ name: 'Down Ledge', a: [13.0, -11.9], b: [16.0, -11.9], ya: 0.3, yb: 0.3, kind: 'ledge' },
{ name: 'Stair Hubba', a: [3.4, 12.2], b: [3.4, 15.0], ya: 0.62, yb: 0.06, kind: 'ledge' },
{ name: 'Stair Hubba', a: [8.6, 12.2], b: [8.6, 15.0], ya: 0.62, yb: 0.06, kind: 'ledge' },
];
// ---------------------------------------------------------------- named gaps
// airborne over the region => award (once per air). Names are pure Brisbane.
export const GAPS = [
{ name: 'THE STAIR GAP', x: 6, z: 13.6, r: 2.4 },
{ name: 'FUNBOX FLY', x: 16, z: -2, r: 2.8 },
{ name: 'SPINE TRANSFER', x: -8, z: 12, r: 2.4 },
{ name: 'BOWL HIP', x: -22.5,z: -2, r: 2.2 },
{ name: 'RAINBOW ARC', x: 2, z: 9, r: 2.2 },
{ name: 'LEDGE LEAP', x: 11.5, z: -11, r: 2.6 },
{ name: 'KEYHOLE GAP', x: -36, z: -14, r: 2.6 },
{ name: 'CAXTON CARVE', x: 10, z: -22.5,r: 2.6 },
{ name: 'ISLAND HOP', x: 2, z: 2.6, r: 2.2 },
];
// ---------------------------------------------------------------- letters
// B O O K Q U O Y — collect the call, land the response.
export const LETTERS = [
{ ch: 'B', x: -27, y: 0.9, z: -6 }, // floating over the bowl
{ ch: 'O', x: -8, y: 2.3, z: 12 }, // over the spine
{ ch: 'O', x: 16, y: 2.4, z: -2 }, // over the funbox
{ ch: 'K', x: 6, y: 1.6, z: 14.2 }, // over the stairs
{ ch: 'Q', x: -35, y: 2.2, z: -13 }, // the keyhole corner
{ ch: 'U', x: 2, y: 2.5, z: 9 }, // rainbow rail apex
{ ch: 'O', x: 33, y: 1.6, z: -2 }, // long ledge
{ ch: 'Y', x: 24, y: 1.5, z: 8 }, // P-rail
];
export const SPAWN = { x: -32, z: -18, heading: 0.85 }; // tennis-courts entrance, facing SE into the park
// park_kit dressing — textured GLBs from the sibling park_kit repo, served at
// /kit/ (see serve.py). Visual only, same as the old cone-figs: no colliders.
// main.js loads these after buildLevel; y sits on heightAt(x,z).
export const PROPS = [
// the island Moreton Bays + parkland trees
{ id: 'tree_fig', x: 0, z: -3, s: 1.15 }, { id: 'tree_fig', x: 4.5, z: -1, s: 0.9 },
{ id: 'tree_fig', x: -1, z: 1, s: 0.8, rot: 2.1 },
{ id: 'tree_fig', x: -34, z: 16, s: 1.0, rot: 0.8 }, { id: 'tree_fig', x: 30, z: 20, s: 1.2 },
{ id: 'tree_fig', x: -14, z: -30, s: 1.05, rot: 4.0 },
{ id: 'tree_gum', x: -40, z: 24, rot: 1.2 }, { id: 'tree_gum', x: 40, z: 10, rot: 3.4 },
// furniture along the south parkland + plaza edge
{ id: 'bench_park', x: 24, z: 15, rot: Math.PI, gap: 'BENCH HOP' },
{ id: 'bin_council', x: 26.2, z: 15 },
{ id: 'bench_park', x: -8, z: 17, rot: Math.PI },
{ id: 'picnic_table', x: -16, z: 20, rot: 0.4, gap: 'TABLE TOPPER' },
{ id: 'bleachers_3', x: -24, z: 22, rot: Math.PI }, // watching the bowl line
{ id: 'shade_sail', x: 18, z: 23 },
// Caxton St side: fence sections + lights + footpath street furniture
{ id: 'fence_chain_3m', x: -16, z: -38.5 }, { id: 'fence_chain_3m', x: -13, z: -38.5 },
{ id: 'fence_chain_3m', x: 19, z: -38.5 }, { id: 'fence_chain_3m', x: 22, z: -38.5 },
{ id: 'floodlight', x: -38, z: -20 }, { id: 'floodlight', x: 38, z: 16 },
{ id: 'hydrant', x: 14, z: -37 },
{ id: 'kerb_3m', x: 8, z: -37.2 }, { id: 'kerb_3m', x: 5, z: -37.2 }, // slappy kerbs
// the wallride wall in the west parkland
{ id: 'wall_graffiti', x: -45, z: 4, rot: Math.PI / 2 },
// the physical street set: bonk it, grind it, or gap it
{ id: 'trolley', x: -6, z: -8, rot: 0.7, gap: 'TROLLEY GAP' },
{ id: 'trolley', x: 2, z: 4.5, rot: 3.6, gap: 'BOOKQUOY TROLLEY' }, // on the island, obviously
{ id: 'planter_box', x: 20, z: -8, gap: 'PLANTER HOP' },
{ id: 'planter_box', x: 20, z: -12 },
{ id: 'parking_block', x: 30, z: -18, rot: 0.2 },
{ id: 'jersey_barrier', x: -20, z: 14, rot: 1.1, gap: 'K-RAIL CLEAR' },
];
// ---------------------------------------------------------------- build visuals
function zoneColor(x, z, h) {
const c = new THREE.Color();
const inPark = IN(x, BOUNDS.x0, BOUNDS.x1) && IN(z, BOUNDS.z0, BOUNDS.z1);
if (!inPark) return c.set(0x4d7c3c); // parkland grass
const ex = (x - 2) / 7, ez = (z + 2) / 4.5;
if (ex * ex + ez * ez < 1) return c.set(0x557f3f); // island grass
if (h < -0.05) return c.set(0x6a7d70); // bowl interior
if (x > 6) return c.set(0x9aa0a2); // old plaza, grey
return c.set(0x7fae6f); // the Paddo green
}
export function buildLevel(scene) {
// heightfield mesh from heightAt — visual == collision by construction
const RES = 0.5, PAD = 14;
const x0 = BOUNDS.x0 - PAD, x1 = BOUNDS.x1 + PAD, z0 = BOUNDS.z0 - PAD, z1 = BOUNDS.z1 + PAD;
const nx = Math.round((x1 - x0) / RES) + 1, nz = Math.round((z1 - z0) / RES) + 1;
const pos = [], col = [], idx = [];
for (let j = 0; j < nz; j++) for (let i = 0; i < nx; i++) {
const x = x0 + i * RES, z = z0 + j * RES, h = heightAt(x, z);
pos.push(x, h, z);
const c = zoneColor(x, z, h); col.push(c.r, c.g, c.b);
}
for (let j = 0; j < nz - 1; j++) for (let i = 0; i < nx - 1; i++) {
const a = j * nx + i, b = a + 1, c = a + nx, d = c + 1;
idx.push(a, c, b, b, c, d);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
g.setIndex(idx); g.computeVertexNormals();
const ground = new THREE.Mesh(g, new THREE.MeshLambertMaterial({ vertexColors: true }));
ground.receiveShadow = true;
scene.add(ground);
// rails + ledge caps as visible steel
const steel = new THREE.MeshStandardMaterial({ color: 0xb9bec4, metalness: 0.8, roughness: 0.35 });
for (const r of RAILS) {
const a = new THREE.Vector3(r.a[0], r.ya, r.a[1]);
const b = new THREE.Vector3(r.b[0], r.yb, r.b[1]);
const len = a.distanceTo(b);
const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, len, 8), steel);
bar.position.copy(a).add(b).multiplyScalar(0.5);
bar.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0),
b.clone().sub(a).normalize());
scene.add(bar);
if (r.kind === 'rail') { // posts
for (const t of [0.12, 0.88]) {
const p = a.clone().lerp(b, t);
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, p.y, 6), steel);
post.position.set(p.x, p.y / 2, p.z); scene.add(post);
}
}
}
// trees/furniture now come from park_kit (PROPS below, loaded by main.js) —
// the procedural cone-figs retired when the textured Moreton Bays arrived
// Caxton St + a nod to Suncorp across the road
const road = new THREE.Mesh(new THREE.BoxGeometry(130, 0.08, 7),
new THREE.MeshLambertMaterial({ color: 0x3a3d42 }));
road.position.set(0, 0.3, BOUNDS.z0 - PAD - 3); scene.add(road);
const stadium = new THREE.Mesh(new THREE.CylinderGeometry(26, 30, 9, 24, 1, false, 0, Math.PI),
new THREE.MeshLambertMaterial({ color: 0x7d8288 }));
stadium.rotation.y = Math.PI; stadium.position.set(10, 3.5, BOUNDS.z0 - PAD - 26);
scene.add(stadium);
// floating letters
const letterMeshes = LETTERS.map(L => {
const cv = document.createElement('canvas'); cv.width = cv.height = 128;
const cx = cv.getContext('2d');
cx.fillStyle = '#ffd93b'; cx.strokeStyle = '#2b2b2b'; cx.lineWidth = 10;
cx.font = 'bold 104px monospace'; cx.textAlign = 'center'; cx.textBaseline = 'middle';
cx.strokeText(L.ch, 64, 70); cx.fillText(L.ch, 64, 70);
const tex = new THREE.CanvasTexture(cv);
const spr = new THREE.Mesh(new THREE.PlaneGeometry(0.9, 0.9),
new THREE.MeshBasicMaterial({ map: tex, transparent: true, side: THREE.DoubleSide }));
const base = heightAt(L.x, L.z);
spr.position.set(L.x, Math.max(base, 0) + L.y, L.z);
spr.userData = { ch: L.ch, taken: false, baseY: spr.position.y };
scene.add(spr);
return spr;
});
return { ground, letterMeshes };
}