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>
460 lines
22 KiB
JavaScript
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');
|
|
}
|