New bowlpoly element: the rim is a polygon, every vertex a draggable handle with its own depth slider. Inside/outside corners, hips, shallow-to-deep escalators and waterfall drops all come from the same continuous carve (IDW floor blend, quarter-pipe wall transition) — no seams by construction. Auto pool coping (bullnose tube, exported as grindable ledge segments per rim edge) and tile band baked into the ground texture. Ships parks/pool_demo.json (THE POOLROOM) as a premade showcase; ridden end-to-end in test mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
567 lines
25 KiB
JavaScript
567 lines
25 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));
|
|
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(); 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 (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);
|
|
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' || 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 (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 (obj.kind === 'bowlpoly') rebuildRails(); }
|
|
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;
|
|
}
|