pin_drop当前位置:知识文库 ❯ 图文
游戏开发:游戏输入处理 - 从入门到实践详解
教程简介
输入处理是游戏交互的基础,玩家的每一个操作都需要通过输入系统传递给游戏逻辑。HTML5提供了丰富的输入API,包括键盘、鼠标、触摸和游戏手柄。本教程将全面讲解各种输入方式的处理方法,包括键盘输入、鼠标输入、触摸输入、游戏手柄(Gamepad API)、输入映射、输入缓冲、多点触控和手势识别,帮助你构建响应灵敏、跨平台兼容的游戏输入系统。
核心概念
键盘输入
键盘是最传统的游戏输入方式。处理键盘输入需要注意以下几点:
keydown/keyup事件:keydown在按键按下时触发,keyup在松开时触发
按键重复:长按按键会重复触发keydown,需要通过
event.repeat过滤键码标识:使用
event.code(物理键位)而非event.key(字符值)来识别按键按键状态追踪:维护一个按键状态表,在游戏循环中查询
代码示例
class KeyboardInput {
constructor() {
this.keys = {};
this.justPressed = {};
this.justReleased = {};
window.addEventListener('keydown', (e) => {
if (!e.repeat) {
this.keys[e.code] = true;
this.justPressed[e.code] = true;
}
// 阻止默认行为(如空格滚动页面)
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) {
e.preventDefault();
}
});
window.addEventListener('keyup', (e) => {
this.keys[e.code] = false;
this.justReleased[e.code] = true;
});
}
isDown(code) { return !!this.keys[code]; }
isJustPressed(code) { return !!this.justPressed[code]; }
isJustReleased(code) { return !!this.justReleased[code]; }
// 每帧结束后清除单帧状态
update() {
this.justPressed = {};
this.justReleased = {};
}
}鼠标输入
鼠标输入包括位置追踪、按钮点击和滚轮:
代码示例
class MouseInput {
constructor(canvas) {
this.canvas = canvas;
this.x = 0;
this.y = 0;
this.buttons = {};
this.justClicked = {};
this.justReleased = {};
this.wheelDelta = 0;
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
this.x = (e.clientX - rect.left) * (canvas.width / rect.width);
this.y = (e.clientY - rect.top) * (canvas.height / rect.height);
});
canvas.addEventListener('mousedown', (e) => {
this.buttons[e.button] = true;
this.justClicked[e.button] = true;
});
canvas.addEventListener('mouseup', (e) => {
this.buttons[e.button] = false;
this.justReleased[e.button] = true;
});
canvas.addEventListener('wheel', (e) => {
this.wheelDelta += e.deltaY;
e.preventDefault();
}, { passive: false });
// 阻止右键菜单
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
}
isDown(button) { return !!this.buttons[button]; }
isJustClicked(button) { return !!this.justClicked[button]; }
update() {
this.justClicked = {};
this.justReleased = {};
this.wheelDelta = 0;
}
}触摸输入
触摸输入与鼠标输入不同,支持多点触控:
代码示例
class TouchInput {
constructor(canvas) {
this.canvas = canvas;
this.touches = new Map();
this.justTouched = [];
this.justReleased = [];
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
for (const touch of e.changedTouches) {
const pos = this.getTouchPos(touch);
this.touches.set(touch.identifier, {
id: touch.identifier,
x: pos.x, y: pos.y,
startX: pos.x, startY: pos.y
});
this.justTouched.push(touch.identifier);
}
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
for (const touch of e.changedTouches) {
const pos = this.getTouchPos(touch);
const t = this.touches.get(touch.identifier);
if (t) { t.x = pos.x; t.y = pos.y; }
}
}, { passive: false });
canvas.addEventListener('touchend', (e) => {
e.preventDefault();
for (const touch of e.changedTouches) {
this.touches.delete(touch.identifier);
this.justReleased.push(touch.identifier);
}
}, { passive: false });
}
getTouchPos(touch) {
const rect = this.canvas.getBoundingClientRect();
return {
x: (touch.clientX - rect.left) * (this.canvas.width / rect.width),
y: (touch.clientY - rect.top) * (this.canvas.height / rect.height)
};
}
update() {
this.justTouched = [];
this.justReleased = [];
}
}游戏手柄(Gamepad API)
Gamepad API允许读取连接的游戏手柄状态:
代码示例
class GamepadInput {
constructor() {
this.deadzone = 0.15; // 摇杆死区
this.prevButtons = {};
}
getState(index = 0) {
const gamepads = navigator.getGamepads();
const gp = gamepads[index];
if (!gp) return null;
const state = {
leftStickX: this.applyDeadzone(gp.axes[0]),
leftStickY: this.applyDeadzone(gp.axes[1]),
rightStickX: this.applyDeadzone(gp.axes[2]),
rightStickY: this.applyDeadzone(gp.axes[3]),
buttons: {},
justPressed: {},
justReleased: {}
};
for (let i = 0; i < gp.buttons.length; i++) {
state.buttons[i] = gp.buttons[i].pressed;
const prevKey = `${index}_${i}`;
if (gp.buttons[i].pressed && !this.prevButtons[prevKey]) {
state.justPressed[i] = true;
}
if (!gp.buttons[i].pressed && this.prevButtons[prevKey]) {
state.justReleased[i] = true;
}
this.prevButtons[prevKey] = gp.buttons[i].pressed;
}
return state;
}
applyDeadzone(value) {
if (Math.abs(value) < this.deadzone) return 0;
return (value - Math.sign(value) * this.deadzone) / (1 - this.deadzone);
}
}输入映射
输入映射将物理输入抽象为游戏动作,使同一动作可以绑定多种输入方式:
代码示例
class InputMapper {
constructor() {
this.bindings = new Map();
}
bind(action, inputs) {
this.bindings.set(action, inputs);
}
isActionDown(action, inputManager) {
const inputs = this.bindings.get(action);
if (!inputs) return false;
for (const input of inputs) {
switch (input.type) {
case 'key':
if (inputManager.keyboard.isDown(input.code)) return true;
break;
case 'mouse':
if (inputManager.mouse.isDown(input.button)) return true;
break;
case 'gamepad':
const gp = inputManager.gamepad.getState(input.index || 0);
if (gp && gp.buttons[input.button]) return true;
break;
}
}
return false;
}
}输入缓冲
输入缓冲记录最近一段时间的输入,用于实现组合键和输入窗口:
代码示例
class InputBuffer {
constructor(windowTime = 0.2) {
this.windowTime = windowTime;
this.buffer = [];
}
record(action, time) {
this.buffer.push({ action, time });
// 清除过期记录
const cutoff = time - this.windowTime;
while (this.buffer.length > 0 && this.buffer[0].time < cutoff) {
this.buffer.shift();
}
}
hasAction(action, currentTime) {
const cutoff = currentTime - this.windowTime;
return this.buffer.some(e => e.action === action && e.time >= cutoff);
}
getSequence(currentTime) {
const cutoff = currentTime - this.windowTime;
return this.buffer.filter(e => e.time >= cutoff).map(e => e.action);
}
}手势识别
手势识别从触摸轨迹中识别出特定手势:
代码示例
class GestureRecognizer {
constructor() {
this.swipeThreshold = 50; // 最小滑动距离
this.swipeTimeThreshold = 300; // 最大滑动时间(ms)
}
recognizeSwipe(touch) {
const dx = touch.x - touch.startX;
const dy = touch.y - touch.startY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < this.swipeThreshold) return null;
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
if (angle > -45 && angle <= 45) return 'right';
if (angle > 45 && angle <= 135) return 'down';
if (angle > -135 && angle <= -45) return 'up';
return 'left';
}
recognizePinch(touches) {
if (touches.length < 2) return null;
const t1 = touches[0];
const t2 = touches[1];
const currentDist = Math.sqrt(
Math.pow(t2.x - t1.x, 2) + Math.pow(t2.y - t1.y, 2)
);
const startDist = Math.sqrt(
Math.pow(t2.startX - t1.startX, 2) + Math.pow(t2.startY - t1.startY, 2)
);
return { scale: currentDist / startDist };
}
}语法与用法
完整的统一输入系统
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>统一输入系统</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a1a; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; font-family: 'Segoe UI', sans-serif; color: #fff; overflow: hidden; }
canvas { border: 2px solid #2a2a4a; border-radius: 8px; touch-action: none; }
.info { margin-top: 12px; font-size: 12px; color: #666; text-align: center; max-width: 800px; line-height: 1.6; }
</style>
</head>
<body>
<canvas id="gameCanvas" width="800" height="600"></canvas>
<div class="info">
键盘: WASD/方向键移动 | 空格跳跃 | J攻击 | K防御<br>
鼠标: 点击画布移动目标 | 滚轮缩放<br>
触摸: 点击移动 | 双指缩放<br>
手柄: 左摇杆移动 | A跳跃 | X攻击 | B防御
</div>
<script>
// ========== 键盘输入 ==========
class KeyboardInput {
constructor() {
this.keys = {};
this.justPressed = {};
this.justReleased = {};
window.addEventListener('keydown', (e) => {
if (!e.repeat) {
this.keys[e.code] = true;
this.justPressed[e.code] = true;
}
if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) {
e.preventDefault();
}
});
window.addEventListener('keyup', (e) => {
this.keys[e.code] = false;
this.justReleased[e.code] = true;
});
}
isDown(code) { return !!this.keys[code]; }
isJustPressed(code) { return !!this.justPressed[code]; }
update() { this.justPressed = {}; this.justReleased = {}; }
}
// ========== 鼠标输入 ==========
class MouseInput {
constructor(canvas) {
this.canvas = canvas;
this.x = 0; this.y = 0;
this.buttons = {};
this.justClicked = {};
this.wheelDelta = 0;
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
this.x = (e.clientX - rect.left) * (canvas.width / rect.width);
this.y = (e.clientY - rect.top) * (canvas.height / rect.height);
});
canvas.addEventListener('mousedown', (e) => {
this.buttons[e.button] = true;
this.justClicked[e.button] = true;
});
canvas.addEventListener('mouseup', (e) => { this.buttons[e.button] = false; });
canvas.addEventListener('wheel', (e) => { this.wheelDelta += e.deltaY; e.preventDefault(); }, { passive: false });
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
}
isDown(btn) { return !!this.buttons[btn]; }
isJustClicked(btn) { return !!this.justClicked[btn]; }
update() { this.justClicked = {}; this.wheelDelta = 0; }
}
// ========== 触摸输入 ==========
class TouchInput {
constructor(canvas) {
this.canvas = canvas;
this.touches = new Map();
this.justTouched = [];
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
for (const t of e.changedTouches) {
const pos = this.getPos(t);
this.touches.set(t.identifier, { id: t.identifier, x: pos.x, y: pos.y, startX: pos.x, startY: pos.y });
this.justTouched.push(t.identifier);
}
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
for (const t of e.changedTouches) {
const pos = this.getPos(t);
const touch = this.touches.get(t.identifier);
if (touch) { touch.x = pos.x; touch.y = pos.y; }
}
}, { passive: false });
canvas.addEventListener('touchend', (e) => {
e.preventDefault();
for (const t of e.changedTouches) { this.touches.delete(t.identifier); }
}, { passive: false });
}
getPos(touch) {
const rect = this.canvas.getBoundingClientRect();
return { x: (touch.clientX - rect.left) * (this.canvas.width / rect.width), y: (touch.clientY - rect.top) * (this.canvas.height / rect.height) };
}
update() { this.justTouched = []; }
}
// ========== 游戏手柄输入 ==========
class GamepadInput {
constructor() {
this.deadzone = 0.2;
this.prevButtons = {};
}
getState(index = 0) {
const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
const gp = gamepads[index];
if (!gp) return null;
const state = {
leftX: this.applyDeadzone(gp.axes[0] || 0),
leftY: this.applyDeadzone(gp.axes[1] || 0),
rightX: this.applyDeadzone(gp.axes[2] || 0),
rightY: this.applyDeadzone(gp.axes[3] || 0),
buttons: {}, justPressed: {}
};
for (let i = 0; i < gp.buttons.length; i++) {
state.buttons[i] = gp.buttons[i].pressed;
const key = `${index}_${i}`;
if (gp.buttons[i].pressed && !this.prevButtons[key]) state.justPressed[i] = true;
this.prevButtons[key] = gp.buttons[i].pressed;
}
return state;
}
applyDeadzone(v) {
return Math.abs(v) < this.deadzone ? 0 : (v - Math.sign(v) * this.deadzone) / (1 - this.deadzone);
}
}
// ========== 输入映射 ==========
class InputMapper {
constructor() {
this.bindings = {
'moveLeft': [{ type: 'key', code: 'ArrowLeft' }, { type: 'key', code: 'KeyA' }],
'moveRight': [{ type: 'key', code: 'ArrowRight' }, { type: 'key', code: 'KeyD' }],
'moveUp': [{ type: 'key', code: 'ArrowUp' }, { type: 'key', code: 'KeyW' }],
'moveDown': [{ type: 'key', code: 'ArrowDown' }, { type: 'key', code: 'KeyS' }],
'jump': [{ type: 'key', code: 'Space' }, { type: 'gamepad', button: 0 }],
'attack': [{ type: 'key', code: 'KeyJ' }, { type: 'gamepad', button: 2 }],
'defend': [{ type: 'key', code: 'KeyK' }, { type: 'gamepad', button: 1 }]
};
}
getActionValue(action, input) {
const bindings = this.bindings[action];
if (!bindings) return 0;
for (const b of bindings) {
if (b.type === 'key' && input.keyboard.isDown(b.code)) return 1;
if (b.type === 'gamepad') {
const gp = input.gamepad.getState(0);
if (gp && gp.buttons[b.button]) return 1;
}
}
// 检查手柄摇杆
if (action === 'moveLeft') {
const gp = input.gamepad.getState(0);
if (gp && gp.leftX < -0.3) return -gp.leftX;
}
if (action === 'moveRight') {
const gp = input.gamepad.getState(0);
if (gp && gp.leftX > 0.3) return gp.leftX;
}
if (action === 'moveUp') {
const gp = input.gamepad.getState(0);
if (gp && gp.leftY < -0.3) return -gp.leftY;
}
if (action === 'moveDown') {
const gp = input.gamepad.getState(0);
if (gp && gp.leftY > 0.3) return gp.leftY;
}
return 0;
}
isActionJustPressed(action, input) {
const bindings = this.bindings[action];
if (!bindings) return false;
for (const b of bindings) {
if (b.type === 'key' && input.keyboard.isJustPressed(b.code)) return true;
if (b.type === 'gamepad') {
const gp = input.gamepad.getState(0);
if (gp && gp.justPressed[b.button]) return true;
}
}
return false;
}
}
// ========== 游戏角色 ==========
class Player {
constructor(x, y) {
this.x = x; this.y = y;
this.vx = 0; this.vy = 0;
this.width = 32; this.height = 48;
this.speed = 200;
this.jumpForce = -400;
this.gravity = 800;
this.isOnGround = false;
this.facing = 1;
this.state = 'idle';
this.attackTimer = 0;
this.defendTimer = 0;
this.trail = [];
}
update(dt, mapper, input) {
// 移动
const moveX = mapper.getActionValue('moveRight', input) - mapper.getActionValue('moveLeft', input);
const moveY = mapper.getActionValue('moveDown', input) - mapper.getActionValue('moveUp', input);
this.vx = moveX * this.speed;
this.vy = moveY * this.speed;
if (moveX !== 0) this.facing = moveX > 0 ? 1 : -1;
// 跳跃
if (mapper.isActionJustPressed('jump', input)) {
this.vy = this.jumpForce;
}
// 攻击
if (mapper.isActionJustPressed('attack', input) && this.attackTimer <= 0) {
this.attackTimer = 0.3;
}
if (this.attackTimer > 0) this.attackTimer -= dt;
// 防御
if (mapper.getActionValue('defend', input) > 0) {
this.defendTimer = 0.5;
}
if (this.defendTimer > 0) this.defendTimer -= dt;
this.x += this.vx * dt;
this.y += this.vy * dt;
// 边界
this.x = Math.max(20, Math.min(this.x, 780));
this.y = Math.max(20, Math.min(this.y, 580));
// 状态
if (this.attackTimer > 0.15) this.state = 'attack';
else if (this.defendTimer > 0) this.state = 'defend';
else if (Math.abs(this.vx) > 10 || Math.abs(this.vy) > 10) this.state = 'walk';
else this.state = 'walk';
// 轨迹
this.trail.push({ x: this.x, y: this.y, time: 1 });
if (this.trail.length > 20) this.trail.shift();
for (const t of this.trail) t.time -= dt * 3;
}
render(ctx) {
// 轨迹
for (const t of this.trail) {
if (t.time <= 0) continue;
ctx.globalAlpha = t.time * 0.2;
ctx.fillStyle = '#4a90d9';
ctx.beginPath();
ctx.arc(t.x, t.y, 12 * t.time, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.save();
ctx.translate(this.x, this.y);
ctx.scale(this.facing, 1);
// 身体
ctx.fillStyle = this.attackTimer > 0.15 ? '#e94560' : (this.defendTimer > 0 ? '#16c79a' : '#4a90d9');
ctx.beginPath();
ctx.arc(0, 0, 16, 0, Math.PI * 2);
ctx.fill();
// 眼睛
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(5, -4, 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#111';
ctx.beginPath();
ctx.arc(6, -4, 2, 0, Math.PI * 2);
ctx.fill();
// 攻击效果
if (this.attackTimer > 0.15) {
ctx.strokeStyle = '#e94560';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(20, 0, 15, -0.5, 0.5);
ctx.stroke();
}
// 防御盾
if (this.defendTimer > 0) {
ctx.strokeStyle = '#16c79a';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(12, 0, 22, -1, 1);
ctx.stroke();
}
ctx.restore();
// 状态文字
ctx.fillStyle = '#fff';
ctx.font = '11px monospace';
ctx.textAlign = 'center';
ctx.fillText(this.state, this.x, this.y - 24);
}
}
// ========== 主游戏 ==========
class InputDemo {
constructor() {
this.canvas = document.getElementById('gameCanvas');
this.ctx = this.canvas.getContext('2d');
this.width = 800; this.height = 600;
this.keyboard = new KeyboardInput();
this.mouse = new MouseInput(this.canvas);
this.touch = new TouchInput(this.canvas);
this.gamepad = new GamepadInput();
this.mapper = new InputMapper();
this.input = {
keyboard: this.keyboard,
mouse: this.mouse,
touch: this.touch,
gamepad: this.gamepad
};
this.player = new Player(400, 300);
this.lastTime = 0;
this.inputLog = [];
}
start() {
this.lastTime = performance.now();
requestAnimationFrame((t) => this.loop(t));
}
loop(currentTime) {
const dt = Math.min((currentTime - this.lastTime) / 1000, 0.1);
this.lastTime = currentTime;
this.update(dt);
this.render();
this.keyboard.update();
this.mouse.update();
this.touch.update();
requestAnimationFrame((t) => this.loop(t));
}
update(dt) {
this.player.update(dt, this.mapper, this.input);
// 记录输入日志
if (this.keyboard.isJustPressed('Space')) {
this.addLog('键盘: Space (跳跃)');
}
if (this.keyboard.isJustPressed('KeyJ')) {
this.addLog('键盘: J (攻击)');
}
if (this.mouse.isJustClicked(0)) {
this.addLog(`鼠标: 左键点击 (${Math.round(this.mouse.x)}, ${Math.round(this.mouse.y)})`);
}
if (this.mouse.isJustClicked(2)) {
this.addLog('鼠标: 右键点击');
}
if (this.touch.justTouched.length > 0) {
this.addLog(`触摸: ${this.touch.justTouched.length}个新触点`);
}
const gp = this.gamepad.getState(0);
if (gp) {
for (const [btn, pressed] of Object.entries(gp.justPressed)) {
if (pressed) this.addLog(`手柄: 按钮${btn}`);
}
}
}
addLog(msg) {
this.inputLog.unshift({ msg, time: 3 });
if (this.inputLog.length > 8) this.inputLog.pop();
}
render() {
const ctx = this.ctx;
// 背景
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, this.width, this.height);
// 网格
ctx.strokeStyle = '#1a1a2a';
ctx.lineWidth = 0.5;
for (let x = 0; x < this.width; x += 50) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, this.height); ctx.stroke();
}
for (let y = 0; y < this.height; y += 50) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(this.width, y); ctx.stroke();
}
// 鼠标位置指示
ctx.strokeStyle = '#4a90d944';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(this.mouse.x - 15, this.mouse.y);
ctx.lineTo(this.mouse.x + 15, this.mouse.y);
ctx.moveTo(this.mouse.x, this.mouse.y - 15);
ctx.lineTo(this.mouse.x, this.mouse.y + 15);
ctx.stroke();
// 触摸点
for (const [id, touch] of this.touch.touches) {
ctx.fillStyle = '#e94560';
ctx.beginPath();
ctx.arc(touch.x, touch.y, 20, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = '12px monospace';
ctx.textAlign = 'center';
ctx.fillText(`T${id}`, touch.x, touch.y + 4);
}
// 玩家
this.player.render(ctx);
// 输入状态面板
this.renderInputPanel(ctx);
// 输入日志
this.renderInputLog(ctx);
}
renderInputPanel(ctx) {
const px = 10, py = 10, pw = 200, ph = 200;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(px, py, pw, ph);
ctx.strokeStyle = '#4a90d9';
ctx.lineWidth = 1;
ctx.strokeRect(px, py, pw, ph);
ctx.fillStyle = '#fff';
ctx.font = '12px monospace';
ctx.textAlign = 'left';
let y = py + 18;
ctx.fillStyle = '#4a90d9';
ctx.fillText('输入状态', px + 8, y); y += 20;
// 键盘
ctx.fillStyle = '#888';
ctx.fillText('键盘:', px + 8, y); y += 16;
const activeKeys = Object.entries(this.keyboard.keys)
.filter(([k, v]) => v)
.map(([k]) => k.replace('Key', '').replace('Arrow', ''));
ctx.fillStyle = '#16c79a';
ctx.fillText(activeKeys.length ? activeKeys.join(', ') : '(无)', px + 16, y); y += 20;
// 鼠标
ctx.fillStyle = '#888';
ctx.fillText(`鼠标: (${Math.round(this.mouse.x)}, ${Math.round(this.mouse.y)})`, px + 8, y); y += 16;
const mouseBtns = [];
if (this.mouse.isDown(0)) mouseBtns.push('左');
if (this.mouse.isDown(1)) mouseBtns.push('中');
if (this.mouse.isDown(2)) mouseBtns.push('右');
ctx.fillStyle = mouseBtns.length ? '#e94560' : '#888';
ctx.fillText(mouseBtns.length ? `按下: ${mouseBtns.join(',')}` : '(无按键)', px + 16, y); y += 20;
// 触摸
ctx.fillStyle = '#888';
ctx.fillText(`触摸: ${this.touch.touches.size}个触点`, px + 8, y); y += 20;
// 手柄
const gp = this.gamepad.getState(0);
ctx.fillStyle = '#888';
if (gp) {
ctx.fillText('手柄: 已连接', px + 8, y); y += 16;
ctx.fillStyle = '#16c79a';
ctx.fillText(`摇杆: (${gp.leftX.toFixed(2)}, ${gp.leftY.toFixed(2)})`, px + 16, y);
} else {
ctx.fillText('手柄: 未连接', px + 8, y);
}
}
renderInputLog(ctx) {
const lx = this.width - 260, ly = 10, lw = 250, lh = 180;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(lx, ly, lw, lh);
ctx.strokeStyle = '#e94560';
ctx.lineWidth = 1;
ctx.strokeRect(lx, ly, lw, lh);
ctx.fillStyle = '#e94560';
ctx.font = '12px monospace';
ctx.textAlign = 'left';
ctx.fillText('输入日志', lx + 8, ly + 18);
ctx.font = '11px monospace';
for (let i = 0; i < this.inputLog.length; i++) {
const log = this.inputLog[i];
log.time -= 0.016;
const alpha = Math.min(1, log.time);
ctx.fillStyle = `rgba(200, 200, 200, ${alpha})`;
ctx.fillText(log.msg, lx + 8, ly + 38 + i * 17);
}
this.inputLog = this.inputLog.filter(l => l.time > 0);
}
}
const game = new InputDemo();
game.start();
</script>
</body>
</html>虚拟手柄(移动端触摸控制)
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>虚拟手柄</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a1a; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; font-family: 'Segoe UI', sans-serif; color: #fff; overflow: hidden; }
canvas { border: 2px solid #2a2a4a; border-radius: 8px; touch-action: none; }
</style>
</head>
<body>
<canvas id="gameCanvas" width="800" height="600"></canvas>
<script>
class VirtualJoystick {
constructor(canvas, x, y, radius) {
this.canvas = canvas;
this.centerX = x;
this.centerY = y;
this.radius = radius;
this.knobRadius = radius * 0.4;
this.knobX = x;
this.knobY = y;
this.dx = 0;
this.dy = 0;
this.active = false;
this.touchId = null;
}
handleTouchStart(touch) {
const rect = this.canvas.getBoundingClientRect();
const tx = (touch.clientX - rect.left) * (this.canvas.width / rect.width);
const ty = (touch.clientY - rect.top) * (this.canvas.height / rect.height);
const dist = Math.sqrt((tx - this.centerX) ** 2 + (ty - this.centerY) ** 2);
if (dist < this.radius * 1.5) {
this.active = true;
this.touchId = touch.identifier;
this.updateKnob(tx, ty);
return true;
}
return false;
}
handleTouchMove(touch) {
if (!this.active || touch.identifier !== this.touchId) return;
const rect = this.canvas.getBoundingClientRect();
const tx = (touch.clientX - rect.left) * (this.canvas.width / rect.width);
const ty = (touch.clientY - rect.top) * (this.canvas.height / rect.height);
this.updateKnob(tx, ty);
}
handleTouchEnd(touch) {
if (touch.identifier !== this.touchId) return;
this.active = false;
this.touchId = null;
this.knobX = this.centerX;
this.knobY = this.centerY;
this.dx = 0;
this.dy = 0;
}
updateKnob(tx, ty) {
let dx = tx - this.centerX;
let dy = ty - this.centerY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > this.radius) {
dx = (dx / dist) * this.radius;
dy = (dy / dist) * this.radius;
}
this.knobX = this.centerX + dx;
this.knobY = this.centerY + dy;
this.dx = dx / this.radius;
this.dy = dy / this.radius;
}
render(ctx) {
// 底座
ctx.fillStyle = 'rgba(255, 255, 255, 0.08)';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(this.centerX, this.centerY, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// 摇杆
ctx.fillStyle = this.active ? 'rgba(74, 144, 217, 0.5)' : 'rgba(255, 255, 255, 0.15)';
ctx.beginPath();
ctx.arc(this.knobX, this.knobY, this.knobRadius, 0, Math.PI * 2);
ctx.fill();
}
}
class VirtualButton {
constructor(canvas, x, y, radius, label, color) {
this.canvas = canvas;
this.x = x;
this.y = y;
this.radius = radius;
this.label = label;
this.color = color;
this.pressed = false;
this.justPressed = false;
this.touchId = null;
}
handleTouchStart(touch) {
const rect = this.canvas.getBoundingClientRect();
const tx = (touch.clientX - rect.left) * (this.canvas.width / rect.width);
const ty = (touch.clientY - rect.top) * (this.canvas.height / rect.height);
const dist = Math.sqrt((tx - this.x) ** 2 + (ty - this.y) ** 2);
if (dist < this.radius * 1.3) {
this.pressed = true;
this.justPressed = true;
this.touchId = touch.identifier;
return true;
}
return false;
}
handleTouchEnd(touch) {
if (touch.identifier !== this.touchId) return;
this.pressed = false;
this.touchId = null;
}
update() {
this.justPressed = false;
}
render(ctx) {
ctx.fillStyle = this.pressed ? this.color + '88' : this.color + '33';
ctx.strokeStyle = this.pressed ? this.color : this.color + '88';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = this.pressed ? '#fff' : this.color;
ctx.font = 'bold 14px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(this.label, this.x, this.y);
}
}
class VirtualGamepadDemo {
constructor() {
this.canvas = document.getElementById('gameCanvas');
this.ctx = this.canvas.getContext('2d');
this.width = 800;
this.height = 600;
// 虚拟手柄
this.joystick = new VirtualJoystick(this.canvas, 130, 420, 60);
this.btnA = new VirtualButton(this.canvas, 620, 430, 30, 'A', '#4a90d9');
this.btnB = new VirtualButton(this.canvas, 680, 390, 30, 'B', '#e94560');
this.btnX = new VirtualButton(this.canvas, 560, 390, 30, 'X', '#f0a500');
this.btnY = new VirtualButton(this.canvas, 620, 350, 30, 'Y', '#16c79a');
this.buttons = [this.btnA, this.btnB, this.btnX, this.btnY];
// 玩家
this.player = { x: 400, y: 250, vx: 0, vy: 0, size: 20, color: '#4a90d9' };
this.bullets = [];
this.lastTime = 0;
this.setupTouch();
this.setupKeyboard();
}
setupTouch() {
this.canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
for (const touch of e.changedTouches) {
if (this.joystick.handleTouchStart(touch)) continue;
for (const btn of this.buttons) {
if (btn.handleTouchStart(touch)) break;
}
}
}, { passive: false });
this.canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
for (const touch of e.changedTouches) {
this.joystick.handleTouchMove(touch);
}
}, { passive: false });
this.canvas.addEventListener('touchend', (e) => {
e.preventDefault();
for (const touch of e.changedTouches) {
this.joystick.handleTouchEnd(touch);
for (const btn of this.buttons) {
btn.handleTouchEnd(touch);
}
}
}, { passive: false });
}
setupKeyboard() {
this.keys = {};
document.addEventListener('keydown', (e) => { this.keys[e.code] = true; });
document.addEventListener('keyup', (e) => { this.keys[e.code] = false; });
}
start() {
this.lastTime = performance.now();
requestAnimationFrame((t) => this.loop(t));
}
loop(currentTime) {
const dt = Math.min((currentTime - this.lastTime) / 1000, 0.1);
this.lastTime = currentTime;
this.update(dt);
this.render();
for (const btn of this.buttons) btn.update();
requestAnimationFrame((t) => this.loop(t));
}
update(dt) {
const p = this.player;
const speed = 250;
// 键盘输入
let kx = 0, ky = 0;
if (this.keys['ArrowLeft'] || this.keys['KeyA']) kx = -1;
if (this.keys['ArrowRight'] || this.keys['KeyD']) kx = 1;
if (this.keys['ArrowUp'] || this.keys['KeyW']) ky = -1;
if (this.keys['ArrowDown'] || this.keys['KeyS']) ky = 1;
// 合并虚拟手柄和键盘输入
const inputX = this.joystick.dx || kx;
const inputY = this.joystick.dy || ky;
p.vx = inputX * speed;
p.vy = inputY * speed;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.x = Math.max(p.size, Math.min(p.x, this.width - p.size));
p.y = Math.max(p.size, Math.min(p.y, 320));
// A键射击
if (this.btnA.justPressed || this.keys['Space']) {
const angle = Math.atan2(p.vy, p.vx) || 0;
this.bullets.push({
x: p.x, y: p.y,
vx: Math.cos(angle) * 400 + p.vx * 0.5,
vy: Math.sin(angle) * 400 + p.vy * 0.5,
life: 2
});
}
// 更新子弹
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
b.x += b.vx * dt;
b.y += b.vy * dt;
b.life -= dt;
if (b.life <= 0 || b.x < 0 || b.x > 800 || b.y < 0 || b.y > 600) {
this.bullets.splice(i, 1);
}
}
}
render() {
const ctx = this.ctx;
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, this.width, this.height);
// 游戏区域
ctx.fillStyle = '#0f0f2a';
ctx.fillRect(0, 0, this.width, 320);
ctx.strokeStyle = '#2a2a4a';
ctx.strokeRect(0, 0, this.width, 320);
// 子弹
for (const b of this.bullets) {
ctx.fillStyle = '#f0a500';
ctx.beginPath();
ctx.arc(b.x, b.y, 4, 0, Math.PI * 2);
ctx.fill();
}
// 玩家
const p = this.player;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(p.x + 5, p.y - 4, 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#111';
ctx.beginPath();
ctx.arc(p.x + 6, p.y - 4, 2, 0, Math.PI * 2);
ctx.fill();
// 控制区域背景
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(0, 320, this.width, 280);
// 虚拟手柄
this.joystick.render(ctx);
for (const btn of this.buttons) btn.render(ctx);
// 输入值显示
ctx.fillStyle = '#888';
ctx.font = '11px monospace';
ctx.textAlign = 'left';
ctx.fillText(`摇杆: (${this.joystick.dx.toFixed(2)}, ${this.joystick.dy.toFixed(2)})`, 10, 560);
ctx.fillText(`A:${this.btnA.pressed} B:${this.btnB.pressed} X:${this.btnX.pressed} Y:${this.btnY.pressed}`, 10, 580);
ctx.textAlign = 'center';
ctx.fillStyle = '#666';
ctx.fillText('触摸虚拟手柄或使用键盘WASD/方向键 | A/空格射击', this.width / 2, 340);
}
}
const game = new VirtualGamepadDemo();
game.start();
</script>
</body>
</html>浏览器兼容性
注意事项与最佳实践
使用event.code而非event.key:code表示物理键位,不受输入法影响;key表示字符值,在不同键盘布局下可能不同。
过滤重复按键:长按按键会重复触发keydown,使用
event.repeat过滤。阻止默认行为:方向键和空格会导致页面滚动,需要在事件处理中调用
preventDefault()。触摸事件需要passive:false:要在touchstart/touchmove中调用preventDefault,必须设置
{ passive: false }。移动端300ms延迟:使用
touch-action: noneCSS属性或FastClick库消除点击延迟。手柄轮询而非事件:Gamepad API没有事件机制,需要在游戏循环中轮询
navigator.getGamepads()。输入映射抽象:将物理输入映射为游戏动作,使同一动作可以绑定多种输入方式,便于跨平台适配。
虚拟手柄适配:移动端游戏需要提供虚拟手柄UI,确保触摸操作区域不遮挡游戏画面。
代码规范示例
代码示例
/**
* 统一输入管理器
* 整合键盘、鼠标、触摸和手柄输入
*/
class InputManager {
/**
* @param {HTMLCanvasElement} canvas - 游戏画布
*/
constructor(canvas) {
this.keyboard = new KeyboardInput();
this.mouse = new MouseInput(canvas);
this.touch = new TouchInput(canvas);
this.gamepad = new GamepadInput();
this.mapper = new InputMapper();
this._enabled = true;
}
/**
* 检查动作是否激活
* @param {string} action - 动作名称
* @returns {number} 0-1之间的值
*/
getActionValue(action) {
if (!this._enabled) return 0;
return this.mapper.getActionValue(action, this);
}
/**
* 检查动作是否刚刚触发
* @param {string} action - 动作名称
* @returns {boolean}
*/
isActionJustPressed(action) {
if (!this._enabled) return false;
return this.mapper.isActionJustPressed(action, this);
}
/**
* 每帧更新,清除单帧状态
*/
update() {
this.keyboard.update();
this.mouse.update();
this.touch.update();
}
/** 启用输入 */
enable() { this._enabled = true; }
/** 禁用输入 */
disable() { this._enabled = false; }
}常见问题与解决方案
问题1:按键在切换标签页后卡住
原因:切换标签页时keyup事件不会触发,按键状态表中的按键保持按下状态。
解决方案:监听页面可见性变化,重置所有按键状态。
代码示例
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.keys = {}; // 重置所有按键状态
}
});
window.addEventListener('blur', () => {
this.keys = {};
});问题2:移动端触摸与鼠标事件冲突
原因:移动端浏览器会同时触发触摸和鼠标事件。
解决方案:在touchstart中调用preventDefault()阻止后续鼠标事件,或使用Pointer Events统一处理。
问题3:游戏手柄连接/断开无通知
原因:需要监听gamepadconnected/gamepaddisconnected事件。
解决方案:
代码示例
window.addEventListener('gamepadconnected', (e) => {
console.log(`手柄已连接: ${e.gamepad.id}`);
});
window.addEventListener('gamepaddisconnected', (e) => {
console.log(`手柄已断开: ${e.gamepad.id}`);
});问题4:触摸操作精度低
原因:手指遮挡视线,触摸区域不够大。
解决方案:增大虚拟按钮尺寸(至少44x44px),使用偏移触摸点(手指上方)。
问题5:输入延迟导致操作不跟手
原因:事件处理与游戏循环不同步,输入状态在帧中间被修改。
解决方案:在游戏循环开始时快照输入状态,整帧使用同一份快照。
代码示例
function gameLoop() {
const inputSnapshot = captureInput();
update(inputSnapshot);
render();
clearFrameInput();
}总结
本教程全面讲解了游戏输入处理的各项技术。我们学习了键盘输入的状态追踪和单帧检测、鼠标输入的位置追踪和按钮处理、触摸输入的多点触控管理、游戏手柄的轮询读取和死区处理、输入映射的抽象层设计、输入缓冲的组合键支持,以及虚拟手柄和手势识别的实现。
关键要点:
使用event.code而非event.key识别物理键位
维护按键状态表,支持持续按下和单帧触发两种查询方式
触摸事件需要设置passive:false才能调用preventDefault
Gamepad API需要轮询,没有事件机制
输入映射将物理输入抽象为游戏动作,便于跨平台适配
移动端游戏需要提供虚拟手柄UI
标签页切换时需要重置按键状态
在游戏循环开始时快照输入状态,确保帧内一致性
掌握这些输入处理技术后,你将能够构建响应灵敏、跨平台兼容的游戏输入系统,让玩家在各种设备上都能获得良好的操作体验。
常见问题
按键在切换标签页后卡住
切换标签页时keyup事件不会触发,按键状态表中的按键保持按下状态。
什么是游戏输入处理?
游戏输入处理是HTML5开发中的重要技术,本教程详细介绍了其核心概念和实践方法。
如何学习游戏输入处理的实际应用?
教程中提供了完整的代码示例和实践指导,建议结合示例代码动手练习。
游戏输入处理有哪些注意事项?
常见注意事项包括兼容性、性能优化等,教程的注意事项与最佳实践部分有详细说明。
游戏输入处理适合初学者吗?
本教程从基础概念讲起,循序渐进,适合有一定HTML和JavaScript基础的初学者。
游戏输入处理的核心要点是什么?
核心要点包括教程简介等内容,建议按教程顺序逐步学习。
本文由小确幸生活整理发布,转载请注明出处
本文涉及AI创作
内容由AI创作,请仔细甄别