Unityの持ち物システムを4段階で作る ― クラス → List → 保存 → シングルトン

広告

Unityでゲームを作っていて、「持ち物を管理するクラス」が1つのファイルに全部入っていて読みにくい、と感じたことはありませんか。シングルトン、PlayerPrefs での保存、内部クラス、LINQ などが一度に出てくると、どこが何の話なのかが分からなくなります。

この記事では、持ち物システムを新しい概念が1つずつ増える4段階に分けて作ります。前の記事「Unityで3Dバトルアクションを1本作って学べること」の5節で触れた「所持アイテムの保存」を、順番に作り直す内容です。

段階 新しく覚えること
1 クラスに、データと操作をまとめる
2 List、FirstOrDefault、足りないときの例外
3 [Serializable]、JsonUtility、PlayerPrefs
4 static の Instance、private なコンストラクタ

段階1: 種類と数だけを持つ小さなクラス

まずは、アイテム1種類ぶんの「種類」と「数」を持つクラスだけを作ります。static も保存も出てきません。

public enum ItemType
{
    Wood,
    Stone,
}

public class OwnedItem
{
    public ItemType Type
    {
        get { return type; }
    }
    public int Number
    {
        get { return number; }
    }
    private ItemType type;
    private int number;

    public OwnedItem(ItemType type)
    {
        this.type = type;
    }

    public void Add(int number = 1)
    {
        this.number += number;
    }

    public void Use(int number = 1)
    {
        this.number -= number;
    }
}

空の GameObject に次のスクリプトを付けて再生すると、Console に結果が出ます。

using UnityEngine;

public class InventoryDemo : MonoBehaviour
{
    private void Start()
    {
        var wood = new OwnedItem(ItemType.Wood);

        wood.Add();
        Debug.Log($"{wood.Type}: {wood.Number}"); // Wood: 1

        wood.Add(3); // 1個以外も試す
        Debug.Log($"{wood.Type}: {wood.Number}"); // Wood: 4

        wood.Use(2);
        Debug.Log($"{wood.Type}: {wood.Number}"); // Wood: 2
    }
}

つまずきポイント: 引数を受け取っているのに使っていない

Add(int number) の中で this.number += 1 と書いてしまうと、Add() だけ試したときは正しく動きます。ところが Add(3) と呼んでも、数は1しか増えません。引数を取るメソッドは、1個の場合以外もテストすると気付けます。上のデモの wood.Add(3) が、その確認です。

段階2: 持ち物のリスト

次に、複数種類のアイテムを持てる Inventory を作ります。OwnedItem は段階1のままです。普通のクラスなので、new で作ります。

using System;
using System.Collections.Generic;
using System.Linq;

public class Inventory
{
    private List<OwnedItem> ownedItems = new List<OwnedItem>();

    public OwnedItem[] OwnedItems
    {
        get { return ownedItems.ToArray(); }
    }

    public void Add(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(ItemType type, int number = 1)
    {
        var item = GetItem(type);
        if (null == item || item.Number < number)
        {
            throw new Exception("アイテムが足りません");
        }
        item.Use(number);
    }

    public OwnedItem GetItem(ItemType type)
    {
        // 条件に合う最初の1つ。なければ null
        return ownedItems.FirstOrDefault(x => x.Type == type);
    }
}

GetItem の FirstOrDefault は、リストから条件に合う最初の1つを探す LINQ のメソッドです。見つからないときは null が返るので、Add では「なければ作る」、Use では「なければ例外」と使い分けています。

var inventory = new Inventory();

inventory.Add(ItemType.Wood, 3);
inventory.Add(ItemType.Stone);
inventory.Add(ItemType.Wood, 2); // 同じ種類は数が増える
Debug.Log($"Wood: {inventory.GetItem(ItemType.Wood).Number}"); // 5

inventory.Use(ItemType.Wood, 4);
Debug.Log($"Wood: {inventory.GetItem(ItemType.Wood).Number}"); // 1

// 別のインベントリは別の持ち物になる
var other = new Inventory();
Debug.Log(null == other.GetItem(ItemType.Wood)); // True

最後の3行がポイントです。new で作ったインベントリは、それぞれ別の持ち物になります。この性質を、段階4で static と見比べます。

段階3: 保存と読み出し

ここで初めて、保存を足します。JsonUtility で文字列にして、PlayerPrefs に入れます。まず、保存したいクラスに [Serializable] を付け、保存したいフィールドに [SerializeField] を付けます。

using System;
using UnityEngine;

[Serializable]
public class OwnedItem
{
    // Type と Number は段階1と同じ
    [SerializeField] private ItemType type;
    [SerializeField] private int number;

    // 以下、コンストラクタ、Add、Use も段階1と同じ
}

Inventory には、Save と Load を足します。

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

[Serializable]
public class Inventory
{
    private const string PlayerPrefsKey = "INVENTORY_STAGE3";

    [SerializeField] private List<OwnedItem> ownedItems = new List<OwnedItem>();

    public void Save()
    {
        var jsonString = JsonUtility.ToJson(this);
        PlayerPrefs.SetString(PlayerPrefsKey, jsonString);
        PlayerPrefs.Save();
    }

    public static Inventory Load()
    {
        if (!PlayerPrefs.HasKey(PlayerPrefsKey))
        {
            return new Inventory();
        }
        return JsonUtility.FromJson<Inventory>(PlayerPrefs.GetString(PlayerPrefsKey));
    }

    // OwnedItems、Add、Use、GetItem は段階2と同じ
}

Load() は static ですが、まだシングルトンではありません。読んだものを、自分で持ち回ります。

private void Start()
{
    var inventory = Inventory.Load();

    inventory.Add(ItemType.Wood, 2);
    Debug.Log($"Wood: {inventory.GetItem(ItemType.Wood).Number}");

    inventory.Save();
}

再生するたびに、木が2個ずつ増えます。前回の保存を読み込むためです。最初からやり直したいときは、PlayerPrefs.DeleteAll(); を1回だけ動かします。

つまずきポイント: [SerializeField] を付け忘れて、保存されない

JsonUtility は、public なフィールドか、[SerializeField] が付いたフィールドだけを文字列にします。private のまま付け忘れると、エラーは出ずに、中身が空のまま保存されます。保存した文字列を、Debug.Log で一度見てみると原因が分かります。

段階4: シングルトンにする

最後に、どこからでも同じ持ち物に届くようにします。変えるのは、Instance と、private なコンストラクタだけです。段階3の Load() が、Instance の中で呼ばれます。

[Serializable]
public class Inventory
{
    private const string PlayerPrefsKey = "INVENTORY_STAGE4";

    public static Inventory Instance
    {
        get
        {
            if (null == _instance)
            {
                _instance = Load();
            }
            return _instance;
        }
    }
    private static Inventory _instance;

    [SerializeField] private List<OwnedItem> ownedItems = new List<OwnedItem>();

    // 外から new できないようにする(Instance だけが入口になる)
    private Inventory()
    {
    }

    private static Inventory Load()
    {
        if (!PlayerPrefs.HasKey(PlayerPrefsKey))
        {
            return new Inventory();
        }
        return JsonUtility.FromJson<Inventory>(PlayerPrefs.GetString(PlayerPrefsKey));
    }

    // Save、Add、Use、GetItem は段階3と同じ
}

拾う側と、表示する側が、別のスクリプトになっていても、同じ持ち物を見られます。

// 拾う側
public class InventoryDemo : MonoBehaviour
{
    private void Start()
    {
        Inventory.Instance.Add(ItemType.Wood, 2);
        Inventory.Instance.Save();
    }
}
using UnityEngine;
using UnityEngine.InputSystem;

// 表示する側(UIの代わり)。スペースキーで、いまの木の数を出す
public class InventoryReader : MonoBehaviour
{
    private void Update()
    {
        if (!Keyboard.current.spaceKey.wasPressedThisFrame) return;

        var wood = Inventory.Instance.GetItem(ItemType.Wood);
        Debug.Log(null == wood ? "Wood: 0" : $"Wood: {wood.Number}");
    }
}

Inventory を引数で受け取らなくても、Instance からどこでも届きます。これが、シングルトンの便利なところです。

シングルトンの良いところと、難しくなるところ

  • 良いところ: 参照を渡し回さなくてよいので、小さなゲームでは書きやすい。
  • 難しくなるところ:
    • どこからでも書き換えられるので、バグの場所を追いにくい。
    • 段階2のように、新しい持ち物を作ってテストできない。
    • 2人のプレイヤーが、別々の持ち物を持つ作りには、そのままでは向かない。

「小さなゲームなら十分」ですが、大きくなってきたら、段階2〜3の形(インスタンスを持ち回る)に戻す選択肢もある、と覚えておくと役立ちます。

まとめ

段階 追加したもの 確認できること
1 OwnedItem Add(3) で3個増える
2 Inventory、List、FirstOrDefault 別のインベントリは別の持ち物
3 [Serializable]、JsonUtility、PlayerPrefs 再生し直しても数が残る
4 static の Instance 別のスクリプトからも同じ持ち物に届く

1つのクラスに全部入ったコードに出会ったら、下から1つずつ、段階に分けて読むと理解しやすくなります。次のステップとして、持ち物にアイテムごとの上限を付ける、種類ごとの説明や画像を足す、保存をクラウドに載せる、といった拡張もおすすめです。

訪問数 1 回, 今日の訪問数 1回

広告

C#

Posted by hidepon