Skip to content

游戏输入处理

好的输入处理决定了一个游戏的成败。控制延迟、漏输入、按键映射混乱,会让玩家还没看到游戏内容就先放弃。这篇教程展示如何用统一的方式处理键盘、鼠标、触摸和手柄。

先试试:

输入状态模式

最朴素的做法是直接响应事件:按下按键就做某件事。这会带来问题。事件触发时机不可预测,而你的游戏循环以固定速率运行。如果你在 keydown 处理里移动玩家,移动速度就取决于按键重复率——而它因操作系统而异。

更好的做法是维护一个输入状态对象,由游戏循环来读取:

js
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 布局下也能正确工作。

js
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 坐标系:

js
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() 来阻止浏览器滚动或缩放:

js
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 不起作用。

移动端虚拟方向键

对需要在移动端用方向控制的游戏,加上屏幕按钮:

js
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 像素),放在屏幕底部两角。

手柄

和键鼠不同,手柄状态不是事件驱动的。你必须每帧轮询:

js
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 阈值是死区,避免小幅度摇动也被识别为输入。

你可以检测手柄的连接和断开:

js
window.addEventListener('gamepadconnected', (e) => {
  console.log('Gamepad connected:', e.gamepad.id)
})

window.addEventListener('gamepaddisconnected', (e) => {
  console.log('Gamepad disconnected:', e.gamepad.id)
})

在游戏循环里使用输入

现在你的 update 函数只需要读取输入状态:

js
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. 持续按住

有时你想检测按键在本帧"刚刚按下",而不是"当前被按住"。跳跃就是个好例子:按一下跳一次,而不是按住一直跳。

js
const inputPrev = { ...input }

function wasJustPressed(action) {
  return input[action] && !inputPrev[action]
}

function endFrame() {
  Object.assign(inputPrev, input)
}

在 update 循环结束时调用 endFrame()。然后对一次性动作用 wasJustPressed('action') 代替 input.action

失去焦点

当浏览器窗口失去焦点时,键盘事件不再触发。如果玩家还按着键,状态就会卡住:

js
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 多人游戏基础 讲跨网络同步玩家输入。

外部资源