5本目まで、当たり判定も移動も全部自分で計算していました。だからテストは簡単でした。同じ種を渡せば同じ結果が出ます。
6本目で初めて Unity の物理(Physics2D)を使いました。落ちて、ぶつかって、転がる。ここで最初に確かめたのは、遊びの面白さではなくこれです。
同じ操作をしたら、同じ結果になるのか。
ならないなら、リプレイも作れませんし、テストの書き方も変えなければいけません。
5回
同じ種で回した回数
同じ入力の規則
一致
置いた数・記録(cm)
5回とも同じ
1フレーム
着地判定のずれ
1回おきに発生
0.13秒
物理テスト5件の実行
実時間を待たない

測り方:時間を自分で進める
実時間を待つと、60秒のプレイを5回で5分かかります。そこで物理の進め方を切り替えました。
Physics2D.simulationMode = SimulationMode2D.Script;
Physics2D.gravity = new Vector2(0f, -11f);
for (int f = 1; f <= 3000; f++)
{
var obs = pieces.Observe(0.02f); // 物理の結果を読む
game.Tick(0.02f, input, obs); // ルールを1ステップ進める
Physics2D.Simulate(0.02f); // 物理を1ステップ進める
}入力も規則で決めます。「吊り下げがまん中に来たら離す」。種が同じなら形も揺れ方も同じなので、この規則だけで入力列も必ず同じになります。人の手を借りずに、同じプレイを何度でも作れます。
ゲームのルールも 物理と同じ 0.02 秒刻み(FixedUpdate)で進めます。Update で進めると、フレームの間隔がばらついた瞬間に「同じ入力」が成り立たなくなります。
結果:記録は一致、フレームは1回おきにずれる
同じ種(4242)で5回続けて回しました。
こうすると壊れる
こうする
| 回 | 離したフレーム | 山のてっぺん |
|---|---|---|
| 1 | 86,149,263,441,618 | -6.7995430 |
| 2 | 86,149,263,441,617 | -6.7994170 |
| 3 | 86,149,263,441,618 | -6.7995430 |
| 4 | 86,149,263,441,617 | -6.7994170 |
| 5 | 86,149,263,441,618 | -6.7995430 |
きれいに2つの状態を行き来しています。奇数回どうし・偶数回どうしは、小数第7位までぴったり同じでした。
差は 0.000126 ユニット。このゲームの表示に直すと 0.00126 cm です。それでも「止まった」と判定されるフレームが1つずれ、次に手を離せるようになるタイミングまで動きました。
⚠️ 注意
1回のプレイの中では完全に再現します。ずれるのは、前のプレイを片づけて次を始めたときです。原因は確かめられていません(作り直しの順番が影響していると見ていますが、Unity の内部は見ていないので推測です)。
エディターとブラウザでも比べた
ここまでは全部エディター(Mono・x64)の話です。実際に遊ばれるのは Web ビルド(IL2CPP・wasm)なので、同じ手順をどちらでも動かせる入口をゲームに作りました。?probe=1 を付けて開くと、同じ種・同じ入力で3回回して結果をコンソールに出すだけのモードです。
| エディター(Mono・x64) | Web ビルド(IL2CPP・wasm) | |
|---|---|---|
| 離したフレーム | 86, 149, 263, 441, 617 | 86, 149, 262, 440, 617 |
| 山のてっぺん | -6.7994170 | -6.7991150 |
| 置けた数・落とした数 | 2 個・3 個 | 2 個・3 個 |
| 記録 | 12 cm | 12 cm |
環境が変わっても、記録は同じだった。
ずれたのは中間の操作フレーム(1フレーム)と、てっぺんの座標 0.000302 ユニット(0.003cm)です。同じエディターの中で回したときのずれ(0.000126)より大きいものの、桁は同じでした。
Web ビルドの側でも、同じプロセスの中で2つの状態を行き来します(差 0.000038)。エディターで見た現象は、環境を変えても消えませんでした。
そこで、合格ラインをこう書いた
「同じ結果になること」を条件にすると、このテストは1回おきに落ちます。落ちるテストは、そのうち誰も見なくなります。
Assert.AreEqual(a.Placed, b.Placed, "置けた数は変わらない"); Assert.AreEqual(a.Misses, b.Misses, "落とした数は変わらない"); Assert.AreEqual(a.HeightCm, b.HeightCm, "記録(cm)は変わらない"); Assert.AreEqual(a.TopY, b.TopY, 0.01f, "山のてっぺんの差は 1cm 未満");
測った値(0.000126)より2桁広い 0.01 を許容にしました。これなら普段は落ちず、設計を壊すような変更をしたときには落ちます。
物理を「1か所」に閉じ込める
もうひとつ決めたのは、置き場所です。ルールの側は今までどおりエンジンから切り離し、物理の結果だけを外から受け取る形にしました。
public readonly struct Observation
{
public readonly float TopY; // 積み上がった山のてっぺん
public readonly float ActiveX; // いま落としている塊の位置
public readonly float ActiveY;
public readonly bool ActiveAsleep; // その塊が止まったか
public readonly bool AllAsleep; // 山全体が止まったか
}こうすると、段階の切り替え・スコア・ミスの判定は物理なしでテストできます。「こう観測された」を手で渡すだけです。実際、ルール側のテスト33件は 4.3 秒で終わり、シーンを1つも開きません。物理そのものは PlayMode の5件だけで見ます。
つまずいたところ
- 症状
- 塊を吊り下げた位置から落としたはずが、毎回ちがう場所から落ちてくる
- 原因
- 使い回すために隠していた(SetActive(false))ゲームオブジェクトに、位置を入れていた。隠れている間は物理の体が無いので、入れた値は捨てられる
- 対処
- 先に有効にしてから位置・角度・速度を入れる。順番を入れ替えただけで直った
最初は原因が見えず、「物理が暴れている」と思い込んでいました。落とした位置と着地の位置を1行ずつ出して並べたら、どの塊も同じ場所から落ちていることが分かり、そこで気づきました。
- 症状
- 高さの判定が、見た目をなめらかにした瞬間にぶれ始めた
- 原因
- 当たり判定の箱(collider.bounds)から高さを読んでいた。補間(Interpolate)を入れると、描画用の位置は物理の位置と少しずれる
- 対処
- 物理の位置と角度から、頂点を回して自分で計算する。描画の設定に影響されなくなった
この方法が向いているもの・向いていないもの
こうすると壊れる
こうする
動くものをエンジン抜きでテストする話はUnityのゲームロジックをテストする方法に、テストとビルドを AI に回させる手順はAIにUnityのテストとビルドを実行させるに書きました。