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>
549 lines
24 KiB
JavaScript
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;
|
|
}
|