js/pad.js synthesizes the same KeyboardEvents the keyboard produces — one input path, zero new handler code. Stick/dpad steer (+variants), R2 push, CROSS ollie, SQUARE/CIRCLE/TRIANGLE flip/heel/shove, R1 tre, L1 fs shove, L2 hardflip, OPTIONS start, CREATE reset. Dual-rumble on bails and bonks. Title screen: dpad cycles discipline, CROSS drops in (Space now works there for keyboards too). Verified with a stubbed DualSense through __step: push 7.6m/s, steer, ollie->kickflip x2 (220). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
373 lines
16 KiB
JavaScript
373 lines
16 KiB
JavaScript
// BOOKQUOY — a Brisbane sesh. Level 01: PADDO.
|
|
// 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, 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';
|
|
import { Pad } from './pad.js';
|
|
|
|
const SESH_SECONDS = 120;
|
|
const GRAV = 18;
|
|
|
|
// ---------------------------------------------------------------- three setup
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0xbfd9ea);
|
|
scene.fog = new THREE.Fog(0xbfd9ea, 60, 160);
|
|
const cam = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.1, 300);
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
renderer.setSize(innerWidth, innerHeight);
|
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
document.body.appendChild(renderer.domElement);
|
|
addEventListener('resize', () => {
|
|
renderer.setSize(innerWidth, innerHeight);
|
|
cam.aspect = innerWidth / innerHeight; cam.updateProjectionMatrix();
|
|
});
|
|
scene.add(new THREE.HemisphereLight(0xffffff, 0x55663f, 1.1));
|
|
const sun = new THREE.DirectionalLight(0xfff3d9, 1.6);
|
|
sun.position.set(30, 46, 18); scene.add(sun);
|
|
|
|
// ---------------------------------------------------------------- state
|
|
const loader = new GLTFLoader();
|
|
const load = u => new Promise((res, rej) => loader.load(u, res, undefined, rej));
|
|
const hud = new Hud();
|
|
let player = null, letters = [];
|
|
let mode = 'title'; // title | run | over
|
|
let discipline = 'skate';
|
|
let timeLeft = SESH_SECONDS;
|
|
let airGapsHit = new Set();
|
|
let landTimer = 0;
|
|
let rig = null, mixer = null;
|
|
let boardAnchor = null, bikeAnchor = null, bladeWheels = [];
|
|
let bankSets = null; // per-discipline {primary, mates, mateMap}
|
|
|
|
// ---------------------------------------------------------------- boot
|
|
async function boot() {
|
|
const { letterMeshes } = buildLevel(scene);
|
|
letters = letterMeshes;
|
|
|
|
// park_kit dressing (textured trees + street furniture). Served at /kit/ from
|
|
// the sibling park_kit repo; if the mount is missing the park just goes bare.
|
|
const KIT_BASE = '/kit/';
|
|
for (const p of PROPS) {
|
|
loader.load(KIT_BASE + 'props/' + p.id + '.glb', g => {
|
|
const o = g.scene;
|
|
o.traverse(m => { if (m.isMesh) m.castShadow = m.receiveShadow = true; });
|
|
o.position.set(p.x, heightAt(p.x, p.z) + (p.y || 0), p.z);
|
|
o.rotation.y = p.rot || 0;
|
|
o.scale.setScalar(p.s || 1);
|
|
scene.add(o);
|
|
}, 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'),
|
|
load('assets/deck_bank.glb'), load('assets/board_raver.glb'),
|
|
load('assets/bike.glb'), load('assets/bike_bank.glb'), load('assets/bike_raver.glb'),
|
|
fetch('assets/pairs.json').then(r => r.json()),
|
|
fetch('assets/bike_pairs.json').then(r => r.json())]);
|
|
|
|
rig = rigG.scene;
|
|
rig.traverse(o => { if (o.isMesh) o.frustumCulled = false; });
|
|
scene.add(rig);
|
|
|
|
// two-rig sockets: both anchors live on the rig; the rider banks drive them by name
|
|
boardAnchor = new THREE.Object3D(); boardAnchor.name = 'Board';
|
|
rig.add(boardAnchor); boardAnchor.add(deck.scene);
|
|
bikeAnchor = new THREE.Object3D(); bikeAnchor.name = 'Bike';
|
|
rig.add(bikeAnchor); bikeAnchor.add(bike.scene);
|
|
|
|
// shoes (+ hidden inline wheels that only show on blades)
|
|
try {
|
|
const sh = await load('assets/shoes/shoe_vulc_low.glb');
|
|
const wheelM = new THREE.MeshStandardMaterial({ color: 0x2c2c30, roughness: 0.55 });
|
|
const upL = new THREE.Vector3(0.03, -0.53, 0.85).normalize(); // foot-local up (measured)
|
|
for (const side of ['Left', 'Right']) {
|
|
const src = sh.scene.getObjectByName('Shoe_' + side);
|
|
const bone = rig.getObjectByName('mixamorig' + side + 'Foot');
|
|
if (src && bone) {
|
|
const c = src.clone(true);
|
|
const ws = bone.getWorldScale(new THREE.Vector3());
|
|
c.scale.set(1 / ws.x, 1 / ws.y, 1 / ws.z);
|
|
bone.add(c);
|
|
const wheels = new THREE.Group();
|
|
for (const t of [-0.05, 0.04, 0.13]) { // fwd = +Y in foot space
|
|
const w = new THREE.Mesh(new THREE.SphereGeometry(0.033, 8, 6), wheelM);
|
|
w.position.set(0, t, 0).addScaledVector(upL, -0.1);
|
|
wheels.add(w);
|
|
}
|
|
wheels.visible = false;
|
|
c.add(wheels); bladeWheels.push(wheels);
|
|
}
|
|
}
|
|
} catch (e) { console.warn('no shoes', e); }
|
|
|
|
mixer = new THREE.AnimationMixer(rig);
|
|
const mk = anims => { const o = {}; for (const c of anims) o[c.name] = mixer.clipAction(c); return o; };
|
|
const boardActs = mk(deckBank.animations);
|
|
const skateRiderActs = mk(skateRider.animations);
|
|
const bikeActs = mk(bikeBank.animations);
|
|
const bmxRiderActs = mk(bmxRider.animations);
|
|
|
|
bankSets = {
|
|
skate: { primary: boardActs, mates: skateRiderActs, mateMap: pairsSkate },
|
|
bmx: { primary: bmxRiderActs, mates: bikeActs, mateMap: pairsBmx },
|
|
blades: { primary: skateRiderActs, mates: {}, mateMap: {} },
|
|
};
|
|
setDiscipline('skate');
|
|
hud.title(true);
|
|
}
|
|
|
|
function setDiscipline(name) {
|
|
discipline = name;
|
|
const kit = KITS[name], b = bankSets[name];
|
|
boardAnchor.visible = name === 'skate';
|
|
bikeAnchor.visible = name === 'bmx';
|
|
for (const w of bladeWheels) w.visible = name === 'blades';
|
|
player = new Player(kit, rig, mixer, b.primary, b.mates, b.mateMap, {
|
|
onTrick: t => { if (mode === 'run') hud.addTrick(t.name, t.score); },
|
|
onGrindTick: pts => { if (mode === 'run') hud.addGrindPoints(pts); },
|
|
onLand: () => { airGapsHit.clear(); landTimer = 0.9; },
|
|
onBail: () => { airGapsHit.clear(); hud.dropCombo(); pad.rumble(0.9, 0.5, 220); },
|
|
onBonk: c => {
|
|
pad.rumble(1.0, 0.6, 260);
|
|
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'])
|
|
document.getElementById('mode-' + id).className = id === name ? 'lit' : '';
|
|
document.getElementById('modelabel').textContent = kit.label;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- input
|
|
const keys = new Set();
|
|
const pad = new Pad(id =>
|
|
hud.flash('CONTROLLER CONNECTED', '#7ee38a'));
|
|
|
|
addEventListener('keydown', e => {
|
|
if (mode !== 'run') {
|
|
if (e.code === 'Digit1') return setDiscipline('skate');
|
|
if (e.code === 'Digit2') return setDiscipline('bmx');
|
|
if (e.code === 'Digit3') return setDiscipline('blades');
|
|
if (e.code === 'ArrowLeft' || e.code === 'ArrowRight') { // pad dpad / stick
|
|
const order = ['skate', 'bmx', 'blades'];
|
|
const i = order.indexOf(discipline);
|
|
return setDiscipline(order[(i + (e.code === 'ArrowRight' ? 1 : 2)) % 3]);
|
|
}
|
|
if (e.code === 'Enter' || e.code === 'Space') startSesh(); // CROSS drops in too
|
|
return;
|
|
}
|
|
if (e.code === 'KeyR') { player.reset(SPAWN); return; }
|
|
if (keys.has(e.code)) return;
|
|
keys.add(e.code);
|
|
if (e.code === 'Space' || /^Key[JKLTYHNF]$/.test(e.code)) {
|
|
e.preventDefault();
|
|
player.tryTrick(e.code, keys);
|
|
}
|
|
});
|
|
addEventListener('keyup', e => keys.delete(e.code));
|
|
|
|
function startSesh() {
|
|
hud.reset(); hud.title(false);
|
|
for (const L of letters) { L.userData.taken = false; L.visible = true; }
|
|
timeLeft = SESH_SECONDS; mode = 'run';
|
|
clearRubbish();
|
|
player.reset(SPAWN);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- rubbish (blades tax)
|
|
const rubbish = [];
|
|
let nextRubbish = 0;
|
|
const canGeo = new THREE.CylinderGeometry(0.05, 0.05, 0.14, 8);
|
|
const packGeo = new THREE.BoxGeometry(0.16, 0.04, 0.12);
|
|
const rubbishMats = [0xd0342c, 0x2c8c46, 0xe9b430, 0x3467d0]
|
|
.map(c => new THREE.MeshLambertMaterial({ color: c }));
|
|
|
|
function spawnRubbish() {
|
|
const a = Math.random() * Math.PI * 2, R = 12 + Math.random() * 4;
|
|
const origin = new THREE.Vector3(player.pos.x + Math.sin(a) * R, 2.0,
|
|
player.pos.z + Math.cos(a) * R);
|
|
const T = 0.85;
|
|
// full predictive lead with a little wobble: straight-liners get pegged, dodging works
|
|
const lead = T * player.speed * (0.85 + Math.random() * 0.3);
|
|
const target = new THREE.Vector3(
|
|
player.pos.x + Math.sin(player.heading) * lead, player.pos.y + 0.9,
|
|
player.pos.z + Math.cos(player.heading) * lead);
|
|
const vel = target.sub(origin).multiplyScalar(1 / T);
|
|
vel.y += 0.5 * GRAV * T;
|
|
const mesh = new THREE.Mesh(Math.random() < 0.6 ? canGeo : packGeo,
|
|
rubbishMats[(Math.random() * rubbishMats.length) | 0]);
|
|
mesh.position.copy(origin);
|
|
scene.add(mesh);
|
|
rubbish.push({ mesh, vel, ttl: 4, rest: 0,
|
|
spin: new THREE.Vector3(Math.random() * 9, Math.random() * 9, Math.random() * 9) });
|
|
}
|
|
function clearRubbish() { for (const r of rubbish) scene.remove(r.mesh); rubbish.length = 0; }
|
|
|
|
function updateRubbish(dt, now) {
|
|
if (KITS[discipline].rubbish && mode === 'run') {
|
|
if (now > nextRubbish) {
|
|
if (rubbish.length < 5) spawnRubbish();
|
|
nextRubbish = now + 2.2 + Math.random() * 2.6;
|
|
}
|
|
}
|
|
for (let i = rubbish.length - 1; i >= 0; i--) {
|
|
const r = rubbish[i], m = r.mesh;
|
|
if (r.rest > 0) {
|
|
r.rest -= dt;
|
|
if (r.rest <= 0) { scene.remove(m); rubbish.splice(i, 1); }
|
|
continue;
|
|
}
|
|
r.vel.y -= GRAV * dt;
|
|
m.position.addScaledVector(r.vel, dt);
|
|
m.rotation.x += r.spin.x * dt; m.rotation.y += r.spin.y * dt; m.rotation.z += r.spin.z * dt;
|
|
const hitR = m.position.distanceTo(player.pos.clone().setY(player.pos.y + 0.9));
|
|
if (hitR < 0.7 && mode === 'run') {
|
|
player.stumble();
|
|
hud.flash(RUBBISH_INSULTS[(Math.random() * RUBBISH_INSULTS.length) | 0], '#ff6b6b');
|
|
scene.remove(m); rubbish.splice(i, 1);
|
|
continue;
|
|
}
|
|
const h = heightAt(m.position.x, m.position.z);
|
|
if (m.position.y < h + 0.03) { m.position.y = h + 0.03; r.rest = 1.4; }
|
|
r.ttl -= dt;
|
|
if (r.ttl <= 0) { scene.remove(m); rubbish.splice(i, 1); }
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- sesh logic
|
|
function seshUpdate(dt) {
|
|
timeLeft -= dt;
|
|
hud.timer(timeLeft);
|
|
if (timeLeft <= 0) {
|
|
hud.bank(); mode = 'over';
|
|
hud.sessionOver(hud.score);
|
|
return;
|
|
}
|
|
if (player.state === 'roll' && hud.combo.length) {
|
|
landTimer -= dt;
|
|
if (landTimer <= 0) hud.bank();
|
|
}
|
|
if (player.state === 'air' || player.state === 'fall') {
|
|
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 > (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);
|
|
}
|
|
}
|
|
}
|
|
for (const L of letters) {
|
|
if (L.userData.taken) continue;
|
|
L.rotation.y += dt * 2.2;
|
|
L.position.y = L.userData.baseY + Math.sin(perfNow * 2 + L.position.x) * 0.08;
|
|
if (L.position.distanceTo(player.pos) < 1.5) {
|
|
L.userData.taken = true; L.visible = false;
|
|
hud.addTrick(`Letter ${L.userData.ch}`, LETTER_SCORE);
|
|
const all = hud.letter(L.userData.ch);
|
|
if (all) {
|
|
hud.addTrick('BOOKQUOYORK!', ALL_LETTERS_SCORE);
|
|
hud.bigFlash('BOOKQUOYORK!!');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- camera
|
|
const camTarget = new THREE.Vector3();
|
|
function updateCamera(dt) {
|
|
const p = player.pos;
|
|
const back = 5.2, up = 2.4;
|
|
const cx = p.x - Math.sin(player.heading) * back;
|
|
const cz = p.z - Math.cos(player.heading) * back;
|
|
const cy = Math.max(p.y + up, heightAt(cx, cz) + 1.2);
|
|
cam.position.lerp(new THREE.Vector3(cx, cy, cz), 1 - Math.pow(0.0015, dt));
|
|
camTarget.lerp(new THREE.Vector3(p.x, p.y + 1.1, p.z), 1 - Math.pow(0.0005, dt));
|
|
cam.lookAt(camTarget);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- loop
|
|
let last = performance.now(), perfNow = 0;
|
|
function frame(now) {
|
|
requestAnimationFrame(frame);
|
|
pad.poll(); // controller -> synthetic key events
|
|
const dt = Math.min((now - last) / 1000, 0.05); last = now;
|
|
perfNow = now / 1000;
|
|
if (player) {
|
|
if (mode === 'run') {
|
|
player.update(dt, keys);
|
|
seshUpdate(dt);
|
|
updateRubbish(dt, perfNow);
|
|
} else {
|
|
player.mixer.update(dt);
|
|
player.rig.rotation.y += dt * 0.25;
|
|
}
|
|
updateCamera(dt);
|
|
}
|
|
renderer.render(scene, cam);
|
|
}
|
|
|
|
// deterministic stepper for headless testing (the browser pane throttles rAF when hidden)
|
|
window.__tp = (x, z, heading) => { // test helper: place the player
|
|
player.pos.set(x, heightAt(x, z), z); player.heading = heading;
|
|
player.state = 'roll'; player.air = player.fall = player.grind = null;
|
|
};
|
|
window.__step = (seconds = 1) => {
|
|
const n = Math.round(seconds * 60);
|
|
for (let i = 0; i < n; i++) {
|
|
const dt = 1 / 60; perfNow += dt;
|
|
pad.poll();
|
|
if (player) {
|
|
if (mode === 'run') { player.update(dt, keys); seshUpdate(dt); updateRubbish(dt, perfNow); }
|
|
else { player.mixer.update(dt); }
|
|
updateCamera(dt);
|
|
}
|
|
}
|
|
renderer.render(scene, cam);
|
|
return window.__dbg ? window.__dbg() : null;
|
|
};
|
|
boot().then(() => { window.__dbg = () => ({ pos: player.pos.toArray(), rigPos: player.rig.position.toArray(), rigVisible: player.rig.visible, camPos: cam.position.toArray(), state: player.state, speed: player.speed, cur: player.cur, mode }); requestAnimationFrame(frame); })
|
|
.catch(e => { document.getElementById('flash').textContent = 'LOAD ERROR ' + e.message;
|
|
document.getElementById('flash').style.opacity = 1; console.error(e); });
|