Unity におけるアイテム処理と所持データ管理システム

1. システム概要

  • 目的
    • シーン上に出現するアイテムの表示と出現アニメーションを実現
    • プレイヤーとの衝突時にアイテムを取得し、その所持数を管理・永続化する
  • 特徴
    • 同一の Item スクリプトを複数種のアイテムプレハブ(例:Item_Wood、Item_Stone、Item_ThrowAxe)に使い回し、インスペクターで type を設定
    • DOTween を利用した直列化されたアニメーションで、見た目に魅力的な出現演出を実現
    • OwnedItemsData によるシングルトンパターンでのデータ管理と、PlayerPrefs を用いた永続化
  • 使用ライブラリ
    • UnityEngine
    • DG.Tweening(DOTween)
    • PlayerPrefs および JsonUtility

2. アイテムクラス(Item.cs)の詳細

2.1 概要

  • シーンに配置されたアイテムオブジェクト(例:Wood用プレハブなど)が持つスクリプト
  • アイテム出現時のアニメーション(移動&スケール変更)とプレイヤーとの衝突処理を担当
  • [RequireComponent(typeof(Collider))] により、Collider コンポーネントの付与を保証

2.2 コード

using DG.Tweening;
using UnityEngine;

[RequireComponent(typeof(Collider))]
public class Item : MonoBehaviour
{
    /// <summary>
    /// アイテムの種類を列挙
    /// </summary>
    public enum ItemType
    {
        Wood,     // 木
        Stone,    // 石
        ThrowAxe  // 投げオノ(木と石で作る!)
    }

    // インスペクター上で種類を設定 (例: Wood / Stone / ThrowAxe)
    [SerializeField] private ItemType type;

    /// <summary>
    /// 初期化処理
    /// - 出現アニメーションを実行
    /// - アニメーション完了まではColliderを無効化
    /// </summary>
    public void Initialize()
    {
        Collider colliderCache = GetComponent<Collider>();
        colliderCache.enabled = false;

        // 出現位置をランダムにずらす
        Transform transformCache = transform;
        Vector3 dropPosition = transformCache.localPosition +
                               new Vector3(Random.Range(-1f, 1f), 0f, Random.Range(-1f, 1f));

        // DOTweenのSequenceで移動とスケールを連続アニメーション
        Sequence sequence = DOTween.Sequence();

        // 移動: 0.5秒かけてランダム座標へ
        sequence.Append(transformCache.DOLocalMove(dropPosition, 0.5f));

        // スケール: ゼロから元の大きさへ (Ease.OutBounceで弾むような演出)
        Vector3 defaultScale = transformCache.localScale;
        transformCache.localScale = Vector3.zero;
        sequence.Append(transformCache.DOScale(defaultScale, 0.5f).SetEase(Ease.OutBounce));

        // アニメーション完了後にColliderを有効化
        sequence.OnComplete(() =>
        {
            colliderCache.enabled = true;
        });
    }

    /// <summary>
    /// プレイヤーとの衝突判定
    /// </summary>
    /// <param name="other">衝突したCollider</param>
    private void OnTriggerEnter(Collider other)
    {
        // タグが"Player"でないなら処理しない
        if (!other.CompareTag("Player")) return;

        // アイテムをプレイヤーの所持データに加算して保存
        OwnedItemsData.Instance.Add(type);
        OwnedItemsData.Instance.Save();

        // 現在の所持アイテムをログ出力
        foreach (var item in OwnedItemsData.Instance.OwnedItems)
        {
            Debug.Log($"{item.Type}を{item.Number}個所持");
        }

        // このゲームオブジェクト(アイテム)を破棄
        Destroy(gameObject);
    }
}

以下は、ItemTypeクラス内に宣言する場合と、独立させる場合の比較内容です。


1. クラス内に宣言する場合

特徴

  • カプセル化
    • Item クラスに密接に関係する型として扱われるため、Item クラスの内部実装の一部として見なされます。
    • そのため、Item クラス以外では通常使われない型であることを明示できます。
  • 命名空間の整理
    • 他のクラスと名前が衝突する可能性が低くなり、Item クラスに関連する定義として集約されます。
  • コンテキストが明確になる
    • この型が Item クラス専用であることがコードを見る人に分かりやすく、設計意図が明確になります。

利点

  • 局所的な利用
    • Item クラス内でのみ使用される場合は、読みやすく管理しやすい。
  • 意図が伝わりやすい
    • 型が Item に紐づいているため、他で誤って使用されるリスクが低い。

欠点

  • 再利用性の低下
    • 複数のクラスや異なるシステムで同じアイテムタイプの概念を利用する場合、Item クラス内にあるとアクセスする際に Item.ItemType と名前空間が長くなる可能性がある。
    • 他のクラスでこの型を使用したい場合、依存関係が生じる可能性がある。

2. 独立させる(独自のファイルに宣言する/名前空間に定義する)場合

特徴

  • グローバルに利用可能
    • プロジェクト内のどのクラスからもアクセスできるため、同じアイテムタイプの列挙値を複数箇所で統一して利用できます。
  • 再利用性の向上
    • アイテムに関連する処理だけでなく、例えば UI 表示、クラフトシステム、ネットワーク通信など、さまざまなシステムでアイテムの種類に関する情報を共有する場合に便利です。
  • 管理の一元化
    • プロジェクトのルート的な定義として管理できるため、どこからでも変更を追跡しやすくなります。

利点

  • 再利用性
    • 複数のクラス・システムで統一的に利用可能。
    • 型が共通の定義として扱われるので、各機能間での情報連携が円滑になります。
  • メンテナンスの容易さ
    • 一箇所で定義されるため、将来的にアイテム種類が追加・変更された際の修正が容易。

欠点

  • 命名の衝突リスク
    • プロジェクトが大規模になると、名前空間をまたいだ同名の型が現れる可能性もあるため、名前付け規約などでしっかり整理する必要があります。
  • 利用範囲の過剰拡大
    • Item クラス以外で利用される場合に、あえて独立させた意味が薄れる可能性もあるため、設計の目的に応じた判断が求められます。

3. コード例

クラス内に宣言する場合

public class Item : MonoBehaviour
{
    /// <summary>
    /// アイテムの種類を列挙(Item専用)
    /// </summary>
    public enum ItemType
    {
        Wood,     // 木
        Stone,    // 石
        ThrowAxe  // 投げオノ(木と石で作る!)
    }

    [SerializeField] private ItemType type;
    // 残りのコードは Item クラス内で利用
}

使用例:

// Item クラス内や、Item クラスに強く関連する処理で利用
Item.ItemType currentType = Item.ItemType.Wood;

独立させる場合

まず、例えば ItemType.cs といったファイルに以下のように定義します。

/// <summary>
/// アイテムの種類を列挙(グローバルに利用)
/// </summary>
public enum ItemType
{
    Wood,     // 木
    Stone,    // 石
    ThrowAxe  // 投げオノ(木と石で作る!)
}

次に、Item.cs では以下のように利用します。

using UnityEngine;

[RequireComponent(typeof(Collider))]
public class Item : MonoBehaviour
{
    [SerializeField] private ItemType type;
    // 残りのコードは Item クラス内で利用
}

使用例:

// プロジェクト内のどこからでも単に ItemType として利用できる
ItemType someType = ItemType.Stone;

4. まとめ

  • クラス内に宣言する場合
    • アイテムに強く関連付けられた局所的な定義として、Item クラスの内部のみで利用する意図を明確にできます。
    • 対象が Item クラス専用で再利用の必要がない場合に適しています。
  • 独立させる場合
    • プロジェクト全体で共通して利用される定義とすることで、複数のシステム間での統一性を保ち、再利用性を向上させます。
    • 将来的に、アイテム管理以外のシステム(例えばクラフトシステムやUI表示)で同じ型を扱う可能性がある場合はこちらが有用です。

どちらの方法にも一長一短があり、プロジェクトの規模や設計思想、再利用の必要性に応じて最適な方法を選択することが望ましいです。

2.3 注意点

  • Initialize() の呼び出しタイミング
    • プレハブを Instantiate した直後や、シーン配置時に Start()/Awake() で呼び出すと、アニメーション演出が適用されます。
  • アイテムの種類設定
    • 各プレハブ(例:Item_Wood)にはインスペクターで type プロパティを設定し、適切なアイテム(例:Wood)として扱います。

3. 所持アイテムデータ管理クラス(OwnedItemsData.cs)の詳細

3.1 概要

  • シングルトンパターンを用いて、ゲーム全体の所持アイテムデータを一元管理
  • PlayerPrefs を利用したセーブ/ロードによって、ゲーム終了後もデータが保持される

3.2 コード

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

[Serializable]
public class OwnedItemsData
{
    /// <summary>
    /// PlayerPrefsに保存する際のキー
    /// </summary>
    private const string PlayerPrefsKey = "OWNED_ITEMS_DATA";

    // シングルトンインスタンス
    private static OwnedItemsData _instance;

    /// <summary>
    /// OwnedItemsDataのインスタンス
    /// </summary>
    public static OwnedItemsData Instance
    {
        get
        {
            if (_instance == null)
            {
                // 保存済みデータがあれば読み込む
                if (PlayerPrefs.HasKey(PlayerPrefsKey))
                {
                    _instance = JsonUtility.FromJson<OwnedItemsData>(PlayerPrefs.GetString(PlayerPrefsKey));
                }
                else
                {
                    _instance = new OwnedItemsData();
                }
            }
            return _instance;
        }
    }

    /// <summary>
    /// 現在所持しているアイテムの配列
    /// </summary>
    public OwnedItem[] OwnedItems => ownedItems.ToArray();

    /// <summary>
    /// アイテムの所持データリスト
    /// </summary>
    [SerializeField] private List<OwnedItem> ownedItems = new List<OwnedItem>();

    /// <summary>
    /// コンストラクタは外部から new できないように private
    /// </summary>
    private OwnedItemsData() { }

    /// <summary>
    /// 現在の所持データをPlayerPrefsにセーブ
    /// </summary>
    public void Save()
    {
        string jsonString = JsonUtility.ToJson(this);
        PlayerPrefs.SetString(PlayerPrefsKey, jsonString);
        PlayerPrefs.Save();
    }

    /// <summary>
    /// (任意)データをロードするメソッド 
    /// シーン初期化時に呼び出すなどの用途に
    /// </summary>
    public static void Load()
    {
        if (PlayerPrefs.HasKey(PlayerPrefsKey))
        {
            _instance = JsonUtility.FromJson<OwnedItemsData>(PlayerPrefs.GetString(PlayerPrefsKey));
        }
        else
        {
            _instance = new OwnedItemsData();
        }
    }

    /// <summary>
    /// アイテムを追加する
    /// </summary>
    /// <param name="type">アイテムの種類</param>
    /// <param name="number">追加個数(デフォルトは1)</param>
    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);
    }

    /// <summary>
    /// アイテムを消費する
    /// </summary>
    /// <param name="type">消費するアイテムの種類</param>
    /// <param name="number">使用する個数(デフォルト1)</param>
    /// <exception cref="Exception">所持数不足の場合に例外をスローする</exception>
    public void Use(Item.ItemType type, int number = 1)
    {
        var item = GetItem(type);
        if (item == null || item.Number < number)
        {
            throw new Exception("アイテムが足りません");
        }
        item.Use(number);
    }

    /// <summary>
    /// 指定のアイテム種類のデータを取得する
    /// </summary>
    /// <param name="type">アイテムの種類</param>
    /// <returns>該当の OwnedItem、存在しなければ null</returns>
    public OwnedItem GetItem(Item.ItemType type)
    {
        return ownedItems.FirstOrDefault(x => x.Type == type);
    }

    /// <summary>
    /// アイテム所持数管理用のモデル
    /// </summary>
    [Serializable]
    public class OwnedItem
    {
        [SerializeField] private Item.ItemType type;
        [SerializeField] private int number;

        /// <summary>
        /// アイテムの種類(読み取り専用)
        /// </summary>
        public Item.ItemType Type => type;

        /// <summary>
        /// 現在の所持数(読み取り専用)
        /// </summary>
        public int Number => number;

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

        /// <summary>
        /// 指定数だけ所持数を追加する
        /// </summary>
        public void Add(int number = 1)
        {
            this.number += number;
        }

        /// <summary>
        /// 指定数だけ所持数を消費する
        /// </summary>
        public void Use(int number = 1)
        {
            this.number -= number;
        }
    }
}

OwnedItem を OwnedItemsData の内部クラス(インナークラス)として定義する方法は、設計の意図や利用範囲によって推奨される場合とそうでない場合があります。以下はそのメリットとデメリットをまとめたものです。


1. インナークラスとして定義する場合

メリット

  • 密結合な関係を明示できる
    • OwnedItem は、基本的に OwnedItemsData によって管理されるデータモデルとして位置付けられるため、外部から直接利用されず、このクラス内でのみ使われるという意図が明確になります。
  • カプセル化
    • 他のクラスから不用意に変更されるリスクを減らし、OwnedItemsData の内部実装として隠蔽できます。
    • このことで、外部に公開する API としての OwnedItemsData のインターフェースがシンプルになるという利点もあります。
  • コードの整理
    • 所持アイテムに関するデータ構造が OwnedItemsData に内包されるため、関連コードがひとまとまりになり、メンテナンスがしやすくなります。

デメリット

  • 再利用性の制限
    • 万が一、OwnedItem と同様のデータ構造が他のシステム(例えば UI 表示やクラフトシステム)でも必要となった場合、内部クラスとして定義していると再利用のためにアクセス方法を工夫する必要が出てくる可能性があります。
    • また、他のコンポーネントやシステムで OwnedItem を直接操作する必要がある場合、内部クラスにしているとアクセス修飾子などの設定に留意する必要があります。

2. 独立したクラスとして定義する場合

メリット

  • 再利用性の向上
    • OwnedItem をプロジェクト全体で利用できる共通のデータモデルとして独立させると、他のシステム(UI、ネットワーキング、クラフトなど)でも容易に利用可能になります。
  • 明確な役割分担
    • 各クラスが独立していると、それぞれの責務がはっきりし、テストや変更の際に個別に扱いやすくなります。

デメリット

  • 関係性が不明瞭になる可能性
    • OwnedItem がどのコンテキストで利用されるのかが、クラス名やファイルの配置から直感的に理解しづらくなる場合があります(特に名前空間の管理やファイル構造を工夫しないと)。
  • 管理の散逸
    • 複数ファイルに分割されるため、関連性の高いコードが分散してしまうと、変更時にどこを修正すべきか追跡が難しくなる可能性もあります。

3. 推奨される記述場所の判断

  • OwnedItem が OwnedItemsData の管理対象としてのみ使われる場合
    • 多くのケースでは、OwnedItem は単にそのデータ構造の一部として扱うため、内部クラスにまとめることで関連性を明示するのが望ましいです。
    • この場合、設計上も「所持アイテムデータ」を管理する OwnedItemsData の内部構造として OwnedItem を位置付けるのが自然です。
  • 将来的に他システムで再利用する可能性がある場合
    • もし OwnedItem をUI表示や、他のゲームシステムで個別に扱う可能性があるなら、最初から独立したクラスとして定義し、共通の名前空間に配置するのも良いでしょう。
    • その際、例えば名前空間を GameDataItems といった共通のパッケージにまとめることで、再利用性と管理のしやすさを両立させられます。

4. まとめ

どちらの記述方法も一長一短ですが、以下のように考えるとよいでしょう:

  • 現在の用途が OwnedItemsData 内でのみ完結しており、他での再利用の必要が低い場合
    → 内部クラスとして OwnedItem を定義する方法がシンプルで整理された実装となります。
  • 今後、他システムとの連携や再利用が予想される場合
    → 独立したクラスとして定義し、プロジェクト全体で共通のデータモデルとして利用する方法が推奨されます。

したがって、現状の用途と将来的な拡張性の見込みを踏まえて判断するのが最適です。特に、プロジェクトが小規模であれば内部クラスでも問題ありませんが、大規模なシステムや複数のコンポーネント間でデータ共有を行う場合は、独立したクラスとして定義することを検討するとよいでしょう。

基本的には、C# の場合、クラス内のメンバーの宣言順序はコンパイラが全体を一括して解析するため、完成したコードであればどの順序で定義しても問題はありません。しかし、初学者が写経中に途中でコンパイルエラーに遭遇する場合、内側に参照される型(ここではインナークラス)がまだ入力されていないためにエラーになることがあります。

例えば、写経中に外部メソッドで OwnedItem を利用しようとした段階でまだその定義が入力されていないと、エラーが出る可能性があります。そういった場合、学習目的としては一時的にインナークラスの定義を先に記述する方法も効果的です。具体的には以下のようなアプローチが考えられます。

  • 一時的なスタブを作成する方法
    • 写経の際、まずは OwnedItem の空の定義を先に記述しておく(後で詳細を追加する)方法です。これにより、他の部分を入力中でも型の存在が認識され、途中でエラーが発生しにくくなります。
  • 全体を一気に入力する方法
    • 可能であれば、最初にコード全体を写経し、その後に自分なりに各部分の意味を考えながら整理・理解する方法もあります。最初から正しい順序(一般的には主要なパブリックメンバーを先に、その後に内部実装としてのインナークラスを記述する構造)に沿って入力することで、最終的なコードの構成に慣れることができます。

まとめると、最終的な推奨は「主要な API やパブリックメンバーを上部に配置し、補助的な内部実装としてのインナークラスは下部に配置する」という構成になります。ただし、写経など初学者の練習段階では、エラーを避けるために一時的にインナークラスのスタブを先に入力するのは、学習プロセス上有効な手段となります。最終的に正しい順序と全体構造に慣れることが、保守性や他者との協働作業を考える際にも重要です。

3.3 ポイント

  • シーン開始時や必要なタイミングで Load() を呼び出すと、前回の所持情報を復元可能
  • アイテム取得後に必ず Save() を呼ぶことで、最新データが PlayerPrefs に書き込まれ、ゲーム終了後も情報が維持されます

4. リファクタリングのポイント

今回のリファクタリングでは、主に以下の点に注目しています

  1. DOTween Sequence の活用によるアニメーションの連結
    • 複数のアニメーション(移動とスケール)の実行順序を明示し、分かりやすいフローを実現
    • 連結した処理の終了タイミングで Collider の有効化を統一的に行うことで、コードの信頼性向上
  2. シングルトンパターンの明確化
    • OwnedItemsData.Instance を通して、ゲーム全体で一つのデータ管理インスタンスを利用
    • 初期化やロード処理を明示的に分離し、後からのテストや拡張が容易に
  3. データの保存/取得処理の整理
    • PlayerPrefs を使用した JSON 化/デシリアライズ処理をクラス内にカプセル化
    • アイテム追加、消費時のエラーチェックをシンプルに記述し、例外発生条件を明示
  4. 再利用性と拡張性の向上
    • 同一の Item スクリプトをプレハブごとに使い回す設計により、インスペクター上での設定変更のみで動作を制御可能
    • 将来的な内部データ構造の改善(例:Dictionary への変更)も視野に入れる設計

5. 運用イメージ

  1. プレハブ作成
    • 例: 「Wood」用のモデルやスプライトを使い、Item_Wood プレハブを作成
    • 各プレハブに Item スクリプトをアタッチし、インスペクター上で type を適切に設定(例:Wood、Stone、ThrowAxe)
  2. シーン配置 / 生成
    • エディタ上で直接配置する場合、または Instantiate 後に GetComponent<Item>().Initialize() を呼び出す
    • これにより、出現時のアニメーションが実行され、プレイヤーが取得可能な状態となる
  3. プレイヤーとの衝突
    • プレイヤーがアイテムに衝突すると OnTriggerEnter が発動し、アイテムが所持データに追加・保存される
  4. データ活用
    • UI 表示やクラフトシステムなど、他のシステムから OwnedItemsData.Instance 経由で所持情報を参照可能

6. まとめ

  • 同一の Item スクリプトを複数のアイテムプレハブに使い回す設計により、各アイテムがインスペクター上の設定で柔軟に管理できる
  • DOTween Sequence によるアニメーション連結で、出現演出と Collider 制御を直感的かつ信頼性高く実現
  • OwnedItemsData によるシングルトンでの所持データ管理と PlayerPrefs を用いた永続化により、ゲーム終了後もデータが維持される
  • リファクタリングの各ポイントを押さえることで、今後の保守性・拡張性が向上し、UI や新たなゲームロジックへの対応が容易になる

以上が、リファクタリングのポイントを含めた「Unity におけるアイテム処理と所持データ管理システム」の資料およびコード全体になります。
この内容をもとに、各アイテムプレハブの設定や追加機能の実装を進めていただければと思います。

JSON,PlayerPrefs,Unity

Posted by hidepon