導入:なぜインベントリ設計が重要か
ゲーム開発において、インベントリシステムはプレイヤーの体験を豊かにする上で欠かせない要素です。しかし、単にアイテムを格納する箱ではありません。 アイテムのデータ構造、 インベントリのロジック、そして プレイヤーに情報を伝えるUI という、三つの異なる要素が密接に連携して初めて機能します。
多くの開発者が陥りがちな問題:
- スパゲッティコード: アイテムを拾う処理、使う処理、捨てる処理がプレイヤーのスクリプトに散らばり、見通しが悪くなる。
- 拡張性の欠如: 「装備品」や「スタック不可アイテム」など、新しい種類のアイテムを追加するたびに、広範囲のコード修正が必要になる。
- UIとの密結合: インベントリのロジックを変更したら、UIコードも修正する必要があり、バグの温床になる。
本記事では、Godotの強力な機能である Resource と シグナル を活用し、これらの問題を解決する、データ・ロジック・UIが疎結合に連携するインベントリ設計を、具体的なコードと共に解説します。
設計思想:3層分離アーキテクチャ
堅牢なシステムの鍵は「関心の分離」です。インベントリシステムは、以下の3つの層に分離して構築します。
| 層 | 役割 | Godotでの実装 | 特徴 |
|---|---|---|---|
| データ層 | 「アイテムとは何か」を定義する静的な情報 | Resource (.tres ファイル) | ゲームデザイナーが編集可能。再利用性が高い。 |
| ロジック層 | アイテムの追加、削除、使用などを管理する動的な状態 | シングルトン Node (オートロード) | ゲーム全体で唯一の状態を持つ中央ハブ。 |
| ビュー(UI)層 | インベントリの状態をプレイヤーに可視化する | Control ノード群 | ロジック層からシグナルを受け取り、表示を更新するだけ。 |
このアーキテクチャにより、各層は他の層の詳細を知ることなく、自身の役割に集中できます。
セクション1: 【データ層】Resourceによるアイテム定義
まず、アイテムの設計図となるItemResourceを定義します。
# res://items/ItemResource.gd
class_name ItemResource
extends Resource
# アイテムの種別を定義するEnum
enum ItemType { CONSUMABLE, EQUIPMENT, KEY_ITEM, MATERIAL }
@export_group("基本情報")
@export var item_name: String = "New Item"
@export_multiline var description: String = ""
@export var texture: Texture2D # アイコンはTexture2Dで直接参照
@export_group("インベントリ設定")
@export var type: ItemType = ItemType.CONSUMABLE
@export var stackable: bool = true
@export var max_stack_size: int = 99
@export_group("ゲームプレイ")
@export var can_be_used: bool = true
@export var heal_amount: int = 10
# 比較のために一意なIDを持つことが推奨される
# resource_pathはファイルとして保存されていない場合は空になるため、フォールバックを用意
func get_id() -> String:
return resource_path if not resource_path.is_empty() else str(get_instance_id())
このItemResourceを継承し、Godotエディタで「剣」や「ポーション」といった具体的なアイテムを.tresファイルとして作成します。パス自体がユニークIDになるため、文字列のIDを別途管理する必要がなくなります。
セクション2: 【ロジック層】シングルトンによる状態管理
次に、インベントリの状態を管理するシングルトンInventoryManagerを作成します。これはオートロードに設定し、ゲーム内のどこからでもアクセスできるようにします。
# res://managers/InventoryManager.gd
extends Node
# インベントリが変更されたときに発行されるシグナル
signal inventory_changed
# アイテムが使用されたときに発行されるシグナル(効果音再生などに利用)
signal item_used(item_resource: ItemResource)
# キー: アイテムID, 値: { "resource": ItemResource, "count": int }
var _items: Dictionary = {}
const MAX_SLOTS: int = 30
# アイテムを追加する
func add_item(item_resource: ItemResource, count: int = 1) -> bool:
if not item_resource:
printerr("追加しようとしたアイテムがnullです")
return false
var item_id = item_resource.get_id()
# スタック可能なアイテムの処理
# 拡張案: max_stack_sizeを超える場合は、超過分を新しいスロットに分割するか、
# 追加を拒否してfalseを返すロジックを追加できます
if item_resource.stackable and _items.has(item_id):
_items[item_id].count += count
inventory_changed.emit()
return true
# 新しいスロットにアイテムを追加
if _items.size() < MAX_SLOTS:
_items[item_id] = { "resource": item_resource, "count": count }
inventory_changed.emit()
return true
print("インベントリがいっぱいです")
return false
# アイテムを使用する
func use_item(item_id: String):
if not _items.has(item_id):
return
var item_data = _items[item_id]
var item_resource: ItemResource = item_data.resource
if not item_resource.can_be_used:
return
# ここでアイテムの効果を実装する
print("%s を使用した。HPが %d 回復!" % [item_resource.item_name, item_resource.heal_amount])
item_used.emit(item_resource)
# 消費アイテムなら個数を減らす
if item_resource.type == ItemResource.ItemType.CONSUMABLE:
remove_item(item_id, 1)
# アイテムを削除・減算する
func remove_item(item_id: String, count: int = 1):
if not _items.has(item_id):
return
_items[item_id].count -= count
if _items[item_id].count <= 0:
_items.erase(item_id)
inventory_changed.emit()
func get_inventory_data() -> Dictionary:
return _items.duplicate(true)
セクション3: 【ビュー層】シグナルによるUIの自動更新
UIはロジック層の状態をただ表示するだけの存在であるべきです。InventoryManagerのinventory_changedシグナルを接続し、UIを更新します。
# res://ui/InventoryUI.gd
extends GridContainer
const SLOT_SCENE = preload("res://ui/InventorySlot.tscn")
func _ready():
# シングルトンに直接接続
InventoryManager.inventory_changed.connect(_on_inventory_changed)
# 初期表示
_redraw_inventory()
func _on_inventory_changed():
_redraw_inventory()
func _redraw_inventory():
# 既存のスロットをクリア
for child in get_children():
child.queue_free()
var inventory_data = InventoryManager.get_inventory_data()
for item_id in inventory_data:
var item_data = inventory_data[item_id]
var slot = SLOT_SCENE.instantiate()
slot.update_display(item_data.resource, item_data.count)
# アイテム使用のためのラムダ関数を接続
slot.gui_input.connect(func(event):
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.is_pressed():
InventoryManager.use_item(item_id)
)
add_child(slot)
# 空スロットの描画
var empty_slots_count = InventoryManager.MAX_SLOTS - inventory_data.size()
for i in range(empty_slots_count):
var slot = SLOT_SCENE.instantiate()
slot.set_empty()
add_child(slot)
UI側はアイテムの使用をInventoryManager.use_item(item_id)として依頼するだけで、具体的な処理はロジック層に完全に委任しています。
よくある間違いとベストプラクティス
インベントリ設計で陥りがちな罠と、それを避けるためのベストプラクティスをまとめました。
| よくある間違い | ベストプラクティス |
|---|---|
ロジックとUIの密結合get_node("../Player").add_item() のような直接参照。 | シグナルとシングルトン UIはロジックからシグナルを受け取り、ロジックはシングルトン経由でどこからでも呼べるようにする。 |
| データ構造のハードコーディング アイテムデータをスクリプト内に直接記述する。 | Resourceの活用 アイテムの定義を .tresファイルに分離し、データ駆動設計にする。 |
| 非効率なUI更新 毎フレームUIを再構築する。 | イベント駆動のUI更新inventory_changedシグナルの発火時のみUIを更新する。大規模な場合はオブジェクトプーリングも検討。 |
| 状態管理の分散 プレイヤー、コンテナ、UIがそれぞれ別のインベントリ情報を持つ。 | 単一の情報源 (Single Source of Truth)InventoryManagerシングルトンが唯一のインベントリ状態を保持し、他はそれを参照するだけ。 |
パフォーマンスと代替パターン
-
パフォーマンス: 今回採用した
Dictionaryは、キーによるアクセスが非常に高速(平均O(1))なため、アイテム数が数千個レベルでもロジック層のボトルネックになることは稀です。パフォーマンスの問題は、むしろUI層で大量のノードを生成・破棄する際に発生しがちです。UIの最適化が重要です。 -
代替パターン(Nodeベース): 各アイテムを
Nodeとしてインスタンス化し、インベントリをNodeツリーとして管理する方法もあります。これは、ワールドにドロップされたアイテムとインベントリ内のアイテムを同じオブジェクトとして扱えるメリットがありますが、状態の永続化(セーブ&ロード)が複雑になりがちです。本記事のResourceベースのアプローチは、データと状態の管理に優れています。
まとめ
本記事では、将来の拡張と保守を見据えたインベントリシステムの設計手法を解説しました。Resourceによるデータ層の分離、シングルトンによるロジック層の一元化、そしてシグナルによるビュー層との疎結合。この3つの原則を守ることで、ゲームの規模が拡大しても、アイテムの種類が増えても、UIのデザインが 変わっても、システムの核となるロジックを安定して維持することができます。
| 要素 | Godotの機能 | 役割 |
|---|---|---|
| アイテムデータ | Resource | アイテムの静的な情報を定義・保存 |
| インベントリロジック | シングルトン(Node) | アイテムの追加・削除などの動作を管理 |
| UI連携 | シグナル | ロジックの変更をUIに通知 |