Edited at

[Unity] Unity1週間ゲームジャム (ぎりぎり) でCAFU (Clean Architecture For Unity) v2 を使って実装してみた


🖥開発環境


  • Unity2018.1.3f2

  • @umm/cafu_core 2.5.2

現在はv3がリリースされており、この記事より構造が大きく異なります。

詳細は以下の記事をご参照ください

KidsStar 開発合宿 vol.6 を開催しました - もんりぃ is undefined.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.


📝この記事の概要

先日 unityroom で開催されたオンラインゲームジャムイベント Unity1週間ゲームジャム お題「ぎりぎり」にて、自分は「ぎりぎりでボールを避ける」ゲームを作りました。 こちら からプレイできます。

unity1week8.gif

今回はこのゲームを作る時に使用したCAFUというライブラリについて紹介します。


🛠Unityにおけるアーキテクチャの話

まずはじめにCAFUというライブラリが何者かという話を軽く紹介します。


Unityからプログラミングを始めるとやりがちな書き方


  • 1つのクラス(MonoBehaviour)に処理を詰め込む

  • publicで関連する処理をアタッチし、MonoBehaviour同士を依存させる


この書き方に問題はあるのか?

これらの要素は個人/短期間でのゲーム開発では特に問題にならないことが多いです。

(この記事自体も、アーキテクチャの利点は述べつつ、極端に推奨するものではないです)

ただし チーム開発/中~長期間のゲーム開発を行う際にプロジェクト/コードが肥大化したときに問題になりがち です。具体的には以下のような問題が発生する可能性があります。


  • コードが肥大化するため、可読性が低く任意の処理を行なっている箇所が探しにくい

  • 密結合なため、機能修正時に影響範囲が大きく、エンバグが発生しやすい

  • 役割が不明瞭/もしくは他責務なため、機能修正/追加の際にどこに処理を記載したらいいかの判断が難しい


クラスの設計を行う

この問題を解消する手段としての一つとして「クラス設計」を適切に行うというものがあります。設計は開発におけるルールや計画書といったポジショニングで機能します。

クラスを設計することにより「役割が不明瞭である」を回避することができ、よりよい設計をすることで「密結合」「コードの肥大化」を避けることに繋がると考えます。


Unityの設計についての参考資料

Unityの設計周りの重要性の話については @toRisouP さんの資料が多くの人に支持されています。


アーキテクチャを用いて解決する

では、どのように設計を行なったらいいかと考える上で アーキテクチャ (設計思想) を適用する手段があります。


Clean Architecture

Clean Architecture はアーキテクチャの1つです。

ドメイン駆動開発(DDD)に適したアーキテクチャで関心の分離が意識されています。

Model(ビジネスロジック)の責務を分割して定義してあり、SOLID原則における単一責任の原則や依存性逆転の原則を意識しやすい特徴があります。

詳しい設計思想は "The Clean Architecture" というドキュメントにまとめられています

また @koutalou さんは以下の記事にてiOSへの適用例を記載しています。


📕CAFU (Clean Architecture For Unity) について

CAFU はUnityでClean Architectureを適用した開発を進めるためのモジュールです。

@monry さんが開発を行なっています。


CAFUを使う上での諸注意 (UniRxについて)

現状 UniRx を用いてイベントの受け渡しを行うことがほぼ前提となっております。

UniRxに関してはここでは解説しませんので、ご了承ください。


🔽CAFUのインストール

CAFUは npm (NodePackageManager) や npm と互換性のあるパッケージマネージャーである yarn を用いてインストールを行います。


npmを使用できる状態にする

node.js をインストールすればnpmも合わせて使用できるようになります。

今回は anyenv を使用して node.js をインストールします。

$ git clone https://github.com/riywo/anyenv ~/.anyenv

$ echo 'export PATH="$HOME/.anyenv/bin:$PATH"' >> ~/.zsh
$ echo 'eval "$(anyenv init -)"' >> ~/.zsh
$ exec $SHELL -l

$ anyenv install ndenv
$ exec $SHELL -l

$ ndenv install -l
$ ndenv install v9.5.0
$ ndenv global v9.5.0


UnityのProjectを作成する

CAFUを適用したいUnityのProjectを作成します。


package.json を定義する

以下のような package.json を作成したProjectのディレクトリの直下に配置します。

@umm/cafu_core が本体で、それ以外は必要に応じて追加するモジュールとなります。

今回は @umm/cafu_generics@umm/generator を使用します。

{

{
"name": "project_name",
"version": "1.0.0",
"description": "",
"dependencies": {
"@umm/cafu_core": "github:umm-projects/cafu_core",
"@umm/cafu_generics": "github:umm-projects/cafu_generics",
"@umm/cafu_generator": "github:umm-projects/cafu_generator"
}
}

package.png


installする

以下のコマンドを実行すると dependencies にて指定したモジュールがインストールされます。

$ npm i

以下のようにProject配下にModulesディレクトリが作成され、その中に各種モジュールがインストールされます。

modules.png

中には package.json にて指定していないモジュールが含まれていますが、これは今回インストールしたモジュールの依存モジュール (各モジュールにおける package.jsondependencies にて指定したもの) が合わせてインストールされた結果です。


ScriptRuntimeVersion を4.Xに上げる

warning.png

この時点で上記のエラーが出ることが確認できると思います。

これはCAFUが .NET 4.x が使用されることを前提としているからです。

BuildSetting > PlayerSettings > OtherSetting > Configuration にて .NET 4.x を使用するように切り替えます。

net4x.png


Scriptsディレクトリを作成する

Scripts 以下のディレクトリ構造はCAFUの各レイヤー毎にディレクトリを作成していきます。

Scripts.png


Generatorを用いたテンプレートコードの作成

@umm/cafu_generator をインストールしたことで使用できる機能です。

上部メニューより Windows > CAFU > ScriptsGenerator を選択します。

このエディタ拡張により、テンプレートコードを生成することができます。

generator.gif


💪CAFUを使ってコンテンツを作成する

ここからは実際に使用したコードの一部を紹介します。


Domain/UseCase

まずはロジックとその関連箇所から見ていきます。

UseCaseはビジネスロジックを責務としております。

usecase.png


ScoreUseCase

スコアの更新と更新時の通知を責務としています


ScoreUseCase.cs

using System;

using CAFU.Core.Domain.UseCase;
using UniRx;

namespace Unity1Week.Barely.Domain.UseCase
{
public interface IScoreUseCase : IUseCase
{
int GetScore();

void IncreaseScore();

IObservable<int> OnChangeScoreAsObservable();
}

public class ScoreUseCase : IScoreUseCase
{
private ReactiveProperty<int> score { get; set; }

public class Factory : DefaultUseCaseFactory<ScoreUseCase>
{
protected override void Initialize(ScoreUseCase instance)
{
base.Initialize(instance);
instance.score = new ReactiveProperty<int>();
}
}

public int GetScore()
{
return this.score.Value;
}

public void IncreaseScore()
{
this.score.Value++;
}

public IObservable<int> OnChangeScoreAsObservable()
{
return this.score.AsObservable();
}
}
}



LevelUseCase

スコアを受け取ってのレベル変更チェック、および現在のレベル管理を責務としています。


LevelUseCase.cs

using System;

using System.Collections.Generic;
using System.Linq;
using CAFU.Core.Domain.Translator;
using CAFU.Core.Domain.UseCase;
using CAFU.Generics.Domain.Repository;
using UniRx;
using Unity1Week.Barely.Data.Entity;
using Unity1Week.Barely.Domain.Model;
using Unity1Week.Barely.Domain.Translator;

namespace Unity1Week.Barely.Domain.UseCase
{
public interface ILevelUseCase : IUseCase
{
void CheckChangeLevel(int score);

IObservable<ILevelModel> OnChangeLevelAsObservable();
}

public class LevelUseCase : ILevelUseCase
{
private Dictionary<int, ILevelModel> threshold2LevelModelMap { get; set; }
private IGenericRepository<LevelEntityList> levelRepository { get; set; }
private ILevelModel currentLevelModel { get; set; }
private LevelModellTranslator levelModellTranslator { get; set; }
private Subject<ILevelModel> changeLevelSubject;

public class Factory : DefaultUseCaseFactory<LevelUseCase>
{
protected override void Initialize(LevelUseCase instance)
{
base.Initialize(instance);
instance.changeLevelSubject = new Subject<ILevelModel>();
instance.levelModellTranslator = new DefaultTranslatorFactory<LevelModellTranslator>().Create();
instance.levelRepository = new GenericRepository<LevelEntityList>.Factory().Create();
instance.threshold2LevelModelMap = new Dictionary<int, ILevelModel>();
instance.Initialize();
}
}

private void Initialize()
{
// threshold2LevelModelMapの初期化
foreach (var levelEntityPair in this.levelRepository.GetEntity().List)
{
// EntityからModelに変換
var levelModel = levelModellTranslator.Translate(levelEntityPair.Key, levelEntityPair.Value);
this.threshold2LevelModelMap.Add(levelModel.ThresholdScore, levelModel);
}

// currentLevelModelに初期値を設定
this.currentLevelModel = this.GetLevelModelByScore(0);
}

public void CheckChangeLevel(int score)
{
var levelModel = this.GetLevelModelByScore(score);
if (levelModel <= currentLevelModel) return;

this.currentLevelModel = levelModel;
this.changeLevelSubject.OnNext(levelModel);
}

public IObservable<ILevelModel> OnChangeLevelAsObservable()
{
return this.changeLevelSubject.AsObservable();
}

private ILevelModel GetLevelModelByScore(int score)
{
return this.threshold2LevelModelMap.OrderByDescending(x => x.Key).FirstOrDefault(x => score >= x.Key).Value;
}
}
}



Data/Entity

LevelUseCaseで出て来た概念に関して順番に説明します。

Entityはデータの構造を定義するレイヤーです。

entity.png


LevelEntityList

LevelEntityは各レベルにおける閾値/設定値を管理します。


LevelEntityList.cs

using System;

using CAFU.Core.Data.Entity;
using CAFU.Generics.Data.Entity;
using UnityEngine;

namespace Unity1Week.Barely.Data.Entity
{
[Serializable]
public class LevelEntityList : ScriptableObjectGenericEntityList<LevelEntityPair>
{
}

// LevelとLevelEntityのkey-value
[Serializable]
public class LevelEntityPair : GenericPairEntity<int, LevelEntity>
{
}

[Serializable]
public class LevelEntity : ILevelEntity
{
[SerializeField] private int thresholdScore;
public int ThresholdScore => thresholdScore;
}

// その他、必要に応じて各レベル毎に必要なデータを定義する
public interface ILevelEntity : IEntity
{
int ThresholdScore { get; }
}
}


今回は @umm/cafu_genericsScriptableObjectGenericEntityList を継承することで ScriptableObject として吐き出せるようにします。

また GenericPairEntity も @umm/cafu_generics から提供される概念で、key-value形式で値を持つことができます。

entity.gif


Data/DataStore

DataStoreはデータとの取り扱い(参照/保存)を責務として持ちます。

出力としてEntityを得ることができることを目指します。

WebAPI経由でJSONを入手してEntityに変換したり、ScriptableObjectからEntityを取り出したりというのが主な例です。

datastore.png


GenericDataStore

https://github.com/umm-projects/cafu_generics/#genericdatastore

今回は "ScriptableObjectからEntityを取り出す" という方針でデータを持つようにしたのですが @umm/cafu_genericsGenericDataStore を提供しており、ScriptableObjectGenericEntityList を継承することで生成可能なScriptableObjectに関しては新規でDataStoreを定義しなくてもEntityとして取り出すことが出来るようになります。

ただSceneに対してどのScriptableObjectを参照するのか定義する必要があります。

GenericDataStore を任意のScene上のGameObjectにアタッチして、参照するScriptableObjectを Scriptable Object Generic Entity List フィールドに指定します。

genericdatastore.gif


Domain/Repository

https://github.com/umm-projects/cafu_generics/#genericrepositorytgenericentity

RepositoryはDataレイヤーと対話するための層です。

データを読み書きするインターフェースを持ちます。

特定のDataを取り出す処理に関して、直接DataStoreを参照しようとすると、DataStoreのデータの取り扱いが変更になった時に修正が多く必要になるので、DataStoreへの参照はRepositoryに任せます。

repository.png

Repositoryに関しても @umm/cafu_genericsGenericRepository を提供しており、 "ScriptableObjectからEntityを取り出す" というケースにおいては、改めてRepositoryを実装する必要はありませんでした。

RepositoryはUseCaseから参照されます。

先述の LevelUseCase においても IGenericRepository<LevelEntityList> を参照できるようにしてあります。


Domain/Model

Entityはデータとして取り扱いやすい単位での定義でしたが、Modelは実際にゲーム上で使う上で都合のいい形式でのデータ構造を定義します。例えば、複数のEntityを組み合わせて使いたいときや、ゲームにおける可変な状態を管理したいときにはModelを使用することとなります。

model.png


LevelModel

LevelEntityをViewにて直接参照することを避けるために作成したのがLevelModelです。Translator経由でLevelEntityをLevelModelに変換しています。


LevelModel.cs

using CAFU.Core.Domain.Model;

using UnityEngine;

namespace Unity1Week.Barely.Domain.Model
{
public interface ILevelModel : IModel
{
int Level { get; }
int ThresholdScore { get; }
}

public class LevelModel : ILevelModel
{
public int Level { get; }
public int ThresholdScore { get; }

public LevelModel(int level, int thresholdScore, int intervalMillSeconds, int forceRange, float safeWidth)
{
this.Level = level;
this.ThresholdScore = thresholdScore;
}
}
}



Domain/Translator

TranslatorはEntityとModelを相互変換するためのレイヤーです。

EntityをView上で取り扱いやすくするためにModelに変換したり、Modelを書き込むためにデータ層が取り扱いやすいEntityの形式に変換したりするために使用します。

Translatorを挟むことで、外部仕様の変更の際に他のレイヤーに強く影響を及ぼさなくできます。

translator.png


LevelTranslator

LevelModellTranslator.Translate() によってModelへの変換処理を行います。


LevelTranslator.cs

using CAFU.Core.Domain.Translator;

using Unity1Week.Barely.Data.Entity;
using Unity1Week.Barely.Domain.Model;

namespace Unity1Week.Barely.Domain.Translator
{
public class LevelModellTranslator : IModelTranslator<int, ILevelEntity, ILevelModel>
{
public ILevelModel Translate(int level, ILevelEntity entity)
{
return new LevelModel(
level,
entity.ThresholdScore
);
}
}
}


ここまでがDomain/Data層の話でした。


Presentation/Presenter

PresnterはViewとUseCaseを繋ぐためのレイヤーです。

ここでは状態を持たず、必要なUseCaseおよび処理を呼び出すだけの責務となります。

PresenterはScene単位で定義し、そのSceneで必要な処理を呼び出すようにするのが一先ず良さそうです。

presenter.png


MainPresenter

MainPresenterは Main というSceneで必要なUseCaseおよび処理を定義しています。


MainPresenter.cs

using CAFU.Core.Presentation.Presenter;

using System;
using Domain.UseCase;
using Unity1Week.Barely.Domain.Model;
using Unity1Week.Barely.Domain.UseCase;
using IPresenter = CAFU.Core.Presentation.Presenter.IPresenter;

namespace Unity1Week.Barely.Presentation.Presenter
{
public class MainPresenter : IPresenter
{
private IScoreUseCase scoreUseCase { get; set; }
private ILevelUseCase levelUseCase { get; set; }

public class Factory : DefaultPresenterFactory<MainPresenter>
{
protected override void Initialize(MainPresenter instance)
{
base.Initialize(instance);
instance.scoreUseCase = new ScoreUseCase.Factory().Create();
instance.levelUseCase = new LevelUseCase.Factory().Create();
}
}

public void IncreaseScore()
{
this.scoreUseCase.IncreaseScore();
}

public IObservable<int> OnChangeScoreAsObservable()
{
return this.scoreUseCase.OnChangeScoreAsObservable();
}

public void CheckChangeLevel(int score)
{
this.levelUseCase.CheckChangeLevel(score);
}

public IObservable<ILevelModel> OnChangeLevelAsObservable()
{
return this.levelUseCase.OnChangeLevelAsObservable();
}
}
}



Presentation/View

Unityからの入力や描画を定義するレイヤーです。

ViewはMonoBehaviourを継承し、Scene上のGameObjectにアタッチされます。

Presenterに対してアクセスすることができます。

view.png


Main/Controller

Controllerはシーン全体で発生しうるイベントの管理やPresenterの初期化を役割として持っています。

ユーザは意識する必要はありませんが、ControllerのジェネリックにMainPresenterを指定することで、Awake時にMainPresenterのInstanceが生成されます。

(下記はMainシーンにおけるControllerを指します)


Controller.cs

using CAFU.Core.Presentation.View;

using UniRx;
using Unity1Week.Barely.Presentation.Presenter;

namespace Unity1Week.Barely.Presentation.View.Main
{
public class Controller : Controller<Controller, MainPresenter, MainPresenter.Factory>
{
protected override void OnStart()
{
base.OnStart();

// スコア増加時にレベルアップ判定を行う
this.GetPresenter().OnChangeScoreAsObservable()
.Subscribe(score => this.GetPresenter().CheckChangeLevel(score))
.AddTo(this);
}
}

// 同一のViewの名前空間に対して、Presnterを取得する拡張を定義しておく
public static class ViewExtension
{
public static MainPresenter GetPresenter(this IView view)
{
return Controller.Instance.Presenter;
}
}
}



Main/Score

ViewにおけるScoreの責務はスコアが更新された時にアニメーションを実行し、描画を更新することとしています。

score.gif


Score.cs

using CAFU.Core.Presentation.View;

using DG.Tweening;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

namespace Unity1Week.Barely.Presentation.View.Main
{
[RequireComponent(typeof(Text))]
public class Score : MonoBehaviour, IView
{
private RectTransform rectTransform;
private Vector2 fromAnchoredPosition;
private Vector2 baseAnchoredPosition;
private Text scoreText;

private void Awake()
{
this.scoreText = this.GetComponent<Text>();
this.rectTransform = this.GetComponent<RectTransform>();
this.baseAnchoredPosition = this.rectTransform.anchoredPosition;
this.fromAnchoredPosition = new Vector2(
this.baseAnchoredPosition.x,
this.baseAnchoredPosition.y - 50f
);
}

private void Start()
{
// スコア更新時にShowScoreメソッドの実行
this.GetPresenter().OnChangeScoreAsObservable()
.Subscribe(this.ShowScore))
.AddTo(this);
}

// スコアのテキストを更新しつつ、アニメーションの実行
private void ShowScore(int score)
{
this.rectTransform.anchoredPosition = this.fromAnchoredPosition;
this.scoreText.color = this.scoreText.color.ToTransparent();
this.scoreText.text = score.ToString();

DOTween.Sequence()
.Append(this.scoreText.DOFade(1.0f, 0.5f))
.Join(this.rectTransform.DOAnchorPos(this.baseAnchoredPosition, 0.5f).SetEase(Ease.OutBounce))
.Play();
}
}
}



Main/LevelUp

レベルが上がった時に演出を再生する責務を持ちます。

(レベルアップの判定の責務をUseCaseに移譲したことで描画/アニメーションに専念できます)

levelup.gif


LevelUp.cs

using CAFU.Core.Presentation.View;

using DG.Tweening;
using UniRx;
using UnityEngine;

namespace Unity1Week.Barely.Presentation.View.Main
{
public class LevelUp : MonoBehaviour, IView
{
[SerializeField] private ParticleSystem particleSystem;
[SerializeField] private RectTransform textRectTransform;
[SerializeField] private Vector2 fromPosition;
[SerializeField] private Vector2 toPosition;
[SerializeField] private AudioSource audioSource;
private Vector2 basePosition;

private void Awake()
{
this.basePosition = this.textRectTransform.anchoredPosition;
this.textRectTransform.anchoredPosition = this.fromPosition;
}

private void Start()
{
this.GetPresenter().OnChangeLevelAsObservable()
.Subscribe(_ =>
{
this.textRectTransform.anchoredPosition = this.fromPosition;
this.particleSystem.Play();
this.audioSource.Play();
DOTween.Sequence()
.Append(this.textRectTransform.DOAnchorPos(basePosition, 1.2f).SetEase(Ease.OutCubic))
.Append(this.textRectTransform.DOAnchorPos(toPosition, 1.2f).SetEase(Ease.InCubic))
.Play();
})
.AddTo(this);
}
}
}



実際に作成したクラス群について

上記は「スコア」「レベルアップ」にスポットを当てて紹介しましたが、他にも「ゲーム全体のステータス管理」「ボールの出現処理」「スローモーション機能」などもあり、実際は以下のようなクラス群が出来ました。責務は分割して分かりやすくなりますが、機能が増えるにつれ、どうしてもファイル数は増加してしまいます。

(ResultなんかはScene自体を分割すれば良かったと思っています)

class.png


🙏最後に


  • 1週間ゲームジャムは新しい技術検証を行う場としていい感じです!

  • CAFUは関連ライブラリを含め、機能が多いので少しずつ紹介していきたいところ

  • まだ理解不足な側面もあるので「こうした方が良さそう」とかあれば、遠慮なくコメントください!!