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 });
+ }
+}