Skip to content

成就系统

构建一个完整的成就系统 -- 当玩家达到特定里程碑(金币超过 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方便你在变量列表中识别
IDgold行为和 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方便识别
IDcombat_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方便识别
IDachievement_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方便识别
IDachievement_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方便识别
IDachievement_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 的瞬间
变量 IDgold监控 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 的瞬间
变量 IDcombat_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_explorerEquals(eqfalse只有在成就尚未解锁时才触发

动作(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 />):

tsx
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;直接放入 dangerouslySetInnerHTML
  • achievements 数组 -- 在 Root Component 中直接定义所有成就的元数据(ID、名称、描述、图标)。想添加新成就?只需在此数组中添加一个条目
  • api.variables[a.id] === true -- 读取布尔变量的值来检查成就是否已解锁
  • unlockedCount -- 统计已解锁数量,显示在标题中(例如「2 / 3」)
  • 未解锁的成就显示灰色锁图标,已解锁的显示金色图标加「Unlocked」标识

不想自己写代码?使用 Studio AI

编辑器顶部 → 点击「进入 Studio」→ AI 助手面板 → 用自然语言描述你想要什么,AI 会为你生成代码。


第 4 步:保存并测试

  1. 点击编辑器顶部的 保存
  2. 点击 开始游戏 或返回主页开始新会话
  3. 最后一条消息下方应该能看到成就面板 -- 所有 3 个成就都是灰色带锁图标
  4. 测试金币成就:与 AI 聊天,让你的角色赚到超过 100 金币。当 gold 从 <= 100 变为 > 100 时,金色通知弹出:「Achievement Unlocked: Big Spender」,面板上第一个成就变为金色
  5. 测试战斗成就:让你的角色赢得 6 场战斗。当 combat_wins 从 5 变为 6 时,通知弹出:「Achievement Unlocked: First Blood」
  6. 测试探索成就:发送包含「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

  1. gold 从 95 变到 101 → 触发 → 条件满足 → 执行(正确)
  2. gold 从 101 变到 102 → 触发 → 条件满足 → 尝试再次执行(错误!maxFireCount 阻止了,但引擎仍然做了一次无用的评估)
  3. gold 从 102 变到 103 → 又触发 → 又检查条件……

variable-crossed

  1. gold 从 95 变到 101 → 检测到越过 100 → 触发 → 执行(正确)
  2. gold 从 101 变到 102 → 没有越过事件 → 完全不触发(高效)

总结:精确的触发器 = 更少的无用评估 = 更好的性能和更清晰的逻辑


扩展思路

建好基础的 3 个成就后,你可以用同样的模式扩展更多:

成就名称变量 ID触发方式条件
Chatterboxachievement_talkative创建 message_count 变量,每轮 +1,越过 50 时触发variable-crossedmessage_count rises above 50
Hoarderachievement_hoardergold 越过 500 时触发variable-crossedgold rises above 500
Socialiteachievement_socialAI 说出关键词「become friends」或「trusts you」ai-keyword,条件 achievement_social eq false
Back from the Deadachievement_survivorHP 越过 10 以下(濒死),然后后来越过 50 以上(恢复)两个关联的行为

对于每个新成就,你只需要:

  1. 添加一个布尔变量(achievement_xxx,默认 false
  2. 添加一个行为(触发器 + 动作 + maxFireCount: 1
  3. 在 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 并导入,查看完整效果:

recipe-13-demo.json

如何导入:

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

包含内容:

  • 5 个变量(goldcombat_winsachievement_richachievement_warriorachievement_explorer
  • 4 个行为(Big Spender、First Blood、Trailblazer x2)
  • 一个带成就面板的 Root Component

这是食谱 #13

成就系统可以与其他食谱自由组合 -- 搭配战斗系统跟踪战斗胜利,搭配商店系统跟踪金币累积,或搭配任务追踪器跟踪已完成的任务。变量是通用的,行为之间互不干扰。