From 7a00c9f9a7800cd53faa75f125f1c2ef76e5b4a7 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 3 Aug 2026 20:19:08 +1000 Subject: [PATCH] Library expansion: full obstacle set, polyline rails, street props 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 --- README.md | 21 +++++-- js/editor.js | 64 ++++++++++++--------- js/export.js | 4 +- js/parkbuild.js | 59 ++++++++++--------- js/parkcheck.js | 27 +++++---- js/parkmath.js | 110 +++++++++++++++++++++++++++++++++-- js/props3d.js | 148 ++++++++++++++++++++++++++++++++++++++++++++++++ js/ui.js | 109 ++++++++++++++++++++++++++++++----- 8 files changed, 447 insertions(+), 95 deletions(-) create mode 100644 js/props3d.js diff --git a/README.md b/README.md index a1603a1..7615d66 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,22 @@ persistence (`parks/`, `assets/uploads/`). ## The editor -- **BUILD palette** — quarter, bank, funbox, pyramid, ledge, stairs, spine, mogul, bowl, - island. Click a piece, click the ground. Drag to move, `[` `]` rotate, `D` dupe, - `⌫` delete, `⌘Z` undo, optional 0.5m grid snap (THPS style). -- **SPOTS** — rails/ledge-caps (two clicks, draggable end-balls), named score gaps, - B·O·O·K·Q·U·O·Y letters, spawn point. +- **TRANSITION** — quarter (escalating radius, lump, vert extension), hip/corner quarter, + bank, kicker (tunable concavity), spine, roller/pump bump, mogul, volcano, + bowl (square pool or round). **STREET** — funbox, pyramid, ledge, wedge/manny pad + (inclined), stairs, island. Click a piece, click the ground. Drag to move, `[` `]` + rotate, `D` dupe, `⌫` delete, `⌘Z` undo, optional 0.5m grid snap (THPS style). +- **STEEL** — rails are polylines: flatbar, square bar, down rail, kink, double-kink, + rainbow, A-frame, pole jam, donkey kick — or draw your own (RAIL, two clicks) and + `+ POINT` for kinks. Round pipe (with posts) or square angle-iron profile; every node + is a draggable ball. Exports flatten to straight segments so bookquoy's grind + detection needs zero changes. +- **PROPS** — parametric street furniture: jersey barrier, picnic table, parking block, + bench, trash can, hydrant, chain-link fence, floodlight, bleachers, shade sail, + fountain, drain grate, fig tree. Make any prop grindable by drawing a ledge rail + along its edge. (Overhangs — cradles, capsules, over-vert — aren't heightfield-able; + those arrive as MODELBEAST GLB props.) +- **SPOTS** — named score gaps, B·O·O·K·Q·U·O·Y letters, spawn point. - **SURFACE** — paint zones (rect/ellipse, any color), **image decals** (drop any image on the window, or generate one on the farm), and a **reference underlay** for tracing a real park from an aerial photo. diff --git a/js/editor.js b/js/editor.js index d770680..6fcc187 100644 --- a/js/editor.js +++ b/js/editor.js @@ -3,8 +3,8 @@ // 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 } - from './parkmath.js'; +import { makeHeightAt, newElement, newId, elementHit, elementOutline, emptyPark, + normalizeRail } from './parkmath.js'; import { buildGround, buildRails, buildProps, buildLetters, bakeGroundTexture, GRID_PAD } from './parkbuild.js'; @@ -140,11 +140,9 @@ export function initEditor(container) { }; if (sel.type === 'element') loop(elementOutline(obj)); else if (sel.type === 'rail') { - const a = new THREE.Vector3(obj.a[0], obj.ya, obj.a[1]); - const b = new THREE.Vector3(obj.b[0], obj.yb, obj.b[1]); - selGrp.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([a, b]), mat)); - handle(a.x, a.y, a.z, true).userData.railEnd = 'a'; - handle(b.x, b.y, b.z, true).userData.railEnd = 'b'; + 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++) { @@ -222,6 +220,7 @@ export function initEditor(container) { 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'); }; @@ -265,7 +264,7 @@ export function initEditor(container) { 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.a) { copy.a = [copy.a[0] + 2, copy.a[1] + 2]; copy.b = [copy.b[0] + 2, copy.b[1] + 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'); @@ -295,8 +294,8 @@ export function initEditor(container) { 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.railEnd) - return { type: 'railEnd', end: hs[0].object.userData.railEnd }; + 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) @@ -310,9 +309,12 @@ export function initEditor(container) { 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 - for (const r of P.rails) if (distSeg(x, z, r.a, r.b) < 0.6) - return { type: 'rail', id: r.id }; + // 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 }; } @@ -348,14 +350,23 @@ export function initEditor(container) { 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; + 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', a: [pendingRail.x, pendingRail.z], b: [x, z], - ya: +pendingRail.y.toFixed(2), yb: +y.toFixed(2), kind: 'rail' }; + 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); @@ -401,16 +412,16 @@ export function initEditor(container) { if (ed.tool) { if (p) place(p); return; } const hit = pick(ev, p); if (!hit) { ed.select(null); return; } - if (hit.type === 'railEnd') { - drag = { kind: 'railEnd', end: hit.end, moved: false }; + 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.a?.[0] ?? 0) - p.x, - grabDZ: (obj.z ?? obj.a?.[1] ?? 0) - p.z }; + grabDX: (obj.x ?? obj.pts?.[0]?.x ?? 0) - p.x, + grabDZ: (obj.z ?? obj.pts?.[0]?.z ?? 0) - p.z }; controls.enabled = false; snapshot(); } }); @@ -419,16 +430,15 @@ export function initEditor(container) { const p = groundPoint(ev); if (!p) return; const s = ed.selection, obj = getSelected(); if (!obj) return; drag.moved = true; - if (drag.kind === 'railEnd') { - const end = drag.end; - obj[end] = [snapV(p.x), snapV(p.z)]; - obj[end === 'a' ? 'ya' : 'yb'] = +(ed.heightAt(p.x, p.z) + 0.4).toFixed(2); + 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.a[0], dz = nz - obj.a[1]; - obj.a = [obj.a[0] + dx, obj.a[1] + dz]; obj.b = [obj.b[0] + dx, obj.b[1] + dz]; + 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(); @@ -444,7 +454,7 @@ export function initEditor(container) { 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' || s?.type === 'railEnd' }); + ed.commit({ snapshot: false, rails: s?.type === 'rail' }); }; renderer.domElement.addEventListener('pointerup', endDrag); renderer.domElement.addEventListener('pointerleave', endDrag); diff --git a/js/export.js b/js/export.js index 6dad687..fc6bcb3 100644 --- a/js/export.js +++ b/js/export.js @@ -24,13 +24,13 @@ export function generateLevelJS(park) { // Drop-in for BOOKQUOY: copy this file + parkmath.js + parkbuild.js (+ assets/uploads/) // into bookquoy/js/ and point main.js/player.js imports at it. Same contract as level.js: // heightAt(x,z) is the single source of truth for visuals AND collision. -import { makeHeightAt, makeNormalAt } from './parkmath.js'; +import { makeHeightAt, makeNormalAt, flattenRails } from './parkmath.js'; import { buildParkScene } from './parkbuild.js'; export const PARK = ${JSON.stringify(park, null, 1)}; export const BOUNDS = PARK.bounds; -export const RAILS = PARK.rails; +export const RAILS = flattenRails(PARK); export const GAPS = PARK.gaps; export const LETTERS = PARK.letters; export const SPAWN = PARK.spawn; diff --git a/js/parkbuild.js b/js/parkbuild.js index d2e6cbc..3bb1f9a 100644 --- a/js/parkbuild.js +++ b/js/parkbuild.js @@ -2,7 +2,8 @@ // 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'; +import { makeHeightAt, normalizeRail } from './parkmath.js'; +import { makeBuiltinProp } from './props3d.js'; export const GRID_RES = 0.5, GRID_PAD = 14; @@ -107,22 +108,32 @@ export function buildGround(park) { } // ---------------------------------------------------------------- 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 || []) { - 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); + 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); @@ -133,35 +144,23 @@ export function buildRails(park) { } // ---------------------------------------------------------------- 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.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 === 'builtin:fig') place(fig(pr.scale || 1, trunkM, leafM)); - else loader.load(pr.src, g => place(g.scene), + 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; diff --git a/js/parkcheck.js b/js/parkcheck.js index 6b4930d..16f8b7a 100644 --- a/js/parkcheck.js +++ b/js/parkcheck.js @@ -3,8 +3,8 @@ // metres: street trannies want r 1.8-2.4, bowls 2.7-3.4, run-outs 3-4.5m of flat. import { makeHeightAt, elementOutline } from './parkmath.js'; -const STREET_KINDS = new Set(['ledge', 'funbox', 'stairs', 'pyramid']); -const TRANNY_KINDS = new Set(['quarter', 'spine', 'bowl', 'bank', 'mogul']); +const STREET_KINDS = new Set(['ledge', 'funbox', 'stairs', 'pyramid', 'wedgepad']); +const TRANNY_KINDS = new Set(['quarter', 'spine', 'bowl', 'bank', 'mogul', 'kicker', 'roller', 'corner', 'volcano']); export function checkPark(park) { const out = []; @@ -34,10 +34,11 @@ export function checkPark(park) { return heightAt(x, z) > 0.16; }; for (const e of els) { - if (!['quarter', 'bank', 'stairs'].includes(e.kind)) continue; + if (!['quarter', 'bank', 'stairs', 'kicker'].includes(e.kind)) continue; const rot = e.rot || 0, c = Math.cos(rot), s = Math.sin(rot); const start = e.kind === 'quarter' ? Math.max(e.r0, e.r1) - : e.kind === 'bank' ? e.run : e.steps * e.going; + : e.kind === 'bank' || e.kind === 'kicker' ? e.run + : e.steps * e.going; let clear = 0; for (let d = 0.5; d <= 4.5; d += 0.5) { const v = start + d; @@ -80,13 +81,19 @@ export function checkPark(park) { if (tranny === 0) warn(null, 'all street, no transition — add quarters/banks/spines'); } - // --- steel: rails floating or buried + // --- steel: rails floating or buried (any node of the polyline) for (const r of park.rails || []) { - const ha = heightAt(r.a[0], r.a[1]), hb = heightAt(r.b[0], r.b[1]); - if (r.ya < ha - 0.05 || r.yb < hb - 0.05) - warn({ type: 'rail', id: r.id }, `"${r.name}" is buried in concrete — raise ya/yb`); - if (r.ya - ha > 1.6 || r.yb - hb > 1.6) - warn({ type: 'rail', id: r.id }, `"${r.name}" floats way above the deck — grinds will feel wrong`); + for (const pt of r.pts || []) { + const h = heightAt(pt.x, pt.z); + if (pt.y < h - 0.05) { + warn({ type: 'rail', id: r.id }, `"${r.name}" is buried in concrete — raise its points`); + break; + } + if (pt.y - h > 1.8) { + warn({ type: 'rail', id: r.id }, `"${r.name}" floats way above the deck — grinds will feel wrong`); + break; + } + } } // --- spawn sanity diff --git a/js/parkmath.js b/js/parkmath.js index 1ecfca5..af51e2f 100644 --- a/js/parkmath.js +++ b/js/parkmath.js @@ -25,19 +25,73 @@ function local(e, x, z) { // world (x,z) -> element-local (u,v) // ---------------------------------------------------------------- element kinds // Each returns a height contribution (concrete = max of all). Bowls carve (override). export const ELEMENT_KINDS = { - quarter: { // straight QP, escalating radius, optional lump + deck + quarter: { // straight QP, escalating radius, lump, deck, vert ext label: 'Quarter Pipe', - defaults: { len: 8, r0: 1.2, r1: 1.2, lump: 0, deck: 0.6 }, + defaults: { len: 8, r0: 1.2, r1: 1.2, lump: 0, deck: 0.6, vert: 0 }, height(e, x, z) { const [u, v] = local(e, x, z); if (Math.abs(u) > e.len / 2) return 0; let r = e.r0 + (e.r1 - e.r0) * sstep(u / e.len + 0.5); if (e.lump) r += e.lump * Math.sin(u * 0.9); if (v < -e.deck) return 0; - if (v <= 0) return r; // deck behind the coping + if (v <= 0) return r + (e.vert || 0); // deck; vert extension rises the wall face return tranny(v, r); }, }, + corner: { // radial quarter wrapped around a point — hips & corners + label: 'Corner / Hip QP', + defaults: { r: 1.4, sweep: Math.PI / 2 }, + height(e, x, z) { + const dx = x - e.x, dz = z - e.z; + const rho = Math.hypot(dx, dz); + if (rho >= e.r) return 0; + let ang = Math.atan2(dz, dx) - (e.rot || 0); + ang = ((ang % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2); + if (ang > e.sweep) return 0; + return tranny(rho, e.r); + }, + }, + kicker: { // launch wedge, blend linear->circular-concave + label: 'Kicker', + defaults: { len: 2.4, h: 0.7, run: 1.8, curve: 0.6, deck: 0.25 }, + height(e, x, z) { + const [u, v] = local(e, x, z); + if (Math.abs(u) > e.len / 2 || v < -e.deck || v >= e.run) return 0; + if (v <= 0) return e.h; + const s = v / e.run; // 0 at lip, 1 at base + const lin = 1 - s; + const con = 1 - Math.sqrt(Math.max(0, 1 - (1 - s) ** 2)); + return e.h * ((1 - (e.curve ?? 0)) * lin + (e.curve ?? 0) * con); + }, + }, + roller: { // pump bump / speed roller — gaussian ridge + label: 'Roller', + defaults: { len: 4, h: 0.45, s: 1.1 }, + height(e, x, z) { + const [u, v] = local(e, x, z); + const over = Math.max(0, Math.abs(u) - e.len / 2); // rounded ends + return e.h * Math.exp(-(v * v + over * over) / (e.s * e.s)); + }, + }, + wedgepad: { // inclined / step-up manny pad (sharp sides) + label: 'Wedge Pad', + defaults: { hw: 1.8, hd: 0.9, h0: 0.2, h1: 0.5 }, + height(e, x, z) { + const [u, v] = local(e, x, z); + if (Math.abs(u) > e.hw || Math.abs(v) > e.hd) return 0; + return e.h0 + (e.h1 - e.h0) * (u / e.hw + 1) / 2; + }, + }, + volcano: { // truncated cone with flat skateable top + label: 'Volcano', + defaults: { r0: 0.7, run: 1.8, h: 0.9 }, + height(e, x, z) { + const rho = Math.hypot(x - e.x, z - e.z); + if (rho <= e.r0) return e.h; + if (rho >= e.r0 + e.run) return 0; + return e.h * (1 - (rho - e.r0) / e.run); + }, + }, bank: { label: 'Bank', defaults: { len: 6, h: 0.9, run: 2.4, deck: 0.5 }, @@ -121,20 +175,46 @@ export const ELEMENT_KINDS = { }, bowl: { // CARVES negative space. Override, not max. label: 'Bowl', - defaults: { size: 4.2, depth: 1.5 }, + defaults: { size: 4.2, depth: 1.5, round: 0 }, carve: true, height(e, x, z) { // returns carve value, caller checks inside() const [u, v] = local(e, x, z); - const dw = Math.min(e.size - Math.abs(u), e.size - Math.abs(v)); + const dw = e.round ? e.size - Math.hypot(u, v) + : Math.min(e.size - Math.abs(u), e.size - Math.abs(v)); return -e.depth + tranny(Math.min(dw, e.depth), e.depth); }, inside(e, x, z) { const [u, v] = local(e, x, z); - return Math.abs(u) < e.size && Math.abs(v) < e.size; + return e.round ? Math.hypot(u, v) < e.size + : Math.abs(u) < e.size && Math.abs(v) < e.size; }, }, }; +// ---------------------------------------------------------------- rails +// v2 rails are polylines: { id, name, kind:'rail'|'ledge', profile:'round'|'square', +// pts:[{x,z,y},...] }. v1 {a,b,ya,yb} files normalize on sight. The GAME contract +// stays straight segments — flattenRails() chops polylines for bookquoy's player. +export function normalizeRail(r) { + if (!r.pts) { + r.pts = [{ x: r.a[0], z: r.a[1], y: r.ya }, { x: r.b[0], z: r.b[1], y: r.yb }]; + delete r.a; delete r.b; delete r.ya; delete r.yb; + } + r.profile ||= 'round'; + return r; +} +export function flattenRails(park) { + const out = []; + for (const r of park.rails || []) { + normalizeRail(r); + for (let i = 0; i < r.pts.length - 1; i++) { + const a = r.pts[i], b = r.pts[i + 1]; + out.push({ name: r.name, a: [a.x, a.z], b: [b.x, b.z], ya: a.y, yb: b.y, kind: r.kind }); + } + } + return out; +} + // ---------------------------------------------------------------- evaluation export function elementHeight(e, x, z) { const k = ELEMENT_KINDS[e.kind]; @@ -192,6 +272,14 @@ export function elementHit(e, x, z) { case 'ledge': { const [u, v] = local(e, x, z); return Math.abs(u) <= e.hw + M && Math.abs(v) <= e.hd + M; } case 'mogul': return Math.hypot(x - e.x, z - e.z) <= e.s + M; + case 'kicker': { const [u, v] = local(e, x, z); + return Math.abs(u) <= e.len / 2 + M && v >= -e.deck - M && v <= e.run + M; } + case 'roller': { const [u, v] = local(e, x, z); + return Math.abs(u) <= e.len / 2 + e.s + M && Math.abs(v) <= e.s + M; } + case 'wedgepad': { const [u, v] = local(e, x, z); + return Math.abs(u) <= e.hw + M && Math.abs(v) <= e.hd + M; } + case 'volcano': return Math.hypot(x - e.x, z - e.z) <= e.r0 + e.run + M; + case 'corner': return Math.hypot(x - e.x, z - e.z) <= e.r + M; case 'spine': { const [u, v] = local(e, x, z); return Math.abs(u) <= e.len / 2 + M && Math.abs(v) <= e.r + M; } case 'stairs': { const [u, v] = local(e, x, z); @@ -220,6 +308,16 @@ export function elementOutline(e) { case 'mogul': { const pts = []; for (let i = 0; i < 24; i++) { const a = i / 24 * Math.PI * 2; pts.push([e.x + Math.cos(a) * e.s, e.z + Math.sin(a) * e.s]); } return pts; } + case 'kicker': return rect(-e.len / 2, e.len / 2, -e.deck, e.run); + case 'roller': return rect(-e.len / 2 - e.s, e.len / 2 + e.s, -e.s, e.s); + case 'wedgepad': return rect(-e.hw, e.hw, -e.hd, e.hd); + case 'volcano': { const pts = [], R = e.r0 + e.run; for (let i = 0; i < 24; i++) { + const a = i / 24 * Math.PI * 2; pts.push([e.x + Math.cos(a) * R, e.z + Math.sin(a) * R]); } + return pts; } + case 'corner': { const pts = [[e.x, e.z]]; for (let i = 0; i <= 12; i++) { + const a = rot + i / 12 * e.sweep; + pts.push([e.x + Math.cos(a) * e.r, e.z + Math.sin(a) * e.r]); } + return pts; } case 'spine': return rect(-e.len / 2, e.len / 2, -e.r, e.r); case 'stairs': return rect(-e.width / 2, e.width / 2, -e.platDepth, e.steps * e.going); case 'dome': { const pts = []; for (let i = 0; i < 24; i++) { diff --git a/js/props3d.js b/js/props3d.js new file mode 100644 index 0000000..d7203ed --- /dev/null +++ b/js/props3d.js @@ -0,0 +1,148 @@ +// SKATEMAKER PRO — builtin street props & park infrastructure. Parametric three.js +// assemblies (instant, tiny, no downloads); MODELBEAST GLBs cover anything fancier. +// Every factory returns a Group with its origin at ground center. To make a prop +// grindable, draw a ledge-cap rail along its edge — steel is data, props are dressing. +import * as THREE from 'three'; + +const M = { + concrete: new THREE.MeshLambertMaterial({ color: 0xa8a49c }), + concreteDark: new THREE.MeshLambertMaterial({ color: 0x8b8880 }), + steel: new THREE.MeshStandardMaterial({ color: 0xb9bec4, metalness: 0.8, roughness: 0.35 }), + steelDark: new THREE.MeshStandardMaterial({ color: 0x5f6670, metalness: 0.7, roughness: 0.45 }), + wood: new THREE.MeshLambertMaterial({ color: 0x9c7a4f }), + woodDark: new THREE.MeshLambertMaterial({ color: 0x74583a }), + red: new THREE.MeshLambertMaterial({ color: 0xc0392b }), + green: new THREE.MeshLambertMaterial({ color: 0x2c6e49 }), + yellow: new THREE.MeshLambertMaterial({ color: 0xd9b23b }), + fabric: new THREE.MeshLambertMaterial({ color: 0x3a7ca5, side: THREE.DoubleSide }), + trunk: new THREE.MeshLambertMaterial({ color: 0x5b4632 }), + leaf: new THREE.MeshLambertMaterial({ color: 0x2e5b2a }), +}; + +const box = (w, h, d, mat, x = 0, y = 0, z = 0, ry = 0) => { + const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat); + m.position.set(x, y, z); m.rotation.y = ry; return m; +}; +const cyl = (r0, r1, h, mat, x = 0, y = 0, z = 0, seg = 10) => { + const m = new THREE.Mesh(new THREE.CylinderGeometry(r0, r1, h, seg), mat); + m.position.set(x, y, z); return m; +}; + +export const PROP_FACTORIES = { + fig() { // Moreton Bay fig (the Paddo original) + const g = new THREE.Group(); + g.add(cyl(0.28, 0.42, 2.6, M.trunk, 0, 1.3, 0, 7)); + 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 s = new THREE.Mesh(new THREE.SphereGeometry(r, 10, 8), M.leaf); + s.position.set(ox, oy, oz); g.add(s); + } + return g; + }, + jersey() { // concrete K-rail, 2m section + const g = new THREE.Group(); + g.add(box(0.62, 0.24, 2.0, M.concreteDark, 0, 0.12)); + const mid = box(0.4, 0.3, 2.0, M.concrete, 0, 0.39); g.add(mid); + const top = box(0.22, 0.4, 2.0, M.concrete, 0, 0.74); g.add(top); + return g; + }, + picnic() { // timber picnic table + const g = new THREE.Group(); + g.add(box(1.6, 0.05, 0.8, M.wood, 0, 0.74)); + g.add(box(1.6, 0.05, 0.3, M.wood, 0, 0.45, 0.62)); + g.add(box(1.6, 0.05, 0.3, M.wood, 0, 0.45, -0.62)); + for (const sx of [-0.65, 0.65]) { + g.add(box(0.08, 0.74, 0.08, M.woodDark, sx, 0.37, 0.25)); + g.add(box(0.08, 0.74, 0.08, M.woodDark, sx, 0.37, -0.25)); + g.add(box(0.08, 0.45, 1.5, M.woodDark, sx, 0.22)); + } + return g; + }, + parkingblock() { // car stop / slappy target + const g = new THREE.Group(); + g.add(box(1.8, 0.14, 0.32, M.yellow, 0, 0.07)); + return g; + }, + trashcan() { + const g = new THREE.Group(); + g.add(cyl(0.34, 0.3, 0.92, M.green, 0, 0.46, 0, 12)); + g.add(cyl(0.36, 0.36, 0.05, M.steelDark, 0, 0.94, 0, 12)); + return g; + }, + hydrant() { + const g = new THREE.Group(); + g.add(cyl(0.14, 0.17, 0.62, M.red, 0, 0.31)); + const dome = new THREE.Mesh(new THREE.SphereGeometry(0.14, 10, 8), M.red); + dome.position.y = 0.64; g.add(dome); + g.add(box(0.44, 0.09, 0.09, M.red, 0, 0.42)); + return g; + }, + bench() { // park bench + const g = new THREE.Group(); + g.add(box(1.6, 0.05, 0.42, M.wood, 0, 0.45)); + g.add(box(1.6, 0.42, 0.05, M.wood, 0, 0.72, -0.2)); + for (const sx of [-0.7, 0.7]) g.add(box(0.07, 0.45, 0.4, M.steelDark, sx, 0.22)); + return g; + }, + fence() { // chain-link section, 2.4m + const g = new THREE.Group(); + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2.4, 1.8, 14, 10), + new THREE.MeshBasicMaterial({ color: 0x9aa2ab, wireframe: true, transparent: true, + opacity: 0.5, side: THREE.DoubleSide })); + mesh.position.y = 0.95; g.add(mesh); + for (const sx of [-1.2, 1.2]) g.add(cyl(0.035, 0.035, 1.9, M.steelDark, sx, 0.95, 0, 8)); + g.add(box(2.4, 0.05, 0.05, M.steelDark, 0, 1.84)); + return g; + }, + floodlight() { + const g = new THREE.Group(); + g.add(cyl(0.09, 0.12, 6.5, M.steelDark, 0, 3.25, 0, 8)); + for (const sx of [-0.3, 0.3]) + g.add(box(0.5, 0.3, 0.25, new THREE.MeshBasicMaterial({ color: 0xfff3c8 }), sx, 6.4)); + return g; + }, + bleachers() { // 3-row spectator bleachers + const g = new THREE.Group(); + for (let i = 0; i < 3; i++) { + g.add(box(3.0, 0.06, 0.4, M.steel, 0, 0.34 + i * 0.32, -i * 0.42)); + g.add(box(3.0, 0.34 + i * 0.32, 0.06, M.steelDark, 0, (0.34 + i * 0.32) / 2, -i * 0.42 + 0.2)); + } + return g; + }, + shadesail() { + const g = new THREE.Group(); + for (const [sx, sz] of [[-2, -2], [2, -2], [0, 2.2]]) + g.add(cyl(0.05, 0.05, 3.2, M.steelDark, sx, 1.6, sz, 8)); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute( + [-2, 3.1, -2, 2, 3.1, -2, 0, 2.7, 2.2], 3)); + geo.computeVertexNormals(); + g.add(new THREE.Mesh(geo, M.fabric)); + return g; + }, + fountain() { // water refill station + const g = new THREE.Group(); + g.add(cyl(0.16, 0.2, 0.85, M.steelDark, 0, 0.42, 0, 8)); + g.add(cyl(0.22, 0.22, 0.07, M.steel, 0, 0.88, 0, 10)); + return g; + }, + grate() { // flush drainage grate (flat detail) + const g = new THREE.Group(); + const p = new THREE.Mesh(new THREE.PlaneGeometry(0.9, 0.9, 6, 6), + new THREE.MeshBasicMaterial({ color: 0x3c4148, wireframe: true })); + p.rotation.x = -Math.PI / 2; p.position.y = 0.015; g.add(p); + return g; + }, +}; + +export const PROP_LABELS = [ + ['jersey', 'JERSEY BARRIER'], ['picnic', 'PICNIC TABLE'], ['parkingblock', 'PARKING BLOCK'], + ['bench', 'BENCH'], ['trashcan', 'TRASH CAN'], ['hydrant', 'HYDRANT'], + ['fence', 'FENCE 2.4M'], ['floodlight', 'FLOODLIGHT'], ['bleachers', 'BLEACHERS'], + ['shadesail', 'SHADE SAIL'], ['fountain', 'FOUNTAIN'], ['grate', 'DRAIN GRATE'], + ['fig', 'FIG TREE'], +]; + +export function makeBuiltinProp(name) { + const f = PROP_FACTORIES[name]; + return f ? f() : null; +} diff --git a/js/ui.js b/js/ui.js index 302de97..368262e 100644 --- a/js/ui.js +++ b/js/ui.js @@ -4,6 +4,7 @@ import { ELEMENT_KINDS } from './parkmath.js'; import { checkPark } from './parkcheck.js'; import { downloadParkJSON, downloadLevelJS } from './export.js'; import * as MB from './modelbeast.js'; +import { PROP_LABELS } from './props3d.js'; const $ = (sel, root = document) => root.querySelector(sel); const el = (tag, cls, html) => { @@ -19,7 +20,17 @@ const COMMON_XZ = [['x', 'x', -60, 60, 0.1], ['z', 'z', -40, 40, 0.1]]; const PARAMS = { quarter: [['len', 'length', 1, 80, 0.5], ['r0', 'radius a', 0.3, 4, 0.05], ['r1', 'radius b', 0.3, 4, 0.05], ['lump', 'lumpiness', 0, 0.3, 0.01], - ['deck', 'deck', 0, 4, 0.1]], + ['deck', 'deck', 0, 4, 0.1], ['vert', 'vert ext', 0, 2, 0.05]], + corner: [['r', 'radius', 0.4, 4, 0.05], ['sweep', 'sweep°', 0.26, 6.28, 0.02]], + kicker: [['len', 'length', 0.6, 12, 0.2], ['h', 'height', 0.2, 2.4, 0.05], + ['run', 'run', 0.5, 5, 0.1], ['curve', 'concavity', 0, 1, 0.05], + ['deck', 'deck', 0, 2, 0.05]], + roller: [['len', 'length', 0.5, 20, 0.5], ['h', 'height', 0.1, 1.2, 0.05], + ['s', 'spread', 0.4, 4, 0.1]], + wedgepad: [['hw', 'half width', 0.4, 8, 0.1], ['hd', 'half depth', 0.3, 6, 0.1], + ['h0', 'height a', 0, 1.5, 0.05], ['h1', 'height b', 0, 1.5, 0.05]], + volcano: [['r0', 'top radius', 0.2, 5, 0.1], ['run', 'skirt', 0.5, 6, 0.1], + ['h', 'height', 0.2, 2.5, 0.05]], bank: [['len', 'length', 1, 80, 0.5], ['h', 'height', 0.1, 3, 0.05], ['run', 'run', 0.5, 8, 0.1], ['deck', 'deck', 0, 4, 0.1]], funbox: [['hw', 'half width', 0.3, 10, 0.1], ['hd', 'half depth', 0.3, 10, 0.1], @@ -37,10 +48,28 @@ const PARAMS = { ['h', 'height', 0.05, 1, 0.01]], bowl: [['size', 'half size', 1.5, 10, 0.1], ['depth', 'depth', 0.5, 3.4, 0.05]], }; -const PALETTE = [ - ['quarter', 'QUARTER'], ['bank', 'BANK'], ['funbox', 'FUNBOX'], ['pyramid', 'PYRAMID'], - ['ledge', 'LEDGE'], ['stairs', 'STAIRS'], ['spine', 'SPINE'], ['mogul', 'MOGUL'], - ['bowl', 'BOWL'], ['dome', 'ISLAND'], +const PALETTE_GROUPS = [ + ['TRANSITION', [['quarter', 'QUARTER'], ['corner', 'HIP/CORNER'], ['bank', 'BANK'], + ['kicker', 'KICKER'], ['spine', 'SPINE'], ['roller', 'ROLLER'], + ['mogul', 'MOGUL'], ['volcano', 'VOLCANO'], ['bowl', 'BOWL']]], + ['STREET', [['funbox', 'FUNBOX'], ['pyramid', 'PYRAMID'], ['ledge', 'LEDGE'], + ['wedgepad', 'WEDGE PAD'], ['stairs', 'STAIRS'], ['dome', 'ISLAND']]], +]; +// rail presets: pts are [dx, dz, dy-above-ground] from the click point +const RAIL_PRESETS = [ + ['FLATBAR', { name: 'Flatbar', pts: [[0, 0, 0.45], [3, 0, 0.45]] }], + ['SQUARE BAR', { name: 'Square Bar', profile: 'square', kind: 'ledge', + pts: [[0, 0, 0.4], [3, 0, 0.4]] }], + ['DOWN RAIL', { name: 'Down Rail', pts: [[0, 0, 1.0], [3.2, 0, 0.3]] }], + ['KINK RAIL', { name: 'Kink Rail', pts: [[0, 0, 0.95], [2, 0, 0.95], [4.2, 0, 0.3]] }], + ['DBL KINK', { name: 'Double Kink', pts: [[0, 0, 1.1], [1.5, 0, 1.1], [3, 0, 0.7], + [4.5, 0, 0.7], [6, 0, 0.25]] }], + ['RAINBOW', { name: 'Rainbow Rail', + pts: Array.from({ length: 7 }, (_, i) => [i * 0.667, 0, + 0.25 + 1.05 * Math.sin(i / 6 * Math.PI)]) }], + ['A-FRAME', { name: 'A-Frame Rail', pts: [[0, 0, 0.35], [2, 0, 1.0], [4, 0, 0.35]] }], + ['POLE JAM', { name: 'Pole Jam', pts: [[0, 0, 0.06], [1.7, 0, 0.85]] }], + ['DONKEY', { name: 'Donkey Kick', pts: [[0, 0, 0.5], [2.4, 0, 0.5], [3.2, 0, 0.95]] }], ]; const SPOTS = [ ['rail', 'RAIL', { mode: 'rail' }], ['gap', 'GAP', { mode: 'gap' }], @@ -57,13 +86,34 @@ export function initUI(ed) { // ---------------------------------------------------------------- palette function buildPalette() { - palette.innerHTML = '
BUILD
'; - const grid = el('div', 'pgrid'); palette.append(grid); - for (const [kind, label] of PALETTE) { + palette.innerHTML = ''; + for (const [title, kinds] of PALETTE_GROUPS) { + palette.append(el('div', 'ptitle', title)); + const grid = el('div', 'pgrid'); palette.append(grid); + for (const [kind, label] of kinds) { + const b = el('button', 'pbtn', label); + b.dataset.tool = 'place:' + kind; + b.onclick = () => ed.setTool(ed.tool?.kind === kind ? null : { mode: 'place', kind }); + grid.append(b); + } + } + palette.append(el('div', 'ptitle', 'STEEL')); + const railGrid = el('div', 'pgrid'); palette.append(railGrid); + for (const [label, preset] of RAIL_PRESETS) { const b = el('button', 'pbtn', label); - b.dataset.tool = 'place:' + kind; - b.onclick = () => ed.setTool(ed.tool?.kind === kind ? null : { mode: 'place', kind }); - grid.append(b); + b.dataset.tool = 'preset:' + label; + b.onclick = () => ed.setTool(ed.tool?.presetLabel === label ? null : + { mode: 'railPreset', presetLabel: label, ...preset }); + railGrid.append(b); + } + palette.append(el('div', 'ptitle', 'PROPS')); + const propGrid = el('div', 'pgrid'); palette.append(propGrid); + for (const [name, label] of PROP_LABELS) { + const b = el('button', 'pbtn', label); + b.dataset.tool = 'prop:' + name; + b.onclick = () => ed.setTool(ed.tool?.propName === name ? null : + { mode: 'prop', propName: name, src: 'builtin:' + name, scale: 1 }); + propGrid.append(b); } palette.append(el('div', 'ptitle', 'SPOTS')); const grid2 = el('div', 'pgrid'); palette.append(grid2); @@ -112,7 +162,8 @@ export function initUI(ed) { for (const b of palette.querySelectorAll('.pbtn[data-tool]')) { const t = b.dataset.tool; const active = ed.tool && - (t === 'place:' + (ed.tool.kind || '') || t === ed.tool.mode); + (t === 'place:' + (ed.tool.kind || '') || t === ed.tool.mode || + t === 'preset:' + (ed.tool.presetLabel || '') || t === 'prop:' + (ed.tool.propName || '')); b.classList.toggle('active', !!active); } setStatus(ed.tool ? 'click the ground to place — ESC to cancel' @@ -164,6 +215,13 @@ export function initUI(ed) { (v, done) => ed.updateSelected('rot', +(v * DEG).toFixed(4), { done }))); for (const [key, label, min, max, step] of PARAMS[obj.kind] || []) inspector.append(numRow(obj, key, label, min, max, step, upd(key))); + if (obj.kind === 'bowl') { + const shape = el('select'); + shape.innerHTML = ''; + shape.value = String(obj.round ? 1 : 0); + shape.onchange = () => ed.updateSelected('round', +shape.value, { done: true }); + inspector.append(row('shape', shape)); + } } else if (s.type === 'rail') { const name = el('input', 'text'); name.value = obj.name || ''; name.onchange = () => ed.updateSelected('name', name.value, { done: true }); @@ -173,9 +231,30 @@ export function initUI(ed) { kind.value = obj.kind; kind.onchange = () => ed.updateSelected('kind', kind.value, { done: true }); inspector.append(row('type', kind)); - inspector.append(numRow(obj, 'ya', 'height a', 0, 4, 0.01, upd('ya'))); - inspector.append(numRow(obj, 'yb', 'height b', 0, 4, 0.01, upd('yb'))); - inspector.append(el('div', 'ihint', 'drag the yellow end-balls to move the ends')); + const prof = el('select'); + prof.innerHTML = ''; + prof.value = obj.profile || 'round'; + prof.onchange = () => ed.updateSelected('profile', prof.value, { done: true }); + inspector.append(row('profile', prof)); + obj.pts.forEach((pt, i) => { + inspector.append(numRow(pt, 'y', 'pt ' + (i + 1) + ' height', 0, 4.5, 0.01, + (v, done) => { pt.y = v; ed.updateSelected('pts', obj.pts, { done }); })); + }); + const pbtns = el('div', 'ibtns'); + const addPt = el('button', 'pbtn', '+ POINT'); + addPt.onclick = () => { + const a = obj.pts[obj.pts.length - 2], b = obj.pts[obj.pts.length - 1]; + obj.pts.push({ x: +(b.x + (b.x - a.x)).toFixed(2), z: +(b.z + (b.z - a.z)).toFixed(2), y: b.y }); + ed.updateSelected('pts', obj.pts, { done: true }); + }; + const delPt = el('button', 'pbtn', '− POINT'); + delPt.onclick = () => { + if (obj.pts.length <= 2) return; + obj.pts.pop(); + ed.updateSelected('pts', obj.pts, { done: true }); + }; + pbtns.append(addPt, delPt); inspector.append(pbtns); + inspector.append(el('div', 'ihint', 'drag the yellow balls to move points; kinks are just points')); } else if (s.type === 'gap') { const name = el('input', 'text'); name.value = obj.name; name.onchange = () => ed.updateSelected('name', name.value, { done: true });