25個の円を重ならないようにランダムに置く
ランダムに置くと必ず重なります。やり直しを「1個単位」ではなく「全体単位」にしたら解決した話と、どうしても失敗したときの保険について。
ナンバーハントは、1から25までの数字が書かれた円を画面に散らばらせて、小さい順にタップしていくゲームです。
このゲームで最初に解くべき問題は、25個の円をどう配置するかでした。ランダムに置くと、当然ながら重なります。数字が別の数字の下に隠れてしまうと、そもそも見つけられません。これは難易度ではなく欠陥です。
素朴な方法とその限界
いちばん単純なのは、置いてみて、近すぎたらやり直す方法です。
1個ずつ、ランダムな座標を選ぶ
→ 既に置いた円のどれかと近すぎたら、座標を選び直す
→ 十分離れていたら確定して次へ
これで大抵はうまくいきます。実際、この方法を採用しました。ただしそのままでは詰まることがあります。
詰まる理由:先に置いた円が悪い
25個目を置こうとして、どこにも空きが見つからない。何度座標を選び直しても入らない。
このとき問題なのは25個目ではありません。それまでに置いた24個の配置が悪いのです。円が偏って固まってしまい、残りが入る隙間がなくなっている。
25個目だけを300回試しても無駄です。原因は過去にあるので、現在をいくらいじっても解決しません。
解決策:やり直しの単位を上げる
そこで、失敗したらレイアウト全体を最初から作り直すことにしました。
for (let retry = 0; retry < 10; retry++) {
const result: BubblePos[] = [];
let failed = false;
for (let i = 0; i < count; i++) {
let placed = false;
for (let attempts = 0; attempts < 300; attempts++) {
// ランダムな座標を選ぶ
if (/* 既存のどれとも近くない */) { placed = true; break; }
}
if (!placed) { failed = true; break; } // この配置は詰んだ
result.push(...);
}
if (!failed) return result; // 成功
}
二重のループになっています。
- 内側:1個の円について、最大300回まで座標を試す
- 外側:全体の配置を、最大10回まで作り直す
1個でも置けなければ、その時点で内側を打ち切って(break)、外側のループでまっさらな状態からやり直します。
途中まで作ったものを捨てるのは無駄に見えますが、詰んだ配置を粘るより、引き直すほうが速く収束します。25個並べる処理は一瞬なので、10回やり直しても体感できるほどの時間はかかりません。
それでも失敗したら:格子に並べる
10回やり直しても入らない、という状況もありえます。極端に画面が狭い端末では、そもそも物理的に入らないかもしれません。
このとき何も返さないわけにはいかないので、保険として格子状に並べる処理を用意しています。
function gridFallback(count, areaW, areaH, offsetX, offsetY, radius) {
const cols = Math.ceil(Math.sqrt(count * (areaW / areaH)));
const rows = Math.ceil(count / cols);
// 各セルの中央に順番に置いていく
}
列数を √(個数 × 縦横比) から求めているのは、画面の形に合わせた格子にするためです。横長の画面なら列を多く、縦長なら行を多くします。
ランダム性は失われますが、数字が重なって遊べなくなるよりはずっとましです。整然と並んだ盤面が出たら、それは端末が狭かったということになります。
こういう「絶対に失敗しない最後の手段」を用意しておくと、上のロジックを安心して書けます。失敗が許されると、思い切った実装ができるというのは何度か経験しました。
円の大きさを画面から逆算する
もうひとつ工夫したのが、円の半径です。固定値にすると、狭い画面では入りきりません。
そこで、画面の面積から逆算しています。
export function computeRadius(areaW: number, areaH: number, count: number): number {
const area = areaW * areaH;
const maxRadius = Math.sqrt(area / (count * Math.PI * 3));
return Math.min(BUBBLE_RADIUS, Math.max(14, Math.floor(maxRadius)));
}
考え方は単純で、「面積を個数で割れば、1個あたりに使える面積が出る」というだけです。そこから円の半径を求めます。
係数の 3 は余裕分です。円がぴったり敷き詰められることはないので、実際に使える面積は理論値よりずっと少なくなります。この値が小さすぎると円が大きくなりすぎて配置に失敗し、大きすぎると必要以上に小さくなります。試して決めた数字です。
そのうえで、14ピクセルから24ピクセルの範囲に収めています。
- 下限14ピクセル:これより小さいと指で正確に押せません
- 上限24ピクセル:大きすぎると25個が入らず、やり直しが頻発します
小さい画面では円が小さくなりますが、必ず重ならずに25個入ることは保証されます。
距離の比較をどう書くか
「近すぎるか」の判定には、素直に距離を使っています。
if (!result.some((b) => Math.hypot(b.x - x, b.y - y) < minDist)) {
placed = true;
break;
}
ここでは平方根を使う Math.hypot を素直に呼んでいます。弾幕フィーバーの当たり判定では平方根を避けて2乗のまま比較しているのですが、こちらでは使っています。
理由は、呼ばれる回数がまったく違うからです。配置の計算はゲーム開始時に1回だけ、多くても数千回のループです。いっぽう当たり判定は毎フレーム数百回、1秒間に何万回も走ります。
最適化すべき場所とそうでない場所を区別するのは大事だと思っています。1回しか呼ばれない処理を最適化しても、コードが読みにくくなるだけです。
副産物:偏りがないことを説明できる
この実装のおかげで、攻略情報として「配置に偏りはありません」とはっきり書けるようになりました。
中央が出やすい、端が出にくい、といった傾向がないので、プレイヤーは先読みできません。逆に言えば、「見つからないのは自分が見落としているから」と断言できます。
実装を把握していると、攻略記事に書けることが増えます。「たぶんランダムです」ではなく「25マスから完全にランダムに選び、円同士が必ず一定以上離れるようにしています」と書ける。この差は、読む人にとって小さくないはずです。
実際の配置はナンバーハントで確かめてみてください。何度リセットしても、円が重なることはありません。