diff --git a/README.md b/README.md index 7615d66..18d0911 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,12 @@ persistence (`parks/`, `assets/uploads/`). - **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 + bowl (square pool or round), and the **CUSTOM BOWL**: a polygon pool where every rim + vertex is a draggable ball with its own depth slider. Inside corners = drag a vertex + in, hips = out, shallow-to-deep escalator = ramp the depths, waterfall = two close + vertices with different depths — one continuous carve, no seams by construction. + Auto **pool coping** (bullnose tube, exported as grindable ledge segments) and a + **tile band** baked into the ground texture. **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, @@ -47,9 +52,10 @@ persistence (`parks/`, `assets/uploads/`). ## Park format `parks/*.json` — bounds, spawn, `elements` (parametric primitives evaluated by -`js/parkmath.js`), `rails`, `gaps`, `letters`, `zones`, `decals`, `props`, optional -`underlay`. `parks/paddo.json` is Paddington Skatepark ported from bookquoy — load it, -ride it, remix it. +`js/parkmath.js`), `rails` (polylines), `gaps`, `letters`, `zones`, `decals`, `props`, +optional `underlay`. Premade parks: `parks/paddo.json` (Paddington Skatepark ported from +bookquoy) and `parks/pool_demo.json` (THE POOLROOM — custom-bowl showcase, passes PARK +CHECK clean). Load them, ride them, remix them. ## Export → BOOKQUOY diff --git a/js/editor.js b/js/editor.js index 6fcc187..6c37482 100644 --- a/js/editor.js +++ b/js/editor.js @@ -138,7 +138,16 @@ export function initEditor(container) { new THREE.MeshBasicMaterial({ color: 0xffd93b })); m.position.set(x, y, z); m.userData.handle = true; selGrp.add(m); return m; }; - if (sel.type === 'element') loop(elementOutline(obj)); + if (sel.type === 'element') { + loop(elementOutline(obj)); + if (obj.kind === 'bowlpoly') { // rim vertices are draggable + const c = Math.cos(obj.rot || 0), sn = Math.sin(obj.rot || 0); + obj.pts.forEach((p, i) => { + const wx = obj.x + p.u * c - p.v * sn, wz = obj.z + p.u * sn + p.v * c; + handle(wx, 0.18, wz, true).userData.bowlPt = i; + }); + } + } else if (sel.type === 'rail') { 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)); @@ -296,6 +305,8 @@ export function initEditor(container) { const hs = ray.intersectObjects(selGrp.children.filter(o => o.userData.handle)); if (hs.length && hs[0].object.userData.railPt !== undefined) return { type: 'railPt', i: hs[0].object.userData.railPt }; + if (hs.length && hs[0].object.userData.bowlPt !== undefined) + return { type: 'bowlPt', i: hs[0].object.userData.bowlPt }; if (!p) return null; const P = ed.park, x = p.x, z = p.z; // 2. props (real raycast) @@ -412,8 +423,8 @@ 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 === 'railPt') { - drag = { kind: 'railPt', i: hit.i, moved: false }; + if (hit.type === 'railPt' || hit.type === 'bowlPt') { + drag = { kind: hit.type, i: hit.i, moved: false }; controls.enabled = false; snapshot(); return; } ed.select({ type: hit.type, id: hit.id }); @@ -435,6 +446,13 @@ export function initEditor(container) { pt.x = snapV(p.x); pt.z = snapV(p.z); rebuildRails(); rebuildSelection(); return; } + if (drag.kind === 'bowlPt') { // world -> bowl-local vertex + const c = Math.cos(obj.rot || 0), sn = Math.sin(obj.rot || 0); + const du = snapV(p.x) - obj.x, dz = snapV(p.z) - obj.z; + obj.pts[drag.i].u = +(du * c + dz * sn).toFixed(2); + obj.pts[drag.i].v = +(-du * sn + dz * c).toFixed(2); + requestGround(); rebuildRails(); rebuildSelection(); return; + } const nx = snapV(p.x + drag.grabDX), nz = snapV(p.z + drag.grabDZ); if (s.type === 'rail') { const dx = nx - obj.pts[0].x, dz = nz - obj.pts[0].z; @@ -464,7 +482,7 @@ export function initEditor(container) { const obj = getSelected(); if (!obj) return; obj[key] = value; const t = ed.selection.type; - if (t === 'element') requestGround(); + if (t === 'element') { requestGround(); if (obj.kind === 'bowlpoly') rebuildRails(); } if (t === 'zone' || t === 'decal') rebakeTexture(); if (t === 'rail') rebuildRails(); if (t === 'prop') rebuildProps(); diff --git a/js/parkbuild.js b/js/parkbuild.js index 3bb1f9a..ad8cd44 100644 --- a/js/parkbuild.js +++ b/js/parkbuild.js @@ -2,7 +2,7 @@ // 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 { makeHeightAt, normalizeRail, bowlRimWorld } from './parkmath.js'; import { makeBuiltinProp } from './props3d.js'; export const GRID_RES = 0.5, GRID_PAD = 14; @@ -40,6 +40,17 @@ export function bakeGroundTexture(park, x0, x1, z0, z1) { 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(); @@ -140,6 +151,23 @@ export function buildRails(park) { } } } + // 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; } diff --git a/js/parkcheck.js b/js/parkcheck.js index 16f8b7a..0243371 100644 --- a/js/parkcheck.js +++ b/js/parkcheck.js @@ -4,7 +4,7 @@ import { makeHeightAt, elementOutline } from './parkmath.js'; const STREET_KINDS = new Set(['ledge', 'funbox', 'stairs', 'pyramid', 'wedgepad']); -const TRANNY_KINDS = new Set(['quarter', 'spine', 'bowl', 'bank', 'mogul', 'kicker', 'roller', 'corner', 'volcano']); +const TRANNY_KINDS = new Set(['quarter', 'spine', 'bowl', 'bank', 'mogul', 'kicker', 'roller', 'corner', 'volcano', 'bowlpoly']); export function checkPark(park) { const out = []; diff --git a/js/parkmath.js b/js/parkmath.js index af51e2f..7a8f878 100644 --- a/js/parkmath.js +++ b/js/parkmath.js @@ -189,8 +189,62 @@ export const ELEMENT_KINDS = { : Math.abs(u) < e.size && Math.abs(v) < e.size; }, }, + bowlpoly: { // modular pool: polygon rim, per-vertex depths. + // Inside corner = concave vertex, outside = convex, escalator = ramp the depths, + // waterfall = two close vertices with different depths. One continuous carve. + label: 'Custom Bowl', + defaults: { + pts: [{ u: 3.5, v: 0 }, { u: 2.2, v: 2.8 }, { u: -1.5, v: 3.2 }, + { u: -3.8, v: 0.8 }, { u: -3.0, v: -2.4 }, { u: 0.8, v: -3.2 }], + depths: [1.2, 1.2, 1.5, 1.8, 1.8, 1.5], + coping: 1, tile: 1, + }, + carve: true, + inside(e, x, z) { + const [u, v] = local(e, x, z); + return pointInPoly(u, v, e.pts); + }, + height(e, x, z) { + const [u, v] = local(e, x, z); + const dB = distToPoly(u, v, e.pts); + // smooth floor: inverse-distance-weighted vertex depths (shallow end slopes to deep) + let sw = 0, swd = 0; + for (let i = 0; i < e.pts.length; i++) { + const du = u - e.pts[i].u, dv = v - e.pts[i].v; + const w = 1 / (du * du + dv * dv + 0.25); + sw += w; swd += w * (e.depths[i] ?? 1.5); + } + const D = swd / sw; + return -D + tranny(Math.min(dB, D), D); + }, + }, }; +function pointInPoly(u, v, pts) { + let inside = false; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + const a = pts[i], b = pts[j]; + if ((a.v > v) !== (b.v > v) && + u < (b.u - a.u) * (v - a.v) / (b.v - a.v) + a.u) inside = !inside; + } + return inside; +} +function distToPoly(u, v, pts) { + let best = Infinity; + for (let i = 0; i < pts.length; i++) { + const a = pts[i], b = pts[(i + 1) % pts.length]; + const du = b.u - a.u, dv = b.v - a.v; + const t = clamp(((u - a.u) * du + (v - a.v) * dv) / (du * du + dv * dv || 1), 0, 1); + best = Math.min(best, Math.hypot(u - (a.u + t * du), v - (a.v + t * dv))); + } + return best; +} +// world-space rim polygon (editor handles, coping tube, tile bake) +export function bowlRimWorld(e) { + const c = Math.cos(e.rot || 0), s = Math.sin(e.rot || 0); + return e.pts.map(p => [e.x + p.u * c - p.v * s, e.z + p.u * s + p.v * c]); +} + // ---------------------------------------------------------------- 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 @@ -212,6 +266,15 @@ export function flattenRails(park) { out.push({ name: r.name, a: [a.x, a.z], b: [b.x, b.z], ya: a.y, yb: b.y, kind: r.kind }); } } + // pool coping rims are grindable steel: one ledge segment per rim edge + 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 a = rim[i], b = rim[(i + 1) % rim.length]; + out.push({ name: 'Pool Coping', a, b, ya: 0.02, yb: 0.02, kind: 'ledge' }); + } + } return out; } @@ -290,6 +353,8 @@ export function elementHit(e, x, z) { return u * u + v * v < 1; } case 'bowl': { const [u, v] = local(e, x, z); return Math.abs(u) <= e.size + M && Math.abs(v) <= e.size + M; } + case 'bowlpoly': { const [u, v] = local(e, x, z); + return pointInPoly(u, v, e.pts) || distToPoly(u, v, e.pts) <= M; } } return false; } @@ -325,6 +390,7 @@ export function elementOutline(e) { pts.push(W(Math.cos(a) * e.rx, Math.sin(a) * e.rz)); } return pts; } case 'bowl': return rect(-e.size, e.size, -e.size, e.size); + case 'bowlpoly': return bowlRimWorld(e); } return []; } diff --git a/js/ui.js b/js/ui.js index 368262e..575ce08 100644 --- a/js/ui.js +++ b/js/ui.js @@ -51,7 +51,8 @@ const PARAMS = { const PALETTE_GROUPS = [ ['TRANSITION', [['quarter', 'QUARTER'], ['corner', 'HIP/CORNER'], ['bank', 'BANK'], ['kicker', 'KICKER'], ['spine', 'SPINE'], ['roller', 'ROLLER'], - ['mogul', 'MOGUL'], ['volcano', 'VOLCANO'], ['bowl', 'BOWL']]], + ['mogul', 'MOGUL'], ['volcano', 'VOLCANO'], ['bowl', 'BOWL'], + ['bowlpoly', 'CUSTOM BOWL']]], ['STREET', [['funbox', 'FUNBOX'], ['pyramid', 'PYRAMID'], ['ledge', 'LEDGE'], ['wedgepad', 'WEDGE PAD'], ['stairs', 'STAIRS'], ['dome', 'ISLAND']]], ]; @@ -215,6 +216,35 @@ 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 === 'bowlpoly') { + inspector.append(el('div', 'ihint', + 'drag the rim balls to shape it — inside corners, hips, whatever')); + obj.depths.forEach((d, i) => { + inspector.append(numRow({ d }, 'd', 'depth pt ' + (i + 1), 0.4, 3.4, 0.05, + (v, done) => { obj.depths[i] = v; ed.updateSelected('depths', obj.depths, { done }); })); + }); + const vbtns = el('div', 'ibtns'); + const addV = el('button', 'pbtn', '+ VERTEX'); + addV.onclick = () => { + const a = obj.pts[obj.pts.length - 1], b = obj.pts[0]; + obj.pts.push({ u: +((a.u + b.u) / 2).toFixed(2), v: +((a.v + b.v) / 2).toFixed(2) }); + obj.depths.push(obj.depths[obj.depths.length - 1]); + ed.updateSelected('pts', obj.pts, { done: true }); + }; + const delV = el('button', 'pbtn', '− VERTEX'); + delV.onclick = () => { + if (obj.pts.length <= 3) return; + obj.pts.pop(); obj.depths.pop(); + ed.updateSelected('pts', obj.pts, { done: true }); + }; + vbtns.append(addV, delV); inspector.append(vbtns); + for (const [key, label] of [['coping', 'pool coping (grindable)'], ['tile', 'tile band']]) { + const cb = el('label', 'snaprow'); + const inp = el('input'); inp.type = 'checkbox'; inp.checked = !!obj[key]; + inp.onchange = () => ed.updateSelected(key, inp.checked ? 1 : 0, { done: true }); + cb.append(inp, ' ' + label); inspector.append(cb); + } + } if (obj.kind === 'bowl') { const shape = el('select'); shape.innerHTML = ''; diff --git a/parks/pool_demo.json b/parks/pool_demo.json new file mode 100644 index 0000000..aa3a26b --- /dev/null +++ b/parks/pool_demo.json @@ -0,0 +1,215 @@ +{ + "name": "THE POOLROOM", + "bounds": { + "x0": -24, + "x1": 24, + "z0": -16, + "z1": 16 + }, + "slabColor": "#8f958f", + "grassColor": "#4d7c3c", + "spawn": { + "x": -18, + "z": 10, + "heading": 1.1 + }, + "elements": [ + { + "id": "e2_bqff", + "kind": "bowlpoly", + "x": -8, + "z": 0, + "rot": 0, + "pts": [ + { + "u": 5, + "v": 0 + }, + { + "u": 3.4, + "v": 4 + }, + { + "u": -2, + "v": 4.6 + }, + { + "u": -5.4, + "v": 1.2 + }, + { + "u": -4.4, + "v": -3.4 + }, + { + "u": 1.2, + "v": -4.6 + } + ], + "depths": [ + 0.9, + 0.9, + 1.4, + 2.2, + 2.2, + 1.4 + ], + "coping": 1, + "tile": 1 + }, + { + "id": "e3_iu5u", + "kind": "quarter", + "x": 12, + "z": -16, + "rot": 0, + "len": 20, + "r0": 1.8, + "r1": 1.8, + "lump": 0, + "deck": 0.6, + "vert": 0 + }, + { + "id": "e4_fhid", + "kind": "kicker", + "x": 10, + "z": 6, + "rot": 3.141592653589793, + "len": 2.4, + "h": 0.7, + "run": 1.8, + "curve": 0.6, + "deck": 0.25 + }, + { + "id": "e5_g65t", + "kind": "roller", + "x": 16, + "z": 0, + "rot": 1.5708, + "len": 8, + "h": 0.45, + "s": 1.1 + }, + { + "id": "e6_dthi", + "kind": "ledge", + "x": 6, + "z": 12, + "rot": 0, + "hw": 1.5, + "hd": 0.8, + "h": 0.22 + } + ], + "rails": [ + { + "id": "rb", + "name": "Rainbow Rail", + "kind": "rail", + "profile": "round", + "pts": [ + { + "x": 2, + "z": -10, + "y": 0.25 + }, + { + "x": 2.667, + "z": -10, + "y": 0.7749999999999999 + }, + { + "x": 3.334, + "z": -10, + "y": 1.1593266739736605 + }, + { + "x": 4.001, + "z": -10, + "y": 1.3 + }, + { + "x": 4.668, + "z": -10, + "y": 1.1593266739736605 + }, + { + "x": 5.335, + "z": -10, + "y": 0.7749999999999999 + }, + { + "x": 6.002000000000001, + "z": -10, + "y": 0.2500000000000001 + } + ] + }, + { + "id": "sq", + "name": "Square Bar", + "kind": "ledge", + "profile": "square", + "pts": [ + { + "x": -16, + "z": -6, + "y": 0.4 + }, + { + "x": -13, + "z": -6, + "y": 0.4 + } + ] + } + ], + "gaps": [ + { + "id": "g1", + "name": "OVER THE DEEP END", + "x": -11, + "z": 0, + "r": 2.4 + } + ], + "letters": [], + "zones": [], + "decals": [], + "props": [ + { + "id": "w1", + "src": "builtin:picnic", + "x": -18, + "z": -12, + "rot": 0.4, + "scale": 1 + }, + { + "id": "w2", + "src": "builtin:floodlight", + "x": 20, + "z": 12, + "rot": 0, + "scale": 1 + }, + { + "id": "w3", + "src": "builtin:fence", + "x": 0, + "z": -15.5, + "rot": 0, + "scale": 1 + }, + { + "id": "w4", + "src": "builtin:bleachers", + "x": -20, + "z": 4, + "rot": 1.5708, + "scale": 1 + } + ] +} \ No newline at end of file