「押してないのにミスになる」を潰した300ミリ秒

制限時間が2秒のゲームで起きた、理不尽な失点のバグ。原因は時間切れと次の問題の切り替わりが同じフレームで起きることでした。

ラッシュ系のゲームを作っていて、いちばん厄介だったバグの話です。

症状はこうでした。時間切れになった直後、押してもいない次の問題で即座にミスになる。しかも毎回ではなく、制限時間が2秒まで縮んだ終盤にだけ起きます。

遊んでいる側からすると、こう見えます。「時間切れでライフが減った。あ、次の問題だ」と思った瞬間、もうライフがもう1つ減っている。何が起きたのか分かりません。

何が起きていたか

原因は、時間切れの検出と次の問題の表示が、同じフレームで同期的に起きていたことでした。

ラッシュ系ゲームの毎フレームの処理は、こうなっています。

update() が毎フレーム呼ばれる
  → 経過時間を計算
  → 残り時間が0以下なら onTimeout() を呼ぶ
      → handleWrong() でライフを1つ減らす
      → nextProblem() で次の問題を生成する

問題は最後の行です。nextProblem() が呼ばれた瞬間、画面上の問題も、内部で保持している正解も、その場で新しいものに差し替わります

いっぽう、プレイヤーの指はどうでしょうか。制限時間ぎりぎりまで粘って、時間切れの直前にタップしたとします。タップのイベントがブラウザから届くのは、ほんのわずかに遅れます。その数ミリ秒の間に、問題が次のものに切り替わっている

結果、「前の問題に対する回答」が「新しい問題に対する回答」として採点されます。正解であるはずがありません。だから即座にミスになるのです。

なぜ2秒モードでだけ起きたのか

制限時間が10秒あるうちは、時間切れになる前にほとんどの人が答えます。時間切れ自体がめったに起きないので、このバグも表面化しませんでした。

制限時間が2秒になると話が変わります。間に合うか間に合わないかの境界で答えることが増えるので、「時間切れの直前にタップした」という状況が頻発します。

このゲームでいちばん緊張感のある場面で、いちばん理不尽なことが起きていたわけです。最悪の場所にあるバグでした。

解決策:時間切れのあとだけ入力を止める

いくつか案を考えました。

案1:タップの時刻と時間切れの時刻を比較する

タップイベントに記録されている時刻を見て、時間切れより前なら前の問題への回答として扱う。理屈は正しいのですが、時刻の基準がブラウザによって微妙に違ううえ、境界の扱いで新しいバグを生みそうでした。時刻の比較は、ずれたときに原因が追えなくなります

案2:問題ごとに通し番号を振る

回答イベントに「どの問題への回答か」を持たせて、番号が一致しなければ無視する。これは堅実です。ただ、入力を扱う全ての場所に番号を持ち回る必要があり、コードが煩雑になりました。

案3:時間切れの直後だけ、少しの間なにも受け付けない

最終的にこれを選びました。時間切れでライフを減らしたあと、300ミリ秒だけ入力を無効にしてから、次の問題を出す

if (reason === 'timeout') {
  this.playing = false;
  this.time.delayedCall(300, () => {
    this.playing = true;
    this.nextProblem();
  });
  return;
}

playing が false のあいだは、入力ハンドラの先頭で弾かれます。遅れて届いたタップは、行き場がなく捨てられます。

なぜ300ミリ秒なのか

短すぎると効きません。長すぎるとテンポが悪くなります。

100ミリ秒では、端末によってはまだ取りこぼしがありました。処理が重い瞬間はフレームが飛ぶので、そのぶん入力の到達も遅れます。

500ミリ秒にすると、今度は**「次の問題がなかなか出ない」**とはっきり感じられるようになりました。2秒で答えるゲームで0.5秒待たされるのは、体感としてかなり長いのです。

300ミリ秒は、取りこぼしがなくなり、かつ待たされた感じがしない境界でした。しかもこの300ミリ秒には、思わぬ副産物がありました。

待ち時間が「失敗を理解する時間」になった

入力を止めている300ミリ秒のあいだ、画面には時間切れになった問題と、ライフが減る演出が表示されたままになります。

修正前は、時間切れの瞬間に次の問題へ切り替わっていたので、何を間違えたのか確認する暇がありませんでした。気づいたらライフが減っている。

300ミリ秒の間があることで、「あ、いま時間切れになったんだ」と認識できるようになりました。バグを直すために入れた待ち時間が、結果的に体験の質を上げていたわけです。

こういうことは、たまにあります。技術的な制約への対処が、そのままデザインとして機能する。

ミスのときは待たない

ひとつ細かい話があります。この300ミリ秒の待ちは、時間切れのときだけ適用しています。プレイヤーが自分でタップして間違えた場合は、待たずにすぐ次の問題に進みます。

理由は、自分で押して間違えた場合は、何が起きたか本人が分かっているからです。確認の時間は要りません。むしろテンポを止めるほうが邪魔になります。

同じ「ミス」でも、原因が違えば適切な扱いは違う。そう考えて、handleWrong() には「なぜ間違えたのか」を表す引数('miss''timeout' か)を渡すようにしています。

学んだこと

このバグから引き出した教訓は2つあります。

ひとつは、状態が切り替わる瞬間は必ず疑うべきだということ。時間切れ、レベルアップ、ゲームオーバー。こうした境界では、直前の入力と直後の状態が食い違う可能性が常にあります。

もうひとつは、ユーザーから見て何が起きたかで判断することです。実装から見れば「1フレーム内で正しく処理された」のですが、遊ぶ側から見れば「押していないのにミスになった」でしかありません。正しく動いていることと、正しく感じられることは別です。

この300ミリ秒の入力ロックは、いまではラッシュ系ゲームを新しく作るときの必須項目として、プロジェクトの設計ガイドラインに「省略不可」と明記してあります。一度踏んだ落とし穴には、柵を立てておくべきなので。

実際の挙動は計算ラッシュ方向パニックの終盤、2秒モードで時間切れになったときに確かめられます。ライフが減ってから次の問題が出るまで、ほんの一拍の間があるはずです。

広告