// 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, ELEMENT_KINDS } from './parkmath.js'; import { buildGround, buildRails, buildProps, buildLetters, bakeGroundTexture, GRID_PAD } from './parkbuild.js'; import { makeGrass, parkGrassAreas } from './grass.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, grassGrp = 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 rebuildGrass() { if (grassGrp) { scene.remove(grassGrp); grassGrp.userData.dispose?.(); grassGrp = null; } const g = ed.park.grass || (ed.park.grass = { style: 'lush', density: 8 }); if (g.style === 'off') return; grassGrp = makeGrass({ heightAt: ed.heightAt, areas: parkGrassAreas(ed.park, ed.heightAt), style: g.style, density: g.density ?? 8, seed: 7 }); scene.add(grassGrp); } ed.rebuildGrass = rebuildGrass; ed.grassPlayer = (x, z, on) => grassGrp?.userData.setPlayer(x, z, on); 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)); if (obj.kind === 'bowlpoly') { // rim vertices are draggable const c = Math.cos(obj.rot || 0), sn = Math.sin(obj.rot || 0); obj.pts.forEach((p, i) => { const wx = obj.x + p.u * c - p.v * sn, wz = obj.z + p.u * sn + p.v * c; handle(wx, 0.18, wz, true).userData.bowlPt = i; }); } } 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(); rebuildGrass(); 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(); rebuildGrass(); } 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(); if (s.type === 'prop') dropKitLinks(arr[i]); 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); if (s.type === 'prop' && kitDef(copy)) makeKitLinks(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 (hs.length && hs[0].object.userData.bowlPt !== undefined) return { type: 'bowlPt', i: hs[0].object.userData.bowlPt }; 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); if (t.src.startsWith('kit:')) makeKitLinks(pr); ed.selection = { type: 'prop', id: pr.id }; ed.commit({ snapshot: false, props: true, rails: true, ground: !!kitDef(pr)?.element }); ed.setTool(null); } } // kit props carry their own collision element + grind rails, linked by prop id. // Local frame follows three's rotation.y: wx = x + lx*cos(th) + lz*sin(th), // wz = z - lx*sin(th) + lz*cos(th). Elements use rot = -th (see parkmath local()). const kitDef = pr => pr?.src?.startsWith('kit:') ? ed.kit[pr.src.slice(4)] : null; function kitWorld(pr, lx, lz) { const th = pr.rot || 0, c = Math.cos(th), sn = Math.sin(th), sc = pr.scale || 1; return [pr.x + (lx * c + lz * sn) * sc, pr.z + (-lx * sn + lz * c) * sc]; } function syncKitLinks(pr) { const def = kitDef(pr); if (!def) return; const sc = pr.scale || 1; for (const r of ed.park.rails) { if (r.linkedTo !== pr.id) continue; const g = def.grinds[r.linkIdx]; const [ax, az] = kitWorld(pr, g.a[0], g.a[1]); const [bx, bz] = kitWorld(pr, g.b[0], g.b[1]); r.pts = [{ x: +ax.toFixed(2), z: +az.toFixed(2), y: +(g.ya * sc).toFixed(2) }, { x: +bx.toFixed(2), z: +bz.toFixed(2), y: +(g.yb * sc).toFixed(2) }]; } const el = ed.park.elements.find(e => e.linkedTo === pr.id); if (el) { const d = def.element; let ax = 0, az = 0; // anchor: quarters sit on their coping line if (d.kind === 'quarter' && def.grinds?.length) { ax = (def.grinds[0].a[0] + def.grinds[0].b[0]) / 2; az = (def.grinds[0].a[1] + def.grinds[0].b[1]) / 2; } const [wx, wz] = kitWorld(pr, ax, az); el.x = +wx.toFixed(2); el.z = +wz.toFixed(2); el.rot = -(pr.rot || 0); } } function makeKitLinks(pr) { const def = kitDef(pr); if (!def) return; (def.grinds || []).forEach((g, i) => { ed.park.rails.push({ id: newId(), name: def.desc || def.id, kind: g.kind || 'ledge', profile: 'round', linkedTo: pr.id, linkIdx: i, pts: [] }); }); if (def.element) { const e = { id: newId(), linkedTo: pr.id, rot: 0, x: 0, z: 0, ...structuredClone(ELEMENT_KINDS[def.element.kind].defaults), ...structuredClone(def.element) }; ed.park.elements.push(e); } syncKitLinks(pr); } function dropKitLinks(pr) { ed.park.rails = ed.park.rails.filter(r => r.linkedTo !== pr.id); ed.park.elements = ed.park.elements.filter(e => e.linkedTo !== pr.id); } ed.kitDef = kitDef; // ---------------------------------------------------------------- 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' || hit.type === 'bowlPt') { drag = { kind: hit.type, 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; } if (drag.kind === 'bowlPt') { // world -> bowl-local vertex const c = Math.cos(obj.rot || 0), sn = Math.sin(obj.rot || 0); const du = snapV(p.x) - obj.x, dz = snapV(p.z) - obj.z; obj.pts[drag.i].u = +(du * c + dz * sn).toFixed(2); obj.pts[drag.i].v = +(-du * sn + dz * c).toFixed(2); requestGround(); 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 (kitDef(obj)) { syncKitLinks(obj); requestGround(); rebuildRails(); } } 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(); if (s?.type === 'prop' && kitDef(getSelected())) 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 (obj.kind === 'bowlpoly') rebuildRails(); } if (t === 'zone' || t === 'decal') rebakeTexture(); if (t === 'rail') rebuildRails(); if (t === 'prop') { rebuildProps(); if (kitDef(obj)) { syncKitLinks(obj); requestGround(); rebuildRails(); } } 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(); try { const m = await fetch('/kit/manifest.json').then(r => r.json()); ed.kit = Object.fromEntries(m.props.map(p => [p.id, p])); ed.kitList = m.props; } catch { ed.kit = {}; ed.kitList = []; } 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; }