Skip to content

商店与交易

构建一个商店 UI——玩家浏览物品,点击购买,金币自动扣除,物品直接进入背包。本教程展示如何将变量、行为和 Root Component 组合成一个完整的交易系统。


你将构建什么

一个嵌入在聊天界面中的商店面板。玩家可以看到自己有多少金币、有哪些商品在售、每件商品的价格。当他们点击「购买」按钮时:

  • 金币自动减去物品价格
  • 物品被添加到背包(一个 JSON 数组)
  • 弹出「购买成功!」通知
  • 如果金币不足,弹出「金币不够!」警告——不扣金币,不添加物品

底部还有一个背包格子,实时显示玩家包里的所有物品。

玩家点击「购买药水(20 金币)」
  → 行为检查:gold >= 20?
    → 是:gold 减 20,inventory push "Potion",显示成功通知
    → 否:显示「金币不够!」警告

工作原理

这个商店系统结合了三个核心机制:

  1. 数字变量 + 条件检查 — 金币是一个数字变量。行为在执行前检查是否足够。
  2. JSON 变量 + push 操作 — 背包是一个 JSON 数组。每次购买使用 push 向数组中添加一个物品。
  3. 动作触发器 — 每个购买按钮对应一个 Action ID。Root Component 中的按钮调用 executeAction() 来触发行为。

完整流程:

Root Component 按钮 UI
  → 玩家点击「购买药水」
  → 调用 api.executeAction("buy-potion")
  → 引擎找到 Action ID 为 "buy-potion" 的行为
  → 检查条件:gold >= 20?
    → 通过 → 执行动作:修改变量(gold -20),修改变量(inventory push "Potion"),显示通知
    → 失败 → 什么都不做(「金币不够」的提示由另一个行为处理)

分步教程

第 1 步:创建变量

我们需要两个变量——一个追踪金币,一个追踪背包里有什么。

编辑器 → 侧边栏 → 变量标签页 → 点击添加变量

变量 1:Gold

字段原因
名称Gold在编辑器中供你自己参考
IDgold在代码和行为中用于读写这个变量
类型Number金币是数值——需要算术运算
默认值100新会话中玩家从 100 金币开始
最小值0防止金币变为负数——引擎会进行限制
分类Resources金币是资源类变量
行为规则Gold 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

字段原因
名称Inventory供你自己参考
IDinventory在代码和行为中使用
类型JSON背包是一个数组——需要 JSON 类型来存储
默认值[]空数组——新会话中背包从空开始
分类Inventory这是背包类变量
行为规则Items 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 步:创建商店行为

我们需要多个行为——每个物品有一个「购买成功」和一个「金币不足」的行为。这里以药水和铁剑为例。

编辑器 → 行为标签页 → 点击添加行为

行为 1:购买药水(成功)

WHEN(触发条件):

字段原因
触发类型Action button pressed当 Root Component 调用 executeAction("buy-potion") 时触发
Action IDbuy-potion必须与 Root Component 代码中的 executeAction("buy-potion") 调用匹配

ONLY IF(前提条件):

变量运算符原因
gold大于等于 (gte)20药水花费 20 金币——只有金币够才能购买

DO(执行动作):

按顺序添加以下动作:

动作类型设置效果
修改变量变量 gold,操作 subtract,值 20扣除 20 金币
修改变量变量 inventory,操作 push,值 "Potion"将「Potion」添加到背包数组
显示通知消息 Purchase successful! You got a Potion.,样式 achievement显示金色的成功通知

push 操作专门用于 JSON 数组。 它在数组末尾追加一个元素,不会覆盖已有内容。所以每次购买药水,都会在背包中多加一个 "Potion" 字符串。

行为 2:购买药水(金币不足)

这个行为监听相同的 Action ID,但条件是「金币不够」。

WHEN:

字段
触发类型Action button pressed
Action IDbuy-potion

ONLY IF:

变量运算符原因
gold小于 (lt)20金币少于 20——买不起

DO:

动作类型设置效果
显示通知消息 Not enough gold! The potion costs 20 gold.,样式 warning显示黄色警告通知

为什么需要两个独立的行为? 因为单个行为只能有一组条件。如果条件通过,动作就执行;如果失败,什么都不发生。所以我们用两个行为来覆盖两种情况:金币够 → 购买成功;金币不够 → 显示警告。它们监听同一个 Action ID,但条件互斥,所以同一时间只有一个会触发。

行为 3:购买铁剑(成功)

WHEN:

字段
触发类型Action button pressed
Action IDbuy-sword

ONLY IF:

变量运算符
gold大于等于 (gte)50

DO:

动作类型设置效果
修改变量变量 gold,操作 subtract,值 50扣除 50 金币
修改变量变量 inventory,操作 push,值 "Iron Sword"将「Iron Sword」添加到背包数组
显示通知消息 Purchase successful! You got an Iron Sword.,样式 achievement显示金色的成功通知

行为 4:购买铁剑(金币不足)

WHEN:

字段
触发类型Action button pressed
Action IDbuy-sword

ONLY IF:

变量运算符
gold小于 (lt)50

DO:

动作类型设置效果
显示通知消息 Not enough gold! The iron sword costs 50 gold.,样式 warning显示黄色警告通知

想添加更多物品?

只需重复这个模式——每个物品两个行为(成功 + 不足),更改 Action ID、价格和物品名称。例如,要添加一个 30 金币的「Shield」:Action ID buy-shield,条件 gold gte 30,动作 subtract 30 + push "Shield"


第 3 步:在 Root Component 中添加商店面板

这是让商店 UI 出现在聊天中的关键步骤。我们会在每条消息下方显示三个区域:金币余额、物品列表(带购买按钮)和背包格子。

编辑器 → Custom UI 部分 → 打开 index.tsx → 粘贴以下代码(替换默认的 return <Chat />):

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

  // 读取变量
  const gold = Number(api.variables.gold ?? 100);
  const inventory = Array.isArray(api.variables.inventory)
    ? api.variables.inventory
    : [];

  // 商店物品定义
  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>
      {/* 正常渲染消息文本(平台已经渲染了 HTML,直接使用 contentHtml) */}
      <div
        style={{ color: "#e2e8f0", lineHeight: 1.7 }}
        dangerouslySetInnerHTML={{ __html: msg.contentHtml }}
      />

      {/* 只在最后一条消息下方显示商店 */}
      {isLastMsg && (
        <div style={{
          marginTop: "16px",
          padding: "16px",
          background: "rgba(15, 23, 42, 0.6)",
          borderRadius: "12px",
          border: "1px solid #334155",
        }}>

          {/* ====== 金币显示 ====== */}
          <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>

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

          {/* ====== 物品列表 ====== */}
          <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>

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

          {/* ====== 背包格子 ====== */}
          {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" },
];

所有物品信息定义在一个数组中,然后用 .map() 渲染。想添加新物品?只需在数组中加一行——当然,还需要在编辑器中创建对应的行为。

购买按钮

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

这是最重要的一行。点击按钮调用 api.executeAction("buy-potion"),引擎找到 Action ID 为 "buy-potion" 的行为,检查条件,执行动作。所有逻辑(检查金币、扣除金币、添加物品、显示通知)都定义在行为中——按钮只是触发它们。

按钮视觉反馈

tsx
background: gold >= item.price
  ? "linear-gradient(135deg, #065f46, #047857)"   // 买得起 → 绿色
  : "linear-gradient(135deg, #374151, #4b5563)",   // 买不起 → 灰色
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={{ /* 格子样式 */ }}>
      <span>{item === "Potion" ? "\u{1F9EA}" : item === "Iron Sword" ? "⚔️" : "📦"}</span>
      <span>{String(item)}</span>
    </div>
  ))}
</div>

使用 CSS Grid 布局背包物品。auto-fill + minmax(80px, 1fr) 让格子自适应可用宽度——窗口越宽每行显示越多,窗口越窄每行显示越少。每个格子显示物品的图标和名称。

不想写代码?使用 Studio AI

在编辑器顶部,点击进入 Studio → AI 助手面板 → 描述你想要的效果,例如「Build a shop UI with gold display, item list, and inventory grid」——AI 会帮你生成代码。


第 4 步:保存并测试

  1. 点击编辑器顶部的保存
  2. 点击开始游戏或返回主页打开新会话
  3. 你会在 AI 回复下方看到商店面板:100 金币,两件物品,背包为空
  4. 点击 20 Gold 购买药水——金币降到 80,背包中出现药水图标,金色通知显示「Purchase successful! You got a Potion.」
  5. 再次点击——金币降到 60,现在背包里有两瓶药水
  6. 点击 50 Gold 购买铁剑——金币降到 10,背包多了一把剑
  7. 现在试试再买任何东西——黄色警告弹出「Not enough gold!」,金币和背包保持不变
  8. 继续与 AI 聊天——商店面板一直在最新消息底部,实时更新

如果出了问题:

症状可能原因解决方法
商店面板没有出现Root Component 代码未保存或有语法错误检查 Custom UI 部分底部的编译状态——应该显示绿色的「OK」
按钮点击没反应行为中的 Action ID 与代码不匹配确认行为的 Action ID 是 buy-potion / buy-sword,与代码中 executeAction() 的参数完全一致
金币扣了但背包没变行为中的 push 动作设置不正确检查修改变量动作:变量应该是 inventory,操作应该是 push,值应该是 "Potion"(带引号)
金币不足但没有警告「金币不足」行为的条件写反了确认条件是 gold lt 20(小于),不是 gold gte 20
背包物品不显示图标物品名称与代码中的图标映射不匹配确认行为的 push 值与代码的图标映射一致("Potion" 映射到试管 emoji,等等)
购买后金币显示没更新正常——它会在下一条消息时刷新发送一条消息再检查,或者检查通知是否出现(如果出现了,说明购买成功了)

进一步:扩展商店系统

掌握了基础之后,你可以用相同的模式构建更复杂的系统。

添加更多物品

在 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" },
];

然后在编辑器的行为标签页中,为每个新物品创建两个行为(成功 + 不足),遵循与药水和铁剑完全相同的模式。

让 AI 知道玩家买了什么

如果你想让 AI 的故事对购买做出反应(例如购买铁剑后 AI 知道玩家有武器了),在购买成功的行为中添加一个「Tell AI」动作:

动作类型设置
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
购买时扣除金币行为动作:修改变量,操作 subtract
购买时添加物品行为动作:修改变量,操作 push
检查玩家是否买得起行为条件:gold gte price
显示「金币不够」警告单独的行为,条件 gold lt price,动作:显示通知(warning)
显示「购买成功」提示行为动作:显示通知(achievement 样式)
按钮触发购买在 Root Component 中调用 api.executeAction("actionId")
显示背包格子在 Root Component 中使用 CSS Grid + inventory.map() 渲染
添加更多物品在 shopItems 数组中加一行 + 在编辑器中创建两个行为

试试看——可导入的演示世界

下载这个 JSON 文件并导入,体验完整的商店系统:

recipe-3-demo.json

如何导入:

  1. 前往 Yumina → 我的世界创建新世界
  2. 在编辑器中,点击更多操作导入包
  3. 选择下载的 .json 文件
  4. 新世界会被创建,所有变量、行为和 Root Component 都已预配置
  5. 开始新会话并试试看

包含内容:

  • 2 个变量(gold + inventory
  • 4 个行为(药水购买成功/不足 + 铁剑购买成功/不足)
  • 一个 Root Component(金币显示 + 物品列表 + 背包格子)

这是教程 #3

之前的教程涵盖了场景跳转和条目修改。本教程展示了如何将变量条件检查 + JSON 数组 + 行为动作组合成一个交互系统。相同的模式适用于任务系统、战斗系统、制作系统——任何需要「检查条件 → 扣除资源 → 添加物品 → 给出反馈」的场景。