ゲームのタッチとマウスが二重反応する:Pointer Eventsで統一
遊びをつくる執筆にClaudeを使用しています。

スマートフォンでゲームをタップしたとき、ボタンが2回押されたように動く――この現象に悩んだことはありませんか。原因はほぼ決まっています。touchstart と mousedown(または click)の両方にイベントリスナーを登録しているとき、スマートフォンのブラウザがタッチ操作に対して両方を順番に発火させるからです。 この二重反応は「タッチイベントとマウスイベントの互換動作」によって引き起こされます。モバイルブラウザはタッチ操作を受け取ると、まずタッチ系イベント(touchstart → touchend)を発火し、その直後にマウス系イベント(mousemove → mousedown → mouseup → click)を順に発火します。この挙動は仕様であり、バグではありません。ゲームロジックが両方を拾うと「1回タップしたのに2回処理が走る」状態になります。 解決策は二つあります。一つは古典的な e.preventDefault() による抑制、もう一つは Pointer Events API を使ってタッチとマウスを最初から一本化する方法です。後者のほうが記述量が減り、タッチペンやスタイラスにも対応できるため、これから書くコードには Pointer Events を選ぶのが得策です。この記事では、二重反応の仕組みを整理したうえで、Pointer Events による統一手順と、よくある落とし穴を解説します。
この記事の目次
なぜ二重反応が起きるのかを確認する
問題のあるコードは、多くの場合このような形をしています。
button.addEventListener('touchstart', onPress);
button.addEventListener('mousedown', onPress);デスクトップではマウスイベントだけが発火するので正常に見えます。しかしスマートフォンでは touchstart で onPress が呼ばれ、続いて mousedown でもう一度 onPress が呼ばれます。結果として onPress は同じ操作で2回実行されます。
この遅延は約 300 ミリ秒とよく言われますが、これは click イベントに対するダブルタップ判定の遅延の話です。mousedown はそれより早く、touchend の直後に発火します。そのため「遅れて2回目が来る」というより「ほぼ同時に2回来る」感覚になります。
どちらが先に発火しているかを確認したいときは、console.log(e.type) をリスナー内に入れてブラウザの開発者ツールで見るのが最速です。touchstart → mousedown の順に出力されれば、互換動作が原因と断定できます。
`e.preventDefault()` による抑制とその限界
最も即効性のある対処は touchstart リスナーの中で e.preventDefault() を呼ぶことです。これによりブラウザはタッチ後のマウスイベント変換をスキップします。
button.addEventListener('touchstart', (e) => {
e.preventDefault();
onPress(e);
});
button.addEventListener('mousedown', onPress);ただし preventDefault() にはいくつか注意点があります。
- スクロール可能な要素に設定すると、スクロール操作も止まることがあります。
- passive: true オプションと併用すると preventDefault() は無視されます(パフォーマンス最適化のために passive: true がデフォルトになっているブラウザがあります)。
- タッチとマウスで処理内容が微妙に違う場合、片方だけ止めると挙動が読みにくくなります。
この方法はすでにタッチとマウスで別々のリスナーを書いてしまったコードへの「後付け修正」として有効です。しかし新規に書くなら、最初から Pointer Events で統一するほうが見通しがよくなります。
Pointer Events で一本化する手順
Pointer Events API はタッチ・マウス・スタイラスを PointerEvent オブジェクトとして統一的に扱います。e.pointerType を参照すれば "mouse" / "touch" / "pen" を区別することもできます。
対応するイベント名は次のとおりです。
- mousedown / touchstart、Pointer Events での対応:pointerdown
- mousemove / touchmove、Pointer Events での対応:pointermove
- mouseup / touchend、Pointer Events での対応:pointerup
- mouseleave / touchcancel、Pointer Events での対応:pointercancel
リスナーを pointerdown 一本にまとめると、タッチでもマウスでもどちらか一回だけ発火します。
button.addEventListener('pointerdown', onPress);これだけで二重反応は解消されます。コールバック内では e.clientX / e.clientY でタッチ・マウス問わず座標を取れます。
マルチタッチが必要な場合
複数の指を同時に追跡するゲーム(ピンチズームや2本指操作)では、pointerId を使います。pointerdown が発火するたびに e.pointerId が払い出されるので、Map などで管理します。
const activePointers = new Map();
canvas.addEventListener('pointerdown', (e) => {
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
});
canvas.addEventListener('pointermove', (e) => {
if (activePointers.has(e.pointerId)) {
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
}
});
canvas.addEventListener('pointerup', (e) => {
activePointers.delete(e.pointerId);
});
canvas.addEventListener('pointercancel', (e) => {
activePointers.delete(e.pointerId);
});pointercancel は電話着信やスクロール割り込みなどでタッチが中断されたときに発火します。これを忘れると activePointers にエントリが残り続け、ゴースト入力の原因になります。
落とし穴と対処
touch-action CSS プロパティを忘れない
Pointer Events を使っていても、要素に touch-action を設定していないとブラウザがスクロール処理を優先し、pointermove が途中でキャンセルされることがあります。ゲームキャンバスには次を指定するのが基本です。
canvas {
touch-action: none;
}touch-action: none は「この要素上のタッチ操作はスクロールやズームに使わない」という宣言です。これにより pointermove が安定して発火します。
既存コードに click リスナーが混在している場合
UI ボタンなどで click を使っている箇所がある場合、pointerdown に統一しても click は引き続き発火します。click は削除するか、pointerdown 側を使わず click に一本化するかを意識的に選んでください。UI ボタンのアクセシビリティ(キーボード操作など)を重視するなら click を残すほうが無難です。ゲームの操作入力として速度が重要な場面では pointerdown を優先します。
古いブラウザへの対応
Pointer Events は主要なブラウザで広くサポートされています。古い Android WebView など特定の環境で動作しない可能性がある場合は、公式の仕様書または MDN の互換性テーブルで確認してください。フォールバックが必要な環境をターゲットにする場合は、window.PointerEvent の存在チェックをしてから登録する方法があります。
if (window.PointerEvent) {
element.addEventListener('pointerdown', onPress);
} else {
element.addEventListener('touchstart', onPress);
element.addEventListener('mousedown', onPress);
}修正の優先順位を決める判断基準
コードのどこを直すべきか迷ったときは、次の順で確認してください。
- touchstart と mousedown(または click)を同じ要素に両方登録している箇所を探す。
- その要素がゲームの入力を受け付ける中心(キャンバス、操作ボタン)かどうかを確認する。
- 中心的な入力要素であれば pointerdown に一本化し、touch-action: none を CSS に追加する。
- UI のテキストリンクや送信ボタンなど、アクセシビリティが必要な要素は click を残す。
二重反応がゲームロジック(スコア加算、弾の発射など)の中で起きているのか、UI 操作(メニュー遷移など)で起きているのかによっても判断が変わります。ゲームロジック側なら速度と正確さを優先して pointerdown 統一、UI 側なら click 残置と preventDefault() 補完の組み合わせが現実的です。
次の行動
手元のゲームで touchstart と mousedown を両方登録している箇所を検索し、pointerdown への置き換えと touch-action: none の追加から試してみてください。修正後はスマートフォンの実機またはブラウザの開発者ツールのデバイスエミュレーターで、ボタンを素早くタップしたときに処理が1回だけ走るかを確認するのが確実です。作成したゲームはアソバースに ZIP または HTML として投稿し、実際のスマートフォン環境での動作を公開前に確かめることもできます。
参考資料
- MDN Web Docs「Pointer events」
- W3C Pointer Events 仕様(Level 2)
- MDN Web Docs「touch-action」

