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:
parent
7afae85599
commit
f62d83f488
61
js/level.js
61
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);
|
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) {
|
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
|
// outside the slab: grass, rising gently away from the park
|
||||||
const inPark = IN(x, BOUNDS.x0, BOUNDS.x1) && IN(z, BOUNDS.z0, BOUNDS.z1);
|
const inPark = IN(x, BOUNDS.x0, BOUNDS.x1) && IN(z, BOUNDS.z0, BOUNDS.z1);
|
||||||
if (!inPark) {
|
if (!inPark) {
|
||||||
@ -179,17 +224,27 @@ export const PROPS = [
|
|||||||
{ id: 'tree_fig', x: -14, z: -30, s: 1.05, rot: 4.0 },
|
{ 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 },
|
{ 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
|
// 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: 24, z: 15, rot: Math.PI, gap: 'BENCH HOP' },
|
||||||
{ id: 'bench_park', x: -8, z: 17, rot: Math.PI }, { id: 'picnic_table', x: -16, z: 20, rot: 0.4 },
|
{ 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: 'bleachers_3', x: -24, z: 22, rot: Math.PI }, // watching the bowl line
|
||||||
{ id: 'shade_sail', x: 18, z: 23 },
|
{ 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: -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: '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: 'floodlight', x: -38, z: -20 }, { id: 'floodlight', x: 38, z: 16 },
|
||||||
{ id: 'hydrant', x: 14, z: -37 },
|
{ 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
|
// the wallride wall in the west parkland
|
||||||
{ id: 'wall_graffiti', x: -45, z: 4, rot: Math.PI / 2 },
|
{ 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
|
// ---------------------------------------------------------------- build visuals
|
||||||
|
|||||||
43
js/main.js
43
js/main.js
@ -2,7 +2,8 @@
|
|||||||
// Three disciplines: SKATE, BMX, and BLADES (locals throw rubbish at bladers. tradition.)
|
// Three disciplines: SKATE, BMX, and BLADES (locals throw rubbish at bladers. tradition.)
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
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 { KITS, RUBBISH_INSULTS, GAP_SCORE, LETTER_SCORE, ALL_LETTERS_SCORE } from './tricks.js';
|
||||||
import { Player } from './player.js';
|
import { Player } from './player.js';
|
||||||
import { Hud } from './hud.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));
|
}, 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] =
|
const [rigG, deck, deckBank, skateRider, bike, bikeBank, bmxRider, pairsSkate, pairsBmx] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
load('assets/woman_raver_01.glb'), load('assets/deck.glb'),
|
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); },
|
onGrindTick: pts => { if (mode === 'run') hud.addGrindPoints(pts); },
|
||||||
onLand: () => { airGapsHit.clear(); landTimer = 0.9; },
|
onLand: () => { airGapsHit.clear(); landTimer = 0.9; },
|
||||||
onBail: () => { airGapsHit.clear(); hud.dropCombo(); },
|
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);
|
player.reset(SPAWN);
|
||||||
for (const id of ['skate', 'bmx', 'blades'])
|
for (const id of ['skate', 'bmx', 'blades'])
|
||||||
@ -242,7 +281,7 @@ function seshUpdate(dt) {
|
|||||||
for (const g of GAPS) {
|
for (const g of GAPS) {
|
||||||
if (airGapsHit.has(g.name)) continue;
|
if (airGapsHit.has(g.name)) continue;
|
||||||
const d = Math.hypot(player.pos.x - g.x, player.pos.z - g.z);
|
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);
|
airGapsHit.add(g.name);
|
||||||
hud.addTrick(g.name, GAP_SCORE);
|
hud.addTrick(g.name, GAP_SCORE);
|
||||||
hud.flash(g.name);
|
hud.flash(g.name);
|
||||||
|
|||||||
19
js/player.js
19
js/player.js
@ -4,7 +4,7 @@
|
|||||||
// Discipline behaviour comes entirely from the KIT (see tricks.js).
|
// Discipline behaviour comes entirely from the KIT (see tricks.js).
|
||||||
|
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { heightAt, normalAt, RAILS } from './level.js';
|
import { heightAt, normalAt, RAILS, resolveBlocker } from './level.js';
|
||||||
|
|
||||||
const GRAV = 18;
|
const GRAV = 18;
|
||||||
const MAX_SPEED = 10.5, PUSH_ACC = 6.5, FRICTION = 0.28, OFFROAD = 2.2;
|
const MAX_SPEED = 10.5, PUSH_ACC = 6.5, FRICTION = 0.28, OFFROAD = 2.2;
|
||||||
@ -128,8 +128,13 @@ export class Player {
|
|||||||
case 'bail': {
|
case 'bail': {
|
||||||
this.bailT -= dt;
|
this.bailT -= dt;
|
||||||
this.speed = Math.max(0, this.speed - 6 * 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.x += Math.sin(this.heading) * this.speed * dt;
|
||||||
p.z += Math.cos(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);
|
p.y = heightAt(p.x, p.z);
|
||||||
if (this.bailT <= 0) { this.state = 'roll'; this.play(this.kit.idle); }
|
if (this.bailT <= 0) { this.state = 'roll'; this.play(this.kit.idle); }
|
||||||
break;
|
break;
|
||||||
@ -180,6 +185,7 @@ export class Player {
|
|||||||
p.x += Math.sin(this.heading) * this.speed * dt;
|
p.x += Math.sin(this.heading) * this.speed * dt;
|
||||||
p.z += Math.cos(this.heading) * this.speed * dt;
|
p.z += Math.cos(this.heading) * this.speed * dt;
|
||||||
p.y += f.vy * dt;
|
p.y += f.vy * dt;
|
||||||
|
resolveBlocker(p, 0.3);
|
||||||
const h = heightAt(p.x, p.z);
|
const h = heightAt(p.x, p.z);
|
||||||
if (this.tryAttachGrind()) break;
|
if (this.tryAttachGrind()) break;
|
||||||
if (p.y <= h) {
|
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;
|
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));
|
this.speed = Math.max(0, this.speed - (off ? OFFROAD : FRICTION) * dt * (0.4 + this.speed * 0.08));
|
||||||
const oldH = p.y;
|
const oldH = p.y;
|
||||||
|
const px0 = p.x, pz0 = p.z;
|
||||||
p.x += Math.sin(this.heading) * this.speed * dt;
|
p.x += Math.sin(this.heading) * this.speed * dt;
|
||||||
p.z += Math.cos(this.heading) * this.speed * dt;
|
p.z += Math.cos(this.heading) * this.speed * dt;
|
||||||
const h = heightAt(p.x, p.z);
|
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.state = 'fall'; this.fall = { vy: 0, from: oldH };
|
||||||
this.play(this.kit.airFall, 0.08);
|
this.play(this.kit.airFall, 0.08);
|
||||||
} else {
|
} else {
|
||||||
@ -228,6 +238,11 @@ export class Player {
|
|||||||
if (this.cur !== this.kit.push + '|' || this.pushT <= 0.05)
|
if (this.cur !== this.kit.push + '|' || this.pushT <= 0.05)
|
||||||
this.play(this.speed > 0.4 ? this.kit.roll : this.kit.idle, 0.15);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user