Godot 4でインベントリUIを作ろうとすると、ついこんな構成にしがちですよね。
Player (CharacterBody2D) ├── InventoryUI (Control 派生シーン) │ ├── GridContainer │ │ ├── Slot1 (Button) │ │ ├── Slot2 (Button) │ │ ├── Slot3 (Button) │ │ └── ...(延々と続く) │ └── Script(アイテム管理・ドラッグ&ドロップ全部入り) └── PlayerInventory.gd(ロジック)
この「UIとロジック全部入りスクリプト」、最初は動くんですが…
- インベントリの見た目を変えたいだけなのに、ドラッグ&ドロップのロジックまで巻き込んで修正が必要
- 敵や宝箱にもインベントリを持たせたいけど、UIごとコピペするのはイヤ
- GridContainerの子スロットを1個ずつ手で配置・接続するのがダルい
つまり「継承された巨大UIシーン」にロジックをべったり書くと、規模が大きくなった途端に破綻しやすいんですね。
そこで今回は、アイテム配列を渡すだけで GridContainer にアイコンを並べ、ドラッグ&ドロップ(D&D)まで面倒を見てくれるコンポーネントを用意します。
UIシーンはただの「箱」、インベントリの表示とD&Dのロジックは「InventoryGridコンポーネント」に丸投げする構成にしましょう。
【Godot 4】ドラッグ&ドロップ対応の汎用インベントリ!「InventoryGrid」コンポーネント
このコンポーネントは、ざっくり言うとこんなことをしてくれます。
@exportで設定した GridContainer に自動でスロットボタンを生成- アイテム配列を受け取り、アイコンテクスチャをスロットに表示
- スロット間のドラッグ&ドロップでアイテムを入れ替え
- インベントリの中身はシンプルな配列として扱える(UIとロジックを分離しやすい)
UI側は「GridContainerを置いて、InventoryGrid をアタッチして、アイテム配列を渡すだけ」。
プレイヤー用でも敵用でも宝箱用でも、同じコンポーネントをポン付けで使い回せます。
フルコード:InventoryGrid.gd
extends Node
class_name InventoryGrid
## InventoryGrid コンポーネント
## - 指定した GridContainer にスロットボタンを自動生成
## - items 配列をもとにアイコン表示
## - スロット間のドラッグ&ドロップでアイテムを入れ替え
## インベントリに表示する「アイテム1つ分」のデータ型を定義します。
## ここではシンプルに「アイコンテクスチャ」と「任意のメタ情報(辞書)」だけ持たせています。
class ItemData:
var icon: Texture2D
var meta: Dictionary
func _init(_icon: Texture2D = null, _meta: Dictionary = {}):
icon = _icon
meta = _meta
## ========== エディタで設定するパラメータ ==========
@export var grid: GridContainer
## スロットを並べる GridContainer。
## シーンに GridContainer を置いて、この変数にドラッグ&ドロップで割り当ててください。
@export var slot_scene: PackedScene
## 各スロットの見た目となるシーン(必須)。
## 例: TextureButton 1個だけを持つシンプルな Control シーンなど。
## スロットのルートノードは Control 派生であることを推奨します。
@export var columns: int = 4:
set(value):
columns = max(1, value)
if is_instance_valid(grid):
grid.columns = columns
## グリッドの列数。GridContainer の columns に反映されます。
@export var slot_count: int = 16:
set(value):
slot_count = max(1, value)
_rebuild_slots()
## スロット数。インベントリの最大サイズとして扱います。
@export var drag_preview_scale: float = 1.0
## ドラッグ中に表示するプレビューアイコンのスケール倍率。
@export var clear_empty_icons: bool = true
## true の場合、アイテムがないスロットはアイコンをクリアします。
## ========== シグナル ==========
signal item_swapped(from_index: int, to_index: int)
## スロット間でアイテムが入れ替わったときに発火します。
signal item_clicked(index: int, button: int)
## スロットがクリックされたときに発火します(左クリック=1, 右クリック=2 など)。
## ========== 内部状態 ==========
var items: Array[ItemData] = []
## インベントリの中身。常に slot_count と同じ長さを維持します。
var _slots: Array[Control] = []
## 生成したスロットノードの参照リスト。
var _dragging_index: int = -1
var _dragging_icon: Texture2D = null
func _ready() -> void:
if not grid:
push_warning("InventoryGrid: grid が設定されていません。エディタで GridContainer を割り当ててください。")
return
if not slot_scene:
push_warning("InventoryGrid: slot_scene が設定されていません。エディタでスロット用シーンを割り当ててください。")
return
# GridContainer の列数を同期
grid.columns = columns
# items 配列をスロット数に合わせて初期化
_resize_items_array(slot_count)
# スロットノードを生成
_rebuild_slots()
## ========== 公開API ==========
func set_items(new_items: Array) -> void:
## 外部からインベントリの中身を丸ごと差し替えるときに使います。
## new_items は ItemData インスタンス(または null)を要素とする配列を想定します。
_resize_items_array(slot_count)
for i in range(min(new_items.size(), slot_count)):
var v = new_items[i]
if v is ItemData:
items[i] = v
elif v == null:
items[i] = null
else:
push_warning("InventoryGrid: set_items に ItemData 以外の値が渡されました。index=%d" % i)
_refresh_all_slots()
func get_items() -> Array[ItemData]:
## 現在のインベントリ内容をそのまま返します。
return items.duplicate(true)
func set_item(index: int, item: ItemData) -> void:
## 特定スロットのアイテムを設定します。
if index < 0 or index >= slot_count:
push_warning("InventoryGrid: set_item index out of range: %d" % index)
return
items[index] = item
_refresh_slot(index)
func get_item(index: int) -> ItemData:
## 特定スロットのアイテムを取得します。
if index < 0 or index >= slot_count:
return null
return items[index]
func clear_item(index: int) -> void:
## 特定スロットのアイテムを削除(null)にします。
set_item(index, null)
func clear_all() -> void:
## 全スロットのアイテムを削除します。
for i in range(slot_count):
items[i] = null
_refresh_all_slots()
## ========== 内部処理:スロット生成と更新 ==========
func _resize_items_array(size: int) -> void:
## items 配列の長さを size に合わせます。
if items.size() < size:
while items.size() < size:
items.append(null)
elif items.size() > size:
items.resize(size)
func _rebuild_slots() -> void:
## GridContainer の子を全削除してから、slot_count 分のスロットを生成します。
if not is_instance_valid(grid) or not slot_scene:
return
# 既存スロットを削除
for child in grid.get_children():
child.queue_free()
_slots.clear()
# スロット生成
for i in range(slot_count):
var slot = slot_scene.instantiate()
if not (slot is Control):
push_warning("InventoryGrid: slot_scene のルートは Control 派生である必要があります。")
grid.add_child(slot)
_slots.append(slot)
# スロットにインデックス情報を持たせる
slot.set_meta("inventory_index", i)
# スロット側のマウス入力を拾うために、gui_input シグナルを接続
if slot.has_signal("gui_input"):
slot.gui_input.connect(_on_slot_gui_input.bind(i))
# Godot のドラッグ&ドロップ API を使うため、slot に以下のメソッドを委譲させます。
# - _get_drag_data
# - _can_drop_data
# - _drop_data
# ここではスロットの script に依存しないよう、callable を使って「上書き」します。
_override_drag_and_drop(slot, i)
# 見た目を更新
_refresh_all_slots()
func _refresh_all_slots() -> void:
for i in range(slot_count):
_refresh_slot(i)
func _refresh_slot(index: int) -> void:
if index < 0 or index >= _slots.size():
return
var slot = _slots[index]
var item: ItemData = items[index]
# スロット内の TextureRect または TextureButton を探してアイコンを設定します。
var texture_node := _find_texture_node(slot)
if texture_node:
if item and item.icon:
texture_node.texture = item.icon
texture_node.visible = true
else:
if clear_empty_icons:
texture_node.texture = null
texture_node.visible = item != null
func _find_texture_node(slot: Node) -> TextureRect:
## スロット内から、最初に見つかった TextureRect or TextureButton を返します。
## スロットシーンの構造に依存させたくないため、ざっくり探索しています。
var queue: Array = [slot]
while queue.size() > 0:
var n: Node = queue.pop_front()
if n is TextureRect:
return n
if n is TextureButton:
return n
for child in n.get_children():
queue.append(child)
return null
## ========== ドラッグ&ドロップ関連 ==========
func _override_drag_and_drop(slot: Control, index: int) -> void:
## スロットにドラッグ&ドロップ用のメソッドを仕込みます。
## 注意:
## - ここでは「メソッドを生やす」のではなく、Control のコールバックを使っています。
## Godot は Control に対して _get_drag_data などを呼ぶので、
## それをスロットの script ではなく、このコンポーネントで処理します。
slot.set_drag_forwarding(this)
func _get_drag_data(at_position: Vector2, from_slot_index: int) -> Variant:
## スロットからドラッグ開始されたときに呼ばれます。
var item := get_item(from_slot_index)
if not item:
return null
_dragging_index = from_slot_index
_dragging_icon = item.icon
# ドラッグプレビュー用の Control を作成
var preview := TextureRect.new()
preview.texture = item.icon
preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
preview.scale = Vector2.ONE * drag_preview_scale
set_drag_preview(preview)
# ドラッグ中に運ぶデータは「元スロットのインデックス」にしておきます。
return from_slot_index
func _can_drop_data(at_position: Vector2, data: Variant, to_slot_index: int) -> bool:
## data は _get_drag_data で返したもの(元スロットのインデックス)です。
if typeof(data) != TYPE_INT:
return false
var from_index: int = data
if from_index < 0 or from_index >= slot_count:
return false
if to_slot_index < 0 or to_slot_index >= slot_count:
return false
# 同じスロットへのドロップも許可(何も起こらない)が、ここで制御してもよい
return true
func _drop_data(at_position: Vector2, data: Variant, to_slot_index: int) -> void:
if typeof(data) != TYPE_INT:
return
var from_index: int = data
if not _can_drop_data(at_position, data, to_slot_index):
return
if from_index == to_slot_index:
return
# アイテムを入れ替える
var tmp := items[from_index]
items[from_index] = items[to_slot_index]
items[to_slot_index] = tmp
_refresh_slot(from_index)
_refresh_slot(to_slot_index)
emit_signal("item_swapped", from_index, to_slot_index)
_dragging_index = -1
_dragging_icon = null
## ========== スロットの入力処理(クリックなど) ==========
func _on_slot_gui_input(event: InputEvent, index: int) -> void:
if event is InputEventMouseButton and event.pressed:
emit_signal("item_clicked", index, event.button_index)
## ========== Control のドラッグ&ドロップ委譲 ==========
func _gui_input(event: InputEvent, slot: Control) -> void:
## set_drag_forwarding(this) を使う場合、本来は Control 自身の _gui_input が呼ばれますが、
## ここでは InventoryGrid に集約して処理するイメージです。
pass # 今回はクリック検出を gui_input シグナルで済ませているので空実装。
func _get_drag_data(at_position: Vector2) -> Variant:
## GridContainer 自体がドラッグ対象になることはないので、null を返します。
return null
func _can_drop_data(at_position: Vector2, data: Variant) -> bool:
## GridContainer 自体にはドロップしないので false。
return false
func _drop_data(at_position: Vector2, data: Variant) -> void:
## 何もしない。
pass
※ 上記コードは「Control.set_drag_forwarding(this)」まわりを簡略化しています。
Godot 4.2 以降では Control.set_drag_forwarding() でドラッグ処理を別ノードに委譲できますが、バージョンや仕様変更により挙動が変わる可能性があるため、実際のプロジェクトでは slot_scene 側に _get_drag_data / _can_drop_data / _drop_data を実装し、その中から InventoryGrid のメソッドを呼ぶ構成にしてもOKです。
使い方の手順
① スロット用シーン(Slot.tscn)を作る
まずは各スロットの見た目となるシーンを用意します。とてもシンプルでOKです。
Slot (Control) └── TextureRect(アイコン表示用)
この Slot.tscn にはスクリプトを付けなくても動きます(必要なら後から付け足せます)。
レイアウトは自由ですが、TextureRect か TextureButton がどこかに1つ以上入っていると、InventoryGrid が自動でアイコンを見つけてくれます。
② インベントリUIシーンに GridContainer と InventoryGrid を置く
例として、プレイヤー用インベントリUIシーンを作ります。
PlayerInventoryUI (Control) ├── Panel │ └── GridContainer └── InventoryGrid (Node)
- GridContainer … スロットを並べるコンテナ。
インスペクタでcolumnsは適当でOK(InventoryGrid側で上書きします)。 - InventoryGrid … 先ほど作った
InventoryGrid.gdをアタッチした Node。
InventoryGrid ノードを選択して、インスペクタで以下を設定します。
- grid … 先ほどの GridContainer をドラッグ&ドロップで割り当て
- slot_scene … Slot.tscn を割り当て
- columns … 4(お好みで)
- slot_count … 16(お好みで)
③ プレイヤーのインベントリ配列を InventoryGrid に渡す
プレイヤー本体(Player.gd)などで、インベントリの中身を管理し、UIに渡します。
# Player.gd (例)
extends CharacterBody2D
@onready var inventory_grid: InventoryGrid = $"CanvasLayer/PlayerInventoryUI/InventoryGrid"
var inventory_items: Array[InventoryGrid.ItemData] = []
func _ready() -> void:
# 適当にアイテムを追加してみる
var sword_icon: Texture2D = preload("res://icons/sword.png")
var potion_icon: Texture2D = preload("res://icons/potion.png")
inventory_items.resize(16)
inventory_items[0] = InventoryGrid.ItemData.new(sword_icon, {"id": "sword_001", "stack": 1})
inventory_items[1] = InventoryGrid.ItemData.new(potion_icon, {"id": "potion_hp", "stack": 3})
# UI に反映
inventory_grid.set_items(inventory_items)
# スロットクリック時の処理(例:右クリックで使用)
inventory_grid.item_clicked.connect(_on_inventory_item_clicked)
# スワップされたときの処理(必要なら)
inventory_grid.item_swapped.connect(_on_inventory_item_swapped)
func _on_inventory_item_clicked(index: int, button: int) -> void:
var item := inventory_grid.get_item(index)
if not item:
return
if button == MOUSE_BUTTON_RIGHT:
print("Use item at index %d: %s" % [index, str(item.meta)])
# ここでアイテム使用ロジックを呼び出す
# 例: if item.meta.get("id") == "potion_hp": heal_player()
# 使用したら削除
inventory_grid.clear_item(index)
func _on_inventory_item_swapped(from_index: int, to_index: int) -> void:
print("Inventory items swapped: %d <-> %d" % [from_index, to_index])
# 必要なら、プレイヤー側の inventory_items 配列にも反映する
inventory_items = inventory_grid.get_items()
シーン構成図の全体像はこんな感じです。
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── CanvasLayer │ └── PlayerInventoryUI (Control) │ ├── Panel │ │ └── GridContainer │ └── InventoryGrid (Node) └── Player.gd(スクリプト)
④ 敵や宝箱にも同じコンポーネントを再利用する
敵や宝箱にもインベントリを持たせたい場合、UIはまったく同じ構成でOKです。
Chest (Area2D) ├── Sprite2D ├── CollisionShape2D ├── CanvasLayer │ └── ChestInventoryUI (Control) │ ├── Panel │ │ └── GridContainer │ └── InventoryGrid (Node) └── Chest.gd
Chest.gd 側で InventoryGrid にアイテム配列を渡すだけで、ドラッグ&ドロップ対応のインベントリUIがそのまま使い回せます。
「プレイヤー用インベントリシーン」を継承して増殖させる必要はありません。
メリットと応用
この InventoryGrid コンポーネントを使うことで、以下のようなメリットがあります。
- UIとロジックの分離
インベントリの中身(配列)はプレイヤーや敵のスクリプト側で管理し、
InventoryGrid は「表示とD&D」だけに専念します。責務が明確ですね。 - シーン構造がシンプル
GridContainer の子にスロットをズラッと手で並べる必要がなく、
InventoryGrid が自動生成してくれるので、シーンツリーがスッキリします。 - 再利用性が高い
プレイヤー、敵、宝箱、商人の在庫UIなど、
「アイテム配列を見せたい場所」に InventoryGrid をポン付けするだけでOKです。 - レイアウト変更に強い
スロットの見た目は Slot.tscn 側で完結しているので、
アイコンの枠やレアリティ色を変えたいときも InventoryGrid のコードは触らなくて済みます。
「継承された巨大UIシーン」ではなく、「GridContainer + InventoryGrid + Slotシーン」の合成(Composition)で組むことで、
UIの見た目とインベントリロジックを気持ちよく分離できます。
改造案:ホットバーだけ「空スロット禁止」にする
例えばホットバー用の InventoryGrid では、「空のスロットにはドロップできない」ようにしたいかもしれません。
その場合、_can_drop_data を少し拡張して、空きスロットへのドロップを禁止することができます。
func _can_drop_data(at_position: Vector2, data: Variant, to_slot_index: int) -> bool:
if typeof(data) != TYPE_INT:
return false
var from_index: int = data
if from_index < 0 or from_index >= slot_count:
return false
if to_slot_index < 0 or to_slot_index >= slot_count:
return false
# 例: ホットバー専用のフラグが立っているときは、空スロットへのドロップを禁止する
var hotbar_mode := true
if hotbar_mode and items[to_slot_index] == null:
return false
return true
このように、InventoryGrid自体を1つの「UIロジックコンポーネント」として育てていくと、
どのゲームにも持ち回せる強力な再利用パーツになります。
継承ベースの巨大シーンから卒業して、コンポーネント指向のインベントリを楽しんでいきましょう。




