skatemakerpro/js/parkbuild.js
type-two db40f433fe SKATEMAKER PRO v1 — stand-alone park builder/editor for BOOKQUOY
Data-driven parks: one heightAt(x,z) evaluated from element JSON drives the
editor viewport, collision, test ride, and exported game levels. Ships with
Paddo ported from bookquoy, a MODELBEAST panel (gen images, cut bg, image->3D,
place farm GLBs as props), image decals + reference underlay tracing, a park
design linter (docs/DESIGN_PRINCIPLES.md), THPS-style instant test ride, and
a level.js codegen that drops straight into bookquoy.

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

205 lines
8.3 KiB
JavaScript

// 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 } from './parkmath.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();
}
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
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 });
for (const r of park.rails || []) {
const a = new THREE.Vector3(r.a[0], r.ya, r.a[1]);
const b = new THREE.Vector3(r.b[0], r.yb, r.b[1]);
const len = Math.max(a.distanceTo(b), 0.01);
const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, len, 8), steel);
bar.position.copy(a).add(b).multiplyScalar(0.5);
bar.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.clone().sub(a).normalize());
bar.userData.railId = r.id;
grp.add(bar);
if (r.kind === 'rail') {
for (const t of [0.12, 0.88]) {
const p = a.clone().lerp(b, t);
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, Math.max(p.y, 0.05), 6), steel);
post.position.set(p.x, p.y / 2, p.z);
post.userData.railId = r.id;
grp.add(post);
}
}
}
return grp;
}
// ---------------------------------------------------------------- props
const fig = (s, trunkM, leafM) => { // builtin Moreton Bay fig
const grp = new THREE.Group();
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.28 * s, 0.42 * s, 2.6 * s, 7), trunkM);
trunk.position.y = 1.3 * s; grp.add(trunk);
for (const [ox, oy, oz, r] of [[0, 3.1, 0, 2.4], [1.4, 2.6, 0.7, 1.7], [-1.3, 2.7, -0.6, 1.6]]) {
const m = new THREE.Mesh(new THREE.SphereGeometry(r * s, 10, 8), leafM);
m.position.set(ox * s, oy * s, oz * s); grp.add(m);
}
return grp;
};
export function buildProps(park, heightAt, onLoaded) {
const grp = new THREE.Group(); grp.name = 'props';
const loader = new GLTFLoader();
const trunkM = new THREE.MeshLambertMaterial({ color: 0x5b4632 });
const leafM = new THREE.MeshLambertMaterial({ color: 0x2e5b2a });
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;
const s = pr.scale || 1;
if (pr.src !== 'builtin:fig') obj.scale.setScalar(s);
obj.userData.propId = pr.id;
obj.traverse(o => { o.userData.propId = pr.id; });
grp.add(obj);
if (onLoaded) onLoaded(pr, obj);
};
if (pr.src === 'builtin:fig') place(fig(pr.scale || 1, trunkM, leafM));
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 };
}