3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

過去の自分に教えたい、ソフトウェア品質の心得を投稿しよう by QualityForwardAdvent Calendar 2024

Day 14

【Unity】プロトタイプコード→CleanArchitecture へのリアーキテクチャで良かったこと

Last updated at Posted at 2024-12-24

はじめに

本記事は、Unity で作成したゲームのプロトタイプコードを CleanArchitecture へリアーキテクチャした時のコードや、それを行い良かったことなどを書いています。筆者と共同開発者は共に Unity 暦 2 ヶ月程度ですので、不適切なことを書いている可能性もあります、ご了承ください。

題材ゲーム

リバーシ将棋というゲームを作成しました。

スクリーンショット 2024-12-24 18.16.01.png

リバーシのルールでコマを置きつつ、将棋のコマなどを置いていきます。将棋コマの場合は、ターン終了時に移動範囲内に敵コマがある場合、攻撃を行い点数を獲得できます。現在、unityroom で公開していますので、内容が気になる方は遊んでみてください。画面としては、スタート画面、ゲーム画面、結果画面が存在します。主にゲーム画面のコードについてこの記事では言及します。

プロトタイプ

プロトタイプを作成した目的

まずは動くものを見て、ゲームが面白そうか、どのような体験になるのかを見たかったので、実装速度優先でプロトタイプを作成することにしました。初心者が最初から綺麗に作ろうとしてしまうと、どうしても何倍もの時間がかかってしまうだろうと思ってのことです。一度動くことを高速で作ってみて、今後の追加開発に問題ありそうな箇所が生じた場合、全てを作り直そうという方針でした。

フォルダ構成

以下がフォルダ構成です。

$ tree Assets/Scripts -P "*.cs"
Assets/Scripts
├── Deck.cs
├── GameManager.cs
├── Result
│   └── ResultManager.cs
├── Stage
│   ├── BoardCell.cs
│   ├── BoardCells.cs
│   ├── HandTable.cs
│   ├── OptionsTable.cs
│   ├── PieceFactory.cs
│   ├── Pieces
│   │   ├── Piece.cs
│   │   ├── Piece0.cs
│   │   ├── Piece1.cs
│   │   ├── Piece10.cs
│   │   ├── Piece2.cs
│   │   ├── Piece3.cs
│   │   ├── Piece4.cs
│   │   ├── Piece5.cs
│   │   ├── Piece6.cs
│   │   ├── Piece7.cs
│   │   ├── Piece8.cs
│   │   └── Piece9.cs
│   ├── Players
│   │   ├── Enemy.cs
│   │   ├── Player.cs
│   │   └── User.cs
│   ├── Reversi.cs
│   └── StageManager.cs
└── Start
    └── StartManager.cs

Start, Stage, Result がそれぞれ画面に対応していて、主に Stage の実装がゲームのメインのものが入っています。

Reversi Class

Reversi Class は CreateBoard, SetBoardLayout, Place など、ボードの作成や描画、リバーシコマを置く処理や CPU の動きなどを実装しています。少しコード量が長いので、ここでは省略します。

BoardCell Class

BoardCell Class はリバーシのボードの 1 マスに対応するクラスで、コマの作成や描画の変更などに関する処理を実装しています。スタート時に Riversi Component を取得し、オーナーの有無によって色を変更します。Place により、この Cell にコマを置きます。

using UnityEngine;
using UnityEngine.EventSystems;

public class BoardCell : MonoBehaviour, IPointerClickHandler, IPointerEnterHandler, IPointerExitHandler
{
    Reversi reversi;
    public bool hasOwner = false;
    public GameObject pieceObject;
    public Piece piece;
    public Vector2 location;
    SpriteRenderer frontSpriteRenderer;

    Color32 defaultColor = new Color32(0, 103, 0, 255);
    Color32 darkColor = new Color32(0, 78, 0, 255);

    void Start()
    {
        reversi = GameObject.Find("ReversiCanvas").GetComponent<Reversi>();
        frontSpriteRenderer = transform.GetChild(1).gameObject.GetComponent<SpriteRenderer>();
        if (hasOwner)
        {
            BeDarkColor();
        }
        else
        {
            BeDefaultColor();
        }
    }

    public void CreatePiece(int ownerID, int pieceID)
    {
        pieceObject = PieceFactory.CreatePiece(pieceID);
        piece = pieceObject.GetComponent<Piece>();
        piece.OwnerID = ownerID;
        piece.Location = location;
        RectTransform r = pieceObject.GetComponent<RectTransform>();
        r.localScale = new Vector3(1, 1, 1);
        pieceObject.transform.SetParent(transform, false);
    }

    public void RemovePiece()
    {
        hasOwner = false;
        Destroy(pieceObject);
        piece = null;
        BeDefaultColor();
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        if (!reversi.CanPlace(this) || !reversi.IsUserTurn) return;

        reversi.Place(this);
    }

    public void OnPointerEnter(PointerEventData eventData)
    {
        if (!reversi.CanPlace(this) || !reversi.IsUserTurn) return;

        BeDarkColor();
    }

    public void OnPointerExit(PointerEventData eventData)
    {
        if (!reversi.CanPlace(this) || hasOwner) return;

        BeDefaultColor();
    }

    void BeDefaultColor()
    {
        frontSpriteRenderer.color = defaultColor;
    }

    void BeDarkColor()
    {
        frontSpriteRenderer.color = darkColor;
    }

    public void Place(int ownerID, int pieceID)
    {
        hasOwner = true;
        CreatePiece(ownerID, pieceID);
        BeDarkColor();
    }
}

Piece Class

Reversi Class はコマに関する処理を書いています。リバーシ将棋における、歩兵や香車などの特殊コマに関しての実装があります。Piece のベースクラスと、それを実装した具体 Piece クラスを作る形でコマを追加します。

Piece

Piece のベースクラスです。Focus された時の色の変化や、BeforePlaced などのゲーム中の処理に関しての実装があります。このクラスを継承することでコマを実装します。

using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;

public class Piece : MonoBehaviour, IPointerClickHandler
{
    protected int id;
    public int ID { get => id; }
    string characterName = "";
    public string CharacterName { get => characterName; set => characterName = value; }
    string shortName = "";
    public string ShortName { get => shortName; set => shortName = value; }
    string explanation;
    public string Explanation { get => string.Format(explanation, power); set => explanation = value; }
    int power = 0;
    protected int Power { get => power; set => power = value; }
    public bool isSilenced = false;
    SpriteRenderer backSpriteRenderer;
    SpriteRenderer frontSpriteRenderer;
    TMP_Text characterNameTMP_Text;

    Color transparent = new Color(0, 0, 0, 0);
    Color white = Color.white;
    Color black = Color.black;

    protected int ownerID = 0;
    public virtual int OwnerID { get => ownerID; set => ownerID = value; }
    protected Vector2 location;
    public Vector2 Location { get => location; set => location = value; }
    bool isFocused = false;
    public bool IsFocused
    {
        get => isFocused;
        set
        {
            isFocused = value;
            BeNowAppearance();
        }
    }

    public bool isChoosable = false;

    protected void Start()
    {
        BeNowAppearance();
    }

    void RefleshChildren()
    {
        if (!backSpriteRenderer)
        {
            backSpriteRenderer = transform.GetChild(0).gameObject.GetComponent<SpriteRenderer>();
        }
        if (!frontSpriteRenderer)
        {
            frontSpriteRenderer = transform.GetChild(1).gameObject.GetComponent<SpriteRenderer>();
        }
        if (!characterNameTMP_Text)
        {
            characterNameTMP_Text = transform.GetChild(2).transform.GetChild(0).gameObject.GetComponent<TMP_Text>();
        }
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        Debug.Log(string.Format("explanation: " + explanation, power));
        if (!isChoosable) return;

    }


    protected void BeBlack()
    {
        backSpriteRenderer.color = transparent;
        frontSpriteRenderer.color = black;
        characterNameTMP_Text.color = white;
        AdjustBlackSize();
        transform.rotation = Quaternion.Euler(0, 0, 0);
    }

    protected void BeFocusedBlack()
    {
        backSpriteRenderer.color = white;
        frontSpriteRenderer.color = black;
        transform.rotation = Quaternion.Euler(0, 0, 0);
    }

    protected void BeWhite()
    {
        backSpriteRenderer.color = transparent;
        frontSpriteRenderer.color = white;
        characterNameTMP_Text.color = black;
        AdjustWhiteSize();
        transform.rotation = Quaternion.Euler(0, 0, 180);
    }

    protected void BeFocusedWhite()
    {
        backSpriteRenderer.color = black;
        frontSpriteRenderer.color = white;
        transform.rotation = Quaternion.Euler(0, 0, 180);
    }

    protected void BeOwnerAppearance()
    {
        RefleshChildren();
        if (ownerID == 0)
        {
            BeBlack();
        }
        else if (ownerID == 1)
        {
            BeWhite();
        }
    }

    protected void BeFocusedOwnerAppearance()
    {
        RefleshChildren();
        if (ownerID == 0)
        {
            BeFocusedBlack();
        }
        else if (ownerID == 1)
        {
            BeFocusedWhite();
        }
    }

    protected void BeNowAppearance()
    {
        if (isFocused)
        {
            BeFocusedOwnerAppearance();
        }
        else
        {
            BeOwnerAppearance();
        }
    }

    void AdjustBlackSize()
    {
        transform.GetChild(0).gameObject.GetComponent<RectTransform>().localScale = new Vector3(0.92f, 0.92f, 0.92f);
    }

    void AdjustWhiteSize()
    {
        transform.GetChild(1).gameObject.GetComponent<RectTransform>().localScale = new Vector3(0.85f, 0.85f, 0.85f);
    }

    public virtual void Reverese()
    {
        OwnerID = (OwnerID + 1) % 2;
        BeNowAppearance();
        OnReversed();
    }
    
    public virtual void BeforePlaced(Reversi reversi)
    {

    }
    public virtual void OnPlaced(ReversedData reversedData)
    {

    }
    public virtual void AfterPlaced(Reversi reversi)
    {

    }
    ...
}

このように、かなり大きなクラスになってしまっています。

Piece5

具体コマの例として、歩兵コマクラスです。自分の前に敵コマがある場合、相手に1ダメージを与えます。ターンの終了時に攻撃します。

using UnityEngine;

public class Piece5 : Piece
{
    protected int INF = 99;
    protected int count = 1;
    public Piece5()
    {
        id = 5;
        CharacterName = "歩兵";
        ShortName = "歩";
        Explanation = "自分のターン終了時、攻撃範囲の敵の数 x {0}ダメージ";
        Power = 1;
    }

    public override void OnEndTurn(Reversi reversi)
    {
        if (isSilenced) return;
        if (reversi.currentTurn != ownerID) return;
        int effectNum = CountAttackPower(reversi);
        GameManager.instance.Attack(OwnerID, Power * effectNum);
    }

    protected virtual Vector3[] attackDirections()
    {
        return new Vector3[]
        {
            new Vector3(0, ownerID == 0 ? 1 : -1, 1),
        };
    }

    int CountAttackPower(Reversi reversi)
    {
        int count = 0;
        ...
        return count;
    }
}

PieceFactory

ピースを生成するクラスです。id により Type.GetType で Piece クラスを取得しコンポーネントとして追加しています。

using System;
using TMPro;
using UnityEngine;

class PieceFactory : MonoBehaviour
{
    public static GameObject CreatePiece(int pieceID)
    {
        GameObject pieceResource = (GameObject)Resources.Load("Objects/Piece");
        GameObject p = Instantiate(pieceResource);
        p.AddComponent(Type.GetType("Piece" + pieceID));
        Piece piece = p.GetComponent<Piece>();
        TextMeshProUGUI t = p.GetComponentInChildren<TextMeshProUGUI>();
        t.text = piece.ShortName;
        return p;
    }
}

プロトタイプコードの問題点

現在 2024 年での unityroom でのリバース将棋はプロトタイプコードで動いています。
問題なく動いていますが、このまま開発を続けるにはいくつか問題があります。

問題1: 大体のクラス、モジュールと Unity が蜜結合している

フォルダ構成をみて、ここのクラスのどれが Unity のコードでどれが pure C# なんだろう?と思われたかもしれません。実は、プロトタイプではほとんどのクラスが MonoBehaviour を継承しているので、Unity に依存しています。

従って、どこのコードを追加/編集するときにも Unity の知識が必要になったり、Unity の Version Up によるコード変更を求められるなどが生じます。
例えば、Reversi のルールに Unity は関係ないはずなのに Unity のクラスに依存しているので、影響を受けてしまいます。

問題2: ロジックとUIコードが1つのクラスに混在している

BoardCell でも Piece でもそうですが、リバーシ将棋というゲームについての記述と、それのための UI に関する記述の両方が 1 クラスに書かれてしまっています。ある程度関数で分かれていますが、例えば今後新しいコマを追加するときに、そのロジックだけを追加したいのに UI に関する部分も継承したクラスで実装するので、常に UI にも気を使う必要が出てきてしまいます。

問題3: いたる所で登場する GameManager や Reversi クラス

GameManager はシングルトンとして、どこからでもアクセスできるようにしています。Reversi はシングルトンではないですが MonoBehaviour を継承しているので、GetComponent で取得したり、いろいろな関数の引数で受け付けたりしています。それらはそこそこいたる所から使われています。

それはすなわち GameManager や Reversi に依存しているクラス/モジュールの数が多いということになります。ゲームを作る上で GameManager や Reversi はそこそこ変更がなされる不安定なクラスということになります。それらに依存するクラスが多いと、例えば GameManager を修正したときに依存先の全てのクラスに影響を与えることになります。必要な依存であれば良いですが、現状は速度重視で作ったということもあり、雑に多くから依存されてしまっています。

リアーキテクチャの検討

これらの問題点が見えていたので、大きくアーキテクチャを変更することにしました。開発者二人とも Unity や C# 初心者でしたので、色々調べながらどれがいいのかを検討、議論していました。

問題をまとめると、ロジック、UI、Unity を適切に分離して、機能追加や変更を今よりは柔軟にできるようにしたいというものです。特に、コマの追加が一番頻繁に起こりうるアップデートですので、これが容易にできるような設計を目指します。

MVP vs MVVM

画面があるタイプのシステムで、View とロジックをうまく切り分けられるアーキテクチャとしてまず思いついた二つが MVP(Model View Presenter) と MVVM(Model View ViewModel) でした。

MVP は Model と View を Presenter で繋ぐ設計です。Model は View を View は Model をそれぞれ知っておらず、Presenter を通じてロジックによる画面反映を行います。Model と View が完全に分離されるので、テスト容易性やモジュール性が向上します。

MVVM は Model と View を ViewModel で繋ぐ設計です。構成は MVP と近いですが、違いとしては双方向データバインディングと言う技術を用いて作ると言う点です。それにより、View での変更は自動的に ViewModel に反映され、その逆も同様となります。

Unity とこれらの実装例を探しました。

結論から言うと、筆者の調べた範囲では Unity には双方向バインディングを実現する機構がないようなので、MVVM で作ることは無理そうでした。単方向バインディングに関しては UniRxR3 を用いると可能なようでしたので、MVP を採用することは可能そうでした。

Clean Architecture

同心円の図でお馴染みの、Clean Architecture です。

18ffbea5828e-20230915.png

重要なものに依存しよう、のような思想で、外部のフレームワークやデータベースといった「技術的な詳細」を最外層へ追いやり、一番大切なビジネスロジックを中心に配置する設計となっています。

「Unity アーキテクチャ」で調べると、Clean Architecture を採用している例がいくつかありました。しかし、やってみた系が多く、Reactive な View に対してどのように Clean Architecture を適用するかに関しては多くはわかりませんでした。

ただ、ロジック、UI、Unity を適切に分離したいというモチベーションに対しては適しているアーキテクチャのようにも感じました。

リアーキテクチャの方針

色々調べてみた結果、Clean Architecture でロジック、UI、Unity を適切に分離しつつ、View とデータの繋ぎ込みを MVP を参考に作っていく方針になりました。

また、ライブラリ選定も行い、以下のライブラリを利用することになりました、

R3

R3 とは Rx 系のライブラリで、Observable な値を扱うことができ、イベントドリブンなコードを書くことができるようになります。詳しくは公式をみてみてください。

以下に例のコードを記述します。

ReactiveProperty<int> currtentHp = new ReactiveProperty<int>(100);
ReactiveProperty<bool> IsDead = CurrentHp.Select(x => x <= 0).ToReactiveProperty();
currtentHp.Subscribe(x => Console.WriteLine("HP:" + x));
IsDead.Where(isDead => isDead == true)
    .Subscribe(_ =>
    {
        Console.WriteLine("Dead");
    });

このように ReactiveProperty を Subscribe することで、hp の値を監視できたり、hp が無くなった時に処理を実行できたりなど、わかりやすい記述が可能になります。

VContainer

VContainer とは、以下のようなもので、いわゆる DI コンテナです。

VContainerは Unity(ゲームエンジン)上で動作する高速なDIフレームワークです。(DI = Dependency Injection)

詳しくは DI や DI コンテナで調べてみてください。以下のようなコードで、どのインターフェースに何を割り当てるか、シングルトンとして利用するかなどを設定することができます。

public class GameLifetimeScope : LifetimeScope
{
    public override void Configure(IContainerBuilder builder)
    {
        builder.RegisterEntryPoint<ActorPresenter>();

        builder.Register<CharacterService>(Lifetime.Scoped);
        builder.Register<IRouteSearch, AStarRouteSearch>(Lifetime.Singleton);

        builder.RegisterComponentInHierarchy<ActorsView>();
    }
}

Clean Architecture x MVP

Clean Architecture をベースに、View 部分を MVP を参考にリアーキテクチャする方針となりました。

フォルダ構成

以下がフォルダ構成です。

$ tree Assets/Script/ -P "*.cs"
Assets/Script/
├── 0_Domain
│   ├── Board.cs
│   ├── BoardCell.cs
│   ├── BoardService.cs
│   ├── Piece.cs
│   ├── Player.cs
│   ├── Point.cs
│   └── Reversi.cs
├── 1_UseCase
│   ├── GameStartUseCase.cs
│   └── PlaceUseCase.cs
├── 2_Adapter
│   ├── GameStart
│   │   ├── GameStartController.cs
│   │   └── GameStartPresenter.cs
│   ├── Place
│   │   ├── PlaceController.cs
│   │   └── PlacePresenter.cs
│   └── View
│       ├── Board
│       │   ├── BoardPresenter.cs
│       │   └── IBoardView.cs
│       ├── BoardCell
│       │   ├── BoardCellPresenter.cs
│       │   └── IBoardCellView.cs
│       ├── BoardPiece
│       │   ├── BoardPiecePresenter.cs
│       │   └── IBoardPieceView.cs
│       ├── IReactivePresenter.cs
│       ├── IViewFactory.cs
│       ├── Player
│       │   ├── IPlayerView.cs
│       │   └── PlayerPresenter.cs
│       └── Turn
│           ├── ITurnView.cs
│           └── TurnPresenter.cs
├── 3_Infrastructure
│   ├── PieceRepository.cs
│   ├── View
│   │   ├── BoardCellView.cs
│   │   ├── BoardPieceView.cs
│   │   ├── BoardView.cs
│   │   ├── PlayerView.cs
│   │   └── TurnView.cs
│   └── ViewFactory.cs
├── GameLifetimeScope.cs
└── GameManager.cs
└── Logger.cs

層ごとにディレクトリを切っています。数字の接頭辞をつけているのは、エディタ上で層の内側から順番に並べる目的でそうしています。

Domain Layer

BoardCell Class

public class BoardCell
{
    public readonly Point point;
    IPiece piece; public IPiece Piece { get => piece; }
    public bool HasOwner { get => piece != null; }
    int ownerId = -1; public int OwnerId { get => ownerId; }
    public bool CanPlace { get => !HasOwner; }

    public BoardCell(Point point)
    {
        this.point = point;
    }

    public bool Place(IPiece piece, int ownerId)
    {
        if (!CanPlace)
        {
            return false;
        }
        this.piece = piece;
        this.ownerId = ownerId;
        return true;
    }

    public bool Reverse()
    {
        if (!HasOwner)
        {
            return false;
        }
        ownerId = (ownerId + 1) % 2;
        return true;
    }
}

前のコードだと、Domain Logic と UI 関連が混在していましたが、無事分離でき、Domain の関心ごとである置く処理やひっくり返す処理のみが記述され、すっきりしています。

Piece Class

public record AttackDirection(int X, int Y, int N)
{
    public Point ToPoint()
    {
        return new Point(X, Y);
    }
};

public interface IPiece
{
    public int Id { get; }
    public int Power { get; }
    public string Name { get; }
    public AttackDirection[] AttackDirections { get; }
    public PieceEffect Effect { get; }
}

public class Piece : IPiece
{
    int id; public int Id { get => id; }
    int power; public int Power { get => power; }
    string name; public string Name { get => name; }
    AttackDirection[] attackDirections; public AttackDirection[] AttackDirections { get => attackDirections; }
    PieceEffect effect; public PieceEffect Effect { get => effect; }

    public Piece(int id, int power, string name)
    {
        this.id = id;
        this.power = power;
        this.name = name;
        attackDirections = new AttackDirection[0];
        effect = new PieceEffect();
    }

    public Piece(int id, int power, string name, AttackDirection[] attackDirections)
    {
        this.id = id;
        this.power = power;
        this.name = name;
        this.attackDirections = attackDirections;
        effect = new PieceEffect();
    }
}

public interface IPieceRepository
{
    public IPiece get(int id);
}

Piece も同様に、UI に関する記述を排除できています。コマの特殊処理に関しては PieceEffect に記述することにより、Piece Class はシンプルに保っています。

PieceEffect
public class PieceEffect
{
    // 操作対象に対して呼び出す
    public void OnPlace(Reversi reversi, Point point) { }
    public void OnPlaced(Reversi reversi, Point point, PlaceData placeData) { }
    public void OnReverse(Reversi reversi, Point point) { }
    // 全ての cell に対して実行する
    public void BeforePlace(Reversi reversi, Point point) { }
    public void AfterPlaced(Reversi reversi, Point point) { }
    public void BeforeEndTurn(Reversi reversi, Point point)
    {
        // 攻撃処理
        if (reversi.Board.Cells[point].OwnerId == reversi.CurrentTurn)
        {
            int attackPower = CountAttackPower(reversi, point);
            reversi.Attack(attackPower, reversi.NextTurn);
        }
    }
    public void AfterEndTurn(Reversi reversi, Point point) { }

    int CountAttackPower(Reversi reversi, Point point)
    {
        int count = 0;
        ...
        return count;
    }
}

コマの特殊処理を PieceEffect で担当しています。攻撃処理はデフォルトとして実装してあり、増殖コマのような特殊なコマを作るときに、このクラスを継承した新しいクラスを作成し、Piece に持たせることで表現します。Reversi という大きなクラスに依存する部分を Piece から分離するためにこのクラスを作成しています。

UseCase Layer

PlaceUseCase

public record PlaceInputData(Reversi Reversi, BoardCell BoardCell);

public interface IPlaceOutputPort
{
    void Emit(PlacedData data);
}


public class PlaceUseCase
{
    public void Handle(PlaceInputData input, IPlaceOutputPort output)
    {
        // 自分のターンでここが置ける場所であれば、指定のピースを置く
        if (!input.Reversi.CanPlace(input.BoardCell)) return;
        if (!input.Reversi.IsUserTurn) return;
        PlacedData placedData = input.Reversi.Place(input.BoardCell);
        output.Emit(placedData);
    }
}

コマを置く UseCase です。Domain 層のモデルなどを利用して、コマを置くアプリケーションロジックを記述しています。出力に関しては OutputPort.Emit を経由して行います。これは上層で実装します。InputPort は実装を省略しています。

Adapter Layer

PlaceController

using VContainer;
using R3;

public class PlaceController
{
    [Inject] PlaceUseCase placeUseCase;
    [Inject] IPlaceOutputPort placePresenter;
    [Inject] Subject<BoardCell> boardCellTapped;
    [Inject] ReactiveProperty<Reversi> reversiReactive;

    public void Place(Reversi reversi, BoardCell boardCell)
    {
        placeUseCase.Handle(new PlaceInputData(reversi, boardCell), placePresenter);
    }

    public void Bind()
    {
        boardCellTapped.Subscribe(boardCell =>
        {
            Place(reversiReactive.CurrentValue, boardCell);
        });
    }
}

このクラスでは、PlaceUseCase の呼び出しを主に担当しています。BoardCell がタップされた時にそのセルにコマを置く処理を記述しています。R3 の Subject により、他の箇所から BoardCell のタップイベントを Subscribe し、Place を呼び出します。

PlacePresenter

using System.Collections.Generic;
using R3;
using VContainer;

public class PlacePresenter : IPlaceOutputPort
{
    [Inject] ReactiveProperty<Reversi> reversiReactive;
    [Inject] Dictionary<Point, ReactiveProperty<BoardCell>> reactiveBoardCells;
    public void Emit(PlacedData data)
    {
        foreach (BoardCell cell in data.Reversed)
        {
            // 返した BoardCell の変更を通知
            reactiveBoardCells[cell.point].OnNext(cell);
        }
        // 置かれた piece の変更を通知
        reactiveBoardCells[data.PlacedCell.point].OnNext(data.PlacedCell);
        // reversi の変更を通知
        reversiReactive.OnNext(reversiReactive.CurrentValue);
    }
}

先ほどの PlaceUseCase に記述していた IPlaceOutputPort の実装です。ここの Presenter は MVP アーキテクチャの Presenter ではなく、Clean Architecture でよく使われる形の Presenter を作成しています。変更された BoardCellReversi の変更を配信し、後続の View の部分で表示の切り替えなどを行なっています。

IBoardCellView

using R3;

public interface IBoardCellView
{
    Observable<Unit> OnMouseDown { get; }
    Observable<Unit> OnMouseOver { get; }
    Observable<Unit> OnMouseExit { get; }

    public void Locate(int boardSize, float boardSideSize, Point point);
    public void ColorLight();
    public void ColorDark();
}

BoardCell の View の Interface です。ここではどのような処理が View に必要かだけを記述しています。このような形で Adapter 層で完結していることにより、View が Unity を使わなくなったとしても、Adapter 層以下のコードは変更が必要なくなります。

BoardCellPresenter

using R3;

public class BoardCellPresenter : IReactivePresenter
{
    readonly IBoardCellView view;
    readonly ReactiveProperty<Reversi> reactiveReversi;
    readonly ReactiveProperty<BoardCell> reactiveBoardCell;
    readonly Observable<float> boardSideSize;
    readonly Point point;
    readonly Subject<BoardCell> boardCellTapped;

    Reversi reversi { get => reactiveReversi.CurrentValue; }
    BoardCell boardCell { get => reactiveBoardCell.CurrentValue; }

    public BoardCellPresenter(
        IBoardCellView view,
        ReactiveProperty<Reversi> reactiveReversi,
        ReactiveProperty<BoardCell> reactiveBoardCell,
        Observable<float> boardSideSize,
        Point point,
        Subject<BoardCell> boardCellTapped
    )
    {
        this.view = view;
        this.reactiveReversi = reactiveReversi;
        this.reactiveBoardCell = reactiveBoardCell;
        this.boardSideSize = boardSideSize;
        this.point = point;
        this.boardCellTapped = boardCellTapped;
    }

    public void Bind()
    {
        // boardSideSize が更新された時に boardCell の位置や横幅も変更する
        boardSideSize.Subscribe(boardSideSize =>
        {
            view.Locate(reversi.Board.boardSize, boardSideSize, point);
        });
        // マウスダウンでそのイベントを通知
        view.OnMouseDown.Subscribe(_ =>
        {
            view.ColorLight();
            boardCellTapped.OnNext(boardCell);
        });
        // マウスオーバーで色を変化させる
        view.OnMouseOver.Subscribe(_ =>
        {
            if (reversi.CanPlace(boardCell) && reversi.IsUserTurn) view.ColorDark();
        });
        // マウスイグジットで色を変化させる
        view.OnMouseExit.Subscribe(_ =>
        {
            if (reversi.CanPlace(boardCell)) view.ColorLight();
        });
    }

}

こちらは MVP アーキテクチャをベースとした Presenter です。Domain Model と View を繋ぎ 、Model の変化に応じて View を変化させたり View の操作により Model の操作をおこなったりしています。Model と View の関係を Presenter に閉じ込めることにより、それぞれの記述がシンプルになりうまく責務が分離できているように思います。

Infrastructure Layer

BoardCellView

using UnityEngine;
using R3;
using R3.Triggers;

public class BoardCellView : MonoBehaviour, IBoardCellView
{
    SpriteRenderer frontSpriteRenderer;
    Color32 lightColor = new Color32(0, 103, 0, 255);
    Color32 darkColor = new Color32(0, 78, 0, 255);

    public Observable<Unit> OnMouseDown { get => this.OnMouseDownAsObservable(); }
    public Observable<Unit> OnMouseOver { get => this.OnMouseOverAsObservable(); }
    public Observable<Unit> OnMouseExit { get => this.OnMouseExitAsObservable(); }


    void Awake()
    {
        frontSpriteRenderer = transform.Find("Front").gameObject.GetComponent<SpriteRenderer>();
        ColorLight();
    }

    public void Locate(int boardSize, float boardSideSize, Point point)
    {
        float cellAnchor = 1.0f / boardSize;
        float cellSideSize = cellAnchor * boardSideSize;

        RectTransform r = GetComponent<RectTransform>();
        r.anchorMin = new Vector2(point.X * cellAnchor, point.Y * cellAnchor);
        r.anchorMax = new Vector2((point.X + 1) * cellAnchor, (point.Y + 1) * cellAnchor);
        r.localScale = new Vector3(cellSideSize, cellSideSize, cellSideSize);
    }

    public void ColorLight()
    {
        frontSpriteRenderer.color = lightColor;
    }

    public void ColorDark()
    {
        frontSpriteRenderer.color = darkColor;
    }
}

IBoardCellView を Unity で実装したものです。

PieceRepository

public class PieceRepository : IPieceRepository
{
    const int INF = 99;
    public IPiece get(int id)
    {
        switch (id)
        {
            case 1:
                return new Piece(id, 1, "歩", new AttackDirection[] { new(0, 1, 1) });
            case 2:
                return new Piece(id, 1, "香", new AttackDirection[] { new(0, 1, INF) });
            case 3:
                return new Piece(id, 1, "桂", new AttackDirection[] {
                    new(-1, 2, INF),
                    new(1, 2, INF)
                });
            case 4:
                return new Piece(id, 1, "飛", new AttackDirection[] {
                    new(0, -1, INF),
                    new(0, 1, INF),
                    new(-1, 0, INF),
                    new(1, 0, INF),
                });
            case 5:
                return new Piece(id, 1, "角", new AttackDirection[] {
                    new(1, 1, INF),
                    new(1, -1, INF),
                    new(-1, 1, INF),
                    new(-1, -1, INF),
                });
        }
        return new Piece(id, 0, "");
    }
}

Piece を取得する PieceRepository です。プロトタイプの時は新しいコマを追加するときは新しい Piece クラスを作成する必要がありましたが、今回は指定のパラメータを持ったインスタンスを生成し返すことによりコマを追加できるようになりました。現状はコードにベタ書きですが、いずれ API で取得するなどの形に変更されると思われます。API から取得する形に変える時に Infrastructure 層未満のコードは変更の必要がないのは大きな利点と言えるかもしれません。

GameLifetimeScope

using System.Collections.Generic;
using UnityEngine;
using VContainer;
using VContainer.Unity;
using R3;

public class GameLifetimeScope : LifetimeScope
{
    [SerializeField] BoardCellView boardCellPrefab;
    [SerializeField] BoardPieceView boardPiecePrefab;

    protected override void Configure(IContainerBuilder builder)
    {
        // Domain
        builder.Register<IPieceRepository, PieceRepository>(Lifetime.Singleton);
        builder.Register<BoardService>(Lifetime.Singleton);
        // UseCase
        builder.Register<GameStartUseCase>(Lifetime.Singleton);
        builder.Register<IGameStartOutputPort, GameStartPresenter>(Lifetime.Singleton);
        builder.Register<PlaceUseCase>(Lifetime.Singleton);
        builder.Register<IPlaceOutputPort, PlacePresenter>(Lifetime.Singleton);
        // Adapter
        builder.Register<GameStartController>(Lifetime.Singleton);
        builder.Register<PlaceController>(Lifetime.Singleton);
        // View
        builder.RegisterComponentInHierarchy<ViewFactory>().AsImplementedInterfaces();
        builder.RegisterComponentInHierarchy<BoardView>();
        builder.RegisterComponentInHierarchy<BoardView>().AsImplementedInterfaces();
        builder.RegisterComponentInHierarchy<TurnView>().AsImplementedInterfaces();
        builder.Register<TurnPresenter>(Lifetime.Singleton);
        builder.Register<PlayerPresenter>(Lifetime.Singleton);
        builder.RegisterComponent(boardCellPrefab);
        builder.RegisterComponent(boardPiecePrefab);

        // Data
        builder.RegisterInstance(new ReactiveProperty<Reversi>());
        builder.RegisterInstance(new Dictionary<Point, ReactiveProperty<BoardCell>>());
        builder.RegisterInstance(new Subject<BoardCell>());

        // Entry
        builder.RegisterEntryPoint<GameManager>().WithParameter("boardSize", 4);
    }
}

VContainer による DI の設定を行うクラスです。 各層の Interface や Class の実体を設定しています。Data までこれで Inject するのかどうかはまだ迷っていますが、レイヤーは犯さない形で作れているので一旦この形で作成しています。

GameManager

using VContainer;
using VContainer.Unity;

public class GameManager : IInitializable
{
    [Inject] readonly int boardSize = 4;
    [Inject] readonly GameStartController gameStartController;
    [Inject] readonly PlaceController placeController;
    [Inject] readonly TurnPresenter turnPresenter;

    void IInitializable.Initialize()
    {
        gameStartController.StartGame(boardSize);
        placeController.Bind();
        turnPresenter.Bind();
    }
}

VContainer によりエントリーポイントとして呼び出すクラスです。IInitializable.Initialize は初期化されたタイミングで一度呼び出されるもので、ここの関数の中でこれまで作ったクラスを用いでゲームを作成しています。

書いてみての所感

Domain/UseCase 層までは概ね指針通りに書けるのですが、Adapter 層をどう書くかを非常に迷いました。また、Clean Architecture としての Presenter と MVP としての Presenter でどちらを採用しようか、両方で1つにできないかなど試行錯誤した末、今回は両方を別々に作成する方式としてみました。名前が同じでわかりにくい問題は、後者は View の中に閉じ込めることで分けるようにしてみました。まだ少し気持ち悪さがあるので、よりブラッシュアップできる案を模索中です。

apadter 層の IView を infrastructure 層で継承する方式や、IViewFactory を継承する方式には結構満足しています。おかげで BoardCellView などの View クラスはかなりシンプルに保てているので、可読性や修正性高くコードを管理できているのではないでしょうか。ViewFactory を MonoBehavior にしなければならないのは改善できればしたいと思っています。

新規機能を追加するときに、プロトタイプコードに比べて多くのファイル数を記述しなければならないというデメリットは存在しますが、運用改善を考えると、読みやすくバグの出にくいコードの方がやはり正義なような気がしています。

ViewFactory

using UnityEngine;
using VContainer;

class ViewFactory : MonoBehaviour, IViewFactory
{
    [Inject] BoardView board;
    [Inject] BoardCellView boardCellPrefab;
    [Inject] BoardPieceView boardPiecePrefab;

    public (IBoardCellView boardCellView, IBoardPieceView boardPieceView) CreateCellAndPiece()
    {
        BoardCellView newBoardCell = Instantiate(boardCellPrefab);
        newBoardCell.transform.SetParent(board.transform, false);
        BoardPieceView newBoardPiece = Instantiate(boardPiecePrefab);
        RectTransform r = newBoardPiece.GetComponent<RectTransform>();
        r.localScale = new Vector3(1, 1, 1);
        newBoardPiece.transform.SetParent(newBoardCell.transform, false);
        return (newBoardCell, newBoardPiece);
    }

}

Instantiate を行うために MonoBehaviour にしています。ここを MonoBehaviour 使わない形で作れれば、もっと綺麗に作れるのではと考えています。

まとめ

Unity のプロトタイプコードを Clean Architecture x MVP で設計し直してみました。全体を Clean Architecture で設計しつつ、View に関する部分を MVP で実装しています。 問題点であった、Unity との密結合問題や、大きな Static Class への依存、Logic と UI コードの密結合などはおおむね解消され、Unity との結合度が下がり Logic と UI が綺麗に分離したかと思われます。

Adapter 層以降が特にまだ試行錯誤の最中です。View からのアクションにより Domain Model の値を変更する例は調べていていくつか見つかりましたが、Domain の変更を View に伝える例は探せず、現時点での私の知識でなんとか作り上げた形になっています。Domain 層では外部ライブラリをできるだけ使いたくないので、Adapter 層以降で Reactive Extensions である R3 を利用するようにしています。発展途上ですが、誰かの参考になればと思います。もしよろしければ、参考にしてみてください。こうした方がいいよ、ここ間違っているよ、などのフィードバックもぜひよろしくお願いします。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?