【Cursor活用】UnityのMonoBehaviourをDDD/オニオンアーキテクチャに沿ってリファクタリングする方法

Unity

ゲームを作り続けていると、気づけばPlayerControllerが1000行を超えていることがあります。 移動・攻撃・UI更新・セーブ処理が1つのMonoBehaviourに混在している状態です。

「分けるべきなのはわかるけど、どう分ければいいかわからない」

そこでCursorを使い、DDD/オニオンアーキテクチャに沿った分割を対話形式で進めてみました。 ただクラスを分けるのではなく、層(レイヤー)に沿って責務を整理することで、あとから機能追加しやすいコードになります。


前提: 自分が使っているアーキテクチャ

自分のUnityプロジェクトではオニオンアーキテクチャ + DDDを採用しています。 ディレクトリは以下の4層に分かれています。

Plaintext
Assets/Scripts/
├── Domain/         # ドメイン層: エンティティ・値オブジェクト・リポジトリIF
├── Application/    # アプリケーション層: ユースケース・サービスIF
├── Infrastructure/ # インフラ層: Unity機能・外部サービスの実装
└── Presentation/   # プレゼンテーション層: MonoBehaviour・UI・View

今回はこの構造に向けてCursorにリファクタリングを手伝ってもらいます。


Before: 肥大化したPlayerController

C#
// Presentation/Objects/PlayerController.cs(リファクタリング前)
public class PlayerController : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private int maxHp = 100;
    private int _currentHp;
    private Rigidbody _rb;

    private void Awake()
    {
        _rb = GetComponent<Rigidbody>();
        _currentHp = maxHp;
    }

    private void Update()
    {
        // 移動処理
        var h = Input.GetAxis("Horizontal");
        var v = Input.GetAxis("Vertical");
        _rb.velocity = new Vector3(h, 0, v) * moveSpeed;

        // HP表示の更新(直接UIを触っている)
        FindObjectOfType<HpBarUI>().SetValue(_currentHp, maxHp);
    }

    // ダメージ処理(ゲームロジックがPresentation層に混在)
    public void TakeDamage(int amount)
    {
        _currentHp -= amount;
        if (_currentHp <= 0) GameManager.Instance.GameOver();
    }

    // セーブ処理(インフラ層の処理がPresentation層に混在)
    public void SaveProgress()
    {
        PlayerPrefs.SetInt("hp", _currentHp);
    }
}

問題点をCursorに整理してもらいました。

Cursorへの質問:

Plaintext
このPlayerControllerの問題点を、オニオンアーキテクチャ(Domain/Application/Infrastructure/Presentation)
の観点から指摘してください。

Cursorの回答(要約):

  • TakeDamage のゲームロジックはDomain層のエンティティが持つべき
  • GameManager.Instance への直接参照はPresentation層からApplication層への依存が密結合
  • FindObjectOfType<HpBarUI>() は毎フレーム走るため負荷が高く、依存も強い
  • SaveProgress のPlayerPrefs操作はInfrastructure層に切り出すべき
  • 入力処理もInfrastructure層の IInputService として分離できる

After: 層ごとに分割する

Step 1: Domain層にエンティティを作る

C#
// Domain/Models/Player/PlayerEntity.cs
// ゲームロジック(HPの増減・ゲームオーバー判定)はドメイン層で完結させる
public class PlayerEntity
{
    public int CurrentHp { get; private set; }
    public int MaxHp { get; }
    public bool IsDead => CurrentHp <= 0;

    public PlayerEntity(int maxHp)
    {
        MaxHp = maxHp;
        CurrentHp = maxHp;
    }

    public void TakeDamage(int amount)
    {
        CurrentHp = Mathf.Max(0, CurrentHp - amount);
    }

    public void Heal(int amount)
    {
        CurrentHp = Mathf.Min(MaxHp, CurrentHp + amount);
    }
}

Cursorへの質問:

Plaintext
PlayerEntityのTakeDamageをテストしやすい形にするには、
MonoBehaviourに依存しないC#クラスとして書くのが正しいですか?
また、IsDead の判定をプロパティにするかメソッドにするか、DDDの観点でどちらが適切ですか?

この問いかけで「状態を表すものはプロパティ、操作を表すものはメソッド」という判断基準を確認しながら進められます。

Step 2: Application層にユースケースを作る

C#
// Application/UseCases/PlayerDamageUseCase.cs
// 「プレイヤーがダメージを受けた」というゲームの流れを記述する
public class PlayerDamageUseCase
{
    private readonly PlayerEntity _player;
    private readonly IGameStateService _gameStateService;  // Application/Services のIF

    public PlayerDamageUseCase(PlayerEntity player, IGameStateService gameStateService)
    {
        _player = player;
        _gameStateService = gameStateService;
    }

    public void Execute(int amount)
    {
        _player.TakeDamage(amount);
        if (_player.IsDead)
        {
            _gameStateService.GameOver();
        }
    }
}

Step 3: Infrastructure層で入力とセーブを実装する

C#
// Infrastructure/Services/Input/UnityInputService.cs
// Input.GetAxisをラップしてIF経由で差し替え可能にする
public class UnityInputService : IInputService
{
    public Vector2 GetMoveInput()
    {
        return new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"));
    }
}

// Infrastructure/Repositories/PlayerPrefsPlayerRepository.cs
// PlayerPrefsの操作はここに閉じ込める
public class PlayerPrefsPlayerRepository : IPlayerRepository
{
    private const string HpKey = "player_hp";

    public void Save(PlayerEntity player)
    {
        PlayerPrefs.SetInt(HpKey, player.CurrentHp);
    }

    public int LoadHp() => PlayerPrefs.GetInt(HpKey, 0);
}

Step 4: Presentation層のPresenterをシンプルにする

R3を使ってHPの変化をObservableとして流し、UIは購読するだけにします。

C#
// Presentation/Objects/PlayerPresenter.cs
using R3;
using UnityEngine;
using VContainer;

public class PlayerPresenter : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 5f;

    private IInputService _inputService;
    private PlayerDamageUseCase _damageUseCase;
    private PlayerEntity _player;
    private Rigidbody _rb;

    // HPの変化をObservableとして公開 → HpBarUIが購読する
    public Observable<(int current, int max)> OnHpChanged => _hpChanged;
    private readonly Subject<(int current, int max)> _hpChanged = new();

    [Inject]
    public void Construct(IInputService inputService, PlayerDamageUseCase damageUseCase, PlayerEntity player)
    {
        _inputService = inputService;
        _damageUseCase = damageUseCase;
        _player = player;
    }

    private void Awake() => _rb = GetComponent<Rigidbody>();

    private void Update()
    {
        var input = _inputService.GetMoveInput();
        _rb.velocity = new Vector3(input.x, 0, input.y) * moveSpeed;
    }

    // 当たり判定などから呼ばれる
    public void TakeDamage(int amount)
    {
        _damageUseCase.Execute(amount);
        // HPが変わったことをUIに通知(UIは直接触らない)
        _hpChanged.OnNext((_player.CurrentHp, _player.MaxHp));
    }
}

Cursorを使う際の効果的な質問の仕方

リファクタリングをCursorに手伝ってもらうとき、「分割して」だけでは汎用的な答えしか返ってきません。 アーキテクチャの前提を伝えると回答の精度が上がります。

効果的なプロンプト例:

Plaintext
# プロジェクト前提を伝える
このUnityプロジェクトはオニオンアーキテクチャ(Domain/Application/Infrastructure/Presentation)を
採用し、VContainerでDI、R3でイベント処理を行っています。

# 具体的に聞く
このPlayerControllerの TakeDamage メソッドを、
ドメイン層のエンティティとアプリケーション層のユースケースに分割するとしたら
どのような設計になりますか?コード例も含めて教えてください。
Plaintext
# レイヤー違反を検出させる
このクラスはPresentation層に置いていますが、
オニオンアーキテクチャの依存ルールに違反している箇所はありますか?

Before / After の比較

観点BeforeAfter
PlayerControllerの行数約80行(混在)約40行(移動のみ)
ゲームロジックの場所MonoBehaviour内PlayerEntity(Domain層)
GameOver処理GameManager.Instance 直参照IGameStateService 経由
UI更新FindObjectOfType で毎フレームR3 Observable で購読
セーブ処理PlayerPrefs を直接呼ぶIPlayerRepository 経由
テスト可否困難(MonoBehaviour依存)PlayerEntityは純粋なC#クラスでテスト可

まとめ

  • Cursorにアーキテクチャの前提(オニオン構造・使用ライブラリ)を伝えると、層ごとの分割案を出してくれる
  • MonoBehaviourのゲームロジックはDomain層のエンティティに、フロー制御はApplication層のユースケースに分離する
  • 入力・セーブはInfrastructure層でインターフェース経由にすると差し替えとテストがしやすくなる
  • UIへの通知はR3 ObservableにするとPresenter側から直接UIを触る必要がなくなる
  • 「分割して」ではなく「このレイヤーに置くとしたらどうなる?」と聞くと精度が上がる

設計の全体像は【DDD/UCDD】Unityで意識している設計手法【オニオンアーキテクチャ】にまとめています。

今回紹介したツール・サービス

  • Cursor: AIを統合したコードエディタ。コード全体のコンテキストを踏まえた提案ができる
  • R3: Unityに最適化されたReactive Extensions。イベント駆動処理の実装に使用
  • VContainer: UnityのDIコンテナ。レイヤー間の依存注入に使用
タイトルとURLをコピーしました