commit 53ad2030afffe01ea67d215de6eacd143e349ce9 Author: jing Date: Mon Aug 3 19:13:13 2026 +1000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f2e848 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1964b82 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# BOOKQUOY — a brisbane sesh + +Late nights in the park in Moorooka, someone yells **"BOOKQUOY!"** into the dark. +Friend or foe? A friend calls back **"BOOKQUOYORK!"** — and everything's sweet. + +That's the game. Skate the parks of Brisbane, land tricks, hit the gaps, and collect +**B·O·O·K·Q·U·O·Y** — light all eight letters and the game calls back. + +## Level 01: PADDO +Paddington Skatepark, 113 Caxton St (Neal Macrossan Park, opposite Suncorp) — Brisbane's +premier street park, stylised from the council upgrade plan and local park guides: +the looping island layout around the Moreton Bay figs, the fast **green concrete**, the +5ft square bowl in the west, the 6ft lumpy quarter at the south end, escalating +transitions on the walls, and the street plaza — funbox with rail combo, tiered +down-ledges, stairset with hubbas, long metal-capped ledge, P-rail, moguls with a +rainbow rail, and the spine. + +Every surface rides on **one function** — `heightAt(x,z)` in `js/level.js` is the single +source of truth for the visual mesh *and* the collision, so what you see is what you skate. + +## Three disciplines +| | | +|---|---| +| **1 SKATE** | full trick bank: flips, varials, tre/laser, grinds on every rail and ledge | +| **2 BMX** | bunnyhop, barspin, double barspin, tailwhip, X-up | +| **3 BLADES** | same park, one catch: **locals throw rubbish at you.** Tradition. Dodge the cans or eat pavement to a chorus of "FRUIT BOOTS!!" | + +## Controls +``` +W / ↑ push SPACE ollie / bunnyhop / hop off a grind +A D / ← → steer S / ↓ brake +J kickflip | barspin K heelflip | tailwhip L pop shove | x-up + (hold ↑ or ↓ with J/K/L for varials + specials) +T tre flip Y laser H hardflip N impossible F fs shove +R reset to the tennis-court entrance ENTER start / run it back +``` +Land on rails and ledges to grind. Chain tricks before the wheels settle to multiply. +2:00 on the clock. Named gaps (THE STAIR GAP, CAXTON CARVE, BOWL HIP…) pay 300 a pop. + +## Run it +```bash +python3 serve.py 8141 # no-cache dev server +open http://localhost:8141 +``` + +## How it's built +- **three.js**, no engine, no bundler — plain ES modules. +- Characters + animation from the GODVERSE `character_kit`: the two-rig system + (rider rig + independently-animated board/bike rig), contact-solved with IK so feet + sit on the deck and hands hold the grips. Tricks are **data** (`js/tricks.js`) — + clip name + score — the design lesson taken from decompiling THUG's QB script tables. +- Physics owns the root; clips are cosmetic and in-place (the other THPS lesson). +- Trick clips carry their own pop, so the controller treats clip-time as air-time and + hands over to projectile physics only for drops. + +Assets are original, license-clean, generated by the character_kit authoring tools. +Character: "raver" (Mixamo base, converted + normalized on m3ultra). + +## Testing hooks +`window.__step(seconds)` advances the sim deterministically (the embedded browser pane +throttles rAF when hidden); `window.__tp(x, z, heading)` places the player; both exist +for headless verification and are harmless in play. + +## Roadmap +- More Brisbane parks: Fairfield bowl? Bracken Ridge? Mt Gravatt snake run? +- Manuals + lip tricks, bail balance meter, session goals ("50-50 the Long Metal Ledge") +- BOOKQUOYORK multiplayer call-and-response (two players, one park?) diff --git a/assets/bike.glb b/assets/bike.glb new file mode 100644 index 0000000..0f9ba67 Binary files /dev/null and b/assets/bike.glb differ diff --git a/assets/bike_bank.glb b/assets/bike_bank.glb new file mode 100644 index 0000000..c60bc0e Binary files /dev/null and b/assets/bike_bank.glb differ diff --git a/assets/bike_pairs.json b/assets/bike_pairs.json new file mode 100644 index 0000000..c84d76e --- /dev/null +++ b/assets/bike_pairs.json @@ -0,0 +1,11 @@ +{ + "BikeRide_Idle": "Bike_Idle", + "BikePedal": "Bike_Pedal", + "BikeAir_Idle": "Bike_Roll", + "Barspin_Rider": "Barspin", + "Tailwhip_Rider": "Tailwhip", + "XUp_Rider": "XUp", + "Bunnyhop_Rider": "Bunnyhop", + "BikeBail_Rider": "Bike_Bail", + "BikeDismount": "Bike_Idle" +} \ No newline at end of file diff --git a/assets/bike_raver.glb b/assets/bike_raver.glb new file mode 100644 index 0000000..579fab1 Binary files /dev/null and b/assets/bike_raver.glb differ diff --git a/assets/board_raver.glb b/assets/board_raver.glb new file mode 100644 index 0000000..8e7e0c5 Binary files /dev/null and b/assets/board_raver.glb differ diff --git a/assets/deck.glb b/assets/deck.glb new file mode 100644 index 0000000..55f9105 Binary files /dev/null and b/assets/deck.glb differ diff --git a/assets/deck_bank.glb b/assets/deck_bank.glb new file mode 100644 index 0000000..64743ee Binary files /dev/null and b/assets/deck_bank.glb differ diff --git a/assets/pairs.json b/assets/pairs.json new file mode 100644 index 0000000..91e71f2 --- /dev/null +++ b/assets/pairs.json @@ -0,0 +1,25 @@ +{ + "Kickflip": "Pop_Kick", + "Heelflip": "Pop_Heel", + "DoubleKickflip": "Pop_Long", + "PopShoveIt": "Pop_Shove", + "FSShoveIt": "Pop_Shove", + "Shove360": "Pop_Shove", + "VarialKickflip": "Pop_Kick", + "VarialHeelflip": "Pop_Heel", + "TreFlip": "Pop_Long", + "LaserFlip": "Pop_Long", + "Hardflip": "Pop_Kick", + "InwardHeelflip": "Pop_Heel", + "Impossible": "Pop_Long", + "BigSpin": "Pop_Shove", + "Ollie": "Pop_Ollie", + "Board_Idle": "Skate_Idle", + "Board_Roll": "Skate_Roll", + "Manual": "Manual_Idle", + "NoseManual": "NoseManual_Idle", + "Grind_5050": "Grind_Idle", + "Boardslide": "Boardslide_Idle", + "Board_Push": "Skate_Push", + "Board_Bail": "Bail_Rider" +} \ No newline at end of file diff --git a/assets/shoes/shoe_vulc_low.glb b/assets/shoes/shoe_vulc_low.glb new file mode 100644 index 0000000..e70e0a5 Binary files /dev/null and b/assets/shoes/shoe_vulc_low.glb differ diff --git a/assets/woman_raver_01.glb b/assets/woman_raver_01.glb new file mode 100644 index 0000000..1382bf0 Binary files /dev/null and b/assets/woman_raver_01.glb differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..41a26bc --- /dev/null +++ b/index.html @@ -0,0 +1,80 @@ + + + + +BOOKQUOY — a brisbane sesh + + + +
+
0
+
+
+ +
+
+ +
+

BOOKQUOY

+

a brisbane sesh — level 01: PADDO (Caxton St)

+
+ 1 SKATE  ·  + 2 BMX  ·  + 3 BLADES +   (blades cop rubbish thrown at you. tradition.) +
+
+ W / ↑ push  ·  A D / ← → steer  ·  S brake  ·  SPACE ollie / hop off a grind
+ J kickflip   K heelflip   L pop shove   (hold ↑ or ↓ for varials + specials)
+ T tre flip   Y laser   H hardflip   N impossible   F fs shove
+ land on rails and ledges to grind  ·  hit the gaps  ·  collect B·O·O·K·Q·U·O·Y
+ R reset to the tennis-court entrance +
+
press ENTER to drop in
+
+ +
+

SESH OVER

+
0
+
best: 0
+
ENTER to run it back
+
+ + + + + diff --git a/js/hud.js b/js/hud.js new file mode 100644 index 0000000..a768cd1 --- /dev/null +++ b/js/hud.js @@ -0,0 +1,76 @@ +// BOOKQUOY HUD — score, combo, timer, the letters, and the callouts. +import { comboLabel } from './tricks.js'; + +export class Hud { + constructor() { + this.$ = id => document.getElementById(id); + this.letterOrder = ['B', 'O', 'O', 'K', 'Q', 'U', 'O', 'Y']; + this.reset(); + } + reset() { + this.score = 0; this.combo = []; this.comboScore = 0; + this.lettersLit = 0; + this.$('score').textContent = '0'; + this.$('combo').textContent = ''; + this.renderLetters(); + this.$('flash').textContent = ''; + this.$('over').style.display = 'none'; + } + renderLetters() { + this.$('letters').innerHTML = this.letterOrder + .map((c, i) => `${c}`).join(''); + } + addTrick(name, score) { + this.combo.push({ name, score }); this.comboScore += score; + this.$('combo').textContent = comboLabel(this.combo) + ` (${this.comboScore * this.combo.length})`; + } + addGrindPoints(pts) { + if (!this.combo.length) this.combo.push({ name: 'Grind', score: 0 }); + this.comboScore += pts; + this.$('combo').textContent = comboLabel(this.combo) + ` (${Math.round(this.comboScore * this.combo.length)})`; + } + bank() { + if (!this.combo.length) return; + this.score += Math.round(this.comboScore * this.combo.length); + this.$('score').textContent = this.score.toLocaleString(); + this.combo = []; this.comboScore = 0; + this.$('combo').textContent = ''; + } + dropCombo() { + if (this.combo.length) this.flash('BAILED — combo lost', '#ff6b6b'); + this.combo = []; this.comboScore = 0; + this.$('combo').textContent = ''; + } + letter(ch) { + this.lettersLit = Math.min(8, this.lettersLit + 1); + this.renderLetters(); + this.flash(ch + ' !', '#ffd93b'); + return this.lettersLit === 8; + } + flash(text, color = '#9be89b') { + const f = this.$('flash'); + f.textContent = text; f.style.color = color; + f.style.opacity = '1'; + clearTimeout(this._ft); + this._ft = setTimeout(() => f.style.opacity = '0', 1400); + } + bigFlash(text) { + const f = this.$('bigflash'); + f.textContent = text; f.style.opacity = '1'; + clearTimeout(this._bt); + this._bt = setTimeout(() => f.style.opacity = '0', 2600); + } + timer(t) { + const m = Math.floor(Math.max(t, 0) / 60), s = Math.floor(Math.max(t, 0) % 60); + this.$('timer').textContent = `${m}:${String(s).padStart(2, '0')}`; + this.$('timer').style.color = t < 15 ? '#ff6b6b' : '#dde'; + } + sessionOver(score) { + const best = Math.max(score, +(localStorage.getItem('bookquoy_best') || 0)); + localStorage.setItem('bookquoy_best', best); + this.$('finalScore').textContent = score.toLocaleString(); + this.$('bestScore').textContent = best.toLocaleString(); + this.$('over').style.display = 'flex'; + } + title(show) { this.$('title').style.display = show ? 'flex' : 'none'; } +} diff --git a/js/level.js b/js/level.js new file mode 100644 index 0000000..c7fbced --- /dev/null +++ b/js/level.js @@ -0,0 +1,269 @@ +// BOOKQUOY level 01 — "PADDO" +// Paddington Skatepark, 113 Caxton St, Brisbane (Neal Macrossan Park, opposite Suncorp). +// Stylised from the council upgrade plan + skateboard.com.au notes: looping island layout +// around the Moreton Bay figs, green fast concrete, 5ft square bowl west, 6ft lumpy +// quarter south, escalating transitions on the walls, street plaza east (funbox + rails, +// tiered down-ledges, stairset with blocks, long metal-capped ledge, P-rail, moguls with +// a rainbow rail, spine transfer). +// +// EVERYTHING RIDES ON ONE FUNCTION: heightAt(x,z) is the single source of truth for the +// visual mesh AND the collision — the same lesson as the skateboard deck's concave. + +import * as THREE from 'three'; + +// ---------------------------------------------------------------- park bounds +export const BOUNDS = { x0: -40, x1: 40, z0: -26, z1: 26 }; +const IN = (x, a, b) => x >= a && x <= b; +const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); +const sstep = t => { t = clamp(t, 0, 1); return t * t * (3 - 2 * t); }; + +// quarter-pipe transition: d = distance from the wall, r = radius. h(0)=r, h(r)=0. +function tranny(d, r) { + if (d >= r) return 0; + if (d <= 0) return r; + const q = r - d; + return r - Math.sqrt(Math.max(r * r - q * q, 0)); +} + +// ---------------------------------------------------------------- elements +// Each entry contributes height; concrete = max(all), bowl carves negative space. +function funbox(x, z, cx, cz, hw, hd, h, skirt) { + const dx = Math.abs(x - cx) - hw, dz = Math.abs(z - cz) - hd; + const d = Math.max(dx, dz); + if (d <= 0) return h; + if (d >= skirt) return 0; + return h * (1 - d / skirt); +} +function plateau(x, z, cx, cz, hw, hd, h) { // sharp-sided ledge / pad + return (Math.abs(x - cx) <= hw && Math.abs(z - cz) <= hd) ? h : 0; +} +function mogul(x, z, cx, cz, h, s) { + const r2 = (x - cx) ** 2 + (z - cz) ** 2; + return h * Math.exp(-r2 / (s * s)); +} +function spine(x, z, cx0, cx1, cz, r) { // back-to-back quarters along x + if (!IN(x, cx0, cx1)) return 0; + const d = Math.abs(z - cz); + return d >= r ? 0 : tranny(d, r); +} + +export function heightAt(x, z) { + // outside the slab: grass, rising gently away from the park + const inPark = IN(x, BOUNDS.x0, BOUNDS.x1) && IN(z, BOUNDS.z0, BOUNDS.z1); + if (!inPark) { + const dx = Math.max(BOUNDS.x0 - x, x - BOUNDS.x1, 0); + const dz = Math.max(BOUNDS.z0 - z, z - BOUNDS.z1, 0); + return 0.25 + 0.09 * Math.hypot(dx, dz); + } + let h = 0; + + // --- walls: escalating transitions (west corner 0.9->1.8, north 0.5->1.1) --- + const rW = 0.9 + 0.9 * sstep((10 - z) / 26); // west wall, bigger toward NW + h = Math.max(h, tranny(x - BOUNDS.x0, rW)); + const rN = 0.5 + 0.6 * sstep((x + 10) / 40); // north (Hale St) wall + h = Math.max(h, tranny(z - BOUNDS.z0, rN)); + // --- south: the 6ft lumpy quarter, x -16..18, plus wide bank west of it --- + if (IN(x, -16, 18)) { + const lump = 1.8 + 0.12 * Math.sin(x * 0.9); // "lumpy" is literal at Paddo + h = Math.max(h, tranny(BOUNDS.z1 - z, lump)); + } + if (IN(x, -26, -16)) { // 900H bank + const d = BOUNDS.z1 - z; + if (d < 2.4) h = Math.max(h, 0.9 * (1 - d / 2.4)); + } + if (IN(x, 20, 34)) { // NE flatbank (existing #22) + const d = z - BOUNDS.z0; + if (d < 2.2) h = Math.max(h, 0.8 * (1 - d / 2.2)); + } + + // --- west: 5ft square bowl (carved DOWN, full-transition walls) --- + const BX = -27, BZ = -6, BH = 4.2, BD = 1.5; + if (Math.abs(x - BX) < BH && Math.abs(z - BZ) < BH) { + // dw = distance in from the rim; full-transition walls: 0 at the rim, -BD at the flat + const dw = Math.min(BH - Math.abs(x - BX), BH - Math.abs(z - BZ)); + return -BD + tranny(Math.min(dw, BD), BD); + } + + // --- volcano + moguls (191-450H + 595-800H with rainbow rail) --- + h = Math.max(h, mogul(x, z, -18, 4, 0.7, 2.6)); // volcano cone + h = Math.max(h, mogul(x, z, -2, 6, 0.45, 2.2)); + h = Math.max(h, mogul(x, z, 2, 9, 0.8, 2.5)); // rainbow-rail mogul + + // --- spine transfer (1200H) --- + h = Math.max(h, spine(x, z, -12, -4, 12, 1.2)); + + // --- central island: kerbed grass pad with the fig trees --- + const ex = (x - 2) / 7, ez = (z + 2) / 4.5; + if (ex * ex + ez * ez < 1) h = Math.max(h, 0.14); + + // --- east street plaza --- + h = Math.max(h, funbox(x, z, 16, -2, 2.5, 1.2, 0.9, 1.6)); // centre funbox + h = Math.max(h, plateau(x, z, 8.5, -11, 1.5, 0.8, 0.6)); // down ledges (tiers) + h = Math.max(h, plateau(x, z, 11.5, -11, 1.5, 0.8, 0.45)); + h = Math.max(h, plateau(x, z, 14.5, -11, 1.5, 0.8, 0.3)); + h = Math.max(h, funbox(x, z, 26, -14, 2.2, 1.2, 0.35, 1.1)); // bank-to-bank manual pad + h = Math.max(h, plateau(x, z, 33.5, -2, 0.5, 12, 0.42)); // long metal ledge + + // --- stairset: 4 x 150mm steps dropping south from a 600H platform --- + if (IN(x, 3.6, 8.4)) { + if (z <= 12.2 && z > 9.0) h = Math.max(h, 0.6); // top platform + else if (IN(z, 12.2, 14.6)) { + const step = Math.floor((z - 12.2) / 0.6); // 0.6m going, 0.15 rise + h = Math.max(h, 0.6 - 0.15 * (step + 1)); + } + } + return h; +} + +export function normalAt(x, z, out) { + const e = 0.18; + const hx = heightAt(x + e, z) - heightAt(x - e, z); + const hz = heightAt(x, z + e) - heightAt(x, z - e); + out.set(-hx, 2 * e, -hz).normalize(); + return out; +} + +// ---------------------------------------------------------------- grindables +// { a, b, y (top height), kind } — kind picks the grind clip + scoring +export const RAILS = [ + { name: 'Funbox Flat Rail', a: [13.5, -2.9], b: [18.5, -2.9], ya: 1.25, yb: 1.25, kind: 'rail' }, + { name: 'Funbox Down Rail', a: [18.5, -1.0], b: [21.8, -1.0], ya: 1.20, yb: 0.35, kind: 'rail' }, + { name: 'P-Rail', a: [22.0, 8.0], b: [26.0, 8.0], ya: 0.35, yb: 0.35, kind: 'rail' }, + { name: 'Rainbow Rail', a: [-0.4, 9.0], b: [2.0, 9.0], ya: 0.45, yb: 1.35, kind: 'rail' }, + { name: 'Rainbow Rail', a: [2.0, 9.0], b: [4.4, 9.0], ya: 1.35, yb: 0.45, kind: 'rail' }, + { name: 'Long Metal Ledge', a: [33.0, -13.5], b: [33.0, 9.5], ya: 0.42, yb: 0.42, kind: 'ledge' }, + { name: 'Down Ledge', a: [7.0, -11.9], b: [10.0, -11.9], ya: 0.6, yb: 0.6, kind: 'ledge' }, + { name: 'Down Ledge', a: [10.0, -11.9], b: [13.0, -11.9], ya: 0.45, yb: 0.45, kind: 'ledge' }, + { name: 'Down Ledge', a: [13.0, -11.9], b: [16.0, -11.9], ya: 0.3, yb: 0.3, kind: 'ledge' }, + { name: 'Stair Hubba', a: [3.4, 12.2], b: [3.4, 15.0], ya: 0.62, yb: 0.06, kind: 'ledge' }, + { name: 'Stair Hubba', a: [8.6, 12.2], b: [8.6, 15.0], ya: 0.62, yb: 0.06, kind: 'ledge' }, +]; + +// ---------------------------------------------------------------- named gaps +// airborne over the region => award (once per air). Names are pure Brisbane. +export const GAPS = [ + { name: 'THE STAIR GAP', x: 6, z: 13.6, r: 2.4 }, + { name: 'FUNBOX FLY', x: 16, z: -2, r: 2.8 }, + { name: 'SPINE TRANSFER', x: -8, z: 12, r: 2.4 }, + { name: 'BOWL HIP', x: -22.5,z: -2, r: 2.2 }, + { name: 'RAINBOW ARC', x: 2, z: 9, r: 2.2 }, + { name: 'LEDGE LEAP', x: 11.5, z: -11, r: 2.6 }, + { name: 'KEYHOLE GAP', x: -36, z: -14, r: 2.6 }, + { name: 'CAXTON CARVE', x: 10, z: -22.5,r: 2.6 }, + { name: 'ISLAND HOP', x: 2, z: 2.6, r: 2.2 }, +]; + +// ---------------------------------------------------------------- letters +// B O O K Q U O Y — collect the call, land the response. +export const LETTERS = [ + { ch: 'B', x: -27, y: 0.9, z: -6 }, // floating over the bowl + { ch: 'O', x: -8, y: 2.3, z: 12 }, // over the spine + { ch: 'O', x: 16, y: 2.4, z: -2 }, // over the funbox + { ch: 'K', x: 6, y: 1.6, z: 14.2 }, // over the stairs + { ch: 'Q', x: -35, y: 2.2, z: -13 }, // the keyhole corner + { ch: 'U', x: 2, y: 2.5, z: 9 }, // rainbow rail apex + { ch: 'O', x: 33, y: 1.6, z: -2 }, // long ledge + { ch: 'Y', x: 24, y: 1.5, z: 8 }, // P-rail +]; + +export const SPAWN = { x: -32, z: -18, heading: 0.85 }; // tennis-courts entrance, facing SE into the park + +// ---------------------------------------------------------------- build visuals +function zoneColor(x, z, h) { + const c = new THREE.Color(); + const inPark = IN(x, BOUNDS.x0, BOUNDS.x1) && IN(z, BOUNDS.z0, BOUNDS.z1); + if (!inPark) return c.set(0x4d7c3c); // parkland grass + const ex = (x - 2) / 7, ez = (z + 2) / 4.5; + if (ex * ex + ez * ez < 1) return c.set(0x557f3f); // island grass + if (h < -0.05) return c.set(0x6a7d70); // bowl interior + if (x > 6) return c.set(0x9aa0a2); // old plaza, grey + return c.set(0x7fae6f); // the Paddo green +} + +export function buildLevel(scene) { + // heightfield mesh from heightAt — visual == collision by construction + const RES = 0.5, PAD = 14; + const x0 = BOUNDS.x0 - PAD, x1 = BOUNDS.x1 + PAD, z0 = BOUNDS.z0 - PAD, z1 = BOUNDS.z1 + PAD; + const nx = Math.round((x1 - x0) / RES) + 1, nz = Math.round((z1 - z0) / RES) + 1; + const pos = [], col = [], idx = []; + for (let j = 0; j < nz; j++) for (let i = 0; i < nx; i++) { + const x = x0 + i * RES, z = z0 + j * RES, h = heightAt(x, z); + pos.push(x, h, z); + const c = zoneColor(x, z, h); col.push(c.r, c.g, c.b); + } + for (let j = 0; j < nz - 1; j++) for (let i = 0; i < nx - 1; i++) { + const a = j * nx + i, b = a + 1, c = a + nx, d = c + 1; + idx.push(a, c, b, b, c, d); + } + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); + g.setAttribute('color', new THREE.Float32BufferAttribute(col, 3)); + g.setIndex(idx); g.computeVertexNormals(); + const ground = new THREE.Mesh(g, new THREE.MeshLambertMaterial({ vertexColors: true })); + ground.receiveShadow = true; + scene.add(ground); + + // rails + ledge caps as visible steel + const steel = new THREE.MeshStandardMaterial({ color: 0xb9bec4, metalness: 0.8, roughness: 0.35 }); + for (const r of RAILS) { + const a = new THREE.Vector3(r.a[0], r.ya, r.a[1]); + const b = new THREE.Vector3(r.b[0], r.yb, r.b[1]); + const len = a.distanceTo(b); + const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, len, 8), steel); + bar.position.copy(a).add(b).multiplyScalar(0.5); + bar.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), + b.clone().sub(a).normalize()); + scene.add(bar); + if (r.kind === 'rail') { // posts + for (const t of [0.12, 0.88]) { + const p = a.clone().lerp(b, t); + const post = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, p.y, 6), steel); + post.position.set(p.x, p.y / 2, p.z); scene.add(post); + } + } + } + + // the Moreton Bay figs on the island (+ a couple in the parkland) + const trunkM = new THREE.MeshLambertMaterial({ color: 0x5b4632 }); + const leafM = new THREE.MeshLambertMaterial({ color: 0x2e5b2a }); + const fig = (x, z, s) => { + const grp = new THREE.Group(); + const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.28 * s, 0.42 * s, 2.6 * s, 7), trunkM); + trunk.position.y = 1.3 * s; grp.add(trunk); + for (const [ox, oy, oz, r] of [[0, 3.1, 0, 2.4], [1.4, 2.6, 0.7, 1.7], [-1.3, 2.7, -0.6, 1.6]]) { + const m = new THREE.Mesh(new THREE.SphereGeometry(r * s, 10, 8), leafM); + m.position.set(ox * s, oy * s, oz * s); grp.add(m); + } + grp.position.set(x, heightAt(x, z), z); scene.add(grp); + }; + fig(0, -3, 1.25); fig(4.5, -1, 1.0); fig(-1, 1, 0.9); + fig(-34, 16, 1.1); fig(30, 20, 1.3); fig(-14, -30, 1.2); + + // Caxton St + a nod to Suncorp across the road + const road = new THREE.Mesh(new THREE.BoxGeometry(130, 0.08, 7), + new THREE.MeshLambertMaterial({ color: 0x3a3d42 })); + road.position.set(0, 0.3, BOUNDS.z0 - PAD - 3); scene.add(road); + const stadium = new THREE.Mesh(new THREE.CylinderGeometry(26, 30, 9, 24, 1, false, 0, Math.PI), + new THREE.MeshLambertMaterial({ color: 0x7d8288 })); + stadium.rotation.y = Math.PI; stadium.position.set(10, 3.5, BOUNDS.z0 - PAD - 26); + scene.add(stadium); + + // floating letters + const letterMeshes = LETTERS.map(L => { + const cv = document.createElement('canvas'); cv.width = cv.height = 128; + const cx = cv.getContext('2d'); + cx.fillStyle = '#ffd93b'; cx.strokeStyle = '#2b2b2b'; cx.lineWidth = 10; + cx.font = 'bold 104px monospace'; cx.textAlign = 'center'; cx.textBaseline = 'middle'; + cx.strokeText(L.ch, 64, 70); cx.fillText(L.ch, 64, 70); + const tex = new THREE.CanvasTexture(cv); + const spr = new THREE.Mesh(new THREE.PlaneGeometry(0.9, 0.9), + new THREE.MeshBasicMaterial({ map: tex, transparent: true, side: THREE.DoubleSide })); + const base = heightAt(L.x, L.z); + spr.position.set(L.x, Math.max(base, 0) + L.y, L.z); + spr.userData = { ch: L.ch, taken: false, baseY: spr.position.y }; + scene.add(spr); + return spr; + }); + + return { ground, letterMeshes }; +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..11959f7 --- /dev/null +++ b/js/main.js @@ -0,0 +1,307 @@ +// 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 } 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; + + 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); }); diff --git a/js/player.js b/js/player.js new file mode 100644 index 0000000..8b7cd86 --- /dev/null +++ b/js/player.js @@ -0,0 +1,254 @@ +// 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); + } +} diff --git a/js/tricks.js b/js/tricks.js new file mode 100644 index 0000000..2538fe3 --- /dev/null +++ b/js/tricks.js @@ -0,0 +1,80 @@ +// BOOKQUOY trick tables — tricks are DATA, not code. +// (The core lesson from the THUG QB decompile: Neversoft shipped yearly because designers +// edited tables.) Clip names bind to the character_kit banks. +// +// Each discipline is a KIT: which bank the trick clips live in, what plays while rolling, +// and its own trick table. `mate` overrides the paired secondary clip when it differs +// from the pairs.json default (e.g. double barspin = same rider clip, faster bike clip). + +export const KITS = { + skate: { + label: 'SKATE', anchor: 'Board', primary: 'prop', grinds: true, + idle: 'Board_Idle', roll: 'Board_Roll', push: 'Board_Push', + bail: 'Board_Bail', airFall: 'Ollie', + ollie: { clip: 'Ollie', name: 'Ollie', score: 10 }, + grindKinds: { + rail: { clip: 'Grind_5050', name: '50-50', base: 100, perSec: 60 }, + ledge: { clip: 'Grind_5050', name: 'Ledge Grind', base: 100, perSec: 60 }, + }, + tricks: { + KeyJ: { base: { clip: 'Kickflip', name: 'Kickflip', score: 100 }, + up: { clip: 'VarialKickflip', name: 'Varial Kickflip', score: 200 }, + down: { clip: 'DoubleKickflip', name: 'Double Kickflip', score: 250 } }, + KeyK: { base: { clip: 'Heelflip', name: 'Heelflip', score: 100 }, + up: { clip: 'VarialHeelflip', name: 'Varial Heelflip', score: 200 }, + down: { clip: 'InwardHeelflip', name: 'Inward Heelflip', score: 275 } }, + KeyL: { base: { clip: 'PopShoveIt', name: 'Pop Shove-It', score: 75 }, + up: { clip: 'BigSpin', name: 'BigSpin', score: 150 }, + down: { clip: 'Shove360', name: '360 Shove-It', score: 200 } }, + KeyT: { base: { clip: 'TreFlip', name: 'Tre Flip', score: 300 } }, + KeyY: { base: { clip: 'LaserFlip', name: 'Laser Flip', score: 350 } }, + KeyH: { base: { clip: 'Hardflip', name: 'Hardflip', score: 300 } }, + KeyN: { base: { clip: 'Impossible', name: 'Impossible', score: 250 } }, + KeyF: { base: { clip: 'FSShoveIt', name: 'FS Shove-It', score: 100 } }, + }, + }, + + bmx: { + label: 'BMX', anchor: 'Bike', primary: 'rider', grinds: false, + idle: 'BikeRide_Idle', roll: 'BikeRide_Idle', push: 'BikePedal', + bail: 'BikeBail_Rider', airFall: 'BikeAir_Idle', + ollie: { clip: 'Bunnyhop_Rider', name: 'Bunnyhop', score: 50 }, + tricks: { + KeyJ: { base: { clip: 'Barspin_Rider', name: 'Barspin', score: 150 }, + down: { clip: 'Barspin_Rider', mate: 'Barspin_Double', + name: 'Double Barspin', score: 300 } }, + KeyK: { base: { clip: 'Tailwhip_Rider', name: 'Tailwhip', score: 250 } }, + KeyL: { base: { clip: 'XUp_Rider', name: 'X-Up', score: 150 } }, + }, + }, + + blades: { + label: 'BLADES', anchor: null, primary: 'rider', grinds: true, rubbish: true, + idle: 'Skate_Idle', roll: 'Skate_Roll', push: 'Skate_Push', + bail: 'Bail_Rider', airFall: 'Pop_Ollie', + ollie: { clip: 'Pop_Ollie', name: 'Air', score: 10 }, + grindKinds: { + rail: { clip: 'Grind_Idle', name: 'Soul Grind', base: 100, perSec: 60 }, + ledge: { clip: 'Grind_Idle', name: 'Royale', base: 100, perSec: 60 }, + }, + tricks: { + KeyJ: { base: { clip: 'Pop_Kick', name: '360 Spin', score: 100 } }, + KeyK: { base: { clip: 'Pop_Heel', name: 'Zero Spin', score: 100 } }, + KeyL: { base: { clip: 'Pop_Shove', name: 'Truespin', score: 120 } }, + KeyT: { base: { clip: 'Pop_Long', name: 'Misty Flip', score: 300 } }, + }, + }, +}; + +export const RUBBISH_INSULTS = [ + 'OI!', 'GET A BOARD!', 'FRUIT BOOTS!!', 'NICE ROLLERBLADES MATE', + 'WRONG PARK, BLADER', 'COP THAT', '*thrown VB can*', +]; + +export const GAP_SCORE = 300; +export const LETTER_SCORE = 500; +export const ALL_LETTERS_SCORE = 5000; + +export function comboLabel(items) { + return items.map(i => i.name).join(' + ') + (items.length > 1 ? ` x${items.length}` : ''); +} diff --git a/serve.py b/serve.py new file mode 100644 index 0000000..fb33699 --- /dev/null +++ b/serve.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""BOOKQUOY dev server — http.server with caching disabled so module edits show up.""" +import http.server, functools, sys + +class NoCache(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header('Cache-Control', 'no-store, must-revalidate') + super().end_headers() + +port = int(sys.argv[1]) if len(sys.argv) > 1 else 8141 +http.server.ThreadingHTTPServer(('', port), NoCache).serve_forever()