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>
204 lines
8.2 KiB
JavaScript
204 lines
8.2 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, normalizeRail } 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();
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
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 };
|
|
}
|