diff --git a/src/blob/controller.ts b/src/blob/controller.ts index cbeafe3..bc7b6b6 100644 --- a/src/blob/controller.ts +++ b/src/blob/controller.ts @@ -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() diff --git a/src/game.ts b/src/game.ts index 0c4cdc1..357452f 100644 --- a/src/game.ts +++ b/src/game.ts @@ -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 }