IkinokoBattle で学ぶ Unity OOP 設計パターン:第6回
プロパティとデータ・ビュー分離
シリーズ目次: IkinokoBattle で学ぶ Unity OOP 設計パターン
前の記事: 第5回 状態パターン
はじめに
「アイテムのデータを更新したら、画面表示も自動的に切り替わってほしい」——UIを持つゲームでよく出てくる要求です。
素朴な実装では「データを変更するコードのすぐ隣に UI 更新の命令を書く」ことになりがちです。しかしデータ処理と UI 処理が混在すると、「バランス調整したいだけなのに UI コードを理解しなければならない」「UI を作り直すたびにデータクラスを触る」という問題が生じます。
IkinokoBattle ではこの問題を C# の プロパティ(set アクセサ) と クラス分割によって解決しています。今回はその実装を ItemButton・ItemsDialog・OwnedItemsData の 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 を自由に実装できます。ItemButton の OwnedItem セッターはその典型的な活用例です。
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 だけ修正すれば済み、OwnedItemsData や ItemsDialog には触りません。
③ 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 表示できるようにする
OwnedItemsData と OwnedItem の両方に [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 のソースコードを手元に置き、実際に動かしながら読み返してみると、理解がいっそう深まるはずです。
最終更新:2026年4月