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>
This commit is contained in:
commit
53ad2030af
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
67
README.md
Normal file
67
README.md
Normal file
@ -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?)
|
||||
BIN
assets/bike.glb
Normal file
BIN
assets/bike.glb
Normal file
Binary file not shown.
BIN
assets/bike_bank.glb
Normal file
BIN
assets/bike_bank.glb
Normal file
Binary file not shown.
11
assets/bike_pairs.json
Normal file
11
assets/bike_pairs.json
Normal file
@ -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"
|
||||
}
|
||||
BIN
assets/bike_raver.glb
Normal file
BIN
assets/bike_raver.glb
Normal file
Binary file not shown.
BIN
assets/board_raver.glb
Normal file
BIN
assets/board_raver.glb
Normal file
Binary file not shown.
BIN
assets/deck.glb
Normal file
BIN
assets/deck.glb
Normal file
Binary file not shown.
BIN
assets/deck_bank.glb
Normal file
BIN
assets/deck_bank.glb
Normal file
Binary file not shown.
25
assets/pairs.json
Normal file
25
assets/pairs.json
Normal file
@ -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"
|
||||
}
|
||||
BIN
assets/shoes/shoe_vulc_low.glb
Normal file
BIN
assets/shoes/shoe_vulc_low.glb
Normal file
Binary file not shown.
BIN
assets/woman_raver_01.glb
Normal file
BIN
assets/woman_raver_01.glb
Normal file
Binary file not shown.
80
index.html
Normal file
80
index.html
Normal file
@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>BOOKQUOY — a brisbane sesh</title>
|
||||
<style>
|
||||
body{margin:0;overflow:hidden;background:#111;font:14px/1.3 monospace;color:#dde}
|
||||
#hudbar{position:fixed;top:10px;left:14px;z-index:5;text-shadow:0 2px 4px #0009}
|
||||
#score{font-size:30px;font-weight:bold;color:#fff}
|
||||
#combo{color:#ffd93b;min-height:18px;margin-top:2px}
|
||||
#right{position:fixed;top:10px;right:16px;z-index:5;text-align:right;text-shadow:0 2px 4px #0009}
|
||||
#timer{font-size:28px;font-weight:bold}
|
||||
#letters span{display:inline-block;margin-left:4px;font-size:20px;font-weight:bold;
|
||||
color:#ffffff33;transition:color .2s, transform .2s}
|
||||
#letters span.lit{color:#ffd93b;transform:scale(1.15)}
|
||||
#modeline span{color:#8b95a3;cursor:default}
|
||||
#modeline span.lit{color:#ffd93b;font-weight:bold}
|
||||
#flash{position:fixed;top:22%;width:100%;text-align:center;z-index:5;font-size:26px;
|
||||
font-weight:bold;opacity:0;transition:opacity .4s;text-shadow:0 2px 6px #000c}
|
||||
#bigflash{position:fixed;top:34%;width:100%;text-align:center;z-index:6;font-size:64px;
|
||||
font-weight:bold;color:#ffd93b;opacity:0;transition:opacity .5s;
|
||||
text-shadow:0 4px 0 #b8860b,0 8px 22px #000d;letter-spacing:4px}
|
||||
.panel{position:fixed;inset:0;z-index:10;display:flex;flex-direction:column;
|
||||
align-items:center;justify-content:center;background:#0008;text-align:center}
|
||||
.panel h1{font-size:64px;margin:0 0 6px;color:#ffd93b;letter-spacing:6px;
|
||||
text-shadow:0 4px 0 #b8860b,0 8px 24px #000}
|
||||
.panel h2{font-weight:normal;color:#cfe;margin:0 0 22px}
|
||||
.panel .keys{color:#9fb;line-height:1.9;font-size:13px}
|
||||
.panel .go{margin-top:26px;font-size:20px;color:#fff;animation:pulse 1.2s infinite}
|
||||
@keyframes pulse{50%{opacity:.35}}
|
||||
#over{display:none}
|
||||
#over .final{font-size:44px;color:#ffd93b;margin:8px 0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="hudbar">
|
||||
<div id="score">0</div>
|
||||
<div id="combo"></div>
|
||||
</div>
|
||||
<div id="right">
|
||||
<div id="timer">2:00</div>
|
||||
<div id="letters"></div>
|
||||
<div id="modelabel" style="color:#9fb;font-size:12px;margin-top:2px">SKATE</div>
|
||||
</div>
|
||||
<div id="flash"></div>
|
||||
<div id="bigflash"></div>
|
||||
|
||||
<div id="title" class="panel">
|
||||
<h1>BOOKQUOY</h1>
|
||||
<h2>a brisbane sesh — level 01: PADDO (Caxton St)</h2>
|
||||
<div id="modeline" style="margin-bottom:18px;font-size:17px">
|
||||
<span id="mode-skate">1 SKATE</span> ·
|
||||
<span id="mode-bmx">2 BMX</span> ·
|
||||
<span id="mode-blades">3 BLADES</span>
|
||||
<span style="color:#889;font-size:12px"> (blades cop rubbish thrown at you. tradition.)</span>
|
||||
</div>
|
||||
<div class="keys">
|
||||
W / ↑ push · A D / ← → steer · S brake · SPACE ollie / hop off a grind<br>
|
||||
J kickflip K heelflip L pop shove (hold ↑ or ↓ for varials + specials)<br>
|
||||
T tre flip Y laser H hardflip N impossible F fs shove<br>
|
||||
land on rails and ledges to grind · hit the gaps · collect B·O·O·K·Q·U·O·Y<br>
|
||||
R reset to the tennis-court entrance
|
||||
</div>
|
||||
<div class="go">press ENTER to drop in</div>
|
||||
</div>
|
||||
|
||||
<div id="over" class="panel">
|
||||
<h1>SESH OVER</h1>
|
||||
<div class="final" id="finalScore">0</div>
|
||||
<div>best: <span id="bestScore">0</span></div>
|
||||
<div class="go">ENTER to run it back</div>
|
||||
</div>
|
||||
|
||||
<script type="importmap">
|
||||
{"imports":{"three":"https://unpkg.com/three@0.160.0/build/three.module.js",
|
||||
"three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/"}}
|
||||
</script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
76
js/hud.js
Normal file
76
js/hud.js
Normal file
@ -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) => `<span class="${i < this.lettersLit ? 'lit' : ''}">${c}</span>`).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'; }
|
||||
}
|
||||
269
js/level.js
Normal file
269
js/level.js
Normal file
@ -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 };
|
||||
}
|
||||
307
js/main.js
Normal file
307
js/main.js
Normal file
@ -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); });
|
||||
254
js/player.js
Normal file
254
js/player.js
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
80
js/tricks.js
Normal file
80
js/tricks.js
Normal file
@ -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}` : '');
|
||||
}
|
||||
11
serve.py
Normal file
11
serve.py
Normal file
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user