【Unity入門】アイテム所持の仕組みを作ろう ― 「データ」と「見た目」を分ける考え方
ゲームでアイテムを拾うと、持ち物リストに並んで、個数も増えていきますよね。この記事では、その裏側の仕組みを、Unityで作ったミニゲームを例にやさしく解説します。
まず結論:3つの役割に分けて考える
アイテムの仕組みは、次の3つに分けると整理しやすくなります。
| 役割 | 何をする? | このゲームでの担当 |
|---|---|---|
| ① データ | 「何を何個持っているか」を覚える | OwnedItemsData |
| ② ワールド | 地面に落ちているアイテム。拾われたらデータに変わる | Item |
| ③ 画面 | 持ち物を表示する | ItemsDialog / ItemButton |
ポイントは、「持っている」という事実(データ)と、それを「見せる」こと(画面)を別々にすることです。
では、実際のゲームのどこに、どのスクリプトが付いているのでしょうか。ゲームオブジェクトとコンポーネントの対応を、図にしてみます。
① データ:持ち物は「メモ帳」のようなもの
OwnedItemsData は、持ち物のメモ帳だと思ってください。
木 … 3個
石 … 5個
メモ帳には「木が3個」と書くだけで、色や形、置いてある場所は書きません。数字と名前だけの、とてもシンプルな記録です。
アイテムの種類は enum で決める
public enum ItemType
{
Wood,
Stone,
ThrowAxe
}
“wood" のような文字列で管理すると、スペルミスに気づきにくくなります。enum なら、打ち間違えるとすぐエラーで教えてくれるので安心です。
同じ種類は「まとめて個数で」数える
木を3個拾ったとき、「木」というデータを3つ作るのではありません。「木 × 3」と、1行にまとめて書きます。
public void Add(Item.ItemType type, int number = 1)
{
var item = GetItem(type); // もう持っている?
if (item == null) // 初めて拾った種類なら
{
item = new OwnedItem(type); // 新しい行を作る
ownedItems.Add(item);
}
item.Add(number); // 個数を足す
}
流れは、「もう持ってる? 持ってなければ行を作る → 個数を足す」。実際のメモ帳の使い方と同じです。
どこからでも使えるようにする(シングルトン)
持ち物メモ帳は、ゲーム内に1冊だけあれば十分です。敵が倒れたとき、アイテムを拾ったとき、メニューを開いたときと、いろいろな場所から同じメモ帳を見に行きます。そこで OwnedItemsData.Instance と書けば、いつでも同じ1冊にアクセスできるようにしています。具体的には、最初に呼ばれたときだけメモ帳を作り(遅延初期化)、コンストラクタを private にして外から new できないようにすることで、1冊だけに保っています。この仕組みをシングルトンと呼びます。
② ワールド:拾ったらデータに変わる
地面に落ちているアイテム(Item)は、いわば「見た目つきの入れ物」です。プレイヤーが触れると、次の順に処理されます。
private void OnTriggerEnter(Collider other)
{
if (!other.CompareTag("Player")) return; // プレイヤー以外は無視
OwnedItemsData.Instance.Add(type); // ① メモ帳に書く
OwnedItemsData.Instance.Save(); // ② 保存する
Destroy(gameObject); // ③ 地面のアイテムは消す
}
拾う = 地面のアイテムを消して、メモ帳に書き足すことです。
※ このコードが動くには、アイテム側の Collider の Is Trigger をオンにし、プレイヤーに Player タグを付けておく必要があります。このゲームのプレイヤーは CharacterController を使っているので、Rigidbody は不要です。
ちょっとした工夫
敵がアイテムを落としたとき、ぽんと弾むアニメーションが再生されます。その間は当たり判定(Collider)をオフにして、終わってからオンにしています。こうしておくと、出現した瞬間に勝手に拾われてしまうことがありません。
保存:PlayerPrefs にJSONで入れておく
ゲームを終了しても持ち物が消えないように、保存もしています。
public void Save()
{
var jsonString = JsonUtility.ToJson(this); // データを文字列(JSON)に変換
PlayerPrefs.SetString(PlayerPrefsKey, jsonString); // 端末に保存
PlayerPrefs.Save();
}
JsonUtility がメモ帳を1つの文字列に変換し、PlayerPrefs という端末の小さな保存場所にしまいます。次に起動したときは、その文字列から元のメモ帳を復元します。
※ PlayerPrefs は本来、設定値のような小さなデータ向けで、暗号化もされません。この記事では、学習用の小規模な保存として使っています。また、このゲームは拾うたびに Save() を呼んでいますが、頻繁に呼ぶと処理が一瞬止まることがあります。
※ JsonUtility で保存したいクラスには [Serializable] を付けます。public の変数はそのまま保存されますが、private の変数を保存したいときは [SerializeField] も付けます。static の変数やプロパティは保存されません。また、Dictionary は直接保存できないので、このゲームのように List を使います。
③ 画面:メモ帳を「読んで」表示するだけ
持ち物画面は、スロット(マス目)を15個用意しておき、開くたびにメモ帳を上から順に読んで、各マスに割り当てる作りです。
※ 今回は最大15種類までを表示する簡単な仕組みです。16種類以上持つと、超えた分は画面に表示されません。
var ownedItems = OwnedItemsData.Instance.OwnedItems;
for (int i = 0; i < buttonNumber; i++)
{
// 持ち物の数より後ろのマスは、空(null)にする
_itemButtons[i].OwnedItem = i < ownedItems.Length ? ownedItems[i] : null;
}
この ? : は三項演算子といいます。条件 ? 真のとき : 偽のとき の形で読みます。ここでは「i が持ち物の数より小さければ ownedItems[i]、そうでなければ null」という意味です。
同じ動きを if文 で書くと、次のようになります。
var ownedItems = OwnedItemsData.Instance.OwnedItems;
for (int i = 0; i < buttonNumber; i++)
{
if (i < ownedItems.Length)
{
// i番目の持ち物があれば入れる
_itemButtons[i].OwnedItem = ownedItems[i];
}
else
{
// なければ空にする
_itemButtons[i].OwnedItem = null;
}
}
どちらも同じ動きです。短く書けるのが三項演算子、読みやすいのがif文です。慣れるまでは、if文で考えてから三項演算子に書き換えてみましょう。
マスに入るデータが決まったら、ItemButton が見た目を切り替えます。
- データがなければ、画像・個数・ボタンを隠す
- あれば、種類に合う画像を探して、個数と一緒に表示する
画面側は、メモ帳を読んで絵にするだけです。持ち物の数を自分で覚えていません。だから、データと表示がずれにくいのです。
最後に、スクリプト同士のつながりをまとめます。実線はInspectorでドラッグして設定する参照、点線はコードの中で呼び出す流れです。この2種類を区別して見ると、仕組みが追いやすくなります。
なぜ分けるといいの?
もし「アイテムオブジェクトの数を数えて持ち物表示にする」作りだったら、次のように困ります。
- 表示用のオブジェクトに数まで持たせると、画面を作り直すたびにデータの引き継ぎが必要になる
- 保存するときに、何を保存すればいいか分からない
- 将来「木2個と石1個でクラフト」を作りたくなっても、数の管理が散らばっている
データを1か所にまとめておけば、保存・表示・クラフトのどれも、同じメモ帳を見るだけで済みます。
全コード(コピーして使えます)
ここまでに出てきたクラスの全体を、折りたたみにまとめました。クリックで開きます。読み飛ばしても記事の理解には困りません。
※ Item のアニメーションには、無料アセットの DOTween を使っています。先にプロジェクトへ導入してください。
このコードは、Microsoft の C# コーディング規則に沿って整理しています。動作はこの記事で説明した内容と同じです。自分で写したコードと見比べて、次の違いを探してみてください。
- 宣言を先に書く:定数、フィールド、プロパティ、メソッドの順に並べる(
ItemButtonでは、フィールドをプロパティより前に移動) - 比較は「変数 == 値」の順:
null == _ownedItemのような逆向きの書き方(ヨーダ記法)をやめ、_ownedItem == nullと書く - 長い処理に名前をつける:
LoadOrCreate、CreateButtons、RefreshButtons、Refreshに分ける - 数字に名前をつける:
0.5fをAnimationSeconds、1fをDropRangeにする - 引数名を変える:フィールドと同じ名前の
numberをamountにして、this.を不要にする - 例外の種類:
Exceptionを、意味の合うInvalidOperationExceptionに変える
① OwnedItemsData.cs ― 持ち物メモ帳(データ)
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
[Serializable]
public class OwnedItemsData
{
// PlayerPrefsの保存先キー
private const string PlayerPrefsKey = "OWNED_ITEMS_DATA";
private static OwnedItemsData _instance;
// どのアイテムを何個所持しているかのリスト
[SerializeField] private List<OwnedItem> ownedItems = new List<OwnedItem>();
// 外部からnewできないようにする
private OwnedItemsData()
{
}
// インスタンスを返す(初回だけ作る)
public static OwnedItemsData Instance
{
get
{
if (_instance == null)
{
_instance = LoadOrCreate();
}
return _instance;
}
}
// 所持アイテム一覧を取得する
public OwnedItem[] OwnedItems
{
get
{
return ownedItems.ToArray();
}
}
// JSON化してPlayerPrefsに保存する
public void Save()
{
var jsonString = JsonUtility.ToJson(this);
PlayerPrefs.SetString(PlayerPrefsKey, jsonString);
PlayerPrefs.Save();
}
// アイテムを追加する
public void Add(Item.ItemType type, int amount = 1)
{
var item = GetItem(type);
if (item == null)
{
item = new OwnedItem(type);
ownedItems.Add(item);
}
item.Add(amount);
}
// アイテムを消費する
public void Use(Item.ItemType type, int amount = 1)
{
var item = GetItem(type);
if (item == null || item.Number < amount)
{
throw new InvalidOperationException("アイテムが足りません");
}
item.Use(amount);
}
// 対象の種類のアイテムデータを取得する(持っていなければnull)
public OwnedItem GetItem(Item.ItemType type)
{
return ownedItems.FirstOrDefault(x => x.Type == type);
}
// 保存データがあれば復元し、なければ新しく作る
private static OwnedItemsData LoadOrCreate()
{
if (!PlayerPrefs.HasKey(PlayerPrefsKey))
{
return new OwnedItemsData();
}
var jsonString = PlayerPrefs.GetString(PlayerPrefsKey);
return JsonUtility.FromJson<OwnedItemsData>(jsonString);
}
// アイテム1種類ぶんの「種類と個数」
[Serializable]
public class OwnedItem
{
[SerializeField] private Item.ItemType type;
[SerializeField] private int number;
public OwnedItem(Item.ItemType type)
{
this.type = type;
}
public Item.ItemType Type
{
get
{
return type;
}
}
public int Number
{
get
{
return number;
}
}
public void Add(int amount)
{
number += amount;
}
public void Use(int amount)
{
number -= amount;
}
}
}
Add:もう持っていれば個数を足し、なければ行を作るInstance:初回だけLoadOrCreateで作る(遅延初期化)Save:メモ帳をJSONにしてPlayerPrefsへ保存する
② Item.cs ― 地面のアイテム(ワールド)
using DG.Tweening;
using UnityEngine;
[RequireComponent(typeof(Collider))]
public class Item : MonoBehaviour
{
// アイテムの種類定義
public enum ItemType
{
Wood,
Stone,
ThrowAxe
}
// 出現アニメーションの長さ(秒)
private const float AnimationSeconds = 0.5f;
// 落ちる位置のばらつき(±この距離)
private const float DropRange = 1f;
[SerializeField] private ItemType type;
// 出現時の初期化(ぽんと弾むアニメーション)
public void Initialize()
{
// アニメーションが終わるまでcolliderを無効化する
var itemCollider = GetComponent<Collider>();
itemCollider.enabled = false;
// 落とす位置を、周囲にランダムに決める
var dropPosition = transform.localPosition
+ new Vector3(Random.Range(-DropRange, DropRange), 0, Random.Range(-DropRange, DropRange));
// 大きさをゼロにしてから、元の大きさに戻す
var defaultScale = transform.localScale;
transform.localScale = Vector3.zero;
transform.DOLocalMove(dropPosition, AnimationSeconds);
transform.DOScale(defaultScale, AnimationSeconds)
.SetEase(Ease.OutBounce)
.OnComplete(() => itemCollider.enabled = true);
}
// プレイヤーが触れたら拾う
private void OnTriggerEnter(Collider other)
{
if (!other.CompareTag("Player"))
{
return;
}
// 所持品としてメモ帳に追加して、保存する
OwnedItemsData.Instance.Add(type);
OwnedItemsData.Instance.Save();
// 地面のアイテムは消す
Destroy(gameObject);
}
}
Initialize:出現アニメーション中は当たり判定を切るOnTriggerEnter:触れたらメモ帳に足して保存し、自分は消える- Collider の Is Trigger をオンにし、プレイヤーには Player タグを付けておく
③ ItemsDialog.cs ― 持ち物画面(画面)
using UnityEngine;
public class ItemsDialog : MonoBehaviour
{
[SerializeField] private int buttonNumber = 15;
[SerializeField] private ItemButton itemButton;
private ItemButton[] _itemButtons;
// アイテム欄の表示/非表示を切り替える
public void Toggle()
{
gameObject.SetActive(!gameObject.activeSelf);
if (gameObject.activeSelf)
{
RefreshButtons();
}
}
private void Start()
{
// 初期状態は非表示
gameObject.SetActive(false);
CreateButtons();
}
// アイテム欄(マス)を必要な数だけそろえる
private void CreateButtons()
{
// 元のボタンが1個あるので、残りを複製する
for (int i = 0; i < buttonNumber - 1; i++)
{
Instantiate(itemButton, transform);
}
// 子要素のアイテムボタンを一括取得して保持する
_itemButtons = GetComponentsInChildren<ItemButton>();
}
// 全マスを、メモ帳の内容で更新する
private void RefreshButtons()
{
var ownedItems = OwnedItemsData.Instance.OwnedItems;
for (int i = 0; i < buttonNumber; i++)
{
// 持ち物の数より後ろのマスは、空(null)にする
_itemButtons[i].OwnedItem = i < ownedItems.Length ? ownedItems[i] : null;
}
}
}
Start:CreateButtonsでマスを15個そろえるToggle:開くたびにRefreshButtonsで、メモ帳を上から順に各マスへ割り当てる- 最大15種類まで表示する簡易仕様。超えた分は表示されない
④ ItemButton.cs ― 1マス分の表示(画面)
using System;
using System.Linq;
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(Button))]
public class ItemButton : MonoBehaviour
{
// 各アイテム用の画像を指定するフィールド
[SerializeField] private ItemTypeSpriteMap[] itemSprites;
[SerializeField] private Image image;
[SerializeField] private Text number;
private Button _button;
private OwnedItemsData.OwnedItem _ownedItem;
// このマスに割り当てる持ち物(空のマスはnull)
public OwnedItemsData.OwnedItem OwnedItem
{
get
{
return _ownedItem;
}
set
{
_ownedItem = value;
Refresh();
}
}
private void Awake()
{
_button = GetComponent<Button>();
_button.onClick.AddListener(OnClick);
}
private void OnClick()
{
// ボタンを押したときの処理(今回は未実装)
}
// データの有無で、画像・個数・ボタンの表示を切り替える
private void Refresh()
{
var isEmpty = _ownedItem == null;
image.gameObject.SetActive(!isEmpty);
number.gameObject.SetActive(!isEmpty);
_button.interactable = !isEmpty;
if (isEmpty)
{
return;
}
image.sprite = itemSprites.First(x => x.itemType == _ownedItem.Type).sprite;
number.text = _ownedItem.Number.ToString();
}
[Serializable]
public class ItemTypeSpriteMap
{
public Item.ItemType itemType;
public Sprite sprite;
}
}
OwnedItemに代入されるとRefreshが呼ばれ、表示が切り替わる- データが
nullなら、画像・個数・ボタンを隠す OnClickは空(アイテムを使う処理は、次回の実装)
コードが長くて難しいと感じた方は、動かすために必要な最小限だけに絞った版も用意しました。まずはこちらで動かしてから、この記事に戻ってくるのもおすすめです。→ 【Unity入門】アイテムを拾って持ち物に表示する最小コード
まとめ
- 持ち物は「種類 × 個数」のメモ帳として管理する
- 地面のアイテムは、拾ったらメモ帳に書き足して消す
- 画面は、メモ帳を読んで表示するだけにする
- 保存は、メモ帳を JSON に変換して PlayerPrefs に入れる
「データ・ワールド・画面を分ける」という考え方は、アイテム以外にも使えます。体力やスコア、セーブデータなど、いろいろな場面で役立つので、ぜひ覚えておいてください。
ちょっと考えてみよう
持ち物画面を閉じている間に木を3個拾っても、次に画面を開いたときに正しく表示されるのはなぜでしょう? 「データ」と「画面」の役割を思い出して、考えてみてください。



















ディスカッション
コメント一覧
まだ、コメントがありません