// 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); } export function heightAt(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 // ---------------------------------------------------------------- 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); } } } // the Moreton Bay figs on the island (+ a couple in the parkland) const trunkM = new THREE.MeshLambertMaterial({ color: 0x5b4632 }); const leafM = new THREE.MeshLambertMaterial({ color: 0x2e5b2a }); const fig = (x, z, s) => { const grp = new THREE.Group(); const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.28 * s, 0.42 * s, 2.6 * s, 7), trunkM); trunk.position.y = 1.3 * s; grp.add(trunk); for (const [ox, oy, oz, r] of [[0, 3.1, 0, 2.4], [1.4, 2.6, 0.7, 1.7], [-1.3, 2.7, -0.6, 1.6]]) { const m = new THREE.Mesh(new THREE.SphereGeometry(r * s, 10, 8), leafM); m.position.set(ox * s, oy * s, oz * s); grp.add(m); } grp.position.set(x, heightAt(x, z), z); scene.add(grp); }; fig(0, -3, 1.25); fig(4.5, -1, 1.0); fig(-1, 1, 0.9); fig(-34, 16, 1.1); fig(30, 20, 1.3); fig(-14, -30, 1.2); // 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 }; }