【Unity入門】アイテム所持の仕組みを作ろう ― 「データ」と「見た目」を分ける考え方

広告

ゲームでアイテムを拾うと、持ち物リストに並んで、個数も増えていきますよね。この記事では、その裏側の仕組みを、Unityで作ったミニゲームを例にやさしく解説します。

冒険者のキャラクターの隣に、木材や石、ポーションなどのアイテムが個数つきのマス目で並んだ、木製の持ち物画面のイメージ
持ち物画面のイメージ(実際のゲームとは異なります)。アイテムの絵と個数がマスに並ぶ

まず結論:3つの役割に分けて考える

アイテムの仕組みは、次の3つに分けると整理しやすくなります。

役割何をする?このゲームでの担当
① データ「何を何個持っているか」を覚えるOwnedItemsData
② ワールド地面に落ちているアイテム。拾われたらデータに変わるItem
③ 画面持ち物を表示するItemsDialog / ItemButton
ワールドのアイテム、データのメモ帳、画面の3つの役割と、書き込みと読み取りの向きを示した図
アイテム管理の3つの役割(ワールドが書き込み、画面が読む)

ポイントは、「持っている」という事実(データ)と、それを「見せる」こと(画面)を別々にすることです。

では、実際のゲームのどこに、どのスクリプトが付いているのでしょうか。ゲームオブジェクトとコンポーネントの対応を、図にしてみます。

Slime_GreenにMobItemDropper、Item_WoodにItem、プレイヤーにPlayerStatusなど、メニューにItemsDialogとItemButtonが付いていて、OwnedItemsDataはオブジェクトに付かない普通のクラスであることを示した図
どのゲームオブジェクトに、どのスクリプトが付いているか

① データ:持ち物は「メモ帳」のようなもの

OwnedItemsData は、持ち物のメモ帳だと思ってください。

木  … 3個
石  … 5個

メモ帳には「木が3個」と書くだけで、色や形、置いてある場所は書きません。数字と名前だけの、とてもシンプルな記録です。

アイテムの種類は enum で決める

public enum ItemType
{
    Wood,
    Stone,
    ThrowAxe
}

“wood" のような文字列で管理すると、スペルミスに気づきにくくなります。enum なら、打ち間違えるとすぐエラーで教えてくれるので安心です。

同じ種類は「まとめて個数で」数える

木を3個拾ったとき、「木」というデータを3つ作るのではありません。「木 × 3」と、1行にまとめて書きます。

木を3個拾うと、メモ帳には木×3の1行だけが書かれることを示した図
同じ種類のアイテムは、メモ帳の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で入れておく

ゲームを終了しても持ち物が消えないように、保存もしています。

OwnedItemsDataをJSON文字列に変換してPlayerPrefsに保存し、起動時に逆順で復元する流れを示した図
メモ帳をJSON文字列にして、PlayerPrefsに保存・復元する
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種類以上持つと、超えた分は画面に表示されません。

メモ帳の木×3と石×5が、15個のスロットの先頭2マスに割り当てられ、残りは空になる図
メモ帳を上から順に、持ち物画面のマスへ割り当てる
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種類を区別して見ると、仕組みが追いやすくなります。

Menu、ItemsDialog、ItemButtonとMobItemDropper、ItemがInspectorの参照でつながり、ItemとItemsDialogがコードからOwnedItemsDataを呼ぶことを示した図
実線はInspectorで設定する参照、点線はコードから呼び出す流れ

なぜ分けるといいの?

もし「アイテムオブジェクトの数を数えて持ち物表示にする」作りだったら、次のように困ります。

  • 表示用のオブジェクトに数まで持たせると、画面を作り直すたびにデータの引き継ぎが必要になる
  • 保存するときに、何を保存すればいいか分からない
  • 将来「木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個拾っても、次に画面を開いたときに正しく表示されるのはなぜでしょう? 「データ」と「画面」の役割を思い出して、考えてみてください。

訪問数 8 回, 今日の訪問数 13回

広告

C#

Posted by hidepon