最近いい感じだと思っている個人開発のアーキテクチャっぽい何か
はじめに
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