skatemakerpro/js/ui.js
type-two db40f433fe SKATEMAKER PRO v1 — stand-alone park builder/editor for BOOKQUOY
Data-driven parks: one heightAt(x,z) evaluated from element JSON drives the
editor viewport, collision, test ride, and exported game levels. Ships with
Paddo ported from bookquoy, a MODELBEAST panel (gen images, cut bg, image->3D,
place farm GLBs as props), image decals + reference underlay tracing, a park
design linter (docs/DESIGN_PRINCIPLES.md), THPS-style instant test ride, and
a level.js codegen that drops straight into bookquoy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 19:34:45 +10:00

460 lines
22 KiB
JavaScript

// 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');
}