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>

浏览器兼容性

API Chrome Firefox Safari Edge 移动端Chrome 移动端Safari
KeyboardEvent.code 完全支持 完全支持 完全支持 完全支持 完全支持 完全支持
MouseEvent 完全支持 完全支持 完全支持 完全支持 完全支持 完全支持
Touch Events 完全支持 完全支持 完全支持 完全支持 完全支持 完全支持
Pointer Events 完全支持 完全支持 完全支持 完全支持 完全支持 完全支持
Gamepad API 完全支持 完全支持 部分支持 完全支持 部分支持 不支持
Pointer Lock API 完全支持 完全支持 完全支持 完全支持 不支持 不支持
Fullscreen API 完全支持 完全支持 需前缀 完全支持 部分支持 部分支持

注意事项与最佳实践

  1. 使用event.code而非event.key:code表示物理键位,不受输入法影响;key表示字符值,在不同键盘布局下可能不同。

  2. 过滤重复按键:长按按键会重复触发keydown,使用event.repeat过滤。

  3. 阻止默认行为:方向键和空格会导致页面滚动,需要在事件处理中调用preventDefault()

  4. 触摸事件需要passive:false:要在touchstart/touchmove中调用preventDefault,必须设置{ passive: false }

  5. 移动端300ms延迟:使用touch-action: none CSS属性或FastClick库消除点击延迟。

  6. 手柄轮询而非事件:Gamepad API没有事件机制,需要在游戏循环中轮询navigator.getGamepads()

  7. 输入映射抽象:将物理输入映射为游戏动作,使同一动作可以绑定多种输入方式,便于跨平台适配。

  8. 虚拟手柄适配:移动端游戏需要提供虚拟手柄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基础的初学者。

游戏输入处理的核心要点是什么?

核心要点包括教程简介等内容,建议按教程顺序逐步学习。

标签: Canvas 键盘输入 场景管理 物理模拟 触摸控制 游戏状态 跳跃游戏 游戏循环

本文由小确幸生活整理发布,转载请注明出处

本文涉及AI创作

内容由AI创作,请仔细甄别

list快速访问

上一篇: 游戏开发:游戏精灵与动画 - 从入门到实践详解 下一篇: 游戏开发:碰撞检测 - 从入门到实践详解

poll相关推荐