TDDでゲームを作ってみる REALITY Advent Calendar 2023
REALITY Advent Calendar 2023 5日目を担当するUnityチームのホンダです。
今年も恒例行事となってきた開発合宿に参加してきました!
今年のネタ
今回はタイトルにもあるとおりテスト駆動開発(Test-Driven Development: TDD)を用いてゲームを作ってみました!
直近の業務で自動テストの導入のため奮闘しているのですが、テストという文脈でTDDという単語もよく出てくるので、試しに使ってみるかというのが動機です。あくまでTDDを使ってゲームを作るというのが目的なので、ゲーム自体はそこそこカジュアルでREALITYのアバターで動かせそうな既にあるゲームを模倣する形で進めました。
TDDの進め方
TDDの進め方としては以下の通りになります。
TODOリストとしてやるべきことをリストアップする
未達成のTODOを選択する
TODOをレッド、グリーン、リファクタリングのサイクルで達成するまで繰り返す ※新たにやるべきことが増えた場合はTODOリストに追加する
3を達成したら2に戻ってTODOリストがなくなるまで繰り返す
TDDの進め方の詳細を説明するとかなり長くなってしまうので、もっと詳しく知りたい方はこちらの動画がとても参考になると思います。
TODOリストを作成する
まずはTODOリストを作る必要がありますが、そのためには模倣するゲームの仕様をある程度洗い出しておく必要があります。
今回は最近Steamで見つけた「広告で見たことあるゲーム」風のカジュアルゲームを模倣してみたいと思います!
このゲームはプレイヤーが前進し続け、定期的に現れる2択のバフを選択して強化しながら限界まで進み続けてハイスコアを目指すという内容です。途中で現れる敵にぶつかった場合敵の残り体力分のダメージをプレイヤーが受けます。また、敵を倒すことでバフアイテムを落とします。
仕様をリストアップしたところ、とんでもない長さになってしまったのでこの記事上ではプレイヤーを中心に進めていこうと思います。
プレイヤーの要件
左右に動くことができる
前方に弾を撃って攻撃する
体力がある
体力は増える
左右に動く速度は可変
敵に衝突するとダメージを受ける
上記要件をもとにTODOリストを作成した結果が画像の内容です。
レッド・グリーン・リファクタリングを繰り返す
TODOリストから体力の項目を選択して進めていきます。
レッド・グリーン・リファクタリングのサイクルの初めのレッドフェーズはテストの目的を明確にした上で失敗するテストを書くことから始まります。
空実装でいいのでとりあえずどういう目的のテストなのかを書きます。
using NUnit.Framework;
// テスト対象のクラス
public class Health
{
public float Value;
public Health(float initialValue)
{
}
public void Increase(float amount)
{
}
}
public class HealthTest
{
[Test]
public void 体力が5増えること()
{
var health = new Health(10f);
health.Increase(5f);
Assert.That(health.Value, Is.EqualTo(15f));
}
}
このテストは当たり前ですが失敗します。失敗することでレッドフェーズはクリアされ、次のグリーンフェーズに進みます。
グリーンフェーズではテストを成功させることが目的です。テストを通すことさえできればいいとされているので、このような形でもグリーンフェーズはクリアしたと言えます。
using NUnit.Framework;
public class Health
{
public float Value = 5f; // 5で初期化しておく
public Health(float initialValue)
{
}
public void Increase(float amount)
{
}
}
public class HealthTest
{
[Test]
public void 体力が5増えること()
{
var health = new Health(10f);
health.Increase(5f);
Assert.That(health.Value, Is.EqualTo(15f));
}
}
グリーンフェーズではとにかく早くテストが通る状態を目指すのが目的のため、定数を返したりコピー&ペーストを使ってでもテストが通るようになれば良いとされているようです。ですが、これでテストが通ったからヨシ!とはなりませんよね?ここで、三角測量を用いてロジックに問題がないかを確認していきます。
public class HealthTest
{
[Test]
public void 体力が5増えること()
{
var health = new Health(10f);
health.Increase(5f);
Assert.That(health.Value, Is.EqualTo(15f));
}
// 体力が10増えることを確認するテストを追加する
[Test]
public void 体力が10増えること()
{
var health = new Health(10f);
health.Increase(10f);
Assert.That(health.Value, Is.EqualTo(20f));
}
// 体力が10増えた後に5増えることを確認するテストを追加する
[Test]
public void 体力が10増えた後に5増えること()
{
var health = new Health(10f);
health.Increase(10f);
health.Increase(5f);
Assert.That(health.Value, Is.EqualTo(25f));
}
}
新たに追加したテストは失敗します。理由は明確でValueの値を初期化しているだけでIncreaceというメソッドを呼んでも加算されていないからですね。このテストが通るようにコードを修正しましょう。
public class Health
{
public float Value;
public Health(float initialValue)
{
Value = initialValue;
}
public void Increase(float amount)
{
Value += amount;
}
}
Valueをコンストラクタ引数のinitialValueで初期化し、Increaceの引数で受け取った値をValueに加算するようにしました。この結果テストは通るようになりました。次にリファクタリングですが、このテストの文脈では特にリファクタリングできそうな部分がなかったのでこれでTODOリスト一つ目の「体力が増える」は達成となりました。この調子で他の項目も一通り実装していきます(記事では割愛します)。
一通りプレイヤーを構成するロジックを実装したところで、Unityで動かす場合どうするかを考えていきます。今回の合宿では以下のことを意識して実装を進めました。
基本的にロジックを持つクラスはMonoBehaviourを継承しない
MonoBehaviourを継承したクラスはテストを書かない
依存方向はMonoBehaviourを継承したクラスがTDDで実装されたクラスに依存する
MonoBehaviourを継承してPlayerLoopのサイクルに依存した形で実装するとPlayModeテストでテストを書く必要が出てきます。PlayModeテストはEditModeテストに比べて実行に時間がかかるため、EditModeテストで済ませられるのであれば済ませたいという考えのもと、非MonoBehaviourなクラスにロジックを集め、MonoBehaviourを継承したクラスはテストを書かないということにしました。また、依存方向も統一するように意識しました。
その結果、このような実装になりました。
// 体力に関するクラス
public class Health
{
public IReadOnlyReactiveProperty<float> Value => _value;
public IObservable<Unit> OnEmpty => _onEmpty;
private readonly FloatReactiveProperty _value = new();
private readonly Subject<Unit> _onEmpty = new();
public Health(float initialValue)
{
_value.Value = initialValue;
}
public void Increase(float amount)
{
_value.Value += amount;
}
public void Decrease(float amount)
{
_value.Value = Mathf.Max(0, _value.Value - amount);
if (_value.Value <= 0)
{
_onEmpty.OnNext(Unit.Default);
}
}
}
// 移動関係の処理をするクラス
public class MovableObject
{
public IReadOnlyReactiveProperty<Vector3> Position => _position;
private readonly ReactiveProperty<Vector3> _position = new();
private float _maxVelocity;
public MovableObject(Vector3 initialPosition, float maxVelocity)
{
_position.Value = initialPosition;
_maxVelocity = Mathf.Max(0, maxVelocity);
}
public void MoveLeft(float velocity, float deltaTime)
{
_position.Value += Vector3.left * ClampVelocity(velocity) * deltaTime;
}
public void MoveRight(float velocity, float deltaTime)
{
_position.Value += Vector3.right * ClampVelocity(velocity) * deltaTime;
}
private float ClampVelocity(float velocity)
{
return Mathf.Clamp(velocity, 0, _maxVelocity);
}
public void IncreaseMaxVelocity(float amount)
{
_maxVelocity += Mathf.Max(0, amount);
}
public void SetPosition(Vector3 position)
{
_position.Value = position;
}
}
// プレイヤー自体を表すクラス
public class Player : MovableObject
{
public Health Health { get; private set; }
public Player(float health, Vector3 initialPosition, float maxVelocity) : base(initialPosition, maxVelocity)
{
Health = new Health(health);
}
}
// MonoBehaviourを継承したPlayerをGameObjectとして扱うクラス
// このクラスをPrefabにアタッチして使用する
public class PlayerGameObject : MonoBehaviour
{
public Player Player { get; private set; }
// 体力を表すためのText
[SerializeField] private Text healthText;
// バーチャルパッド
private Joystick _joyStick;
public void Setup(Player player, Joystick joystick)
{
Player = player;
_joyStick = joystick;
// イベントをもとにUIやプレイヤーの座標を更新する
Player.Health.Value.Subscribe(health => healthText.text = Mathf.FloorToInt(health).ToString()).AddTo(this);
Player.Health.OnEmpty.Subscribe(_ => Destroy(gameObject)).AddTo(this);
Player.Position.Subscribe(pos => transform.position = pos).AddTo(this);
}
private void Update()
{
// バーチャルパッドの入力情報をもとにMovableObjectの移動処理を呼ぶ
var input = _joyStick.Direction;
var inputSqrMagnitude = input.sqrMagnitude;
var isLeft = input.x < 0;
if (isLeft)
{
Player.MoveLeft(inputSqrMagnitude, Time.deltaTime);
}
else
{
Player.MoveRight(inputSqrMagnitude, Time.deltaTime);
}
}
}
PlayerクラスはPlayerに必要な実装をまとめたクラスで、PlayerGameObjectクラスはMonoBehaviourを継承したUnityのPlayerLoopのイベントを受け取るクラスです。PlayerGameObjectクラスがPlayerクラスに依存しています。PlayerGameObjectをアタッチしたPrefabを生成するタイミングでPlayerクラスのインスタンスを渡すことを想定しています。
こんな感じでひたすら作っていった結果がこちら。
感じたメリット・デメリット
メリット
TODOリストに沿って進めていくので手が止まるという感覚はなかった
手を加えた時にテストがすでにあるのでテストを実行しながら開発を進めることで不具合に気づきやすい
リファクタリングをするときの心理的負担が低い
仕様の理解度が高まる
デメリット
時間がかかる
TODOリストをテストが書きやすい粒度にするのが難しい
手堅くサイクルを回すことで一歩ずつ確実に進んでいく感じが、手を動かしていて達成感が感じられました!
デメリットとしてはやはり全体的に時間がかかるなというのが率直な感想です。ただ、この時間がかかるという部分は、のちにテストという資産を残せるという意味では、テストの網羅性は意識する必要はありますが初期投資として考えるのもアリなのかなと思いました!
まとめ
ちょっと合宿でやるには時間が足りなかったですが、手応えはあったように思います。
この作り方に慣れていけばもう少し時間をかけずに開発していける気がするので伸び代は感じました!
明日のアドベントカレンダーは?
明日は、サーバエンジニアのabeさんです!ぜひチェックしてください!