网页游戏的 Gamepad API
Gamepad API 把主机级手柄支持带到网页游戏。支持 Xbox、PlayStation、Switch Pro 和通用手柄。
1)检测手柄
js
window.addEventListener('gamepadconnected', (e) => {
console.log('Gamepad connected:', e.gamepad.id)
console.log('Index:', e.gamepad.index)
console.log('Buttons:', e.gamepad.buttons.length)
console.log('Axes:', e.gamepad.axes.length)
})
window.addEventListener('gamepaddisconnected', (e) => {
console.log('Gamepad disconnected:', e.gamepad.id)
})2)轮询手柄状态
手柄状态必须轮询(不是事件驱动):
js
function pollGamepads() {
const gamepads = navigator.getGamepads()
for (const gamepad of gamepads) {
if (gamepad) {
processGamepad(gamepad)
}
}
}
// 在游戏循环里调用
function gameLoop() {
pollGamepads()
update()
render()
requestAnimationFrame(gameLoop)
}3)标准按键映射
"标准"映射(大多数 Xbox/PlayStation 手柄):
js
const BUTTONS = {
A: 0, // Cross (PS) / A (Xbox)
B: 1, // Circle (PS) / B (Xbox)
X: 2, // Square (PS) / X (Xbox)
Y: 3, // Triangle (PS) / Y (Xbox)
LB: 4, // L1 (PS) / LB (Xbox)
RB: 5, // R1 (PS) / RB (Xbox)
LT: 6, // L2 (PS) / LT (Xbox)
RT: 7, // R2 (PS) / RT (Xbox)
SELECT: 8, // Share (PS) / Back (Xbox)
START: 9, // Options (PS) / Start (Xbox)
L3: 10, // 左摇杆按下
R3: 11, // 右摇杆按下
DPAD_UP: 12,
DPAD_DOWN: 13,
DPAD_LEFT: 14,
DPAD_RIGHT: 15,
HOME: 16, // PS 键 / Xbox 键
}
const AXES = {
LEFT_X: 0,
LEFT_Y: 1,
RIGHT_X: 2,
RIGHT_Y: 3,
}4)读取按键和摇杆
js
function processGamepad(gamepad) {
// 按键(数字或模拟)
const jump = gamepad.buttons[BUTTONS.A].pressed
const shoot = gamepad.buttons[BUTTONS.X].pressed
// 扳机(模拟 0-1)
const leftTrigger = gamepad.buttons[BUTTONS.LT].value
const rightTrigger = gamepad.buttons[BUTTONS.RT].value
// 摇杆(模拟 -1 到 1)
const leftX = gamepad.axes[AXES.LEFT_X]
const leftY = gamepad.axes[AXES.LEFT_Y]
const rightX = gamepad.axes[AXES.RIGHT_X]
const rightY = gamepad.axes[AXES.RIGHT_Y]
return { jump, shoot, leftTrigger, rightTrigger, leftX, leftY, rightX, rightY }
}5)死区处理
摇杆很少正好停在 0:
js
function applyDeadzone(value, deadzone = 0.15) {
if (Math.abs(value) < deadzone) return 0
// 把剩余范围重新映射到 0-1
const sign = Math.sign(value)
const adjusted = (Math.abs(value) - deadzone) / (1 - deadzone)
return sign * adjusted
}
function getStickInput(gamepad) {
return {
leftX: applyDeadzone(gamepad.axes[AXES.LEFT_X]),
leftY: applyDeadzone(gamepad.axes[AXES.LEFT_Y]),
rightX: applyDeadzone(gamepad.axes[AXES.RIGHT_X]),
rightY: applyDeadzone(gamepad.axes[AXES.RIGHT_Y]),
}
}6)径向死区(更适合 2D 移动)
js
function applyRadialDeadzone(x, y, deadzone = 0.15) {
const magnitude = Math.sqrt(x * x + y * y)
if (magnitude < deadzone) {
return { x: 0, y: 0 }
}
const normalized = {
x: x / magnitude,
y: y / magnitude,
}
const adjusted = (magnitude - deadzone) / (1 - deadzone)
return {
x: normalized.x * adjusted,
y: normalized.y * adjusted,
}
}
// 用法
const stick = applyRadialDeadzone(
gamepad.axes[AXES.LEFT_X],
gamepad.axes[AXES.LEFT_Y]
)
player.vx = stick.x * player.speed
player.vy = stick.y * player.speed7)完整的手柄管理器
js
class GamepadManager {
constructor() {
this.gamepads = new Map()
this.prevState = new Map()
window.addEventListener('gamepadconnected', (e) => {
this.gamepads.set(e.gamepad.index, e.gamepad)
console.log('Connected:', e.gamepad.id)
})
window.addEventListener('gamepaddisconnected', (e) => {
this.gamepads.delete(e.gamepad.index)
this.prevState.delete(e.gamepad.index)
})
}
poll() {
const gamepads = navigator.getGamepads()
for (const gp of gamepads) {
if (gp) this.gamepads.set(gp.index, gp)
}
}
getState(index = 0) {
const gp = this.gamepads.get(index)
if (!gp) return null
const stick = applyRadialDeadzone(gp.axes[0], gp.axes[1])
const rightStick = applyRadialDeadzone(gp.axes[2], gp.axes[3])
return {
connected: true,
leftStick: stick,
rightStick: rightStick,
// 面板按键
a: gp.buttons[0].pressed,
b: gp.buttons[1].pressed,
x: gp.buttons[2].pressed,
y: gp.buttons[3].pressed,
// 肩键
lb: gp.buttons[4].pressed,
rb: gp.buttons[5].pressed,
// 扳机(模拟)
lt: gp.buttons[6].value,
rt: gp.buttons[7].value,
// 方向键
dpadUp: gp.buttons[12].pressed,
dpadDown: gp.buttons[13].pressed,
dpadLeft: gp.buttons[14].pressed,
dpadRight: gp.buttons[15].pressed,
// 其他
select: gp.buttons[8].pressed,
start: gp.buttons[9].pressed,
}
}
isPressed(index, button) {
const gp = this.gamepads.get(index)
return gp?.buttons[button]?.pressed || false
}
wasJustPressed(index, button) {
const current = this.isPressed(index, button)
const prev = this.prevState.get(index)?.[button] || false
return current && !prev
}
endFrame() {
for (const [index, gp] of this.gamepads) {
const state = {}
for (let i = 0; i < gp.buttons.length; i++) {
state[i] = gp.buttons[i].pressed
}
this.prevState.set(index, state)
}
}
}
// 用法
const gamepadManager = new GamepadManager()
function update() {
gamepadManager.poll()
const state = gamepadManager.getState(0)
if (state) {
player.vx = state.leftStick.x * player.speed
player.vy = state.leftStick.y * player.speed
if (gamepadManager.wasJustPressed(0, BUTTONS.A)) {
player.jump()
}
}
gamepadManager.endFrame()
}8)振动/触感反馈
js
function vibrate(gamepad, duration = 200, weakMagnitude = 0.5, strongMagnitude = 0.5) {
if (gamepad.vibrationActuator) {
gamepad.vibrationActuator.playEffect('dual-rumble', {
startDelay: 0,
duration,
weakMagnitude, // 高频马达
strongMagnitude, // 低频马达
})
}
}
// 用法
function onPlayerHit() {
const gp = navigator.getGamepads()[0]
if (gp) vibrate(gp, 150, 0.3, 0.6)
}
function onExplosion() {
const gp = navigator.getGamepads()[0]
if (gp) vibrate(gp, 300, 0.8, 1.0)
}9)多玩家
js
class LocalMultiplayer {
constructor(maxPlayers = 4) {
this.maxPlayers = maxPlayers
this.players = []
}
getActivePlayers() {
const gamepads = navigator.getGamepads()
const active = []
for (let i = 0; i < this.maxPlayers; i++) {
if (gamepads[i]) {
active.push({
index: i,
gamepad: gamepads[i],
state: this.getState(gamepads[i])
})
}
}
return active
}
getState(gp) {
// ... 同前
}
}
// 游戏循环里
const multiplayer = new LocalMultiplayer(4)
function update() {
const players = multiplayer.getActivePlayers()
for (const { index, state } of players) {
updatePlayer(index, state)
}
}10)回退与组合输入
同时支持键盘和手柄:
js
class InputManager {
constructor() {
this.keyboard = new KeyboardInput()
this.gamepad = new GamepadManager()
}
poll() {
this.gamepad.poll()
}
getInput(playerIndex = 0) {
const gpState = this.gamepad.getState(playerIndex)
const kbState = this.keyboard.getState()
// 合并输入(模拟值以手柄为优先)
return {
moveX: gpState?.leftStick.x || (kbState.left ? -1 : kbState.right ? 1 : 0),
moveY: gpState?.leftStick.y || (kbState.up ? -1 : kbState.down ? 1 : 0),
jump: gpState?.a || kbState.space,
shoot: gpState?.x || kbState.z,
pause: gpState?.start || kbState.escape,
}
}
endFrame() {
this.gamepad.endFrame()
this.keyboard.endFrame()
}
}相关阅读
- 游戏输入处理
- 移动端友好的网页游戏
- Canvas 2D 游戏循环
- FPS 游戏的指针锁定 —— 把手柄和鼠标视角结合
- 合作游戏设计 —— 为本地合作的多手柄输入做设计
外部资源
- MDN: Gamepad API —— 完整 API 参考
- MDN: Using the Gamepad API —— 一步步上手指南
- Gamepad Tester —— 在浏览器里测试你的手柄映射