Unity設計練習 - リバーシ
はじめに
設計練習としてリバーシを作ったのでその流れをまとめます。
設計の方針
ドメイン駆動設計(DDD)っぽくやる
ドメイン駆動設計とは、モデリングを中心にソフトウェアの価値を高める設計手法です。作りたいゲームに対する目的や知識を重要視して作る考え方なので、クラスの役割が綺麗になりやすいとされています。
また、学習コストが高い事でも有名です。
また、今回は3層のレイヤーを意識して作りました。
- ドメイン層:問題解決しようとする対象領域やそのルール・制約を担当する層。ゲームのコア。
- アプリケーション層:ドメイン層を使って操作を組み合わせ、ユースケースを作る層。ドメイン層を参照できる
- プレゼンテーション層:Unityの入出力を担う。アプリケーション層を参照できる。MonoBehaviourを使う。
追記:後学の際、この表現はあまり適切ではないと感じたため、撤回します。
テスト駆動開発(TDD)っぽくやる
テスト駆動開発とは、テストを先に書いてから実装を行う手法です。テストがそのままユースケースになるため、迷わずに必要十分な機能を実装できるのがいいポイントです。
また、テストを書くことで実装の進捗がわかりやすくなるほか、バグの発見やリファクタリングがしやすくなります。
DIコンテナを使ってできるだけMonoBehaviourを使わない
UnityのMonoBehaviourは便利ですが、テストのしにくさや、コンストラクタが使えず実行順にも制約があることなどから、できるだけ使わないようにしました(個人の好み)。
今回は「MonoBehaviourを使っている→Unityに関係せざるを得ないクラスである」という状態を目指しました。
具体的には、プレゼンテーション層だけMonoBehaviourを使い、それ以外の層はMonoBehaviourを使わないようにしました。
サンプル
凄くシンプルなリバーシです。
実践
まずはテスト(TDD)
本当に必要最低限、ドメインの知識をもとに作りたいものを書いていきます。
今回はリバーシなので、盤面(Board)と石(Stone)が必要です。
初期化時におなじみの8x8の盤面を作れるかだけをまずテストします。
namespace Tests.EditMode
{
internal sealed class BoardTest
{
[Test]
public void A_8x8が作れる()
{
var board = Board.CreateBasicBoard();
Assert.IsTrue(board.Contains(new BoardPosition(0, 0)));
Assert.IsTrue(board.Contains(new BoardPosition(7, 7)));
Assert.IsFalse(board.Contains(new BoardPosition(8, 0)));
Assert.IsTrue(board.Height == 8);
Assert.IsTrue(board.Width == 8);
Assert.IsTrue(board.GetStone(new BoardPosition(0, 0)) is StoneNone);
Assert.IsTrue(board.GetStone(new BoardPosition(7, 7)) is StoneNone);
}
}
}
このままでは当然エラーが出るので、とりあえずコンパイルを通します。
このとき、不変性を持てるクラスは積極的に不変にしていきます。
今回はBoardPosition
とStoneNone
は不変で、同じものだったら替えが効く存在です。
そういった場合、ドメイン駆動設計ではValueObject
と呼ばれるオブジェクトとして扱えます。
record
を使うと簡単にValueObject
を使えるので便利です(このためだけにC#のバージョンをCsprojModifierで上げています)。
BoardPosition
namespace Domains.Boards
{
public sealed record BoardPosition(int X, int Y);
}
Stone
namespace Domains.Boards
{
public interface IStone
{
}
public readonly record struct StoneBlack : IStone
{
}
public readonly record struct StoneWhite : IStone
{
}
public readonly record struct StoneNone : IStone
{
}
}
Board
namespace Domains.Boards
{
public sealed class Board
{
private readonly IStone[,] _stones;
private Board(IStone[,] stones) // Factoryメソッドで基本は作るのでコンストラクタは隠蔽
{
_stones = stones ?? throw new ArgumentNullException(nameof(stones)); // nullはそもそも許容しない
}
public int Width => _stones.GetLength(1);
public int Height => _stones.GetLength(0);
public IStone GetStone(BoardPosition boardPosition)
{
throw new NotImplementedException();
}
public bool Contains(BoardPosition boardPosition)
{
throw new NotImplementedException();
}
/// <summary>
/// 普通の初期状態のBoardを作成するFactoryメソッド
/// </summary>
/// <returns></returns>
public static Board CreateBasicBoard()
{
throw new NotImplementedException();
}
}
}
これでコンパイルは通りますが当然テストは通りません。
Boardの実装
using System;
using System.Collections.Generic;
using System.Text;
using Domains.Boards.Stones;
using Domains.Turns;
namespace Domains.Boards
{
public sealed class Board
{
private readonly IStone[,] _stones;
internal Board(IStone[,] stones)
{
_stones = stones ?? throw new ArgumentNullException(nameof(stones));
}
public int Width => _stones.GetLength(1);
public int Height => _stones.GetLength(0);
public IStone GetStone(BoardPosition boardPosition)
{
if (!Contains(boardPosition)) throw new ArgumentOutOfRangeException(nameof(boardPosition));
return _stones[boardPosition.Y, boardPosition.X];
}
public bool Contains(BoardPosition boardPosition) => boardPosition.Y >= 0 && boardPosition.Y < Height &&
boardPosition.X >= 0 && boardPosition.X < Width;
/// <summary>
/// 普通の初期状態のBoardを作成するFactoryメソッド
/// </summary>
/// <returns></returns>
public static Board CreateBasicBoard()
{
var stones = new IStone[8, 8];
for (var y = 0; y < 8; y++)
for (var x = 0; x < 8; x++)
{
stones[x, y] = new StoneNone();
}
stones[3, 3] = new StoneWhite();
stones[4, 4] = new StoneWhite();
stones[3, 4] = new StoneBlack();
stones[4, 3] = new StoneBlack();
return new Board(stones);
}
}
}
これでテストが通りました。
盤面に不変性はないのでValueObject
ではありません。よってrecord
ではなくclass
で作りました。
テストを追加
次も同じようにテストを書いて欲しい機能をまとめていきます。
[Test]
public void A_イコール判定できる()
{
var board = Board.CreateBasicBoard();
var board2 = Board.CreateBasicBoard();
Assert.That(board.IsSameBoardAs(board2));
var board3 = new Board(new[,]
{
{ _, _, _, _, _, _, _, _ },
{ _, _, _, _, _, _, _, _ },
{ _, _, _, _, _, _, _, _ },
{ _, _, _, O, X, _, _, _ },
{ _, X, _, X, O, _, _, _ },
{ _, _, _, _, _, _, _, _ },
{ _, _, _, _, _, _, _, _ },
{ _, _, _, _, _, _, O, _ },
});
var board4 = new Board(new[,]
{
{ _, _, _, _, _, _, _, _ },
{ _, _, _, _, _, _, _, _ },
{ _, _, _, _, _, _, _, _ },
{ _, _, _, O, X, _, _, _ },
{ _, X, _, X, O, _, _, _ },
{ _, _, _, _, _, _, _, _ },
{ _, _, _, _, _, _, _, _ },
{ _, _, _, _, _, _, O, _ },
});
Assert.That(board3.IsSameBoardAs(board4));
}
[Test]
public void A_Xが右でYが下()
{
// ________
// ________
// ________
// ___OX___
// _X_XO___
// ________
// ________
// ______O_
var board = Board.CreateBasicBoard();
var next = board.ChangeStoneAt(new BoardPosition(1, 4), new StoneBlack());
var next2 = next.ChangeStoneAt(new BoardPosition(6, 7), new StoneWhite());
Assert.IsTrue(next2.GetStone(new BoardPosition(1, 4)) is StoneBlack);
Assert.IsTrue(next2.GetStone(new BoardPosition(6, 7)) is StoneWhite);
var expected = new Board(new[,]
{
{ _, _, _, _, _, _, _, _ },
{ _, _, _, _, _, _, _, _ },
{ _, _, _, _, _, _, _, _ },
{ _, _, _, O, X, _, _, _ },
{ _, X, _, X, O, _, _, _ },
{ _, _, _, _, _, _, _, _ },
{ _, _, _, _, _, _, _, _ },
{ _, _, _, _, _, _, O, _ },
});
Assert.That(next2.IsSameBoardAs(expected));
}
[Test]
public void B_Stoneを置いてひっくり返せる1()
{
// ________
// ________
// ________
// ___OX___
// ___XO___
// ________
// ________
// ________
//
// ________
// ________
// ___X____
// ___XX___
// ___XO___
// ________
// ________
// ________
var board = Board.CreateBasicBoard();
var turn = new TurnBlack();
var nextBoard = board.PutReverseStone(new BoardPosition(3, 2), turn.Stone);
Debug.Log(nextBoard);
Assert.IsTrue(nextBoard.GetStone(new BoardPosition(3, 2)) is StoneBlack);
Assert.IsTrue(nextBoard.GetStone(new BoardPosition(3, 3)) is StoneBlack);
Assert.AreEqual(4, nextBoard.StoneCount(turn));
Assert.AreEqual(1, nextBoard.StoneCount(new TurnWhite()));
}
...
このように生やしたテストに対して実装を進めていき、全部のテストが通るまで頑張ります。
テストの完成
Boardの完成
こんな調子でドメイン層は大体完成です!
アプリケーション層を作る
アプリケーション層はドメイン層を使ってビジネスロジックを実行する層です。ドメイン層や各ライブラリを参照してもいいですが、MonoBehaviourを使うのは控えます。
今回はシンプルなゲームなので、GamePresenter
に全部のゲームループを書いてしまいました。
また、直接的にプレゼンテーション層を参照してはいけないので、IView
系のインターフェースを作ってそれを参照するようにしました。
namespace Applications
{
public interface IBoardView
{
Observable<BoardPosition> OnPut { get; }
void ShowPuttablePositions(IEnumerable<BoardPosition> puttablePositions);
void ResetShowPuttablePositions();
void Render(Board board);
}
}
こんな感じで作ったインターフェースをアプリケーション層に置き、実装はプレゼンテーション層で書きます。
インターフェースを切ってアプリケーション層を書くと、インターフェースで公開しているAPIが自然にユースケースとして見えてくるのは良い感じだと思いました。
プレゼンテーション層を作る
プレゼンテーション層はユーザーインターフェースを担当する層です。MonoBehaviourを使ってもいいです。
using System.Collections.Generic;
using System.Linq;
using Applications;
using Domains.Boards;
using R3;
using UnityEngine;
namespace Presentations
{
public sealed class BoardView : MonoBehaviour, IBoardView
{
[SerializeField] private BoardTile _boardTilePrefab;
private readonly Dictionary<BoardPosition, BoardTile> _boardTiles = new();
public Observable<BoardPosition> OnPut => _onPut;
private readonly Subject<BoardPosition> _onPut = new();
private void Awake()
{
_onPut.AddTo(this);
}
public void Initialize(Board board)
{
foreach (var position in board.Positions)
{
var boardTile = Instantiate(_boardTilePrefab, transform); // ここはあんまりよくないかも
boardTile.Initialize(position);
boardTile.OnPut.Subscribe(_onPut.OnNext).AddTo(this);
_boardTiles.Add(position, boardTile);
}
}
public void ShowPuttablePositions(IEnumerable<BoardPosition> puttablePositions)
{
var boardPositions = puttablePositions as BoardPosition[] ?? puttablePositions.ToArray();
foreach (var position in _boardTiles.Keys)
{
if (boardPositions.Contains(position))
{
_boardTiles[position].ShowPutIndicator();
}
else
{
_boardTiles[position].HidePutIndicator();
}
}
}
public void ResetShowPuttablePositions()
{
foreach (var boardTile in _boardTiles.Values)
{
boardTile.HidePutIndicator();
}
}
public void Render(Board board)
{
foreach (var position in board.Positions)
{
_boardTiles[position].Render(board.GetStone(position));
}
}
}
}
LifetimeScopeを使ってDI
最後にVContainerを使ってDIをします。
エントリポイント(自分から動くコード)はGamePresenter
のみになりました。
まとめ
最後駆け足になっちゃいました。
色々勉強になる部分が多くて個人的には面白かったです。
また数日規模で別のゲームを作って試してみようと思っています!
参考
Discussion