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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-03 21:40:57 +10:00
parent 7afae85599
commit f62d83f488
3 changed files with 116 additions and 7 deletions

View File

@ -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

View File

@ -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);

View File

@ -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;
}
}