🐕

最近いい感じだと思っている個人開発のアーキテクチャっぽい何か

2024/05/24に公開

はじめに

Unityで個人開発をしていて、個人的にそこそこしっくりくるアーキテクチャ的な何かを考えているので、その内容を共有します。

前提

  • DIちょっとわかる / DIコンテナ使ったことがある
    • VContainerを使っていることを前提としたコード例を書きます

Data-View-System

最近やっている開発では、主に以下の3つの要素(レイヤー?)に分けて開発を進めています。

  • Data
  • View
  • System

ECSの設計をちょっと参考にしています。

Data

Dataは、ゲーム内で扱うデータを管理するクラスです。状態変数は基本的にすべてDataに配置します。
ただ、もちろんViewにてMonoBehaviourを継承する以上このやり方には限界があるので、ある程度はViewにも状態変数を持たせることもあります。

public sealed class PlayerMovementData
{
    public bool IsGrounded { get; set; }
    public bool IsAgainstWall { get; set; }
    public bool HasJumped { get; set; }
    public bool HasDashed { get; set; }
    public float CurrentMovementLerpSpeed { get; set; }
    public Vector3 DashDirection { get; set; }
}

public sealed class InputData
{
    public Vector2 MovementInput { get; set; }
    public bool JumpInput { get; set; }
    public bool DashInput { get; set; }
}

View

ViewではがっつりとMonoBehaviourを継承したクラスを作成します。
ViewのクラスはUnityでの描画や入力を行うAPIを提供するイメージで書きます。
Unityの標準のコンポーネントの機能やMonoBehaviourの機能を使うものはViewに書きます。

また、イベント等の発行もViewで行いますが、その際はSystemに通知する形で行います。Viewにロジックを書くことは基本的に避けます。

public sealed class PlayerMovementView : MonoBehaviour
{
    [SerializeField] private Rigidbody _rigidbody;

    public Vector3 Position => transform.position;
    public Vector3 Velocity => _rigidbody.velocity;

    public void SetVelocity(Vector3 velocity)
    {
        _rigidbody.velocity = velocity;
    }

    public void AddForce(Vector3 force, ForceMode forceMode)
    {
        _rigidbody.AddForce(force, forceMode);
    }

    public void SetUseGravity(bool useGravity)
    {
        _rigidbody.useGravity = useGravity;
    }

    public void SetFacingDirection(Vector3 direction)
    {
        transform.forward = direction;
    }

    public void SetPosition(Vector3 position)
    {
        transform.position = position;
    }
}

System

SystemはDataとViewを参照しながら、ゲーム内のロジックを実装するクラスです。エントリポイントとなるクラスはSystemに配置します。

/// <summary>
/// 入力を受け取ってInputDataに反映させるシステム
/// </summary>
public sealed class InputProviderSystem : ITickable
{
    private readonly InputData _data;

    public InputProviderSystem(InputData data)
    {
        _data = data;
    }

    public void Tick()
    {
        _data.MovementInput = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"));
        _data.JumpInput = Input.GetButtonDown("Jump");
        _data.DashInput = Input.GetButtonDown("Dash");
    }
}

/// <summary>
/// プレイヤーの移動を制御するシステム
/// </summary>
public sealed class PlayerMovementSystem : ITickable
{
    private readonly PlayerMovementData _movementData;
    private readonly InputData _inputData;
    private readonly PlayerMovementView _view;

    public PlayerMovementSystem(PlayerMovementData movementData, InputData inputData, PlayerMovementView view)
    {
        _movementData = movementData;
        _inputData = inputData;
        _view = view;
    }

    // 多分まともに動かないけど、例として!!
    private void Tick()
    {
        // プレイヤーの設置判定の処理
        _movementData.IsGrounded = Physics.Raycast(_view.Position, Vector3.down, 1f);
        _movementData.IsAgainstWall = Physics.Raycast(_view.Position, _view.transform.forward, 1f);

        // プレイヤーの水平移動の処理
        var velocity = _view.Velocity;
        var movementInput = _inputData.MovementInput;
        _view.SetVelocity(new Vector3(movementInput.x, velocity.y, movementInput.y));

        // プレイヤーのジャンプ処理
        if (_inputData.JumpInput && _movementData.IsGrounded)
        {
            _view.AddForce(Vector3.up * 10f, ForceMode.Impulse);
        }
    }
}

いいこと

Dataを分離することで、管理や参照が容易になる

ゲーム開発において他クラスから一番いじりたくなるのはDataの部分です。この部分を分離することで、Dataの管理や参照が容易になります。

エントリポイントが追いやすくなる

Start()Update()が複数のクラスに分散していると、どこから処理が始まるのかがわかりにくくなります。Systemにエントリポイントを集約することで、処理の流れが追いやすくなります。

この構造では、エントリポイントを持つSystemクラスが主導権を握っているイメージでコードを書いています。

一応Viewにてこういったイベント関数を利用することもできますが、最低限にとどめておくのがよいかと思います。

クラス名がわかりやすい

Data, View, Systemという層にわけることで、クラスの役割が一目でわかります。
変数を参照するためだけにManagerクラスを参照する、みたいな不明瞭なクラス依存が減るかと思います。

基本的にデータを取りたい、編集したいときにDataを参照するだけなので、参照しているクラスからはっきりと目的が見えるのはコード全体の可読性につながると思います。

テストしやすい

しやすいと思います。自分はあんまりしてないけど。

個人的なルール一覧

  • Systemは状態変数を持たない
    • 状態変数はDataに持たせる
  • SystemはDataとViewを参照していい
  • DataでもViewでもない感じのクラスはとりあえずSystemに配置する
  • transform.position = new Vector3(1, 0, 0); みたいにMonoBehaviourの機能を直接いじるのはViewの責務
    • Systemにて小文字から始まるフィールド(MonoBehaviour等のフィールド)を左辺に持ってこない
    • Systemからこういうのは書かない、書くならViewにメソッドとして書く
  • Viewにロジックを書かない
    • できるだけUnityの標準のコンポーネントの機能やMonoBehaviourの機能を使うにとどめる
  • ViewはMonoBehaviourを継承する
  • Viewのイベント関数は最低限にとどめる
    • コンポーネントの初期化や破棄の処理、コライダーとの衝突判定などはOK
  • ViewはDataを参照しない
    • ここは状況次第で緩めてもいいかもしれない

FAQ

Q.MVPパターンを利用したい場合は?
A.ModelをDataに、PresenterをSystemに、ViewをViewに書く感じがいいと思っています。

Q.Viewを参照するときに結局ミスってtransform.position = new Vector3(1, 0, 0);みたいなの書いちゃいそう
A.Viewをラップするクラスを書くという解決方法があります。

public sealed class PlayerMovement
{
    private readonly PlayerMovementView _view;

    public PlayerMovement(PlayerMovementView view)
    {
        _view = view;
    }

    public void SetPosition(Vector3 position)
    {
        _view.SetPosition(position);
    }

    public void SetVelocity(Vector3 velocity)
    {
        _view.SetVelocity(velocity);
    }
}

public sealed class PlayerMovementSystem : ITickable
{
    private readonly PlayerMovementData _movementData;
    private readonly InputData _inputData;
    private readonly PlayerMovement _movement;

    public PlayerMovementSystem(PlayerMovementData movementData, InputData inputData, PlayerMovement movement)
    {
        _movementData = movementData;
        _inputData = inputData;
        _movement = movement;
    }

    private void Tick()
    {
        // プレイヤーの設置判定の処理
        _movementData.IsGrounded = Physics.Raycast(_movement.Position, Vector3.down, 1f);
        _movementData.IsAgainstWall = Physics.Raycast(_movement.Position, _movement.transform.forward, 1f);

        // プレイヤーの水平移動の処理
        var velocity = _movement.Velocity;
        var movementInput = _inputData.MovementInput;
        _movement.SetVelocity(new Vector3(movementInput.x, velocity.y, movementInput.y));
        _movement.transform.forward = new Vector3(movementInput.x, 0, movementInput.y); // コンパイルエラーになる

        // プレイヤーのジャンプ処理
        if (_inputData.JumpInput && _movementData.IsGrounded)
        {
            _movement.AddForce(Vector3.up * 10f, ForceMode.Impulse);
        }
    }

この場合、「必ずこのクラスのみを参照する(Viewの方は参照しない)」というルールが追加されるので、それはそれで忘れる可能性があるのがちょっと難点…

Q.なんで個人開発向けなの?
A.個人開発向けというより、自分が個人開発で使っているだけです。ただ、ルールが多すぎる気もするので、チーム開発には向いていない気がしなくもないです。

終わりに

この設計は私が勝手に納得してるだけのものなので、そこはご了承ください。
この設計がゲーム開発において有効だという気はあんまりありませんが、個人的にしっくりきているというだけです。

また、いつかこの設計を使ったゲームのScriptをGitHubに公開したいなーと思っています。

Discussion