成就系统
做一套成就系统——当玩家达成特定里程碑(金币超过 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:金币
| 字段 | 填什么 | 为什么这样填 |
|---|---|---|
| 显示名称 | 金币 | 给你自己看的,方便在变量列表里识别 |
| ID | gold | 行为规则和消息渲染器代码用这个 ID 来读写 |
| 类型 | 数字 | 金币是数值,需要做加减运算 |
| 默认值 | 0 | 新会话从 0 金币开始 |
| 分类 | 属性 | 和角色属性相关的变量 |
| 行为规则 | 当前金币数量。AI 可以在叙事需要时通过指令修改。 | 告诉 AI 这是什么、怎么用 |
变量 2:战斗胜利次数
| 字段 | 填什么 | 为什么这样填 |
|---|---|---|
| 显示名称 | 战斗胜利次数 | 方便识别 |
| ID | combat_wins | 行为引用这个 ID |
| 类型 | 数字 | 计数用 |
| 默认值 | 0 | 从 0 开始 |
| 分类 | 属性 | 角色属性 |
| 行为规则 | 玩家在战斗中获胜的累计次数。AI 可以在战斗胜利时通过指令 +1。 | 让 AI 知道何时增加 |
变量 3:成就——一掷千金
| 字段 | 填什么 | 为什么这样填 |
|---|---|---|
| 显示名称 | 成就:一掷千金 | 方便识别 |
| ID | achievement_rich | 成就变量统一用 achievement_ 前缀 |
| 类型 | 布尔 | 只有"已解锁"和"未解锁"两种状态 |
| 默认值 | false | 开局未解锁 |
| 分类 | 成就 | 所有成就变量放在同一分类下,方便管理 |
| 行为规则 | 不要直接修改这个变量——成就由行为规则根据条件自动解锁,并弹出通知。手动修改会绕过通知系统。 | 成就必须通过行为规则触发才能正确弹出通知 |
变量 4:成就——初露锋芒
| 字段 | 填什么 | 为什么这样填 |
|---|---|---|
| 显示名称 | 成就:初露锋芒 | 方便识别 |
| ID | achievement_warrior | 同上前缀规则 |
| 类型 | 布尔 | 同上 |
| 默认值 | false | 开局未解锁 |
| 分类 | 成就 | 同上 |
| 行为规则 | 不要直接修改这个变量——成就由行为规则根据条件自动解锁,并弹出通知。手动修改会绕过通知系统。 | 成就必须通过行为规则触发才能正确弹出通知 |
变量 5:成就——探索先驱
| 字段 | 填什么 | 为什么这样填 |
|---|---|---|
| 显示名称 | 成就:探索先驱 | 方便识别 |
| ID | achievement_explorer | 同上前缀规则 |
| 类型 | 布尔 | 同上 |
| 默认值 | false | 开局未解锁 |
| 分类 | 成就 | 同上 |
| 行为规则 | 不要直接修改这个变量——成就由行为规则根据条件自动解锁,并弹出通知。手动修改会绕过通知系统。 | 成就必须通过行为规则触发才能正确弹出通知 |
为什么成就要用单独的布尔变量?
因为消息渲染器需要读取每个成就的状态来显示面板。如果只靠 maxFireCount 来防止重复触发,渲染器无法知道"这个成就到底解没解锁"——它看不到行为的触发计数。布尔变量是给渲染器和其他行为读取用的公开状态。
第 2 步:创建行为
我们需要 3 条行为——每个成就一条。
编辑器 → 左侧边栏 → 行为 标签页 → 逐个点击「添加行为」
行为 1:一掷千金(金币 > 100)
基本信息:
| 字段 | 填什么 | 为什么这样填 |
|---|---|---|
| 名称 | 成就:一掷千金 | 给你自己看的 |
| 最大触发次数 | 1 | 成就只解锁一次,触发一次后永远不再触发 |
触发器(WHEN):
| 字段 | 填什么 | 为什么这样填 |
|---|---|---|
| 触发类型 | 变量越过阈值 (variable-crossed) | 我们要检测 gold 越过 100 的那个瞬间 |
| 变量 ID | gold | 监控金币变量 |
| 方向 | 上升 (rises-above) | 从 <= 100 变到 > 100 时触发 |
| 阈值 | 100 | 里程碑数值 |
执行动作(DO):
| 动作类型 | 设置 | 作用 |
|---|---|---|
| 修改变量 | achievement_rich 设为 true | 标记成就已解锁,给渲染器读取 |
| 显示通知 | 消息 成就解锁:一掷千金,样式 achievement | 弹出金色成就通知 |
关于
maxFireCount: 1。 这个字段设在行为本身上(不是触发器上)。意思是"这条行为一辈子最多执行 1 次"。触发够了之后,不管 gold 怎么变化,这条行为都不会再跑了。这是成就系统的核心保障——没有人想看到同一个成就弹两次。
行为 2:初露锋芒(战斗胜利 > 5)
基本信息:
| 字段 | 填什么 | 为什么这样填 |
|---|---|---|
| 名称 | 成就:初露锋芒 | 给你自己看的 |
| 最大触发次数 | 1 | 同上 |
触发器(WHEN):
| 字段 | 填什么 | 为什么这样填 |
|---|---|---|
| 触发类型 | 变量越过阈值 (variable-crossed) | 检测 combat_wins 越过 5 的瞬间 |
| 变量 ID | combat_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」→ 粘贴以下代码:
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 步:保存并测试
- 点击编辑器顶部的「保存」
- 点击「开始游戏」或回到首页开一个新会话
- 最后一条消息下方应该能看到成就面板——3 个成就全部是灰色锁图标
- 测试金币成就:和 AI 对话,让角色获得超过 100 金币。当
gold从 <= 100 变到 > 100 时,屏幕上弹出金色通知"成就解锁:一掷千金",面板上第一个成就变成金色 - 测试战斗成就:让角色赢得 6 场战斗。当
combat_wins从 5 变到 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:
- gold 从 95 变到 101 → 触发 → 条件满足 → 执行(正确)
- gold 从 101 变到 102 → 触发 → 条件满足 → 又想执行(错误!虽然
maxFireCount挡住了,但引擎还是做了一次无谓的评估) - gold 从 102 变到 103 → 又触发 → 又检查条件……
用 variable-crossed:
- gold 从 95 变到 101 → 检测到上穿 100 → 触发 → 执行(正确)
- gold 从 101 变到 102 → 没有穿越事件 → 根本不触发(高效)
结论:精确的触发器 = 更少的无效评估 = 更好的性能和更清晰的逻辑。
扩展思路
做完基础的 3 个成就后,你可以用同样的模式扩展更多:
| 成就名称 | 变量 ID | 触发方式 | 条件 |
|---|---|---|---|
| 话痨 | achievement_talkative | 创建一个 message_count 变量,每回合 +1,越过 50 时触发 | variable-crossed,message_count 上穿 50 |
| 守财奴 | achievement_hoarder | gold 越过 500 时触发 | variable-crossed,gold 上穿 500 |
| 社交达人 | achievement_social | AI 说了关键词"成为朋友"或"信任你" | ai-keyword,条件 achievement_social eq false |
| 死而复生 | achievement_survivor | HP 越过 10(下穿,濒死)后又越过 50(上穿,恢复) | 两条行为联动 |
每加一个成就,只需要:
- 添加一个布尔变量(
achievement_xxx,默认false) - 添加一条行为(触发器 + 动作 +
maxFireCount: 1) - 在消息渲染器的
achievements数组里加一项
速查表
| 你想做的事 | 怎么做 |
|---|---|
| 数值达标时解锁成就 | 行为触发器选「变量越过阈值」(variable-crossed),方向选上升,填阈值 |
| 关键词触发成就 | 行为触发器选「玩家说了关键词」(keyword) 或「AI 说了关键词」(ai-keyword) |
| 确保成就只触发一次 | 行为设 maxFireCount: 1,关键词类再加条件 achievement_xxx eq false |
| 弹出金色成就通知 | 行为动作:显示通知,样式选 achievement |
| 在聊天里显示成就面板 | 消息渲染器读取布尔变量,渲染已解锁/未解锁状态 |
| 添加新成就 | 加布尔变量 + 加行为 + 渲染器数组加一项 |
直接试试——可导入的示例世界
下载这个 JSON 文件,导入即可体验完整效果:
导入方法:
- 进入 Yumina → 我的世界 → 创建新世界
- 在编辑器顶部点「更多操作」→「导入包」
- 选择下载的
.json文件 - 世界会被创建,所有变量、行为和渲染器都已预配置好
- 开一个新会话试试看
包含内容:
- 5 个变量(
gold、combat_wins、achievement_rich、achievement_warrior、achievement_explorer) - 4 条行为(一掷千金、初露锋芒、探索先驱 x2)
- 一个带成就面板的消息渲染器
