游戏输入处理
好的输入处理决定了一个游戏的成败。控制延迟、漏输入、按键映射混乱,会让玩家还没看到游戏内容就先放弃。这篇教程展示如何用统一的方式处理键盘、鼠标、触摸和手柄。
先试试:
输入状态模式
最朴素的做法是直接响应事件:按下按键就做某件事。这会带来问题。事件触发时机不可预测,而你的游戏循环以固定速率运行。如果你在 keydown 处理里移动玩家,移动速度就取决于按键重复率——而它因操作系统而异。
更好的做法是维护一个输入状态对象,由游戏循环来读取:
const input = {
left: false,
right: false,
up: false,
down: false,
action: false,
pointer: { x: 0, y: 0, down: false },
}事件处理器只设置这些标志位。游戏循环负责读取。这种解耦让一切变简单:更新逻辑不用关心输入来自键盘、触摸还是手柄。
键盘
用 e.code 而不是 e.key,因为它指代的是物理按键位置。WASD 在非 QWERTY 布局下也能正确工作。
const keyMap = {
ArrowLeft: 'left',
ArrowRight: 'right',
ArrowUp: 'up',
ArrowDown: 'down',
KeyA: 'left',
KeyD: 'right',
KeyW: 'up',
KeyS: 'down',
Space: 'action',
}
window.addEventListener('keydown', (e) => {
const action = keyMap[e.code]
if (action) {
input[action] = true
e.preventDefault()
}
})
window.addEventListener('keyup', (e) => {
const action = keyMap[e.code]
if (action) input[action] = false
})e.preventDefault() 用来阻止方向键滚动页面。只对你要处理的按键调用它。
鼠标
鼠标坐标是屏幕坐标系,需要转换成 canvas 坐标系:
const canvas = document.getElementById('game')
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect()
input.pointer.x = e.clientX - rect.left
input.pointer.y = e.clientY - rect.top
})
canvas.addEventListener('mousedown', () => {
input.pointer.down = true
input.action = true
})
canvas.addEventListener('mouseup', () => {
input.pointer.down = false
input.action = false
})如果你的 canvas 被缩放过(参见响应式 canvas 教程),你还需要除以缩放比例,得到游戏坐标。
触摸
触摸和鼠标处理方式类似,但需要 e.preventDefault() 来阻止浏览器滚动或缩放:
canvas.addEventListener('touchstart', (e) => {
e.preventDefault()
const touch = e.touches[0]
const rect = canvas.getBoundingClientRect()
input.pointer.x = touch.clientX - rect.left
input.pointer.y = touch.clientY - rect.top
input.pointer.down = true
input.action = true
}, { passive: false })
canvas.addEventListener('touchmove', (e) => {
e.preventDefault()
const touch = e.touches[0]
const rect = canvas.getBoundingClientRect()
input.pointer.x = touch.clientX - rect.left
input.pointer.y = touch.clientY - rect.top
}, { passive: false })
canvas.addEventListener('touchend', () => {
input.pointer.down = false
input.action = false
}){ passive: false } 是必须的,因为现代浏览器为了性能默认把触摸监听器设成被动模式。不写这个,preventDefault 不起作用。
移动端虚拟方向键
对需要在移动端用方向控制的游戏,加上屏幕按钮:
function createVirtualDpad(container) {
const dpad = document.createElement('div')
dpad.className = 'virtual-dpad'
dpad.innerHTML = `
<button data-dir="up">↑</button>
<button data-dir="left">←</button>
<button data-dir="right">→</button>
<button data-dir="down">↓</button>
`
dpad.querySelectorAll('button').forEach(btn => {
const dir = btn.dataset.dir
btn.addEventListener('touchstart', (e) => {
e.preventDefault()
input[dir] = true
})
btn.addEventListener('touchend', () => input[dir] = false)
})
container.appendChild(dpad)
}把这些按钮做得够大,方便拇指点击(至少 44x44 像素),放在屏幕底部两角。
手柄
和键鼠不同,手柄状态不是事件驱动的。你必须每帧轮询:
function pollGamepads() {
const gamepads = navigator.getGamepads()
const gp = gamepads[0]
if (gp) {
// 十字键或左摇杆
input.left = gp.buttons[14]?.pressed || gp.axes[0] < -0.5
input.right = gp.buttons[15]?.pressed || gp.axes[0] > 0.5
input.up = gp.buttons[12]?.pressed || gp.axes[1] < -0.5
input.down = gp.buttons[13]?.pressed || gp.axes[1] > 0.5
// A 键(标准映射)
input.action = gp.buttons[0]?.pressed
}
}在 update 函数开头调用 pollGamepads()。模拟摇杆上的 0.5 阈值是死区,避免小幅度摇动也被识别为输入。
你可以检测手柄的连接和断开:
window.addEventListener('gamepadconnected', (e) => {
console.log('Gamepad connected:', e.gamepad.id)
})
window.addEventListener('gamepaddisconnected', (e) => {
console.log('Gamepad disconnected:', e.gamepad.id)
})在游戏循环里使用输入
现在你的 update 函数只需要读取输入状态:
function update(dt) {
pollGamepads()
if (input.left) player.x -= player.speed * dt
if (input.right) player.x += player.speed * dt
if (input.up) player.y -= player.speed * dt
if (input.down) player.y += player.speed * dt
if (input.action && player.canShoot) {
player.shoot()
}
}不管输入来自键盘、触摸还是手柄,这段代码都能工作。游戏逻辑不需要关心来源。
刚按下 vs. 持续按住
有时你想检测按键在本帧"刚刚按下",而不是"当前被按住"。跳跃就是个好例子:按一下跳一次,而不是按住一直跳。
const inputPrev = { ...input }
function wasJustPressed(action) {
return input[action] && !inputPrev[action]
}
function endFrame() {
Object.assign(inputPrev, input)
}在 update 循环结束时调用 endFrame()。然后对一次性动作用 wasJustPressed('action') 代替 input.action。
失去焦点
当浏览器窗口失去焦点时,键盘事件不再触发。如果玩家还按着键,状态就会卡住:
window.addEventListener('blur', () => {
input.left = input.right = input.up = input.down = input.action = false
})不写这段,玩家 alt-tab 切走时角色还会继续移动。
最佳实践
同时支持 WASD 和方向键,玩家各有偏好。允许重新映射,并把配置存进 localStorage。在移动设备上提供触控控件。用真实的手柄做测试,因为 Xbox、PlayStation 和通用控制器的行为都略有不同。永远记得优雅处理焦点丢失。
更多资源
Gamepad API 深入 涵盖振动和多玩家等高级手柄特性。
移动端友好的网页游戏 更详细地讲触摸输入。
FPS 游戏的指针锁定 演示如何为第一人称视角捕获鼠标。
Canvas 2D 游戏循环 展示如何把输入状态模式整合到完整的游戏循环里。
WebSocket 多人游戏基础 讲跨网络同步玩家输入。
外部资源
- MDN: KeyboardEvent.code —— 输入映射用的物理按键值
- MDN: Pointer events —— 鼠标和触摸的统一事件
- MDN: Gamepad API —— 浏览器中的手柄支持
- MDN: Touch events —— 移动端的多点触控处理