成就系统
构建一个完整的成就系统 -- 当玩家达到特定里程碑(金币超过 100、5 次以上战斗胜利、发现隐藏区域……)时,屏幕上弹出金色成就通知。使用布尔变量跟踪哪些成就已解锁,使用 Root Component 显示成就面板。
你将构建什么
一个嵌入聊天中的成就系统:
- 金色弹出通知 -- 玩家达到里程碑的瞬间,屏幕上出现金色成就提示(使用
achievement样式),例如「Achievement Unlocked: Big Spender」 - 自动检测 -- 引擎在后台监控变量变化,条件满足时自动触发,不需要玩家操作
- 只触发一次 -- 每个成就只解锁一次,不会再次弹出。
maxFireCount和布尔变量提供双重保险 - 成就面板 -- 最后一条消息下方的迷你面板列出所有成就及其解锁状态(已解锁 = 金色图标,未解锁 = 灰色锁)
工作原理
核心循环是:变量变化 → 引擎检测到变量越过阈值 → 行为触发 → 通知弹出 + 布尔变量设为 true。
玩家在冒险中累积了 101 金币
→ 引擎检测到 gold 越过 100 的阈值
→ 「Big Spender」行为触发
→ 动作执行:achievement_rich 设为 true,金色通知「Achievement Unlocked: Big Spender」
→ maxFireCount: 1 确保此行为不再触发
→ Root Component 读取 achievement_rich = true,面板显示金色奖杯图标这里有一个重要的设计决策:为什么使用 variable-crossed 而不是 state-change?
state-change意味着「任何变量变化时都检查」-- 非常宽泛。如果你使用state-change+ 条件gold gt 100,那么每次 gold 从 101 变到 102、102 变到 103……条件都会被重新评估。虽然maxFireCount: 1能防止重复触发,但引擎仍然会做无用的评估。variable-crossed意味着「仅在 gold 从 <= 100 变为 > 100 的瞬间触发」-- 精确且高效。配合maxFireCount: 1,你就有了双重保险。
逐步操作
第 1 步:创建变量
你需要 5 个变量 -- 2 个数值变量跟踪进度,3 个布尔变量跟踪每个成就是否已解锁。
编辑器 → 左侧边栏 → 变量 选项卡 → 为每个变量点击「添加变量」
变量 1:金币
| 字段 | 值 | 原因 |
|---|---|---|
| 显示名称 | Gold | 方便你在变量列表中识别 |
| ID | gold | 行为和 Root Component 通过此 ID 读写 |
| 类型 | Number | 金币是数值,需要算术运算 |
| 默认值 | 0 | 新会话从 0 金币开始 |
| 分类 | Stats | 与角色属性归为一组 |
| 行为规则 | Current gold count. The AI can modify this via directives when the narrative calls for it. | 告诉 AI 这是什么以及如何使用 |
变量 2:战斗胜利次数
| 字段 | 值 | 原因 |
|---|---|---|
| 显示名称 | Combat Wins | 方便识别 |
| ID | combat_wins | 被行为引用 |
| 类型 | Number | 它是一个计数器 |
| 默认值 | 0 | 从 0 开始 |
| 分类 | Stats | 角色属性 |
| 行为规则 | Cumulative number of battles the player has won. The AI can +1 this via directive when the player wins a fight. | 告诉 AI 何时递增 |
变量 3:成就 -- Big Spender
| 字段 | 值 | 原因 |
|---|---|---|
| 显示名称 | Achievement: Big Spender | 方便识别 |
| ID | achievement_rich | 所有成就变量使用 achievement_ 前缀 |
| 类型 | Boolean | 只有两种状态:已解锁或未解锁 |
| 默认值 | false | 开始时未解锁 |
| 分类 | Achievements | 将所有成就变量归为一个分类,便于管理 |
| 行为规则 | Do not modify this variable directly — achievements are unlocked automatically by behavior rules when conditions are met, which also triggers a notification. Modifying it manually bypasses the notification system. | 成就必须通过行为触发才能正确显示通知 |
变量 4:成就 -- First Blood
| 字段 | 值 | 原因 |
|---|---|---|
| 显示名称 | Achievement: First Blood | 方便识别 |
| ID | achievement_warrior | 相同的前缀约定 |
| 类型 | Boolean | 同上 |
| 默认值 | false | 开始时未解锁 |
| 分类 | Achievements | 同上 |
| 行为规则 | Do not modify this variable directly — achievements are unlocked automatically by behavior rules when conditions are met, which also triggers a notification. Modifying it manually bypasses the notification system. | 同理 |
变量 5:成就 -- Trailblazer
| 字段 | 值 | 原因 |
|---|---|---|
| 显示名称 | Achievement: Trailblazer | 方便识别 |
| ID | achievement_explorer | 相同的前缀约定 |
| 类型 | Boolean | 同上 |
| 默认值 | false | 开始时未解锁 |
| 分类 | Achievements | 同上 |
| 行为规则 | Do not modify this variable directly — achievements are unlocked automatically by behavior rules when conditions are met, which also triggers a notification. Modifying it manually bypasses the notification system. | 同理 |
为什么要用单独的布尔变量记录成就?
因为 Root Component 需要读取每个成就的状态来显示面板。如果只依赖 maxFireCount 来防止重复触发,组件无法知道「这个成就解锁了没有」-- 它看不到行为的触发计数。布尔变量是 Root Component 和其他行为可以读取的公开状态。
第 2 步:创建行为
你需要 3 个行为 -- 每个成就一个。
编辑器 → 左侧边栏 → 行为 选项卡 → 为每个行为点击「添加行为」
行为 1:Big Spender(gold > 100)
基本信息:
| 字段 | 值 | 原因 |
|---|---|---|
| 名称 | Achievement: Big Spender | 供你自己参考 |
| 最大触发次数 | 1 | 成就只解锁一次 -- 触发后,此行为不再运行 |
触发器(WHEN):
| 字段 | 值 | 原因 |
|---|---|---|
| 触发类型 | Variable Crossed Threshold(variable-crossed) | 我们想检测 gold 越过 100 的瞬间 |
| 变量 ID | gold | 监控 gold 变量 |
| 方向 | Rises Above(rises-above) | 当 gold 从 <= 100 变为 > 100 时触发 |
| 阈值 | 100 | 里程碑值 |
动作(DO):
| 动作类型 | 设置 | 用途 |
|---|---|---|
| 设置变量 | achievement_rich 设为 true | 标记成就已解锁,供 Root Component 读取 |
| 显示通知 | 消息 Achievement Unlocked: Big Spender,样式 achievement | 弹出金色成就提示 |
关于
maxFireCount: 1。 此字段设置在行为本身上(不是触发器上)。它意味着「此行为最多执行 1 次」。一旦触发过,无论之后 gold 如何变化,此行为都不会再运行。这是成就系统的核心保障 -- 没人想看到同一个成就弹两次。
行为 2:First Blood(combat wins > 5)
基本信息:
| 字段 | 值 | 原因 |
|---|---|---|
| 名称 | Achievement: First Blood | 供你自己参考 |
| 最大触发次数 | 1 | 同上 |
触发器(WHEN):
| 字段 | 值 | 原因 |
|---|---|---|
| 触发类型 | Variable Crossed Threshold(variable-crossed) | 检测 combat_wins 越过 5 的瞬间 |
| 变量 ID | combat_wins | 监控战斗胜利计数 |
| 方向 | Rises Above(rises-above) | 当 combat_wins 从 <= 5 变为 > 5 时触发 |
| 阈值 | 5 | 里程碑值 |
动作(DO):
| 动作类型 | 设置 | 用途 |
|---|---|---|
| 设置变量 | achievement_warrior 设为 true | 标记成就已解锁 |
| 显示通知 | 消息 Achievement Unlocked: First Blood,样式 achievement | 弹出金色成就提示 |
行为 3:Trailblazer(关键词触发)
这个成就与前两个不同 -- 它不是监控数值阈值,而是监控消息内容。当玩家说「explore」或 AI 说「discover」,且成就尚未解锁时,就会触发。
基本信息:
| 字段 | 值 | 原因 |
|---|---|---|
| 名称 | Achievement: Trailblazer | 供你自己参考 |
| 最大触发次数 | 1 | 同上 |
触发器(WHEN):
这个成就需要监控两个来源 -- 玩家消息和 AI 消息。在 Yumina 中,一个行为只能有一个触发器,所以你需要创建两个行为来覆盖两个来源。
最简单的方法是创建两个行为:
行为 3a:Trailblazer(玩家关键词)
| 字段 | 值 | 原因 |
|---|---|---|
| 触发类型 | Player Said Keyword(keyword) | 监控玩家消息 |
| 关键词 | explore | 当玩家说「I want to explore」时匹配 |
| 最大触发次数 | 1 | 只触发一次 |
条件(ONLY IF):
| 变量 ID | 运算符 | 值 | 原因 |
|---|---|---|---|
achievement_explorer | Equals(eq) | false | 只有在成就尚未解锁时才触发 |
动作(DO):
| 动作类型 | 设置 | 用途 |
|---|---|---|
| 设置变量 | achievement_explorer 设为 true | 标记成就已解锁 |
| 显示通知 | 消息 Achievement Unlocked: Trailblazer,样式 achievement | 弹出金色成就提示 |
行为 3b:Trailblazer(AI 关键词)
| 字段 | 值 | 原因 |
|---|---|---|
| 触发类型 | AI Said Keyword(ai-keyword) | 监控 AI 回复 |
| 关键词 | discover | 当 AI 提到「discover」时匹配 |
| 最大触发次数 | 1 | 只触发一次 |
条件和动作与行为 3a 相同。
为什么需要条件
achievement_explorer eq false? 因为两个行为(3a 和 3b)都可以解锁同一个成就。假设行为 3a 先触发 -- 它将achievement_explorer设为true并用完了自己的maxFireCount。但行为 3b 的maxFireCount还没用完!没有条件的话,行为 3b 下次匹配到关键词时仍会触发,玩家会看到两次通知。有了条件,行为 3b 检查到achievement_explorer已经是true,条件不满足,就不会触发。
第 3 步:在 Root Component 中添加成就面板
这是让成就面板显示在聊天中的关键步骤。面板只出现在最后一条消息下方。
编辑器 → 自定义 UI 部分 → 打开 index.tsx → 粘贴以下代码(替换默认的 return <Chat />):
export default function MyWorld() {
const api = useYumina();
const msgs = api.messages || [];
// 成就列表定义
const achievements = [
{
id: "achievement_rich",
name: "Big Spender",
desc: "Accumulate over 100 gold",
icon: "💰",
},
{
id: "achievement_warrior",
name: "First Blood",
desc: "Win more than 5 battles",
icon: "⚔️",
},
{
id: "achievement_explorer",
name: "Trailblazer",
desc: "Discover a hidden area or secret",
icon: "🗺️",
},
];
// 统计已解锁的成就数量
const unlockedCount = achievements.filter(
(a) => api.variables[a.id] === true
).length;
return (
<Chat renderBubble={(msg) => {
const isLastMsg = msg.messageIndex === msgs.length - 1;
return (
<div>
{/* 正常渲染消息文本(平台已将 Markdown 转为 HTML -- 直接使用 contentHtml) */}
<div
style={{ color: "#e2e8f0", lineHeight: 1.7 }}
dangerouslySetInnerHTML={{ __html: msg.contentHtml }}
/>
{/* 成就面板 -- 仅在最后一条消息下方 */}
{isLastMsg && (
<div
style={{
marginTop: "16px",
padding: "12px 16px",
background: "linear-gradient(135deg, #1c1917, #292524)",
border: "1px solid #44403c",
borderRadius: "10px",
}}
>
{/* 面板标题 */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "10px",
}}
>
<span
style={{
fontSize: "13px",
fontWeight: "bold",
color: "#fbbf24",
letterSpacing: "0.05em",
}}
>
🏆 Achievements
</span>
<span style={{ fontSize: "12px", color: "#a8a29e" }}>
{unlockedCount} / {achievements.length}
</span>
</div>
{/* 成就列表 */}
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
{achievements.map((a) => {
const unlocked = api.variables[a.id] === true;
return (
<div
key={a.id}
style={{
display: "flex",
alignItems: "center",
gap: "10px",
padding: "6px 8px",
borderRadius: "6px",
background: unlocked
? "rgba(251, 191, 36, 0.08)"
: "rgba(120, 113, 108, 0.08)",
}}
>
{/* 图标 */}
<span style={{ fontSize: "18px", opacity: unlocked ? 1 : 0.3 }}>
{unlocked ? a.icon : "🔒"}
</span>
{/* 文字 */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "13px",
fontWeight: "600",
color: unlocked ? "#fbbf24" : "#78716c",
}}
>
{a.name}
</div>
<div
style={{
fontSize: "11px",
color: unlocked ? "#a8a29e" : "#57534e",
marginTop: "1px",
}}
>
{a.desc}
</div>
</div>
{/* 状态标识 */}
{unlocked && (
<span style={{ fontSize: "11px", color: "#fbbf24" }}>
✓ Unlocked
</span>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
}} />
);
}逐行说明:
MyWorld()是 Root Component -- 世界的 UI 入口。<Chat renderBubble={...} />让平台负责消息列表、输入框和滚动;我们只自定义每个气泡的布局const api = useYumina()-- 获取 Yumina API 来读取变量状态msg.messageIndex === msgs.length - 1-- 只在最后一条消息上显示面板,不会在每条消息上重复msg.contentHtml-- 平台已经将 Markdown 渲染为 HTML;直接放入dangerouslySetInnerHTMLachievements数组 -- 在 Root Component 中直接定义所有成就的元数据(ID、名称、描述、图标)。想添加新成就?只需在此数组中添加一个条目api.variables[a.id] === true-- 读取布尔变量的值来检查成就是否已解锁unlockedCount-- 统计已解锁数量,显示在标题中(例如「2 / 3」)- 未解锁的成就显示灰色锁图标,已解锁的显示金色图标加「Unlocked」标识
不想自己写代码?使用 Studio AI
编辑器顶部 → 点击「进入 Studio」→ AI 助手面板 → 用自然语言描述你想要什么,AI 会为你生成代码。
第 4 步:保存并测试
- 点击编辑器顶部的 保存
- 点击 开始游戏 或返回主页开始新会话
- 最后一条消息下方应该能看到成就面板 -- 所有 3 个成就都是灰色带锁图标
- 测试金币成就:与 AI 聊天,让你的角色赚到超过 100 金币。当
gold从 <= 100 变为 > 100 时,金色通知弹出:「Achievement Unlocked: Big Spender」,面板上第一个成就变为金色 - 测试战斗成就:让你的角色赢得 6 场战斗。当
combat_wins从 5 变为 6 时,通知弹出:「Achievement Unlocked: First Blood」 - 测试探索成就:发送包含「explore」的消息(例如「I want to explore this cave」)。如果关键词匹配,通知弹出:「Achievement Unlocked: Trailblazer」
如果出了问题:
| 症状 | 可能原因 | 修复方法 |
|---|---|---|
| 看不到成就面板 | Root Component 代码没有保存或有语法错误 | 检查自定义 UI 面板底部的编译状态 -- 应该显示绿色「OK」 |
| 金币超过 100 但没有通知 | 变量没有「越过」从 <= 100 到 > 100 -- 而是直接设为了 200 | 确保金币是逐步变化的(AI 通过指令加减),而不是一次跳到很大的数字 |
| 成就弹出了两次 | 行为的 maxFireCount 没有设为 1 | 回到编辑器检查行为设置 |
| 探索成就弹出了两次 | 行为 3a 和 3b 都触发了,且缺少条件检查 | 确认两个行为都有条件 achievement_explorer eq false |
| 面板状态没有更新 | Root Component 代码中的变量 ID 拼错了 | 确认 api.variables[a.id] 中的 a.id 与变量 ID 完全匹配 |
深入理解:variable-crossed vs state-change
这是成就系统中最重要的概念区分 -- 值得展开讲讲。
variable-crossed(变量越过阈值)
检测一个瞬时事件:「变量从阈值的一侧越过到另一侧」。
gold: 80 → 95 → 101 ← 在 95→101 步触发(越过 100 以上)
gold: 101 → 150 → 200 ← 不触发(已经在阈值以上)
gold: 200 → 50 → 120 ← 在 50→120 步触发(再次越过 100 以上)关键特性:
- 只在越过的瞬间触发,而不是「在阈值以上时持续触发」
- 如果值降到阈值以下再回升,会再次触发(除非
maxFireCount阻止) - 适合:成就解锁、里程碑通知、HP 归零死亡检查
state-change(变量变化)
检测一个持续事件:「任何变量发生了变化」。
gold: 80 → 95 ← 触发(gold 变化了)
gold: 95 → 101 ← 触发(gold 又变化了)
gold: 101 → 150 ← 触发(gold 还在变化)
hp: 100 → 90 ← 也触发(hp 变化了)关键特性:
- 任何变量的任何变化都会触发
- 需要条件(ONLY IF)来过滤
- 适合:通用状态监控、根据当前状态切换世界上下文
为什么 variable-crossed 更适合成就
因为成就是里程碑 -- 你只关心越线的那个瞬间。如果你用 state-change + 条件 gold gt 100:
- gold 从 95 变到 101 → 触发 → 条件满足 → 执行(正确)
- gold 从 101 变到 102 → 触发 → 条件满足 → 尝试再次执行(错误!
maxFireCount阻止了,但引擎仍然做了一次无用的评估) - gold 从 102 变到 103 → 又触发 → 又检查条件……
用 variable-crossed:
- gold 从 95 变到 101 → 检测到越过 100 → 触发 → 执行(正确)
- gold 从 101 变到 102 → 没有越过事件 → 完全不触发(高效)
总结:精确的触发器 = 更少的无用评估 = 更好的性能和更清晰的逻辑。
扩展思路
建好基础的 3 个成就后,你可以用同样的模式扩展更多:
| 成就名称 | 变量 ID | 触发方式 | 条件 |
|---|---|---|---|
| Chatterbox | achievement_talkative | 创建 message_count 变量,每轮 +1,越过 50 时触发 | variable-crossed,message_count rises above 50 |
| Hoarder | achievement_hoarder | gold 越过 500 时触发 | variable-crossed,gold rises above 500 |
| Socialite | achievement_social | AI 说出关键词「become friends」或「trusts you」 | ai-keyword,条件 achievement_social eq false |
| Back from the Dead | achievement_survivor | HP 越过 10 以下(濒死),然后后来越过 50 以上(恢复) | 两个关联的行为 |
对于每个新成就,你只需要:
- 添加一个布尔变量(
achievement_xxx,默认false) - 添加一个行为(触发器 + 动作 +
maxFireCount: 1) - 在 Root Component 的
achievements数组中添加一个条目
快速参考
| 你想做什么 | 怎么做 |
|---|---|
| 数值达标时解锁成就 | 行为触发器:「Variable Crossed Threshold」(variable-crossed),方向:rises above,设置阈值 |
| 关键词触发成就 | 行为触发器:「Player Said Keyword」(keyword)或「AI Said Keyword」(ai-keyword) |
| 确保成就只触发一次 | 在行为上设置 maxFireCount: 1;对于关键词触发,还需添加条件 achievement_xxx eq false |
| 弹出金色成就通知 | 行为动作:显示通知,样式 achievement |
| 在聊天中显示成就面板 | Root Component 读取布尔变量,渲染已解锁/未解锁状态 |
| 添加新成就 | 添加布尔变量 + 添加行为 + 在 Root Component 的 achievements 数组中添加条目 |
自己试试 -- 可导入的演示世界
下载此 JSON 并导入,查看完整效果:
如何导入:
- 前往 Yumina → 我的世界 → 创建新世界
- 在编辑器中,点击 更多操作 → 导入包
- 选择下载的
.json文件 - 一个新世界会被创建,所有变量、行为和 Root Component 都已预配置
- 开始新会话并试用
包含内容:
- 5 个变量(
gold、combat_wins、achievement_rich、achievement_warrior、achievement_explorer) - 4 个行为(Big Spender、First Blood、Trailblazer x2)
- 一个带成就面板的 Root Component
