マガジン

スコアが一度に2回増える:イベントの二重登録を追う

遊びをつくる

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

小さなゲーム模型を四つの試験コースで動かし、修正を確かめる机。
記事のテーマをもとに制作したAI生成イラスト。

ボタンを1回押しただけなのに、スコアが+10ではなく+20になる場面を考えてみます。加点の式を見直しても「10」としか書いていないのに、画面は20を示します。こうなったとき、式を疑い続けても答えは出ません。多くの場合、式は正しく、式が呼ばれる回数が2回になっています。 この記事で確かめるのは、その2回目がコードのどこから来ているかを、ログだけで絞り込む手順です。ログを置くのは「イベントを登録する側」と「点を加える側」の2か所です。読むのは、行数、イベントの時刻の印、呼び出しの連鎖の3つです。これで、リスナーの再登録なのか、1回の操作が2種類のイベントになっているのか、加点処理が別の場所からも呼ばれているのかを切り分けられます。 後半に、二重登録を意図的に含む30行ほどの最小ゲームを載せます。手元のHTMLファイルとして保存して開けば、同じ手順をそのまま試せます。

この記事の目次
  1. 2回目の正体を3つに分ける
  2. ログを2か所に置く
  3. ログの読み方:行数・時刻の印・呼び出しの連鎖
  4. 再現条件を変えて、3回ずつ数える
  5. 直し方と、直ったことの確かめ方
  6. 投稿の前後で気をつけること
  7. 次の行動
  8. 参考資料

2回目の正体を3つに分ける

「2回増える」の原因は、ログを見る前に3つの仮説に分けられます。

  1. 登録が2回ある:初期化関数が2回呼ばれる、やり直し(リスタート)のたびに登録をやり直しているなど。同じ操作に対してリスナーが2つ付いている状態です。
  2. 1回の操作が2種類のイベントを生む:親要素と子要素の両方にclickリスナーがある(バブリング)、タッチ操作でtouchstartと後続のclickの両方を拾っている、キーを押しっぱなしにしてkeydownが繰り返されている、など。
  3. 加点の呼び出し元が2つある:イベント処理と、衝突判定やタイマー処理の両方が加点関数を呼んでいる状態です。

最初に立てる問いは1つです。操作1回につき、加点ログは何行出るか。 期待は1行です。2行以上なら、行同士の時刻の印と呼び出し元を比べて、上の3つのどれかに当てはめます。

ログを2か所に置く

次のHTMLを1ファイルに保存します。前半の<script>が診断用で、ゲーム本体のスクリプトより前に置くのが条件です。後から登録されたリスナーを数えるには、登録の入口を先に包んでおく必要があるためです。

html
<!doctype html><meta charset="utf-8">
<script>
// ① 診断用:ゲームのスクリプトより前に置く
const count = new Map();
const orig = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, fn, opt) {
  const key = type + "@" + (this.id || this.nodeName || "window");
  const n = (count.get(key) || 0) + 1;
  count.set(key, n);
  console.log("登録", n, "回目:", key);
  if (n > 1) console.trace("2回目以降の登録元");
  return orig.call(this, type, fn, opt);
};
</script>
<button id="hit">+10</button>
<button id="again">やり直し</button>
<p id="s">0</p>
<script>
// ② 二重登録を含む最小ゲーム
const $ = id => document.getElementById(id);
let score = 0;
function addScore(p, from) {
  score += p;
  $("s").textContent = score;
  console.log("加点", p, "合計", score, from);
}
function setup() {
  $("hit").addEventListener("click",
    e => addScore(10, e.type + " " + e.timeStamp.toFixed(1)));
}
function restart() { score = 0; $("s").textContent = 0; setup(); }
$("again").addEventListener("click", restart);
setup();
</script>

ログの役割は次のとおりです。

  • 登録ログは、「種類@対象」ごとに何回目の登録かを数えます。2回目以降はconsole.traceが、その登録に至った呼び出しの連鎖を出します。
  • 加点ログは、加点した量、合計、そしてイベントの種類とtimeStamp(イベントが生じた時刻の印)を出します。

このコードは、restartがsetupを呼び、setupがリスナーを登録する構造です。そのため「やり直し」を押したあとは、click@hitの2回目の登録ログと、setupからrestartへとたどれるトレースが出るはずです。以降の「+10」では加点ログが2行になるはずです。必ず手元で動かして、出方を自分の目で確かめてください。

登録の入口を包む方法は、ゲーム本体のコードを1行も変えずに使えるのが利点です。ただし、キーはidか要素名で作っているので、idのない同種の要素が複数あると同じ数え方になります。対象が多い場合は、キーに加える情報を増やしてください。

ログの読み方:行数・時刻の印・呼び出しの連鎖

加点ログが2行出たら、次の表で絞り込みます。

  • timeStampが同じ値で2行、登録ログの様子:同じ「種類@対象」が2回以上、疑う場所:登録の重複(初期化の再実行、リスタート時の再登録)
  • timeStampが同じ値で2行、登録ログの様子:親と子など、対象が別々に1回ずつ、疑う場所:バブリング(登録位置の見直し、stopPropagationの検討)
  • timeStampが違い、typeも違う(touchstartとclickなど)、登録ログの様子:種類が別々、疑う場所:入力種別の重複(pointerdownなどへの一本化)
  • timeStampが違い、typeは同じ(keydownなど)、登録ログの様子:登録は1回、疑う場所:押しっぱなしの自動リピート(e.repeatの確認)や連打
  • fromの表示がイベント以外、登録ログの様子:登録は1回、疑う場所:加点処理が別の場所からも呼ばれている

決め手はtimeStampです。同じイベントを複数のリスナーが受け取ると、同じイベントオブジェクトを共有するため、時刻の印は同じ値になります。同じ値が2行なら「1回のイベントを2つの処理が受けた」と判断できます。値が違うなら、イベントそのものが2回起きています。

同じ値のときは、登録ログの対象を見ます。同じ対象で2回登録されていれば重複登録です。別の対象なら、バブリングの可能性が高くなります。なお、ブラウザは「同じ対象・同じ種類・同じ関数の参照・同じ指定」の登録を2回目から無視します。二重になるのは、アロー関数や無名関数のように、登録のたびに新しい関数が作られる書き方のときです。名前付き関数を登録しているのに二重なら、別の原因を疑ってください。

呼び出しの連鎖は、2回目の登録ログに付いたconsole.traceの出力で読みます。最上段はログ用のラッパー自身なので無視してください。その下に、実際のaddEventListenerを呼んだ関数、その呼び出し元、とたどれます。ここまで来れば、直すべき行がほぼ見えています。

再現条件を変えて、3回ずつ数える

1回の再現だけで結論を出すと、原因を取り違えやすくなります。条件を変えて、同じ数え方を繰り返してください。

記録の単位は「操作1回につき、加点ログが何行出たか」です。条件ごとに3回ずつ操作して、次のような表に書き込みます。

  • ① 読み込み直後、1回目:、2回目:、3回目:
  • ② 「やり直し」を1回押したあと、1回目:、2回目:、3回目:
  • ③ 「やり直し」を3回押したあと、1回目:、2回目:、3回目:
  • ④ タッチ操作(実機または端末エミュレーション)、1回目:、2回目:、3回目:

結果は次のように読みます。

  • ①から常に2行なら、起動時から二重です。初期化で登録が2回走っているか、2か所で登録しています。
  • ①は1行で、②から2行になるなら、やり直し時の再登録です。
  • ③が、②より行数が増えているなら、やり直しのたびに登録が積み上がっています。放置するほど加点が膨らみます。
  • ①〜③が1行で、④だけ2行なら、入力種別の重複です。

期待値を先に決めておく

この表が使えるのは、「正解は1行」と言い切れるときだけです。何点入るのが正しいのかが曖昧だと、二重かどうかも判断できません。

公式作品の確認済みの仕様に、その考え方の例があります。「こだまの森」は、聴き直しで15点減り、間違いで30点減り、間違いが3回で終了する設計です。点の増減と終了条件が数字で決まっているので、聴き直しを1回押したら減点が1回分で済んでいるか、という期待値を立てられます。「紙ひこうき郵便」の「風船に3回ぶつかると終了」や、「時計じかけの台所」の「ミスは3回まで」も同様です。回数に上限がある作品では、1回の出来事が2回数えられると、体感が大きく変わってしまいます。

この3作品が内部で実際にどう数えているかは、ここでは扱いません。作品を開いて操作して確かめてください。自作でも、加点・減点・ミスの仕様を数字で書き出してから、ログを見てください。

直し方と、直ったことの確かめ方

直し方は主に2つです。

1つ目は、登録を起動時の1回だけにすることです。 restartでは状態(scoreなど)だけを戻し、setupは呼びません。初期化と状態のリセットを別の関数に分けるのが、根本的な対策です。

2つ目は、登録し直す必要がある場合に、前回の登録をまとめて解除することです。 AbortControllerのsignalを渡すと、abort()で一括解除できます。

js
let ac;
function setup() {
  ac?.abort();                 // 前回の登録をまとめて解除
  ac = new AbortController();
  $("hit").addEventListener("click",
    e => addScore(10, e.type + " " + e.timeStamp.toFixed(1)),
    { signal: ac.signal });
}

この形にしたあとも、登録ログの回数は「2回目、3回目」と増えていきます。ログは解除を数えないためです。直ったかどうかの判定は登録ログではなく、加点ログの行数で行います。先ほどの表をもう一度、同じ条件で埋めて、すべて1行になっていれば完了です。

バブリングが原因なら登録位置を見直します。入力種別の重複なら、pointerdownなどの1種類に絞るのが定番の対処です。キーの押しっぱなしなら、e.repeatがtrueのときは処理を飛ばします。

投稿の前後で気をつけること

アソバースに投稿する前後に、確認済みの範囲で押さえておきたい点が4つあります。

  • ログは手元で取ります。 作品は外部通信なしの独立したHTMLとして動き、サーバー用コードは投稿の対象外です。ログを集める仕組みは作らず、ブラウザの開発者ツールのコンソールで読みます。
  • 開発者ツールの対象に注意します。 アップロードされたゲームは、sandbox付きのiframeで、メインサイトとは別のオリジンから配信されて動きます。公開後の画面で調べるときは、コンソールの対象がページ本体ではなくiframe内になっているかを確認してください。切り替えの操作はブラウザごとに違うため、お使いのブラウザの案内で確かめます。
  • 診断用の<script>は、アップロードするファイルに含めません。 addEventListenerを書き換えるので、他のコードの挙動にも影響します。公開前に外し、外した状態でもう一度、表の数え方を通してください。投稿は「入力→表示の確認→公開」の3段階です。「表示の確認」の段階でも、同じ回数確認を行うと安心です。直した版は、公開内容の更新で反映できます。
  • 保存した値は、直しても自動では戻りません。 SDKのクラウドセーブ(作品ごとにJSONで128KiBまで)を使う場合、未同期の端末セーブはクラウドの古いデータより優先して再送される仕組みです。二重加算で膨らんだ値が保存済みの端末では、修正版を出したあとも、その値が読み込まれる可能性があります。セーブにバージョン番号を持たせ、読み込み時に値が想定の範囲内かを検証する設計が有効です。

なお、ゲーム内スコアの共通ランキングは現在提供されていないため、二重加算がほかの人の順位を直接動かすことはありません。その分、作品内の得点が正しいかどうかは、作者の確認にかかっています。

次の行動

まず上のHTMLを保存し、「やり直し」を押してから「+10」を押して、加点ログが何行出るかを見てください。次に、自作のゲームで、スコアや減点が変わる操作の期待値を紙に書き出し、4つの条件で3回ずつ数えます。行数が1行でない条件が見つかったら、timeStampと登録ログのconsole.traceをたどって、直す行を特定します。

参考資料

  • MDN Web Docs「EventTarget.addEventListener()」
  • MDN Web Docs「AbortController」「AbortSignal」
  • MDN Web Docs「console.trace()」
  • MDN Web Docs「Event.timeStamp」「KeyboardEvent.repeat」