bookquoy/js/level.js
jing 53ad2030af BOOKQUOY v0.1 — Paddo, three disciplines, letters, gaps, 2:00 sesh
Level 01: Paddington Skatepark from the council plan + park guides —
heightAt(x,z) single-source terrain, grindable rails/ledges, named gaps,
B-O-O-K-Q-U-O-Y letters with the BOOKQUOYORK callback.

SKATE / BMX / BLADES (blades cop thrown rubbish, as is tradition).
character_kit two-rig banks: physics owns the root, clips are cosmetic.

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

270 lines
13 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);
}
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 };
}