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>
This commit is contained in:
type-two 2026-08-03 19:34:45 +10:00
commit db40f433fe
15 changed files with 2235 additions and 0 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "skatemakerpro",
"runtimeExecutable": "python3",
"runtimeArgs": ["serve.py", "8093"],
"port": 8093
}
]
}

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.DS_Store
assets/uploads/
__pycache__/

59
README.md Normal file
View File

@ -0,0 +1,59 @@
# SKATEMAKER PRO — stand-alone park builder
The park editor for **BOOKQUOY** (and whatever comes after it). Born from bookquoy's one
rule: **`heightAt(x,z)` is the single source of truth** — the same element data drives the
editor viewport, the collision, the test ride, and the exported game level. Shape it in the
editor and it *is* the real thing.
## Run it
```
python3 serve.py # http://localhost:8093
```
`serve.py` is static hosting **plus** the MODELBEAST proxy (token read from
`~/Documents/backnforth/.env` server-side — never shipped to the browser) and park/asset
persistence (`parks/`, `assets/uploads/`).
## The editor
- **BUILD palette** — quarter, bank, funbox, pyramid, ledge, stairs, spine, mogul, bowl,
island. Click a piece, click the ground. Drag to move, `[` `]` rotate, `D` dupe,
`⌫` delete, `⌘Z` undo, optional 0.5m grid snap (THPS style).
- **SPOTS** — rails/ledge-caps (two clicks, draggable end-balls), named score gaps,
B·O·O·K·Q·U·O·Y letters, spawn point.
- **SURFACE** — paint zones (rect/ellipse, any color), **image decals** (drop any image on
the window, or generate one on the farm), and a **reference underlay** for tracing a real
park from an aerial photo.
- **MODELBEAST panel** — browse farm assets, one-click **PLACE** any GLB as a park prop,
use any farm image as a decal, **GEN IMAGE** (flux_local, ~9s), **MAKE 3D**
(hunyuan3d_mlx image→GLB). Everything free and local on the m3ultra queue.
- **PARK CHECK** — live design linter encoding `docs/DESIGN_PRINCIPLES.md`: pumping
geometry, run-out clearance, crossing lines, skill tiering, buried/floating steel.
- **▶ TEST RIDE** (`Enter`) — instant Create-A-Park-style build↔skate toggle with
bookquoy's physics constants over the live data. W push, A/D steer, Space ollie.
## Park format
`parks/*.json` — bounds, spawn, `elements` (parametric primitives evaluated by
`js/parkmath.js`), `rails`, `gaps`, `letters`, `zones`, `decals`, `props`, optional
`underlay`. `parks/paddo.json` is Paddington Skatepark ported from bookquoy — load it,
ride it, remix it.
## Export → BOOKQUOY
**⬇ LEVEL.JS** generates `level.<name>.js` exporting bookquoy's exact `level.js` contract
(`heightAt, normalAt, BOUNDS, RAILS, GAPS, LETTERS, SPAWN, buildLevel`). To ship a park:
1. copy `js/parkmath.js` + `js/parkbuild.js` + the generated `level.<name>.js` into
`bookquoy/js/`, and any `assets/uploads/` files it references
2. point the two imports (`main.js`, `player.js`) at the new level file
**⬇ JSON** downloads the raw park data; **SAVE/LOAD…** persist to `parks/` on disk.
## Lineage
- bookquoy (`../bookquoy`) — the game this feeds; level 01 PADDO
- `~/Documents/thwk/THPS_STUDY.md` — Neversoft architecture study (technique only,
no extracted assets ever ship)
- THPS Create-A-Park — inspiration for the palette/test-loop/themes/premade-parks shape

36
docs/DESIGN_PRINCIPLES.md Normal file
View File

@ -0,0 +1,36 @@
# What makes a great skatepark
The design brief behind PARK CHECK (`js/parkcheck.js`). World units are metres;
bookquoy scale maps 1:1 (a 1.8 radius quarter = a 6ft quarter).
## Flow & line architecture
- **Collision-free traffic paths** — no intersecting lines; nothing drops in across
another feature's run.
- **Adequate flatland & run-outs** — 1015ft (34.5m) of clear flat for setup and landing
recovery. Tight squeezes kill speed and create bottleneck bail zones.
- **Pumping geometry** — transition radii that feel natural: 68ft (1.82.4m) for
street-flow sections, 911ft+ (2.73.4m) for deep bowls.
## Obstacle variety & progression
- **Skill tiering** — 810in (0.20.25m) micro-ledges and mellow banks alongside the
1418in (0.350.45m) standard ledges, handrails and hubbas, so beginners progress
without occupying main lines.
- **Terrain balance** — street (stairs, manual pads, flatbars, euro-gaps) paired with
transition (spines, hips, speed bumps, open bowls).
- **Coping mechanics** — consistent reveal (~3/8in proud above and in front of the lip)
for predictable grinds; pool coping and tile in advanced bowls. *(Game mapping: rails
hug the deck height — PARK CHECK flags buried or floating steel.)*
## Surface & build quality
- Smooth high-PSI steel-trowelled concrete; prefab metal/wood deteriorates and seams at
the transition base. *(Game mapping: one continuous heightfield — no seams by
construction.)*
- Expansion joints routed away from landings and slide paths.
## Drainage & sightlines
- Drains and micro-slopes strictly outside trick lines; no puddle pockets.
- Open sightlines across the footprint to spot traffic and clear landings.
## Amenities & community
- Lighting for evenings, shaded perimeter seating clear of run-ups, water, bins, transit
access. *(Game mapping: props/set dressing live outside the bounds slab.)*

104
index.html Normal file
View File

@ -0,0 +1,104 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>SKATEMAKER PRO — park builder</title>
<style>
:root{ --bg:#14181e; --panel:#1c222b; --panel2:#232b36; --ink:#dbe4ee; --dim:#7d8a99;
--gold:#ffd93b; --gold2:#b8860b; --line:#2e3947; --danger:#ff6b6b; }
*{box-sizing:border-box}
body{margin:0;overflow:hidden;background:var(--bg);color:var(--ink);
font:13px/1.4 "SF Mono",Menlo,monospace}
#app{display:grid;height:100vh;
grid-template-rows:46px 1fr 26px;
grid-template-columns:170px 1fr 230px;
grid-template-areas:"top top top" "pal view insp" "status status status"}
#topbar{grid-area:top;display:flex;align-items:center;gap:8px;padding:0 12px;
background:var(--panel);border-bottom:1px solid var(--line)}
.logo{font-weight:bold;font-size:16px;letter-spacing:1px;margin-right:6px;color:var(--ink)}
.logo b{color:var(--gold)}
#palette{grid-area:pal;overflow-y:auto;background:var(--panel);
border-right:1px solid var(--line);padding:10px}
#viewport{grid-area:view;position:relative;min-width:0;min-height:0}
#viewport canvas{display:block}
#inspector{grid-area:insp;overflow-y:auto;background:var(--panel);
border-left:1px solid var(--line);padding:10px}
#status{grid-area:status;display:flex;align-items:center;padding:0 12px;
background:var(--panel);border-top:1px solid var(--line);color:var(--dim);
white-space:nowrap;overflow:hidden}
#drawer{display:none;position:fixed;left:178px;bottom:34px;width:400px;max-height:56vh;
flex-direction:column;gap:8px;overflow-y:auto;background:var(--panel2);
border:1px solid var(--line);border-radius:8px;padding:12px;z-index:20;
box-shadow:0 12px 40px #000a}
.ptitle{color:var(--dim);font-size:11px;letter-spacing:2px;margin:14px 0 6px}
.ptitle:first-child{margin-top:0}
.ptitle .dim{letter-spacing:0;color:#5a6673}
.pgrid{display:grid;grid-template-columns:1fr 1fr;gap:5px}
.pcol{display:flex;flex-direction:column;gap:5px}
.pbtn{background:var(--panel2);border:1px solid var(--line);color:var(--ink);
font:inherit;font-size:11px;padding:7px 6px;border-radius:5px;cursor:pointer;
letter-spacing:.5px}
.pbtn:hover{border-color:var(--gold);color:var(--gold)}
.pbtn.active{background:var(--gold);color:#1a1a1a;border-color:var(--gold);font-weight:bold}
.pbtn.accent{border-color:var(--gold2);color:var(--gold)}
.pbtn.danger{color:var(--danger)}
.pbtn.wide{width:100%}
.pbtn.test{font-weight:bold}
.snaprow{display:flex;gap:6px;align-items:center;color:var(--dim);font-size:11px;
padding:4px 2px;cursor:pointer}
.ititle{font-weight:bold;color:var(--gold);letter-spacing:1px;margin-bottom:8px}
.ihint{color:var(--dim);font-size:11px;margin:8px 0}
.irow{display:flex;align-items:center;gap:6px;margin:5px 0}
.irow>label{width:66px;flex-shrink:0;color:var(--dim);font-size:11px}
.numwrap{display:flex;align-items:center;gap:5px;flex:1;min-width:0}
input[type=range]{flex:1;min-width:0;accent-color:var(--gold)}
input.num{width:52px;background:var(--panel2);border:1px solid var(--line);
color:var(--ink);font:inherit;font-size:11px;padding:2px 4px;border-radius:4px}
input.text,select{background:var(--panel2);border:1px solid var(--line);color:var(--ink);
font:inherit;font-size:12px;padding:4px 6px;border-radius:4px;min-width:0}
input.parkname{width:150px;color:var(--gold);font-weight:bold}
input[type=color]{background:none;border:1px solid var(--line);border-radius:4px;
width:44px;height:24px;padding:1px}
.ibtns{display:flex;gap:6px;margin-top:12px}
.ibtns .pbtn{flex:1}
.checklist{display:flex;flex-direction:column;gap:5px}
.checkitem{background:var(--panel);border:1px solid var(--line);border-radius:5px;
padding:7px 9px;font-size:12px;color:#f0c674}
.checkitem.link{cursor:pointer}
.checkitem.link:hover{border-color:var(--gold)}
.checkok{color:#7bd88f;padding:8px}
.mbgen{display:flex;flex-wrap:wrap;gap:6px}
.mbgen input{flex:1 1 100%}
.mbstatus{color:var(--dim);font-size:11px;flex:1;align-self:center}
.mblist{display:flex;flex-direction:column;gap:4px;max-height:30vh;overflow-y:auto}
.mbitem{display:flex;align-items:center;gap:6px;background:var(--panel);
border:1px solid var(--line);border-radius:5px;padding:5px 8px}
.mbname{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px}
</style>
</head>
<body>
<div id="app">
<div id="topbar"></div>
<div id="palette"></div>
<div id="viewport"></div>
<div id="inspector"></div>
<div id="status">booting…</div>
</div>
<div id="drawer"></div>
<script type="importmap">
{"imports":{"three":"https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/"}}
</script>
<script type="module">
import { initEditor } from './js/editor.js';
import { initUI } from './js/ui.js';
import { attachTestRide } from './js/testride.js';
const ed = initEditor(document.getElementById('viewport'));
attachTestRide(ed);
ed.boot().then(() => initUI(ed));
window.__ed = ed; // headless testing hook
</script>
</body>
</html>

538
js/editor.js Normal file
View File

@ -0,0 +1,538 @@
// SKATEMAKER PRO — editor core. Scene, picking, dragging, undo, rebuilds.
// The park is DATA; every edit mutates park JSON and the world rebuilds from it —
// so the editor can never lie about what the game will do.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { makeHeightAt, newElement, newId, elementHit, elementOutline, emptyPark }
from './parkmath.js';
import { buildGround, buildRails, buildProps, buildLetters, bakeGroundTexture,
GRID_PAD } from './parkbuild.js';
const AUTOSAVE_KEY = 'smp_autosave_v1';
export function initEditor(container) {
// ---------------------------------------------------------------- three
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a2028);
scene.fog = new THREE.Fog(0x1a2028, 90, 220);
const cam = new THREE.PerspectiveCamera(55, 1, 0.1, 500);
cam.position.set(0, 42, 46);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
container.appendChild(renderer.domElement);
scene.add(new THREE.HemisphereLight(0xffffff, 0x3a4436, 1.05));
const sun = new THREE.DirectionalLight(0xfff3d9, 1.5);
sun.position.set(30, 46, 18); scene.add(sun);
const controls = new OrbitControls(cam, renderer.domElement);
controls.enableDamping = true; controls.dampingFactor = 0.12;
controls.maxPolarAngle = Math.PI * 0.49;
controls.mouseButtons = { LEFT: -1, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE };
controls.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_PAN };
controls.keyPanSpeed = 20;
const resize = () => {
const w = container.clientWidth, h = container.clientHeight;
renderer.setSize(w, h); cam.aspect = w / h; cam.updateProjectionMatrix();
};
new ResizeObserver(resize).observe(container);
// ---------------------------------------------------------------- state
const ed = {
scene, cam, renderer, controls,
park: null, heightAt: null,
selection: null, // {type:'element'|'rail'|'gap'|'letter'|'spawn'|'prop'|'zone'|'decal', id}
tool: null, // {mode:'place', kind} | {mode:'rail'} | {mode:'gap'}...
testing: false,
snapStep: 0, // 0 = free, 0.5 = THPS-style grid snap
listeners: {},
on(ev, fn) { (this.listeners[ev] ||= []).push(fn); },
emit(ev, ...a) { for (const f of this.listeners[ev] || []) f(...a); },
};
let ground = null, railsGrp = null, propsGrp = null, letterMeshes = [],
markersGrp = new THREE.Group(), selGrp = new THREE.Group(),
underlayMesh = null;
scene.add(markersGrp, selGrp);
// ---------------------------------------------------------------- rebuilds
function rebuildGround() {
if (ground) {
scene.remove(ground);
ground.geometry.dispose(); ground.material.map?.dispose(); ground.material.dispose();
}
ground = buildGround(ed.park);
scene.add(ground);
}
function rebakeTexture() {
if (!ground) return;
const B = ed.park.bounds;
ground.material.map?.dispose();
ground.material.map = bakeGroundTexture(ed.park,
B.x0 - GRID_PAD, B.x1 + GRID_PAD, B.z0 - GRID_PAD, B.z1 + GRID_PAD);
ground.material.needsUpdate = true;
}
function rebuildRails() {
if (railsGrp) scene.remove(railsGrp);
railsGrp = buildRails(ed.park); scene.add(railsGrp);
}
function rebuildProps() {
if (propsGrp) scene.remove(propsGrp);
propsGrp = buildProps(ed.park, ed.heightAt); scene.add(propsGrp);
}
function rebuildMarkers() {
markersGrp.clear();
for (const m of letterMeshes) scene.remove(m);
letterMeshes = buildLetters(ed.park, ed.heightAt);
for (const m of letterMeshes) scene.add(m);
const ringMat = new THREE.LineBasicMaterial({ color: 0xffd93b, transparent: true, opacity: 0.75 });
for (const g of ed.park.gaps) {
const pts = [];
for (let i = 0; i <= 32; i++) {
const a = i / 32 * Math.PI * 2, x = g.x + Math.cos(a) * g.r, z = g.z + Math.sin(a) * g.r;
pts.push(new THREE.Vector3(x, ed.heightAt(x, z) + 0.12, z));
}
markersGrp.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), ringMat));
}
const s = ed.park.spawn;
const cone = new THREE.Mesh(new THREE.ConeGeometry(0.35, 1.1, 8),
new THREE.MeshBasicMaterial({ color: 0xff8c3b }));
cone.rotation.x = Math.PI / 2; cone.rotation.z = -s.heading;
const holder = new THREE.Group(); holder.add(cone);
cone.position.z = 0.4;
holder.rotation.y = s.heading;
holder.position.set(s.x, ed.heightAt(s.x, s.z) + 0.5, s.z);
markersGrp.add(holder);
}
function rebuildUnderlay() {
if (underlayMesh) { scene.remove(underlayMesh); underlayMesh.material.map?.dispose(); underlayMesh = null; }
const u = ed.park.underlay;
if (!u || !u.img || !u.visible) return;
const tex = new THREE.TextureLoader().load(u.img, t => {
t.colorSpace = THREE.SRGBColorSpace;
const asp = t.image.height / t.image.width;
underlayMesh.scale.set(u.w, u.w * asp, 1);
});
underlayMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1),
new THREE.MeshBasicMaterial({ map: tex, transparent: true, opacity: u.opacity ?? 0.5,
depthWrite: false }));
underlayMesh.rotation.x = -Math.PI / 2;
underlayMesh.rotation.z = u.rot || 0;
underlayMesh.position.set(u.x || 0, (u.y ?? 6), u.z || 0);
underlayMesh.renderOrder = 5;
scene.add(underlayMesh);
}
// selection highlight
function rebuildSelection() {
selGrp.clear();
const sel = ed.selection, obj = getSelected();
if (!sel || !obj) return;
const mat = new THREE.LineBasicMaterial({ color: 0xffd93b });
const loop = pts2 => {
const pts = pts2.map(([x, z]) => new THREE.Vector3(x, ed.heightAt(x, z) + 0.1, z));
selGrp.add(new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(pts), mat));
};
const handle = (x, y, z, big) => {
const m = new THREE.Mesh(new THREE.SphereGeometry(big ? 0.28 : 0.18, 12, 8),
new THREE.MeshBasicMaterial({ color: 0xffd93b }));
m.position.set(x, y, z); m.userData.handle = true; selGrp.add(m); return m;
};
if (sel.type === 'element') loop(elementOutline(obj));
else if (sel.type === 'rail') {
const a = new THREE.Vector3(obj.a[0], obj.ya, obj.a[1]);
const b = new THREE.Vector3(obj.b[0], obj.yb, obj.b[1]);
selGrp.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([a, b]), mat));
handle(a.x, a.y, a.z, true).userData.railEnd = 'a';
handle(b.x, b.y, b.z, true).userData.railEnd = 'b';
} else if (sel.type === 'gap') {
const pts = [];
for (let i = 0; i < 32; i++) {
const a = i / 32 * Math.PI * 2;
pts.push([obj.x + Math.cos(a) * obj.r, obj.z + Math.sin(a) * obj.r]);
}
loop(pts);
} else if (sel.type === 'letter') {
handle(obj.x, ed.heightAt(obj.x, obj.z) + obj.y, obj.z, true);
} else if (sel.type === 'spawn') {
handle(obj.x, ed.heightAt(obj.x, obj.z) + 0.5, obj.z, true);
} else if (sel.type === 'prop') {
const o = propsGrp?.children.find(c => c.userData.propId === obj.id);
if (o) selGrp.add(new THREE.BoxHelper(o, 0xffd93b));
} else if (sel.type === 'zone' || sel.type === 'decal') {
const hw = sel.type === 'zone' ? obj.hw : obj.w / 2,
hd = sel.type === 'zone' ? obj.hd : obj.h / 2;
const c = Math.cos(obj.rot || 0), s = Math.sin(obj.rot || 0);
const W = (u, v) => [obj.x + u * c - v * s, obj.z + u * s + v * c];
loop([W(-hw, -hd), W(hw, -hd), W(hw, hd), W(-hw, hd)]);
}
}
let groundTimer = 0, groundDirty = false;
function requestGround() {
groundDirty = true;
const now = performance.now();
if (now - groundTimer > 90) { groundTimer = now; groundDirty = false; rebuildGround(); }
}
function rebuildAll() {
ed.heightAt = makeHeightAt(ed.park);
rebuildGround(); rebuildRails(); rebuildProps(); rebuildMarkers();
rebuildUnderlay(); rebuildSelection();
}
// ---------------------------------------------------------------- undo / persistence
const undoStack = [], redoStack = [];
function snapshot() {
undoStack.push(JSON.stringify(ed.park));
if (undoStack.length > 120) undoStack.shift();
redoStack.length = 0;
}
function autosave() {
try { localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(ed.park)); } catch {}
}
// parts: {ground, rails, props, markers, tex, underlay} — default everything cheap
ed.commit = (parts = {}) => {
if (parts.snapshot !== false) snapshot();
if (parts.ground) rebuildGround();
if (parts.tex) rebakeTexture();
if (parts.rails) rebuildRails();
if (parts.props) rebuildProps();
if (parts.markers !== false) rebuildMarkers();
if (parts.underlay) rebuildUnderlay();
rebuildSelection();
autosave();
ed.emit('change');
};
ed.undo = () => {
if (!undoStack.length) return;
redoStack.push(JSON.stringify(ed.park));
ed.park = JSON.parse(undoStack.pop());
ed.selection = null; rebuildAll(); autosave(); ed.emit('change'); ed.emit('select');
};
ed.redo = () => {
if (!redoStack.length) return;
undoStack.push(JSON.stringify(ed.park));
ed.park = JSON.parse(redoStack.pop());
ed.selection = null; rebuildAll(); autosave(); ed.emit('change'); ed.emit('select');
};
ed.loadPark = json => {
ed.park = json;
for (const list of [json.elements, json.rails, json.gaps, json.letters,
json.zones, json.decals, json.props])
for (const o of list || []) if (!o.id) o.id = newId();
ed.selection = null; undoStack.length = redoStack.length = 0;
rebuildAll(); autosave(); ed.emit('change'); ed.emit('select');
};
ed.newPark = name => ed.loadPark(emptyPark(name));
// ---------------------------------------------------------------- selection helpers
function getSelected() {
const s = ed.selection;
if (!s || !ed.park) return null;
const P = ed.park;
switch (s.type) {
case 'element': return P.elements.find(e => e.id === s.id);
case 'rail': return P.rails.find(e => e.id === s.id);
case 'gap': return P.gaps.find(e => e.id === s.id);
case 'letter': return P.letters.find(e => e.id === s.id);
case 'prop': return P.props.find(e => e.id === s.id);
case 'zone': return P.zones.find(e => e.id === s.id);
case 'decal': return P.decals.find(e => e.id === s.id);
case 'spawn': return P.spawn;
}
return null;
}
ed.getSelected = getSelected;
ed.select = sel => { ed.selection = sel; rebuildSelection(); ed.emit('select'); };
ed.deleteSelected = () => {
const s = ed.selection;
if (!s || s.type === 'spawn') return;
const lists = { element: 'elements', rail: 'rails', gap: 'gaps', letter: 'letters',
prop: 'props', zone: 'zones', decal: 'decals' };
const arr = ed.park[lists[s.type]];
const i = arr.findIndex(e => e.id === s.id);
if (i < 0) return;
snapshot(); arr.splice(i, 1); ed.selection = null;
rebuildAll(); autosave(); ed.emit('change'); ed.emit('select');
};
ed.duplicateSelected = () => {
const s = ed.selection, obj = getSelected();
if (!obj || s.type === 'spawn') return;
const lists = { element: 'elements', rail: 'rails', gap: 'gaps', letter: 'letters',
prop: 'props', zone: 'zones', decal: 'decals' };
const copy = structuredClone(obj); copy.id = newId();
if (copy.x !== undefined) { copy.x += 2; copy.z += 2; }
if (copy.a) { copy.a = [copy.a[0] + 2, copy.a[1] + 2]; copy.b = [copy.b[0] + 2, copy.b[1] + 2]; }
snapshot(); ed.park[lists[s.type]].push(copy);
ed.selection = { type: s.type, id: copy.id };
rebuildAll(); autosave(); ed.emit('change'); ed.emit('select');
};
// ---------------------------------------------------------------- picking
const ray = new THREE.Raycaster();
const ndc = new THREE.Vector2();
const planeY0 = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
function groundPoint(ev) {
const r = renderer.domElement.getBoundingClientRect();
ndc.set((ev.clientX - r.left) / r.width * 2 - 1, -((ev.clientY - r.top) / r.height) * 2 + 1);
ray.setFromCamera(ndc, cam);
const hit = ground ? ray.intersectObject(ground)[0] : null;
if (hit) return hit.point;
const p = new THREE.Vector3();
return ray.ray.intersectPlane(planeY0, p) ? p : null;
}
const distSeg = (px, pz, a, b) => {
const dx = b[0] - a[0], dz = b[1] - a[1];
const t = Math.max(0, Math.min(1, ((px - a[0]) * dx + (pz - a[1]) * dz) / (dx * dx + dz * dz || 1)));
return Math.hypot(px - (a[0] + t * dx), pz - (a[1] + t * dz));
};
function pick(ev, p) {
// 1. selection handles (rail endpoints)
const hs = ray.intersectObjects(selGrp.children.filter(o => o.userData.handle));
if (hs.length && hs[0].object.userData.railEnd)
return { type: 'railEnd', end: hs[0].object.userData.railEnd };
if (!p) return null;
const P = ed.park, x = p.x, z = p.z;
// 2. props (real raycast)
if (propsGrp) {
const ph = ray.intersectObjects(propsGrp.children, true)[0];
if (ph) { const id = ph.object.userData.propId;
if (id) return { type: 'prop', id }; }
}
// 3. letters
for (const L of P.letters) if (Math.hypot(x - L.x, z - L.z) < 1.0)
return { type: 'letter', id: L.id };
// 4. spawn
if (Math.hypot(x - P.spawn.x, z - P.spawn.z) < 1.0) return { type: 'spawn' };
// 5. rails
for (const r of P.rails) if (distSeg(x, z, r.a, r.b) < 0.6)
return { type: 'rail', id: r.id };
// 6. gaps (ring click — near the radius, not anywhere inside)
for (const g of P.gaps) { const d = Math.hypot(x - g.x, z - g.z);
if (Math.abs(d - g.r) < 0.5) return { type: 'gap', id: g.id }; }
// 7. elements, topmost drawn last
for (let i = P.elements.length - 1; i >= 0; i--)
if (elementHit(P.elements[i], x, z)) return { type: 'element', id: P.elements[i].id };
// 8. zones / decals
const inRect = (o, hw, hd) => {
const c = Math.cos(o.rot || 0), s = Math.sin(o.rot || 0);
const dx = x - o.x, dz = z - o.z;
return Math.abs(dx * c + dz * s) <= hw && Math.abs(-dx * s + dz * c) <= hd;
};
for (let i = (P.decals || []).length - 1; i >= 0; i--)
if (inRect(P.decals[i], P.decals[i].w / 2, P.decals[i].h / 2))
return { type: 'decal', id: P.decals[i].id };
for (let i = (P.zones || []).length - 1; i >= 0; i--)
if (inRect(P.zones[i], P.zones[i].hw, P.zones[i].hd))
return { type: 'zone', id: P.zones[i].id };
return null;
}
// ---------------------------------------------------------------- tools & placement
ed.setTool = tool => { ed.tool = tool; pendingRail = null; ed.emit('tool'); };
let pendingRail = null; // first click of a rail placement
const snapV = v => ed.snapStep ? Math.round(v / ed.snapStep) * ed.snapStep : +v.toFixed(2);
function place(p) {
const t = ed.tool, P = ed.park;
const x = snapV(p.x), z = snapV(p.z);
if (t.mode === 'place') {
const e = newElement(t.kind, x, z);
snapshot(); P.elements.push(e);
ed.selection = { type: 'element', id: e.id };
rebuildGround(); ed.commit({ snapshot: false });
} else if (t.mode === 'rail') {
const y = ed.heightAt(x, z) + 0.4;
if (!pendingRail) { pendingRail = { x, z, y }; ed.emit('status', 'rail: click the far end'); return; }
const r = { id: newId(), name: 'Rail', a: [pendingRail.x, pendingRail.z], b: [x, z],
ya: +pendingRail.y.toFixed(2), yb: +y.toFixed(2), kind: 'rail' };
snapshot(); P.rails.push(r); pendingRail = null;
ed.selection = { type: 'rail', id: r.id };
ed.commit({ snapshot: false, rails: true });
ed.setTool(null);
} else if (t.mode === 'gap') {
const g = { id: newId(), name: 'NEW GAP', x, z, r: 2.4 };
snapshot(); P.gaps.push(g);
ed.selection = { type: 'gap', id: g.id };
ed.commit({ snapshot: false }); ed.setTool(null);
} else if (t.mode === 'letter') {
const used = P.letters.map(l => l.ch).join('');
const next = 'BOOKQUOY'.split('').find((c, i) => 'BOOKQUOY'.slice(0, i + 1).split(c).length - 1 >
used.split(c).length - 1) || 'O';
const L = { id: newId(), ch: next, x, y: 1.8, z };
snapshot(); P.letters.push(L);
ed.selection = { type: 'letter', id: L.id };
ed.commit({ snapshot: false }); ed.setTool(null);
} else if (t.mode === 'spawn') {
snapshot(); P.spawn.x = x; P.spawn.z = z;
ed.selection = { type: 'spawn' };
ed.commit({ snapshot: false }); ed.setTool(null);
} else if (t.mode === 'zone') {
const zn = { id: newId(), shape: 'rect', x, z, rot: 0, hw: 3, hd: 3,
color: '#9aa0a2', opacity: 1 };
snapshot(); P.zones.push(zn);
ed.selection = { type: 'zone', id: zn.id };
ed.commit({ snapshot: false, tex: true }); ed.setTool(null);
} else if (t.mode === 'decal') {
const d = { id: newId(), img: t.img, x, z, rot: 0, w: t.w || 6,
h: (t.w || 6) * (t.aspect || 1), opacity: 1 };
snapshot(); P.decals.push(d);
ed.selection = { type: 'decal', id: d.id };
ed.commit({ snapshot: false, tex: true }); ed.setTool(null);
} else if (t.mode === 'prop') {
const pr = { id: newId(), src: t.src, x, z, rot: 0, scale: t.scale || 1 };
snapshot(); P.props.push(pr);
ed.selection = { type: 'prop', id: pr.id };
ed.commit({ snapshot: false, props: true }); ed.setTool(null);
}
}
// ---------------------------------------------------------------- drag
let drag = null; // {kind:'move'|'railEnd', grabDX, grabDZ, end, moved}
renderer.domElement.addEventListener('pointerdown', ev => {
if (ev.button !== 0 || ed.testing) return;
const p = groundPoint(ev);
if (ed.tool) { if (p) place(p); return; }
const hit = pick(ev, p);
if (!hit) { ed.select(null); return; }
if (hit.type === 'railEnd') {
drag = { kind: 'railEnd', end: hit.end, moved: false };
controls.enabled = false; snapshot(); return;
}
ed.select({ type: hit.type, id: hit.id });
const obj = getSelected();
if (obj && p) {
drag = { kind: 'move', moved: false,
grabDX: (obj.x ?? obj.a?.[0] ?? 0) - p.x,
grabDZ: (obj.z ?? obj.a?.[1] ?? 0) - p.z };
controls.enabled = false; snapshot();
}
});
renderer.domElement.addEventListener('pointermove', ev => {
if (!drag || ed.testing) return;
const p = groundPoint(ev); if (!p) return;
const s = ed.selection, obj = getSelected(); if (!obj) return;
drag.moved = true;
if (drag.kind === 'railEnd') {
const end = drag.end;
obj[end] = [snapV(p.x), snapV(p.z)];
obj[end === 'a' ? 'ya' : 'yb'] = +(ed.heightAt(p.x, p.z) + 0.4).toFixed(2);
rebuildRails(); rebuildSelection(); return;
}
const nx = snapV(p.x + drag.grabDX), nz = snapV(p.z + drag.grabDZ);
if (s.type === 'rail') {
const dx = nx - obj.a[0], dz = nz - obj.a[1];
obj.a = [obj.a[0] + dx, obj.a[1] + dz]; obj.b = [obj.b[0] + dx, obj.b[1] + dz];
rebuildRails();
} else { obj.x = nx; obj.z = nz; }
if (s.type === 'element') requestGround();
if (s.type === 'zone' || s.type === 'decal') rebakeTexture();
if (s.type === 'prop') rebuildProps();
if (s.type === 'letter' || s.type === 'gap' || s.type === 'spawn') rebuildMarkers();
rebuildSelection();
ed.emit('select'); // live-update inspector numbers
});
const endDrag = () => {
if (!drag) return;
const wasMoved = drag.moved, s = ed.selection;
drag = null; controls.enabled = true;
if (!wasMoved) { undoStack.pop(); return; } // click, no move — drop the snapshot
if (s?.type === 'element') rebuildGround();
ed.commit({ snapshot: false, rails: s?.type === 'rail' || s?.type === 'railEnd' });
};
renderer.domElement.addEventListener('pointerup', endDrag);
renderer.domElement.addEventListener('pointerleave', endDrag);
// ---------------------------------------------------------------- param edits from UI
ed.updateSelected = (key, value, opts = {}) => {
const obj = getSelected(); if (!obj) return;
obj[key] = value;
const t = ed.selection.type;
if (t === 'element') requestGround();
if (t === 'zone' || t === 'decal') rebakeTexture();
if (t === 'rail') rebuildRails();
if (t === 'prop') rebuildProps();
rebuildMarkers(); rebuildSelection();
if (opts.done) { // slider released / field committed
if (t === 'element') rebuildGround();
ed.commit({ snapshot: true, rails: t === 'rail', props: t === 'prop' });
}
};
// ---------------------------------------------------------------- keys
addEventListener('keydown', ev => {
if (ed.testing) return;
if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'SELECT' ||
ev.target.tagName === 'TEXTAREA') return;
const obj = getSelected();
if ((ev.metaKey || ev.ctrlKey) && ev.code === 'KeyZ') {
ev.preventDefault(); ev.shiftKey ? ed.redo() : ed.undo(); return;
}
if ((ev.metaKey || ev.ctrlKey) && ev.code === 'KeyY') { ev.preventDefault(); ed.redo(); return; }
switch (ev.code) {
case 'Escape': ed.setTool(null); ed.select(null); break;
case 'Backspace': case 'Delete': ed.deleteSelected(); break;
case 'KeyD': if (!ev.metaKey) ed.duplicateSelected(); break;
case 'BracketLeft': case 'BracketRight': {
if (!obj || obj.rot === undefined) break;
const step = (ev.code === 'BracketLeft' ? -1 : 1) * (ev.shiftKey ? 5 : 15) * Math.PI / 180;
ed.updateSelected('rot', +(obj.rot + step).toFixed(3), { done: true });
ed.emit('select');
break;
}
case 'KeyT': ed.topView(); break;
}
});
ed.topView = () => {
const B = ed.park.bounds;
const cx = (B.x0 + B.x1) / 2, cz = (B.z0 + B.z1) / 2;
controls.target.set(cx, 0, cz);
cam.position.set(cx, Math.max(B.x1 - B.x0, B.z1 - B.z0) * 1.05, cz + 0.01);
};
// ---------------------------------------------------------------- boot + loop
let extraUpdate = null;
ed.setExtraUpdate = fn => { extraUpdate = fn; };
let last = performance.now();
function frame(now) {
requestAnimationFrame(frame);
const dt = Math.min((now - last) / 1000, 0.05); last = now;
if (groundDirty && now - groundTimer > 90) { groundTimer = now; groundDirty = false; rebuildGround(); }
for (const m of letterMeshes) { m.rotation.y += dt * 2.2; }
if (extraUpdate) extraUpdate(dt);
else controls.update();
renderer.render(scene, cam);
}
// deterministic stepper for headless testing (browser pane throttles rAF when
// hidden — same lesson, and same convention, as bookquoy's window.__step)
ed.step = (seconds = 1) => {
const n = Math.round(seconds * 60);
for (let i = 0; i < n; i++) {
const dt = 1 / 60;
if (extraUpdate) extraUpdate(dt); else controls.update();
}
renderer.render(scene, cam);
};
ed.boot = async () => {
resize();
let loaded = null;
try { loaded = JSON.parse(localStorage.getItem(AUTOSAVE_KEY)); } catch {}
if (!loaded) {
try { loaded = await fetch('parks/paddo.json').then(r => r.json()); } catch {}
}
ed.loadPark(loaded || emptyPark('NEW PARK'));
ed.topView();
requestAnimationFrame(frame);
};
return ed;
}

49
js/export.js Normal file
View File

@ -0,0 +1,49 @@
// SKATEMAKER PRO — exports. A park leaves here two ways:
// 1. park.json — the native data format
// 2. level.<name>.js — a drop-in for BOOKQUOY: exports the exact interface
// bookquoy's js/level.js has (heightAt, normalAt, BOUNDS,
// RAILS, GAPS, LETTERS, SPAWN, buildLevel). Ship it with
// parkmath.js + parkbuild.js + assets/uploads/.
function download(name, text, type = 'application/octet-stream') {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], { type }));
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}
const slug = park => (park.name || 'park').toLowerCase().replace(/[^\w]+/g, '_');
export function downloadParkJSON(park) {
download(slug(park) + '.json', JSON.stringify(park, null, 1), 'application/json');
}
export function generateLevelJS(park) {
return `// ${park.name} — generated by SKATEMAKER PRO ${new Date().toISOString().slice(0, 10)}
// Drop-in for BOOKQUOY: copy this file + parkmath.js + parkbuild.js (+ assets/uploads/)
// into bookquoy/js/ and point main.js/player.js imports at it. Same contract as level.js:
// heightAt(x,z) is the single source of truth for visuals AND collision.
import { makeHeightAt, makeNormalAt } from './parkmath.js';
import { buildParkScene } from './parkbuild.js';
export const PARK = ${JSON.stringify(park, null, 1)};
export const BOUNDS = PARK.bounds;
export const RAILS = PARK.rails;
export const GAPS = PARK.gaps;
export const LETTERS = PARK.letters;
export const SPAWN = PARK.spawn;
export const heightAt = makeHeightAt(PARK);
export const normalAt = makeNormalAt(heightAt);
export function buildLevel(scene) {
const { ground, letterMeshes } = buildParkScene(scene, PARK);
return { ground, letterMeshes };
}
`;
}
export function downloadLevelJS(park) {
download('level.' + slug(park) + '.js', generateLevelJS(park), 'text/javascript');
}

69
js/modelbeast.js Normal file
View File

@ -0,0 +1,69 @@
// SKATEMAKER PRO — MODELBEAST client. Talks only to serve.py's /mb/ proxy;
// the farm token never reaches the browser. Guest tier: max 4 active jobs.
// Contract (mb_recon.py, verified 2026-07-16): submit {operator, asset_id?, params},
// poll status, then find your output by parent_job in the assets list.
async function j(url, opts) {
const r = await fetch(url, opts);
const body = await r.json();
if (body.error) throw new Error(body.error);
return body;
}
export async function listAssets() {
const a = await j('/mb/assets');
return Array.isArray(a) ? a : a.items || [];
}
export const importAsset = (id, name) =>
j(`/mb/import?asset=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`);
export const submitJob = (operator, params = {}, assetId = null) =>
j('/mb/jobs', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assetId ? { operator, asset_id: assetId, params }
: { operator, params }),
});
export async function pollJob(id, onStatus, timeoutS = 600) {
const t0 = Date.now();
for (;;) {
await new Promise(res => setTimeout(res, 5000));
const st = (await j(`/mb/jobs/${id}`)).status;
onStatus?.(`job ${id}: ${st} (${((Date.now() - t0) / 1000) | 0}s)`);
if (st === 'done') return;
if (st === 'error' || st === 'cancelled') throw new Error('job ended ' + st);
if (Date.now() - t0 > timeoutS * 1000) throw new Error('job timed out');
}
}
async function outputOf(jobId, extRe) {
const assets = await listAssets();
const mine = assets.filter(a => a.parent_job === jobId &&
extRe.test(a.name || a.filename || ''));
return mine[0] || null;
}
// prompt -> image on the farm (flux_local, ~9s on the m3ultra)
export async function generateImage(prompt, onStatus) {
onStatus?.('submitting flux_local…');
const job = await submitJob('flux_local', { prompt });
await pollJob(job.id, onStatus);
return outputOf(job.id, /\.(png|jpe?g|webp)$/i);
}
// MB image asset -> same image with background removed (for clean decals)
export async function removeBg(assetId, onStatus) {
onStatus?.('submitting bg_remove_local…');
const job = await submitJob('bg_remove_local', {}, assetId);
await pollJob(job.id, onStatus);
return outputOf(job.id, /\.(png|webp)$/i);
}
// MB image asset -> GLB (hunyuan3d_mlx). Slow — minutes, not seconds.
export async function imageTo3D(assetId, onStatus) {
onStatus?.('submitting hunyuan3d_mlx…');
const job = await submitJob('hunyuan3d_mlx', {}, assetId);
await pollJob(job.id, onStatus, 1800);
return outputOf(job.id, /\.glb$/i);
}

204
js/parkbuild.js Normal file
View File

@ -0,0 +1,204 @@
// SKATEMAKER PRO — park -> three.js scene. Shared by the editor AND exported levels,
// so what you see in the editor is byte-for-byte what the game builds.
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { makeHeightAt } from './parkmath.js';
export const GRID_RES = 0.5, GRID_PAD = 14;
// ---------------------------------------------------------------- ground texture
// Zones + image decals bake into one canvas mapped planar over the grid.
// Vertex colors carry height shading; three multiplies map * vertexColors.
export function bakeGroundTexture(park, x0, x1, z0, z1) {
const PX = 16; // texels per metre
const cv = document.createElement('canvas');
cv.width = Math.min(4096, Math.round((x1 - x0) * PX));
cv.height = Math.min(4096, Math.round((z1 - z0) * PX));
const sx = cv.width / (x1 - x0), sz = cv.height / (z1 - z0);
const g = cv.getContext('2d');
const px = (x, z) => [(x - x0) * sx, (z - z0) * sz];
g.fillStyle = park.grassColor || '#4d7c3c'; // parkland
g.fillRect(0, 0, cv.width, cv.height);
const B = park.bounds;
const [bx, bz] = px(B.x0, B.z0);
g.fillStyle = park.slabColor || '#7fae6f'; // the slab
g.fillRect(bx, bz, (B.x1 - B.x0) * sx, (B.z1 - B.z0) * sz);
const drawShape = zn => {
g.save();
const [cx, cz] = px(zn.x, zn.z);
g.translate(cx, cz); g.rotate(zn.rot || 0);
g.beginPath();
if (zn.shape === 'ellipse') g.ellipse(0, 0, zn.hw * sx, zn.hd * sz, 0, 0, Math.PI * 2);
else g.rect(-zn.hw * sx, -zn.hd * sz, zn.hw * 2 * sx, zn.hd * 2 * sz);
};
for (const zn of park.zones || []) {
drawShape(zn);
g.fillStyle = zn.color || '#9aa0a2';
g.globalAlpha = zn.opacity ?? 1;
g.fill(); g.restore();
}
const jobs = []; // decals load async, re-bake per image
for (const d of park.decals || []) {
const img = new Image();
jobs.push(new Promise(res => {
img.onload = () => res({ d, img });
img.onerror = () => res(null);
img.src = d.img;
}));
}
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4;
Promise.all(jobs).then(loaded => {
for (const hit of loaded) {
if (!hit) continue;
const { d, img } = hit;
g.save();
const [cx, cz] = px(d.x, d.z);
g.translate(cx, cz); g.rotate(d.rot || 0);
g.globalAlpha = d.opacity ?? 1;
g.drawImage(img, -d.w / 2 * sx, -d.h / 2 * sz, d.w * sx, d.h * sz);
g.restore();
}
tex.needsUpdate = true;
});
return tex;
}
// height shading: darker in carves, subtle lift with elevation — cheap depth cueing
function shade(h) {
const s = h < -0.02 ? 0.72 + 0.1 * h : Math.min(1, 0.9 + h * 0.06);
return Math.max(0.55, s);
}
export function buildGround(park) {
const heightAt = makeHeightAt(park);
const B = park.bounds;
const x0 = B.x0 - GRID_PAD, x1 = B.x1 + GRID_PAD, z0 = B.z0 - GRID_PAD, z1 = B.z1 + GRID_PAD;
const nx = Math.round((x1 - x0) / GRID_RES) + 1, nz = Math.round((z1 - z0) / GRID_RES) + 1;
const pos = new Float32Array(nx * nz * 3), col = new Float32Array(nx * nz * 3),
uv = new Float32Array(nx * nz * 2), idx = [];
let p = 0, q = 0;
for (let j = 0; j < nz; j++) for (let i = 0; i < nx; i++) {
const x = x0 + i * GRID_RES, z = z0 + j * GRID_RES, h = heightAt(x, z);
pos[p] = x; pos[p + 1] = h; pos[p + 2] = z;
const s = shade(h);
col[p] = s; col[p + 1] = s; col[p + 2] = s;
p += 3;
uv[q++] = (x - x0) / (x1 - x0); uv[q++] = 1 - (z - z0) / (z1 - z0);
}
for (let j = 0; j < nz - 1; j++) for (let i = 0; i < nx - 1; i++) {
const a = j * nx + i, b = a + 1, c = a + nx, d = c + 1;
idx.push(a, c, b, b, c, d);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
geo.setIndex(idx); geo.computeVertexNormals();
const tex = bakeGroundTexture(park, x0, x1, z0, z1);
const mesh = new THREE.Mesh(geo,
new THREE.MeshLambertMaterial({ map: tex, vertexColors: true }));
mesh.receiveShadow = true;
mesh.name = 'ground';
return mesh;
}
// ---------------------------------------------------------------- steel
export function buildRails(park) {
const grp = new THREE.Group(); grp.name = 'rails';
const steel = new THREE.MeshStandardMaterial({ color: 0xb9bec4, metalness: 0.8, roughness: 0.35 });
for (const r of park.rails || []) {
const a = new THREE.Vector3(r.a[0], r.ya, r.a[1]);
const b = new THREE.Vector3(r.b[0], r.yb, r.b[1]);
const len = Math.max(a.distanceTo(b), 0.01);
const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, len, 8), steel);
bar.position.copy(a).add(b).multiplyScalar(0.5);
bar.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.clone().sub(a).normalize());
bar.userData.railId = r.id;
grp.add(bar);
if (r.kind === 'rail') {
for (const t of [0.12, 0.88]) {
const p = a.clone().lerp(b, t);
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, Math.max(p.y, 0.05), 6), steel);
post.position.set(p.x, p.y / 2, p.z);
post.userData.railId = r.id;
grp.add(post);
}
}
}
return grp;
}
// ---------------------------------------------------------------- props
const fig = (s, trunkM, leafM) => { // builtin Moreton Bay fig
const grp = new THREE.Group();
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.28 * s, 0.42 * s, 2.6 * s, 7), trunkM);
trunk.position.y = 1.3 * s; grp.add(trunk);
for (const [ox, oy, oz, r] of [[0, 3.1, 0, 2.4], [1.4, 2.6, 0.7, 1.7], [-1.3, 2.7, -0.6, 1.6]]) {
const m = new THREE.Mesh(new THREE.SphereGeometry(r * s, 10, 8), leafM);
m.position.set(ox * s, oy * s, oz * s); grp.add(m);
}
return grp;
};
export function buildProps(park, heightAt, onLoaded) {
const grp = new THREE.Group(); grp.name = 'props';
const loader = new GLTFLoader();
const trunkM = new THREE.MeshLambertMaterial({ color: 0x5b4632 });
const leafM = new THREE.MeshLambertMaterial({ color: 0x2e5b2a });
for (const pr of park.props || []) {
const place = obj => {
obj.position.set(pr.x, heightAt(pr.x, pr.z) + (pr.y || 0), pr.z);
obj.rotation.y = pr.rot || 0;
const s = pr.scale || 1;
if (pr.src !== 'builtin:fig') obj.scale.setScalar(s);
obj.userData.propId = pr.id;
obj.traverse(o => { o.userData.propId = pr.id; });
grp.add(obj);
if (onLoaded) onLoaded(pr, obj);
};
if (pr.src === 'builtin:fig') place(fig(pr.scale || 1, trunkM, leafM));
else loader.load(pr.src, g => place(g.scene),
undefined, () => console.warn('prop failed', pr.src));
}
return grp;
}
// ---------------------------------------------------------------- letters
export function buildLetters(park, heightAt) {
return (park.letters || []).map(L => {
const cv = document.createElement('canvas'); cv.width = cv.height = 128;
const cx = cv.getContext('2d');
cx.fillStyle = '#ffd93b'; cx.strokeStyle = '#2b2b2b'; cx.lineWidth = 10;
cx.font = 'bold 104px monospace'; cx.textAlign = 'center'; cx.textBaseline = 'middle';
cx.strokeText(L.ch, 64, 70); cx.fillText(L.ch, 64, 70);
const tex = new THREE.CanvasTexture(cv);
const spr = new THREE.Mesh(new THREE.PlaneGeometry(0.9, 0.9),
new THREE.MeshBasicMaterial({ map: tex, transparent: true, side: THREE.DoubleSide }));
const base = heightAt(L.x, L.z);
spr.position.set(L.x, Math.max(base, 0) + L.y, L.z);
spr.userData = { ch: L.ch, taken: false, baseY: spr.position.y, letterId: L.id };
return spr;
});
}
// ---------------------------------------------------------------- whole scene
// Returns the same shape bookquoy's buildLevel returns, so exports are drop-ins.
export function buildParkScene(scene, park, opts = {}) {
const heightAt = makeHeightAt(park);
const ground = buildGround(park);
scene.add(ground);
const rails = buildRails(park);
scene.add(rails);
const props = buildProps(park, heightAt, opts.onPropLoaded);
scene.add(props);
let letterMeshes = [];
if (!opts.skipLetters) {
letterMeshes = buildLetters(park, heightAt);
for (const m of letterMeshes) scene.add(m);
}
return { ground, rails, props, letterMeshes };
}

98
js/parkcheck.js Normal file
View File

@ -0,0 +1,98 @@
// 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;
}

253
js/parkmath.js Normal file
View File

@ -0,0 +1,253 @@
// 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, optional lump + deck
label: 'Quarter Pipe',
defaults: { len: 8, r0: 1.2, r1: 1.2, lump: 0, deck: 0.6 },
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; // deck behind the coping
return tranny(v, r);
},
},
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 },
carve: true,
height(e, x, z) { // returns carve value, caller checks inside()
const [u, v] = local(e, x, z);
const dw = 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 Math.abs(u) < e.size && Math.abs(v) < e.size;
},
},
};
// ---------------------------------------------------------------- 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 '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 '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: [],
};
}

120
js/testride.js Normal file
View File

@ -0,0 +1,120 @@
// SKATEMAKER PRO — test ride. The Create-A-Park lesson: the instant build↔skate
// toggle is what makes an editor sing. Simplified bookquoy physics over the live
// park data — same heightAt, same constants, so speed and pop feel like the game.
import * as THREE from 'three';
import { makeHeightAt, makeNormalAt } from './parkmath.js';
const GRAV = 18, MAX_SPEED = 10.5, PUSH_ACC = 6.5, FRICTION = 0.28, STEER = 2.6;
export function attachTestRide(ed) {
const keys = new Set();
let rider = null, heightAt = null, normalAt = null;
let pos, heading = 0, speed = 0, vy = 0, state = 'roll';
const N = new THREE.Vector3();
const camTarget = new THREE.Vector3();
let savedCam = null;
function makeRider() {
const g = new THREE.Group();
const deck = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.03, 0.82),
new THREE.MeshStandardMaterial({ color: 0xd8a13a, roughness: 0.6 }));
deck.position.y = 0.1; g.add(deck);
const wheelM = new THREE.MeshStandardMaterial({ color: 0xf5f0e6, roughness: 0.4 });
for (const [dx, dz] of [[-0.08, 0.3], [0.08, 0.3], [-0.08, -0.3], [0.08, -0.3]]) {
const w = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.04, 10), wheelM);
w.rotation.z = Math.PI / 2; w.position.set(dx, 0.05, dz); g.add(w);
}
const body = new THREE.Mesh(new THREE.CapsuleGeometry(0.16, 0.75, 4, 10),
new THREE.MeshStandardMaterial({ color: 0x3a86ff, roughness: 0.7 }));
body.position.y = 0.75; g.add(body);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.13, 12, 10),
new THREE.MeshStandardMaterial({ color: 0xe8b98a, roughness: 0.8 }));
head.position.y = 1.42; g.add(head);
return g;
}
function start() {
heightAt = makeHeightAt(ed.park);
normalAt = makeNormalAt(heightAt);
const s = ed.park.spawn;
pos = new THREE.Vector3(s.x, heightAt(s.x, s.z), s.z);
heading = s.heading; speed = 0; vy = 0; state = 'roll';
rider = makeRider();
ed.scene.add(rider);
savedCam = { p: ed.cam.position.clone(), t: ed.controls.target.clone() };
ed.controls.enabled = false;
ed.testing = true;
ed.emit('test', true);
ed.emit('status', 'TEST RIDE — W push · A/D steer · S brake · SPACE ollie · R respawn · ENTER back to editing');
}
function stop() {
if (rider) ed.scene.remove(rider);
rider = null;
ed.testing = false;
ed.controls.enabled = true;
if (savedCam) { ed.cam.position.copy(savedCam.p); ed.controls.target.copy(savedCam.t); }
ed.setExtraUpdate(null);
ed.emit('test', false);
ed.emit('status', 'back to building');
}
function update(dt) {
const fwd = new THREE.Vector3(Math.sin(heading), 0, Math.cos(heading));
if (state === 'roll') {
if (keys.has('KeyA') || keys.has('ArrowLeft')) heading += STEER * dt;
if (keys.has('KeyD') || keys.has('ArrowRight')) heading -= STEER * dt;
if (keys.has('KeyW') || keys.has('ArrowUp')) speed = Math.min(speed + PUSH_ACC * dt, MAX_SPEED);
if (keys.has('KeyS') || keys.has('ArrowDown')) speed = Math.max(speed - 14 * dt, 0);
normalAt(pos.x, pos.z, N);
speed += -(N.x * fwd.x + N.z * fwd.z) * GRAV * dt * 1.4; // slope pulls
speed -= FRICTION * dt * speed * 0.4;
speed = Math.max(0, Math.min(speed, 16));
pos.x += fwd.x * speed * dt; pos.z += fwd.z * speed * dt;
const h = heightAt(pos.x, pos.z);
const dh = h - pos.y;
if (dh < -0.12) { // rolled off something
state = 'air'; vy = Math.max(-2, dh / dt * 0.2);
} else {
pos.y = h;
if (keys.has('Space')) { state = 'air'; vy = 4.6 + speed * 0.12; }
}
} else {
vy -= GRAV * dt;
pos.x += fwd.x * speed * dt; pos.z += fwd.z * speed * dt; pos.y += vy * dt;
const h = heightAt(pos.x, pos.z);
if (pos.y <= h) { pos.y = h; state = 'roll'; vy = 0; }
}
if (keys.has('KeyR')) {
const s = ed.park.spawn;
pos.set(s.x, heightAt(s.x, s.z), s.z); heading = s.heading; speed = 0; state = 'roll';
}
rider.position.copy(pos);
rider.rotation.y = heading;
if (state === 'roll') {
normalAt(pos.x, pos.z, N);
const tilt = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), N);
rider.quaternion.slerp(tilt.multiply(new THREE.Quaternion()
.setFromAxisAngle(new THREE.Vector3(0, 1, 0), heading)), 0.25);
}
// bookquoy chase cam
const back = 5.2, up = 2.4;
const cx = pos.x - Math.sin(heading) * back, cz = pos.z - Math.cos(heading) * back;
const cy = Math.max(pos.y + up, heightAt(cx, cz) + 1.2);
ed.cam.position.lerp(new THREE.Vector3(cx, cy, cz), 1 - Math.pow(0.0015, dt));
camTarget.lerp(new THREE.Vector3(pos.x, pos.y + 1.1, pos.z), 1 - Math.pow(0.0005, dt));
ed.cam.lookAt(camTarget);
}
ed.toggleTest = () => {
if (ed.testing) stop();
else { start(); ed.setExtraUpdate(update); }
};
addEventListener('keydown', e => {
if (e.code === 'Enter' && !['INPUT', 'SELECT', 'TEXTAREA'].includes(e.target.tagName)) {
ed.toggleTest(); return;
}
if (ed.testing) { keys.add(e.code); if (e.code === 'Space') e.preventDefault(); }
});
addEventListener('keyup', e => keys.delete(e.code));
}

459
js/ui.js Normal file
View File

@ -0,0 +1,459 @@
// SKATEMAKER PRO — DOM panels. Everything talks to the editor through its tiny API:
// setTool / select / updateSelected / commit / loadPark. No three.js in here.
import { ELEMENT_KINDS } from './parkmath.js';
import { checkPark } from './parkcheck.js';
import { downloadParkJSON, downloadLevelJS } from './export.js';
import * as MB from './modelbeast.js';
const $ = (sel, root = document) => root.querySelector(sel);
const el = (tag, cls, html) => {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (html !== undefined) n.innerHTML = html;
return n;
};
// numeric param rows: [key, label, min, max, step]
const DEG = Math.PI / 180;
const COMMON_XZ = [['x', 'x', -60, 60, 0.1], ['z', 'z', -40, 40, 0.1]];
const PARAMS = {
quarter: [['len', 'length', 1, 80, 0.5], ['r0', 'radius a', 0.3, 4, 0.05],
['r1', 'radius b', 0.3, 4, 0.05], ['lump', 'lumpiness', 0, 0.3, 0.01],
['deck', 'deck', 0, 4, 0.1]],
bank: [['len', 'length', 1, 80, 0.5], ['h', 'height', 0.1, 3, 0.05],
['run', 'run', 0.5, 8, 0.1], ['deck', 'deck', 0, 4, 0.1]],
funbox: [['hw', 'half width', 0.3, 10, 0.1], ['hd', 'half depth', 0.3, 10, 0.1],
['h', 'height', 0.1, 2.5, 0.05], ['skirt', 'skirt', 0.2, 4, 0.1]],
ledge: [['hw', 'half width', 0.2, 14, 0.1], ['hd', 'half depth', 0.2, 14, 0.1],
['h', 'height', 0.05, 1.5, 0.01]],
mogul: [['h', 'height', 0.1, 2, 0.05], ['s', 'spread', 0.5, 6, 0.1]],
spine: [['len', 'length', 1, 30, 0.5], ['r', 'radius', 0.4, 3, 0.05]],
pyramid: [['hw', 'half width', 0.5, 10, 0.1], ['hd', 'half depth', 0.5, 10, 0.1],
['h', 'height', 0.2, 3, 0.05], ['run', 'run', 0.5, 6, 0.1]],
stairs: [['width', 'width', 1, 12, 0.2], ['h', 'drop', 0.2, 2.4, 0.05],
['steps', 'steps', 2, 12, 1], ['going', 'going', 0.3, 1.2, 0.05],
['platDepth', 'platform', 0.5, 8, 0.1]],
dome: [['rx', 'radius x', 0.5, 14, 0.1], ['rz', 'radius z', 0.5, 14, 0.1],
['h', 'height', 0.05, 1, 0.01]],
bowl: [['size', 'half size', 1.5, 10, 0.1], ['depth', 'depth', 0.5, 3.4, 0.05]],
};
const PALETTE = [
['quarter', 'QUARTER'], ['bank', 'BANK'], ['funbox', 'FUNBOX'], ['pyramid', 'PYRAMID'],
['ledge', 'LEDGE'], ['stairs', 'STAIRS'], ['spine', 'SPINE'], ['mogul', 'MOGUL'],
['bowl', 'BOWL'], ['dome', 'ISLAND'],
];
const SPOTS = [
['rail', 'RAIL', { mode: 'rail' }], ['gap', 'GAP', { mode: 'gap' }],
['letter', 'LETTER', { mode: 'letter' }], ['spawn', 'SPAWN', { mode: 'spawn' }],
['zone', 'PAINT', { mode: 'zone' }],
];
export function initUI(ed) {
const palette = $('#palette'), inspector = $('#inspector'),
status = $('#status'), drawer = $('#drawer');
const setStatus = t => { status.textContent = t; };
ed.on('status', setStatus);
// ---------------------------------------------------------------- palette
function buildPalette() {
palette.innerHTML = '<div class="ptitle">BUILD</div>';
const grid = el('div', 'pgrid'); palette.append(grid);
for (const [kind, label] of PALETTE) {
const b = el('button', 'pbtn', label);
b.dataset.tool = 'place:' + kind;
b.onclick = () => ed.setTool(ed.tool?.kind === kind ? null : { mode: 'place', kind });
grid.append(b);
}
palette.append(el('div', 'ptitle', 'SPOTS'));
const grid2 = el('div', 'pgrid'); palette.append(grid2);
for (const [key, label, tool] of SPOTS) {
const b = el('button', 'pbtn', label);
b.dataset.tool = tool.mode;
b.onclick = () => ed.setTool(ed.tool?.mode === tool.mode ? null : { ...tool });
grid2.append(b);
}
palette.append(el('div', 'ptitle', 'SURFACE'));
const surf = el('div', 'pcol'); palette.append(surf);
const decalBtn = el('button', 'pbtn wide', 'IMAGE DECAL…');
decalBtn.onclick = () => pickFile('image/*', async f => {
const path = await uploadFile(f);
const img = new Image();
img.onload = () => {
ed.setTool({ mode: 'decal', img: path, w: 6, aspect: img.height / img.width });
setStatus('decal loaded — click the ground to place it');
};
img.src = path;
});
surf.append(decalBtn);
const underBtn = el('button', 'pbtn wide', 'REFERENCE UNDERLAY…');
underBtn.onclick = () => pickFile('image/*', async f => {
const path = await uploadFile(f);
ed.park.underlay = { img: path, visible: true, opacity: 0.45, w: 90, x: 0, z: 0, rot: 0, y: 6 };
ed.commit({ underlay: true });
ed.select({ type: 'underlay' });
});
surf.append(underBtn);
const snapRow = el('label', 'snaprow',
'<input type="checkbox" id="snapbox"> grid snap 0.5m');
surf.append(snapRow);
$('#snapbox', snapRow).onchange = e => { ed.snapStep = e.target.checked ? 0.5 : 0; };
palette.append(el('div', 'ptitle', 'PANELS'));
const pans = el('div', 'pcol'); palette.append(pans);
const mbBtn = el('button', 'pbtn wide accent', 'MODELBEAST');
mbBtn.onclick = () => toggleDrawer('mb');
const ckBtn = el('button', 'pbtn wide', 'PARK CHECK');
ckBtn.onclick = () => toggleDrawer('check');
pans.append(mbBtn, ckBtn);
}
ed.on('tool', () => {
for (const b of palette.querySelectorAll('.pbtn[data-tool]')) {
const t = b.dataset.tool;
const active = ed.tool &&
(t === 'place:' + (ed.tool.kind || '') || t === ed.tool.mode);
b.classList.toggle('active', !!active);
}
setStatus(ed.tool ? 'click the ground to place — ESC to cancel'
: 'click to select · drag to move · rightdrag orbit · [ ] rotate · D dupe · T top');
});
// ---------------------------------------------------------------- inspector
function row(label, input) {
const r = el('div', 'irow');
r.append(el('label', null, label), input);
return r;
}
function numRow(obj, key, label, min, max, step, onInput) {
const wrap = el('div', 'numwrap');
const slider = el('input'); slider.type = 'range';
slider.min = min; slider.max = max; slider.step = step; slider.value = obj[key] ?? 0;
const num = el('input', 'num'); num.type = 'number';
num.step = step; num.value = obj[key] ?? 0;
const set = (v, done) => {
v = +v; if (Number.isNaN(v)) return;
slider.value = v; num.value = v;
onInput(v, done);
};
slider.oninput = () => set(slider.value, false);
slider.onchange = () => set(slider.value, true);
num.onchange = () => set(num.value, true);
wrap.append(slider, num);
return row(label, wrap);
}
function buildInspector() {
inspector.innerHTML = '';
const s = ed.selection, obj = ed.getSelected?.();
if (s?.type === 'underlay') return buildUnderlayInspector();
if (!s || !obj) {
inspector.append(el('div', 'ihint',
'nothing selected<br><br>· click a piece to edit it<br>· palette → click ground to add' +
'<br>· ENTER to test ride<br>· ctrl-Z undo'));
return buildParkInspector();
}
const title = s.type === 'element' ? ELEMENT_KINDS[obj.kind].label : s.type.toUpperCase();
inspector.append(el('div', 'ititle', title));
const upd = key => (v, done) => ed.updateSelected(key, v, { done });
if (s.type === 'element') {
for (const [key, label, min, max, step] of COMMON_XZ)
inspector.append(numRow(obj, key, label, min, max, step, upd(key)));
inspector.append(numRow({ rot: (obj.rot || 0) / DEG }, 'rot', 'rotation°', -180, 180, 5,
(v, done) => ed.updateSelected('rot', +(v * DEG).toFixed(4), { done })));
for (const [key, label, min, max, step] of PARAMS[obj.kind] || [])
inspector.append(numRow(obj, key, label, min, max, step, upd(key)));
} else if (s.type === 'rail') {
const name = el('input', 'text'); name.value = obj.name || '';
name.onchange = () => ed.updateSelected('name', name.value, { done: true });
inspector.append(row('name', name));
const kind = el('select');
kind.innerHTML = '<option value="rail">rail (posts)</option><option value="ledge">ledge cap</option>';
kind.value = obj.kind;
kind.onchange = () => ed.updateSelected('kind', kind.value, { done: true });
inspector.append(row('type', kind));
inspector.append(numRow(obj, 'ya', 'height a', 0, 4, 0.01, upd('ya')));
inspector.append(numRow(obj, 'yb', 'height b', 0, 4, 0.01, upd('yb')));
inspector.append(el('div', 'ihint', 'drag the yellow end-balls to move the ends'));
} else if (s.type === 'gap') {
const name = el('input', 'text'); name.value = obj.name;
name.onchange = () => ed.updateSelected('name', name.value, { done: true });
inspector.append(row('name', name));
for (const [key, label, min, max, step] of COMMON_XZ)
inspector.append(numRow(obj, key, label, min, max, step, upd(key)));
inspector.append(numRow(obj, 'r', 'radius', 0.5, 8, 0.1, upd('r')));
} else if (s.type === 'letter') {
const ch = el('input', 'text'); ch.maxLength = 1; ch.value = obj.ch;
ch.onchange = () => ed.updateSelected('ch', (ch.value || 'O').toUpperCase(), { done: true });
inspector.append(row('letter', ch));
inspector.append(numRow(obj, 'y', 'float height', 0.5, 6, 0.1, upd('y')));
} else if (s.type === 'spawn') {
inspector.append(numRow({ heading: (obj.heading || 0) / DEG }, 'heading', 'heading°', -180, 180, 5,
(v, done) => ed.updateSelected('heading', +(v * DEG).toFixed(3), { done })));
} else if (s.type === 'prop') {
inspector.append(el('div', 'ihint', obj.src));
inspector.append(numRow({ rot: (obj.rot || 0) / DEG }, 'rot', 'rotation°', -180, 180, 5,
(v, done) => ed.updateSelected('rot', +(v * DEG).toFixed(4), { done })));
inspector.append(numRow(obj, 'scale', 'scale', 0.05, 8, 0.05, upd('scale')));
inspector.append(numRow({ y: obj.y || 0 }, 'y', 'lift', -2, 6, 0.05, upd('y')));
} else if (s.type === 'zone') {
const shape = el('select');
shape.innerHTML = '<option value="rect">rect</option><option value="ellipse">ellipse</option>';
shape.value = obj.shape;
shape.onchange = () => ed.updateSelected('shape', shape.value, { done: true });
inspector.append(row('shape', shape));
const color = el('input'); color.type = 'color'; color.value = obj.color;
color.oninput = () => ed.updateSelected('color', color.value, {});
color.onchange = () => ed.updateSelected('color', color.value, { done: true });
inspector.append(row('color', color));
inspector.append(numRow(obj, 'hw', 'half width', 0.3, 40, 0.1, upd('hw')));
inspector.append(numRow(obj, 'hd', 'half depth', 0.3, 40, 0.1, upd('hd')));
inspector.append(numRow({ rot: (obj.rot || 0) / DEG }, 'rot', 'rotation°', -180, 180, 5,
(v, done) => ed.updateSelected('rot', +(v * DEG).toFixed(4), { done })));
inspector.append(numRow({ opacity: obj.opacity ?? 1 }, 'opacity', 'opacity', 0, 1, 0.05, upd('opacity')));
} else if (s.type === 'decal') {
inspector.append(numRow(obj, 'w', 'width', 0.5, 60, 0.1, (v, done) => {
const asp = obj.h / obj.w; ed.updateSelected('w', v, {});
ed.updateSelected('h', +(v * asp).toFixed(2), { done });
}));
inspector.append(numRow(obj, 'h', 'height', 0.5, 60, 0.1, upd('h')));
inspector.append(numRow({ rot: (obj.rot || 0) / DEG }, 'rot', 'rotation°', -180, 180, 5,
(v, done) => ed.updateSelected('rot', +(v * DEG).toFixed(4), { done })));
inspector.append(numRow({ opacity: obj.opacity ?? 1 }, 'opacity', 'opacity', 0, 1, 0.05, upd('opacity')));
}
if (s.type !== 'spawn') {
const btns = el('div', 'ibtns');
const dup = el('button', 'pbtn', 'DUPE (D)'); dup.onclick = () => ed.duplicateSelected();
const del = el('button', 'pbtn danger', 'DELETE'); del.onclick = () => ed.deleteSelected();
btns.append(dup, del); inspector.append(btns);
}
}
function buildUnderlayInspector() {
const u = ed.park.underlay;
inspector.append(el('div', 'ititle', 'REFERENCE UNDERLAY'));
if (!u) { inspector.append(el('div', 'ihint', 'none loaded')); return; }
const updU = key => (v, done) => { u[key] = v; if (done !== false) ed.commit({ underlay: true, snapshot: done }); };
inspector.append(numRow(u, 'opacity', 'opacity', 0, 1, 0.05, updU('opacity')));
inspector.append(numRow(u, 'w', 'width (m)', 10, 300, 1, updU('w')));
inspector.append(numRow(u, 'x', 'x', -80, 80, 0.5, updU('x')));
inspector.append(numRow(u, 'z', 'z', -60, 60, 0.5, updU('z')));
inspector.append(numRow(u, 'y', 'hover', 0.1, 20, 0.1, updU('y')));
inspector.append(numRow({ rot: (u.rot || 0) / DEG }, 'rot', 'rotation°', -180, 180, 1,
(v, done) => { u.rot = v * DEG; ed.commit({ underlay: true, snapshot: done }); }));
const vis = el('button', 'pbtn wide', u.visible ? 'HIDE' : 'SHOW');
vis.onclick = () => { u.visible = !u.visible; ed.commit({ underlay: true }); buildInspector(); };
const kill = el('button', 'pbtn danger wide', 'REMOVE');
kill.onclick = () => { delete ed.park.underlay; ed.commit({ underlay: true }); ed.select(null); };
inspector.append(vis, kill);
}
function buildParkInspector() {
inspector.append(el('div', 'ititle', 'PARK'));
const cols = [['slabColor', 'slab'], ['grassColor', 'grass']];
for (const [key, label] of cols) {
const c = el('input'); c.type = 'color'; c.value = ed.park[key];
c.onchange = () => { ed.park[key] = c.value; ed.commit({ tex: true }); };
inspector.append(row(label, c));
}
const B = ed.park.bounds;
for (const [key, label, min, max] of [['x0', 'west edge', -120, -5], ['x1', 'east edge', 5, 120],
['z0', 'north edge', -120, -5], ['z1', 'south edge', 5, 120]])
inspector.append(numRow(B, key, label, min, max, 1, (v, done) => {
B[key] = v; if (done) ed.commit({ ground: true });
}));
}
ed.on('select', buildInspector);
ed.on('change', () => { if (!ed.selection) buildInspector(); });
// ---------------------------------------------------------------- drawer (MB / check)
let drawerMode = null;
function toggleDrawer(mode) {
drawerMode = drawerMode === mode ? null : mode;
drawer.style.display = drawerMode ? 'flex' : 'none';
if (drawerMode === 'mb') buildMB();
if (drawerMode === 'check') buildCheck();
}
function buildCheck() {
drawer.innerHTML = '<div class="ptitle">PARK CHECK <span class="dim">— flow · tiers · steel</span></div>';
const list = el('div', 'checklist'); drawer.append(list);
const results = checkPark(ed.park);
if (!results.length) {
list.append(el('div', 'checkok', '✓ park reads clean — flow, tiers and steel all check out'));
return;
}
for (const r of results) {
const item = el('div', 'checkitem', '⚠ ' + r.msg);
if (r.sel) {
item.classList.add('link');
item.onclick = () => ed.select(r.sel);
}
list.append(item);
}
const re = el('button', 'pbtn wide', 'RE-CHECK');
re.onclick = buildCheck; drawer.append(re);
}
// ---------------------------------------------------------------- MODELBEAST
async function buildMB() {
drawer.innerHTML = '<div class="ptitle">MODELBEAST <span class="dim">— farm assets & gen</span></div>';
const gen = el('div', 'mbgen');
const prompt = el('input', 'text'); prompt.placeholder = 'prompt… e.g. "graffiti tag, spray paint, transparent bg"';
const genImg = el('button', 'pbtn accent', 'GEN IMAGE');
const genStatus = el('div', 'mbstatus');
gen.append(prompt, genImg, genStatus); drawer.append(gen);
genImg.onclick = async () => {
if (!prompt.value.trim()) return;
genImg.disabled = true;
try {
await MB.generateImage(prompt.value.trim(), s => { genStatus.textContent = s; });
genStatus.textContent = 'done — refreshing list';
await refresh();
} catch (e) { genStatus.textContent = 'error: ' + e.message; }
genImg.disabled = false;
};
const list = el('div', 'mblist', 'loading farm assets…'); drawer.append(list);
async function refresh() {
let assets;
try { assets = await MB.listAssets(); }
catch (e) { list.innerHTML = `<div class="checkitem">farm unreachable: ${e.message}</div>`; return; }
list.innerHTML = '';
for (const a of assets.slice(0, 40)) {
const name = a.name || a.filename || a.id;
const isGlb = /\.glb$/i.test(name), isImg = /\.(png|jpe?g|webp)$/i.test(name);
if (!isGlb && !isImg) continue;
const item = el('div', 'mbitem');
item.append(el('span', 'mbname', `${isGlb ? '◆' : '▣'} ${name}`));
if (isGlb) {
const b = el('button', 'pbtn', 'PLACE');
b.onclick = async () => {
b.textContent = '…';
const { path } = await MB.importAsset(a.id, name);
ed.setTool({ mode: 'prop', src: path, scale: 1 });
setStatus('model fetched — click the ground to place it');
b.textContent = 'PLACE';
};
item.append(b);
}
if (isImg) {
const d = el('button', 'pbtn', 'DECAL');
d.onclick = async () => {
d.textContent = '…';
const { path } = await MB.importAsset(a.id, name);
const img = new Image();
img.onload = () => {
ed.setTool({ mode: 'decal', img: path, w: 6, aspect: img.height / img.width });
setStatus('image fetched — click the ground to place the decal');
};
img.src = path;
d.textContent = 'DECAL';
};
const m3 = el('button', 'pbtn', 'MAKE 3D');
m3.onclick = async () => {
m3.disabled = true;
try {
await MB.imageTo3D(a.id, s => { genStatus.textContent = s; });
genStatus.textContent = 'mesh done — hit REFRESH and PLACE it';
} catch (e) { genStatus.textContent = 'error: ' + e.message; }
m3.disabled = false;
};
const bg = el('button', 'pbtn', 'CUT BG');
bg.onclick = async () => {
bg.disabled = true;
try {
await MB.removeBg(a.id, s => { genStatus.textContent = s; });
genStatus.textContent = 'bg cut — hit REFRESH for the clean version';
} catch (e) { genStatus.textContent = 'error: ' + e.message; }
bg.disabled = false;
};
item.append(d, bg, m3);
}
list.append(item);
}
if (!list.children.length) list.append(el('div', 'ihint', 'no glb/img assets on the farm yet'));
}
const rf = el('button', 'pbtn wide', 'REFRESH');
rf.onclick = refresh; drawer.append(rf);
refresh();
}
// ---------------------------------------------------------------- topbar
function buildTopbar() {
const bar = $('#topbar');
bar.innerHTML = '';
bar.append(el('span', 'logo', 'SKATEMAKER<b>PRO</b>'));
const name = el('input', 'text parkname');
name.value = ed.park?.name || 'NEW PARK';
name.onchange = () => { ed.park.name = name.value; ed.commit({}); };
bar.append(name);
ed.on('change', () => { if (document.activeElement !== name) name.value = ed.park.name; });
const mk = (label, fn, cls = '') => {
const b = el('button', 'pbtn ' + cls, label); b.onclick = fn; bar.append(b); return b;
};
mk('NEW', () => { if (confirm('start a fresh park? (autosave is overwritten)')) ed.newPark('NEW PARK'); });
mk('SAVE', async () => {
const n = ed.park.name.replace(/[^\w\- ]+/g, '').trim() || 'park';
await fetch('/api/parks/' + encodeURIComponent(n), { method: 'POST', body: JSON.stringify(ed.park, null, 1) });
setStatus(`saved parks/${n}.json`);
});
const loadSel = el('select'); loadSel.innerHTML = '<option value="">LOAD…</option>';
loadSel.onfocus = async () => {
const names = await fetch('/api/parks').then(r => r.json());
loadSel.innerHTML = '<option value="">LOAD…</option>' +
names.map(n => `<option>${n}</option>`).join('');
};
loadSel.onchange = async () => {
if (!loadSel.value) return;
ed.loadPark(await fetch('/api/parks/' + encodeURIComponent(loadSel.value)).then(r => r.json()));
loadSel.value = '';
};
bar.append(loadSel);
mk('⬇ JSON', () => downloadParkJSON(ed.park));
mk('⬇ LEVEL.JS', () => downloadLevelJS(ed.park));
mk('TOP', () => ed.topView());
const test = mk('▶ TEST RIDE', () => ed.toggleTest?.(), 'accent test');
ed.on('test', on => { test.textContent = on ? '■ STOP (Enter)' : '▶ TEST RIDE'; });
}
// ---------------------------------------------------------------- file helpers
function pickFile(accept, cb) {
const inp = el('input'); inp.type = 'file'; inp.accept = accept;
inp.onchange = () => { if (inp.files[0]) cb(inp.files[0]); };
inp.click();
}
async function uploadFile(f) {
const name = Date.now().toString(36) + '_' + f.name.replace(/[^\w.\-]+/g, '_');
const r = await fetch('/api/upload?name=' + encodeURIComponent(name),
{ method: 'POST', body: await f.arrayBuffer() });
const j = await r.json();
if (j.error) throw new Error(j.error);
return j.path;
}
// drag & drop anywhere: .glb -> prop, image -> decal
addEventListener('dragover', e => e.preventDefault());
addEventListener('drop', async e => {
e.preventDefault();
const f = e.dataTransfer.files?.[0];
if (!f) return;
const path = await uploadFile(f);
if (/\.glb$/i.test(f.name)) {
ed.setTool({ mode: 'prop', src: path, scale: 1 });
setStatus('model uploaded — click the ground to place it');
} else if (/^image\//.test(f.type)) {
const img = new Image();
img.onload = () => {
ed.setTool({ mode: 'decal', img: path, w: 6, aspect: img.height / img.width });
setStatus('image uploaded — click the ground to place the decal');
};
img.src = path;
}
});
buildPalette(); buildTopbar(); buildInspector();
ed.emit('tool');
}

75
parks/paddo.json Normal file
View File

@ -0,0 +1,75 @@
{
"name": "PADDO",
"bounds": { "x0": -40, "x1": 40, "z0": -26, "z1": 26 },
"slabColor": "#7fae6f",
"grassColor": "#4d7c3c",
"spawn": { "x": -32, "z": -18, "heading": 0.85 },
"elements": [
{ "id": "wall_w", "kind": "quarter", "x": -40, "z": 0, "rot": -1.5708, "len": 52, "r0": 0.9, "r1": 1.8, "lump": 0, "deck": 0 },
{ "id": "wall_n", "kind": "quarter", "x": 0, "z": -26, "rot": 0, "len": 80, "r0": 0.5, "r1": 1.1, "lump": 0, "deck": 0 },
{ "id": "lumpy_s", "kind": "quarter", "x": 1, "z": 26, "rot": 3.1416, "len": 34, "r0": 1.8, "r1": 1.8, "lump": 0.12, "deck": 0 },
{ "id": "bank_900", "kind": "bank", "x": -21, "z": 26, "rot": 3.1416, "len": 10, "h": 0.9, "run": 2.4, "deck": 0 },
{ "id": "bank_ne", "kind": "bank", "x": 27, "z": -26, "rot": 0, "len": 14, "h": 0.8, "run": 2.2, "deck": 0 },
{ "id": "bowl_w", "kind": "bowl", "x": -27, "z": -6, "rot": 0, "size": 4.2, "depth": 1.5 },
{ "id": "volcano", "kind": "mogul", "x": -18, "z": 4, "h": 0.7, "s": 2.6 },
{ "id": "mogul_1", "kind": "mogul", "x": -2, "z": 6, "h": 0.45, "s": 2.2 },
{ "id": "mogul_rb", "kind": "mogul", "x": 2, "z": 9, "h": 0.8, "s": 2.5 },
{ "id": "spine_1", "kind": "spine", "x": -8, "z": 12, "rot": 0, "len": 8, "r": 1.2 },
{ "id": "island", "kind": "dome", "x": 2, "z": -2, "rot": 0, "rx": 7, "rz": 4.5, "h": 0.14 },
{ "id": "funbox_c", "kind": "funbox", "x": 16, "z": -2, "rot": 0, "hw": 2.5, "hd": 1.2, "h": 0.9, "skirt": 1.6 },
{ "id": "ledge_a", "kind": "ledge", "x": 8.5, "z": -11, "rot": 0, "hw": 1.5, "hd": 0.8, "h": 0.6 },
{ "id": "ledge_b", "kind": "ledge", "x": 11.5,"z": -11, "rot": 0, "hw": 1.5, "hd": 0.8, "h": 0.45 },
{ "id": "ledge_c", "kind": "ledge", "x": 14.5,"z": -11, "rot": 0, "hw": 1.5, "hd": 0.8, "h": 0.3 },
{ "id": "manny", "kind": "funbox", "x": 26, "z": -14, "rot": 0, "hw": 2.2, "hd": 1.2, "h": 0.35, "skirt": 1.1 },
{ "id": "long_ldg", "kind": "ledge", "x": 33.5,"z": -2, "rot": 0, "hw": 0.5, "hd": 12, "h": 0.42 },
{ "id": "stairs_1", "kind": "stairs", "x": 6, "z": 12.2,"rot": 0, "width": 4.8, "h": 0.6, "steps": 4, "going": 0.6, "platDepth": 3.2 }
],
"rails": [
{ "id": "r1", "name": "Funbox Flat Rail", "a": [13.5, -2.9], "b": [18.5, -2.9], "ya": 1.25, "yb": 1.25, "kind": "rail" },
{ "id": "r2", "name": "Funbox Down Rail", "a": [18.5, -1.0], "b": [21.8, -1.0], "ya": 1.2, "yb": 0.35, "kind": "rail" },
{ "id": "r3", "name": "P-Rail", "a": [22.0, 8.0], "b": [26.0, 8.0], "ya": 0.35, "yb": 0.35, "kind": "rail" },
{ "id": "r4", "name": "Rainbow Rail", "a": [-0.4, 9.0], "b": [2.0, 9.0], "ya": 0.45, "yb": 1.35, "kind": "rail" },
{ "id": "r5", "name": "Rainbow Rail", "a": [2.0, 9.0], "b": [4.4, 9.0], "ya": 1.35, "yb": 0.45, "kind": "rail" },
{ "id": "r6", "name": "Long Metal Ledge", "a": [33.0, -13.5], "b": [33.0, 9.5], "ya": 0.42, "yb": 0.42, "kind": "ledge" },
{ "id": "r7", "name": "Down Ledge", "a": [7.0, -11.9], "b": [10.0, -11.9],"ya": 0.6, "yb": 0.6, "kind": "ledge" },
{ "id": "r8", "name": "Down Ledge", "a": [10.0, -11.9], "b": [13.0, -11.9],"ya": 0.45, "yb": 0.45, "kind": "ledge" },
{ "id": "r9", "name": "Down Ledge", "a": [13.0, -11.9], "b": [16.0, -11.9],"ya": 0.3, "yb": 0.3, "kind": "ledge" },
{ "id": "r10", "name": "Stair Hubba", "a": [3.4, 12.2], "b": [3.4, 15.0], "ya": 0.62, "yb": 0.06, "kind": "ledge" },
{ "id": "r11", "name": "Stair Hubba", "a": [8.6, 12.2], "b": [8.6, 15.0], "ya": 0.62, "yb": 0.06, "kind": "ledge" }
],
"gaps": [
{ "id": "g1", "name": "THE STAIR GAP", "x": 6, "z": 13.6, "r": 2.4 },
{ "id": "g2", "name": "FUNBOX FLY", "x": 16, "z": -2, "r": 2.8 },
{ "id": "g3", "name": "SPINE TRANSFER", "x": -8, "z": 12, "r": 2.4 },
{ "id": "g4", "name": "BOWL HIP", "x": -22.5, "z": -2, "r": 2.2 },
{ "id": "g5", "name": "RAINBOW ARC", "x": 2, "z": 9, "r": 2.2 },
{ "id": "g6", "name": "LEDGE LEAP", "x": 11.5, "z": -11, "r": 2.6 },
{ "id": "g7", "name": "KEYHOLE GAP", "x": -36, "z": -14, "r": 2.6 },
{ "id": "g8", "name": "CAXTON CARVE", "x": 10, "z": -22.5, "r": 2.6 },
{ "id": "g9", "name": "ISLAND HOP", "x": 2, "z": 2.6, "r": 2.2 }
],
"letters": [
{ "id": "L1", "ch": "B", "x": -27, "y": 0.9, "z": -6 },
{ "id": "L2", "ch": "O", "x": -8, "y": 2.3, "z": 12 },
{ "id": "L3", "ch": "O", "x": 16, "y": 2.4, "z": -2 },
{ "id": "L4", "ch": "K", "x": 6, "y": 1.6, "z": 14.2 },
{ "id": "L5", "ch": "Q", "x": -35, "y": 2.2, "z": -13 },
{ "id": "L6", "ch": "U", "x": 2, "y": 2.5, "z": 9 },
{ "id": "L7", "ch": "O", "x": 33, "y": 1.6, "z": -2 },
{ "id": "L8", "ch": "Y", "x": 24, "y": 1.5, "z": 8 }
],
"zones": [
{ "id": "z1", "shape": "rect", "x": 23, "z": 0, "rot": 0, "hw": 17, "hd": 26, "color": "#9aa0a2" },
{ "id": "z2", "shape": "ellipse", "x": 2, "z": -2, "rot": 0, "hw": 7, "hd": 4.5, "color": "#557f3f" },
{ "id": "z3", "shape": "rect", "x": -27, "z": -6, "rot": 0, "hw": 4.2, "hd": 4.2, "color": "#6a7d70" }
],
"decals": [],
"props": [
{ "id": "p1", "src": "builtin:fig", "x": 0, "z": -3, "rot": 0, "scale": 1.25 },
{ "id": "p2", "src": "builtin:fig", "x": 4.5, "z": -1, "rot": 0, "scale": 1.0 },
{ "id": "p3", "src": "builtin:fig", "x": -1, "z": 1, "rot": 0, "scale": 0.9 },
{ "id": "p4", "src": "builtin:fig", "x": -34, "z": 16, "rot": 0, "scale": 1.1 },
{ "id": "p5", "src": "builtin:fig", "x": 30, "z": 20, "rot": 0, "scale": 1.3 },
{ "id": "p6", "src": "builtin:fig", "x": -14, "z": -30, "rot": 0, "scale": 1.2 }
]
}

157
serve.py Normal file
View File

@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""SKATEMAKER PRO dev server.
Static files + three jobs the browser can't do alone:
/api/parks GET list | /api/parks/<name> GET load, POST save (JSON body)
/api/upload?name= POST raw bytes -> assets/uploads/<name>, returns {"path": ...}
/mb/... MODELBEAST proxy (queue on the m3ultra, token from
~/Documents/backnforth/.env stays server-side, never in JS)
/mb/assets GET -> asset list
/mb/assets/<id>/file GET -> file bytes (same-origin for GLTFLoader)
/mb/jobs POST -> submit {operator, asset_id?, params}
/mb/jobs/<id> GET -> poll (control-char-safe)
/mb/import?asset=&name= GET -> download MB asset into assets/uploads/, return path
Run: python3 serve.py [port] (default 8093)
"""
import json, os, re, sys, urllib.parse, urllib.request
from http.server import HTTPServer, SimpleHTTPRequestHandler
ROOT = os.path.dirname(os.path.abspath(__file__))
PARKS = os.path.join(ROOT, 'parks')
UPLOADS = os.path.join(ROOT, 'assets', 'uploads')
MB_HOST = os.environ.get('MB_HOST', 'http://100.89.131.57:8777')
SAFE = re.compile(r'^[\w.\- ]+$')
def mb_token():
t = os.environ.get('MB_TOKEN')
if t:
return t
try:
for line in open(os.path.expanduser('~/Documents/backnforth/.env')):
if line.startswith('MB_TOKEN='):
return line.split('=', 1)[1].strip()
except OSError:
pass
return None
def mb_req(path, data=None, ctype=None, timeout=180):
tok = mb_token()
if not tok:
raise RuntimeError('MB_TOKEN not found')
h = {'Authorization': 'Bearer ' + tok}
if ctype:
h['Content-Type'] = ctype
r = urllib.request.Request(MB_HOST + path, data=data, headers=h)
return urllib.request.urlopen(r, timeout=timeout).read()
def clean_json(b): # MB job logs carry raw control chars
s = b.decode('utf-8', 'replace')
return ''.join(c if c >= ' ' or c in '\t\n' else ' ' for c in s).encode()
class H(SimpleHTTPRequestHandler):
def _send(self, code, body, ctype='application/json'):
self.send_response(code)
self.send_header('Content-Type', ctype)
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.end_headers()
self.wfile.write(body)
def _json(self, obj, code=200):
self._send(code, json.dumps(obj).encode())
def _err(self, msg, code=500):
self._json({'error': str(msg)}, code)
def do_GET(self):
path, _, query = self.path.partition('?')
q = dict(p.split('=', 1) for p in query.split('&') if '=' in p)
try:
if path == '/api/parks':
names = sorted(f[:-5] for f in os.listdir(PARKS) if f.endswith('.json'))
return self._json(names)
if path.startswith('/api/parks/'):
name = urllib.parse.unquote(path.split('/', 3)[3])
if not SAFE.match(name):
return self._err('bad name', 400)
f = os.path.join(PARKS, name + '.json')
if not os.path.exists(f):
return self._err('not found', 404)
return self._send(200, open(f, 'rb').read())
if path == '/mb/assets':
return self._send(200, clean_json(mb_req('/api/assets?limit=100')))
m = re.match(r'^/mb/assets/([\w-]+)/file$', path)
if m:
return self._send(200, mb_req(f'/api/assets/{m.group(1)}/file'),
'application/octet-stream')
m = re.match(r'^/mb/jobs/([\w-]+)$', path)
if m:
return self._send(200, clean_json(mb_req(f'/api/jobs/{m.group(1)}')))
if path == '/mb/import':
aid, name = q.get('asset', ''), urllib.parse.unquote(q.get('name', 'asset.bin'))
if not re.match(r'^[\w-]+$', aid) or not SAFE.match(name):
return self._err('bad args', 400)
os.makedirs(UPLOADS, exist_ok=True)
data = mb_req(f'/api/assets/{aid}/file')
out = os.path.join(UPLOADS, name)
open(out, 'wb').write(data)
return self._json({'path': 'assets/uploads/' + name, 'bytes': len(data)})
except Exception as e:
return self._err(e)
return super().do_GET()
def do_POST(self):
path, _, query = self.path.partition('?')
q = dict(p.split('=', 1) for p in query.split('&') if '=' in p)
n = int(self.headers.get('Content-Length') or 0)
body = self.rfile.read(n) if n else b''
try:
if path.startswith('/api/parks/'):
name = urllib.parse.unquote(path.split('/', 3)[3])
if not SAFE.match(name):
return self._err('bad name', 400)
json.loads(body) # must be valid JSON
os.makedirs(PARKS, exist_ok=True)
open(os.path.join(PARKS, name + '.json'), 'wb').write(body)
return self._json({'saved': name})
if path == '/api/upload':
name = urllib.parse.unquote(q.get('name', ''))
if not SAFE.match(name):
return self._err('bad name', 400)
os.makedirs(UPLOADS, exist_ok=True)
open(os.path.join(UPLOADS, name), 'wb').write(body)
return self._json({'path': 'assets/uploads/' + name, 'bytes': len(body)})
if path == '/mb/jobs':
return self._send(200, clean_json(
mb_req('/api/jobs', data=body, ctype='application/json')))
if path == '/mb/upload': # raw bytes -> MB multipart asset
name = urllib.parse.unquote(q.get('name', 'upload.png'))
if not SAFE.match(name):
return self._err('bad name', 400)
bound = 'smpb0undary'
ctype = q.get('type', 'application/octet-stream')
mp = (f'--{bound}\r\nContent-Disposition: form-data; name="file"; '
f'filename="{name}"\r\nContent-Type: {ctype}\r\n\r\n').encode() \
+ body + f'\r\n--{bound}--\r\n'.encode()
return self._send(200, clean_json(mb_req(
'/api/assets', data=mp,
ctype=f'multipart/form-data; boundary={bound}')))
except Exception as e:
return self._err(e)
return self._err('unknown endpoint', 404)
def log_message(self, fmt, *a):
if '/mb/jobs/' not in str(a[0] if a else ''): # quiet the poll spam
super().log_message(fmt, *a)
if __name__ == '__main__':
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8093
os.chdir(ROOT)
print(f'SKATEMAKER PRO on http://localhost:{port} (MB proxy -> {MB_HOST})')
HTTPServer(('0.0.0.0', port), H).serve_forever()