学習記事一覧 · Unity

IkinokoBattle で学ぶ Unity OOP 設計パターン:第6回

プロパティとデータ・ビュー分離

シリーズ目次: IkinokoBattle で学ぶ Unity OOP 設計パターン
前の記事: 第5回 状態パターン


はじめに

「アイテムのデータを更新したら、画面表示も自動的に切り替わってほしい」——UIを持つゲームでよく出てくる要求です。

素朴な実装では「データを変更するコードのすぐ隣に UI 更新の命令を書く」ことになりがちです。しかしデータ処理と UI 処理が混在すると、「バランス調整したいだけなのに UI コードを理解しなければならない」「UI を作り直すたびにデータクラスを触る」という問題が生じます。

IkinokoBattle ではこの問題を C# の プロパティ(set アクセサ)クラス分割によって解決しています。今回はその実装を ItemButtonItemsDialogOwnedItemsData の 3 クラスを通じて読み解きます。


今回の題材

クラス 役割
OwnedItemsData データ層 アイテムの所持状態を保持・JSON で永続化
ItemButton UI 層 アイテム 1 枠の表示。プロパティ経由でデータを受け取り即座に UI へ反映
ItemsDialog 制御層 ダイアログの開閉と ItemButton へのデータ配布を担当
Item ゲームオブジェクト層 フィールドのアイテム。拾ったら OwnedItemsData に追加・保存

コード全文

OwnedItemsData.cs(データ層)

using System;
using System.Linq;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class OwnedItemsData
{
    private const string PlayerPrefsKey = "OWNED_ITEMS_DATA";
    // ──────────────────────────────────────────
    // Singleton(純粋 C# 版、第1回参照)
    // ──────────────────────────────────────────
    public static OwnedItemsData Instance
    {
        get
        {
            if (null == _instance)
            {
                // PlayerPrefs にデータがあれば JSON から復元、なければ新規作成
                _instance = PlayerPrefs.HasKey(PlayerPrefsKey)
                    ? JsonUtility.FromJson<OwnedItemsData>(PlayerPrefs.GetString(PlayerPrefsKey))
                    : new OwnedItemsData();
            }
            return _instance;
        }
    }
    private static OwnedItemsData _instance;
    // ──────────────────────────────────────────
    // データ
    // ──────────────────────────────────────────
    /// <summary>所持アイテム一覧を取得します。</summary>
    public OwnedItem[] OwnedItems => ownedItems.ToArray();
    [SerializeField] private List<OwnedItem> ownedItems = new List<OwnedItem>();
    private OwnedItemsData() { } // 外部から new できないようにする(Singleton)
    // ──────────────────────────────────────────
    // 永続化
    // ──────────────────────────────────────────
    /// <summary>JSON 化して PlayerPrefs に保存します。</summary>
    public void Save()
    {
        var jsonString = JsonUtility.ToJson(this);
        PlayerPrefs.SetString(PlayerPrefsKey, jsonString);
        PlayerPrefs.Save();
    }
    // ──────────────────────────────────────────
    // CRUD 操作
    // ──────────────────────────────────────────
    public void Add(Item.ItemType type, int number = 1)
    {
        var item = GetItem(type);
        if (null == item)
        {
            item = new OwnedItem(type);
            ownedItems.Add(item);
        }
        item.Add(number);
    }
    public void Use(Item.ItemType type, int number = 1)
    {
        var item = GetItem(type);
        if (null == item || item.Number < number)
            throw new Exception("アイテムがありません");
        item.Use(number);
    }
    public OwnedItem GetItem(Item.ItemType type)
        => ownedItems.FirstOrDefault(x => x.Type == type);
    // ──────────────────────────────────────────
    // ネストクラス:アイテム 1 件分のデータモデル
    // ──────────────────────────────────────────
    [Serializable]
    public class OwnedItem
    {
        public Item.ItemType Type => type;
        public int Number         => number;
        [SerializeField] private Item.ItemType type;
        [SerializeField] private int           number;
        public OwnedItem(Item.ItemType type) { this.type = type; }
        public void Add(int number = 1) { this.number += number; }
        public void Use(int number = 1) { this.number -= number; }
    }
}

ItemButton.cs(UI 層)

using System;
using System.Linq;
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(Button))]
public class ItemButton : MonoBehaviour
{
    // ──────────────────────────────────────────
    // プロパティ:set に UI 更新を組み込む
    // ──────────────────────────────────────────
    public OwnedItemsData.OwnedItem OwnedItem
    {
        get { return _ownedItem; }
        set
        {
            _ownedItem = value;
            // アイテムが割り当てられたかどうかで表示を切り替える
            var isEmpty = null == _ownedItem;
            image.gameObject.SetActive(!isEmpty);
            number.gameObject.SetActive(!isEmpty);
            _button.interactable = !isEmpty;
            if (!isEmpty)
            {
                // アイテム種類に対応したスプライトを検索して表示
                image.sprite = itemSprites.First(x => x.itemType == _ownedItem.Type).sprite;
                number.text  = "" + _ownedItem.Number;
            }
        }
    }
    [SerializeField] private ItemTypeSpriteMap[] itemSprites; // アイテム種別→スプライトのマッピング
    [SerializeField] private Image image;
    [SerializeField] private Text  number;
    private Button                    _button;
    private OwnedItemsData.OwnedItem  _ownedItem;
    private void Awake()
    {
        _button = GetComponent<Button>();
        _button.onClick.AddListener(OnClick);
    }
    private void OnClick()
    {
        // TODO: ボタン押下時の処理
    }
    /// <summary>アイテム種別と Sprite を Inspector で紐付けるためのクラス</summary>
    [Serializable]
    public class ItemTypeSpriteMap
    {
        public Item.ItemType itemType;
        public Sprite        sprite;
    }
}

ItemsDialog.cs(制御層)

using UnityEngine;
public class ItemsDialog : MonoBehaviour
{
    [SerializeField] private int        buttonNumber = 15;
    [SerializeField] private ItemButton itemButton;  // Prefab
    private ItemButton[] _itemButtons;
    private void Start()
    {
        gameObject.SetActive(false); // 初期状態は非表示
        // buttonNumber - 1 個を追加 Instantiate(元の 1 個と合わせて buttonNumber 個)
        for (var i = 0; i < buttonNumber - 1; i++)
        {
            Instantiate(itemButton, transform);
        }
        // 子要素の ItemButton を一括取得・キャッシュ
        _itemButtons = GetComponentsInChildren<ItemButton>();
    }
    /// <summary>アイテム欄の表示/非表示を切り替えます。</summary>
    public void Toggle()
    {
        gameObject.SetActive(!gameObject.activeSelf);
        if (gameObject.activeSelf)
        {
            // 表示された場合はアイテムデータをリフレッシュ
            for (var i = 0; i < buttonNumber; i++)
            {
                // データがあれば OwnedItem を、なければ null をセット
                _itemButtons[i].OwnedItem = OwnedItemsData.Instance.OwnedItems.Length > i
                    ? OwnedItemsData.Instance.OwnedItems[i]
                    : null;
            }
        }
    }
}

Item.cs(フィールドアイテム:拾ったときのデータ保存)

public enum ItemType { Wood, Stone, ThrowAxe }
private void OnTriggerEnter(Collider other)
{
    if (!other.CompareTag("Player")) return;
    OwnedItemsData.Instance.Add(type);  // データ層に追加
    OwnedItemsData.Instance.Save();     // 即座に永続化
    Destroy(gameObject);
}

ポイント解説

① プロパティ駆動更新 — set アクセサに UI 処理を組み込む

C# のプロパティは get / set を自由に実装できます。ItemButtonOwnedItem セッターはその典型的な活用例です。

public OwnedItemsData.OwnedItem OwnedItem
{
    get { return _ownedItem; }
    set
    {
        _ownedItem = value;      // ① データを保存
                                 // ② 即座に UI へ反映(ここが肝心)
        var isEmpty = null == _ownedItem;
        image.gameObject.SetActive(!isEmpty);
        number.gameObject.SetActive(!isEmpty);
        _button.interactable = !isEmpty;
        if (!isEmpty)
        {
            image.sprite = itemSprites.First(x => x.itemType == _ownedItem.Type).sprite;
            number.text  = "" + _ownedItem.Number;
        }
    }
}

呼び出し側(ItemsDialog)は代入するだけです。

_itemButtons[i].OwnedItem = OwnedItemsData.Instance.OwnedItems[i];
// ↑ この 1 行で、画像・数量テキスト・ボタン活性状態が全て更新される

「データを渡したら表示が自動で整う」——この仕組みのおかげで ItemsDialog は UI の内部構造を一切知らなくて済みます。


② データ・ビュー・制御の 3 層分離

IkinokoBattle のアイテムシステムは 3 層に分かれています。

┌──────────────────────────────────────────────────────────┐
│  制御層   ItemsDialog                                     │
│    ・ダイアログの開閉(Toggle)                            │
│    ・OwnedItemsData から取得 → ItemButton へ配布          │
│    ・UI の内部構造も永続化の方法も知らない                 │
└───────────────────┬──────────────────┬───────────────────┘
                    │ OwnedItem を渡す  │ OwnedItem をセット
        ┌───────────▼──────────┐  ┌────▼─────────────────┐
        │  データ層             │  │  UI 層               │
        │  OwnedItemsData       │  │  ItemButton          │
        │  ・所持アイテムを保持  │  │  ・1 枠の表示担当    │
        │  ・JSON で保存・復元  │  │  ・set で即時 UI 更新│
        │  ・Singleton          │  │  ・データ保存は不関与│
        └───────────────────────┘  └──────────────────────┘

各層の関心事が分離しているため、たとえば「UI デザインを変えたい」なら ItemButton だけ修正すれば済み、OwnedItemsDataItemsDialog には触りません。


③ JsonUtility と PlayerPrefs — シンプルなデータ永続化

OwnedItemsData.Save() は 3 行で実装されています。

public void Save()
{
    var jsonString = JsonUtility.ToJson(this); // オブジェクト → JSON 文字列
    PlayerPrefs.SetString(PlayerPrefsKey, jsonString); // デバイスに保存
    PlayerPrefs.Save(); // 即座にディスクへ書き出し
}

JsonUtility.ToJson()[Serializable] を付けたクラスと [SerializeField] フィールドを JSON 文字列に変換します。逆は JsonUtility.FromJson<T>() で復元します。

// 復元
_instance = JsonUtility.FromJson<OwnedItemsData>(
    PlayerPrefs.GetString(PlayerPrefsKey));

PlayerPrefs はプラットフォームを問わずキーと値のペアを保存できる Unity 組み込みの仕組みで、文字列・整数・浮動小数点数に対応しています。JSON との組み合わせで任意の構造体をまるごと保存できます。

API 用途
JsonUtility.ToJson(obj) オブジェクト → JSON 文字列
JsonUtility.FromJson<T>(json) JSON 文字列 → オブジェクト
PlayerPrefs.SetString(key, val) 文字列をデバイスに書き込む
PlayerPrefs.GetString(key) デバイスから文字列を読み込む
PlayerPrefs.HasKey(key) キーが存在するか確認する
PlayerPrefs.Save() バッファをディスクに即時書き出す

④ [Serializable] — クラスを JSON 化・Inspector 表示できるようにする

OwnedItemsDataOwnedItem の両方に [Serializable] が付いています。

[Serializable]
public class OwnedItemsData { ... }
[Serializable]
public class OwnedItem { ... }

JsonUtility で変換できるのは [Serializable] を付けたクラス・構造体のみです。また [Serializable] + [SerializeField] のフィールドは Inspector にも表示されるため、デバッグ中にデータを直接確認・編集できます。

ItemTypeSpriteMap(アイテム種別 → スプライト のマッピングクラス)にも同じ理由で [Serializable] が付いています。

[Serializable]
public class ItemTypeSpriteMap
{
    public Item.ItemType itemType;
    public Sprite        sprite;
}

こちらは [SerializeField] private ItemTypeSpriteMap[] itemSprites として保持されており、Inspector でアイテムの種類ごとにスプライトを割り当てられます。


⑤ GetComponentsInChildren — 動的生成した子を一括取得する

ItemsDialog.Start() では Instantiate でボタンを量産した後、GetComponentsInChildren<T>() で一括取得しています。

// buttonNumber - 1 個を追加(元の 1 個と合わせて buttonNumber 個になる)
for (var i = 0; i < buttonNumber - 1; i++)
{
    Instantiate(itemButton, transform);
}
// 子要素の ItemButton を全て取得してキャッシュ
_itemButtons = GetComponentsInChildren<ItemButton>();

GetComponentsInChildren<T>() は自身と全子孫 GameObject のコンポーネントを配列で返します。Instantiate で動的に生成した後でも正しく取得できます。GetComponentInChildren<T>(単数形)は最初の 1 つだけを返す点に注意が必要です。

メソッド 戻り値 対象
GetComponent<T>() T(最初の 1 つ) 自身の GameObject のみ
GetComponentInChildren<T>() T(最初の 1 つ) 自身 + 全子孫
GetComponentsInChildren<T>() T[](全て) 自身 + 全子孫

全体の処理の流れを整理する

[フィールドでアイテムを拾う]
  Item.OnTriggerEnter()
    └─ OwnedItemsData.Instance.Add(type)   // データ層に追加
    └─ OwnedItemsData.Instance.Save()      // JSON → PlayerPrefs に保存
    └─ Destroy(gameObject)                 // フィールドから消える
[メニューボタンを押してアイテムダイアログを開く]
  Menu.cs → ItemsDialog.Toggle()
    └─ gameObject.SetActive(true)
    └─ ループ(i = 0 〜 buttonNumber - 1
         └─ _itemButtons[i].OwnedItem = OwnedItemsData.Instance.OwnedItems[i]
                                          or null(データがない枠)
              └─ OwnedItem.set が発火
                   └─ image.sprite = 対応スプライト
                   └─ number.text  = 個数
                   └─ image / number / button の表示切り替え
[アプリ再起動後]
  OwnedItemsData.Instance(プロパティ)
    └─ PlayerPrefs.HasKey() = true
    └─ JsonUtility.FromJson<OwnedItemsData>(json)
         └─ 保存済みデータが復元される

データとビューを分離することで得られるもの

今回のパターンをまとめると、変更の影響範囲が明確に限定されます。

変更内容 修正が必要なクラス
アイテムの種類を増やす Item.ItemType(enum に追加)・OwnedItemsData のみ
HP バーのデザインを変える ItemButton(UI 層)のみ
保存形式を変える(JSON → SQLite 等) OwnedItemsData(データ層)のみ
ダイアログのレイアウト変更 ItemsDialog(制御層)のみ

データ・ビュー・制御を分けた設計は、それぞれが**「自分の関心事以外を知らない」**状態を保つことで、変更のコストと影響範囲を最小化します。


まとめ

キーワード 意味と役割
プロパティ set 駆動更新 セッターに UI 更新を組み込み「データを渡すだけで表示が整う」仕組みを作る
データ・ビュー・制御の分離 関心事を 3 層に分けることで変更の影響範囲を限定する
[Serializable] クラスを JsonUtility で変換可能にし、Inspector にも表示できるようにする
JsonUtility Unity 組み込みの JSON 変換ユーティリティ。ToJson / FromJson<T> で相互変換
PlayerPrefs プラットフォームを問わずキー・バリューを永続化する Unity 組み込みの仕組み
GetComponentsInChildren<T>() 動的生成した子を含む全子孫から T[] を一括取得する

C# のプロパティは「フィールドへのアクセスを制御する入口」であり、set に副作用(UI 更新)を持たせることで「データが変わったら表示が自動で整う」という直感的な設計が実現できます。IkinokoBattle の ItemButton はその好例であり、MVC / MVP といった本格的なアーキテクチャパターンへの入り口でもあります。


シリーズ全体を振り返って

全 6 回で IkinokoBattle のコードから 6 つの OOP パターンを読み解きました。

パターン 核心
第1回 Singleton 唯一のインスタンスを保証する 3 つのバリエーション
第2回 Template Method 骨格を親クラスが持ち、差分だけ子クラスが実装する
第3回 Delegation + UnityEvent 「仕事を別のオブジェクトに委ねる」コンポーネント設計
第4回 Single Responsibility クラスを変更する理由はひとつ。調整役に徹する PlayerController
第5回 State 状態遷移を一か所に集約し、if の散乱を防ぐ
第6回 Data / View 分離 プロパティ経由でデータとビューを疎結合に保つ

これらのパターンはどれも「変更に強いコードを書く」という共通の目的を持っています。IkinokoBattle のソースコードを手元に置き、実際に動かしながら読み返してみると、理解がいっそう深まるはずです。


シリーズ目次: IkinokoBattle で学ぶ Unity OOP 設計パターン


最終更新:2026年4月