// 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, resolveBlocker } 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); const bx0 = p.x, bz0 = p.z; p.x += Math.sin(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); 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; resolveBlocker(p, 0.3); 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; const px0 = p.x, pz0 = p.z; 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.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.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); } 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; } } // 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); } }