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!」

玩家点击 Iron Sword 上的「Equip」按钮
  → 渲染器检查:已经装备了吗?
    → 没有:触发 "equip-sword" 行为
    → 行为设置 equipped_weapon,通知 AI,显示通知
    → 已经装备:显示信息提示「Already equipped!」

工作原理

背包存储为一个 JSON 变量——一个单独的变量保存着整个物品对象数组。Root Component 读取这个数组来显示格子,并在玩家使用或获取物品时通过 api.setVariable() 直接操作它。

为什么在 Root Component 中处理逻辑? 行为系统的条件运算符(eqneqgtltcontains 等)作用于简单值——数字、字符串、布尔值。它们无法在 JSON 数组内部搜索(例如「数组中是否包含名为 Potion 的对象?」)。对于像背包这样的复杂数据结构,Root Component 是用 JavaScript 处理逻辑的正确场所。

行为仍然用于它们擅长的事情:设置简单变量(equipped_weapon)、注入 AI 指令(「Tell AI」)和显示通知。

分工:

功能位置原因
显示背包格子Root Component读取 JSON 数组并渲染 UI
使用消耗品Root Component需要查找、更新和移除数组元素
装备武器行为设置字符串变量 + 通知 AI
通知 AI 变更行为只有行为能注入 AI 指令

分步教程

第 1 步:创建变量

我们需要 3 个变量——背包(JSON 数组)、生命值(数字)和当前装备的武器(字符串)。

编辑器 → 左侧边栏 → 变量标签页 → 为每个点击「添加变量」

变量 1:Inventory

字段原因
显示名称Inventory供你阅读的标签
IDinventory代码和行为中用于读写这个变量的 ID
类型JSON背包是一个数组——需要 JSON 类型来存储
默认值[{"name":"Potion","icon":"🧪","count":2},{"name":"Iron Sword","icon":"⚔️","count":1}]新会话从 2 瓶药水和 1 把铁剑开始
分类Inventory归入背包分类
行为规则Inventory 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。 字段名和字符串值使用双引号。每个物品对象有三个字段:name(用于匹配和显示)、icon(用于 UI)、count(追踪消耗品的数量)。

变量 2:Hit Points

字段原因
显示名称Hit Points供你阅读的标签
IDhp药水恢复 HP 时使用
类型NumberHP 是数值——需要加减运算
默认值80低于最大值开始,给玩家一个使用药水的理由
最小值0防止 HP 变为负数
最大值100HP 上限 100,防止无限叠加
分类Stats角色属性变量
行为规则Current value represents the player's remaining hit points (0-100). Decrease in combat or dangerous situations, increase when using potions or resting.告诉 AI 何时改变 HP

变量 3:Equipped Weapon

字段原因
显示名称Equipped Weapon供你阅读的标签
IDequipped_weapon记录玩家装备的武器名称
类型String以文本形式存储武器名称
默认值(留空)空字符串 = 未装备武器
分类Custom装备状态变量
行为规则Current 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? 因为玩家同一时间只能持有一把武器。一个简单的字符串就足够了——空意味着未装备,"Iron Sword" 意味着已装备。如果你想要多槽位装备系统(武器 + 护甲 + 饰品),可以改用 JSON 对象。


第 2 步:创建行为

我们需要 2 个行为——装备铁剑(成功和已装备)。药水使用完全在 Root Component 中处理。

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

行为 1:装备铁剑(成功)

WHEN(触发条件):

字段原因
触发类型Action button pressed当 Root Component 调用 executeAction("equip-sword") 时触发
Action IDequip-sword与 Root Component 中的 executeAction("equip-sword") 调用匹配

ONLY IF(条件):

变量运算符原因
equipped_weaponneqIron Sword尚未装备——防止与行为 2 重叠

DO(效果):

按顺序添加这些效果:

效果类型设置作用
修改变量变量 equipped_weapon,操作 set,值 Iron Sword将当前武器设为 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 知道武器的存在
显示通知消息 Equipped Iron Sword!,样式 achievement金色成功弹窗

「Tell AI」做了什么? 它向 AI 的上下文注入一条临时指令。这样当 AI 写下一个回复时,它知道玩家刚装备了一把剑,可以在叙事中反映出来(例如「You tighten your grip on the iron sword. Its cold edge glints in the firelight.」)。

行为 2:装备铁剑(已装备)

WHEN:

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

ONLY IF:

变量运算符原因
equipped_weaponeqIron Sword已经装备——不需要再次装备

DO:

效果类型设置作用
显示通知消息 Iron Sword is already equipped!,样式 info蓝色信息弹窗

为什么拆成两个行为? 与商店教程的模式相同——单个行为只能有一组条件。如果条件通过就执行;如果不通过,什么都不发生。所以我们用两个行为来覆盖两种情况。它们监听同一个 Action ID,但条件互斥——同一时间只有一个会触发。

为什么没有「use-potion」行为? 因为检查 JSON 数组是否包含特定物品需要 JavaScript——行为系统的 contains 运算符只能作用于字符串,不能作用于数组。所以药水逻辑放在 Root Component 中,那里有完整的 JavaScript 能力。Root Component 通过 api.setVariable() 直接更新 inventoryhp 变量。


第 3 步:在 Root Component 中添加背包面板

这一步让背包 UI 出现在聊天中。我们会在最新消息下方显示三个部分:HP 条、装备槽和背包格子(每件物品带操作按钮)。

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

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

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

  // ── 背包逻辑(在 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(); // 复制数组
    if (Number(item.count) <= 1) {
      newInv.splice(idx, 1); // 完全移除
    } else {
      newInv[idx] = { name: item.name, icon: item.icon, count: Number(item.count) - 1 };
    }
    api.setVariable("inventory", newInv);

    // 药水特殊处理:恢复 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); // 触发行为来 set + Tell AI
  }

  // 物品类型映射:决定每件物品获得什么操作
  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>
      {/* 正常渲染消息文本(平台已经渲染了 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",
        }}>

          {/* ====== HP 条 ====== */}
          <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>

          {/* ====== 装备槽 ====== */}
          <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>

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

          {/* ====== 背包格子 ====== */}
          {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
                        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;
  }
  // ... 更新数组并调用 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

编辑器顶部 → 点击「进入 Studio」→ AI 助手面板 → 用自然语言描述你想要的效果,例如「Build an inventory grid with an HP bar, equipment slot, and items that can be used or equipped」——AI 会帮你生成代码。


第 4 步:保存并测试

  1. 点击编辑器顶部的保存
  2. 点击开始游戏或返回主页开始新会话
  3. 你会在 AI 回复下方看到背包面板:HP 80/100,武器槽为空,2 瓶药水和 1 把铁剑
  4. 点击药水上的「Use」——HP 从 80 变为 100,药水消失,提示显示「Used a potion! HP +20」
  5. 点击 Iron Sword 上的「Equip」——武器槽显示「Iron Sword」,按钮变灰显示「Equipped」,弹出「Equipped Iron Sword!」
  6. 再次点击 Iron Sword 上的「Equipped」按钮——提示显示「Iron Sword is already equipped!」
  7. 继续与 AI 聊天——如果你添加了「Tell AI」效果,AI 的回复会反映玩家持有铁剑

如果出了问题:

症状可能原因解决方法
背包面板没有出现Root Component 代码未保存或有语法错误检查 Custom UI 部分底部的编译状态——应该显示绿色的「OK」
背包不显示物品JSON 变量默认值格式错误确保默认值是有效的 JSON 数组,字段名使用双引号
点击按钮没反应行为的 Action ID 与代码不匹配确认行为的 Action 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 → 我的世界创建新世界
  2. 在编辑器中,点击更多操作导入包
  3. 选择下载的 .json 文件
  4. 世界会被创建,所有变量、行为和 Root Component 都已预配置
  5. 开始新会话并试试看

包含内容:

  • 3 个变量(inventory + hp 生命值 + equipped_weapon 当前武器)
  • 2 个行为(装备铁剑成功 + 已装备)
  • 一个 Root Component(HP 条 + 装备槽 + 背包格子 + 操作按钮 + 使用/装备逻辑)

这是教程 #7

之前的教程涵盖了场景跳转、战斗系统、商店界面和角色创建。本教程教你如何使用 Root Component 的 JavaScript 逻辑结合行为的简单状态变更来管理 JSON 数组背包。相同的模式适用于任务日志、技能树、制作配方——任何需要「管理列表,对其元素执行操作」的场景。