diff --git a/README.md b/README.md index b0f800a..6c8bf60 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,19 @@ source of truth for the visual mesh *and* the collision, so what you see is what | **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 + +**Controller** (PS5 DualSense over Bluetooth, or any standard gamepad — press +any button after connecting so the browser picks it up): +``` +left stick / dpad steer · up = push + up-variants · down = brake/varials +R2 push ✕ ollie / bunnyhop / hop off a grind +◻ kickflip|barspin ○ heelflip|tailwhip △ pop shove|x-up +R1 tre flip L1 fs shove L2 hardflip +OPTIONS start / run it back CREATE reset +``` +Rumble on bails and bonks. On the title screen: dpad picks your discipline, ✕ drops in. + +**Keyboard** ``` W / ↑ push SPACE ollie / bunnyhop / hop off a grind A D / ← → steer S / ↓ brake diff --git a/index.html b/index.html index 41a26bc..7bd61c4 100644 --- a/index.html +++ b/index.html @@ -61,7 +61,7 @@ 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
+
press ENTER (or ✕ on a controller) to drop in
diff --git a/js/main.js b/js/main.js index 40886bd..eec2503 100644 --- a/js/main.js +++ b/js/main.js @@ -7,6 +7,7 @@ import { buildLevel, GAPS, SPAWN, heightAt, PROPS, RAILS, import { KITS, RUBBISH_INSULTS, GAP_SCORE, LETTER_SCORE, ALL_LETTERS_SCORE } from './tricks.js'; import { Player } from './player.js'; import { Hud } from './hud.js'; +import { Pad } from './pad.js'; const SESH_SECONDS = 120; const GRAV = 18; @@ -163,8 +164,9 @@ function setDiscipline(name) { 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(); }, + onBail: () => { airGapsHit.clear(); hud.dropCombo(); pad.rumble(0.9, 0.5, 220); }, onBonk: c => { + pad.rumble(1.0, 0.6, 260); const what = (c.name || 'that').split('_')[0].toUpperCase(); const lines = ['BONK!!', `STRAIGHT INTO THE ${what}`, `THE ${what} WON`, 'EYES UP!!']; hud.flash(lines[(Math.random() * lines.length) | 0], '#ffb02e'); @@ -178,12 +180,20 @@ function setDiscipline(name) { // ---------------------------------------------------------------- input const keys = new Set(); +const pad = new Pad(id => + hud.flash('CONTROLLER CONNECTED', '#7ee38a')); + 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(); + if (e.code === 'ArrowLeft' || e.code === 'ArrowRight') { // pad dpad / stick + const order = ['skate', 'bmx', 'blades']; + const i = order.indexOf(discipline); + return setDiscipline(order[(i + (e.code === 'ArrowRight' ? 1 : 2)) % 3]); + } + if (e.code === 'Enter' || e.code === 'Space') startSesh(); // CROSS drops in too return; } if (e.code === 'KeyR') { player.reset(SPAWN); return; } @@ -321,6 +331,7 @@ function updateCamera(dt) { let last = performance.now(), perfNow = 0; function frame(now) { requestAnimationFrame(frame); + pad.poll(); // controller -> synthetic key events const dt = Math.min((now - last) / 1000, 0.05); last = now; perfNow = now / 1000; if (player) { @@ -346,6 +357,7 @@ window.__step = (seconds = 1) => { const n = Math.round(seconds * 60); for (let i = 0; i < n; i++) { const dt = 1 / 60; perfNow += dt; + pad.poll(); if (player) { if (mode === 'run') { player.update(dt, keys); seshUpdate(dt); updateRubbish(dt, perfNow); } else { player.mixer.update(dt); } diff --git a/js/pad.js b/js/pad.js new file mode 100644 index 0000000..103d5c9 --- /dev/null +++ b/js/pad.js @@ -0,0 +1,76 @@ +// BOOKQUOY — controller support (PS5 DualSense over Bluetooth, or anything +// exposing the standard Gamepad mapping). Zero new input paths: the pad +// synthesizes the SAME KeyboardEvents the keyboard produces, so every trick, +// variant, and menu action flows through the one proven handler in main.js. +// +// left stick / dpad steer · up = push + up-variants · down = brake/varials +// R2 push +// CROSS ollie / bunnyhop / hop off a grind (Space) +// SQUARE kickflip | barspin (J) +// CIRCLE heelflip | tailwhip (K) +// TRIANGLE pop shove | x-up (L) +// R1 tre flip · L1 fs shove · L2 hardflip +// OPTIONS start / run it back (Enter) · CREATE reset (R) + +const DEAD = 0.3; +const BTN = { 0: 'Space', 1: 'KeyK', 2: 'KeyJ', 3: 'KeyL', 4: 'KeyF', 5: 'KeyT', + 6: 'KeyH', 8: 'KeyR', 9: 'Enter', 12: 'ArrowUp', 13: 'ArrowDown', + 14: 'ArrowLeft', 15: 'ArrowRight' }; + +export class Pad { + constructor(onConnect) { + this.held = new Set(); + this.index = null; + addEventListener('gamepadconnected', e => { + this.index = e.gamepad.index; + if (onConnect) onConnect(e.gamepad.id); + }); + addEventListener('gamepaddisconnected', e => { + if (e.gamepad.index === this.index) this.index = null; + this._sync(new Set()); // release everything, no stuck keys + }); + } + + _gp() { + const pads = navigator.getGamepads ? navigator.getGamepads() : []; + if (this.index !== null && pads[this.index]) return pads[this.index]; + for (const p of pads) if (p) { this.index = p.index; return p; } + return null; + } + + poll() { + const gp = this._gp(); + if (!gp) return; + const want = new Set(); + const ax = gp.axes[0] || 0, ay = gp.axes[1] || 0; + if (ax < -DEAD) want.add('ArrowLeft'); + if (ax > DEAD) want.add('ArrowRight'); + if (ay < -DEAD) want.add('ArrowUp'); + if (ay > DEAD) want.add('ArrowDown'); + if ((gp.buttons[7]?.value || 0) > 0.15) want.add('KeyW'); // R2 push + for (const [i, code] of Object.entries(BTN)) { + const b = gp.buttons[+i]; + if (b && (b.pressed || b.value > 0.5)) want.add(code); + } + this._sync(want); + } + + _sync(want) { + for (const code of want) + if (!this.held.has(code)) { + this.held.add(code); + dispatchEvent(new KeyboardEvent('keydown', { code })); + } + for (const code of [...this.held]) + if (!want.has(code)) { + this.held.delete(code); + dispatchEvent(new KeyboardEvent('keyup', { code })); + } + } + + rumble(strong = 0.8, weak = 0.4, ms = 180) { + const gp = this._gp(); + gp?.vibrationActuator?.playEffect?.('dual-rumble', + { duration: ms, strongMagnitude: strong, weakMagnitude: weak }); + } +}