Skip to content

インベントリと装備

インベントリグリッドを構築しましょう — プレイヤーが集めた全アイテムをアイコンと数量付きで表示します。消耗品は使用可能(消耗品が尽きると消える)、装備品は身につけることができます。このレシピでは、JSON変数、Root Componentロジック、ビヘイビアを組み合わせて完全なインベントリシステムを構築する方法を示します。


これから作るもの

チャットインターフェースに組み込まれたインベントリパネル。プレイヤーはすべてのアイテムを見ることができ、各アイテムにアイコン、名前、数量が表示されます。各アイテムの下にアクションボタンがあります:

  • 消耗品(例:ポーション)— 「Use」をクリック → HPが20回復 → ポーション数が1減る → 数が0になったらインベントリから削除 → ポップアップに「Used a potion! HP +20」
  • 装備品(例:アイアンソード)— 「Equip」をクリック → 武器スロットに「Iron Sword」が表示 → AIがプレイヤーがアイアンソードを装備していることを知る → ポップアップに「Equipped Iron Sword!」
プレイヤーがポーションの「Use」ボタンをクリック
  → レンダラーがチェック:インベントリにポーションは含まれているか?
    → はい:インベントリ配列を更新、hp +20、成功トースト
    → いいえ:警告トースト「No potions left!」

プレイヤーがアイアンソードの「Equip」ボタンをクリック
  → レンダラーがチェック:すでに装備済みか?
    → いいえ:「equip-sword」ビヘイビアをトリガー
    → ビヘイビアがequipped_weaponを設定、AIに伝え、通知を表示
    → はい:情報トースト「Already equipped!」

仕組み

インベントリはJSON変数として保存されます — アイテムオブジェクトの配列全体を保持する単一の変数です。Root Componentがこの配列を読んでグリッドを表示し、プレイヤーがアイテムを使ったり取得したりするときapi.setVariable()を使って直接操作します。

なぜRoot Componentでロジックを処理するのか? ビヘイビアシステムの条件演算子(eqneqgtltcontainsなど)は単純な値 — 数値、文字列、boolean — で動作します。JSON配列の中を検索することはできません(例:「配列にname = Potionのオブジェクトが含まれているか?」)。インベントリのような複雑なデータ構造には、JavaScriptを使ってロジックを処理するRoot Componentが正しい場所です。

ビヘイビアは引き続き得意なことに使います:単純な変数の設定(equipped_weapon)、AI指示の注入(「Tell AI」)、通知の表示です。

分担:

何をどこで理由
インベントリグリッドを表示Root ComponentJSON配列を読み取りUIをレンダリング
消耗品を使うRoot Component配列要素を探し、更新し、削除する必要がある
武器を装備ビヘイビア文字列変数を設定 + AIに伝える
変更をAIに伝えるビヘイビアビヘイビアのみがAI指示を注入できる

ステップバイステップ

ステップ1:変数の作成

3つの変数が必要です — インベントリ(JSON配列)、ヒットポイント(数値)、現在装備中の武器(文字列)。

エディタ → 左サイドバー → Variables タブ → 各々「Add Variable」をクリック

変数1:Inventory

フィールド理由
Display NameInventory自分用の人間可読ラベル
IDinventoryコードとビヘイビアがこの変数を読み書きするときに使うID
TypeJSONインベントリは配列 — JSONタイプが必要
Default Value[{"name":"Potion","icon":"🧪","count":2},{"name":"Iron Sword","icon":"⚔️","count":1}]新規セッションはポーション2個とアイアンソード1個でスタート
CategoryInventoryInventoryカテゴリにグループ化
Behavior RulesInventory buttons handle use and equip actions automatically. You may also add items during the story (player finds loot, receives a reward) or remove items (broken, lost, stolen).ストーリー中もインベントリが変化できることをAIに伝える

JSON変数のデフォルト値は有効なJSONでなければなりません。 フィールド名と文字列値にはダブルクォートを使用。各アイテムオブジェクトには3つのフィールドがあります:name(マッチングと表示用)、icon(UI用)、count(消耗品の数量追跡用)。

変数2:Hit Points

フィールド理由
Display NameHit Points人間可読ラベル
IDhpポーションがHPを回復するときに使用
TypeNumberHPは数値 — 加減算が必要
Default Value80最大未満から始まることで、プレイヤーにポーションを使う理由を与える
Min Value0HPがマイナスにならないよう防ぐ
Max Value100HPの上限100、無限スタックを防ぐ
CategoryStatsキャラクターステータス変数
Behavior RulesCurrent value represents the player's remaining hit points (0-100). Decrease in combat or dangerous situations, increase when using potions or resting.いつHPを変更するかAIに伝える

変数3:Equipped Weapon

フィールド理由
Display NameEquipped Weapon人間可読ラベル
IDequipped_weaponプレイヤーの装備武器名を記録
TypeString武器名をテキストとして保存
Default Value(空のまま)空文字列 = 武器未装備
CategoryCustom装備状態変数
Behavior RulesCurrent value is the name of the player's equipped weapon. Empty string means nothing equipped. The equip button sets this automatically, but you may also change it during the story — e.g. weapon breaks, gets stolen, or player finds a new one.物語的にも装備状態が変化できることをAIに伝える

なぜequipped_weaponにJSONではなく文字列を使うのか? プレイヤーは一度に1つの武器しか振るえないからです。単純な文字列で十分 — 空は未装備、"Iron Sword"は装備中。マルチスロット装備システム(武器 + 防具 + アクセサリ)が欲しい場合はJSONオブジェクトを使うこともできます。


ステップ2:ビヘイビアの作成

2つのビヘイビアが必要です — アイアンソードを装備(成功と装備済み)。ポーションの使用は完全にRoot Componentで処理されます。

エディタ → Behaviors タブ → 「Add Behavior」をクリック

ビヘイビア1:Equip Iron Sword(成功)

WHEN(トリガー):

フィールド理由
Trigger TypeAction button pressedRoot ComponentがexecuteAction("equip-sword")を呼ぶと発火
Action IDequip-swordRoot Component内のexecuteAction("equip-sword")呼び出しと一致

ONLY IF(条件):

変数演算子理由
equipped_weaponneqIron Swordまだ装備していない — ビヘイビア2との重複を防ぐ

DO(エフェクト):

これらのエフェクトを順番に追加:

Effect Type設定動作
Modify Variable変数equipped_weapon、オペレーションset、値Iron Sword現在の武器をアイアンソードに設定
Tell AI内容:The player equipped an Iron Sword. From now on, the player is wielding an iron longsword. Reflect the weapon's presence in combat descriptions and interactions.AIが武器について知るよう指示を注入
Show NotificationメッセージEquipped Iron Sword!、スタイルachievement金色の成功ポップアップ

「Tell AI」は何をする? AIのコンテキストに一時的な指示を注入します。これにより、AIが次の応答を書くときに、プレイヤーがちょうど剣を装備したことを知り、それをナラティブに反映できます(例:「You tighten your grip on the iron sword. Its cold edge glints in the firelight.」)。

ビヘイビア2:Equip Iron Sword(既に装備済み)

WHEN:

フィールド
Trigger TypeAction button pressed
Action IDequip-sword

ONLY IF:

変数演算子理由
equipped_weaponeqIron Sword既に装備済み — 再装備不要

DO:

Effect Type設定動作
Show NotificationメッセージIron Sword is already equipped!、スタイルinfo青の情報ポップアップ

なぜこれを2つのビヘイビアに分割するのか? ショップレシピと同じパターン — 1つのビヘイビアは1セットの条件しか持てません。条件が通れば実行、通らなければ何も起きません。そのため、2つのケースをカバーするために2つのビヘイビアを使います。同じアクションIDをリッスンしますが、条件は相互排他的 — どちらか一方だけが発火します。

なぜ「use-potion」ビヘイビアがないのか? JSON配列に特定のアイテムが含まれているかチェックするにはJavaScriptが必要だからです — ビヘイビアシステムのcontains演算子は文字列でのみ動作し、配列では動作しません。そのため、ポーションロジックはJavaScriptへの完全なアクセスを持つRoot Componentに存在します。Root Componentがapi.setVariable()経由でinventoryhp変数を直接更新します。


ステップ3:Root Componentにインベントリパネルを追加

これがチャット内にインベントリUIを表示させるステップです。最新メッセージの下に3つのセクションを表示します:HPバー、装備スロット、インベントリグリッド(各アイテムにアクションボタン付き)。

エディタ → Custom UI セクション → index.tsxを開く → 以下を貼り付け(デフォルトのreturn <Chat />を置き換え):

tsx
export default function MyWorld() {
  var api = useYumina();
  var msgs = api.messages || [];

  // Read variables
  var hp = Number(api.variables.hp ?? 80);
  var equippedWeapon = String(api.variables.equipped_weapon || "");
  var inventory = Array.isArray(api.variables.inventory)
    ? api.variables.inventory
    : [];

  // ── Inventory logic (runs in the Root Component) ──

  function useItem(itemName) {
    var inv = Array.isArray(api.variables.inventory)
      ? api.variables.inventory
      : [];
    var idx = -1;
    for (var i = 0; i < inv.length; i++) {
      if (inv[i] && inv[i].name === itemName) { idx = i; break; }
    }
    if (idx === -1) {
      api.showToast("No " + itemName + " left!", "error");
      return;
    }
    var item = inv[idx];
    var newInv = inv.slice(); // copy the array
    if (Number(item.count) <= 1) {
      newInv.splice(idx, 1); // remove entirely
    } else {
      newInv[idx] = { name: item.name, icon: item.icon, count: Number(item.count) - 1 };
    }
    api.setVariable("inventory", newInv);

    // Potion-specific: restore HP
    if (itemName === "Potion") {
      var currentHp = Number(api.variables.hp ?? 0);
      api.setVariable("hp", Math.min(currentHp + 20, 100));
      api.showToast("Used a potion! HP +20", "success");
    }
  }

  function equipItem(itemName, actionId) {
    if (equippedWeapon === itemName) {
      api.showToast(itemName + " is already equipped!", "info");
      return;
    }
    api.executeAction(actionId); // triggers the behavior for set + Tell AI
  }

  // Item type map: decides what action each item gets
  var itemActions = {
    "Potion": { type: "consumable", handler: function() { useItem("Potion"); }, label: "Use" },
    "Iron Sword": { type: "equipment", handler: function() { equipItem("Iron Sword", "equip-sword"); }, label: "Equip" },
  };

  return (
    <Chat renderBubble={(msg) => {
      var isLastMsg = msg.messageIndex === msgs.length - 1;
      return (
    <div>
      {/* Render message text normally (platform already rendered HTML, use contentHtml directly) */}
      <div
        style={{ color: "#e2e8f0", lineHeight: 1.7 }}
        dangerouslySetInnerHTML={{ __html: msg.contentHtml }}
      />

      {/* Show inventory panel only below the last message */}
      {isLastMsg && (
        <div style={{
          marginTop: "16px",
          padding: "16px",
          background: "rgba(15, 23, 42, 0.6)",
          borderRadius: "12px",
          border: "1px solid #334155",
        }}>

          {/* ====== HP Bar ====== */}
          <div style={{
            display: "flex",
            alignItems: "center",
            gap: "10px",
            marginBottom: "14px",
          }}>
            <span style={{ fontSize: "16px" }}>❤️</span>
            <div style={{ flex: 1 }}>
              <div style={{
                display: "flex",
                justifyContent: "space-between",
                marginBottom: "4px",
              }}>
                <span style={{ color: "#94a3b8", fontSize: "12px" }}>HP</span>
                <span style={{ color: "#e2e8f0", fontSize: "12px", fontWeight: "bold" }}>
                  {hp} / 100
                </span>
              </div>
              <div style={{
                height: "8px",
                background: "#1e293b",
                borderRadius: "4px",
                overflow: "hidden",
              }}>
                <div style={{
                  height: "100%",
                  width: Math.min(hp, 100) + "%",
                  background: hp > 50
                    ? "linear-gradient(90deg, #22c55e, #4ade80)"
                    : hp > 20
                      ? "linear-gradient(90deg, #eab308, #facc15)"
                      : "linear-gradient(90deg, #ef4444, #f87171)",
                  borderRadius: "4px",
                  transition: "width 0.3s ease",
                }} />
              </div>
            </div>
          </div>

          {/* ====== Equipment Slot ====== */}
          <div style={{
            display: "flex",
            alignItems: "center",
            gap: "8px",
            marginBottom: "14px",
            padding: "10px 14px",
            background: "rgba(30, 41, 59, 0.8)",
            borderRadius: "8px",
            border: "1px solid #475569",
          }}>
            <span style={{ fontSize: "16px" }}>⚔️</span>
            <span style={{ color: "#94a3b8", fontSize: "13px" }}>Weapon:</span>
            <span style={{
              color: equippedWeapon ? "#e2e8f0" : "#475569",
              fontSize: "13px",
              fontWeight: equippedWeapon ? "600" : "normal",
              fontStyle: equippedWeapon ? "normal" : "italic",
            }}>
              {equippedWeapon || "None"}
            </span>
          </div>

          {/* ====== Inventory Header ====== */}
          <div style={{
            fontSize: "14px",
            fontWeight: "bold",
            color: "#94a3b8",
            marginBottom: "10px",
            textTransform: "uppercase",
            letterSpacing: "1px",
          }}>
            Inventory
          </div>

          {/* ====== Inventory Grid ====== */}
          {inventory.length === 0 ? (
            <div style={{
              padding: "24px",
              textAlign: "center",
              color: "#475569",
              fontSize: "13px",
              background: "rgba(30, 41, 59, 0.4)",
              borderRadius: "8px",
              border: "1px dashed #334155",
            }}>
              Inventory is empty
            </div>
          ) : (
            <div style={{
              display: "grid",
              gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
              gap: "8px",
            }}>
              {inventory.map(function(item, idx) {
                var name = String(item?.name || item);
                var icon = String(item?.icon || "📦");
                var count = Number(item?.count ?? 1);
                var action = itemActions[name];

                return (
                  <div
                    key={idx}
                    style={{
                      display: "flex",
                      flexDirection: "column",
                      alignItems: "center",
                      padding: "12px 8px 8px",
                      background: "rgba(30, 41, 59, 0.8)",
                      borderRadius: "8px",
                      border: equippedWeapon === name
                        ? "1px solid #22d3ee"
                        : "1px solid #475569",
                      gap: "6px",
                    }}
                  >
                    <span style={{ fontSize: "28px" }}>{icon}</span>
                    <span style={{
                      color: "#e2e8f0",
                      fontSize: "12px",
                      fontWeight: "600",
                      textAlign: "center",
                    }}>
                      {name}
                    </span>
                    <span style={{
                      color: "#64748b",
                      fontSize: "11px",
                    }}>
                      x{count}
                    </span>

                    {/* Action button */}
                    {action && (
                      <button
                        onClick={action.handler}
                        style={{
                          marginTop: "4px",
                          padding: "4px 14px",
                          background: action.type === "consumable"
                            ? "linear-gradient(135deg, #065f46, #047857)"
                            : equippedWeapon === name
                              ? "linear-gradient(135deg, #374151, #4b5563)"
                              : "linear-gradient(135deg, #1e3a5f, #1e40af)",
                          border: action.type === "consumable"
                            ? "1px solid #10b981"
                            : equippedWeapon === name
                              ? "1px solid #6b7280"
                              : "1px solid #3b82f6",
                          borderRadius: "6px",
                          color: action.type === "consumable"
                            ? "#a7f3d0"
                            : equippedWeapon === name
                              ? "#9ca3af"
                              : "#bfdbfe",
                          fontSize: "12px",
                          fontWeight: "600",
                          cursor: "pointer",
                          width: "100%",
                        }}
                      >
                        {equippedWeapon === name ? "Equipped" : action.label}
                      </button>
                    )}
                  </div>
                );
              })}
            </div>
          )}
        </div>
      )}
    </div>
      );
    }} />
  );
}

コードウォークスルー

長さに圧倒されないでください — 行うことは非常に簡単です。セクションごとに見ていきましょう:

基本セットアップ

tsx
var api = useYumina();
var msgs = api.messages || [];
// ...
<Chat renderBubble={(msg) => {
  var isLastMsg = msg.messageIndex === msgs.length - 1;
  // ...
}} />
  • Root Component MyWorld()はワールドUIのエントリ。<Chat renderBubble={...} />はメッセージリスト、入力ボックス、スクロールをプラットフォームに任せ — 各バブルの見た目だけ引き継ぐ
  • useYumina() — Yumina APIを取得して変数を読み、アクションをトリガーできる
  • msg.messageIndex — 現在のバブルのメッセージリスト内のインデックス。インベントリパネルは最後のメッセージの下にのみレンダリングされるため、各メッセージで繰り返さない
  • msg.contentHtml — プラットフォームがMarkdownからレンダリングしたHTML、dangerouslySetInnerHTMLに直接渡せる

変数の読み取り

tsx
var hp = Number(api.variables.hp ?? 80);
var equippedWeapon = String(api.variables.equipped_weapon || "");
var inventory = Array.isArray(api.variables.inventory)
  ? api.variables.inventory
  : [];
  • api.variables.hp — ヒットポイントを読み取る。?? 80は変数がまだロードされていない場合のフォールバック
  • api.variables.equipped_weapon — 現在の武器を読み取る。空文字列は何も装備していないことを意味する
  • api.variables.inventory — インベントリを読み取る。Array.isArray()は予期しない型から保護

インベントリロジック関数

tsx
function useItem(itemName) {
  var inv = Array.isArray(api.variables.inventory)
    ? api.variables.inventory : [];
  var idx = -1;
  for (var i = 0; i < inv.length; i++) {
    if (inv[i] && inv[i].name === itemName) { idx = i; break; }
  }
  if (idx === -1) {
    api.showToast("No " + itemName + " left!", "error");
    return;
  }
  // ... update array and call api.setVariable()
}

これが重要なパターンです。ビヘイビアシステムの条件演算子がJSON配列の中を検索できないため、ロジックをここRoot Componentで処理します:

  1. アイテムを見つける — 配列をループしnameで一致を確認
  2. 存在を確認 — 見つからない場合エラートーストを表示
  3. 配列を更新 — カウントを減らすか完全に削除
  4. 書き戻すapi.setVariable("inventory", newInv)を呼んで変更を永続化

装備の場合、equipItem()api.executeAction()に委任します。ビヘイビアが変数設定とAI指示の注入を処理するからです:

tsx
function equipItem(itemName, actionId) {
  if (equippedWeapon === itemName) {
    api.showToast(itemName + " is already equipped!", "info");
    return;
  }
  api.executeAction(actionId);
}

アイテムタイプマップ

tsx
var itemActions = {
  "Potion": { type: "consumable", handler: function() { useItem("Potion"); }, label: "Use" },
  "Iron Sword": { type: "equipment", handler: function() { equipItem("Iron Sword", "equip-sword"); }, label: "Equip" },
};

ルックアップテーブルです。アイテム名を与えると、ボタンラベルと呼び出すハンドラ関数を教えてくれます。typeフィールドがボタン色を制御 — 消耗品は緑、装備品は青。新しいアイテムを追加したい?ここに行を追加。消耗品の場合はuseItemにロジックを追加。装備品の場合はエディタで対応するビヘイビアを作成。

アクションボタン

tsx
<button onClick={action.handler}>
  {equippedWeapon === name ? "Equipped" : action.label}
</button>

ボタンをクリックするとハンドラ関数を直接呼びます。消耗品の場合、ハンドラがJavaScriptで配列を管理。装備品の場合、ハンドラがapi.executeAction()を呼んで対応するビヘイビアをトリガー。

自分でコードを書きたくない?Studio AIを使おう

エディタ上部 → 「Enter Studio」をクリック → AI Assistantパネル → 平易な言葉で説明、例:「HPバー、装備スロット、使用または装備可能なアイテムを持つインベントリグリッドを構築」と説明すれば、AIがコードを生成します。


ステップ4:保存してテスト

  1. エディタ上部のSaveをクリック
  2. Start Gameをクリックするか、ホームページに戻って新規セッションを開始
  3. AIの応答の下にインベントリパネルが見える:HP 80/100、武器スロット空、ポーション2個とアイアンソード1個
  4. ポーションの「Use」をクリック — HPが80から100に、ポーションが消える、トーストに「Used a potion! HP +20」
  5. アイアンソードの「Equip」をクリック — 武器スロットに「Iron Sword」が表示、ボタンがグレーになり「Equipped」と表示、ポップアップに「Equipped Iron Sword!」
  6. アイアンソードの「Equipped」ボタンを再びクリック — トーストに「Iron Sword is already equipped!」
  7. AIとチャットを続ける — 「Tell AI」エフェクトを追加していれば、AIの応答にプレイヤーがアイアンソードを振るっていることが反映される

何かが機能していない場合:

症状想定される原因対処
インベントリパネルが表示されないRoot Componentコードが保存されていない、または構文エラーCustom UIセクション下部のコンパイル状態を確認 — 緑の「OK」が表示されるべき
インベントリにアイテムが表示されないJSON変数のデフォルト値の書式が不正デフォルトがダブルクォートされたフィールド名を持つ有効なJSON配列であることを確認
クリックしてもボタンが反応しないビヘイビアアクションIDがコードと一致しないビヘイビアのアクションIDが正確にequip-swordで、コード内のexecuteAction()引数と一致することを確認
ポーションを使ったが消えないuseItem関数がアイテム名を見つけられないJSONのnameフィールドがuseItem()が探すものと完全に一致することを確認 — 大文字小文字を区別
HPが変わらないapi.setVariable呼び出しが正しい変数に届いていない変数IDが正確にhpで、変数定義と一致することを確認
装備したがAIが知らない「Tell AI」エフェクトが欠落装備ビヘイビアのDOセクションに「Tell AI」エフェクトを追加

AIがインベントリを変更する方法

AIもストーリー中にディレクティブを使ってアイテムを追加または削除できます。インベントリはJSON変数なので、AIはpushディレクティブを使ってアイテムを追加できます:

You defeated the goblin and found a health potion among its belongings.
[inventory: push {"name":"Potion","icon":"🧪","count":1}]

配列に対するAIディレクティブの制限

pushディレクティブはアイテムの追加にうまく動作します。ただし、配列に対するdeleteは数値インデックスでのみ動作し(例:[inventory: delete 0]は最初の要素を削除)、mergeはプレーンオブジェクトでのみ動作し配列では動作しません。複雑なインベントリ操作(名前で特定アイテムを削除、アイテム数を更新)には、Root ComponentのJavaScriptロジックを使うか、AIが他の変数を介して意図を伝え、ビヘイビアがそれに基づいて動作するようにシステムを設計してください。


クイックリファレンス

やりたいこと方法
アイテムリストを保存JSON変数を作成、デフォルト値[{...}, ...]
インベントリグリッドを表示Root ComponentでCSS Grid + inventory.map()を使用
消耗品を使うRoot Component:アイテムを探す → 配列を更新 → api.setVariable() → トースト表示
アイテムを装備Root Component:api.executeAction()を呼ぶ → ビヘイビア:変数設定 + Tell AI
プレイヤーがアイテムを持っているか確認Root Component:inventory.find(i => i.name === "ItemName")
アイテムを追加(AI)AIディレクティブ:[inventory: push {"name":"Item","icon":"📦","count":1}]
現在の装備を追跡文字列変数を作成 — 空文字列 = 何も装備していない
ボタンで使用/装備をトリガーRoot Componentでハンドラ関数またはapi.executeAction("actionId")を呼ぶ
変更をAIに伝えるビヘイビアに「Tell AI」エフェクトを追加

自分で試す — インポート可能なデモワールド

このJSONをダウンロードし、新規ワールドとしてインポートしてすべての動作を確認してください:

recipe-7-demo.json

インポート方法:

  1. Yumina → My WorldsCreate New World に移動
  2. エディタで More ActionsImport Package をクリック
  3. ダウンロードした.jsonファイルを選択
  4. 全変数、ビヘイビア、Root Componentが事前設定されたワールドが作成されます
  5. 新規セッションを開始して試す

含まれるもの:

  • 3つの変数(inventory + hp ヒットポイント + equipped_weapon 現在の武器)
  • 2つのビヘイビア(アイアンソード装備成功 + 装備済み)
  • Root Component(HPバー + 装備スロット + インベントリグリッド + アクションボタン + 使用/装備ロジック)

これはRecipe #7です

これまでのレシピでは、シーンジャンプ、戦闘システム、ショップインターフェース、キャラクター作成をカバーしました。このレシピでは、Root ComponentのJavaScriptロジックとビヘイビアを組み合わせて単純な状態変更でJSON配列インベントリを管理する方法を教えます。同じパターンが、クエストログ、スキルツリー、クラフトレシピなど、「リストを管理しその要素に対する操作を実行する」あらゆるものに使えます。