【Godot 4】InventoryGrid (インベントリ) コンポーネントの作り方

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

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ロジックコンポーネント」として育てていくと、
どのゲームにも持ち回せる強力な再利用パーツになります。
継承ベースの巨大シーンから卒業して、コンポーネント指向のインベントリを楽しんでいきましょう。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!