Skip to content

ショップとトレード

ショップUIを構築しましょう — プレイヤーがアイテムを閲覧、クリックして購入、ゴールドが自動的に差し引かれ、アイテムがそのままインベントリに入ります。このレシピでは、変数、ビヘイビア、Root Componentを完全なトレーディングシステムに組み合わせる方法を示します。


これから作るもの

チャットインターフェースに組み込まれたショップパネル。プレイヤーは自分のゴールド量、販売中のアイテム、各アイテムの価格を見られます。「Buy」ボタンをクリックすると:

  • ゴールドがアイテムの価格分自動的に減る
  • アイテムがインベントリに追加(JSON配列)
  • 「Purchase successful!」通知がポップアップ
  • ゴールドが不足している場合、「Not enough gold!」警告が表示 — ゴールドは差し引かれず、アイテムも追加されない

下にはプレイヤーのバッグ内のすべてのアイテムをリアルタイムで表示するインベントリグリッドもあります。

プレイヤーが「Buy Potion (20 gold)」をクリック
  → ビヘイビアがチェック:gold >= 20?
    → はい:gold が 20 減る、inventory に "Potion" を push、成功通知を表示
    → いいえ:「Not enough gold!」警告を表示

仕組み

このショップシステムは3つのコアメカニズムを組み合わせます:

  1. 数値変数 + 条件チェック — ゴールドは数値変数。ビヘイビアが実行前に十分かチェック。
  2. JSON変数 + push操作 — インベントリはJSON配列。各購入でpushを使ってアイテムを追加。
  3. アクショントリガー — 各購入ボタンはアクションIDに対応。Root Component内のボタンがexecuteAction()を呼んでビヘイビアをトリガー。

完全な流れ:

Root Component ボタン UI
  → プレイヤーが「Buy Potion」をクリック
  → api.executeAction("buy-potion") を呼ぶ
  → エンジンが action ID "buy-potion" を持つビヘイビアを見つける
  → 条件をチェック:gold >= 20?
    → パス → アクション実行:変数変更(gold -20)、変数変更(inventory に "Potion" push)、通知表示
    → 失敗 → 何もしない(「ゴールド不足」メッセージは別のビヘイビアで処理)

ステップバイステップ

ステップ1:変数の作成

2つの変数が必要です — 1つはゴールドを追跡、1つはインベントリの内容を追跡。

エディタ → サイドバー → Variables タブ → Add Variable をクリック

変数1:Gold

フィールド理由
NameGoldエディタ内で自分用の識別
IDgoldコードとビヘイビアでこの変数を読み書きするのに使用
TypeNumberゴールドは数値 — 算術操作が必要
Default Value100新規セッションでプレイヤーは100ゴールドで開始
Min Value0ゴールドがマイナスにならないよう防ぐ — エンジンがクランプする
CategoryResourcesゴールドはリソースタイプ変数
Behavior RulesGold is automatically deducted when the player buys items from the shop. You may also increase or decrease gold in the story — e.g., quest rewards, getting robbed by thieves, or finding a treasure chest.ショップだけでなくストーリー中もゴールドが変化できることをAIに伝える

なぜ最小値を0に設定するのか? ビヘイビアの条件で「プレイヤーが買えるか?」をすでにチェックしますが、エンジンレベルの保護を追加するのは安全です。何かがすり抜けても、ゴールドはマイナスになりません。

変数2:Inventory

フィールド理由
NameInventory自分用の識別名
IDinventoryコードとビヘイビアで使用
TypeJSONインベントリは配列 — 保存にJSONタイプが必要
Default Value[]空の配列 — 新規セッションでインベントリは空で開始
CategoryInventoryこれはインベントリタイプ変数
Behavior RulesItems are automatically added when bought from the shop. You may also add or remove items in the story — e.g., the player picks something up, an item breaks, gets stolen, or is received as a quest reward.ショップだけでなくストーリー中もインベントリが変化できることをAIに伝える

JSON変数は任意のJSONデータ構造を保存できます。 ここでは配列([])を使ってアイテム名のリストを保持します。各購入でpushを使って配列の末尾に文字列を追加。例えば、ポーション購入後の値は[]から["Potion"]になり、その後アイアンソードを買うと["Potion", "Iron Sword"]になります。


ステップ2:ショップビヘイビアを作成

複数のビヘイビアが必要です — 各アイテムに「購入成功」と「ゴールド不足」のビヘイビア。ここではポーションとアイアンソードを例にします。

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

ビヘイビア1:Buy Potion(成功)

WHEN(トリガー):

フィールド理由
Trigger TypeAction button pressedRoot ComponentがexecuteAction("buy-potion")を呼ぶと発火
Action IDbuy-potionRoot Componentコード内のexecuteAction("buy-potion")呼び出しと一致する必要がある

ONLY IF(条件):

変数演算子理由
goldGreater than or equal (gte)20ポーションは20ゴールド — 十分にあるときだけ買える

DO(アクション):

以下のアクションを順番に追加:

Action Type設定効果
Modify Variable変数gold、オペレーションsubtract、値2020ゴールド差し引く
Modify Variable変数inventory、オペレーションpush、値"Potion"インベントリ配列に「Potion」を追加
Show NotificationメッセージPurchase successful! You got a Potion.、スタイルachievement金色の成功通知を表示

push操作はJSON配列専用です。 既存の内容を上書きせずに配列の末尾に要素を追加します。そのため、ポーションを買うたびに、別の"Potion"文字列がインベントリに追加されます。

ビヘイビア2:Buy Potion(ゴールド不足)

このビヘイビアは同じアクションIDをリッスンしますが、条件は「ゴールドが足りない」です。

WHEN:

フィールド
Trigger TypeAction button pressed
Action IDbuy-potion

ONLY IF:

変数演算子理由
goldLess than (lt)20ゴールドが20未満 — 買えない

DO:

Action Type設定効果
Show NotificationメッセージNot enough gold! The potion costs 20 gold.、スタイルwarning黄色の警告通知を表示

なぜ2つの別々のビヘイビアか? 1つのビヘイビアは1セットの条件しか持てないからです。条件が通ればアクションが実行、失敗すれば何も起きない。そのため2つのビヘイビアを使って両方のケースをカバーします:十分なゴールド → 購入成功;不足 → 警告表示。同じアクションIDをリッスンしますが、条件は相互排他的、どちらか一方だけ発火します。

ビヘイビア3:Buy Iron Sword(成功)

WHEN:

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

ONLY IF:

変数演算子
goldGreater than or equal (gte)50

DO:

Action Type設定効果
Modify Variable変数gold、オペレーションsubtract、値5050ゴールド差し引く
Modify Variable変数inventory、オペレーションpush、値"Iron Sword"インベントリ配列に「Iron Sword」を追加
Show NotificationメッセージPurchase successful! You got an Iron Sword.、スタイルachievement金色の成功通知を表示

ビヘイビア4:Buy Iron Sword(ゴールド不足)

WHEN:

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

ONLY IF:

変数演算子
goldLess than (lt)50

DO:

Action Type設定効果
Show NotificationメッセージNot enough gold! The iron sword costs 50 gold.、スタイルwarning黄色の警告通知を表示

もっとアイテムを追加したい?

パターンを繰り返すだけ — アイテムごとに2つのビヘイビア(成功 + 不足)、アクションID、価格、アイテム名を変更。例えば、30ゴールドの「Shield」を追加するには:アクションIDbuy-shield、条件gold gte 30、アクションsubtract 30 + push "Shield"


ステップ3:Root Componentにショップパネルを追加

これがチャット内にショップUIを表示させる重要なステップです。各メッセージの下に3つのエリアを表示:ゴールド残高、アイテムリスト(購入ボタン付き)、インベントリグリッド。

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

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

  // Read variables
  const gold = Number(api.variables.gold ?? 100);
  const inventory = Array.isArray(api.variables.inventory)
    ? api.variables.inventory
    : [];

  // Shop item definitions
  const shopItems = [
    { name: "Potion",     price: 20, actionId: "buy-potion", icon: "\u{1F9EA}", desc: "Restores a small amount of health" },
    { name: "Iron Sword", price: 50, actionId: "buy-sword",  icon: "⚔️", desc: "A plain iron sword" },
  ];

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

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

          {/* ====== Gold display ====== */}
          <div style={{
            display: "flex",
            alignItems: "center",
            gap: "8px",
            marginBottom: "16px",
            padding: "10px 14px",
            background: "linear-gradient(135deg, #78350f, #92400e)",
            borderRadius: "8px",
            border: "1px solid #b45309",
          }}>
            <span style={{ fontSize: "20px" }}>{"💰"}</span>
            <span style={{ color: "#fde68a", fontSize: "16px", fontWeight: "bold" }}>
              {gold} Gold
            </span>
          </div>

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

          {/* ====== Item list ====== */}
          <div style={{ display: "flex", flexDirection: "column", gap: "8px", marginBottom: "16px" }}>
            {shopItems.map((item) => (
              <div
                key={item.actionId}
                style={{
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "space-between",
                  padding: "10px 14px",
                  background: "rgba(30, 41, 59, 0.8)",
                  borderRadius: "8px",
                  border: "1px solid #475569",
                }}
              >
                <div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
                  <span style={{ fontSize: "22px" }}>{item.icon}</span>
                  <div>
                    <div style={{ color: "#e2e8f0", fontSize: "14px", fontWeight: "600" }}>
                      {item.name}
                    </div>
                    <div style={{ color: "#64748b", fontSize: "12px" }}>
                      {item.desc}
                    </div>
                  </div>
                </div>
                <button
                  onClick={() => api.executeAction(item.actionId)}
                  style={{
                    padding: "6px 16px",
                    background: gold >= item.price
                      ? "linear-gradient(135deg, #065f46, #047857)"
                      : "linear-gradient(135deg, #374151, #4b5563)",
                    border: gold >= item.price
                      ? "1px solid #10b981"
                      : "1px solid #6b7280",
                    borderRadius: "6px",
                    color: gold >= item.price ? "#a7f3d0" : "#9ca3af",
                    fontSize: "13px",
                    fontWeight: "600",
                    cursor: gold >= item.price ? "pointer" : "not-allowed",
                    opacity: gold >= item.price ? 1 : 0.6,
                    whiteSpace: "nowrap",
                  }}
                >
                  {item.price} Gold
                </button>
              </div>
            ))}
          </div>

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

          {/* ====== Inventory grid ====== */}
          {inventory.length === 0 ? (
            <div style={{
              padding: "20px",
              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(80px, 1fr))",
              gap: "8px",
            }}>
              {inventory.map((item, idx) => (
                <div
                  key={idx}
                  style={{
                    display: "flex",
                    flexDirection: "column",
                    alignItems: "center",
                    justifyContent: "center",
                    padding: "10px 6px",
                    background: "rgba(30, 41, 59, 0.8)",
                    borderRadius: "8px",
                    border: "1px solid #475569",
                    gap: "4px",
                  }}
                >
                  <span style={{ fontSize: "24px" }}>
                    {item === "Potion" ? "\u{1F9EA}" : item === "Iron Sword" ? "⚔️" : "📦"}
                  </span>
                  <span style={{ color: "#cbd5e1", fontSize: "11px", textAlign: "center" }}>
                    {String(item)}
                  </span>
                </div>
              ))}
            </div>
          )}
        </div>
      )}
    </div>
      );
    }} />
  );
}

コードウォークスルー

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

基本セットアップ

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

変数の読み取り

tsx
const gold = Number(api.variables.gold ?? 100);
const inventory = Array.isArray(api.variables.inventory)
  ? api.variables.inventory
  : [];
  • api.variables.gold — ゴールド変数を読む。?? 100は変数がまだロードされていない場合のフォールバック
  • api.variables.inventory — インベントリ変数を読む。Array.isArray()を使って実際に配列かを確認、予期しないデータから保護

ショップアイテム定義

tsx
const shopItems = [
  { name: "Potion",     price: 20, actionId: "buy-potion", icon: "\u{1F9EA}", desc: "Restores a small amount of health" },
  { name: "Iron Sword", price: 50, actionId: "buy-sword",  icon: "⚔️", desc: "A plain iron sword" },
];

すべてのアイテム情報を1つの配列に定義し、.map()でレンダリング。新しいアイテムを追加したい?配列に行を追加するだけ — そしてもちろん、エディタで対応するビヘイビアも作成。

購入ボタン

tsx
<button onClick={() => api.executeAction(item.actionId)}>
  {item.price} Gold
</button>

これが最も重要な行です。ボタンをクリックするとapi.executeAction("buy-potion")を呼び、エンジンがアクションID "buy-potion"を持つビヘイビアを見つけ、条件をチェックし、アクションを実行します。すべてのロジック(ゴールドチェック、差し引き、アイテム追加、通知表示)はビヘイビアで定義されています — ボタンはそれらをトリガーするだけです。

ボタンの視覚的フィードバック

tsx
background: gold >= item.price
  ? "linear-gradient(135deg, #065f46, #047857)"   // affordable → green
  : "linear-gradient(135deg, #374151, #4b5563)",   // can't afford → gray
cursor: gold >= item.price ? "pointer" : "not-allowed",
opacity: gold >= item.price ? 1 : 0.6,

ボタンの色、カーソルスタイル、不透明度がプレイヤーがアイテムを買えるかに応じて動的に変わります。買えるアイテムは緑のボタン;買えないものはグレーアウト。これは純粋な視覚的フィードバック — 実際の購入ロジックはビヘイビアの条件にあります。

インベントリグリッド

tsx
<div style={{
  display: "grid",
  gridTemplateColumns: "repeat(auto-fill, minmax(80px, 1fr))",
  gap: "8px",
}}>
  {inventory.map((item, idx) => (
    <div key={idx} style={{ /* cell styles */ }}>
      <span>{item === "Potion" ? "\u{1F9EA}" : item === "Iron Sword" ? "⚔️" : "📦"}</span>
      <span>{String(item)}</span>
    </div>
  ))}
</div>

CSS Gridを使ってインベントリアイテムをレイアウト。auto-fill + minmax(80px, 1fr)がセルを利用可能な幅に適応 — 幅広いウィンドウは行ごとにより多くのアイテム、狭いウィンドウは少ない。各セルがアイテムのアイコンと名前を表示。

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

エディタ上部 → Enter Studio をクリック → AI Assistantパネル → 欲しいものを説明、例えば「ゴールド表示、アイテムリスト、インベントリグリッドを持つショップUIを構築」 — AIがコードを生成します。


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

  1. エディタ上部のSaveをクリック
  2. Start Gameをクリックするか、ホームページに戻って新規セッションを開く
  3. AIの返信の下にショップパネルが見える:100ゴールド、2つのアイテム、空のインベントリ
  4. 20 Goldをクリックしてポーションを購入 — ゴールドが80に、ポーションアイコンがインベントリに表示、金色の通知に「Purchase successful! You got a Potion.」
  5. もう一度クリック — ゴールドが60に、今度はインベントリに2つのポーション
  6. 50 Goldをクリックしてアイアンソードを購入 — ゴールドが10に、インベントリに剣を獲得
  7. 今何かを買おうとする — 黄色の警告が「Not enough gold!」、ゴールドとインベントリは変わらず
  8. AIとチャットを続ける — ショップパネルは最新メッセージの下に留まり、リアルタイムで更新される

うまく動かない場合:

症状想定される原因対処
ショップパネルが表示されないRoot Componentコードが保存されていない、または構文エラーCustom UIセクション下部のコンパイル状態を確認 — 緑の「OK」が表示されるべき
ボタンがクリックに応答しないビヘイビアのアクションIDがコードと一致しないビヘイビアアクションIDがbuy-potion / buy-swordで、コード内のexecuteAction()引数と正確に一致することを確認
ゴールドは引かれたがインベントリが変わらないビヘイビアのpushアクションが正しく設定されていないmodify variableアクションを確認:変数はinventory、オペレーションはpush、値は"Potion"(クォート付き)であるべき
ゴールド不足だが警告が表示されない「ゴールド不足」ビヘイビアの条件が反転条件がgold lt 20(less than)で、gold gte 20ではないことを確認
インベントリアイテムにアイコンが表示されないアイテム名がコードのアイコンマッピングと一致しないビヘイビアのpush値がコードのアイコンマッピングと一致することを確認("Potion"は試験管絵文字にマップなど)
購入後ゴールド表示が更新されない正常 — 次のメッセージでリフレッシュメッセージを送って再確認、または通知が表示されたか確認(表示されたなら購入成功)

さらに進める:ショップシステムの拡張

基本が分かったら、同じパターンを使ってより複雑なシステムを構築できます。

アイテムを追加

Root ComponentのshopItems配列に行を追加:

tsx
const shopItems = [
  { name: "Potion",       price: 20, actionId: "buy-potion", icon: "\u{1F9EA}", desc: "Restores a small amount of health" },
  { name: "Iron Sword",   price: 50, actionId: "buy-sword",  icon: "⚔️", desc: "A plain iron sword" },
  { name: "Shield",       price: 30, actionId: "buy-shield",  icon: "🛡️", desc: "Provides basic protection" },
  { name: "Magic Scroll", price: 80, actionId: "buy-scroll", icon: "📜", desc: "Unleashes a fireball spell" },
];

そしてエディタのBehaviorsタブで、新しいアイテムごとに2つのビヘイビア(成功 + 不足)を作成、ポーションとアイアンソードと同じパターンに従う。

プレイヤーが何を買ったかAIに知らせる

AIのストーリーが購入に反応するようにしたい場合(例:アイアンソード購入後、AIはプレイヤーが武装していることを知る)、購入成功ビヘイビアに「Tell AI」アクションを追加:

Action Type設定
Tell AI内容:The player just bought an Iron Sword at the shop. Please reference this weapon in subsequent replies where appropriate.

これによりAIのコンテキストに一時的な指示が注入され、何が起きたか知らせます。

ゴールドを稼ぐ

今プレイヤーはゴールドを使うことしかできず、稼ぐことはできません。ビヘイビアを使ってプレイヤーにゴールドを与えられます:

  • ターンごとの報酬:トリガー「Every N turns」(例:3ターンごと)でビヘイビアを作成、アクションはModify Variable gold add 10。プレイヤーは3会話ラウンドごとに自動的に10ゴールドを稼ぐ。
  • キーワード報酬:トリガー「AI said keyword」で「battle won」または「quest complete」のキーワードを使用。AIが返信でこれらの単語を言及すると、ゴールドが自動的に追加。
  • 手動稼ぎボタン:Root Componentに「Work for Gold」ボタンを追加、executeAction("earn-gold")でビヘイビアをトリガー、アクションはgold add 15

クイックリファレンス

やりたいこと方法
ゴールドを追跡数値変数を作成、カテゴリ:Resources
インベントリを追跡JSON変数を作成、デフォルト[]、カテゴリ:Inventory
購入時にゴールド差し引きビヘイビアアクション:Modify Variable、オペレーションsubtract
購入時にアイテム追加ビヘイビアアクション:Modify Variable、オペレーションpush
プレイヤーが買えるかチェックビヘイビア条件:gold gte price
「ゴールド不足」警告を表示別のビヘイビア、条件gold lt price、アクション:Show Notification(warning)
「購入成功」アラートを表示ビヘイビアアクション:Show Notification(achievementスタイル)
ボタンが購入をトリガーRoot Componentでapi.executeAction("actionId")を呼ぶ
インベントリグリッドを表示Root ComponentでCSS Grid + inventory.map()を使用してレンダリング
アイテムを追加shopItems配列に行を追加 + エディタで2つのビヘイビアを作成

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

このJSONファイルをダウンロードし、インポートして完全なショップシステムを体験してください:

recipe-3-demo.json

インポート方法:

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

含まれるもの:

  • 2つの変数(gold + inventory
  • 4つのビヘイビア(ポーション購入成功/不足 + アイアンソード購入成功/不足)
  • Root Component(ゴールド表示 + アイテムリスト + インベントリグリッド)

これはRecipe #3です

以前のレシピではシーンジャンプとエントリ変更をカバーしました。このレシピでは、変数条件チェック + JSON配列 + ビヘイビアアクションをインタラクティブシステムに組み合わせる方法を示します。同じパターンが、クエストシステム、戦闘システム、クラフトシステムなど、「条件チェック → リソース差し引き → アイテム追加 → フィードバックを与える」を必要とするあらゆるものに使えます。