AIゲーム制作ラボ
Unity×AI6分で読めます

動くものの挙動をテストする(時間を引数で進める設計)

敵や弾のように毎フレーム動くものは、遊んでみないと分からない——と思われがちです。時間を引数で進める形にして、エンジンを参照しない場所に置くと、10分ぶん(36,000フレーム)を1秒未満で回せます。4本目で実際に書いたテストと、踏んだ落とし穴を残します。

Libra

公開 2026年9月19日

この記事の要点4 points

  1. Tick(float dt) にすると、何万フレームでもテストから回せる10分ぶん=36,000フレームが一瞬)。
  2. Core は UnityEngine を参照しない設定にして、コンパイラに守らせる(Vector2 も使えない)。
  3. 乱数は自前の種つきにする。 同じ種なら同じ波が出るので、あとから同じ場面を作れる。
  4. テスト32件0.8秒 シーンもゲームオブジェクトも作らない。
目次6
検証した環境最終確認 2026年9月19日
  • Windows 11
  • Unity 6.6(6000.6.0f1)
  • Claude Code(Opus 5 / 思考レベル 高)

書いてあるのは、この環境で実際に試した結果です。ツールの仕様や料金は変わるので、重要な判断の前には公式の情報も確認してください。

弾が飛び、敵が降りてきて、当たれば消える。こういう毎フレーム動くものは「遊んでみないと分からない」と思われがちです。

4本目のゲームでは、遊ばずに確かめられる形にしました。方法は1つだけです。

時間を、外から渡す。

36,000

テストで回すフレーム

10分ぶんを一瞬で

32件

EditMode のテスト

0.8秒

0個

テストで作るシーン

エンジンを使わない

6,000

確保0バイトを見張る回数

フレーム

Tick(float dt) にする

ゲームの中身(自機・弾・敵・当たり判定・点)は、MonoBehaviour ではない普通のクラスに置いています。時間は外から渡します。

public void Tick(float dt, in InputState input)
{
    if (State != BattleState.Playing) return;

    Time += dt;
    MovePlayer(dt, input);
    UpdateTimers(dt);
    Fire(dt);
    MoveShots(dt);
    MoveEnemies(dt);
    HitEnemies();
    HitPlayer();
}

Unity 側(Update)がすることは、これだけになります。

void Update()
{
    _director.Tick(Time.deltaTime, Battle);
    Battle.Tick(Time.deltaTime, ReadInput());
    _view.Render(Battle);
}

テストからは、1/60 秒を好きな回数だけ渡します。

const float Frame = 1f / 60f;

static void Run(Battle battle, float seconds, InputState input)
{
    int frames = (int)(seconds / Frame);
    for (int i = 0; i < frames; i++) battle.Tick(Frame, input);
}

エンジンを参照しないことを、コンパイラに守らせる

「Unity に依存しないように書く」は、気をつけているだけでは守れません。asmdef の設定で参照そのものを切りました。

{
  "name": "Mureuchi.Core",
  "noEngineReferences": true,
  "references": []
}

こうすると Vector2Mathf も使えません。座標は自前の小さな構造体にしています。

public readonly struct Vec2
{
    public readonly float X;
    public readonly float Y;

    /// <summary>長さの2乗。距離の比較はこれで足りる(平方根を取らない)</summary>
    public float SqrMagnitude => X * X + Y * Y;
}

不便さが、そのまま速さになることもある

Vector2 が無いので距離の比較を自分で書くことになり、自然と平方根を取らない書き方になりました。当たり判定は「半径の和の2乗」と比べるだけで足ります。

種を決めれば、同じ波が出る

敵の出方は乱数です。そのままだとテストで固定できないので、種を渡せる乱数を自前で持っています(xorshift・十数行)。

public uint NextUInt()
{
    uint x = _state;
    x ^= x << 13;
    x ^= x >> 17;
    x ^= x << 5;
    _state = x;
    return x;
}

これで「同じ種なら同じ波」が保証でき、こういうテストが書けます。

[Test]
public void 同じ種なら同じ波が出る()
{
    Assert.That(Play(2026, 20f), Is.EqualTo(Play(2026, 20f)));
}

Play20秒ぶん回して、出てきた敵の種類と位置を文字にして返すだけです。同じ文字になれば同じ波

実際に書いたテスト

動くものについて見張っていること

弾は決めた間隔で出る(0.5秒で4〜5発)
画面の外に出た弾は消える(増え続けない)
弾が当たると敵が消えて点が入る。硬い敵は複数回当てないと落ちない
敵にぶつかるとライフが減り、1秒は無敵(続けて減らない)
揺れる敵は画面からはみ出さない
撃ってくる敵は、自機の方向へ撃つ
10分ぶん(36,000フレーム)回しても、上限を超えず例外も出ない
6,000フレーム進めても、新しくメモリを確保しない

最後の2つは、遊びでは気づけない種類のバグを捕まえます。

01

上限を超えて増える

症状
長く遊んでいると、だんだん重くなる
原因
消し忘れ・画面外に出たものを片づけていない
対処
10分ぶん回して、数が上限以下であることを毎フレーム確かめる
02

毎フレーム少しずつ確保している

この形にしてから、確保は0バイトのまま
症状
ときどきカクつく(掃除が走る)
原因
`Tick` の中で配列やリストを作り直している
対処
GC.GetTotalMemory を前後で比べ、増えていたら落ちるテストにする
Git BashOK
run_tests	true	32/32 件成功 (EditMode, 0.82s)

dt を固定しないと再現しない

1つ注意があります。同じ種でも、渡す時間(dt)が違えば結果は変わります。 実機の Time.deltaTime は毎フレーム揺れるので、テストと本番でまったく同じにはなりません。

⚠️ テストで確かめられるのは「決まりごと」

テストが守るのは「ルールどおりか」であって、「実機でまったく同じ画になるか」ではありません。実機の揺れまで再現したいなら、固定の時間刻みで進める作り(固定タイムステップ)にする必要があります。4本目ではそこまでは必要ないと判断しました。

まとめ

  • 動くものは Tick(float dt) にする。時間を外から渡せば、テストから何万フレームでも回せる
  • Core は noEngineReferences。エンジンの型が混ざらないことをコンパイラに守らせる
  • 乱数は種つき。同じ種なら同じ波=あとから同じ場面を作れる
  • 「長く回す」「確保が増えない」は、遊びでは気づけないバグを捕まえる
  • dt が違えば結果は変わる。テストが守るのは決まりごとであって、実機の画そのものではない

FAQ

よくある質問

動きのあるゲームは、どうテストしますか?

時間を引数で受け取る形(Tick(float dt))にして、そのクラスを Unity から切り離します。テストでは 1/60 秒を必要な回数だけ渡せばよく、シーンを開く必要がありません。

MonoBehaviour の Update に書いてはいけませんか?

動かすだけなら書けますが、テストのたびに再生モードへ入ることになります。判定・移動・出現などの決まりごとを普通の C# クラスに移すと、EditMode のテストで一瞬で回せます。

同じ場面を再現するには?

乱数に種を渡せる形にします。System.Random ではなく短い自前の実装(xorshift)にすると、どの環境でも同じ並びになり、テストでも本番でも同じ波を作れます。

エンジンを参照しないことは、どう保証しますか?

asmdef の noEngineReferences を立てます。UnityEngine の型を使った時点でコンパイルが通らなくなるので、うっかり混ざりません。

この記事は役に立ちましたか?

NEXT