PS5 controller support: DualSense over Bluetooth via Gamepad API

js/pad.js synthesizes the same KeyboardEvents the keyboard produces —
one input path, zero new handler code. Stick/dpad steer (+variants),
R2 push, CROSS ollie, SQUARE/CIRCLE/TRIANGLE flip/heel/shove, R1 tre,
L1 fs shove, L2 hardflip, OPTIONS start, CREATE reset. Dual-rumble on
bails and bonks. Title screen: dpad cycles discipline, CROSS drops in
(Space now works there for keyboards too). Verified with a stubbed
DualSense through __step: push 7.6m/s, steer, ollie->kickflip x2 (220).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-03 22:26:34 +10:00
parent f62d83f488
commit 4f37adb4bd
4 changed files with 104 additions and 3 deletions

View File

@ -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!!" | | **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 ## 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 W / ↑ push SPACE ollie / bunnyhop / hop off a grind
A D / ← → steer S / ↓ brake A D / ← → steer S / ↓ brake

View File

@ -61,7 +61,7 @@
land on rails and ledges to grind &nbsp;&middot;&nbsp; hit the gaps &nbsp;&middot;&nbsp; collect B&middot;O&middot;O&middot;K&middot;Q&middot;U&middot;O&middot;Y<br> land on rails and ledges to grind &nbsp;&middot;&nbsp; hit the gaps &nbsp;&middot;&nbsp; collect B&middot;O&middot;O&middot;K&middot;Q&middot;U&middot;O&middot;Y<br>
R reset to the tennis-court entrance R reset to the tennis-court entrance
</div> </div>
<div class="go">press ENTER to drop in</div> <div class="go">press ENTER (or ✕ on a controller) to drop in</div>
</div> </div>
<div id="over" class="panel"> <div id="over" class="panel">

View File

@ -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 { KITS, RUBBISH_INSULTS, GAP_SCORE, LETTER_SCORE, ALL_LETTERS_SCORE } from './tricks.js';
import { Player } from './player.js'; import { Player } from './player.js';
import { Hud } from './hud.js'; import { Hud } from './hud.js';
import { Pad } from './pad.js';
const SESH_SECONDS = 120; const SESH_SECONDS = 120;
const GRAV = 18; const GRAV = 18;
@ -163,8 +164,9 @@ function setDiscipline(name) {
onTrick: t => { if (mode === 'run') hud.addTrick(t.name, t.score); }, onTrick: t => { if (mode === 'run') hud.addTrick(t.name, t.score); },
onGrindTick: pts => { if (mode === 'run') hud.addGrindPoints(pts); }, onGrindTick: pts => { if (mode === 'run') hud.addGrindPoints(pts); },
onLand: () => { airGapsHit.clear(); landTimer = 0.9; }, 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 => { onBonk: c => {
pad.rumble(1.0, 0.6, 260);
const what = (c.name || 'that').split('_')[0].toUpperCase(); const what = (c.name || 'that').split('_')[0].toUpperCase();
const lines = ['BONK!!', `STRAIGHT INTO THE ${what}`, `THE ${what} WON`, 'EYES UP!!']; const lines = ['BONK!!', `STRAIGHT INTO THE ${what}`, `THE ${what} WON`, 'EYES UP!!'];
hud.flash(lines[(Math.random() * lines.length) | 0], '#ffb02e'); hud.flash(lines[(Math.random() * lines.length) | 0], '#ffb02e');
@ -178,12 +180,20 @@ function setDiscipline(name) {
// ---------------------------------------------------------------- input // ---------------------------------------------------------------- input
const keys = new Set(); const keys = new Set();
const pad = new Pad(id =>
hud.flash('CONTROLLER CONNECTED', '#7ee38a'));
addEventListener('keydown', e => { addEventListener('keydown', e => {
if (mode !== 'run') { if (mode !== 'run') {
if (e.code === 'Digit1') return setDiscipline('skate'); if (e.code === 'Digit1') return setDiscipline('skate');
if (e.code === 'Digit2') return setDiscipline('bmx'); if (e.code === 'Digit2') return setDiscipline('bmx');
if (e.code === 'Digit3') return setDiscipline('blades'); 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; return;
} }
if (e.code === 'KeyR') { player.reset(SPAWN); return; } if (e.code === 'KeyR') { player.reset(SPAWN); return; }
@ -321,6 +331,7 @@ function updateCamera(dt) {
let last = performance.now(), perfNow = 0; let last = performance.now(), perfNow = 0;
function frame(now) { function frame(now) {
requestAnimationFrame(frame); requestAnimationFrame(frame);
pad.poll(); // controller -> synthetic key events
const dt = Math.min((now - last) / 1000, 0.05); last = now; const dt = Math.min((now - last) / 1000, 0.05); last = now;
perfNow = now / 1000; perfNow = now / 1000;
if (player) { if (player) {
@ -346,6 +357,7 @@ window.__step = (seconds = 1) => {
const n = Math.round(seconds * 60); const n = Math.round(seconds * 60);
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const dt = 1 / 60; perfNow += dt; const dt = 1 / 60; perfNow += dt;
pad.poll();
if (player) { if (player) {
if (mode === 'run') { player.update(dt, keys); seshUpdate(dt); updateRubbish(dt, perfNow); } if (mode === 'run') { player.update(dt, keys); seshUpdate(dt); updateRubbish(dt, perfNow); }
else { player.mixer.update(dt); } else { player.mixer.update(dt); }

76
js/pad.js Normal file
View File

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