skatemakerpro/js/editor.js
type-two 7a00c9f9a7 Library expansion: full obstacle set, polyline rails, street props
New heightfield kinds: hip/corner quarter, kicker (tunable concavity), roller,
wedge/manny pad, volcano, round-bowl option, quarter vert extension. Rails are
now polylines (kink/dbl-kink/rainbow/A-frame/pole-jam/donkey presets, round or
square profile, per-node drag handles) that flatten to straight segments on
export so bookquoy grind detection is untouched. 13 parametric street props
(jersey barrier, picnic table, parking block, fence, floodlight, bleachers...).
Palette regrouped TRANSITION/STREET/STEEL/PROPS; park check covers new kinds.

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

549 lines
24 KiB
JavaScript

// 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,
normalizeRail } 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 pts = obj.pts.map(p => new THREE.Vector3(p.x, p.y, p.z));
selGrp.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), mat));
obj.pts.forEach((p, i) => { handle(p.x, p.y, p.z, true).userData.railPt = i; });
} 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();
for (const r of json.rails || []) normalizeRail(r);
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.pts) for (const pt of copy.pts) { pt.x += 2; pt.z += 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.railPt !== undefined)
return { type: 'railPt', i: hs[0].object.userData.railPt };
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 (any segment of the polyline)
for (const r of P.rails) {
for (let i = 0; i < r.pts.length - 1; i++)
if (distSeg(x, z, [r.pts[i].x, r.pts[i].z], [r.pts[i + 1].x, r.pts[i + 1].z]) < 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).toFixed(2);
if (!pendingRail) { pendingRail = { x, z, y }; ed.emit('status', 'rail: click the far end'); return; }
const r = { id: newId(), name: 'Rail', kind: 'rail', profile: 'round',
pts: [{ x: pendingRail.x, z: pendingRail.z, y: pendingRail.y }, { x, z, y }] };
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 === 'railPreset') {
const base = ed.heightAt(x, z);
const r = { id: newId(), name: t.name, kind: t.kind || 'rail', profile: t.profile || 'round',
pts: t.pts.map(([px, pz, py]) => ({ x: +(x + px).toFixed(2), z: +(z + pz).toFixed(2),
y: +(base + py).toFixed(2) })) };
snapshot(); P.rails.push(r);
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 === 'railPt') {
drag = { kind: 'railPt', i: hit.i, 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.pts?.[0]?.x ?? 0) - p.x,
grabDZ: (obj.z ?? obj.pts?.[0]?.z ?? 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 === 'railPt') {
const pt = obj.pts[drag.i];
pt.x = snapV(p.x); pt.z = snapV(p.z);
rebuildRails(); rebuildSelection(); return;
}
const nx = snapV(p.x + drag.grabDX), nz = snapV(p.z + drag.grabDZ);
if (s.type === 'rail') {
const dx = nx - obj.pts[0].x, dz = nz - obj.pts[0].z;
for (const pt of obj.pts) { pt.x = +(pt.x + dx).toFixed(2); pt.z = +(pt.z + dz).toFixed(2); }
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' });
};
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;
}