ゲームを作り続けていると、気づけば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 の比較
| 観点 | Before | After |
|---|---|---|
| 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コンテナ。レイヤー間の依存注入に使用



