マガジン

ゲームのキーボード入力を正しく取る:押しっぱなしと同時押し

遊びをつくる

執筆にClaudeを使用しています。

キーボードとタッチの二つの入力が、同じ駒のジャンプにつながる様子を描いたイラスト。
記事のテーマをもとに制作したAI生成イラスト。

ブラウザゲームでキャラクターを動かそうとすると、多くの人が最初に keydown イベントだけで実装します。しかしこの方法では「最初の1回は動くが、押しっぱなしにすると一瞬止まってから動き出す」という現象が起きます。これはOSのキーリピート機能が原因で、ゲームの滑らかな操作感とは相性が悪いものです。 同時押しにも同様の落とし穴があります。keydown イベントを単純に拾うだけでは、Aキーを押しながらWキーを押したときに「Aが離された」と誤認することがあります。結果として斜め移動ができなかったり、コンボ入力が成立しなかったりします。 この記事では、押しっぱなしと同時押しを正確に扱うための「キー状態管理」のパターンを説明します。イベント駆動のまま書く方法と、ゲームループと組み合わせる方法の両方を示すので、自分のゲームの構造に合わせて選んでください。

この記事の目次
  1. なぜ `keydown` だけでは足りないのか
  2. キー状態を記録するオブジェクトを作る
  3. ゲームループと組み合わせる
  4. 一度だけ反応させたいキーの扱い方
  5. 実装前に確認しておく3つのこと
  6. 次の行動

なぜ `keydown` だけでは足りないのか

keydown イベントはキーを押した瞬間だけでなく、押し続けているあいだも繰り返し発火します。この繰り返し間隔はOSの設定に依存していて、ゲームからは制御できません。一般的には最初の発火から約500ミリ秒の遅延があり、その後は約30〜50ミリ秒ごとに発火するという挙動になります。この遅延が「一瞬止まる」感覚の正体です。

同時押しについては、イベントの順序が問題になります。AキーとWキーをほぼ同時に押した場合、keydown イベントは必ずどちらかが先になります。「Aキーの keydown」→「Wキーの keydown」の順に来るとき、Aキーの keydown ハンドラの中では「Wが押されているかどうか」をイベントから知る方法がありません。

これらを解決する考え方はシンプルです。「今どのキーが押されている状態か」を自前のオブジェクトに記録し続け、ゲームロジックはそのオブジェクトを読む、という設計にします。

キー状態を記録するオブジェクトを作る

実装の核心は、pressed という名前のオブジェクト(またはSetやMap)に「今押されているキー」を保持することです。keydown でキーを追加し、keyup でキーを削除します。ゲームロジック側はこのオブジェクトを参照するだけで、イベントを直接読みません。

javascript
const pressed = {};

window.addEventListener('keydown', (e) => {
  pressed[e.code] = true;
});

window.addEventListener('keyup', (e) => {
  delete pressed[e.code];
});

e.key ではなく e.code を使うのがポイントです。e.key はShiftキーの状態やIMEによって変わりますが、e.code は物理キーの位置に対応するため、入力環境に依存しません。たとえばAキーは "KeyA"、スペースキーは "Space"、左矢印は "ArrowLeft" になります。

このオブジェクトを使った斜め移動の判定は次のように書けます。

javascript
function update() {
  const speed = 3;
  if (pressed['ArrowLeft'])  player.x -= speed;
  if (pressed['ArrowRight']) player.x += speed;
  if (pressed['ArrowUp'])    player.y -= speed;
  if (pressed['ArrowDown'])  player.y += speed;
}

if を並べているだけなので、左矢印と上矢印を同時に押しても両方の処理が走ります。OSのキーリピートに依存しないため、押し続けても遅延なく動き続けます。

ゲームループと組み合わせる

update() 関数はゲームループの中で毎フレーム呼ぶ必要があります。requestAnimationFrame を使った基本的なループは次の形になります。

javascript
function gameLoop() {
  update();
  draw();
  requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

update() の中でキー状態を読むことで、1フレームあたり1回だけ入力を処理できます。これにより、押しっぱなし中は毎フレームキャラクターが動き、離したフレームからすぐに止まるという自然な動作になります。

よくある失敗:`keydown` イベントの中でゲームロジックを書く

イベントハンドラの中にゲームロジックを直接書くと、次の2つの問題が生じます。まず、OSのキーリピートで keydown が発火するたびにロジックが走るため、フレームレートとは無関係な頻度で処理が実行されます。次に、同時押し中は複数のイベントが混在するため、状態の読み取りタイミングがばらつきます。pressed オブジェクトへの記録はイベントハンドラで行い、読み取りはゲームループで行う、という役割分担を守ることが安定動作の鍵です。

一度だけ反応させたいキーの扱い方

押しっぱなしで連続処理したいキーとは別に、「押した瞬間だけ1回だけ反応させたい」キーもあります。ジャンプ、決定、攻撃などがこれに当たります。こうした「エッジ検出」は pressed オブジェクトだけでは実現できないため、別の仕組みが必要です。

よく使われる方法が「前フレームのキー状態と今フレームのキー状態を比較する」方法です。

javascript
const pressed = {};
const justPressed = {};

window.addEventListener('keydown', (e) => {
  if (!pressed[e.code]) {
    justPressed[e.code] = true;  // 初回のkeydownだけtrueにする
  }
  pressed[e.code] = true;
});

window.addEventListener('keyup', (e) => {
  delete pressed[e.code];
  delete justPressed[e.code];
});

function update() {
  if (justPressed['Space']) {
    player.jump();  // 押した瞬間だけジャンプ
  }
  // フレーム末にリセット
  for (const key in justPressed) {
    delete justPressed[key];
  }
}

justPressed は1フレームだけ true になり、次の update() の末尾でリセットされます。keydown ハンドラの if (!pressed[e.code]) の条件がOSのキーリピートを弾く役割を果たしています。ここを書き忘れると、押しっぱなし中に毎フレームジャンプが発動するバグになります。

実装前に確認しておく3つのこと

`e.preventDefault()` が必要な場面

スペースキーはページのスクロール、矢印キーはフォーカス移動にデフォルトで割り当てられています。ゲームがページ内に埋め込まれていると、矢印キーでページがスクロールしてゲームの表示が動いてしまうことがあります。ゲーム中だけ e.preventDefault() を呼ぶか、ゲームのCanvas要素にフォーカスを当てておく設計にする必要があります。

タブ切り替えやウィンドウフォーカスの喪失

別のウィンドウへ切り替えたとき、keyup イベントがゲームに届かない場合があります。この場合、「Aを押しながらウィンドウを切り替えてAを離す」と、pressed に "KeyA" が残り続けます。window.addEventListener('blur', ...) でウィンドウのフォーカス喪失を検知し、pressed と justPressed を全クリアする処理を入れておくと安全です。

モバイル端末への対応

タッチ操作のデバイスではキーボードイベントが発生しません。ゲームをモバイルでもプレイさせたい場合は、画面上のボタンUI(仮想キーパッド)から同じ pressed オブジェクトを操作する設計にすることで、PC・モバイルの入力処理を統一できます。

次の行動

まず pressed オブジェクトと requestAnimationFrame ループを組み合わせた最小構成を書き、ブラウザでキー入力の挙動を確認してみてください。動作が確認できたら、justPressed を追加してジャンプや攻撃などの単発アクションを実装します。完成したゲームはHTMLファイルまたはZIPファイルとしてアソバース(https://aso-verse.com)に投稿して公開できます。