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>
352 lines
14 KiB
JavaScript
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: [],
|
|
};
|
|
}
|