serve.py mounts ../park_kit at /kit/. New PARK KIT drawer browses all 52
manifest props (filter + category groups); placing one creates a linked unit:
textured GLB + matching heightfield element (anchored on the coping/grind
line; quarter kind gained an optional h cap for truncated real-mini-ramp
transitions) + grindable edge rails. Move/rotate/scale/delete cascades through
the links. Kit textures also drive the ground: park.surf {slab,grass} pattern-
fills the bake (concrete_green, tarmac, grass_dry...), PARK panel selects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
289 lines
12 KiB
JavaScript
289 lines
12 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, bowlRimWorld } from './parkmath.js';
|
|
import { makeBuiltinProp } from './props3d.js';
|
|
import { makeGrass, parkGrassAreas } from './grass.js';
|
|
|
|
export const GRID_RES = 0.5, GRID_PAD = 14;
|
|
// where the park_kit mount lives; games can repoint it (e.g. '../park_kit/')
|
|
export let KIT_BASE = '/kit/';
|
|
export const setKitBase = u => { KIT_BASE = u; };
|
|
|
|
// ---------------------------------------------------------------- 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];
|
|
|
|
const B = park.bounds;
|
|
const [bx, bz] = px(B.x0, B.z0);
|
|
const TILE_M = 3; // metres per texture tile
|
|
const paint = (fill, x, z, w, h) => { g.fillStyle = fill; g.fillRect(x, z, w, h); };
|
|
const surfFill = (img, fallback) => {
|
|
if (!img) return fallback;
|
|
const pat = g.createPattern(img, 'repeat');
|
|
const m = new DOMMatrix().scale(TILE_M * sx / img.width, TILE_M * sz / img.height);
|
|
pat.setTransform(m);
|
|
return pat;
|
|
};
|
|
const drawBase = (imgs = {}) => {
|
|
paint(surfFill(imgs.grass, park.grassColor || '#4d7c3c'), 0, 0, cv.width, cv.height);
|
|
paint(surfFill(imgs.slab, park.slabColor || '#7fae6f'),
|
|
bx, bz, (B.x1 - B.x0) * sx, (B.z1 - B.z0) * sz);
|
|
};
|
|
drawBase();
|
|
const surf = park.surf || {};
|
|
const surfJobs = [];
|
|
for (const key of ['slab', 'grass']) {
|
|
if (!surf[key]) continue;
|
|
const img = new Image();
|
|
surfJobs.push(new Promise(res => {
|
|
img.onload = () => res([key, img]);
|
|
img.onerror = () => res(null);
|
|
img.src = KIT_BASE + 'textures/' + surf[key] + '.jpg';
|
|
}));
|
|
}
|
|
|
|
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;
|
|
const drawZonesRims = () => {
|
|
for (const zn of park.zones || []) {
|
|
drawShape(zn);
|
|
g.fillStyle = zn.color || '#9aa0a2';
|
|
g.globalAlpha = zn.opacity ?? 1;
|
|
g.fill(); g.restore();
|
|
}
|
|
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();
|
|
}
|
|
}
|
|
};
|
|
Promise.all([Promise.all(surfJobs), Promise.all(jobs)]).then(([surfs, loaded]) => {
|
|
const imgs = Object.fromEntries(surfs.filter(Boolean));
|
|
if (imgs.slab || imgs.grass) { drawBase(imgs); drawZonesRims(); }
|
|
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 {
|
|
const url = pr.src.startsWith('kit:')
|
|
? KIT_BASE + 'props/' + pr.src.slice(4) + '.glb' : pr.src;
|
|
loader.load(url, 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);
|
|
let grass = null;
|
|
const gcfg = park.grass || { style: 'lush', density: 8 };
|
|
if (gcfg.style && gcfg.style !== 'off' && !opts.skipGrass) {
|
|
grass = makeGrass({ heightAt, areas: parkGrassAreas(park, heightAt),
|
|
style: gcfg.style, density: gcfg.density ?? 8 });
|
|
scene.add(grass);
|
|
}
|
|
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, grass };
|
|
}
|