😺

Unity設計練習 - リバーシ

2024/06/28に公開

はじめに

設計練習としてリバーシを作ったのでその流れをまとめます。

設計の方針

ドメイン駆動設計(DDD)っぽくやる

ドメイン駆動設計とは、モデリングを中心にソフトウェアの価値を高める設計手法です。作りたいゲームに対する目的や知識を重要視して作る考え方なので、クラスの役割が綺麗になりやすいとされています。

また、学習コストが高い事でも有名です。

今回は3層構造で作りました。

  • ドメイン層:問題解決しようとする対象領域やそのルール・制約を担当する層。ゲームのコア。
  • アプリケーション層:ドメイン層を使って操作を組み合わせ、ユースケースを作る層。ドメイン層を参照できる
  • プレゼンテーション層:Unityの入出力を担う。アプリケーション層を参照できる。MonoBehaviourを使う。

テスト駆動開発(TDD)っぽくやる

テスト駆動開発とは、テストを先に書いてから実装を行う手法です。テストがそのままユースケースになるため、迷わずに必要十分な機能を実装できるのがいいポイントです。

また、テストを書くことで実装の進捗がわかりやすくなるほか、バグの発見やリファクタリングがしやすくなります。

DIコンテナを使ってできるだけMonoBehaviourを使わない

UnityのMonoBehaviourは便利ですが、テストのしにくさや、コンストラクタが使えず実行順にも制約があることなどから、できるだけ使わないようにしました(個人の好み)。

今回は「MonoBehaviourを使っている→Unityに関係せざるを得ないクラスである」という状態を目指しました。

具体的には、プレゼンテーション層だけMonoBehaviourを使い、それ以外の層はMonoBehaviourを使わないようにしました。

サンプル

凄くシンプルなリバーシです。

https://github.com/qemel/gpp-reversi/tree/main

実践

まずはテスト(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);
        }
    }
}

このままでは当然エラーが出るので、とりあえずコンパイルを通します。

このとき、不変性を持てるクラスは積極的に不変にしていきます

今回はBoardPositionStoneNoneは不変で、同じものだったら替えが効く存在です。
そういった場合、ドメイン駆動設計ではValueObjectと呼ばれるオブジェクトとして扱えます。

recordを使うと簡単にValueObjectを使えるので便利です(このためだけにC#のバージョンをCsprojModifierで上げています)。

https://github.com/Cysharp/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で作りました。

テストを追加

次も同じようにテストを書いて欲しい機能をまとめていきます。

BoardTest.cs
[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()));
}

...

このように生やしたテストに対して実装を進めていき、全部のテストが通るまで頑張ります。

テストの完成

https://github.com/qemel/gpp-reversi/blob/main/Scripts/Tests/EditMode/BoardTest.cs

Boardの完成

https://github.com/qemel/gpp-reversi/blob/main/Scripts/Domains/Boards/Board.cs

こんな調子でドメイン層は大体完成です!

アプリケーション層を作る

アプリケーション層はドメイン層を使ってビジネスロジックを実行する層です。ドメイン層や各ライブラリを参照してもいいですが、MonoBehaviourを使うのは控えます。

今回はシンプルなゲームなので、GamePresenterに全部のゲームループを書いてしまいました。

また、直接的にプレゼンテーション層を参照してはいけないので、IView系のインターフェースを作ってそれを参照するようにしました。

namespace Applications
{
    public interface IBoardView
    {
        Observable<BoardPosition> OnPut { get; }
        void ShowPuttablePositions(IEnumerable<BoardPosition> puttablePositions);
        void ResetShowPuttablePositions();
        void Render(Board board);
    }
}

こんな感じで作ったインターフェースをアプリケーション層に置き、実装はプレゼンテーション層で書きます。

インターフェースを切ってアプリケーション層を書くと、インターフェースで公開しているAPIが自然にユースケースとして見えてくるのは良い感じだと思いました。

https://github.com/qemel/gpp-reversi/blob/main/Scripts/Applications/GamePresenter.cs

プレゼンテーション層を作る

プレゼンテーション層はユーザーインターフェースを担当する層です。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のみになりました。

https://github.com/qemel/gpp-reversi/blob/main/Scripts/LifetimeScopes/LifetimeScopeInGame.cs

まとめ

最後駆け足になっちゃいました。

色々勉強になる部分が多くて個人的には面白かったです。

また数日規模で別のゲームを作って試してみようと思っています!

参考

https://qiita.com/chorome/items/1d9521210aac893b3dd2

Discussion