【Unreal Engine】インベントリシステムを作る:定義と所持を分けて設計する

作成: 2026-07-20

アイテムの「定義」と「所持状態」を分けるところから、インベントリを組み立てます。Data Assetで定義し、Mapで所持数を持ち、Inventory Componentに切り出し、Event DispatcherでUIへ通知。拾う・持つ・使う・減るが一周する最小のインベントリを作る実践つき。

インベントリを作り始めると、決めることが一度に押し寄せてきます。アイテムの情報をどこに置くか、同じアイテムをどうまとめるか、上限をどう持つか、UIへどう伝えるか。とりあえずプレイヤーに配列を1本持たせて始めると、装備やスタックを足すたびに作り直しになります。

インベントリは、ここまでの記事で出てきた部品が集まってできています。Data AssetMap構造体ComponentEvent Dispatcher。この記事では、それらをどう組み合わせるかという 設計の決め方 から始めて、拾う・持つ・使う・減るが一周する最小のインベントリまでを解説します。

左に3枚のアイテム定義カード、右に所持数の表が並び、線で結ばれているイメージ

この記事でわかること

  • 設計の出発点は 「定義」と「所持状態」を分ける こと
  • 所持の持ち方は Map(定義 → 個数)構造体の配列 。選ぶ基準は2つ
  • Inventory Component に切り出して、どのActorにも挿せる形にする
  • スタック上限と容量上限 をどこで判定するか
  • OnInventoryChanged でUIへ通知し、毎フレームの作り直しをやめる
  • 実践: 拾う・持つ・使う・減るが一周する最小のインベントリ

Sponsored

定義と所持を分ける

最初に決めるのはこれだけです。 アイテムに関する情報を、2つに割ります。

定義(全個体で共通)所持状態(そのプレイヤー固有)
中身表示名、アイコン、最大スタック数、回復量、説明文何を、いくつ持っているか
変わるタイミング開発中の調整だけプレイ中に常に変わる
何個あるかアイテムの種類の数プレイヤーごとに1つ
置き場所Data AssetInventory Component の変数
左に薬草の定義カード1枚(名前・アイコン・MaxStack)、右にプレイヤー3人分の所持数の表があり、3人とも同じ定義カードを指している図

分ける理由は、 定義は1枚で足りるから です。薬草の回復量が20であることは、プレイヤーが3個持っていようが99個持っていようが同じです。所持データの中に回復量をコピーして入れてしまうと、拾った瞬間の値が持ち主ごとに焼き付きます。バランス調整で20を25に変えても、すでに拾われた薬草は20のままです。

所持データが持つべきなのは、 どの定義を指しているか(参照)と、いくつか(数) だけです。

定義はアイコンやメッシュへの参照を含むので、Data Assetが向いています。作り方はData Assetの記事にまとめてあるので、ここでは形だけ示します。

変数名例(薬草)
DisplayNameText薬草
IconTexture 2D(アイコン画像)
MaxStackInteger9
HealAmountFloat20.0

補足: 数値と文字列が中心で、100行を表で見比べたいならData Tableでも構いません。その場合、所持データが指すのは定義アセットではなく 行の名前(Name) になります。以降の設計はそのまま同じです。

所持の持ち方は2通り

所持状態の持ち方には、大きく2つの形があります。 どちらが正しいかではなく、作るゲームがどちらを要求しているか で決まります。

左:Mapは定義カードから個数へ矢印が伸びる形。右:構造体の配列はスロットが並び、各スロットに定義・個数・耐久値が入る形
Map(定義 → 個数)構造体の配列
Map<アイテム定義, Integer>Array<S_ItemSlot>
「薬草を何個持ってる?」Find 一発全要素を走査する
同じアイテムを2枠に分けるできないできる
1本ずつ違う耐久値・強化値持てない持てる
並び順保証されない保証される
向いているゲーム素材・消耗品が中心装備・クラフト・グリッド型のカバン

判断は次の2つの質問で決まります。

  1. 同じアイテムに個体差があるか。 「鉄の剣(耐久72)」と「鉄の剣(耐久13)」を区別したいなら、個数だけでは表せません。構造体の配列です
  2. スロットの位置に意味があるか。 マインクラフト型のように「9番目の枠に置いてある」ことが表示や操作に効くなら、順番を保証できるのは配列だけです

どちらでもないなら Mapが最短 です。所持数の増減が FindAdd の2ノードで書け、「何個持っているか」に一発で答えられます。この記事の実践もMapで組みます。

Mapを選んだときの注意が1つあります。 並び順が保証されない ので、UIに出す順番は別に決める必要があります。定義側に SortOrder のような整数を持たせて Keys の結果を並べ替えるか、表示順のArrayを別に持ってください。

構造体の配列を選ぶ場合は、 取り出した構造体はコピーである という値型の性質に必ずぶつかります。Get して耐久値を減らしても元の配列は変わらないので、Set Array Elem で書き戻します。詳しくは構造体の記事にあります。

Sponsored

Inventory Componentに切り出す

インベントリをプレイヤーのCharacterに直接書くこともできますが、 Component にしておくと後が楽になります 。宝箱、店のNPC、倒したあとに漁れる敵。「中身を持つもの」はプレイヤー以外にも増えるからです。

親クラスは ActorComponent です。位置を持つ必要がないので、SceneComponentではありません(→ Componentの記事)。

BP_InventoryComponentの構造図。中にItems(Map)とMaxCapacityがあり、外へ出ているのはAddItem/RemoveItem/GetCountの受け口とOnInventoryChangedの放送口だけ

BP_InventoryComponent の中身は次のとおりです。

種類名前内容
変数ItemsMap 。キーが BPDA_ItemDefinition(オブジェクト参照)、値が Integer
変数MaxCapacityInteger。合計で持てる個数。 Instance Editable にして個体ごとに変える
関数AddItem(Item, Amount)→ Success受け口 :拾ったときに呼ぶ
関数RemoveItem(Item, Amount)→ Success受け口 :使った・捨てたときに呼ぶ
関数GetCount(Item)→ Integer所持数を返す
関数GetTotalCount()→ Integer全種類の合計個数を返す
Event DispatcherOnInventoryChanged放送口 :中身が変わったことを知らせる

外に出しているのは 受け口(関数)と放送口(Dispatcher)だけ です。Items を直接 Set させないのが要点で、こうしておくと上限のチェックを通さずに中身が書き換わる経路がなくなります。

MaxCapacity を Instance Editable にしておくと、プレイヤーは15個、宝箱は5個、というように配置した個体ごとに変えられます。

追加する:2つの上限をどこで見るか

インベントリで最初に作るのは AddItem です。ここに 2種類の上限 が入ります。混ざりやすいので、意味を分けておきます。

上限どこに書いてあるか判定するもの
スタック上限定義(MaxStackそのアイテム1種類 をいくつまで重ねられるか
容量上限Component(MaxCapacityカバン全体 でいくつまで持てるか

薬草は9個までしか重ならないが、カバン全体では15個まで持てる、という関係です。片方だけだとどちらの制限も表現しきれません。

AddItem関数のノードグラフ。Is Valid、Find、MaxStack判定のBranch、容量判定のBranch、Add、OnInventoryChangedのCallが左から右へ並ぶ
関数: AddItem(入力: Item / BPDA_ItemDefinition, Amount / Integer → 出力: Success / Boolean)

  → Is Valid(Input Object: Item)
      Is Not Valid → Set Success = false → Return

  → Find(Target: Items, Key: Item)
      Value → Promote to local variable(CurrentCount)

  → Branch(Condition: CurrentCount + Amount > Item → Max Stack)
      True → Print String("これ以上重ねられません")→ Set Success = false → Return

  → Branch(Condition: GetTotalCount + Amount > MaxCapacity)
      True → Print String("カバンがいっぱいです")→ Set Success = false → Return

  → Add(Target: Items, Key: Item, Value: CurrentCount + Amount)
  → OnInventoryChanged を Call
  → Set Success = true

3つ、押さえておく点があります。

1つ目は、Find が見つからないときに 0 を返すこと。 Integerの既定値が0なので、「初回かどうか」の分岐を書かずに済みます。CurrentCount + Amount は、1個目でもそのまま正しい値になります。

2つ目は、Mapの Add が上書きだということ。 同じキーで Add すると、要素が増えるのではなく値が置き換わります。 Find で今の数を取り、足して、Add で入れ直す という3手が、Mapで数を増やすときの定型です(→ コンテナの記事)。

3つ目は、判定を追加より先に書くこと。 先に Add してから上限を超えていないか調べる作りにすると、超えた分を戻す処理が必要になります。 入れる前に断る ほうが、状態が壊れる余地がありません。

GetTotalCountValues で個数の配列を取り、For Each Loopで合計します。

関数: GetTotalCount(出力: Total / Integer)

  → Values(Target: Items)
  → For Each Loop
      Loop Body → Set Total = Total + Array Element
      Completed → Return(Total)

この関数は Pure(純粋関数)にしないでおきます 。Pureにすると、出力ピンをつないだ先の数だけ毎回計算が走ります。ループを含む関数では、その回数が読めなくなります。

減らす、使う

RemoveItem は追加の逆ですが、 0になったキーを消す という一手が増えます。

RemoveItem関数のノードグラフ。Findで所持数を確認し、足りなければfalseで抜け、足りていれば減算し、0以下ならRemove、そうでなければAddで書き戻してOnInventoryChangedをCallする
関数: RemoveItem(入力: Item / BPDA_ItemDefinition, Amount / Integer → 出力: Success / Boolean)

  → Find(Target: Items, Key: Item)
      Value → CurrentCount
  → Branch(Condition: CurrentCount < Amount)
      True → Set Success = false → Return        ← 持っていない・足りない

  → Set NewCount = CurrentCount - Amount
  → Branch(Condition: NewCount <= 0)
      True  → Remove(Target: Items, Key: Item)   ← 0個のキーを残さない
      False → Add(Target: Items, Key: Item, Value: NewCount)

  → OnInventoryChanged を Call
  → Set Success = true

0になったキーを Remove しているのは、 Length を「持っている種類の数」として素直に使える ようにするためです。0個のキーが残っていると、UIに空のスロットが出続け、「持っていないのにリストにある」という状態になります。

「使う」は、この RemoveItem の上に薄く乗せます。

関数: UseItem(入力: Item / BPDA_ItemDefinition)

  → RemoveItem(Item: Item, Amount: 1)
      Success → Branch
          True → 効果を適用する(例: HealAmount ぶん回復)
          False → Print String("持っていません")

先に減らして、成功したときだけ効果を出す 順序にします。逆にすると、所持数が0でも回復してしまう作りになりがちです。減らす処理が唯一の「持っているかどうか」の判定を兼ねるので、条件が二重にならずに済みます。

回復や装備といった効果そのものは、インベントリの仕事ではありません。HPを扱う部分は体力とダメージの記事に分けてあります。

Sponsored

変更をUIへ届ける

インベントリのUIで最もやりがちなのが、 Event Tickでスロットを毎フレーム作り直す ことです。所持数が変わっていなくても、毎フレームWidgetを生成して破棄し続けます。

左:Tickで毎フレームスロットを作り直す図。右:OnInventoryChangedが呼ばれた瞬間だけ更新される図

OnInventoryChanged は、そのために置いてあります。 中身が変わった瞬間だけ UIへ届きます。

WBP_Inventory
Event Construct
  → Get Owning Player Pawn
  → Get Component by Class(Component Class: BP_InventoryComponent)
      → Promote to variable(InventoryRef)
  → Bind Event to On Inventory Changed(Target: InventoryRef)
        Event ピン ← Custom Event: RefreshSlots
  → RefreshSlots                       ← 初回は自分で1回呼ぶ

Custom Event: RefreshSlots
  → Clear Children(Target: SlotContainer)
  → Keys(Target: Items)→ For Each Loop
        Loop Body →
          Create Widget(WBP_ItemSlot)
          → Set Slot Data(Definition: Array Element, Count: GetCount(Array Element))
          → Add Child(Target: SlotContainer)

Event Destruct
  → Unbind Event from On Inventory Changed(Target: InventoryRef)

2点だけ補足します。

Constructで1回自分で呼びます。 Dispatcherは「変わったとき」しか飛んでこないので、UIを開いた時点ですでに持っているアイテムは、自分で読みに行かないと表示されません。

Destructで必ずUnbindします。 Widgetのほうが先に消えるのに購読が残っていると、存在しないWidgetへ通知が飛びます。BindとUnbindを対で書く理由はEvent Dispatcherの記事にまとめてあります。

スロットWidgetのレイアウトやアイコンの表示はUMGの記事の範囲です。ここでは Uniform Grid PanelWrap BoxAdd Child していく形だけ押さえておけば足ります。

実践:拾う・持つ・使うを一周させる

RPGの所持品、ローグライクのバックパック、サバイバルゲームのクラフト素材、脱出ゲームの鍵。 拾って、数えて、使って、減る という一周は、どのジャンルでも同じ形になります。ここでは表示を作り込まず、Print Stringで数を見ながら一周を通します。

動かすとこうなる

薬草を9個まで拾えて10個目は断られ、カバンが15個で満杯になり、使うと減って、0になった種類はリストから消えます。

薬草9個・ポーション5個・鍵1個で合計15になり満杯、薬草を使うと8に減り、使い切ると種類が2つになる流れ図

再現条件

Third Person テンプレート で新規プロジェクトを作ります。

アイテム定義の型Data Assetの記事の手順で作成。親クラスは Primary Data Asset

クラス名変数備考
BPDA_ItemDefinitionDisplayNameTextInstance Editable をオン
MaxStackInteger既定値 1
HealAmountFloat既定値 0.0

アイテムのデータ4枚(コンテンツブラウザ右クリック → Miscellaneous > Data Asset → BPDA_ItemDefinition を選択)

アセットDisplayNameMaxStackHealAmount
DA_Herb薬草920.0
DA_Potionポーション550.0
DA_Key10.0
DA_Bomb爆弾30.0

BP_InventoryComponent(親クラス: ActorComponent)

変数名コンテナ初期値
ItemsBPDA_ItemDefinition(オブジェクト参照)→ IntegerMap(空)
MaxCapacityIntegerSingle15 (Instance Editable)

関数は前の節の AddItem / RemoveItem / UseItem / GetCount / GetTotalCount 、Event Dispatcherは OnInventoryChanged を作っておきます。

BP_ThirdPersonCharacter

用意するもの内容
ComponentBP_InventoryComponent を追加(MaxCapacity15
変数 CurrentHealthFloat / 初期値 50.0
変数 MaxHealthFloat / 初期値 100.0

拾う側をつなぐ

確認しやすいように、キー入力で拾う形にします。プロジェクト設定を触らずに済むよう、キーボードイベントをそのまま使います。

キーボードイベントからGet Component by ClassでInventory Componentを取り、AddItemを呼んでSuccessをBranch��で受けるノードグラフ
BP_ThirdPersonCharacter(イベントグラフ)

Keyboard Event: 1 (Pressed)
  → AddItem(Target: BP_InventoryComponent, Item: DA_Herb, Amount: 1)
      Success → Branch
          True → Print String(Append: "薬草: " + GetCount(DA_Herb) を文字列化)

Keyboard Event: 2 (Pressed) → AddItem(Item: DA_Potion, Amount: 1)  ← 同じ形
Keyboard Event: 3 (Pressed) → AddItem(Item: DA_Key,    Amount: 1)
Keyboard Event: 4 (Pressed) → AddItem(Item: DA_Bomb,   Amount: 1)

Keyboard Event: Q (Pressed)
  → UseItem(Target: BP_InventoryComponent, Item: DA_Herb)

Keyboard Event: E (Pressed)
  → Print String(Append: "合計: " + GetTotalCount + " / 種類: " + Length(Items))

UseItem の効果側は、Characterの CurrentHealth を増やします。

UseItem の効果適用(BP_InventoryComponent 内)
  → Get Owner → Cast To BP_ThirdPersonCharacter
  → Set Current Health
      = Clamp(Value: CurrentHealth + Item → Heal Amount, Min: 0.0, Max: MaxHealth)
  → Print String(Append: "HP: " + CurrentHealth)

注意: ここでComponentの中から Cast To BP_ThirdPersonCharacter をしています。動きますが、 このComponentはプレイヤー専用になります 。宝箱にも挿すつもりなら、効果の適用はComponentではなくOwner側に書き、Componentは「使った」と放送するだけにしてください(→ Componentの記事)。まずは動く形を作り、後で分けても構いません。

確認する

Playして、次の順にキーを押してください。

操作期待される表示
1 を9回薬草: 1 から 薬草: 9 まで増える
1 をもう1回(10回目)これ以上重ねられません 。薬草は9のまま
2 を5回ポーション: 1ポーション: 5
3 を1回鍵: 1
E合計: 15 / 種類: 3
4(爆弾)カバンがいっぱいです 。15 + 1 は MaxCapacity を超える
QHP: 70薬草: 8 に減る
Q をもう2回HP: 90HP: 100 (110にはならない)
Q を残り6回(合計9回)薬草が0になり、Q の10回目は 持っていません
E合計: 6 / 種類: 2 。薬草のキーがMapから消えている
4(爆弾)今度は通る。合計が6なので、まだ入る

最後の2行が、この実践でいちばん見ておきたいところです。 満杯だったカバンが、使ったぶんだけまた入るようになる。 上限のチェックが、固定の数ではなく今の合計と比べて行われている証拠です。

うまくいかないときの切り分けです。

  • 薬草が常に1のままFind の結果を足さずに、Add へ直接1を入れている
  • 10個目も入ってしまうMaxStack の判定を >= ではなく > で書く必要がある箇所を取り違えている。CurrentCount + Amount > MaxStack が正しい形
  • 種類: 3 のままで薬草が消えないRemoveItem で0のときに Remove せず、Add で0を入れ直している
  • 合計 が減らないGetTotalCountValues ではなく Keys の数を数えている
  • 満杯なのに拾えてしまうMaxCapacity が Instance Editable で、レベルに置いた個体側の値が既定の0や大きい値のままになっている
  • UIが更新されないAddItem / RemoveItem の最後で OnInventoryChanged を Call し忘れている。Print Stringの数字は正しいのにUIだけ古い、という症状になる
  • キーを押しても何も起きない → Characterが操作対象になっていない。GameModeの Default Pawn Class を確認する(→ Print Stringとログの記事

ポイントは2つです。

  • 上限のチェックは、入れる前に全部済ませる: AddItem は「断るか、入れて放送するか」のどちらかしかしません。途中まで入れて後から戻す経路を作らなければ、所持数が壊れる余地そのものがなくなります。捨てる・売る・クラフトで消費する、と入口が増えても、通り道は AddItemRemoveItem の2本のままです
  • 所持データは、定義への参照と個数だけに保つ: 表示名も回復量も、定義アセットを見れば分かります。所持側にコピーしないでおくと、バランス調整がData Assetの数値を書き換えるだけで終わります

次は、この所持品を次回の起動にも残す段階です。 Data Assetへの参照はそのまま保存できない ので、定義側にIDを持たせてIDの配列として書き出し、ロード時に引き直します。手順はセーブ/ロードの記事へ、スロットの見た目とドラッグ操作はUMGの記事へ続きます。

Sponsored

おまけ:先に知っておくと良いこと

  • 拾う対象をワールドに置くなら: 床に落ちているアイテムは、BPDA_ItemDefinition への参照を Instance Editable で持つ BP_ItemPickup を1つ作れば足ります。Data Assetを差し替えるだけで薬草にも鍵にもなるので、アイテムの種類ぶんBlueprintを作る必要はありません。手が届く範囲だけ拾いたいなら、Line Traceの記事の当て方がそのまま使えます
  • 重量制にするのも同じ形: MaxCapacity を個数ではなく重さにして、定義側に Weight を持たせ、GetTotalCount を「重さの合計」に変えるだけです。判定を1箇所にまとめてあると、こういう仕様変更が1関数の書き換えで済みます
  • 所持品をレベルをまたいで持たせたいなら: Characterに挿したComponentは、レベル遷移で作り直されます。ステージをまたいで持ち越すなら、所持データをGame Instanceへ預けるか、遷移の前後で書き出して読み戻してください
  • Data Assetをキーにするときの注意: Mapのキーにできるのは、ハッシュ化できる型です。オブジェクト参照は使えますが、 同じアイテムを指す別のアセット を作ってしまうと別のキーになります。DA_Herb を複製して DA_Herb2 を作り、両方が流通している状態にしないでください
  • 装備は別の仕組みで持つ: 「装備中の武器」は所持品の一部ですが、 1つしか選べない という別のルールを持ちます。インベントリのMapとは分けて、EquippedWeapon という単独の変数を用意するほうが素直です。所持品から装備へ移すときに、インベントリ側から減らすかどうかもゲーム次第で決めてください

まとめ

  • 設計の出発点は 定義(Data Asset)と所持状態(Componentの変数)を分ける こと。所持側に定義の値をコピーしない
  • 所持の持ち方は 個体差があるか、並び順に意味があるか で決まる。どちらでもなければ Map(定義 → 個数) が最短
  • インベントリは ActorComponent に切り出す。外に出すのは 受け口(関数)と放送口(Dispatcher)だけ
  • 上限は2種類。 スタック上限は定義に、容量上限はComponentに 。判定は入れる前に全部済ませる
  • Mapで数を増やす形は Find → 加算 → Add 。0になったキーは Remove して残さない
  • UIの更新は OnInventoryChanged で。Constructで1回自分で呼び、DestructでUnbindする

いま作っているゲームで、プレイヤーが持ち歩くものに個体差はありますか。その答えだけで、Mapか構造体の配列かが決まります。