// 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: [], }; }