bookquoy/js/player.js
jing 53ad2030af BOOKQUOY v0.1 — Paddo, three disciplines, letters, gaps, 2:00 sesh
Level 01: Paddington Skatepark from the council plan + park guides —
heightAt(x,z) single-source terrain, grindable rails/ledges, named gaps,
B-O-O-K-Q-U-O-Y letters with the BOOKQUOYORK callback.

SKATE / BMX / BLADES (blades cop thrown rubbish, as is tradition).
character_kit two-rig banks: physics owns the root, clips are cosmetic.

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

255 lines
10 KiB
JavaScript

// BOOKQUOY player — arcade controller over the character_kit two-rig banks.
// THPS rule (from the ROM study): PHYSICS OWNS THE ROOT, animation is cosmetic.
// Clips are in-place; this file moves the root and picks which clip pair plays.
// Discipline behaviour comes entirely from the KIT (see tricks.js).
import * as THREE from 'three';
import { heightAt, normalAt, RAILS } from './level.js';
const GRAV = 18;
const MAX_SPEED = 10.5, PUSH_ACC = 6.5, FRICTION = 0.28, OFFROAD = 2.2;
const STEER = 2.6;
const BAIL_DROP = 3.4;
export class Player {
// primaryActs: bank holding the KIT's trick/idle clips. mateMap: primary -> secondary.
constructor(kit, rig, mixer, primaryActs, mateActs, mateMap, events) {
this.kit = kit;
this.rig = rig; this.mixer = mixer;
this.primary = primaryActs; this.mates = mateActs || {}; this.mateMap = mateMap || {};
this.ev = events;
this.pos = new THREE.Vector3();
this.heading = 0; this.speed = 0;
this.state = 'roll';
this.air = null; this.fall = null; this.grind = null;
this.bailT = 0; this.pushT = 0;
this.cur = null;
this._n = new THREE.Vector3(); this._q = new THREE.Quaternion();
this._up = new THREE.Vector3(0, 1, 0);
}
reset(spawn) {
this.pos.set(spawn.x, heightAt(spawn.x, spawn.z), spawn.z);
this.heading = spawn.heading; this.speed = 0;
this.state = 'roll'; this.air = this.fall = this.grind = null;
this.cur = null;
this.play(this.kit.idle);
}
// ---- animation --------------------------------------------------------
play(clip, fade = 0.09, mateOverride = null) {
const key = clip + '|' + (mateOverride || '');
if (this.cur === key) return;
for (const set of [this.primary, this.mates])
for (const k in set) if (set[k].isRunning()) set[k].fadeOut(fade);
const p = this.primary[clip];
if (p) p.reset().fadeIn(fade).play();
const mateName = mateOverride || this.mateMap[clip];
const m = mateName && this.mates[mateName];
if (m) m.reset().fadeIn(fade).play();
this.cur = key;
}
clipDur(name) { const a = this.primary[name]; return a ? a.getClip().duration : 0.5; }
// ---- inputs ------------------------------------------------------------
tryTrick(code, keys) {
if (this.state === 'bail') return;
if (this.state === 'grind' && code === 'Space') return this.ollieOut();
if (this.state !== 'roll' && this.state !== 'air') return;
let t;
if (code === 'Space') t = this.kit.ollie;
else {
const row = this.kit.tricks[code]; if (!row) return;
t = (keys.has('ArrowUp') && row.up) ? row.up
: (keys.has('ArrowDown') && row.down) ? row.down : row.base;
}
if (this.state === 'roll' && this.speed < 1.2 && code !== 'Space') return;
this.state = 'air';
this.air = { ...t, dur: this.clipDur(t.clip), t: 0, y0: this.pos.y };
this.fall = null;
this.play(t.clip, 0.05, t.mate || null);
this.ev.onTrick({ name: t.name, score: t.score });
}
ollieOut() {
this.grind = null;
const t = this.kit.ollie;
this.state = 'air';
this.air = { ...t, dur: this.clipDur(t.clip), t: 0, y0: this.pos.y };
this.play(t.clip, 0.05);
this.ev.onTrick({ name: t.name, score: t.score });
}
stumble() { // rubbish to the dome (blades tax)
if (this.state === 'bail') return;
this.grind = null; this.air = null; this.fall = null;
this.state = 'bail'; this.bailT = 0.9;
this.pos.y = heightAt(this.pos.x, this.pos.z);
this.play(this.kit.bail, 0.05);
this.ev.onBail();
}
// ---- grind attach ------------------------------------------------------
tryAttachGrind() {
if (!this.kit.grinds) return false;
for (const r of RAILS) {
const ax = r.a[0], az = r.a[1], bx = r.b[0], bz = r.b[1];
const dx = bx - ax, dz = bz - az;
const len2 = dx * dx + dz * dz;
const t = ((this.pos.x - ax) * dx + (this.pos.z - az) * dz) / len2;
if (t < 0 || t > 1) continue;
const px = ax + t * dx, pz = az + t * dz;
const hd = Math.hypot(this.pos.x - px, this.pos.z - pz);
const railY = r.ya + (r.yb - r.ya) * t;
// The pop lives in the CLIP, not the physics root, so mid-air the root is still at
// takeoff height — give the attach window the hop's reach instead of raw root y.
const reach = this.state === 'air' ? 0.95 : 0.3;
if (hd < 0.5 && railY <= this.pos.y + reach && railY >= this.pos.y - 1.0) {
const railDir = Math.atan2(dx, dz);
const dir = Math.cos(this.heading - railDir) >= 0 ? 1 : -1;
const kind = this.kit.grindKinds[r.kind] || this.kit.grindKinds.rail;
this.grind = { rail: r, t, dir, kind, timer: 0 };
this.state = 'grind';
this.air = null; this.fall = null;
this.speed = Math.max(Math.abs(this.speed), 3.2);
this.heading = dir > 0 ? railDir : railDir + Math.PI;
this.play(kind.clip, 0.06);
this.ev.onTrick({ name: `${kind.name} (${r.name})`, score: kind.base });
return true;
}
}
return false;
}
// ---- per-frame ---------------------------------------------------------
update(dt, keys) {
const p = this.pos;
switch (this.state) {
case 'bail': {
this.bailT -= dt;
this.speed = Math.max(0, this.speed - 6 * dt);
p.x += Math.sin(this.heading) * this.speed * dt;
p.z += Math.cos(this.heading) * this.speed * dt;
p.y = heightAt(p.x, p.z);
if (this.bailT <= 0) { this.state = 'roll'; this.play(this.kit.idle); }
break;
}
case 'grind': {
const g = this.grind, r = g.rail;
const dx = r.b[0] - r.a[0], dz = r.b[1] - r.a[1];
const len = Math.hypot(dx, dz);
g.t += (g.dir * this.speed * dt) / len;
g.timer += dt;
this.ev.onGrindTick(g.kind.perSec * dt);
if (g.t < 0 || g.t > 1) {
this.grind = null; this.state = 'fall';
this.fall = { vy: 0, from: p.y };
this.play(this.kit.airFall, 0.05);
break;
}
p.x = r.a[0] + g.t * dx; p.z = r.a[1] + g.t * dz;
p.y = r.ya + (r.yb - r.ya) * g.t;
break;
}
case 'air': {
const a = this.air;
a.t += dt;
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 (a.t >= a.dur * 0.35 && this.tryAttachGrind()) break;
if (a.t >= a.dur) {
if (h >= a.y0 - 0.5) {
p.y = h; this.state = 'roll';
this.play(this.speed > 4 ? this.kit.roll : this.kit.idle);
this.ev.onLand();
} else {
this.state = 'fall';
this.fall = { vy: 0, from: a.y0 };
this.play(this.kit.airFall, 0.08);
}
this.air = null;
} else {
p.y = Math.max(a.y0, h);
}
break;
}
case 'fall': {
const f = this.fall;
f.vy -= GRAV * dt;
p.x += Math.sin(this.heading) * this.speed * dt;
p.z += Math.cos(this.heading) * this.speed * dt;
p.y += f.vy * dt;
const h = heightAt(p.x, p.z);
if (this.tryAttachGrind()) break;
if (p.y <= h) {
p.y = h;
const drop = f.from - h;
if (drop > BAIL_DROP) {
this.state = 'bail'; this.bailT = 1.15;
this.play(this.kit.bail, 0.05);
this.ev.onBail();
} else {
this.state = 'roll';
this.play(this.speed > 4 ? this.kit.roll : this.kit.idle);
this.ev.onLand();
}
this.fall = null;
}
break;
}
case 'roll': default: {
const steer = (keys.has('ArrowLeft') || keys.has('KeyA') ? 1 : 0)
- (keys.has('ArrowRight') || keys.has('KeyD') ? 1 : 0);
this.heading += steer * STEER * dt * Math.min(1, 0.25 + this.speed / 6);
if (keys.has('ArrowUp') || keys.has('KeyW')) {
this.speed = Math.min(MAX_SPEED, this.speed + PUSH_ACC * dt);
this.pushT -= dt;
if (this.pushT <= 0 && this.speed < MAX_SPEED * 0.97) {
this.play(this.kit.push, 0.12); this.pushT = this.clipDur(this.kit.push);
}
} else this.pushT = 0;
if (keys.has('ArrowDown') || keys.has('KeyS'))
this.speed = Math.max(0, this.speed - 8 * dt);
const n = normalAt(p.x, p.z, this._n);
const gAlong = -(n.x * Math.sin(this.heading) + n.z * Math.cos(this.heading));
this.speed = Math.max(0, this.speed + gAlong * GRAV * 0.55 * dt);
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;
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) {
this.state = 'fall'; this.fall = { vy: 0, from: oldH };
this.play(this.kit.airFall, 0.08);
} else {
p.y = h;
if (this.cur !== this.kit.push + '|' || this.pushT <= 0.05)
this.play(this.speed > 0.4 ? this.kit.roll : this.kit.idle, 0.15);
}
break;
}
}
// Neal Macrossan Park has a fence: soft-clamp to the meshed world so nobody
// rolls off into the void (the heightfield only extends ~12m past the slab)
const LIM_X = 50, LIM_Z = 36;
if (Math.abs(p.x) > LIM_X || Math.abs(p.z) > LIM_Z) {
p.x = Math.max(-LIM_X, Math.min(LIM_X, p.x));
p.z = Math.max(-LIM_Z, Math.min(LIM_Z, p.z));
this.speed *= 0.6;
if (this.state === 'roll') p.y = heightAt(p.x, p.z);
}
// pose the rig: root from physics, tilt to the terrain when grounded
this.rig.position.copy(p);
const targetUp = (this.state === 'roll') ? normalAt(p.x, p.z, this._n)
: this._up.set(0, 1, 0);
this._q.setFromUnitVectors(new THREE.Vector3(0, 1, 0), targetUp);
const yaw = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.heading);
this.rig.quaternion.slerp(this._q.multiply(yaw), 0.25);
this.mixer.update(dt);
}
}