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