地图与场景导航
做一个可点击的地图界面——玩家点击某个地点 → 场景切换、知识条目切换、BGM 渐变过渡,AI 描述新区域的氛围。这篇教你怎么用变量、行为、知识库和消息渲染器搭出来。
你要做的东西
一个嵌入在聊天里的视觉地图导航系统:
- 地图 UI——最后一条消息下方显示一个网格布局的地图面板,每个地点是一个带 emoji 图标的按钮
- 当前位置高亮——玩家当前所在的地点按钮用不同颜色标出,一眼就能分辨
- 场景切换——点击某个地点 → 行为触发 → 切换知识条目(旧地点禁用、新地点启用)→ AI 描写到达新区域的场景
- BGM 渐变过渡——每个地点有自己的背景音乐,切换时用 crossfade(交叉淡入淡出)平滑过渡,不会突然断掉
- 四个地点——村庄、森林、洞穴、集市,各有独特的氛围描写和 BGM
原理
整个系统的核心是:按钮触发行为 → 行为切换变量 + 开关条目 + 渐变切歌 + 发送上下文 → AI 描述新场景。
玩家在地图上点击「森林」按钮
→ 代码调用 api.executeAction("go-forest")
→ 行为触发:
1. current_location 设为 "forest"
2. 禁用「村庄氛围」条目,启用「森林氛围」条目
3. crossfade 切换到森林 BGM
4. 请求 AI 回复,附带上下文「玩家从村庄前往森林」
→ AI 收到新的知识条目 + 上下文 → 描写森林的场景
→ 消息渲染器检测到 current_location 变了 → 地图上「森林」按钮变成高亮什么是 crossfade? 交叉淡入淡出是一种音频过渡技术——旧曲目渐渐变小声的同时,新曲目渐渐变大声,两段音乐有一小段时间同时播放。效果就像电影里的场景切换,音乐不会突然断掉再重新开始,而是丝滑地从一首过渡到另一首。在 Yumina 里,行为的「播放音乐」动作支持 crossfade 操作,你只需要指定新曲目 ID 和渐变时长就行。
一步步来
第 1 步:创建变量
我们需要 1 个变量来追踪玩家当前所在的地点。
编辑器 → 左侧边栏 → 变量 标签页 → 点击「添加变量」
| 字段 | 填什么 | 为什么这样填 |
|---|---|---|
| 显示名称 | 当前地点 | 给你自己看的,方便识别 |
| ID | current_location | 行为和消息渲染器用这个 ID 来读写 |
| 类型 | 字符串 | 因为值是文字("village"、"forest"、"cave"、"market") |
| 默认值 | village | 新会话从村庄开始 |
| 分类 | 自定义 | 地图系统专用分类 |
| 行为规则 | 不要修改这个变量。它由玩家的地图 UI 控制。当前值代表玩家所在的地点。 | 告诉 AI 不要自作主张改地点——只有玩家点击地图才能改 |
为什么用英文作为变量值而不用中文? 因为变量值会在代码里比较和传递。用英文(
"village"而不是"村庄")可以避免编码问题,也让行为规则的条件判断更可靠。中文只出现在给玩家看的 UI 标签和给 AI 看的知识条目里。
第 2 步:创建四个地点知识条目
每个地点需要一个知识条目,描述该地点的环境氛围。默认只启用「村庄」,其他三个禁用。
编辑器 → 知识库 标签页 → 逐个新建条目
条目 1:村庄氛围
| 字段 | 填什么 | 为什么这样填 |
|---|---|---|
| 名称 | 村庄氛围 | 给你自己看的 |
| 区域 | 预设 | 预设区的条目每次都会发给 AI |
| 启用 | 是(打开开关) | 游戏从村庄开始,所以默认启用 |
内容:
[当前地点:村庄]
玩家正在村庄中。描写场景时请体现以下氛围:
- 一座宁静的小村庄,石板路蜿蜒在木屋之间
- 炊烟从屋顶缓缓升起,空气里弥漫着面包和炖肉的香气
- 村民们在井边闲聊,铁匠铺传来有节奏的锤击声
- 远处的田野一片金黄,微风拂过麦穗荡起波浪
- 整体氛围是温暖、安宁、充满生活气息的条目 2:森林氛围
| 字段 | 填什么 | 为什么这样填 |
|---|---|---|
| 名称 | 森林氛围 | 给你自己看的 |
| 区域 | 预设 | 预设区 |
| 启用 | 否(关闭开关) | 等行为规则在地点切换时打开 |
内容:
[当前地点:森林]
玩家正在森林中。描写场景时请体现以下氛围:
- 参天古木遮蔽了大部分阳光,只有斑驳的光影洒在苔藓上
- 空气潮湿清新,混合着泥土、树脂和野花的气味
- 鸟鸣声从四面八方传来,偶尔有树枝折断的脆响
- 小径两旁的灌木丛里可能藏着野兔、鹿或更危险的东西
- 越往深处走,树木越密集,光线越昏暗
- 整体氛围是神秘、原始、充满未知的条目 3:洞穴氛围
| 字段 | 填什么 | 为什么这样填 |
|---|---|---|
| 名称 | 洞穴氛围 | 给你自己看的 |
| 区域 | 预设 | 预设区 |
| 启用 | 否(关闭开关) | 等行为规则打开 |
内容:
[当前地点:洞穴]
玩家正在洞穴中。描写场景时请体现以下氛围:
- 岩壁上附着发光的菌类,散发微弱的蓝绿色光芒
- 水滴从钟乳石上滴落,每一声都在洞穴里回荡
- 空气冰冷潮湿,带着矿石和地下水的金属气味
- 脚下的地面湿滑不平,深处的隧道黑暗到伸手不见五指
- 偶尔传来不明的低吼声或岩石碎裂声——洞穴深处可能并不安全
- 整体氛围是阴暗、压抑、暗藏危险的条目 4:集市氛围
| 字段 | 填什么 | 为什么这样填 |
|---|---|---|
| 名称 | 集市氛围 | 给你自己看的 |
| 区域 | 预设 | 预设区 |
| 启用 | 否(关闭开关) | 等行为规则打开 |
内容:
[当前地点:集市]
玩家正在集市中。描写场景时请体现以下氛围:
- 五颜六色的帐篷和摊位一字排开,商品琳琅满目
- 商人们大声吆喝,讨价还价的声音此起彼伏
- 空气中混杂着香料、烤肉、皮革和花卉的气味
- 魔法道具店的橱窗里闪烁着奇异的光芒,炼金师在角落里调配药水
- 人群熙熙攘攘,各种种族和职业的旅行者汇聚于此
- 整体氛围是热闹、喧嚣、充满商业活力的为什么默认只启用「村庄」? 因为游戏从村庄开始。如果四个条目全部启用,AI 会同时收到村庄、森林、洞穴、集市的描写指令,不知道该描写哪个地点。每次只启用一个,AI 就能准确把握当前场景。
第 3 步:(可选)上传地点 BGM
如果你想让每个地点有不同的背景音乐,需要先上传音频文件。
编辑器 → 音频 标签页 → 添加音轨
| 音轨 ID | 名称 | 类型 | 循环 | 淡入 | 淡出 |
|---|---|---|---|---|---|
bgm_village | 村庄 | BGM | 是 | 2 秒 | 2 秒 |
bgm_forest | 森林 | BGM | 是 | 2 秒 | 2 秒 |
bgm_cave | 洞穴 | BGM | 是 | 2 秒 | 2 秒 |
bgm_market | 集市 | BGM | 是 | 2 秒 | 2 秒 |
没有音频文件怎么办? 可以跳过这一步。地图导航的核心是知识条目切换,BGM 是锦上添花。你随时可以后续补上。
在 BGM 播放列表里,把 autoPlay 设为 true,默认播放 bgm_village。后续地点切换时,行为规则会用 crossfade 动作渐变切歌。
crossfade 的工作原理
普通的「停止旧曲 → 播放新曲」会有一瞬间的断裂——音乐突然没了,然后突然响起另一首,体验很突兀。crossfade 的做法不同:旧曲目和新曲目有一段重叠播放的时间窗口。假设你设置了 3 秒的渐变时长:
- 第 0 秒:旧曲音量 100%,新曲开始播放、音量 0%
- 第 1.5 秒:旧曲音量 50%,新曲音量 50%
- 第 3 秒:旧曲音量 0%(停止),新曲音量 100%
效果就像调色板上两种颜色慢慢混合再分开——过渡丝滑,玩家几乎感觉不到"换歌了",只是氛围自然地变了。
第 4 步:创建行为规则
每个地点需要一条行为——玩家点击地图按钮时触发对应的行为,完成地点切换的所有操作。
编辑器 → 行为 标签页 → 逐个添加行为
行为 1:前往村庄
WHEN(什么时候检查):
| 字段 | 填什么 | 为什么这样填 |
|---|---|---|
| 触发器类型 | 动作 | 当消息渲染器代码调用 executeAction("go-village") 时触发 |
| 动作 ID | go-village | 对应地图按钮的点击事件 |
DO(做什么):
| # | 动作类型 | 设置 | 作用 |
|---|---|---|---|
| 1 | 修改变量 | current_location 设为 village | 更新当前地点 |
| 2 | 禁用知识条目 | 森林氛围 | 关掉其他地点的条目 |
| 3 | 禁用知识条目 | 洞穴氛围 | 关掉其他地点的条目 |
| 4 | 禁用知识条目 | 集市氛围 | 关掉其他地点的条目 |
| 5 | 启用知识条目 | 村庄氛围 | 打开目标地点的条目 |
| 6 | 播放音乐 | bgm_village,操作:crossfade,渐变时长 3 秒 | 渐变切换到村庄 BGM |
| 7 | 请求 AI 回复 | 上下文:玩家回到了村庄。请描写玩家到达村庄时看到的场景。 | 让 AI 生成一段到达描写 |
为什么要先「禁用」其他三个、再「启用」目标? 因为玩家可能从任何地点出发。如果玩家从森林去村庄,需要关掉森林条目;如果从洞穴去村庄,需要关掉洞穴条目。最简单的做法是不管从哪来,先把其他地点全关掉,再打开目标地点。这样无论玩家从哪里出发,结果都是正确的。
行为 2:前往森林
WHEN:
| 字段 | 填什么 |
|---|---|
| 触发器类型 | 动作 |
| 动作 ID | go-forest |
DO:
| # | 动作类型 | 设置 | 作用 |
|---|---|---|---|
| 1 | 修改变量 | current_location 设为 forest | 更新当前地点 |
| 2 | 禁用知识条目 | 村庄氛围 | 关掉其他地点 |
| 3 | 禁用知识条目 | 洞穴氛围 | 关掉其他地点 |
| 4 | 禁用知识条目 | 集市氛围 | 关掉其他地点 |
| 5 | 启用知识条目 | 森林氛围 | 打开目标地点 |
| 6 | 播放音乐 | bgm_forest,操作:crossfade,渐变时长 3 秒 | 渐变切换 BGM |
| 7 | 请求 AI 回复 | 上下文:玩家进入了森林。请描写玩家踏入森林时看到的场景。 | AI 描写到达场景 |
行为 3:前往洞穴
WHEN:
| 字段 | 填什么 |
|---|---|
| 触发器类型 | 动作 |
| 动作 ID | go-cave |
DO:
| # | 动作类型 | 设置 | 作用 |
|---|---|---|---|
| 1 | 修改变量 | current_location 设为 cave | 更新当前地点 |
| 2 | 禁用知识条目 | 村庄氛围 | 关掉其他地点 |
| 3 | 禁用知识条目 | 森林氛围 | 关掉其他地点 |
| 4 | 禁用知识条目 | 集市氛围 | 关掉其他地点 |
| 5 | 启用知识条目 | 洞穴氛围 | 打开目标地点 |
| 6 | 播放音乐 | bgm_cave,操作:crossfade,渐变时长 3 秒 | 渐变切换 BGM |
| 7 | 请求 AI 回复 | 上下文:玩家走进了洞穴。请描写玩家进入洞穴时看到的场景。 | AI 描写到达场景 |
行为 4:前往集市
WHEN:
| 字段 | 填什么 |
|---|---|
| 触发器类型 | 动作 |
| 动作 ID | go-market |
DO:
| # | 动作类型 | 设置 | 作用 |
|---|---|---|---|
| 1 | 修改变量 | current_location 设为 market | 更新当前地点 |
| 2 | 禁用知识条目 | 村庄氛围 | 关掉其他地点 |
| 3 | 禁用知识条目 | 森林氛围 | 关掉其他地点 |
| 4 | 禁用知识条目 | 洞穴氛围 | 关掉其他地点 |
| 5 | 启用知识条目 | 集市氛围 | 打开目标地点 |
| 6 | 播放音乐 | bgm_market,操作:crossfade,渐变时长 3 秒 | 渐变切换 BGM |
| 7 | 请求 AI 回复 | 上下文:玩家来到了集市。请描写玩家走进集市时看到的场景。 | AI 描写到达场景 |
四条行为结构完全一样,只是目标地点不同。 每条行为做三件事:(1) 更新变量 → (2) 切换条目 + 切歌 → (3) 让 AI 描写新场景。模式统一,以后想加新地点只需要复制一条行为、改改参数就行。
为什么用「请求 AI 回复」而不是「告诉 AI」?
「告诉 AI」只是往上下文里注入一段隐藏文字,AI 不会立刻回复——要等玩家发下一条消息后 AI 才会看到它。而「请求 AI 回复」会立刻触发一条 AI 回复,并且把你写的上下文作为这次回复的背景信息。在地图导航中,我们希望玩家一点按钮就看到 AI 描写新场景,而不是还要自己再发一条消息,所以用「请求 AI 回复」更合适。
第 5 步:做地图消息渲染器
这是让地图 UI 出现在聊天界面的关键步骤。我们用样式化的 div 按钮 + emoji 图标来做一个简易"地图"——不需要实际的图片资源。
编辑器 → 消息渲染器 标签页 → 选「自定义 TSX」→ 粘贴以下代码:
export default function Renderer({ content, renderMarkdown, messageIndex }) {
const api = useYumina();
// ---- 读取变量 ----
const currentLocation = String(api.variables.current_location || "village");
// ---- 地点配置 ----
const locations = [
{ id: "village", label: "村庄", icon: "🏘️", action: "go-village",
color: "#92400e", bg: "#fef3c7", border: "#f59e0b",
activeBg: "#f59e0b", activeColor: "#ffffff" },
{ id: "forest", label: "森林", icon: "🌲", action: "go-forest",
color: "#166534", bg: "#dcfce7", border: "#22c55e",
activeBg: "#22c55e", activeColor: "#ffffff" },
{ id: "cave", label: "洞穴", icon: "🕳️", action: "go-cave",
color: "#3b0764", bg: "#f3e8ff", border: "#a855f7",
activeBg: "#a855f7", activeColor: "#ffffff" },
{ id: "market", label: "集市", icon: "🏪", action: "go-market",
color: "#9a3412", bg: "#ffedd5", border: "#f97316",
activeBg: "#f97316", activeColor: "#ffffff" },
];
// ---- 判断是否是最后一条消息 ----
const msgs = api.messages || [];
const isLastMsg = messageIndex === msgs.length - 1;
return (
<div>
{/* 正常渲染消息文字 */}
<div
style={{ color: "#e2e8f0", lineHeight: 1.7 }}
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
/>
{/* 地图面板——只在最后一条消息上显示 */}
{isLastMsg && (
<div style={{
marginTop: "16px",
padding: "16px",
background: "rgba(15,23,42,0.6)",
borderRadius: "12px",
border: "1px solid #334155",
}}>
{/* 标题 */}
<div style={{
fontSize: "13px",
color: "#94a3b8",
marginBottom: "12px",
fontWeight: "600",
letterSpacing: "0.05em",
}}>
世界地图
</div>
{/* 2x2 网格布局 */}
<div style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "10px",
}}>
{locations.map((loc) => {
const isActive = currentLocation === loc.id;
return (
<button
key={loc.id}
onClick={() => {
if (!isActive) {
api.executeAction(loc.action);
}
}}
style={{
padding: "14px 10px",
background: isActive
? loc.activeBg
: loc.bg,
border: `2px solid ${isActive ? loc.activeBg : loc.border}`,
borderRadius: "10px",
color: isActive ? loc.activeColor : loc.color,
fontSize: "14px",
fontWeight: "700",
cursor: isActive ? "default" : "pointer",
opacity: isActive ? 1 : 0.85,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "6px",
transition: "all 0.2s ease",
}}
>
<span style={{ fontSize: "28px" }}>{loc.icon}</span>
<span>{loc.label}</span>
{isActive && (
<span style={{
fontSize: "11px",
opacity: 0.9,
fontWeight: "500",
}}>
当前位置
</span>
)}
</button>
);
})}
</div>
</div>
)}
</div>
);
}代码逐行解释:
api.variables.current_location-- 读取当前地点变量的值locations-- 一个配置数组,定义了每个地点的 ID、中文标签、emoji 图标、对应的行为动作 ID、以及普通状态和高亮状态的颜色。想加新地点只需要往数组里添一项isLastMsg-- 只在最后一条消息上显示地图,不是每条消息都显示isActive-- 判断这个按钮是否是当前所在地点。如果是,按钮会用高亮颜色,并显示「当前位置」文字!isActive时才调用executeAction-- 防止玩家重复点击当前地点。你已经在村庄了,再点村庄不会触发任何事gridTemplateColumns: "1fr 1fr"-- 两列等宽的网格布局,四个按钮排成 2x2 的方阵transition: "all 0.2s ease"-- 鼠标悬停时有微妙的动画过渡
想改成其他布局?
把 gridTemplateColumns 改成 "1fr 1fr 1fr" 就是三列布局;改成 "1fr" 就是单列竖排。gap 控制按钮之间的间距。布局完全由 CSS Grid 控制,你可以随意调整。
第 6 步:保存并测试
- 点击编辑器顶部的「保存」
- 点击「开始游戏」或回到首页开一个新会话
- 你会看到 AI 的消息下方出现一个地图面板,有四个地点按钮。「村庄」按钮是高亮的,显示「当前位置」
- 点击「森林」按钮——AI 立刻回复一段描写森林场景的文字,地图上「森林」按钮变成高亮
- 如果你配了 BGM,此时应该能听到从村庄曲渐变到森林曲的过渡
- 点击「洞穴」——场景再次切换,AI 描写洞穴,BGM 渐变过渡
- 尝试点击已高亮的当前地点——不会有任何反应(已经在这里了)
- 正常和 AI 对话,然后再切换地点——一切正常,地图始终显示在最后一条消息下方
如果遇到问题:
| 现象 | 可能的原因 | 解决方法 |
|---|---|---|
| 看不到地图面板 | 消息渲染器代码没保存或有语法错误 | 检查消息渲染器底部的编译状态,应该显示绿色「OK」 |
| 点了按钮没反应 | 行为的动作 ID 不匹配 | 确认行为的动作 ID(go-village 等)和代码里 locations 数组的 action 字段完全一致 |
| AI 没有回复新场景 | 行为里没加「请求 AI 回复」动作 | 检查每条行为的最后一个动作是否是「请求 AI 回复」 |
| 地图上高亮没变 | 变量没被修改 | 检查行为的第一个动作是否是「修改变量」,且目标变量是 current_location |
| BGM 没切换 | 音轨 ID 不匹配或没上传音频 | 确认行为里的音轨 ID 和音频标签页里的音轨 ID 一致 |
| BGM 切换时有断裂感 | 没有用 crossfade | 确认「播放音乐」动作的操作选的是 crossfade,渐变时长至少 2-3 秒 |
| 四个条目同时启用 | 行为里忘了禁用其他条目 | 每条行为必须先禁用其他三个地点的条目,再启用目标地点的条目 |
扩展思路
加更多地点
想加第五个地点(比如「港口」)?需要做四件事:
- 知识库 标签页 → 新建「港口氛围」条目(默认禁用)
- 音频 标签页 → 新建
bgm_port音轨(可选) - 行为 标签页 → 新建「前往港口」行为,动作 ID
go-port,动作模式和前四个一样。同时回到前四个行为里,各自加一行「禁用知识条目:港口氛围」 - 消息渲染器 → 在
locations数组里加一项:
{ id: "port", label: "港口", icon: "⚓", action: "go-port",
color: "#1e40af", bg: "#dbeafe", border: "#3b82f6",
activeBg: "#3b82f6", activeColor: "#ffffff" },网格布局会自动适配——5 个按钮会变成第一行 2 个、第二行 2 个、第三行 1 个。
限制可通行路线
如果你不希望玩家能从任意地点直接跳到任意地点(比如"必须穿过森林才能到洞穴"),可以在消息渲染器里加判断逻辑:
// 定义可达路线
const routes = {
village: ["forest", "market"], // 村庄可以去森林和集市
forest: ["village", "cave"], // 森林可以去村庄和洞穴
cave: ["forest"], // 洞穴只能回森林
market: ["village"], // 集市只能回村庄
};
const reachable = routes[currentLocation] || [];
// 在 button 的 onClick 和 style 里加判断
const canGo = reachable.includes(loc.id);
// ...
onClick={() => {
if (!isActive && canGo) {
api.executeAction(loc.action);
}
}}
style={{
// ...
opacity: isActive ? 1 : canGo ? 0.85 : 0.3,
cursor: isActive ? "default" : canGo ? "pointer" : "not-allowed",
}}不可达的地点会变成半透明且不可点击,玩家一看就知道"现在去不了那里"。
速查表
| 你想做的事 | 怎么做 |
|---|---|
| 追踪玩家当前地点 | 字符串变量 current_location,值是地点 ID |
| 点按钮切换场景 | 行为触发器选「动作」,动作 ID 和消息渲染器里的 executeAction() 一致 |
| 切换地点氛围 | 行为动作:先「禁用知识条目」关掉旧地点,再「启用知识条目」打开新地点 |
| BGM 平滑过渡 | 行为动作「播放音乐」,操作选 crossfade,渐变时长 2-3 秒 |
| 点击后 AI 立刻描写新场景 | 行为动作用「请求 AI 回复」,附上到达上下文 |
| 当前地点高亮 | 消息渲染器里比较 current_location 和按钮 ID,匹配的用高亮样式 |
| 防止重复点击当前地点 | if (!isActive) 判断后才调用 executeAction |
| 地图只在最后一条消息显示 | 消息渲染器里判断 isLastMsg |
直接试试——可导入的示例世界
下载这个 JSON 文件,导入即可体验完整效果:
导入方法:
- 进入 Yumina → 我的世界 → 创建新世界
- 在编辑器顶部点「更多操作」→「导入包」
- 选择下载的
.json文件 - 世界会被创建,所有变量、条目、行为和渲染器都已预配置好
- 开一个新会话试试看
包含内容:
- 1 个变量(
current_location追踪当前地点) - 4 个知识条目(村庄 / 森林 / 洞穴 / 集市氛围,默认只启用村庄)
- 4 条行为(前往村庄 / 前往森林 / 前往洞穴 / 前往集市,各自切换条目 + 渐变切歌 + 请求 AI 描写)
- 一个消息渲染器(2x2 网格地图面板,当前地点高亮)
- 4 条 BGM 音轨(需要你自己上传音频文件替换 URL)
这是实战配方 #12
这个配方展示了行为系统 + 消息渲染器的经典组合——用按钮触发行为,行为里同时做变量更新、条目切换、BGM crossfade、AI 回复请求。同样的模式可以用来做楼层导航、房间探索、世界传送门等任何"在多个场景之间移动"的东西。
