Skip to content

成就系统 ​

玩家达到某个里程碑的时候(金币超过 100、打赢 5 场以上、找到隐藏区域),弹出一张小卡片,写着成就名和一句话。每个成就只解锁一次,解锁后收进一个列表,玩家界面里有一页列出哪些已经拿到、哪些还锁着。整套都不用写代码。


你要做的东西 ​

  • 达成的那一刻弹出来:一张小卡片,有标题、一句话,图片可加可不加
  • 自动检测:行为盯着数值和对话,玩家什么都不用做
  • 每个成就只解锁一次:已经在列表里的时刻不会再弹第二次
  • 成就页:所有成就列在一起,解锁的显示说明,没解锁的显示提示
  • 可选:重开一局也还在的成就

怎么运作 ​

玩家的 Gold 从 95 涨到 101
  → 行为「小富翁」在盯着 Gold(变量越过阈值,上升到以上 100)
  → 它的效果「解锁一个时刻」弹出卡片「小富翁」
  → 标题「小富翁」被加进列表变量 Achievements
  → 成就页上「小富翁」显示为已解锁

数值是 AI 跟着剧情在改,但成就解没解锁是引擎说了算,所以不会漏掉,也不会发两次。


一步一步来 ​

第 1 步:建变量 ​

点卡底下 还可以加 里的 + 变量,下面每个变量建一次。

Gold ​

字段值
名称Gold
类型数值
初始值0
范围0 到(不填)
行为规则玩家的金币。拿到报酬、捡到东西、卖掉东西时增加;花钱时减少。

Battles won ​

字段值
名称Battles won
类型数值
初始值0
行为规则玩家打赢了几场战斗。玩家明确打赢一场时加 1。

Achievements ​

字段值
名称Achievements
类型列表/表格
初始值[]

展开 AI 怎么用它,把 AI 权限 设成 仅引擎(对 AI 隐藏)。这个列表只由行为来写,AI 读了也没用。

重开一局也还在的成就

在同一栏里勾上 跨存档保留(每个玩家自己的)。这样玩家重开一局时,列表从他已经解锁的成就开始,这些成就也不会再弹一次。CG 图鉴、「已达成结局」收集都是这么做的。


第 2 步:每个成就建一条行为 ​

点 还可以加 里的 + 行为。

行为 1:小富翁 ​

部分设置
什么时候触发变量越过阈值:Gold,上升到以上,100
效果解锁一个时刻

把时刻填好:

字段值
标题小富翁
消息你攒下了 100 多金币。
图片(链接)可选:一个 https:// 链接,或者 面板 → 资源 里的素材
收进哪个列表Achievements

「变量越过阈值」只在数值从 100 或以下变成 100 以上的那一下触发。金币从 101 继续涨到 150,不会再触发。金币掉回 100 以下再涨上来,会再触发一次,但这个时刻已经在 Achievements 里了,所以什么都不会弹。

行为 2:老练战士 ​

部分设置
什么时候触发变量越过阈值:Battles won,上升到以上,5
效果解锁一个时刻:标题 老练战士,消息 你已经打赢了 5 场以上。,收进 Achievements

行为 3:开路先锋(关键词) ​

这个盯的是对话,不是数值。一条行为只有一个触发,所以想同时抓玩家说的和 AI 说的,就建两条行为。

3a

部分设置
什么时候触发玩家说出关键词:探索, 搜索山洞
效果解锁一个时刻:标题 开路先锋,消息 你去找了藏起来的东西。,收进 Achievements

3b

部分设置
什么时候触发AI 说出关键词:暗道, 密室
效果和 3a 一样的 解锁一个时刻,标题也一样

关键词用逗号隔开,出现任意一个就算。两条行为收进同一个列表、用同一个标题,所以谁先触发谁解锁,另一条发现列表里已经有了,就不出声。不用另外加条件。

用时刻还是用通知?

显示通知(样式选 成功)也能弹一句「成就解锁」,但它什么都不记:你得每个成就配一个开关变量,加一个条件防止触发两次,再把 最大触发次数 设成 1。解锁一个时刻 把这些记账的事都替你做了。


第 3 步:做成就页 ​

点顶栏中间的 玩家界面。用 新建一页 加一页(选 空白页 就行),起个标题,再从 加一个部件 里加一个 列表:

  1. 内容从哪来 选 自己写几行,每个成就加一行:小富翁、老练战士、开路先锋,每行配一句话
  2. 打开 每一行画成一张卡
  3. 在 锁住直到 里打开 没解锁的行显示成锁住。看哪个变量 选 Achievements,对照这一行的哪个字段 保持 title 不动。列表里有这一行的标题,这一行就解锁,解锁一个时刻 放进去的正好就是标题
  4. 在 锁住时显示 里写解锁前玩家看到的提示,比如「先攒点钱」
  5. 加一个 按钮,步骤选 跳到页面,跳回对话页,免得这一页走不出去(不然页面栏会提醒 没出口)
  6. 在对话页上加一个写着「成就」的 按钮,步骤是 跳到页面 → 你刚建的那一页

如果你的收集品是 AI 在剧情里发的,而不是行为发的,就改从 收集图鉴 模板开始:一开始都是灰的,剧情发给玩家一个就亮一个。它自带列表变量、弹窗,还有告诉 AI 什么时候往里加的行为规则。

把顶上的 编辑 关掉,就能像玩家一样点着试试这一页。

数一数解锁了几个

加一个数值变量叫「Achievements unlocked」,公式(自动算) 填 len(Achievements)。在页面上放一个文字部件写 {Achievements unlocked} / 3,数字会自己更新。


第 4 步:试玩 ​

点顶栏的 试玩。

  1. 点 成就:三个都锁着,显示各自的提示
  2. 一直玩到金币超过 100。「小富翁」卡片弹出来,成就页上显示已解锁
  3. 打赢六场。Battles won 从 5 变成 6 的时候,「老练战士」弹出来
  4. 发一句「我想探索一下这个山洞」。「开路先锋」弹出来。之后 AI 提到暗道,不会再弹

试玩右边的 这回合 能看到哪些行为触发了,没触发的是为什么。

如果出了问题:

现象可能的原因怎么改
金币过了 100 但什么都没弹金币根本没越过这条线:一开始就在 100 以上阈值只在越过的那一下触发。检查 初始值 是不是等于或低于阈值
时刻弹了,但成就页没变收进哪个列表 没填,或者页面看的是另一个变量两边都选 Achievements
已经弹过了,成就页还显示锁着这一行的标题和时刻的标题不完全一样改成一模一样,大小写也要一样
一个成就弹了两次两条行为用了不同的标题或不同的列表同一个标题、同一个列表

写代码的做法:聊天下方的成就面板 ​

如果想自己画这个面板,比如放在最后一条消息下面,而不是单独一页,就在界面代码里写。上面的行为一点都不用改,代码只是读 Achievements 这个列表。

面板 → 前端代码 → 打开 index.tsx,把默认的 return <Chat /> 换掉:

tsx
export default function MyWorld() {
  const api = useYumina();
  const msgs = api.messages || [];

  // 标题要和行为里时刻的标题一致
  const achievements = [
    { title: "小富翁", desc: "攒下 100 多金币", icon: "💰" },
    { title: "老练战士", desc: "打赢 5 场以上", icon: "⚔️" },
    { title: "开路先锋", desc: "找到隐藏区域或秘密", icon: "🗺️" },
  ];

  // 时刻收进去的那个列表,按名字读
  const unlockedList = Array.isArray(api.variables["Achievements"])
    ? api.variables["Achievements"].map(String)
    : [];
  const isUnlocked = (a) => unlockedList.includes(a.title);
  const unlockedCount = achievements.filter(isUnlocked).length;

  return (
    <Chat renderBubble={(msg) => {
      const isLastMsg = msg.messageIndex === msgs.length - 1;
      return (
        <div>
          <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" }}>
                  🏆 成就
                </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 = isUnlocked(a);
                  return (
                    <div
                      key={a.title}
                      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.title}
                        </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>
      );
    }} />
  );
}

代码做了什么:

  • <Chat renderBubble={...} /> 保留平台自带的消息列表、输入框和滚动,你只负责画每个气泡
  • msg.messageIndex === msgs.length - 1 让面板只出现在最后一条消息下面
  • msg.contentHtml 是已经渲染成 HTML 的消息
  • api.variables["Achievements"] 按变量名读这个列表。名字里有空格或大写字母,用方括号的写法都能读
  • 标题在列表里,这个成就就算解锁,和时刻用的是同一条规则

面板 → 前端代码 里文件旁边的状态应该是绿色的 正常。如果显示 错误,下面的提示会告诉你是哪一行。

不想自己写代码?

点画布右上角的 创作助手,说说你想要什么样的面板,它会替你写界面代码。


「变量越过阈值」和「变量变化」 ​

两个触发都是盯变量的,但触发的时机不一样。

变量越过阈值 在数值越过一条线的那一下触发:

gold: 80 → 95 → 101   ← 95→101 时触发(往上越过了 100)
gold: 101 → 150 → 200  ← 不触发(本来就在上面)
gold: 200 → 50 → 120   ← 50→120 时触发(又一次往上越过了 100)

适合里程碑、警告(「生命掉到 20 以下」)和死亡判定。

变量变化 在你选的那个变量每次变化时都触发,不管值是多少:

gold: 80 → 95   ← 触发
gold: 95 → 101  ← 触发
gold: 101 → 150 ← 触发

它得配一个 如果…… 条件才有用(「如果 Gold ≥ 100」),而且每变一次都要重新检查一遍。成就关心的是越过的那一下,所以用 变量越过阈值 最顺手。


更多成就 ​

每多一个成就,就是一条行为,加上成就页上的一行:

成就什么时候触发
话痨每 N 回合:50,最大触发次数 填 1
囤积狂变量越过阈值:Gold 上升到 500 以上
社交达人AI 说出关键词:信任你, 成了朋友
死里逃生变量越过阈值:Health 下降到 10 以下时,把开关「Was nearly dead」打开;第二条行为在 Health 上升到 50 以上、如果 Was nearly dead 是开着的时候,解锁这个时刻

速查 ​

你想要怎么做
数值达标时解锁什么时候触发:变量越过阈值,上升到以上
说了某句话时解锁玩家说出关键词 或 AI 说出关键词
弹一张卡片并记住它效果:解锁一个时刻,填好 收进哪个列表
确保只解锁一次同一个标题、同一个列表。列表里已经有的标题,时刻不会再触发
重开一局成就还在在列表变量上勾 跨存档保留(每个玩家自己的)
显示已拿到和还锁着的成就玩家界面 → 收集图鉴 模板,或者一个打开了 锁住直到 的列表部件
数一数一个数值变量,公式填 len(Achievements)
自己画的面板读这个列表的界面代码(见上面写代码的做法)

自己试试:可导入的演示 ​

下载这个 JSON 导入进去,就能看到能跑的版本:

recipe-13-demo-zh.json

怎么导入:

  1. 点左边的 创建,选 空白项目,再选 画布
  2. 在顶栏点 ⋮ → 导入文件,选下载好的 .json 文件。它会替换掉这张新卡里的所有内容
  3. 点 保存,再点 试玩

演示用的是写代码的版本:每个成就一个开关变量,行为负责打开它们并弹通知,成就面板写在界面代码里。


这是食谱 #13

成就可以和别的食谱搭着用:从战斗系统里数胜场,盯着商店里的金币,或者任务完成时解锁一个。行为之间互不干扰,加了成就也不会改变卡里其它部分怎么运作。