Skip to content

成就系统

做一套成就系统——当玩家达成特定里程碑(金币超过 100、战斗胜利超过 5 次、发现隐藏区域……),屏幕上弹出金色成就通知。用布尔变量追踪哪些成就已解锁,用消息渲染器显示一个成就面板。


你要做的东西

一个嵌入在聊天里的成就系统:

  • 金色弹窗通知——玩家达成里程碑的瞬间,屏幕上弹出金色成就通知(achievement 样式),比如"成就解锁:一掷千金"
  • 自动检测——引擎在后台监控变量变化,条件满足时自动触发,不需要玩家手动操作
  • 只触发一次——每个成就只解锁一次,不会重复弹出。用 maxFireCount 和布尔变量双重保险
  • 成就面板——最后一条消息下方显示一个迷你面板,列出所有成就及其解锁状态(已解锁 = 金色图标,未解锁 = 灰色锁)

原理

整个系统的核心是:变量变化 → 引擎检测到变量越过阈值 → 触发行为 → 弹出通知 + 标记布尔变量为 true

玩家在冒险中积累了 101 金币
  → 引擎检测到 gold 上穿 100
  → 触发「一掷千金」行为
  → 行为执行:achievement_rich 设为 true,弹出金色通知"成就解锁:一掷千金"
  → maxFireCount: 1 确保这条行为永远不再触发
  → 消息渲染器读取 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:金币

字段填什么为什么这样填
显示名称金币给你自己看的,方便在变量列表里识别
IDgold行为规则和消息渲染器代码用这个 ID 来读写
类型数字金币是数值,需要做加减运算
默认值0新会话从 0 金币开始
分类属性和角色属性相关的变量
行为规则当前金币数量。AI 可以在叙事需要时通过指令修改。告诉 AI 这是什么、怎么用

变量 2:战斗胜利次数

字段填什么为什么这样填
显示名称战斗胜利次数方便识别
IDcombat_wins行为引用这个 ID
类型数字计数用
默认值0从 0 开始
分类属性角色属性
行为规则玩家在战斗中获胜的累计次数。AI 可以在战斗胜利时通过指令 +1。让 AI 知道何时增加

变量 3:成就——一掷千金

字段填什么为什么这样填
显示名称成就:一掷千金方便识别
IDachievement_rich成就变量统一用 achievement_ 前缀
类型布尔只有"已解锁"和"未解锁"两种状态
默认值false开局未解锁
分类成就所有成就变量放在同一分类下,方便管理
行为规则不要直接修改这个变量——成就由行为规则根据条件自动解锁,并弹出通知。手动修改会绕过通知系统。成就必须通过行为规则触发才能正确弹出通知

变量 4:成就——初露锋芒

字段填什么为什么这样填
显示名称成就:初露锋芒方便识别
IDachievement_warrior同上前缀规则
类型布尔同上
默认值false开局未解锁
分类成就同上
行为规则不要直接修改这个变量——成就由行为规则根据条件自动解锁,并弹出通知。手动修改会绕过通知系统。成就必须通过行为规则触发才能正确弹出通知

变量 5:成就——探索先驱

字段填什么为什么这样填
显示名称成就:探索先驱方便识别
IDachievement_explorer同上前缀规则
类型布尔同上
默认值false开局未解锁
分类成就同上
行为规则不要直接修改这个变量——成就由行为规则根据条件自动解锁,并弹出通知。手动修改会绕过通知系统。成就必须通过行为规则触发才能正确弹出通知

为什么成就要用单独的布尔变量?

因为消息渲染器需要读取每个成就的状态来显示面板。如果只靠 maxFireCount 来防止重复触发,渲染器无法知道"这个成就到底解没解锁"——它看不到行为的触发计数。布尔变量是给渲染器和其他行为读取用的公开状态。


第 2 步:创建行为

我们需要 3 条行为——每个成就一条。

编辑器 → 左侧边栏 → 行为 标签页 → 逐个点击「添加行为」

行为 1:一掷千金(金币 > 100)

基本信息:

字段填什么为什么这样填
名称成就:一掷千金给你自己看的
最大触发次数1成就只解锁一次,触发一次后永远不再触发

触发器(WHEN):

字段填什么为什么这样填
触发类型变量越过阈值 (variable-crossed)我们要检测 gold 越过 100 的那个瞬间
变量 IDgold监控金币变量
方向上升 (rises-above)从 <= 100 变到 > 100 时触发
阈值100里程碑数值

执行动作(DO):

动作类型设置作用
修改变量achievement_rich 设为 true标记成就已解锁,给渲染器读取
显示通知消息 成就解锁:一掷千金,样式 achievement弹出金色成就通知

关于 maxFireCount: 1 这个字段设在行为本身上(不是触发器上)。意思是"这条行为一辈子最多执行 1 次"。触发够了之后,不管 gold 怎么变化,这条行为都不会再跑了。这是成就系统的核心保障——没有人想看到同一个成就弹两次。

行为 2:初露锋芒(战斗胜利 > 5)

基本信息:

字段填什么为什么这样填
名称成就:初露锋芒给你自己看的
最大触发次数1同上

触发器(WHEN):

字段填什么为什么这样填
触发类型变量越过阈值 (variable-crossed)检测 combat_wins 越过 5 的瞬间
变量 IDcombat_wins监控战斗胜利次数
方向上升 (rises-above)从 <= 5 变到 > 5 时触发
阈值5里程碑数值

执行动作(DO):

动作类型设置作用
修改变量achievement_warrior 设为 true标记成就已解锁
显示通知消息 成就解锁:初露锋芒,样式 achievement弹出金色成就通知

行为 3:探索先驱(关键词触发)

这个成就和前两个不同——它不监控数值阈值,而是监控玩家或 AI 的消息内容。当玩家说了"探索"或 AI 说了"发现"时,如果成就尚未解锁,就触发。

基本信息:

字段填什么为什么这样填
名称成就:探索先驱给你自己看的
最大触发次数1同上

触发器(WHEN):

这个成就需要监控两个来源——玩家消息和 AI 消息。在 Yumina 中,一条行为只能有一个触发器,所以我们需要创建两条行为来覆盖两个来源,或者用一个更宽泛的方式。

最简单的做法是创建两条行为:

行为 3a:探索先驱(玩家关键词)

字段填什么为什么这样填
触发类型玩家说了关键词 (keyword)监控玩家消息
关键词探索玩家说"我想去探索"就会匹配
最大触发次数1只触发一次

条件(ONLY IF):

变量 ID运算符为什么
achievement_explorer等于 (eq)false只有在成就尚未解锁时才触发

执行动作(DO):

动作类型设置作用
修改变量achievement_explorer 设为 true标记成就已解锁
显示通知消息 成就解锁:探索先驱,样式 achievement弹出金色成就通知

行为 3b:探索先驱(AI 关键词)

字段填什么为什么这样填
触发类型AI 说了关键词 (ai-keyword)监控 AI 回复
关键词发现AI 回复里出现"发现"就匹配
最大触发次数1只触发一次

条件和动作与行为 3a 完全相同。

为什么这里要用条件 achievement_explorer eq false 因为有两条行为(3a 和 3b)都能解锁同一个成就。假设行为 3a 先触发了——它把 achievement_explorer 设为 true,自己的 maxFireCount 也用完了。但行为 3b 的 maxFireCount 还没用过!如果没有这个条件,行为 3b 下次匹配到关键词时还会再触发一次,玩家就会看到两个通知。加了条件之后,行为 3b 检查到 achievement_explorer 已经是 true,条件不满足,就不会触发了。


第 3 步:做成就面板的消息渲染器

这是让成就面板出现在聊天界面的关键步骤。面板只显示在最后一条消息的下方。

编辑器 → 消息渲染器 标签页 → 选「自定义 TSX」→ 粘贴以下代码:

tsx
export default function Renderer({ content, renderMarkdown, messageIndex }) {
  const api = useYumina();
  const msgs = api.messages || [];
  const isLastMsg = messageIndex === msgs.length - 1;

  // 成就列表定义
  const achievements = [
    {
      id: "achievement_rich",
      name: "一掷千金",
      desc: "累计获得超过 100 金币",
      icon: "💰",
    },
    {
      id: "achievement_warrior",
      name: "初露锋芒",
      desc: "在战斗中胜利超过 5 次",
      icon: "⚔️",
    },
    {
      id: "achievement_explorer",
      name: "探索先驱",
      desc: "发现隐藏的区域或秘密",
      icon: "🗺️",
    },
  ];

  // 统计已解锁数量
  const unlockedCount = achievements.filter(
    (a) => api.variables[a.id] === true
  ).length;

  return (
    <div>
      {/* 正常渲染消息文字 */}
      <div
        style={{ color: "#e2e8f0", lineHeight: 1.7 }}
        dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
      />

      {/* 成就面板——只在最后一条消息下方显示 */}
      {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",
              }}
            >
              🏆 成就
            </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" }}>
                      ✓ 已解锁
                    </span>
                  )}
                </div>
              );
            })}
          </div>
        </div>
      )}
    </div>
  );
}

代码逐行解释:

  • const api = useYumina() — 获取 Yumina 的 API,读取变量状态
  • isLastMsg — 只在最后一条消息上显示面板,避免每条消息都重复
  • achievements 数组 — 在渲染器里定义所有成就的元数据(ID、名称、描述、图标)。要加新成就?往这个数组里加一项就行
  • api.variables[a.id] === true — 读取布尔变量的值,判断成就是否已解锁
  • unlockedCount — 统计已解锁数量,显示在标题栏右侧(如"2 / 3")
  • 未解锁的成就显示灰色锁图标 🔒,已解锁的显示对应的金色图标和"✓ 已解锁"标记

不想自己写代码?用工作室 AI

编辑器顶部 → 点击「进入工作室」→ AI 助手面板 → 用中文描述你想要什么,AI 会帮你生成代码。


第 4 步:保存并测试

  1. 点击编辑器顶部的「保存」
  2. 点击「开始游戏」或回到首页开一个新会话
  3. 最后一条消息下方应该能看到成就面板——3 个成就全部是灰色锁图标
  4. 测试金币成就:和 AI 对话,让角色获得超过 100 金币。当 gold 从 <= 100 变到 > 100 时,屏幕上弹出金色通知"成就解锁:一掷千金",面板上第一个成就变成金色
  5. 测试战斗成就:让角色赢得 6 场战斗。当 combat_wins 从 5 变到 6 时,弹出"成就解锁:初露锋芒"
  6. 测试探索成就:发一条包含"探索"的消息(比如"我想去探索这个洞穴")。如果匹配到关键词,弹出"成就解锁:探索先驱"

如果遇到问题:

现象可能的原因解决方法
看不到成就面板消息渲染器代码没保存或有语法错误检查消息渲染器底部的编译状态,应该显示绿色「OK」
金币超过 100 了但没弹通知变量不是从 <= 100 "越过"到 > 100,而是直接被设成了 200确认 gold 的变化是渐进式的(AI 通过指令加减),不是直接跳到一个大数
成就弹了两次行为的 maxFireCount 没设成 1回到编辑器检查行为设置
探索成就弹了两次行为 3a 和 3b 都触发了,且缺少条件检查确认两条行为都有条件 achievement_explorer eq false
面板上状态没更新渲染器代码里变量 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触发方式条件
话痨achievement_talkative创建一个 message_count 变量,每回合 +1,越过 50 时触发variable-crossedmessage_count 上穿 50
守财奴achievement_hoardergold 越过 500 时触发variable-crossedgold 上穿 500
社交达人achievement_socialAI 说了关键词"成为朋友"或"信任你"ai-keyword,条件 achievement_social eq false
死而复生achievement_survivorHP 越过 10(下穿,濒死)后又越过 50(上穿,恢复)两条行为联动

每加一个成就,只需要:

  1. 添加一个布尔变量(achievement_xxx,默认 false
  2. 添加一条行为(触发器 + 动作 + maxFireCount: 1
  3. 在消息渲染器的 achievements 数组里加一项

速查表

你想做的事怎么做
数值达标时解锁成就行为触发器选「变量越过阈值」(variable-crossed),方向选上升,填阈值
关键词触发成就行为触发器选「玩家说了关键词」(keyword) 或「AI 说了关键词」(ai-keyword)
确保成就只触发一次行为设 maxFireCount: 1,关键词类再加条件 achievement_xxx eq false
弹出金色成就通知行为动作:显示通知,样式选 achievement
在聊天里显示成就面板消息渲染器读取布尔变量,渲染已解锁/未解锁状态
添加新成就加布尔变量 + 加行为 + 渲染器数组加一项

直接试试——可导入的示例世界

下载这个 JSON 文件,导入即可体验完整效果:

recipe-13-demo-zh.json

导入方法:

  1. 进入 Yumina → 我的世界 → 创建新世界
  2. 在编辑器顶部点「更多操作」→「导入包」
  3. 选择下载的 .json 文件
  4. 世界会被创建,所有变量、行为和渲染器都已预配置好
  5. 开一个新会话试试看

包含内容:

  • 5 个变量(goldcombat_winsachievement_richachievement_warriorachievement_explorer
  • 4 条行为(一掷千金、初露锋芒、探索先驱 x2)
  • 一个带成就面板的消息渲染器

这是实战配方 #13

成就系统可以和其他配方自由组合——配合战斗系统追踪战斗胜利,配合商店系统追踪金币积累,配合任务追踪器追踪任务完成数。变量是通用的,行为之间互不干扰。