serve.py mounts the sibling park_kit repo at /kit/ (PARK_KIT env to repoint) — same convention as skatemakerpro, so editor-exported levels work unchanged. level.js PROPS lists the dressing (island Moreton Bays, gums, benches, bin, picnic table, bleachers, shade sail, fence sections, floodlights, hydrant, graffiti wall); main.js loads them onto heightAt. Procedural cone-figs retired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
281 lines
14 KiB
JavaScript
281 lines
14 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
|
|
|
|
// 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 }, { 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 },
|
|
{ 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 + a hydrant on the footpath
|
|
{ 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 },
|
|
// the wallride wall in the west parkland
|
|
{ id: 'wall_graffiti', x: -45, z: 4, rot: Math.PI / 2 },
|
|
];
|
|
|
|
// ---------------------------------------------------------------- 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 };
|
|
}
|