背包与装备
构建一个背包格子——显示玩家收集的每件物品,带图标和数量。消耗品可以使用(耗尽时消失),装备可以穿戴。本教程展示如何将 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 中处理逻辑? 行为系统的条件运算符(eq、neq、gt、lt、contains 等)作用于简单值——数字、字符串、布尔值。它们无法在 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 | 供你阅读的标签 |
| ID | inventory | 代码和行为中用于读写这个变量的 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 | 供你阅读的标签 |
| ID | hp | 药水恢复 HP 时使用 |
| 类型 | Number | HP 是数值——需要加减运算 |
| 默认值 | 80 | 低于最大值开始,给玩家一个使用药水的理由 |
| 最小值 | 0 | 防止 HP 变为负数 |
| 最大值 | 100 | HP 上限 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 | 供你阅读的标签 |
| ID | equipped_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 ID | equip-sword | 与 Root Component 中的 executeAction("equip-sword") 调用匹配 |
ONLY IF(条件):
| 变量 | 运算符 | 值 | 原因 |
|---|---|---|---|
equipped_weapon | neq | Iron 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 ID | equip-sword |
ONLY IF:
| 变量 | 运算符 | 值 | 原因 |
|---|---|---|---|
equipped_weapon | eq | Iron Sword | 已经装备——不需要再次装备 |
DO:
| 效果类型 | 设置 | 作用 |
|---|---|---|
| 显示通知 | 消息 Iron Sword is already equipped!,样式 info | 蓝色信息弹窗 |
为什么拆成两个行为? 与商店教程的模式相同——单个行为只能有一组条件。如果条件通过就执行;如果不通过,什么都不发生。所以我们用两个行为来覆盖两种情况。它们监听同一个 Action ID,但条件互斥——同一时间只有一个会触发。
为什么没有「use-potion」行为? 因为检查 JSON 数组是否包含特定物品需要 JavaScript——行为系统的
contains运算符只能作用于字符串,不能作用于数组。所以药水逻辑放在 Root Component 中,那里有完整的 JavaScript 能力。Root Component 通过api.setVariable()直接更新inventory和hp变量。
第 3 步:在 Root Component 中添加背包面板
这一步让背包 UI 出现在聊天中。我们会在最新消息下方显示三个部分:HP 条、装备槽和背包格子(每件物品带操作按钮)。
编辑器 → Custom UI 部分 → 打开 index.tsx → 粘贴以下内容(替换默认的 return <Chat />):
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>
);
}} />
);
}代码详解
不要被代码长度吓到——它做的事情非常简单。让我们逐部分讲解:
基础设置
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
读取变量
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()防止意外类型
背包逻辑函数
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 中直接处理逻辑:
- 查找物品 — 遍历数组,按
name匹配 - 检查是否存在 — 如果没找到,显示错误提示
- 更新数组 — 减少数量或完全移除
- 写回 — 调用
api.setVariable("inventory", newInv)持久化变更
对于装备,equipItem() 委托给 api.executeAction(),因为行为负责设置变量和注入 AI 指令:
function equipItem(itemName, actionId) {
if (equippedWeapon === itemName) {
api.showToast(itemName + " is already equipped!", "info");
return;
}
api.executeAction(actionId);
}物品类型映射
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 中添加逻辑。对于装备,在编辑器中创建匹配的行为。
操作按钮
<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 步:保存并测试
- 点击编辑器顶部的保存
- 点击开始游戏或返回主页开始新会话
- 你会在 AI 回复下方看到背包面板:HP 80/100,武器槽为空,2 瓶药水和 1 把铁剑
- 点击药水上的「Use」——HP 从 80 变为 100,药水消失,提示显示「Used a potion! HP +20」
- 点击 Iron Sword 上的「Equip」——武器槽显示「Iron Sword」,按钮变灰显示「Equipped」,弹出「Equipped Iron Sword!」
- 再次点击 Iron Sword 上的「Equipped」按钮——提示显示「Iron Sword is already equipped!」
- 继续与 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 并作为新世界导入,查看完整效果:
如何导入:
- 前往 Yumina → 我的世界 → 创建新世界
- 在编辑器中,点击更多操作 → 导入包
- 选择下载的
.json文件 - 世界会被创建,所有变量、行为和 Root Component 都已预配置
- 开始新会话并试试看
包含内容:
- 3 个变量(
inventory+hp生命值 +equipped_weapon当前武器) - 2 个行为(装备铁剑成功 + 已装备)
- 一个 Root Component(HP 条 + 装备槽 + 背包格子 + 操作按钮 + 使用/装备逻辑)
这是教程 #7
之前的教程涵盖了场景跳转、战斗系统、商店界面和角色创建。本教程教你如何使用 Root Component 的 JavaScript 逻辑结合行为的简单状态变更来管理 JSON 数组背包。相同的模式适用于任务日志、技能树、制作配方——任何需要「管理列表,对其元素执行操作」的场景。
