// 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); });