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 = '