skatemakerpro/js/parkcheck.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

99 lines
4.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SKATEMAKER PRO — park design linter. Encodes the real-park design brief
// (docs/DESIGN_PRINCIPLES.md) as live checks over the park data. World units are
// metres: street trannies want r 1.8-2.4, bowls 2.7-3.4, run-outs 3-4.5m of flat.
import { makeHeightAt, elementOutline } from './parkmath.js';
const STREET_KINDS = new Set(['ledge', 'funbox', 'stairs', 'pyramid']);
const TRANNY_KINDS = new Set(['quarter', 'spine', 'bowl', 'bank', 'mogul']);
export function checkPark(park) {
const out = [];
const warn = (sel, msg) => out.push({ sel, msg });
const heightAt = makeHeightAt(park);
const els = park.elements || [];
// --- pumping geometry: transition radii in the natural-feel band
for (const e of els) {
if (e.kind === 'quarter') {
const r = Math.min(e.r0, e.r1);
if (r < 0.9) warn({ type: 'element', id: e.id },
`tight quarter (r ${r}) — pumping geometry wants ~1.82.4m for street flow`);
}
if (e.kind === 'spine' && e.r < 0.9)
warn({ type: 'element', id: e.id },
`tight spine (r ${e.r}) — hard to pump; 1.2+ feels natural`);
if (e.kind === 'bowl' && e.depth > 1.2 && e.depth > e.size * 0.45)
warn({ type: 'element', id: e.id },
`bowl walls near-vertical (depth ${e.depth} vs size ${e.size}) — deep bowls want 2.73.4m radii`);
}
// --- run-outs: flat clear concrete along the facing direction of ramps
const isObstacleAt = (x, z, ignore) => {
const B = park.bounds;
if (x < B.x0 || x > B.x1 || z < B.z0 || z > B.z1) return true; // grass = no run-out
return heightAt(x, z) > 0.16;
};
for (const e of els) {
if (!['quarter', 'bank', 'stairs'].includes(e.kind)) continue;
const rot = e.rot || 0, c = Math.cos(rot), s = Math.sin(rot);
const start = e.kind === 'quarter' ? Math.max(e.r0, e.r1)
: e.kind === 'bank' ? e.run : e.steps * e.going;
let clear = 0;
for (let d = 0.5; d <= 4.5; d += 0.5) {
const v = start + d;
const x = e.x - v * s, z = e.z + v * c; // +v dir = (-sin, cos)
if (isObstacleAt(x, z)) break;
clear = d;
}
if (clear < 3) warn({ type: 'element', id: e.id },
`short run-out (${clear.toFixed(1)}m clear) — landings want 34.5m of flat before the next thing`);
}
// --- collision-free traffic: heavy footprint overlaps between big elements
const boxes = els.filter(e => e.kind !== 'mogul' && e.kind !== 'dome').map(e => {
const pts = elementOutline(e);
const xs = pts.map(p => p[0]), zs = pts.map(p => p[1]);
return { e, x0: Math.min(...xs), x1: Math.max(...xs), z0: Math.min(...zs), z1: Math.max(...zs) };
});
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
const a = boxes[i], b = boxes[j];
const ox = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
const oz = Math.min(a.z1, b.z1) - Math.max(a.z0, b.z0);
if (ox > 1.2 && oz > 1.2)
warn({ type: 'element', id: a.e.id },
`${a.e.kind} overlaps ${b.e.kind} — crossing lines create bail zones (intentional combos are fine)`);
}
// --- skill tiering: someone has to be able to learn here
const ledges = els.filter(e => e.kind === 'ledge');
if (ledges.length >= 2 && !ledges.some(e => e.h <= 0.28))
warn(null, 'no micro-ledge (≤0.25m) — beginners need low tier features off the main lines');
const qs = els.filter(e => e.kind === 'quarter');
if (qs.length >= 2 && !qs.some(e => Math.min(e.r0, e.r1) <= 1.0))
warn(null, 'all quarters are big — add a mellow one so beginners can drop in');
// --- terrain balance: street vs transition
const street = els.filter(e => STREET_KINDS.has(e.kind)).length;
const tranny = els.filter(e => TRANNY_KINDS.has(e.kind)).length;
if (street + tranny >= 6) {
if (street === 0) warn(null, 'all transition, no street — add ledges/stairs/flat rails');
if (tranny === 0) warn(null, 'all street, no transition — add quarters/banks/spines');
}
// --- steel: rails floating or buried
for (const r of park.rails || []) {
const ha = heightAt(r.a[0], r.a[1]), hb = heightAt(r.b[0], r.b[1]);
if (r.ya < ha - 0.05 || r.yb < hb - 0.05)
warn({ type: 'rail', id: r.id }, `"${r.name}" is buried in concrete — raise ya/yb`);
if (r.ya - ha > 1.6 || r.yb - hb > 1.6)
warn({ type: 'rail', id: r.id }, `"${r.name}" floats way above the deck — grinds will feel wrong`);
}
// --- spawn sanity
const sp = park.spawn;
if (heightAt(sp.x, sp.z) > 0.5)
warn({ type: 'spawn' }, 'spawn is on top of an obstacle — riders drop in blind');
return out;
}