skatemakerpro/js/parkmath.js
type-two db40f433fe SKATEMAKER PRO v1 — stand-alone park builder/editor for BOOKQUOY
Data-driven parks: one heightAt(x,z) evaluated from element JSON drives the
editor viewport, collision, test ride, and exported game levels. Ships with
Paddo ported from bookquoy, a MODELBEAST panel (gen images, cut bg, image->3D,
place farm GLBs as props), image decals + reference underlay tracing, a park
design linter (docs/DESIGN_PRINCIPLES.md), THPS-style instant test ride, and
a level.js codegen that drops straight into bookquoy.

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

254 lines
9.9 KiB
JavaScript

// SKATEMAKER PRO — park math. The whole religion in one file:
// a park is DATA (elements list), and heightAt(x,z) evaluated from that data is the
// single source of truth for the visual mesh AND the collision. Same lesson as BOOKQUOY.
//
// Every element lives in a rotated local frame: u along the element, v across it.
// u_dir = ( cos rot, sin rot ) in (x,z)
// v_dir = (-sin rot, cos rot ) — ramps descend toward +v ("facing").
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.
export 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));
}
function local(e, x, z) { // world (x,z) -> element-local (u,v)
const dx = x - e.x, dz = z - e.z, c = Math.cos(e.rot || 0), s = Math.sin(e.rot || 0);
return [dx * c + dz * s, -dx * s + dz * c];
}
// ---------------------------------------------------------------- element kinds
// Each returns a height contribution (concrete = max of all). Bowls carve (override).
export const ELEMENT_KINDS = {
quarter: { // straight QP, escalating radius, optional lump + deck
label: 'Quarter Pipe',
defaults: { len: 8, r0: 1.2, r1: 1.2, lump: 0, deck: 0.6 },
height(e, x, z) {
const [u, v] = local(e, x, z);
if (Math.abs(u) > e.len / 2) return 0;
let r = e.r0 + (e.r1 - e.r0) * sstep(u / e.len + 0.5);
if (e.lump) r += e.lump * Math.sin(u * 0.9);
if (v < -e.deck) return 0;
if (v <= 0) return r; // deck behind the coping
return tranny(v, r);
},
},
bank: {
label: 'Bank',
defaults: { len: 6, h: 0.9, run: 2.4, deck: 0.5 },
height(e, x, z) {
const [u, v] = local(e, x, z);
if (Math.abs(u) > e.len / 2 || v < -e.deck || v >= e.run) return 0;
return v <= 0 ? e.h : e.h * (1 - v / e.run);
},
},
funbox: { // soft-skirted box (bookquoy funbox)
label: 'Funbox',
defaults: { hw: 2.5, hd: 1.2, h: 0.9, skirt: 1.6 },
height(e, x, z) {
const [u, v] = local(e, x, z);
const d = Math.max(Math.abs(u) - e.hw, Math.abs(v) - e.hd);
if (d <= 0) return e.h;
if (d >= e.skirt) return 0;
return e.h * (1 - d / e.skirt);
},
},
ledge: { // sharp-sided plateau / manual pad / grind ledge
label: 'Ledge / Pad',
defaults: { hw: 1.5, hd: 0.8, h: 0.45 },
height(e, x, z) {
const [u, v] = local(e, x, z);
return (Math.abs(u) <= e.hw && Math.abs(v) <= e.hd) ? e.h : 0;
},
},
mogul: { // gaussian bump / volcano
label: 'Mogul / Volcano',
defaults: { h: 0.6, s: 2.4 },
height(e, x, z) {
const r2 = (x - e.x) ** 2 + (z - e.z) ** 2;
return e.h * Math.exp(-r2 / (e.s * e.s));
},
},
spine: { // back-to-back quarters along u
label: 'Spine',
defaults: { len: 8, r: 1.2 },
height(e, x, z) {
const [u, v] = local(e, x, z);
if (Math.abs(u) > e.len / 2) return 0;
const d = Math.abs(v);
return d >= e.r ? 0 : tranny(d, e.r);
},
},
pyramid: { // 4-sided bank pyramid with flat top
label: 'Pyramid',
defaults: { hw: 3, hd: 3, h: 1.0, run: 2.2 },
height(e, x, z) {
const [u, v] = local(e, x, z);
const d = Math.max(Math.abs(u) - e.hw, Math.abs(v) - e.hd);
if (d <= 0) return e.h;
if (d >= e.run) return 0;
return e.h * (1 - d / e.run);
},
},
stairs: { // platform + steps descending toward +v
label: 'Stairs',
defaults: { width: 4.8, h: 0.6, steps: 4, going: 0.6, platDepth: 3.2 },
height(e, x, z) {
const [u, v] = local(e, x, z);
if (Math.abs(u) > e.width / 2) return 0;
if (v <= 0) return v >= -e.platDepth ? e.h : 0;
const total = e.steps * e.going;
if (v >= total) return 0;
const rise = e.h / e.steps;
const step = Math.floor(v / e.going);
return e.h - rise * (step + 1);
},
},
dome: { // elliptical pad (grass island, kerbed pad)
label: 'Island Pad',
defaults: { rx: 7, rz: 4.5, h: 0.14 },
height(e, x, z) {
const c = Math.cos(e.rot || 0), s = Math.sin(e.rot || 0);
const dx = x - e.x, dz = z - e.z;
const u = (dx * c + dz * s) / e.rx, v = (-dx * s + dz * c) / e.rz;
return u * u + v * v < 1 ? e.h : 0;
},
},
bowl: { // CARVES negative space. Override, not max.
label: 'Bowl',
defaults: { size: 4.2, depth: 1.5 },
carve: true,
height(e, x, z) { // returns carve value, caller checks inside()
const [u, v] = local(e, x, z);
const dw = Math.min(e.size - Math.abs(u), e.size - Math.abs(v));
return -e.depth + tranny(Math.min(dw, e.depth), e.depth);
},
inside(e, x, z) {
const [u, v] = local(e, x, z);
return Math.abs(u) < e.size && Math.abs(v) < e.size;
},
},
};
// ---------------------------------------------------------------- evaluation
export function elementHeight(e, x, z) {
const k = ELEMENT_KINDS[e.kind];
return k ? k.height(e, x, z) : 0;
}
// makeHeightAt(park) -> heightAt(x,z). Closure over the data; rebuild-free (data is live).
export function makeHeightAt(park) {
return function heightAt(x, z) {
const B = park.bounds;
const inPark = x >= B.x0 && x <= B.x1 && z >= B.z0 && z <= B.z1;
if (!inPark) { // parkland grass rising gently away
const dx = Math.max(B.x0 - x, x - B.x1, 0);
const dz = Math.max(B.z0 - z, z - B.z1, 0);
return 0.25 + 0.09 * Math.hypot(dx, dz);
}
let h = 0;
for (const e of park.elements) {
const k = ELEMENT_KINDS[e.kind];
if (!k) continue;
if (k.carve) {
if (k.inside(e, x, z)) return k.height(e, x, z); // bowls own their footprint
continue;
}
const eh = k.height(e, x, z);
if (eh > h) h = eh;
}
return h;
};
}
export function makeNormalAt(heightAt) {
return 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;
};
}
// footprint hit-test for the editor (generous margins so small things stay clickable)
export function elementHit(e, x, z) {
const M = 0.4;
const k = ELEMENT_KINDS[e.kind];
if (!k) return false;
switch (e.kind) {
case 'quarter': { const [u, v] = local(e, x, z);
return Math.abs(u) <= e.len / 2 + M && v >= -e.deck - M && v <= Math.max(e.r0, e.r1) + M; }
case 'bank': { const [u, v] = local(e, x, z);
return Math.abs(u) <= e.len / 2 + M && v >= -e.deck - M && v <= e.run + M; }
case 'funbox': case 'pyramid': { const [u, v] = local(e, x, z);
const sk = e.skirt ?? e.run;
return Math.abs(u) <= e.hw + sk + M && Math.abs(v) <= e.hd + sk + M; }
case 'ledge': { const [u, v] = local(e, x, z);
return Math.abs(u) <= e.hw + M && Math.abs(v) <= e.hd + M; }
case 'mogul': return Math.hypot(x - e.x, z - e.z) <= e.s + M;
case 'spine': { const [u, v] = local(e, x, z);
return Math.abs(u) <= e.len / 2 + M && Math.abs(v) <= e.r + M; }
case 'stairs': { const [u, v] = local(e, x, z);
return Math.abs(u) <= e.width / 2 + M && v >= -e.platDepth - M && v <= e.steps * e.going + M; }
case 'dome': { const c = Math.cos(e.rot || 0), s = Math.sin(e.rot || 0);
const dx = x - e.x, dz = z - e.z;
const u = (dx * c + dz * s) / (e.rx + M), v = (-dx * s + dz * c) / (e.rz + M);
return u * u + v * v < 1; }
case 'bowl': { const [u, v] = local(e, x, z);
return Math.abs(u) <= e.size + M && Math.abs(v) <= e.size + M; }
}
return false;
}
// footprint outline (world-space [x,z] loop) for selection highlights
export function elementOutline(e) {
const rot = e.rot || 0, c = Math.cos(rot), s = Math.sin(rot);
const W = (u, v) => [e.x + u * c - v * s, e.z + u * s + v * c];
const rect = (u0, u1, v0, v1) => [W(u0, v0), W(u1, v0), W(u1, v1), W(u0, v1)];
switch (e.kind) {
case 'quarter': return rect(-e.len / 2, e.len / 2, -e.deck, Math.max(e.r0, e.r1));
case 'bank': return rect(-e.len / 2, e.len / 2, -e.deck, e.run);
case 'funbox': return rect(-e.hw - e.skirt, e.hw + e.skirt, -e.hd - e.skirt, e.hd + e.skirt);
case 'pyramid': return rect(-e.hw - e.run, e.hw + e.run, -e.hd - e.run, e.hd + e.run);
case 'ledge': return rect(-e.hw, e.hw, -e.hd, e.hd);
case 'mogul': { const pts = []; for (let i = 0; i < 24; i++) {
const a = i / 24 * Math.PI * 2; pts.push([e.x + Math.cos(a) * e.s, e.z + Math.sin(a) * e.s]); }
return pts; }
case 'spine': return rect(-e.len / 2, e.len / 2, -e.r, e.r);
case 'stairs': return rect(-e.width / 2, e.width / 2, -e.platDepth, e.steps * e.going);
case 'dome': { const pts = []; for (let i = 0; i < 24; i++) {
const a = i / 24 * Math.PI * 2;
pts.push(W(Math.cos(a) * e.rx, Math.sin(a) * e.rz)); }
return pts; }
case 'bowl': return rect(-e.size, e.size, -e.size, e.size);
}
return [];
}
// ---------------------------------------------------------------- park scaffolding
let idCounter = 1;
export const newId = () => 'e' + (idCounter++) + '_' + ((Math.random() * 1e6) | 0).toString(36);
export function newElement(kind, x, z) {
const k = ELEMENT_KINDS[kind];
return { id: newId(), kind, x: +x.toFixed(2), z: +z.toFixed(2), rot: 0,
...structuredClone(k.defaults) };
}
export function emptyPark(name = 'NEW PARK') {
return {
name,
bounds: { x0: -30, x1: 30, z0: -20, z1: 20 },
slabColor: '#7fae6f',
grassColor: '#4d7c3c',
spawn: { x: -20, z: -12, heading: 0.8 },
elements: [], rails: [], gaps: [], letters: [], zones: [], decals: [], props: [],
};
}