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:
commit
db40f433fe
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal 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
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.DS_Store
|
||||||
|
assets/uploads/
|
||||||
|
__pycache__/
|
||||||
59
README.md
Normal file
59
README.md
Normal 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
36
docs/DESIGN_PRINCIPLES.md
Normal 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** — 10–15ft (3–4.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: 6–8ft (1.8–2.4m) for
|
||||||
|
street-flow sections, 9–11ft+ (2.7–3.4m) for deep bowls.
|
||||||
|
|
||||||
|
## Obstacle variety & progression
|
||||||
|
- **Skill tiering** — 8–10in (0.2–0.25m) micro-ledges and mellow banks alongside the
|
||||||
|
14–18in (0.35–0.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
104
index.html
Normal 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
538
js/editor.js
Normal 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
49
js/export.js
Normal 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
69
js/modelbeast.js
Normal 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
204
js/parkbuild.js
Normal 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
98
js/parkcheck.js
Normal 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.8–2.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.7–3.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 3–4.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
253
js/parkmath.js
Normal 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
120
js/testride.js
Normal 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
459
js/ui.js
Normal 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
75
parks/paddo.json
Normal 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
157
serve.py
Normal 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()
|
||||||
Loading…
Reference in New Issue
Block a user