マガジン

たまに敵が消える:乱数と境界の組み合わせ

遊びをつくる

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

道をつなぐ線、丸い駒、幾何学のタイルからなるパズルの模型。
記事のテーマをもとに制作したAI生成イメージ。実際のゲーム画面ではありません。

自作のシューティングを遊んでいる場面を考えてみます。敵は画面の外から乱数で決めた位置に出てきます。何十回遊んでも問題ないのに、ごくたまに、出てきたはずの敵が一瞬で消えます。もう一度同じように遊んでも起きません。「気のせいかもしれない」と放置したくなるタイプの不具合です。 この種の不具合は、乱数だけ、あるいは境界だけが原因であることはまれです。「乱数がある値の端に当たる」と「境界の判定がその端を別扱いする」の2つが同時に起きたときだけ発動します。条件が重なるので頻度が下がり、再現しにくくなります。 この記事では、この「たまに」を分解し、シード(乱数の出発点となる値)と記録を使って確実に再現させる方法を整理します。後半では、アソバースの公式作品の設計から読み取れる「再現しやすさ」の工夫にも触れます。

この記事の目次
  1. 「たまに」は条件の積でできている
  2. 乱数と境界が重なりやすい5つの場所
  3. シードを固定して、同じ数列を再生する
  4. 消えた理由を記録する
  5. 再現の手順:捕まえるまでの流れ
  6. 公式作品の設計から読み取れること
  7. 次の行動

「たまに」は条件の積でできている

まず、仮想の例で確率の感覚をつかみます。画面の幅を800とします。敵の出現位置xを、0から800までの整数で選びたいとします。

JavaScriptの Math.random() は、0以上1未満の値を返します。これに800を掛けて小数を切り捨てると、0から799までの整数になります。「800も含めたい」と考えて801を掛けると、0から800までになります。ここで、画面外の判定が「xが800以上なら画面外として削除する」と書かれていたとします。

  • 乱数が一様なら、xが800になる確率は801分の1ほどです。
  • そのとき、出現した敵は生成の直後に画面外と判定されて消えます。
  • さらに「右端から出現するパターンのときだけ」という条件が付けば、頻度はもっと下がります。

これらは、確認のための仮の数字です。大事なのは、どれか一つの条件を見ても、不具合の原因とは気づきにくいことです。乱数の範囲の指定も、画面外判定も、単体で読めばそれらしく見えます。「たまに」起きる不具合は、条件の積でできていると考えると、探す方向が決まります。

乱数と境界が重なりやすい5つの場所

敵が消える不具合で、乱数と境界が重なりやすい場所を表にまとめます。いずれも一般的なゲーム制作でよく話題になるものです。自分のコードに当てはまるかを確認する目安にしてください。

  • 範囲の端の扱い、起こりやすいこと:「以上」と「より大きい」の書き間違いで、端ちょうどの値だけ削除される、乱数との関係:端の値を引いたときだけ発動する
  • 生成直後の画面外判定、起こりやすいこと:画面の外側で生成した敵が、最初のフレームで「画面外」と判定されて消える、乱数との関係:生成位置や速度の乱数次第で、判定を踏む
  • 壁のすり抜けと押し戻し、起こりやすいこと:1フレームの移動量が壁の厚みを超え、すり抜けたあとに「領域外」として削除される、乱数との関係:乱数で決めた速度が大きい個体だけ起きる
  • 配列の添字のずれ、起こりやすいこと:ループ中の削除で番号がずれ、別の敵を消す、または消したい敵が残る、乱数との関係:同じフレームに複数の敵が消えるときだけ起きる
  • 無効な数値の混入、起こりやすいこと:0での除算などで座標が無効な値(NaN)になり、比較がすべて偽になって消える、または描画されなくなる、乱数との関係:乱数が0になる、あるいは同じ位置になったときだけ起きる

「消える」にも二通りあることに注意してください。データとして削除されている場合と、データは残っていて表示されないだけの場合です。前者は削除の処理が呼ばれており、後者は座標や透明度、描画順の問題です。切り分けは、後述する記録の段階で行います。

乱数の範囲指定は、まず疑う

Math.random() は1を含みません。そのため「0からnまで」を作るのか、「0からn-1まで」を作るのか、どちらの意図でコードを書いたのかが曖昧になりやすい箇所です。配列から要素を選ぶ場面では、長さ を掛けて切り捨てれば範囲はちょうど収まります。一方、座標のような連続量では、端を含めるかどうかの意図がコードに残りません。乱数の範囲を決めた行に「端を含む/含まない」を日本語でコメントしておくだけでも、後から矛盾に気づけます。

シードを固定して、同じ数列を再生する

再現しにくい不具合を捕まえる主役はシードです。Math.random() は出発点を指定できないため、そのままでは「あのときの乱数」を呼び戻せません。そこで、シードを受け取る小さな疑似乱数生成器を自作するか、定評のある短い実装を使います。短い実装としては、mulberry32のような名前で知られるものがあります。同じシードを渡せば同じ数列が出てくる、という性質が目的に合っています。

シードを導入するときに、つまずきやすい点が3つあります。

  1. 乱数を呼ぶ順序が変わると、再現しなくなります。 敵の配置、エフェクトの揺らぎ、効果音の高さが同じ乱数列を共有していると、見た目の演出を一つ足しただけで、敵の出現位置がずれます。ゲームの進行に関わる乱数と、見た目だけの乱数は、別々の生成器に分けます。
  2. 時間が乱数以外の入力になっていないかを確認します。 フレームごとの経過時間をそのまま移動量に掛けていると、同じシードでも端末の負荷によって結果が変わります。再現を優先するなら、更新を一定の時間刻みで行う固定タイムステップにするか、経過時間そのものを記録します。
  3. 入力も記録に含めます。 プレイヤーが動いた位置によって敵の出方が変わる設計なら、シードだけでは足りません。どのフレームで何を押したかを残せば、同じ操作を再生できます。

シードの決め方は、普段は起動のたびにランダムに決め、その値を画面の隅に小さく表示しておく形が扱いやすいです。不具合に出会ったら、その数字を控えるだけで再現の入口に立てます。さらに、URLの末尾やテキスト欄などから手動でシードを指定できるようにしておくと、報告された数字を自分の環境へ流し込めます。

消えた理由を記録する

シードがあっても、「どこで消えたか」がわからなければ原因の特定に時間がかかります。そこで、敵を消す処理を一か所に集め、消す理由を必ず文字列で受け取る形にします。

記録する項目の例は次のとおりです。

  • シードの値
  • フレーム番号(または経過時間)
  • 敵の識別番号、種類、生成時の座標、消えた時点の座標と速度
  • 消した理由(「画面外判定」「体力ゼロ」「衝突」「生成直後の整理」など)
  • 消した処理の呼び出し元を示す短い印

理由の文字列が「画面外判定」だったのに、座標が画面内にあれば、判定式の境界に問題があるとわかります。座標が無効な値だったなら、その前の計算を遡れます。理由を記録せずに消していた敵があれば、そこにも手がかりが残ります。消す場所が散らばっているコードでは、一か所に集める作業だけでも、不具合の絞り込みが進みます。

記録の量は、全フレームを残す必要はありません。直近の数百件だけを保持して古いものから上書きする方式(リングバッファと呼ばれます)にして、消えた瞬間にその周辺だけを取り出せれば十分です。

記録の置き場所をどうするか

アソバースに投稿する作品なら、記録の保存先は次の点を踏まえて決めます。

  • アップロードした作品の中の localStorage や sessionStorage は、一時メモリーとして動く互換処理です。再訪問してもログが残る前提にはしないでおきます。
  • クラウドセーブは作品ごとにJSONで128KiBまでです。作品のURLを投稿する場合は、作者がSDKを読み込むと使えます。セーブの用途は進行の保存が本来なので、不具合のログを大量に入れるのは避けます。
  • 外に持ち出す手段が必要なら、画面にテキストとして表示し、コピーできるようにするのが確実です。シードと直近のログ数行なら、それだけで再現の手掛かりになります。
  • なお、ゲームにはFirebaseのUIDや認証トークン、メールアドレスを渡さない決まりです。ログに個人を特定する情報を混ぜないよう注意してください。

再現の手順:捕まえるまでの流れ

ここまでの内容を、手順としてまとめます。

  1. 敵を消す処理を一か所に集め、消す理由を文字列で受け取るようにします。
  2. 乱数をシード付きの生成器に置き換え、ゲーム進行用と見た目用に分けます。
  3. 起動時のシードを画面の隅に表示し、直近のログを表示・コピーできるようにします。
  4. 普通に遊んで、敵が消えたと感じたらすぐにシードとログを控えます。
  5. 控えたシードを指定して起動し、同じ操作を繰り返します。出現位置と消えた位置、理由がログと一致するかを確認します。
  6. 一致したら、表の5つの場所と照らし合わせます。理由が「画面外判定」なら範囲の端の扱い、消えた時間が生成直後なら生成直後の判定を、まず疑います。
  7. 修正後は、同じシードで再度確認します。さらに、端の値だけを意図的に出す確認用の入力を用意しておくと、「たまに」を待たなくて済みます。

手順7が、この記事でいちばん重要な考え方です。原因の見当がついたら、乱数の結果を人工的に端の値へ固定して、その場で起こしてみます。確率に任せて待つ作業を、決まった条件の確認作業に置き換えられます。

公式作品の設計から読み取れること

アソバースの公式作品には、敵が出てくる作品はありません。しかし、再現や境界の扱いという視点で見ると、設計から読み取れる点がいくつかあります。以下は確認済みの機能にもとづく読み取りで、内部の実装や制作の経緯を示すものではありません。

  • かたちのあいだには「同じ配置の再挑戦」があります。盤面を同じ状態からやり直せる設計です。シードを使っているのか、配置を丸ごと保持しているのかは公開情報からはわかりません。作品を開いて、再挑戦したときの配置が一致するかを確かめてみてください。
  • 小さな軌道には、全軌道予測(点線)と前回の軌道(細い実線)の表示があります。今回と前回を重ねて見比べられる作りは、「何が変わったのか」を目で比較する発想として参考になります。自作でも、前回のログと今回のログを並べて見る機能は役に立ちます。
  • 時計じかけの台所では、針が緑の範囲に来たら止める遊びがあります。範囲の端ちょうどで止めたとき、成功とみなされるかどうかは、境界の扱いそのものです。この部分は作品を開いて確かめるのがよい題材です。
  • 紙ひこうき郵便は、風船にぶつかると紙が傷み、3回で終了します。ルートごとの制限時間や手紙の数も決まっています。ぶつかり判定の端をどう扱うかや、風船の配置が毎回変わるのかは、作品を開いて確かめる部分です。

作品を遊ぶときは、「端ちょうどの値で何が起きるか」を観察の問いにすると、自作の不具合を見つける目も鍛えられます。

次の行動

自作の敵の出現処理を開き、乱数で範囲を決めている行を探してください。端の値を含めるか含めないか、その意図と、画面外判定の比較式(以上か、より大きいか)が合っているかを見比べます。次に、敵を消す処理を一か所にまとめ、理由を文字列で記録する変更を一つだけ加えてみてください。それだけで、次に「たまに」が起きたときの手掛かりが残ります。