地图与场景导航
构建一个可点击的地图界面 -- 玩家点击一个地点 → 场景切换、知识条目交换、BGM 淡入淡出,AI 描述新区域的氛围。本食谱展示如何用变量、行为、知识条目和 Root Component 将它们全部串联起来。
你将构建什么
一个嵌入聊天中的可视化地图导航系统:
- 地图 UI -- 最后一条消息下方的网格布局地图面板,每个地点是一个带表情图标的按钮
- 当前位置高亮 -- 玩家当前所在地点的按钮使用不同颜色,一眼就能识别
- 场景切换 -- 点击一个地点 → 行为触发 → 知识条目交换(旧地点禁用,新地点启用)→ AI 描述到达新区域的场景
- BGM 淡入淡出 -- 每个地点有自己的背景音乐;切换时使用 crossfade(交叉溶解)实现平滑过渡,而不是突然中断
- 四个地点 -- 村庄、森林、洞穴、市场,各有独特的氛围描述和 BGM
工作原理
整个系统的核心是:按钮触发行为 → 行为更新变量 + 切换条目 + 淡入淡出音乐 + 请求 AI 回复 → AI 描述新场景。
玩家在地图上点击「森林」按钮
→ 代码调用 api.executeAction("go-forest")
→ 行为触发:
1. current_location 设为 "forest"
2. 禁用「村庄氛围」条目,启用「森林氛围」条目
3. 淡入淡出到森林 BGM
4. 请求 AI 回复,上下文:「玩家从村庄前往森林」
→ AI 接收到新的知识条目 + 上下文 → 描述森林场景
→ Root Component 检测到 current_location 变化 → 地图上「森林」按钮变为高亮什么是 crossfade? Crossfade 是一种音频过渡技术 -- 旧音轨逐渐淡出,同时新音轨逐渐淡入,两者在短暂的重叠期间同时播放。效果就像电影场景过渡:音乐永远不会突然中断再重新开始,而是从一首平滑地流到下一首。在 Yumina 中,「播放音乐」行为动作支持 crossfade 操作 -- 你只需指定新音轨 ID 和淡入淡出时长。
逐步操作
第 1 步:创建变量
我们需要 1 个变量来跟踪玩家的当前位置。
编辑器 → 侧边栏 → 变量 选项卡 → 点击「添加变量」
| 字段 | 值 | 原因 |
|---|---|---|
| 显示名称 | Current Location | 供你自己参考 |
| ID | current_location | 行为和 Root Component 通过此 ID 读写 |
| 类型 | String | 因为值是文本("village"、"forest"、"cave"、"market") |
| 默认值 | village | 新会话从村庄开始 |
| 分类 | Custom | 地图系统专用分类 |
| 行为规则 | Do not modify this variable. It is controlled by the player's map UI. The current value represents the player's location. | 告诉 AI 不要自行修改位置 -- 只有玩家的地图点击可以 |
第 2 步:创建四个地点知识条目
每个地点需要一个描述其氛围的知识条目。只有「村庄」默认启用;其他三个禁用。
编辑器 → 知识 选项卡 → 逐个创建条目
条目 1:村庄氛围
| 字段 | 值 | 原因 |
|---|---|---|
| 名称 | Village Atmosphere | 供你自己参考 |
| 区域 | System Presets | Presets 区域的条目每次都会发送给 AI |
| 启用 | 是(开启) | 游戏从村庄开始,所以默认启用 |
内容:
[Current Location: Village]
The player is in the Village. When describing the scene, convey the following atmosphere:
- A peaceful little village with cobblestone paths winding between wooden houses
- Wisps of smoke rise from rooftops; the air carries the scent of fresh bread and stew
- Villagers chat by the well; rhythmic hammer strikes ring out from the blacksmith's shop
- Golden wheat fields stretch into the distance, swaying gently in the breeze
- The overall mood is warm, tranquil, and full of everyday life条目 2:森林氛围
| 字段 | 值 | 原因 |
|---|---|---|
| 名称 | Forest Atmosphere | 供你自己参考 |
| 区域 | System Presets | Presets 区域 |
| 启用 | 否(关闭) | 当玩家前往此处时由行为启用 |
内容:
[Current Location: Forest]
The player is in the Forest. When describing the scene, convey the following atmosphere:
- Towering ancient trees block most of the sunlight; only dappled light filters through onto the moss below
- The air is damp and fresh, a mix of earth, tree resin, and wildflower scents
- Birdsong comes from every direction, punctuated by the occasional sharp crack of a snapping branch
- Bushes along the trail might conceal rabbits, deer, or something more dangerous
- The deeper you go, the denser the trees and the dimmer the light
- The overall mood is mysterious, primal, and full of the unknown条目 3:洞穴氛围
| 字段 | 值 | 原因 |
|---|---|---|
| 名称 | Cave Atmosphere | 供你自己参考 |
| 区域 | System Presets | Presets 区域 |
| 启用 | 否(关闭) | 由行为启用 |
内容:
[Current Location: Cave]
The player is in the Cave. When describing the scene, convey the following atmosphere:
- Bioluminescent fungi cling to the rock walls, casting a faint blue-green glow
- Water drips from stalactites, each drop echoing through the cavern
- The air is cold and damp, carrying a metallic scent of minerals and underground streams
- The ground underfoot is slippery and uneven; tunnels deeper in are pitch black
- Occasionally, an unidentifiable low growl or the crack of shifting rock reverberates from the depths — it may not be safe down here
- The overall mood is dark, oppressive, and laced with hidden danger条目 4:市场氛围
| 字段 | 值 | 原因 |
|---|---|---|
| 名称 | Market Atmosphere | 供你自己参考 |
| 区域 | System Presets | Presets 区域 |
| 启用 | 否(关闭) | 由行为启用 |
内容:
[Current Location: Market]
The player is in the Market. When describing the scene, convey the following atmosphere:
- Colorful tents and stalls line up in rows, brimming with every kind of goods
- Merchants shout their wares; the sounds of haggling rise and fall on all sides
- The air is a blend of spices, roasting meat, leather, and flowers
- A magic-item shop's display window flickers with strange light; an alchemist mixes potions in a corner
- Crowds bustle through — travelers of every race and profession converge here
- The overall mood is lively, bustling, and full of commercial energy为什么只有「村庄」默认启用? 因为游戏从村庄开始。如果四个条目同时启用,AI 会同时收到村庄、森林、洞穴和市场的氛围描述,不知道该描述哪个场景。每次只启用一个条目可以让 AI 专注于当前地点。
第 3 步:(可选)上传地点 BGM
如果你希望每个地点有自己的背景音乐,需要先上传音频文件。
编辑器 → 音频 选项卡 → 添加音轨
| 音轨 ID | 名称 | 类型 | 循环 | 淡入 | 淡出 |
|---|---|---|---|---|---|
bgm_village | Village | BGM | 是 | 2秒 | 2秒 |
bgm_forest | Forest | BGM | 是 | 2秒 | 2秒 |
bgm_cave | Cave | BGM | 是 | 2秒 | 2秒 |
bgm_market | 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(触发器):
| 字段 | 值 | 原因 |
|---|---|---|
| 触发类型 | Action | 当 Root Component 代码调用 executeAction("go-village") 时触发 |
| Action ID | go-village | 对应地图按钮的点击事件 |
DO(动作):
| # | 动作类型 | 设置 | 用途 |
|---|---|---|---|
| 1 | 修改变量 | current_location 设为 village | 更新当前位置 |
| 2 | 禁用知识条目 | Forest Atmosphere | 关闭其他地点条目 |
| 3 | 禁用知识条目 | Cave Atmosphere | 关闭其他地点条目 |
| 4 | 禁用知识条目 | Market Atmosphere | 关闭其他地点条目 |
| 5 | 启用知识条目 | Village Atmosphere | 开启目标地点的条目 |
| 6 | 播放音乐 | bgm_village,操作:crossfade,淡入淡出时长 3秒 | 淡入淡出切换到村庄 BGM |
| 7 | 请求 AI 回复 | 上下文:The player has returned to the Village. Describe the scene the player sees upon arriving. | 让 AI 生成到达描述 |
为什么先禁用其他三个,再启用目标? 因为玩家可能从任何地点出发。如果他们从森林去村庄,需要关闭森林条目;如果从洞穴来,需要关闭洞穴条目。最简单的方法是总是关闭所有其他地点,然后开启目标 -- 无论玩家从哪里来,结果都是正确的。
行为 2:前往森林
WHEN:
| 字段 | 值 |
|---|---|
| 触发类型 | Action |
| Action ID | go-forest |
DO:
| # | 动作类型 | 设置 | 用途 |
|---|---|---|---|
| 1 | 修改变量 | current_location 设为 forest | 更新当前位置 |
| 2 | 禁用知识条目 | Village Atmosphere | 关闭其他地点 |
| 3 | 禁用知识条目 | Cave Atmosphere | 关闭其他地点 |
| 4 | 禁用知识条目 | Market Atmosphere | 关闭其他地点 |
| 5 | 启用知识条目 | Forest Atmosphere | 开启目标地点 |
| 6 | 播放音乐 | bgm_forest,操作:crossfade,淡入淡出时长 3秒 | 淡入淡出切换 BGM |
| 7 | 请求 AI 回复 | 上下文:The player has entered the Forest. Describe the scene the player sees as they step into the forest. | AI 描述到达场景 |
行为 3:前往洞穴
WHEN:
| 字段 | 值 |
|---|---|
| 触发类型 | Action |
| Action ID | go-cave |
DO:
| # | 动作类型 | 设置 | 用途 |
|---|---|---|---|
| 1 | 修改变量 | current_location 设为 cave | 更新当前位置 |
| 2 | 禁用知识条目 | Village Atmosphere | 关闭其他地点 |
| 3 | 禁用知识条目 | Forest Atmosphere | 关闭其他地点 |
| 4 | 禁用知识条目 | Market Atmosphere | 关闭其他地点 |
| 5 | 启用知识条目 | Cave Atmosphere | 开启目标地点 |
| 6 | 播放音乐 | bgm_cave,操作:crossfade,淡入淡出时长 3秒 | 淡入淡出切换 BGM |
| 7 | 请求 AI 回复 | 上下文:The player has entered the Cave. Describe the scene the player sees as they step inside. | AI 描述到达场景 |
行为 4:前往市场
WHEN:
| 字段 | 值 |
|---|---|
| 触发类型 | Action |
| Action ID | go-market |
DO:
| # | 动作类型 | 设置 | 用途 |
|---|---|---|---|
| 1 | 修改变量 | current_location 设为 market | 更新当前位置 |
| 2 | 禁用知识条目 | Village Atmosphere | 关闭其他地点 |
| 3 | 禁用知识条目 | Forest Atmosphere | 关闭其他地点 |
| 4 | 禁用知识条目 | Cave Atmosphere | 关闭其他地点 |
| 5 | 启用知识条目 | Market Atmosphere | 开启目标地点 |
| 6 | 播放音乐 | bgm_market,操作:crossfade,淡入淡出时长 3秒 | 淡入淡出切换 BGM |
| 7 | 请求 AI 回复 | 上下文:The player has arrived at the Market. Describe the scene the player sees as they walk in. | AI 描述到达场景 |
四个行为的结构完全相同 -- 只是目标地点不同。 每个行为做三件事:(1) 更新变量 → (2) 交换条目 + 淡入淡出音乐 → (3) 让 AI 描述新场景。模式是统一的,以后添加新地点只需复制一个行为并调整参数。
为什么用「请求 AI 回复」而不是「告诉 AI」?
「告诉 AI」只是将隐藏文本注入上下文 -- AI 不会立即回复。它会等到玩家发送下一条消息。「请求 AI 回复」立即触发 AI 回复,你的文本作为该回复的背景上下文。对于地图导航,我们希望玩家点击按钮的瞬间就能看到 AI 描述新场景,而不是还要再发一条消息。所以「请求 AI 回复」在这里更合适。
第 5 步:在 Root Component 中添加地图面板
这一步让地图 UI 出现在聊天界面中。我们用带表情图标的样式化 div 按钮来创建一个简单的「地图」-- 不需要图片素材。
编辑器 → 自定义 UI 部分 → 打开 index.tsx → 粘贴以下代码(替换默认的 return <Chat />):
export default function MyWorld() {
const api = useYumina();
// ---- 读取变量 ----
const currentLocation = String(api.variables.current_location || "village");
// ---- 地点配置 ----
const locations = [
{ id: "village", label: "Village", icon: "🏘️", action: "go-village",
color: "#92400e", bg: "#fef3c7", border: "#f59e0b",
activeBg: "#f59e0b", activeColor: "#ffffff" },
{ id: "forest", label: "Forest", icon: "🌲", action: "go-forest",
color: "#166534", bg: "#dcfce7", border: "#22c55e",
activeBg: "#22c55e", activeColor: "#ffffff" },
{ id: "cave", label: "Cave", icon: "🕳️", action: "go-cave",
color: "#3b0764", bg: "#f3e8ff", border: "#a855f7",
activeBg: "#a855f7", activeColor: "#ffffff" },
{ id: "market", label: "Market", icon: "🏪", action: "go-market",
color: "#9a3412", bg: "#ffedd5", border: "#f97316",
activeBg: "#f97316", activeColor: "#ffffff" },
];
// ---- 消息列表,用于找到最后一条 ----
const msgs = api.messages || [];
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: "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",
}}>
WORLD MAP
</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",
}}>
You are here
</span>
)}
</button>
);
})}
</div>
</div>
)}
</div>
);
}} />
);
}逐行说明:
api.variables.current_location-- 读取当前位置变量的值locations-- 一个配置数组,定义了每个地点的 ID、英文标签、表情图标、行为 action ID,以及普通和高亮状态的颜色。要添加新地点,只需在数组中追加一个条目isLastMsg-- 地图只显示在最后一条消息上,而不是每条消息都显示isActive-- 检查此按钮是否匹配当前位置。如果是,按钮使用高亮颜色并显示「You are here」- 在调用
executeAction之前检查!isActive-- 防止玩家重复点击当前位置。你已经在村庄了;再次点击村庄什么都不做 gridTemplateColumns: "1fr 1fr"-- 两列等宽的网格布局,四个按钮排列成 2x2 网格transition: "all 0.2s ease"-- 悬停时的微妙动画
想要不同的布局?
将 gridTemplateColumns 改为 "1fr 1fr 1fr" 可以变成三列,或者 "1fr" 变成单列垂直堆叠。gap 控制按钮间距。布局完全由 CSS Grid 控制 -- 随意调整。
第 6 步:保存并测试
- 点击编辑器顶部的 保存
- 点击 开始游戏 或返回主页开始新会话
- 你会在 AI 消息下方看到一个地图面板,有四个地点按钮。「Village」按钮高亮显示,显示「You are here」
- 点击 Forest -- AI 立即用一段文字描述森林场景,地图上「Forest」按钮变为高亮
- 如果你配置了 BGM,应该能听到音乐从村庄音轨淡入淡出到森林音轨
- 点击 Cave -- 场景再次切换,AI 描述洞穴,BGM 淡入淡出
- 试着点击当前高亮的地点 -- 什么都不会发生(你已经在那里了)
- 正常与 AI 聊天,然后切换地点 -- 一切正常;地图始终在最后一条消息底部
如果出了问题:
| 症状 | 可能原因 | 修复方法 |
|---|---|---|
| 看不到地图面板 | Root Component 代码没有保存或有语法错误 | 检查自定义 UI 面板底部的编译状态 -- 应该显示绿色「OK」 |
| 点击按钮没有反应 | 行为的 action ID 不匹配 | 确认行为的 action ID(go-village 等)与代码中 locations 数组的 action 字段完全一致 |
| AI 没有回复新场景 | 行为缺少「请求 AI 回复」动作 | 检查每个行为的最后一个动作是否为「请求 AI 回复」 |
| 地图高亮没有变化 | 变量没有被更新 | 检查每个行为的第一个动作是否为「修改变量」,目标为 current_location |
| BGM 没有切换 | 音轨 ID 不匹配或音频未上传 | 确认行为中的音轨 ID 与音频选项卡中的音轨 ID 匹配 |
| BGM 过渡听起来很突兀 | 没有使用 crossfade | 确认「播放音乐」动作的操作设为 crossfade,淡入淡出时长至少 2-3 秒 |
| 四个条目同时启用 | 行为忘记禁用其他条目 | 每个行为必须在启用目标地点条目之前禁用其他三个地点的条目 |
扩展思路
添加更多地点
想添加第五个地点(比如「港口」)?需要做四件事:
- 知识 选项卡 → 创建「港口氛围」条目(默认禁用)
- 音频 选项卡 → 创建
bgm_harbor音轨(可选) - 行为 选项卡 → 创建「前往港口」行为,action ID 为
go-harbor,与其他四个相同的动作模式。同时回到现有的四个行为中,各添加一个「禁用知识条目:港口氛围」动作 - Root Component → 在
locations数组中添加一个条目:
{ id: "harbor", label: "Harbor", icon: "⚓", action: "go-harbor",
color: "#1e40af", bg: "#dbeafe", border: "#3b82f6",
activeBg: "#3b82f6", activeColor: "#ffffff" },网格布局会自动适应 -- 5 个按钮将排列为第一行 2 个,第二行 2 个,第三行 1 个。
限制旅行路线
如果你不想让玩家在任意两个地点之间自由跳转(例如「必须经过森林才能到达洞穴」),在 Root Component 中添加路线逻辑:
// 定义可达路线
const routes = {
village: ["forest", "market"], // 村庄可以到达森林和市场
forest: ["village", "cave"], // 森林可以到达村庄和洞穴
cave: ["forest"], // 洞穴只能回到森林
market: ["village"], // 市场只能回到村庄
};
const reachable = routes[currentLocation] || [];
// 在按钮的 onClick 和样式中添加检查
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",
}}不可达的地点会变淡且不可点击 -- 玩家一眼就能看出「我现在去不了那里」。
快速参考
| 你想做什么 | 怎么做 |
|---|---|
| 跟踪玩家当前位置 | String 变量 current_location,值为地点 ID |
| 点击按钮切换场景 | 行为触发器设为「Action」,action ID 匹配 Root Component 中的 executeAction() |
| 交换地点氛围 | 行为动作:「禁用知识条目」关闭旧地点,「启用知识条目」开启新地点 |
| 平滑的 BGM 过渡 | 行为动作「播放音乐」,操作设为 crossfade,淡入淡出时长 2-3 秒 |
| 点击后 AI 立即描述新场景 | 行为动作「请求 AI 回复」,附带到达上下文 |
| 高亮当前位置 | 在 Root Component 中比较 current_location 和按钮 ID;匹配的按钮获得高亮样式 |
| 防止重复点击当前位置 | 在调用 executeAction 之前检查 if (!isActive) |
| 地图只显示在最后一条消息上 | 在 <Chat renderBubble> 中检查 msg.messageIndex === msgs.length - 1 |
自己试试 -- 可导入的演示世界
下载此 JSON 并作为新世界导入,查看完整效果:
如何导入:
- 前往 Yumina → 我的世界 → 创建新世界
- 在编辑器中,点击 更多操作 → 导入包
- 选择下载的
.json文件 - 一个新世界会被创建,所有变量、条目、行为和 Root Component 都已预配置
- 开始新会话并试用
包含内容:
- 1 个变量(
current_location跟踪当前位置) - 4 个知识条目(村庄/森林/洞穴/市场氛围 -- 仅村庄默认启用)
- 4 个行为(前往村庄/前往森林/前往洞穴/前往市场 -- 每个都切换条目 + 淡入淡出音乐 + 请求 AI 描述)
- 一个 Root Component(2x2 网格地图面板,带当前位置高亮)
- 4 个 BGM 音轨(你需要上传自己的音频文件来替换占位 URL)
这是食谱 #12
本食谱展示了经典的行为 + Root Component 组合 -- 按钮触发行为,每个行为同时更新变量、交换知识条目、淡入淡出 BGM 并请求 AI 回复。同样的模式适用于楼层导航、房间探索、世界传送门,或任何涉及「在多个场景之间移动」的场景。
