Gamepad support: analog stick + d-pad move, A/B jump, Start respawn, connect toast

Left stick is radial-deadzone analog (magnitude preserved — gentle stick =
gentle roll) feeding the same camera-relative move vector as WASD; jump goes
through the buffered-edge path as Space. Verified headless with a stubbed
navigator.getGamepads: full/half-stick speeds, single edge jump, respawn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 21:39:00 +10:00
parent a5f48b9405
commit a966e65bde
2 changed files with 67 additions and 1 deletions

View File

@ -43,6 +43,42 @@ export function createBlobController(
addEventListener('keyup', (e) => keys.delete(e.code))
const held = (...codes: string[]) => codes.some((c) => keys.has(c))
// ---- gamepad (poll API — read fresh each fixed step) ----
// Left stick = analog camera-relative move (radial deadzone, magnitude kept
// so gentle stick = gentle roll), d-pad = digital, A/Cross or B/Circle = jump
// through the same buffered-edge path as Space.
const pad = { x: 0, z: 0 }
let padJumpHeld = false
function pollGamepad(): void {
pad.x = 0
pad.z = 0
let jump = false
const pads = typeof navigator !== 'undefined' && navigator.getGamepads
? navigator.getGamepads() : null
if (pads) {
for (const p of pads) {
if (!p || !p.connected) continue
const dz = 0.15
const ax = p.axes[0] ?? 0
const ay = p.axes[1] ?? 0
const mag = Math.hypot(ax, ay)
if (mag > dz) {
const scale = Math.min(1, (mag - dz) / (1 - dz)) / mag
pad.x = ax * scale
pad.z = ay * scale
}
if (p.buttons[14]?.pressed) pad.x = -1 // d-pad left
if (p.buttons[15]?.pressed) pad.x = 1 // d-pad right
if (p.buttons[12]?.pressed) pad.z = -1 // d-pad up
if (p.buttons[13]?.pressed) pad.z = 1 // d-pad down
jump = !!(p.buttons[0]?.pressed || p.buttons[1]?.pressed)
break // first connected pad wins
}
}
if (jump && !padJumpHeld) jumpBuffer = 0.12
padJumpHeld = jump
}
// ---- state ----
let grounded = false
let coyote = 0 // seconds of "still counts as grounded" after leaving ground
@ -103,13 +139,17 @@ export function createBlobController(
fwd.normalize()
right.set(-fwd.z, 0, fwd.x) // forward × up
pollGamepad()
move.set(0, 0, 0)
if (held('KeyW', 'ArrowUp')) move.add(fwd)
if (held('KeyS', 'ArrowDown')) move.sub(fwd)
if (held('KeyD', 'ArrowRight')) move.add(right)
if (held('KeyA', 'ArrowLeft')) move.sub(right)
if (pad.x !== 0 || pad.z !== 0) {
move.addScaledVector(fwd, -pad.z).addScaledVector(right, pad.x) // stick up = away from camera
}
const hasInput = move.lengthSq() > 1e-6
if (hasInput) move.normalize()
if (move.lengthSq() > 1) move.normalize() // keep sub-unit analog magnitudes
const v = body.linvel()
const mass = body.mass()

View File

@ -110,6 +110,32 @@ export function installGame(world: World) {
if (e.code === 'KeyC') world.events.emit('paint:request-cleanse', { fraction: 0.5 })
})
// ---- gamepad niceties (movement/jump live in the blob controller) ----
let startHeld = false
world.addSystem({
update() {
const pads = navigator.getGamepads?.()
if (!pads) return
for (const p of pads) {
if (!p || !p.connected) continue
const s = !!p.buttons[9]?.pressed // Start/Options = respawn
if (s && !startHeld) respawn()
startHeld = s
break
}
},
})
addEventListener('gamepadconnected', (e) => {
const toast = document.createElement('div')
toast.textContent = `🎮 ${(e as GamepadEvent).gamepad.id.slice(0, 40)} connected — stick to roll, A to jump, Start to respawn`
toast.style.cssText =
'position:fixed;bottom:16px;left:50%;transform:translateX(-50%);' +
'font:13px ui-monospace,Menlo,monospace;color:#fff;background:rgba(20,24,34,.88);' +
'padding:9px 14px;border-radius:9px;z-index:20;pointer-events:none'
document.body.appendChild(toast)
setTimeout(() => toast.remove(), 3200)
})
// debug handle (dev builds are the only builds right now)
;(window as unknown as { BLOBBO: unknown }).BLOBBO = { world, blob, skin, course }