Skip to content

地图与场景导航

构建一个可点击的地图界面 -- 玩家点击一个地点 → 场景切换、知识条目交换、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供你自己参考
IDcurrent_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 PresetsPresets 区域的条目每次都会发送给 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 PresetsPresets 区域
启用(关闭)当玩家前往此处时由行为启用

内容:

[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 PresetsPresets 区域
启用(关闭)由行为启用

内容:

[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 PresetsPresets 区域
启用(关闭)由行为启用

内容:

[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_villageVillageBGM2秒2秒
bgm_forestForestBGM2秒2秒
bgm_caveCaveBGM2秒2秒
bgm_marketMarketBGM2秒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 IDgo-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 IDgo-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 IDgo-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 IDgo-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 />):

tsx
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 步:保存并测试

  1. 点击编辑器顶部的 保存
  2. 点击 开始游戏 或返回主页开始新会话
  3. 你会在 AI 消息下方看到一个地图面板,有四个地点按钮。「Village」按钮高亮显示,显示「You are here」
  4. 点击 Forest -- AI 立即用一段文字描述森林场景,地图上「Forest」按钮变为高亮
  5. 如果你配置了 BGM,应该能听到音乐从村庄音轨淡入淡出到森林音轨
  6. 点击 Cave -- 场景再次切换,AI 描述洞穴,BGM 淡入淡出
  7. 试着点击当前高亮的地点 -- 什么都不会发生(你已经在那里了)
  8. 正常与 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 秒
四个条目同时启用行为忘记禁用其他条目每个行为必须在启用目标地点条目之前禁用其他三个地点的条目

扩展思路

添加更多地点

想添加第五个地点(比如「港口」)?需要做四件事:

  1. 知识 选项卡 → 创建「港口氛围」条目(默认禁用)
  2. 音频 选项卡 → 创建 bgm_harbor 音轨(可选)
  3. 行为 选项卡 → 创建「前往港口」行为,action ID 为 go-harbor,与其他四个相同的动作模式。同时回到现有的四个行为中,各添加一个「禁用知识条目:港口氛围」动作
  4. Root Component → 在 locations 数组中添加一个条目:
tsx
{ id: "harbor", label: "Harbor", icon: "⚓", action: "go-harbor",
  color: "#1e40af", bg: "#dbeafe", border: "#3b82f6",
  activeBg: "#3b82f6", activeColor: "#ffffff" },

网格布局会自动适应 -- 5 个按钮将排列为第一行 2 个,第二行 2 个,第三行 1 个。

限制旅行路线

如果你不想让玩家在任意两个地点之间自由跳转(例如「必须经过森林才能到达洞穴」),在 Root Component 中添加路线逻辑:

tsx
// 定义可达路线
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 并作为新世界导入,查看完整效果:

recipe-12-demo.json

如何导入:

  1. 前往 Yumina → 我的世界创建新世界
  2. 在编辑器中,点击 更多操作导入包
  3. 选择下载的 .json 文件
  4. 一个新世界会被创建,所有变量、条目、行为和 Root Component 都已预配置
  5. 开始新会话并试用

包含内容:

  • 1 个变量(current_location 跟踪当前位置)
  • 4 个知识条目(村庄/森林/洞穴/市场氛围 -- 仅村庄默认启用)
  • 4 个行为(前往村庄/前往森林/前往洞穴/前往市场 -- 每个都切换条目 + 淡入淡出音乐 + 请求 AI 描述)
  • 一个 Root Component(2x2 网格地图面板,带当前位置高亮)
  • 4 个 BGM 音轨(你需要上传自己的音频文件来替换占位 URL)

这是食谱 #12

本食谱展示了经典的行为 + Root Component 组合 -- 按钮触发行为,每个行为同时更新变量、交换知识条目、淡入淡出 BGM 并请求 AI 回复。同样的模式适用于楼层导航、房间探索、世界传送门,或任何涉及「在多个场景之间移动」的场景。