From f62d83f48839c42e06b7f7d0ec328b3730407507 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 3 Aug 2026 21:40:57 +1000 Subject: [PATCH] kit props go physical: colliders, rideable tops, grinds, gaps, bonks level.js adopts skatemakerpro's COLLIDERS export shape (box|circle) so editor parks drop in: box tops join heightAt (land on the picnic table), circles push out (bins, trunks, hydrants). player.js: hard-edge step block in roll + bail, blocker resolve in fall, bonk -> stumble + flash ('STRAIGHT INTO THE BIN'). Kit grinds append to RAILS (bench boardslides, trolley 50-50s); props declare gap triggers vs terrain height (y0). New Paddo street set: two trolleys, slappy kerbs, planters, parking block, jersey barrier. Verified: bin stop at contact, planter stand y=0.50, bench + trolley grinds, 'Tre Flip + TROLLEY GAP + 50-50 (trolley) x3 (2109)'. Co-Authored-By: Claude Opus 5 --- js/level.js | 61 +++++++++++++++++++++++++++++++++++++++++++++++++--- js/main.js | 43 ++++++++++++++++++++++++++++++++++-- js/player.js | 19 ++++++++++++++-- 3 files changed, 116 insertions(+), 7 deletions(-) diff --git a/js/level.js b/js/level.js index df17a65..e7add58 100644 --- a/js/level.js +++ b/js/level.js @@ -47,7 +47,52 @@ function spine(x, z, cx0, cx1, cz, r) { // back-to-back quarters a return d >= r ? 0 : tranny(d, r); } +// ------------------------------------------------------------ prop physics +// COLLIDERS uses skatemakerpro's export shape verbatim ({type:'box'|'circle', +// x, z, rot, hw/hd/r, h}) so editor-exported parks drop in unchanged. Boxes are +// RIDEABLE: their tops join heightAt (land on the picnic table); circles are +// blockers only (bins, trunks, hydrants — resolveBlocker pushes you out). +// main.js fills this from placed kit props; exported levels ship it pre-filled. +export const COLLIDERS = []; + +export function registerColliders(list) { + for (const c of list) { + c.top = terrainAt(c.x, c.z) + c.h; // world top, precomputed once + if (c.type === 'box') { c.c = Math.cos(c.rot || 0); c.s = Math.sin(c.rot || 0); } + COLLIDERS.push(c); + } +} + +function boxTop(c, x, z) { + const dx = x - c.x, dz = z - c.z; + const lx = dx * c.c - dz * c.s, lz = dx * c.s + dz * c.c; + return (Math.abs(lx) <= c.hw && Math.abs(lz) <= c.hd) ? c.top : -Infinity; +} + +// circle-vs-player push-out. Mutates pos; returns the collider hit (for bonks). +export function resolveBlocker(pos, pr = 0.3) { + for (const c of COLLIDERS) { + if (c.type !== 'circle' || pos.y > c.top) continue; + const dx = pos.x - c.x, dz = pos.z - c.z; + const d = Math.hypot(dx, dz), min = c.r + pr; + if (d < min) { + const f = d > 1e-4 ? min / d : 1; + pos.x = c.x + dx * (d > 1e-4 ? f : min); + pos.z = c.z + (d > 1e-4 ? dz * f : 0); + return c; + } + } + return null; +} + export function heightAt(x, z) { + let h = terrainAt(x, z); + for (const c of COLLIDERS) + if (c.type === 'box' && c.top > h) h = Math.max(h, boxTop(c, x, z)); + return h; +} + +function terrainAt(x, z) { // outside the slab: grass, rising gently away from the park const inPark = IN(x, BOUNDS.x0, BOUNDS.x1) && IN(z, BOUNDS.z0, BOUNDS.z1); if (!inPark) { @@ -179,17 +224,27 @@ export const PROPS = [ { id: 'tree_fig', x: -14, z: -30, s: 1.05, rot: 4.0 }, { id: 'tree_gum', x: -40, z: 24, rot: 1.2 }, { id: 'tree_gum', x: 40, z: 10, rot: 3.4 }, // furniture along the south parkland + plaza edge - { id: 'bench_park', x: 24, z: 15, rot: Math.PI }, { id: 'bin_council', x: 26.2, z: 15 }, - { id: 'bench_park', x: -8, z: 17, rot: Math.PI }, { id: 'picnic_table', x: -16, z: 20, rot: 0.4 }, + { id: 'bench_park', x: 24, z: 15, rot: Math.PI, gap: 'BENCH HOP' }, + { id: 'bin_council', x: 26.2, z: 15 }, + { id: 'bench_park', x: -8, z: 17, rot: Math.PI }, + { id: 'picnic_table', x: -16, z: 20, rot: 0.4, gap: 'TABLE TOPPER' }, { id: 'bleachers_3', x: -24, z: 22, rot: Math.PI }, // watching the bowl line { id: 'shade_sail', x: 18, z: 23 }, - // Caxton St side: fence sections + lights + a hydrant on the footpath + // Caxton St side: fence sections + lights + footpath street furniture { id: 'fence_chain_3m', x: -16, z: -38.5 }, { id: 'fence_chain_3m', x: -13, z: -38.5 }, { id: 'fence_chain_3m', x: 19, z: -38.5 }, { id: 'fence_chain_3m', x: 22, z: -38.5 }, { id: 'floodlight', x: -38, z: -20 }, { id: 'floodlight', x: 38, z: 16 }, { id: 'hydrant', x: 14, z: -37 }, + { id: 'kerb_3m', x: 8, z: -37.2 }, { id: 'kerb_3m', x: 5, z: -37.2 }, // slappy kerbs // the wallride wall in the west parkland { id: 'wall_graffiti', x: -45, z: 4, rot: Math.PI / 2 }, + // the physical street set: bonk it, grind it, or gap it + { id: 'trolley', x: -6, z: -8, rot: 0.7, gap: 'TROLLEY GAP' }, + { id: 'trolley', x: 2, z: 4.5, rot: 3.6, gap: 'BOOKQUOY TROLLEY' }, // on the island, obviously + { id: 'planter_box', x: 20, z: -8, gap: 'PLANTER HOP' }, + { id: 'planter_box', x: 20, z: -12 }, + { id: 'parking_block', x: 30, z: -18, rot: 0.2 }, + { id: 'jersey_barrier', x: -20, z: 14, rot: 1.1, gap: 'K-RAIL CLEAR' }, ]; // ---------------------------------------------------------------- build visuals diff --git a/js/main.js b/js/main.js index 6970797..40886bd 100644 --- a/js/main.js +++ b/js/main.js @@ -2,7 +2,8 @@ // Three disciplines: SKATE, BMX, and BLADES (locals throw rubbish at bladers. tradition.) import * as THREE from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; -import { buildLevel, GAPS, SPAWN, heightAt, PROPS } from './level.js'; +import { buildLevel, GAPS, SPAWN, heightAt, PROPS, RAILS, + registerColliders } from './level.js'; import { KITS, RUBBISH_INSULTS, GAP_SCORE, LETTER_SCORE, ALL_LETTERS_SCORE } from './tricks.js'; import { Player } from './player.js'; import { Hud } from './hud.js'; @@ -60,6 +61,39 @@ async function boot() { }, undefined, () => console.warn('kit prop missing (is ../park_kit checked out?)', p.id)); } + // Physics from the kit manifest: colliders (skatemakerpro's COLLIDERS shape, + // cylinder->circle), grind edges appended to RAILS, per-prop gap triggers. + // Registered AFTER buildLevel so the terrain mesh stays pure terrain. + try { + const man = await (await fetch(KIT_BASE + 'manifest.json')).json(); + const byId = Object.fromEntries(man.props.map(d => [d.id, d])); + const solids = []; + for (const p of PROPS) { + const def = byId[p.id]; if (!def) continue; + const s = p.s || 1, rot = p.rot || 0; + const c = Math.cos(rot), sn = Math.sin(rot); + const W = (lx, lz) => [p.x + (lx * c + lz * sn) * s, p.z + (-lx * sn + lz * c) * s]; + const col = def.collider; + if (col?.type === 'box') + solids.push({ type: 'box', name: p.id, x: p.x, z: p.z, rot, + hw: col.hw * s, hd: col.hd * s, h: col.h * s }); + else if (col?.type === 'cylinder') + solids.push({ type: 'circle', name: p.id, x: p.x, z: p.z, + r: col.r * s, h: col.h * s }); + const base = heightAt(p.x, p.z); + for (const g of def.grinds || []) { + const [ax, az] = W(g.a[0], g.a[1]), [bx, bz] = W(g.b[0], g.b[1]); + RAILS.push({ name: p.id.replace(/_/g, ' '), a: [ax, az], b: [bx, bz], + ya: base + g.ya * s, yb: base + g.yb * s, + kind: g.kind === 'ledge' ? 'ledge' : 'rail' }); + } + // y0 = TERRAIN height (colliders not yet registered) — the gap check + // must not measure against the prop's own box top + if (p.gap) GAPS.push({ name: p.gap, x: p.x, z: p.z, r: 2.3, y0: base }); + } + registerColliders(solids); + } catch (e) { console.warn('kit manifest unavailable — props stay ghosts', e); } + const [rigG, deck, deckBank, skateRider, bike, bikeBank, bmxRider, pairsSkate, pairsBmx] = await Promise.all([ load('assets/woman_raver_01.glb'), load('assets/deck.glb'), @@ -130,6 +164,11 @@ function setDiscipline(name) { onGrindTick: pts => { if (mode === 'run') hud.addGrindPoints(pts); }, onLand: () => { airGapsHit.clear(); landTimer = 0.9; }, onBail: () => { airGapsHit.clear(); hud.dropCombo(); }, + onBonk: c => { + const what = (c.name || 'that').split('_')[0].toUpperCase(); + const lines = ['BONK!!', `STRAIGHT INTO THE ${what}`, `THE ${what} WON`, 'EYES UP!!']; + hud.flash(lines[(Math.random() * lines.length) | 0], '#ffb02e'); + }, }); player.reset(SPAWN); for (const id of ['skate', 'bmx', 'blades']) @@ -242,7 +281,7 @@ function seshUpdate(dt) { for (const g of GAPS) { if (airGapsHit.has(g.name)) continue; const d = Math.hypot(player.pos.x - g.x, player.pos.z - g.z); - if (d < g.r && player.pos.y > heightAt(g.x, g.z) + 0.25) { + if (d < g.r && player.pos.y > (g.y0 !== undefined ? g.y0 : heightAt(g.x, g.z)) + 0.25) { airGapsHit.add(g.name); hud.addTrick(g.name, GAP_SCORE); hud.flash(g.name); diff --git a/js/player.js b/js/player.js index 8b7cd86..adebe58 100644 --- a/js/player.js +++ b/js/player.js @@ -4,7 +4,7 @@ // Discipline behaviour comes entirely from the KIT (see tricks.js). import * as THREE from 'three'; -import { heightAt, normalAt, RAILS } from './level.js'; +import { heightAt, normalAt, RAILS, resolveBlocker } from './level.js'; const GRAV = 18; const MAX_SPEED = 10.5, PUSH_ACC = 6.5, FRICTION = 0.28, OFFROAD = 2.2; @@ -128,8 +128,13 @@ export class Player { case 'bail': { this.bailT -= dt; this.speed = Math.max(0, this.speed - 6 * dt); + const bx0 = p.x, bz0 = p.z; p.x += Math.sin(this.heading) * this.speed * dt; p.z += Math.cos(this.heading) * this.speed * dt; + if (heightAt(p.x, p.z) - p.y > 0.32) { // sliding into a prop: stop there + p.x = bx0; p.z = bz0; this.speed = 0; + } + resolveBlocker(p, 0.3); p.y = heightAt(p.x, p.z); if (this.bailT <= 0) { this.state = 'roll'; this.play(this.kit.idle); } break; @@ -180,6 +185,7 @@ export class Player { p.x += Math.sin(this.heading) * this.speed * dt; p.z += Math.cos(this.heading) * this.speed * dt; p.y += f.vy * dt; + resolveBlocker(p, 0.3); const h = heightAt(p.x, p.z); if (this.tryAttachGrind()) break; if (p.y <= h) { @@ -217,10 +223,14 @@ export class Player { const off = p.x < -40 || p.x > 40 || p.z < -26 || p.z > 26; this.speed = Math.max(0, this.speed - (off ? OFFROAD : FRICTION) * dt * (0.4 + this.speed * 0.08)); const oldH = p.y; + const px0 = p.x, pz0 = p.z; p.x += Math.sin(this.heading) * this.speed * dt; p.z += Math.cos(this.heading) * this.speed * dt; const h = heightAt(p.x, p.z); - if (h < oldH - 0.55) { + if (h - oldH > 0.32) { + // prop wall / hard edge (bench side, fence, trolley): you stop, it doesn't + p.x = px0; p.z = pz0; this.speed *= 0.7; + } else if (h < oldH - 0.55) { this.state = 'fall'; this.fall = { vy: 0, from: oldH }; this.play(this.kit.airFall, 0.08); } else { @@ -228,6 +238,11 @@ export class Player { if (this.cur !== this.kit.push + '|' || this.pushT <= 0.05) this.play(this.speed > 0.4 ? this.kit.roll : this.kit.idle, 0.15); } + const bonk = resolveBlocker(p, 0.3); + if (bonk) { + if (this.speed > 5.5) { this.ev.onBonk && this.ev.onBonk(bonk); this.stumble(); } + else this.speed *= 0.8; + } break; } }