bookquoy/js/main.js
type-two 7afae85599 park_kit dressing: textured figs, furniture, fence line + /kit/ mount
serve.py mounts the sibling park_kit repo at /kit/ (PARK_KIT env to
repoint) — same convention as skatemakerpro, so editor-exported levels
work unchanged. level.js PROPS lists the dressing (island Moreton Bays,
gums, benches, bin, picnic table, bleachers, shade sail, fence sections,
floodlights, hydrant, graffiti wall); main.js loads them onto heightAt.
Procedural cone-figs retired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:15:19 +10:00

322 lines
13 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 } 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';
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));
}
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(); },
});
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();
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 === 'Enter') startSesh();
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 > 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);
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;
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); });