level.js adopts skatemakerpro's COLLIDERS export shape (box|circle) so
editor parks drop in: box tops join heightAt (land on the picnic table),
circles push out (bins, trunks, hydrants). player.js: hard-edge step
block in roll + bail, blocker resolve in fall, bonk -> stumble + flash
('STRAIGHT INTO THE BIN'). Kit grinds append to RAILS (bench boardslides,
trolley 50-50s); props declare gap triggers vs terrain height (y0).
New Paddo street set: two trolleys, slappy kerbs, planters, parking
block, jersey barrier. Verified: bin stop at contact, planter stand
y=0.50, bench + trolley grinds, 'Tre Flip + TROLLEY GAP + 50-50
(trolley) x3 (2109)'.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
270 lines
11 KiB
JavaScript
270 lines
11 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, 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);
|
|
}
|
|
}
|