ゲームのキーボード入力を正しく取る:押しっぱなしと同時押し
遊びをつくる執筆にClaudeを使用しています。

ブラウザゲームでキャラクターを動かそうとすると、多くの人が最初に keydown イベントだけで実装します。しかしこの方法では「最初の1回は動くが、押しっぱなしにすると一瞬止まってから動き出す」という現象が起きます。これはOSのキーリピート機能が原因で、ゲームの滑らかな操作感とは相性が悪いものです。 同時押しにも同様の落とし穴があります。keydown イベントを単純に拾うだけでは、Aキーを押しながらWキーを押したときに「Aが離された」と誤認することがあります。結果として斜め移動ができなかったり、コンボ入力が成立しなかったりします。 この記事では、押しっぱなしと同時押しを正確に扱うための「キー状態管理」のパターンを説明します。イベント駆動のまま書く方法と、ゲームループと組み合わせる方法の両方を示すので、自分のゲームの構造に合わせて選んでください。
なぜ `keydown` だけでは足りないのか
keydown イベントはキーを押した瞬間だけでなく、押し続けているあいだも繰り返し発火します。この繰り返し間隔はOSの設定に依存していて、ゲームからは制御できません。一般的には最初の発火から約500ミリ秒の遅延があり、その後は約30〜50ミリ秒ごとに発火するという挙動になります。この遅延が「一瞬止まる」感覚の正体です。
同時押しについては、イベントの順序が問題になります。AキーとWキーをほぼ同時に押した場合、keydown イベントは必ずどちらかが先になります。「Aキーの keydown」→「Wキーの keydown」の順に来るとき、Aキーの keydown ハンドラの中では「Wが押されているかどうか」をイベントから知る方法がありません。
これらを解決する考え方はシンプルです。「今どのキーが押されている状態か」を自前のオブジェクトに記録し続け、ゲームロジックはそのオブジェクトを読む、という設計にします。
キー状態を記録するオブジェクトを作る
実装の核心は、pressed という名前のオブジェクト(またはSetやMap)に「今押されているキー」を保持することです。keydown でキーを追加し、keyup でキーを削除します。ゲームロジック側はこのオブジェクトを参照するだけで、イベントを直接読みません。
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" になります。
このオブジェクトを使った斜め移動の判定は次のように書けます。
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 を使った基本的なループは次の形になります。
function gameLoop() {
update();
draw();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);update() の中でキー状態を読むことで、1フレームあたり1回だけ入力を処理できます。これにより、押しっぱなし中は毎フレームキャラクターが動き、離したフレームからすぐに止まるという自然な動作になります。
よくある失敗:`keydown` イベントの中でゲームロジックを書く
イベントハンドラの中にゲームロジックを直接書くと、次の2つの問題が生じます。まず、OSのキーリピートで keydown が発火するたびにロジックが走るため、フレームレートとは無関係な頻度で処理が実行されます。次に、同時押し中は複数のイベントが混在するため、状態の読み取りタイミングがばらつきます。pressed オブジェクトへの記録はイベントハンドラで行い、読み取りはゲームループで行う、という役割分担を守ることが安定動作の鍵です。
一度だけ反応させたいキーの扱い方
押しっぱなしで連続処理したいキーとは別に、「押した瞬間だけ1回だけ反応させたい」キーもあります。ジャンプ、決定、攻撃などがこれに当たります。こうした「エッジ検出」は pressed オブジェクトだけでは実現できないため、別の仕組みが必要です。
よく使われる方法が「前フレームのキー状態と今フレームのキー状態を比較する」方法です。
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)に投稿して公開できます。

