Unityを触り始めると、つい Update() に全部書いてしまいがちですよね。
インベントリUIの例でいうと、
- アイテムリストの管理
- Grid上のアイコン生成と破棄
- マウス入力の監視
- ドラッグ&ドロップ中のアイコン追従
- スロットの入れ替え処理
こういった処理をひとつの巨大スクリプトに詰め込むと、すぐに「どこを触ればいいのか分からないクラス」になってしまいます。
そこで今回は、「アイテム配列を受け取り、GridContainerにアイコンを並べ、D&D操作を管理する」という役割だけに絞った InventoryGrid コンポーネントを作って、UIインベントリ周りの責務をきれいに分割してみましょう。
【Unity】ドラッグ&ドロップ対応のインベントリUI!「InventoryGrid」コンポーネント
以下では、
- アイテム配列(スクリプタブルオブジェクト想定)を受け取る
- GridLayoutGroup 配下にアイコンボタンを自動生成する
- マウスによるドラッグ&ドロップでスロット入れ替えを行う
という、インベントリ表示専用のコンポーネントを実装していきます。
「インベントリの中身をどう決めるか」は別のコンポーネントに任せて、UI表示とD&Dだけを担当させる設計ですね。
フルコード:InventoryGrid.cs
using System;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace Sample.Inventory
{
/// <summary>
/// シンプルなインベントリアイテムの定義。
/// 実際のプロジェクトでは ScriptableObject にしてもOK。
/// </summary>
[Serializable]
public class InventoryItem
{
// アイテム名(デバッグ・ツールチップなどに使用)
public string itemName;
// UIに表示するアイコン
public Sprite icon;
}
/// <summary>
/// インベントリの見た目(Grid)とドラッグ&ドロップを管理するコンポーネント。
/// ・アイテム配列を受け取り
/// ・GridLayoutGroup配下にスロットを並べ
/// ・ドラッグ&ドロップでスロット入れ替えを行う
/// という役割だけに責務を絞っています。
/// </summary>
public class InventoryGrid : MonoBehaviour
{
[Header("参照")]
[SerializeField]
private Transform gridContainer;
// GridLayoutGroup をアタッチした GameObject を指定
[SerializeField]
private GameObject slotPrefab;
// 1スロット分のプレハブ(Button + Image など)
[SerializeField]
private Canvas rootCanvas;
// ドラッグ中のアイコンを正しく表示するための Canvas
[Header("インベントリデータ")]
[SerializeField]
private InventoryItem[] items;
// 外部のインベントリ管理クラスからセットしてもOK
[Header("ドラッグ中アイコンの見た目")]
[SerializeField]
private Image dragIconImage;
// ドラッグ中のアイコンを表示する Image
// (Canvas 配下に置き、最初は非表示にしておく)
// 内部管理用:生成したスロットを保持
private InventorySlotUI[] _slots;
// 現在ドラッグ中かどうか
private bool _isDragging;
// ドラッグ開始元のスロットインデックス
private int _dragOriginIndex = -1;
private void Awake()
{
// 必須参照チェック(最低限のガード)
if (gridContainer == null)
{
Debug.LogError("InventoryGrid: gridContainer が設定されていません。");
}
if (slotPrefab == null)
{
Debug.LogError("InventoryGrid: slotPrefab が設定されていません。");
}
if (rootCanvas == null)
{
rootCanvas = GetComponentInParent<Canvas>();
if (rootCanvas == null)
{
Debug.LogError("InventoryGrid: rootCanvas が設定されておらず、親にも Canvas が見つかりません。");
}
}
if (dragIconImage != null)
{
dragIconImage.gameObject.SetActive(false);
}
}
private void Start()
{
RebuildGrid();
}
/// <summary>
/// 外部からアイテム配列を差し替えるためのAPI。
/// インベントリの中身が更新されたときに呼び出します。
/// </summary>
public void SetItems(InventoryItem[] newItems)
{
items = newItems;
RebuildGrid();
}
/// <summary>
/// Grid上のスロットを作り直し、現在の items 配列を反映する。
/// </summary>
public void RebuildGrid()
{
if (gridContainer == null || slotPrefab == null)
{
return;
}
// 既存の子オブジェクト(スロット)を削除
for (int i = gridContainer.childCount - 1; i >= 0; i--)
{
Destroy(gridContainer.GetChild(i).gameObject);
}
if (items == null || items.Length == 0)
{
_slots = Array.Empty<InventorySlotUI>();
return;
}
_slots = new InventorySlotUI[items.Length];
for (int i = 0; i < items.Length; i++)
{
// スロットプレハブを生成
GameObject slotObj = Instantiate(slotPrefab, gridContainer);
// スロットUIコンポーネントを取得(なければ付与)
InventorySlotUI slotUI = slotObj.GetComponent<InventorySlotUI>();
if (slotUI == null)
{
slotUI = slotObj.AddComponent<InventorySlotUI>();
}
// スロットに必要な初期化情報を渡す
slotUI.Initialize(this, i, items[i]);
_slots[i] = slotUI;
}
}
/// <summary>
/// スロットから呼ばれる:ドラッグ開始。
/// </summary>
public void BeginDrag(int slotIndex)
{
if (items == null || slotIndex < 0 || slotIndex >= items.Length)
{
return;
}
InventoryItem item = items[slotIndex];
if (item == null || item.icon == null)
{
// 空スロットやアイコンなしの場合はドラッグしない
return;
}
_isDragging = true;
_dragOriginIndex = slotIndex;
// ドラッグ中アイコンの見た目をセット
if (dragIconImage != null)
{
dragIconImage.sprite = item.icon;
dragIconImage.SetNativeSize();
dragIconImage.gameObject.SetActive(true);
}
}
/// <summary>
/// スロットから呼ばれる:ドラッグ終了。
/// マウスが離された時点で呼び出されます。
/// </summary>
public void EndDrag(int pointerOverSlotIndex)
{
if (!_isDragging)
{
return;
}
// ドラッグ中フラグをクリア
_isDragging = false;
// ドラッグ中アイコンを非表示に
if (dragIconImage != null)
{
dragIconImage.gameObject.SetActive(false);
}
// 有効なインデックスでなければ何もしない
if (_dragOriginIndex < 0 || _dragOriginIndex >= items.Length)
{
_dragOriginIndex = -1;
return;
}
// ドロップ先が有効なスロットならアイテムを入れ替え
if (pointerOverSlotIndex >= 0 && pointerOverSlotIndex < items.Length)
{
SwapItems(_dragOriginIndex, pointerOverSlotIndex);
}
_dragOriginIndex = -1;
// UIを更新
RefreshAllSlots();
}
/// <summary>
/// 毎フレーム、ドラッグ中のアイコンをマウス位置に追従させる。
/// Update はあくまで「見た目の追従」だけに責務を絞る。
/// </summary>
private void Update()
{
if (!_isDragging || dragIconImage == null || rootCanvas == null)
{
return;
}
// マウスのスクリーン座標を取得
Vector2 screenPos = Input.mousePosition;
// Canvas の RenderMode に応じた座標変換
RectTransformUtility.ScreenPointToLocalPointInRectangle(
rootCanvas.transform as RectTransform,
screenPos,
rootCanvas.renderMode == RenderMode.ScreenSpaceOverlay
? null
: rootCanvas.worldCamera,
out Vector2 localPos
);
dragIconImage.rectTransform.anchoredPosition = localPos;
}
/// <summary>
/// 2つのスロット間でアイテムを入れ替える。
/// </summary>
private void SwapItems(int indexA, int indexB)
{
if (items == null)
{
return;
}
if (indexA < 0 || indexA >= items.Length) return;
if (indexB < 0 || indexB >= items.Length) return;
if (indexA == indexB) return;
InventoryItem temp = items[indexA];
items[indexA] = items[indexB];
items[indexB] = temp;
}
/// <summary>
/// 内部の items 配列の内容に合わせて、全スロットの表示を更新。
/// </summary>
private void RefreshAllSlots()
{
if (_slots == null || items == null)
{
return;
}
int count = Mathf.Min(_slots.Length, items.Length);
for (int i = 0; i < count; i++)
{
_slots[i].SetItem(items[i]);
}
}
}
/// <summary>
/// 1スロット分のUIと、ドラッグ開始/終了のイベントを担当するコンポーネント。
/// このクラスは「スロットの見た目とイベント」だけに責務を絞ります。
/// </summary>
[RequireComponent(typeof(Image))]
public class InventorySlotUI : MonoBehaviour,
IPointerDownHandler,
IPointerUpHandler,
IPointerEnterHandler,
IPointerExitHandler
{
[SerializeField]
private Image iconImage;
// アイテムアイコンを表示する Image
// 所属する InventoryGrid
private InventoryGrid _inventoryGrid;
// 自分のスロットインデックス
private int _slotIndex;
// マウスが現在このスロットの上にあるか
private bool _isPointerOver;
/// <summary>
/// InventoryGrid から呼ばれる初期化処理。
/// </summary>
public void Initialize(InventoryGrid inventoryGrid, int slotIndex, InventoryItem item)
{
_inventoryGrid = inventoryGrid;
_slotIndex = slotIndex;
// iconImage が未設定なら、自身の Image を利用
if (iconImage == null)
{
iconImage = GetComponent<Image>();
}
SetItem(item);
}
/// <summary>
/// スロットに表示するアイテムを更新。
/// null の場合はアイコンを非表示にします。
/// </summary>
public void SetItem(InventoryItem item)
{
if (iconImage == null)
{
return;
}
if (item != null && item.icon != null)
{
iconImage.sprite = item.icon;
iconImage.color = Color.white;
}
else
{
// 空スロット用の見た目(透明にするなど)
iconImage.sprite = null;
iconImage.color = new Color(1f, 1f, 1f, 0f);
}
}
/// <summary>
/// マウスが押されたときに呼ばれる。
/// ここでドラッグ開始を InventoryGrid に通知します。
/// </summary>
public void OnPointerDown(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left)
{
return;
}
_inventoryGrid?.BeginDrag(_slotIndex);
}
/// <summary>
/// マウスが離されたときに呼ばれる。
/// InventoryGrid に「どのスロット上で離されたか」を伝えます。
/// </summary>
public void OnPointerUp(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left)
{
return;
}
// 現在マウスが乗っているスロットインデックスを渡す
int dropIndex = _isPointerOver ? _slotIndex : -1;
_inventoryGrid?.EndDrag(dropIndex);
}
/// <summary>
/// マウスカーソルがこのスロットに入ったとき。
/// </summary>
public void OnPointerEnter(PointerEventData eventData)
{
_isPointerOver = true;
}
/// <summary>
/// マウスカーソルがこのスロットから出たとき。
/// </summary>
public void OnPointerExit(PointerEventData eventData)
{
_isPointerOver = false;
}
}
}
使い方の手順
ここでは、プレイヤーのインベントリUI を例に、実際のセットアップ手順を見ていきます。
-
UIレイアウトを作る
- Canvas を1つ用意します(Render Mode は Screen Space – Overlay 推奨)。
- Canvas の子に、インベントリ用の Panel を作成します。
- Panel の子に
GridLayoutGroupをアタッチした GameObject(例:InventoryGridContainer)を作成します。
Cell Size や Spacing を調整して、インベントリのマス目レイアウトを決めましょう。
-
スロット用プレハブを作る
- Hierarchy 上で、空の UI > Image を作成し、名前を
InventorySlotにします。 - サイズを 64×64 などに調整し、背景画像や枠線を設定します。
- このオブジェクトに
InventorySlotUIをアタッチします。 - 必要なら、この Image を
iconImageにドラッグ&ドロップで割り当てます(未設定でも自動取得されます)。 - Prefab 化して、Project ビューにドラッグして保存します。
- Hierarchy 上で、空の UI > Image を作成し、名前を
-
InventoryGrid コンポーネントをセットアップする
- Panel(または任意の GameObject)に
InventoryGridをアタッチします。 Grid Containerに、先ほど作成したInventoryGridContainer(GridLayoutGroup付き)を指定します。Slot Prefabに、作成したInventorySlotプレハブを指定します。Root Canvasに、インベントリUIが属している Canvas を指定します(未設定でも親から自動取得を試みます)。- ドラッグ中アイコン用に、Canvas の子に空の Image を作成し、
dragIconImageに割り当てておきます。最初はSetActive(false)にしておくか、インスペクタで非表示にしておきましょう。
- Panel(または任意の GameObject)に
-
アイテム配列を用意して渡す
テスト用として、インスペクタから直接配列を埋めてもOKですし、以下のような簡単な管理スクリプトを作ってもよいです。
using UnityEngine; using Sample.Inventory; public class PlayerInventoryExample : MonoBehaviour { [SerializeField] private InventoryGrid inventoryGrid; [SerializeField] private Sprite[] testItemIcons; private InventoryItem[] _items; private void Start() { // テスト用に、アイコン配列から InventoryItem 配列を組み立てる _items = new InventoryItem[testItemIcons.Length]; for (int i = 0; i < testItemIcons.Length; i++) { _items[i] = new InventoryItem { itemName = $"Item_{i}", icon = testItemIcons[i] }; } // InventoryGrid に渡して表示させる inventoryGrid.SetItems(_items); } }この状態で再生すると、Grid 上にアイコンが並び、左クリックでドラッグ&ドロップしてスロットを入れ替えできるようになります。
メリットと応用
この InventoryGrid コンポーネントを使うことで、
- UI表示とデータ管理の責務を分離できる
- インベントリの中身(何を持っているか)は
PlayerInventoryなど別コンポーネントで管理。 - Grid の生成・ドラッグ&ドロップ・アイコン表示だけを
InventoryGridが担当。 - 巨大な God クラスを避け、テストや改造がしやすくなります。
- インベントリの中身(何を持っているか)は
- プレハブ化でレベルデザインが楽になる
- インベントリUI一式をプレハブにしておけば、シーンごとに簡単に再利用できます。
- Grid の列数やスロット間隔を変えたバリエーションも、Prefab Variant などで管理しやすいです。
- ドラッグ&ドロップのロジックを1か所に集約
- 「ドラッグ中のビジュアル」「スロット入れ替え」「どのスロットに落としたか」の判定が
InventoryGridに集約されます。 - 後から「右クリックで使用」「Ctrl+ドラッグで分割」などの機能追加もしやすくなります。
- 「ドラッグ中のビジュアル」「スロット入れ替え」「どのスロットに落としたか」の判定が
応用例としては、
- 敵のドロップ品一覧UI(クリックで拾う)
- クラフト画面の素材スロット
- 装備スロット(武器・防具など)
などにも同じ仕組みを流用できます。「何を持っているか」のデータ構造だけ変えて、UI部分はこのコンポーネントを使い回すイメージですね。
改造案:スロットクリックでアイテム名をログに出す
ドラッグ&ドロップ以外にも、スロットクリックで何かしたくなる場面は多いです。
例えば、「左クリックで詳細ウィンドウを開く」などの処理を足したい場合、InventoryGrid に以下のようなメソッドを追加し、InventorySlotUI から呼ぶ形にすると責務を保ったまま拡張できます。
public void OnSlotClicked(int slotIndex)
{
if (items == null || slotIndex < 0 || slotIndex >= items.Length)
{
return;
}
InventoryItem item = items[slotIndex];
if (item == null)
{
Debug.Log($"スロット {slotIndex} は空です。");
}
else
{
Debug.Log($"スロット {slotIndex} をクリック: {item.itemName}");
// ここで詳細ウィンドウを開く・装備する などの処理に分岐させてもOK
}
}
このように、小さなコンポーネントを組み合わせていくと、
「インベントリUIを全部1ファイルに書く」のではなく、「表示担当」「データ担当」「操作担当」に分けた、拡張しやすい設計に育てていけます。ぜひ自分のプロジェクト用にカスタマイズしてみてください。
