マガジン

連打すると画面遷移が重なる:操作の受け付け制御

遊びをつくる

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

小さな迷路の壁を一か所取り外し、通り道を確かめる模型。
記事のテーマをもとに制作したAI生成イメージ。実際のゲーム画面ではありません。

タイトル画面の「はじめる」を素早く5回押す場面を考えてみます。画面がフェードアウトして、ゲーム画面に切り替わるはずです。ところが、ゲームの初期化が2回走って敵が二重に出たり、結果画面が2枚重なったりすることがあります。原因はたいてい、遷移の演出に時間がかかる間もボタンが入力を受け付けていることです。 この記事が確かめるのは、「一度押したら無効にする」実装そのものより、「いつ有効に戻すか」のほうです。無効にするのは1行で済みますが、解除のタイミングを誤ると、二重に遷移するか、永久に押せなくなるかのどちらかになります。ここでは受け付け制御の書き方を整理し、「動きを抑える」設定のもとで解除が来なくなる落とし穴を扱います。最後に、自分のゲームで連打を診断する手順を示します。

この記事の目次
  1. 二重遷移が起きる仕組み
  2. 受け付けを一度に絞る二つの書き方
  3. 解除のタイミングが決める三つの壊れ方
  4. 「動きを抑える」設定で解除が来なくなる
  5. 公式作品から読み取れる「受け付けの境界」
  6. 連打を自分のゲームで診断する手順
  7. 次の行動
  8. 参考資料

二重遷移が起きる仕組み

遷移は多くの場合、一瞬では終わりません。フェードアウト、読み込み、効果音の再生などを挟み、その間に時間が流れます。クリックのイベントは、前の遷移が終わるのを待たずに届きます。

次のような処理を考えてみます。

  1. クリックされたら、フェードアウトを始める。
  2. フェードアウトが終わったら、ゲーム画面を初期化する。

フェードアウトに0.4秒かかるなら、その間の2回目、3回目のクリックも手順1から始まります。結果として、初期化の手順2が3回走ります。「押した瞬間に状態を変える」ことを怠ると、この重なりは防げません。

重なりは、ボタンのクリック以外でも起きます。キーボードのEnterキーを押し続けると、キーを押している間に keydown イベントが繰り返し届きます(event.repeat が true になります)。ポインタ操作だけを想定して対策すると、キーボード経路が抜けます。

受け付けを一度に絞る二つの書き方

受け付け制御には、大きく二つの書き方があります。

フラグで止める

let busy = false; のような変数を用意し、処理の入口で if (busy) return; とする書き方です。短く書けますが、フラグが増えるのが弱点です。「遷移中」「一時停止中」「結果表示中」と増えると、組み合わせの数が膨らみます。2つのフラグが同時に立つ状態が、意図せず生まれます。

状態で止める

state という1つの変数に、'title'、'leaving'、'playing' のような値を持たせます。「この操作は 'title' のときだけ受け付ける」と決めておけば、遷移中に来た入力は入口で弾かれます。同時に二つの状態になることはないので、組み合わせの矛盾も起きません。

どちらの書き方でも、ボタンの disabled は見た目の合図として使い、本当の防御は入口のガードで行うのが安全です。disabled が効くのは、そのボタン自身の操作だけです。キーボードショートカットや、同じ処理を呼ぶ別の入口には効きません。「処理の入口で状態を見る」ことが、どの経路にも効く土台になります。

解除のタイミングが決める三つの壊れ方

無効にした後の解除タイミングは、次の三つの失敗に分けて考えると整理しやすくなります。

  • 解除が早すぎる、起きること:遷移中に再び受け付けて二重になる、原因の例:演出の開始直後に解除している
  • 解除が来ない、起きること:受け付けが戻らず、ボタンが永久に押せない、原因の例:解除を呼ぶイベントが発火しない、途中で例外が起きる
  • 解除が多すぎる、起きること:想定外の状態から受け付けが再開する、原因の例:複数の場所で別々に解除している

特に見落とされやすいのは、二つ目の「解除が来ない」です。次の節で詳しく見ます。

三つ目への対策は、状態の変更を1か所にまとめることです。「遷移を始める」「遷移を終える」「失敗して戻す」の3つの関数だけが state を書き換えるようにしておくと、解除の場所を追いやすくなります。

「動きを抑える」設定で解除が来なくなる

アニメーションの終了を待って解除する書き方は、よくあります。CSSの遷移が終わったときに発火する transitionend イベントを使う方法です。

ここに落とし穴があります。CSSの transition が実質ゼロ秒になっていたり、変化する値が変わらなかったりすると、一般に transitionend は発火しません。「動きを抑える」設定のもとで演出を省く実装にすると、解除を待つイベントが一度も来ないことがあります。

アソバースの公式13作品は、すべて「動きを抑える」設定に対応していると確認されています。この設計から読み取れるのは、演出を省いた経路でも、進行がきちんと成立している必要があるということです。演出の終わりを解除の合図にしている作品なら、演出を省く経路で解除が来なくなる危険を避けなければなりません。各作品が実際にどう実装しているかは確認済みの情報にありません。この部分は、作品を開いて「動きを抑える」を有効にし、遷移を繰り返して確かめます。

対策は二つ重ねるのが堅実です。

  • 「動きを抑える」が有効なときは、演出を待たずにすぐ次へ進みます。
  • 演出を待つ場合は、終了イベントだけに頼らず、保険のタイマーも置きます。

次のコードは、この二つを入れた最小の構成です。単独で動きます。

html
<button id="go">はじめる</button>
<div id="screen">タイトル</div>
<p id="log"></p>
<style>
#screen{transition:opacity .4s}
#screen.out{opacity:0}
@media (prefers-reduced-motion: reduce){#screen{transition:none}}
</style>
<script>
let state = 'title', runs = 0;
const go = document.getElementById('go');
const screenEl = document.getElementById('screen');
const log = document.getElementById('log');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;

function fadeOut() {
  return new Promise(resolve => {
    screenEl.classList.add('out');
    if (reduce) return resolve();          // 演出を省く場合は待たない
    const done = () => {
      clearTimeout(timer);
      screenEl.removeEventListener('transitionend', done);
      resolve();
    };
    const timer = setTimeout(done, 600);   // 保険
    screenEl.addEventListener('transitionend', done);
  });
}

async function toPlaying() {
  if (state !== 'title') return;           // 入口のガード
  state = 'leaving';
  go.disabled = true;
  runs++;
  try {
    await fadeOut();
    state = 'playing';
    screenEl.textContent = 'プレイ中';
    screenEl.classList.remove('out');
  } catch (e) {
    state = 'title';                       // 失敗時だけ受け付けを戻す
    go.disabled = false;
  }
  log.textContent = '遷移が走った回数: ' + runs;
}
go.addEventListener('click', toPlaying);
</script>

読みどころは3点です。

  • state を書き換えるのは toPlaying の中だけで、入口で 'title' 以外を弾いています。
  • 成功した場合は 'playing' へ進み、タイトルのボタンは戻しません。解除が必要なのは失敗時だけです。
  • 保険のタイマーは、transitionend が来ない場合の最後の手段です。タイマーの時間は、演出の長さより少し長くします。短すぎると、演出の途中で次へ進みます。

公式作品から読み取れる「受け付けの境界」

入力の受け付けが、ゲームの採点や進行に直結している公式作品を見てみます。確認済みの範囲で、設計から読み取れることを挙げます。

  • こだまの森:聴き直しは15点減、間違いは30点減で、3回で終了します。入力が1回ごとに点数と終了条件へ影響するため、「どの瞬間の入力を1回と数えるか」の境界が、そのまま公平さに関わります。一時停止があるので、一時停止中の入力の扱いも境界の一部です。連打したときの挙動は、作品を開いて確かめます。
  • 時計じかけの台所:本番は、針が緑の範囲に来たときに止めて配膳します。ミスは3回までです。止める操作を連続で入力したときにミスがどう数えられるかは、確認済み事項にありません。作品で試します。
  • かたちのあいだ:一手戻すを使っても、手数は減りません。「操作を数える」ルールが明確で、戻す操作も数の対象になっていることが読み取れます。
  • 潮目のオセロ(オンライン対局):手番・合法手・勝敗はサーバーで確認されます。オンラインでは、手元の連打に左右されず、サーバー側で受け付けの正しさが守られる構造です。ひとり対局側の連打時の挙動は、確認済み事項にないため、作品で確かめます。

共通して言えるのは、受け付けの境界を決めることが、画面遷移だけでなくゲームのルールそのものの設計だという点です。

連打を自分のゲームで診断する手順

自分のゲームで、受け付け制御が壊れていないかを確かめる手順です。

準備するもの

  • 診断したい遷移(タイトルからプレイ、プレイから結果、など)
  • 遷移の開始回数と完了回数を数えるログ(上のコードの runs のような変数)
  • ブラウザの開発者ツール。多くのブラウザで、「動きを抑える」設定を擬似的に切り替える機能が使えます。

手順

  1. 「動きを抑える」を無効にして、対象のボタンを1秒間に5回以上、約3秒間押します。これを10回繰り返します。
  2. キーボードのEnterキーを、押しっぱなしにして同じ遷移を試します。これも10回です。
  3. 「動きを抑える」を有効にして、1と2を繰り返します。
  4. 遷移の途中で、ブラウザのタブを切り替えたり、画面を拡大したりして、同じ確認をします。

記録の取り方

毎回、次の4項目を記録します。

  • 遷移の開始回数(連打の回数に対して何回走ったか)
  • 遷移の完了回数
  • 最終的な状態(目的の画面で止まったか)
  • 操作が戻るべき場面で、ボタンが復帰したか

結果の読み方

  • 開始回数が2回以上、疑う箇所:入口のガードがない、または状態を書き換えるのが遅い
  • 「動きを抑える」有効時だけ、進めなくなる、疑う箇所:解除を演出の終了イベントに頼っている
  • Enter長押しだけ二重になる、疑う箇所:キーボード経路に別の入口がある
  • 例外の後に、ボタンが戻らない、疑う箇所:失敗時の戻し処理がない

1回の試行だけで判断せず、10回のうち何回で起きたかを見ます。再現率が低い不具合は、タイミング依存のことが多いためです。

次の行動

自分のゲームで、遷移を始める関数の1行目に、状態を見るガードを入れるところから始めます。続けて、解除が演出の終了イベントだけに頼っていないかを確認し、保険のタイマーと「動きを抑える」時の分岐を足します。最後に、上の診断手順を10回ずつ行い、開始回数が1回になっているかを確かめます。

参考資料

  • MDN Web Docs「transitionend イベント」
  • MDN Web Docs「prefers-reduced-motion」
  • MDN Web Docs「KeyboardEvent: repeat プロパティ」