商店与交易
构建一个商店 UI——玩家浏览物品,点击购买,金币自动扣除,物品直接进入背包。本教程展示如何将变量、行为和 Root Component 组合成一个完整的交易系统。
你将构建什么
一个嵌入在聊天界面中的商店面板。玩家可以看到自己有多少金币、有哪些商品在售、每件商品的价格。当他们点击「购买」按钮时:
- 金币自动减去物品价格
- 物品被添加到背包(一个 JSON 数组)
- 弹出「购买成功!」通知
- 如果金币不足,弹出「金币不够!」警告——不扣金币,不添加物品
底部还有一个背包格子,实时显示玩家包里的所有物品。
玩家点击「购买药水(20 金币)」
→ 行为检查:gold >= 20?
→ 是:gold 减 20,inventory push "Potion",显示成功通知
→ 否:显示「金币不够!」警告工作原理
这个商店系统结合了三个核心机制:
- 数字变量 + 条件检查 — 金币是一个数字变量。行为在执行前检查是否足够。
- JSON 变量 + push 操作 — 背包是一个 JSON 数组。每次购买使用
push向数组中添加一个物品。 - 动作触发器 — 每个购买按钮对应一个 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 | 在编辑器中供你自己参考 |
| ID | gold | 在代码和行为中用于读写这个变量 |
| 类型 | 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 | 供你自己参考 |
| ID | inventory | 在代码和行为中使用 |
| 类型 | 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 ID | buy-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 ID | buy-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 ID | buy-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 ID | buy-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 />):
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>
);
}} />
);
}代码详解
不要被代码长度吓到——它做的事情非常简单。让我们逐部分讲解:
基础设置
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中
读取变量
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()确认它确实是数组,防止意外数据类型
商店物品定义
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() 渲染。想添加新物品?只需在数组中加一行——当然,还需要在编辑器中创建对应的行为。
购买按钮
<button onClick={() => api.executeAction(item.actionId)}>
{item.price} Gold
</button>这是最重要的一行。点击按钮调用 api.executeAction("buy-potion"),引擎找到 Action ID 为 "buy-potion" 的行为,检查条件,执行动作。所有逻辑(检查金币、扣除金币、添加物品、显示通知)都定义在行为中——按钮只是触发它们。
按钮视觉反馈
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,按钮的颜色、鼠标样式和透明度会根据玩家是否买得起动态变化。买得起的物品按钮是绿色的;买不起的是灰色的。这纯粹是视觉反馈——实际的购买逻辑在行为条件中。
背包格子
<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 步:保存并测试
- 点击编辑器顶部的保存
- 点击开始游戏或返回主页打开新会话
- 你会在 AI 回复下方看到商店面板:100 金币,两件物品,背包为空
- 点击 20 Gold 购买药水——金币降到 80,背包中出现药水图标,金色通知显示「Purchase successful! You got a Potion.」
- 再次点击——金币降到 60,现在背包里有两瓶药水
- 点击 50 Gold 购买铁剑——金币降到 10,背包多了一把剑
- 现在试试再买任何东西——黄色警告弹出「Not enough gold!」,金币和背包保持不变
- 继续与 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 数组中添加一行:
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 文件并导入,体验完整的商店系统:
如何导入:
- 前往 Yumina → 我的世界 → 创建新世界
- 在编辑器中,点击更多操作 → 导入包
- 选择下载的
.json文件 - 新世界会被创建,所有变量、行为和 Root Component 都已预配置
- 开始新会话并试试看
包含内容:
- 2 个变量(
gold+inventory) - 4 个行为(药水购买成功/不足 + 铁剑购买成功/不足)
- 一个 Root Component(金币显示 + 物品列表 + 背包格子)
这是教程 #3
之前的教程涵盖了场景跳转和条目修改。本教程展示了如何将变量条件检查 + JSON 数组 + 行为动作组合成一个交互系统。相同的模式适用于任务系统、战斗系统、制作系统——任何需要「检查条件 → 扣除资源 → 添加物品 → 给出反馈」的场景。
