New bowlpoly element: the rim is a polygon, every vertex a draggable handle with its own depth slider. Inside/outside corners, hips, shallow-to-deep escalators and waterfall drops all come from the same continuous carve (IDW floor blend, quarter-pipe wall transition) — no seams by construction. Auto pool coping (bullnose tube, exported as grindable ledge segments per rim edge) and tile band baked into the ground texture. Ships parks/pool_demo.json (THE POOLROOM) as a premade showcase; ridden end-to-end in test mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
418 lines
17 KiB
JavaScript
418 lines
17 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;
|
|
},
|
|
},
|
|
bowlpoly: { // modular pool: polygon rim, per-vertex depths.
|
|
// Inside corner = concave vertex, outside = convex, escalator = ramp the depths,
|
|
// waterfall = two close vertices with different depths. One continuous carve.
|
|
label: 'Custom Bowl',
|
|
defaults: {
|
|
pts: [{ u: 3.5, v: 0 }, { u: 2.2, v: 2.8 }, { u: -1.5, v: 3.2 },
|
|
{ u: -3.8, v: 0.8 }, { u: -3.0, v: -2.4 }, { u: 0.8, v: -3.2 }],
|
|
depths: [1.2, 1.2, 1.5, 1.8, 1.8, 1.5],
|
|
coping: 1, tile: 1,
|
|
},
|
|
carve: true,
|
|
inside(e, x, z) {
|
|
const [u, v] = local(e, x, z);
|
|
return pointInPoly(u, v, e.pts);
|
|
},
|
|
height(e, x, z) {
|
|
const [u, v] = local(e, x, z);
|
|
const dB = distToPoly(u, v, e.pts);
|
|
// smooth floor: inverse-distance-weighted vertex depths (shallow end slopes to deep)
|
|
let sw = 0, swd = 0;
|
|
for (let i = 0; i < e.pts.length; i++) {
|
|
const du = u - e.pts[i].u, dv = v - e.pts[i].v;
|
|
const w = 1 / (du * du + dv * dv + 0.25);
|
|
sw += w; swd += w * (e.depths[i] ?? 1.5);
|
|
}
|
|
const D = swd / sw;
|
|
return -D + tranny(Math.min(dB, D), D);
|
|
},
|
|
},
|
|
};
|
|
|
|
function pointInPoly(u, v, pts) {
|
|
let inside = false;
|
|
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
|
const a = pts[i], b = pts[j];
|
|
if ((a.v > v) !== (b.v > v) &&
|
|
u < (b.u - a.u) * (v - a.v) / (b.v - a.v) + a.u) inside = !inside;
|
|
}
|
|
return inside;
|
|
}
|
|
function distToPoly(u, v, pts) {
|
|
let best = Infinity;
|
|
for (let i = 0; i < pts.length; i++) {
|
|
const a = pts[i], b = pts[(i + 1) % pts.length];
|
|
const du = b.u - a.u, dv = b.v - a.v;
|
|
const t = clamp(((u - a.u) * du + (v - a.v) * dv) / (du * du + dv * dv || 1), 0, 1);
|
|
best = Math.min(best, Math.hypot(u - (a.u + t * du), v - (a.v + t * dv)));
|
|
}
|
|
return best;
|
|
}
|
|
// world-space rim polygon (editor handles, coping tube, tile bake)
|
|
export function bowlRimWorld(e) {
|
|
const c = Math.cos(e.rot || 0), s = Math.sin(e.rot || 0);
|
|
return e.pts.map(p => [e.x + p.u * c - p.v * s, e.z + p.u * s + p.v * c]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 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 });
|
|
}
|
|
}
|
|
// pool coping rims are grindable steel: one ledge segment per rim edge
|
|
for (const e of park.elements || []) {
|
|
if (e.kind !== 'bowlpoly' || !e.coping) continue;
|
|
const rim = bowlRimWorld(e);
|
|
for (let i = 0; i < rim.length; i++) {
|
|
const a = rim[i], b = rim[(i + 1) % rim.length];
|
|
out.push({ name: 'Pool Coping', a, b, ya: 0.02, yb: 0.02, kind: 'ledge' });
|
|
}
|
|
}
|
|
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; }
|
|
case 'bowlpoly': { const [u, v] = local(e, x, z);
|
|
return pointInPoly(u, v, e.pts) || distToPoly(u, v, e.pts) <= 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);
|
|
case 'bowlpoly': return bowlRimWorld(e);
|
|
}
|
|
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: [],
|
|
};
|
|
}
|