// SKATEMAKER PRO — park -> three.js scene. Shared by the editor AND exported levels, // so what you see in the editor is byte-for-byte what the game builds. import * as THREE from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { makeHeightAt, normalizeRail, bowlRimWorld } from './parkmath.js'; import { makeBuiltinProp } from './props3d.js'; export const GRID_RES = 0.5, GRID_PAD = 14; // ---------------------------------------------------------------- ground texture // Zones + image decals bake into one canvas mapped planar over the grid. // Vertex colors carry height shading; three multiplies map * vertexColors. export function bakeGroundTexture(park, x0, x1, z0, z1) { const PX = 16; // texels per metre const cv = document.createElement('canvas'); cv.width = Math.min(4096, Math.round((x1 - x0) * PX)); cv.height = Math.min(4096, Math.round((z1 - z0) * PX)); const sx = cv.width / (x1 - x0), sz = cv.height / (z1 - z0); const g = cv.getContext('2d'); const px = (x, z) => [(x - x0) * sx, (z - z0) * sz]; g.fillStyle = park.grassColor || '#4d7c3c'; // parkland g.fillRect(0, 0, cv.width, cv.height); const B = park.bounds; const [bx, bz] = px(B.x0, B.z0); g.fillStyle = park.slabColor || '#7fae6f'; // the slab g.fillRect(bx, bz, (B.x1 - B.x0) * sx, (B.z1 - B.z0) * sz); const drawShape = zn => { g.save(); const [cx, cz] = px(zn.x, zn.z); g.translate(cx, cz); g.rotate(zn.rot || 0); g.beginPath(); if (zn.shape === 'ellipse') g.ellipse(0, 0, zn.hw * sx, zn.hd * sz, 0, 0, Math.PI * 2); else g.rect(-zn.hw * sx, -zn.hd * sz, zn.hw * 2 * sx, zn.hd * 2 * sz); }; for (const zn of park.zones || []) { drawShape(zn); g.fillStyle = zn.color || '#9aa0a2'; g.globalAlpha = zn.opacity ?? 1; g.fill(); g.restore(); } // pool detail: tile band + coping line traced around every custom-bowl rim for (const e of park.elements || []) { if (e.kind !== 'bowlpoly' || !e.tile) continue; const rim = bowlRimWorld(e).map(([wx, wz]) => px(wx, wz)); for (const [w, color] of [[0.7 * PX, '#7fb4c9'], [0.22 * PX, '#e8e4d8']]) { g.beginPath(); rim.forEach(([cx, cz], i) => i ? g.lineTo(cx, cz) : g.moveTo(cx, cz)); g.closePath(); g.strokeStyle = color; g.lineWidth = w; g.lineJoin = 'round'; g.stroke(); } } const jobs = []; // decals load async, re-bake per image for (const d of park.decals || []) { const img = new Image(); jobs.push(new Promise(res => { img.onload = () => res({ d, img }); img.onerror = () => res(null); img.src = d.img; })); } const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; Promise.all(jobs).then(loaded => { for (const hit of loaded) { if (!hit) continue; const { d, img } = hit; g.save(); const [cx, cz] = px(d.x, d.z); g.translate(cx, cz); g.rotate(d.rot || 0); g.globalAlpha = d.opacity ?? 1; g.drawImage(img, -d.w / 2 * sx, -d.h / 2 * sz, d.w * sx, d.h * sz); g.restore(); } tex.needsUpdate = true; }); return tex; } // height shading: darker in carves, subtle lift with elevation — cheap depth cueing function shade(h) { const s = h < -0.02 ? 0.72 + 0.1 * h : Math.min(1, 0.9 + h * 0.06); return Math.max(0.55, s); } export function buildGround(park) { const heightAt = makeHeightAt(park); const B = park.bounds; const x0 = B.x0 - GRID_PAD, x1 = B.x1 + GRID_PAD, z0 = B.z0 - GRID_PAD, z1 = B.z1 + GRID_PAD; const nx = Math.round((x1 - x0) / GRID_RES) + 1, nz = Math.round((z1 - z0) / GRID_RES) + 1; const pos = new Float32Array(nx * nz * 3), col = new Float32Array(nx * nz * 3), uv = new Float32Array(nx * nz * 2), idx = []; let p = 0, q = 0; for (let j = 0; j < nz; j++) for (let i = 0; i < nx; i++) { const x = x0 + i * GRID_RES, z = z0 + j * GRID_RES, h = heightAt(x, z); pos[p] = x; pos[p + 1] = h; pos[p + 2] = z; const s = shade(h); col[p] = s; col[p + 1] = s; col[p + 2] = s; p += 3; uv[q++] = (x - x0) / (x1 - x0); uv[q++] = 1 - (z - z0) / (z1 - z0); } for (let j = 0; j < nz - 1; j++) for (let i = 0; i < nx - 1; i++) { const a = j * nx + i, b = a + 1, c = a + nx, d = c + 1; idx.push(a, c, b, b, c, d); } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); geo.setAttribute('color', new THREE.BufferAttribute(col, 3)); geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); geo.setIndex(idx); geo.computeVertexNormals(); const tex = bakeGroundTexture(park, x0, x1, z0, z1); const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ map: tex, vertexColors: true })); mesh.receiveShadow = true; mesh.name = 'ground'; return mesh; } // ---------------------------------------------------------------- steel // Rails are polylines (kinks, rainbows, A-frames); each segment is one bar. Round // profile = pipe + posts; square profile = angle-iron look, no posts. export function buildRails(park) { const grp = new THREE.Group(); grp.name = 'rails'; const steel = new THREE.MeshStandardMaterial({ color: 0xb9bec4, metalness: 0.8, roughness: 0.35 }); const UP = new THREE.Vector3(0, 1, 0); for (const r of park.rails || []) { normalizeRail(r); for (let i = 0; i < r.pts.length - 1; i++) { const pa = r.pts[i], pb = r.pts[i + 1]; const a = new THREE.Vector3(pa.x, pa.y, pa.z); const b = new THREE.Vector3(pb.x, pb.y, pb.z); const len = Math.max(a.distanceTo(b), 0.01); const geo = r.profile === 'square' ? new THREE.BoxGeometry(0.09, len, 0.07) : new THREE.CylinderGeometry(0.035, 0.035, len, 8); const bar = new THREE.Mesh(geo, steel); bar.position.copy(a).add(b).multiplyScalar(0.5); bar.quaternion.setFromUnitVectors(UP, b.clone().sub(a).normalize()); bar.userData.railId = r.id; grp.add(bar); } if (r.kind === 'rail' && r.profile !== 'square') { for (const p of r.pts) { // a post under every node if (p.y < 0.12) continue; const post = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, p.y, 6), steel); post.position.set(p.x, p.y / 2, p.z); post.userData.railId = r.id; grp.add(post); } } } // bullnose pool coping around custom-bowl rims const stone = new THREE.MeshStandardMaterial({ color: 0xe8e4d8, roughness: 0.7 }); const UP2 = new THREE.Vector3(0, 1, 0); for (const e of park.elements || []) { if (e.kind !== 'bowlpoly' || !e.coping) continue; const rim = bowlRimWorld(e); for (let i = 0; i < rim.length; i++) { const [ax, az] = rim[i], [bx, bz] = rim[(i + 1) % rim.length]; const a = new THREE.Vector3(ax, 0.03, az), b = new THREE.Vector3(bx, 0.03, bz); const len = Math.max(a.distanceTo(b), 0.01); const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.055, len, 8), stone); bar.position.copy(a).add(b).multiplyScalar(0.5); bar.quaternion.setFromUnitVectors(UP2, b.clone().sub(a).normalize()); bar.userData.elId = e.id; grp.add(bar); } } return grp; } // ---------------------------------------------------------------- props export function buildProps(park, heightAt, onLoaded) { const grp = new THREE.Group(); grp.name = 'props'; const loader = new GLTFLoader(); for (const pr of park.props || []) { const place = obj => { obj.position.set(pr.x, heightAt(pr.x, pr.z) + (pr.y || 0), pr.z); obj.rotation.y = pr.rot || 0; obj.scale.setScalar(pr.scale || 1); obj.userData.propId = pr.id; obj.traverse(o => { o.userData.propId = pr.id; }); grp.add(obj); if (onLoaded) onLoaded(pr, obj); }; if (pr.src.startsWith('builtin:')) { const obj = makeBuiltinProp(pr.src.slice(8)); if (obj) place(obj); else console.warn('unknown builtin prop', pr.src); } else loader.load(pr.src, g => place(g.scene), undefined, () => console.warn('prop failed', pr.src)); } return grp; } // ---------------------------------------------------------------- letters export function buildLetters(park, heightAt) { return (park.letters || []).map(L => { const cv = document.createElement('canvas'); cv.width = cv.height = 128; const cx = cv.getContext('2d'); cx.fillStyle = '#ffd93b'; cx.strokeStyle = '#2b2b2b'; cx.lineWidth = 10; cx.font = 'bold 104px monospace'; cx.textAlign = 'center'; cx.textBaseline = 'middle'; cx.strokeText(L.ch, 64, 70); cx.fillText(L.ch, 64, 70); const tex = new THREE.CanvasTexture(cv); const spr = new THREE.Mesh(new THREE.PlaneGeometry(0.9, 0.9), new THREE.MeshBasicMaterial({ map: tex, transparent: true, side: THREE.DoubleSide })); const base = heightAt(L.x, L.z); spr.position.set(L.x, Math.max(base, 0) + L.y, L.z); spr.userData = { ch: L.ch, taken: false, baseY: spr.position.y, letterId: L.id }; return spr; }); } // ---------------------------------------------------------------- whole scene // Returns the same shape bookquoy's buildLevel returns, so exports are drop-ins. export function buildParkScene(scene, park, opts = {}) { const heightAt = makeHeightAt(park); const ground = buildGround(park); scene.add(ground); const rails = buildRails(park); scene.add(rails); const props = buildProps(park, heightAt, opts.onPropLoaded); scene.add(props); let letterMeshes = []; if (!opts.skipLetters) { letterMeshes = buildLetters(park, heightAt); for (const m of letterMeshes) scene.add(m); } return { ground, rails, props, letterMeshes }; }