キーを押しっぱなしにすると動きが止まる:キーリピートの罠
遊びをつくる執筆にClaudeを使用しています。

キャラクターを動かすゲームを作り、keydownイベントの中で x += 5 と書いたとします。矢印キーを押すと確かに動きます。ところが押しっぱなしにすると、最初の一歩のあとで少し止まり、それから動き出すように見えます。左右を素早く切り替えると、押しているのに反応しない瞬間も出てきます。 こうした「動きが止まる」現象の多くは、キーボードのリピート入力を移動処理に直結していることが原因です。この記事では、ブラウザがキー入力をどう通知するかを整理し、「押されているキーの状態」を持って毎フレーム読む方式への置き換え方を示します。さらに、自分の環境でリピートの挙動を観察する手順と、アソバースの公式作品を開いて入力まわりを確かめる観点も紹介します。観察結果はOS・ブラウザ・キーボードの設定で変わるため、数値は書きません。読者の環境で記録する形にします。
この記事の目次
「止まる」には少なくとも3種類ある
「押しっぱなしで止まる」と一言でいっても、原因は別々です。症状ごとに切り分けないと、直したつもりで別の止まり方が残ります。
- 最初の一歩のあと、少し間が空いてから動き出す、起きやすい原因:OSのキーリピートには「最初のリピートまでの待ち時間」があり、keydownを移動に直結すると、その間は入力が来ない、確かめ方:後述のログで最初のkeydownと次のkeydownの間隔を見る
- 左を押したまま右を押すと、動きが止まる、または逆に動く、起きやすい原因:複数キーを押したときのリピートの扱いがOSやブラウザで異なる。keyupの順序で状態が食い違うこともある、確かめ方:左→右、右→左の順に押し、離す順も変えてログを見る
- 別のウィンドウに切り替えて戻ると、勝手に動き続ける、または動かない、起きやすい原因:フォーカスが外れた間のkeyupがゲームに届かず、「押されている」状態が残る、確かめ方:押したままウィンドウを切り替え、戻ってログを見る
一つ目は待ち時間の問題、二つ目は同時押しの問題、三つ目はイベントの取りこぼしの問題です。三つ目は「止まる」というより「止まらない」不具合ですが、同じ入力処理の設計で一緒に解決できるので、ここで扱います。
なお、複数キーの同時押しには、キーボード本体の仕様で認識できる組み合わせに限りがある場合もあります。これはソフトウェアでは直せません。切り分けのときは、この可能性も頭に置いてください。
keydownイベントの正体:repeatフラグ
ブラウザは、キーを押した瞬間に keydown を発火します。押し続けると、OSのキーリピートに従って keydown が繰り返し発火します。このとき、イベントの repeat プロパティが true になります。最初の1回は false、リピートで届く2回目以降は true という区別です。
ここから、keydownで直接動かす設計の問題が見えてきます。
- 動く速さが、ユーザーのOS設定(リピート速度)に左右されます。設定が違う2人で、同じゲームの移動速度が変わってしまいます。
- 最初のリピートまでの待ち時間の間、入力が途切れます。これが「一歩動いて止まる」の正体です。
- 音を鳴らすゲームでは、リピートのたびに同じ音が鳴り直すことがあります。repeat が true のイベントを無視する処理が必要になる場面です。
つまり keydown は「キーが押された」という出来事の通知であって、「いま押されている」という状態の通知ではありません。出来事を拾うのか、状態を読むのかを、操作ごとに決めておくのが設計の出発点です。
- 出来事で扱う操作:ジャンプ、決定、ポーズ、音を一回鳴らす演奏。押した瞬間に一度だけ反応すれば足ります。repeat が true のものは捨てます。
- 状態で扱う操作:左右移動、加速、狙いの微調整。押している間ずっと作用し続けるべきものです。
状態で管理する:押されているキーの集合
状態管理の考え方は単純です。keydownで「押されている」集合にキーを入れ、keyupで取り除きます。移動の計算は、キー入力のイベントの中ではなく、毎フレームのループの中で集合を読んで行います。リピートが何回届いても、集合に入れ直すだけで結果は変わりません。
次のコードは、単独のHTMLファイルとして保存すれば動く最小構成です。左右の矢印キーで四角が動き、押下中のキーが下に表示されます。
<!doctype html>
<meta charset="utf-8">
<canvas id="c" width="320" height="120"></canvas>
<p id="log"></p>
<script>
const keys = new Set();
const ctx = document.getElementById("c").getContext("2d");
const log = document.getElementById("log");
let x = 160, last = performance.now();
addEventListener("keydown", e => {
if (e.code === "ArrowLeft" || e.code === "ArrowRight") e.preventDefault();
keys.add(e.code);
});
addEventListener("keyup", e => keys.delete(e.code));
addEventListener("blur", () => keys.clear());
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
const dir = (keys.has("ArrowRight") ? 1 : 0) - (keys.has("ArrowLeft") ? 1 : 0);
x = Math.max(10, Math.min(310, x + dir * 180 * dt));
ctx.clearRect(0, 0, 320, 120);
ctx.fillRect(x - 10, 50, 20, 20);
log.textContent = "押下中: " + [...keys].join(", ");
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>ポイントは4つあります。
- e.code を使っています。 e.key は入力された文字を表し、シフトキーや日本語入力の状態で値が変わることがあります。e.code は物理的なキーの位置を表すので、移動キーの判定には向いています。
- blur で集合を空にしています。 ウィンドウのフォーカスが外れると、その間のkeyupが届かないことがあります。集合を空にしておけば、「押されたまま」の状態が残りません。
- 移動量は経過時間 dt に比例させています。 画面の更新頻度が違う環境でも、移動速度が揃います。dt の上限を設けているのは、タブを切り替えた直後に巨大な値が入り、一気に飛ぶのを避けるためです。
- preventDefault() で矢印キーの既定動作を止めています。 止めないと、ページのスクロールが同時に起きることがあります。ただし、止めるキーは必要最小限にしてください。何でも止めると、ブラウザのショートカットや読み上げ機能などの操作を妨げます。
左右を同時に押したときは、この例では打ち消し合って止まります。これは仕様として意図したものかどうか、設計時に決めておく項目です。「最後に押した方を優先する」とするなら、押した順を配列で持つ方法があります。
まず自分の環境で観察する
状態管理に作り替える前に、自分の環境でイベントがどう届くかを記録してみます。結果はOS、ブラウザ、キーボードで変わるので、ここでは手順と読み方だけを示します。
次のコードは、届いたイベントを順に表示するだけの観察用ページです。
<!doctype html>
<meta charset="utf-8">
<pre id="out">キーを押してください</pre>
<script>
const rows = []; let t0 = null;
const out = document.getElementById("out");
addEventListener("keydown", e => {
const t = performance.now(); if (t0 === null) t0 = t;
rows.push(`down ${e.code} repeat=${e.repeat} +${Math.round(t - t0)}ms`);
show();
});
addEventListener("keyup", e => {
rows.push(`up ${e.code} +${Math.round(performance.now() - t0)}ms`); show();
});
addEventListener("blur", () => { rows.push("--- blur ---"); show(); });
function show() { out.textContent = rows.slice(-25).join("\n"); }
</script>手順と記録
次の4つを順に行い、それぞれ3回ずつ、ログを書き写すかスクリーンショットで残します。
- 単独の長押し。 右矢印キーを2秒ほど押しっぱなしにして離します。記録するのは、最初の keydown から2回目の keydown までの時間と、その後の keydown の間隔です。
- 押し替え。 左を押したまま右を押し、左を離します。続けて右も離します。右の keydown がリピートし続けるか、左のリピートが止まるかを見ます。
- 離す順の入れ替え。 2と同じ操作で、離す順を逆にします。keyupがどの順に届くかを見ます。
- フォーカスの喪失。 右を押したままウィンドウを切り替え、離してから戻ります。ログに blur が出ること、up が欠けていることを確認します。
結果の読み方
- 1回目と2回目の keydown の間隔が、その後の間隔より明らかに長ければ、リピート開始の待ち時間があります。keydown直結の移動では、この区間が止まって見えます。
- 押し替えのとき、後から押したキーだけがリピートするなら、先に押したキーの押下状態は「集合」で持たないと分からなくなります。これが「左右が逆転する・止まる」の原因候補です。
- ウィンドウを切り替えたのに up がなければ、blur で集合をリセットする処理が必要です。
- 3回の記録がばらつく場合は、その環境では間隔が一定とは限らないと判断します。固定の数値をコードに埋め込まないほうが安全です。
起こりやすい誤りは、ブラウザ内の別の入力欄やデベロッパーツールにフォーカスがある状態で試すことです。キーイベントが期待した場所に届かないので、ページをクリックしてから操作してください。
公式作品を開いて確かめる観点
アソバースの公式作品には、キーボード操作を持つものがあります。確認済みの範囲で、入力の設計から読み取れることと、作品を開いて確かめる点を整理します。
- 紙ひこうき郵便。 左右キー、ドラッグ、矢印ボタンの3種類で操縦できます。連続して方向を保つ操作は押しっぱなしを前提にしやすく、さらに複数の入力手段を並べている設計から、キーボード以外の環境も想定していると読み取れます。内部がどう実装されているかは分かりません。左右を押し替えたとき、キーを押したままウィンドウを切り替えて戻ったときの反応は、作品を開いて確かめてください。
- 潮目のオセロ。 矢印キーとEnterで操作できます。矢印キーで選び、Enterで確定する形は、「移動」と「決定」を分けた操作設計と読み取れます。矢印キーを押しっぱなしにしたとき、選択位置がどう動くかは作品で確かめます。
- 雨音のピアノ。 キーボードの A W S E D F T G Y H U J で演奏し、Z/Xで音域を変えます。鍵盤のように「押した瞬間に一音」の操作は、先ほどの分類では出来事で扱う側です。キーを押しっぱなしにしたとき、音が鳴り直すのか、一度だけ鳴るのかは、実際に押して聞いて確かめられます。
もう一点、アソバースではゲームがsandbox付きのiframeで実行されます。一般的なブラウザの仕組みとして、キーイベントはフォーカスのある文書に届きます。そのため、ゲームの画面をクリックしてから操作するほうが確実です。自作ゲームを投稿する場合も、開始時に「クリックまたはタップで始める」場面を設けておくと、フォーカスの問題を避けられます。投稿前の表示確認の段階で、押しっぱなしや押し替えを試しておくと安心です。
次の行動
観察用ページを保存し、先ほどの4つの操作を3回ずつ記録してみてください。そのうえで、自作ゲームの移動処理がkeydownに直結していれば、押下集合と毎フレーム読む方式に置き換えます。決定やジャンプなどの単発の操作は repeat が true のイベントを無視する形で残してください。
参考資料
- MDN Web Docs「KeyboardEvent」「KeyboardEvent.repeat」「KeyboardEvent.code」
- MDN Web Docs「Window: requestAnimationFrame()」

