skatemakerpro/js/parkmath.js
type-two 7a00c9f9a7 Library expansion: full obstacle set, polyline rails, street props
New heightfield kinds: hip/corner quarter, kicker (tunable concavity), roller,
wedge/manny pad, volcano, round-bowl option, quarter vert extension. Rails are
now polylines (kink/dbl-kink/rainbow/A-frame/pole-jam/donkey presets, round or
square profile, per-node drag handles) that flatten to straight segments on
export so bookquoy grind detection is untouched. 13 parametric street props
(jersey barrier, picnic table, parking block, fence, floodlight, bleachers...).
Palette regrouped TRANSITION/STREET/STEEL/PROPS; park check covers new kinds.

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

352 lines
14 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, lump, deck, vert ext
label: 'Quarter Pipe',
defaults: { len: 8, r0: 1.2, r1: 1.2, lump: 0, deck: 0.6, vert: 0 },
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 + (e.vert || 0); // deck; vert extension rises the wall face
return tranny(v, r);
},
},
corner: { // radial quarter wrapped around a point — hips & corners
label: 'Corner / Hip QP',
defaults: { r: 1.4, sweep: Math.PI / 2 },
height(e, x, z) {
const dx = x - e.x, dz = z - e.z;
const rho = Math.hypot(dx, dz);
if (rho >= e.r) return 0;
let ang = Math.atan2(dz, dx) - (e.rot || 0);
ang = ((ang % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
if (ang > e.sweep) return 0;
return tranny(rho, e.r);
},
},
kicker: { // launch wedge, blend linear->circular-concave
label: 'Kicker',
defaults: { len: 2.4, h: 0.7, run: 1.8, curve: 0.6, deck: 0.25 },
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;
if (v <= 0) return e.h;
const s = v / e.run; // 0 at lip, 1 at base
const lin = 1 - s;
const con = 1 - Math.sqrt(Math.max(0, 1 - (1 - s) ** 2));
return e.h * ((1 - (e.curve ?? 0)) * lin + (e.curve ?? 0) * con);
},
},
roller: { // pump bump / speed roller — gaussian ridge
label: 'Roller',
defaults: { len: 4, h: 0.45, s: 1.1 },
height(e, x, z) {
const [u, v] = local(e, x, z);
const over = Math.max(0, Math.abs(u) - e.len / 2); // rounded ends
return e.h * Math.exp(-(v * v + over * over) / (e.s * e.s));
},
},
wedgepad: { // inclined / step-up manny pad (sharp sides)
label: 'Wedge Pad',
defaults: { hw: 1.8, hd: 0.9, h0: 0.2, h1: 0.5 },
height(e, x, z) {
const [u, v] = local(e, x, z);
if (Math.abs(u) > e.hw || Math.abs(v) > e.hd) return 0;
return e.h0 + (e.h1 - e.h0) * (u / e.hw + 1) / 2;
},
},
volcano: { // truncated cone with flat skateable top
label: 'Volcano',
defaults: { r0: 0.7, run: 1.8, h: 0.9 },
height(e, x, z) {
const rho = Math.hypot(x - e.x, z - e.z);
if (rho <= e.r0) return e.h;
if (rho >= e.r0 + e.run) return 0;
return e.h * (1 - (rho - e.r0) / e.run);
},
},
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, round: 0 },
carve: true,
height(e, x, z) { // returns carve value, caller checks inside()
const [u, v] = local(e, x, z);
const dw = e.round ? e.size - Math.hypot(u, v)
: 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 e.round ? Math.hypot(u, v) < e.size
: Math.abs(u) < e.size && Math.abs(v) < e.size;
},
},
};
// ---------------------------------------------------------------- rails
// v2 rails are polylines: { id, name, kind:'rail'|'ledge', profile:'round'|'square',
// pts:[{x,z,y},...] }. v1 {a,b,ya,yb} files normalize on sight. The GAME contract
// stays straight segments — flattenRails() chops polylines for bookquoy's player.
export function normalizeRail(r) {
if (!r.pts) {
r.pts = [{ x: r.a[0], z: r.a[1], y: r.ya }, { x: r.b[0], z: r.b[1], y: r.yb }];
delete r.a; delete r.b; delete r.ya; delete r.yb;
}
r.profile ||= 'round';
return r;
}
export function flattenRails(park) {
const out = [];
for (const r of park.rails || []) {
normalizeRail(r);
for (let i = 0; i < r.pts.length - 1; i++) {
const a = r.pts[i], b = r.pts[i + 1];
out.push({ name: r.name, a: [a.x, a.z], b: [b.x, b.z], ya: a.y, yb: b.y, kind: r.kind });
}
}
return out;
}
// ---------------------------------------------------------------- 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 'kicker': { const [u, v] = local(e, x, z);
return Math.abs(u) <= e.len / 2 + M && v >= -e.deck - M && v <= e.run + M; }
case 'roller': { const [u, v] = local(e, x, z);
return Math.abs(u) <= e.len / 2 + e.s + M && Math.abs(v) <= e.s + M; }
case 'wedgepad': { const [u, v] = local(e, x, z);
return Math.abs(u) <= e.hw + M && Math.abs(v) <= e.hd + M; }
case 'volcano': return Math.hypot(x - e.x, z - e.z) <= e.r0 + e.run + M;
case 'corner': return Math.hypot(x - e.x, z - e.z) <= e.r + 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 'kicker': return rect(-e.len / 2, e.len / 2, -e.deck, e.run);
case 'roller': return rect(-e.len / 2 - e.s, e.len / 2 + e.s, -e.s, e.s);
case 'wedgepad': return rect(-e.hw, e.hw, -e.hd, e.hd);
case 'volcano': { const pts = [], R = e.r0 + e.run; for (let i = 0; i < 24; i++) {
const a = i / 24 * Math.PI * 2; pts.push([e.x + Math.cos(a) * R, e.z + Math.sin(a) * R]); }
return pts; }
case 'corner': { const pts = [[e.x, e.z]]; for (let i = 0; i <= 12; i++) {
const a = rot + i / 12 * e.sweep;
pts.push([e.x + Math.cos(a) * e.r, e.z + Math.sin(a) * e.r]); }
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: [],
};
}