Skip to content

地图与场景导航

做一个可点击的地图界面——玩家点击某个地点 → 场景切换、知识条目切换、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 个变量来追踪玩家当前所在的地点。

编辑器 → 左侧边栏 → 变量 标签页 → 点击「添加变量」

字段填什么为什么这样填
显示名称当前地点给你自己看的,方便识别
IDcurrent_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村庄BGM2 秒2 秒
bgm_forest森林BGM2 秒2 秒
bgm_cave洞穴BGM2 秒2 秒
bgm_market集市BGM2 秒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") 时触发
动作 IDgo-village对应地图按钮的点击事件

DO(做什么):

#动作类型设置作用
1修改变量current_location 设为 village更新当前地点
2禁用知识条目森林氛围关掉其他地点的条目
3禁用知识条目洞穴氛围关掉其他地点的条目
4禁用知识条目集市氛围关掉其他地点的条目
5启用知识条目村庄氛围打开目标地点的条目
6播放音乐bgm_village,操作:crossfade,渐变时长 3 秒渐变切换到村庄 BGM
7请求 AI 回复上下文:玩家回到了村庄。请描写玩家到达村庄时看到的场景。让 AI 生成一段到达描写

为什么要先「禁用」其他三个、再「启用」目标? 因为玩家可能从任何地点出发。如果玩家从森林去村庄,需要关掉森林条目;如果从洞穴去村庄,需要关掉洞穴条目。最简单的做法是不管从哪来,先把其他地点全关掉,再打开目标地点。这样无论玩家从哪里出发,结果都是正确的。

行为 2:前往森林

WHEN:

字段填什么
触发器类型动作
动作 IDgo-forest

DO:

#动作类型设置作用
1修改变量current_location 设为 forest更新当前地点
2禁用知识条目村庄氛围关掉其他地点
3禁用知识条目洞穴氛围关掉其他地点
4禁用知识条目集市氛围关掉其他地点
5启用知识条目森林氛围打开目标地点
6播放音乐bgm_forest,操作:crossfade,渐变时长 3 秒渐变切换 BGM
7请求 AI 回复上下文:玩家进入了森林。请描写玩家踏入森林时看到的场景。AI 描写到达场景

行为 3:前往洞穴

WHEN:

字段填什么
触发器类型动作
动作 IDgo-cave

DO:

#动作类型设置作用
1修改变量current_location 设为 cave更新当前地点
2禁用知识条目村庄氛围关掉其他地点
3禁用知识条目森林氛围关掉其他地点
4禁用知识条目集市氛围关掉其他地点
5启用知识条目洞穴氛围打开目标地点
6播放音乐bgm_cave,操作:crossfade,渐变时长 3 秒渐变切换 BGM
7请求 AI 回复上下文:玩家走进了洞穴。请描写玩家进入洞穴时看到的场景。AI 描写到达场景

行为 4:前往集市

WHEN:

字段填什么
触发器类型动作
动作 IDgo-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」→ 粘贴以下代码:

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

  1. 点击编辑器顶部的「保存」
  2. 点击「开始游戏」或回到首页开一个新会话
  3. 你会看到 AI 的消息下方出现一个地图面板,有四个地点按钮。「村庄」按钮是高亮的,显示「当前位置」
  4. 点击「森林」按钮——AI 立刻回复一段描写森林场景的文字,地图上「森林」按钮变成高亮
  5. 如果你配了 BGM,此时应该能听到从村庄曲渐变到森林曲的过渡
  6. 点击「洞穴」——场景再次切换,AI 描写洞穴,BGM 渐变过渡
  7. 尝试点击已高亮的当前地点——不会有任何反应(已经在这里了)
  8. 正常和 AI 对话,然后再切换地点——一切正常,地图始终显示在最后一条消息下方

如果遇到问题:

现象可能的原因解决方法
看不到地图面板消息渲染器代码没保存或有语法错误检查消息渲染器底部的编译状态,应该显示绿色「OK」
点了按钮没反应行为的动作 ID 不匹配确认行为的动作 ID(go-village 等)和代码里 locations 数组的 action 字段完全一致
AI 没有回复新场景行为里没加「请求 AI 回复」动作检查每条行为的最后一个动作是否是「请求 AI 回复」
地图上高亮没变变量没被修改检查行为的第一个动作是否是「修改变量」,且目标变量是 current_location
BGM 没切换音轨 ID 不匹配或没上传音频确认行为里的音轨 ID 和音频标签页里的音轨 ID 一致
BGM 切换时有断裂感没有用 crossfade确认「播放音乐」动作的操作选的是 crossfade,渐变时长至少 2-3 秒
四个条目同时启用行为里忘了禁用其他条目每条行为必须先禁用其他三个地点的条目,再启用目标地点的条目

扩展思路

加更多地点

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

  1. 知识库 标签页 → 新建「港口氛围」条目(默认禁用)
  2. 音频 标签页 → 新建 bgm_port 音轨(可选)
  3. 行为 标签页 → 新建「前往港口」行为,动作 ID go-port,动作模式和前四个一样。同时回到前四个行为里,各自加一行「禁用知识条目:港口氛围」
  4. 消息渲染器 → 在 locations 数组里加一项:
tsx
{ id: "port", label: "港口", icon: "⚓", action: "go-port",
  color: "#1e40af", bg: "#dbeafe", border: "#3b82f6",
  activeBg: "#3b82f6", activeColor: "#ffffff" },

网格布局会自动适配——5 个按钮会变成第一行 2 个、第二行 2 个、第三行 1 个。

限制可通行路线

如果你不希望玩家能从任意地点直接跳到任意地点(比如"必须穿过森林才能到洞穴"),可以在消息渲染器里加判断逻辑:

tsx
// 定义可达路线
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 文件,导入即可体验完整效果:

recipe-12-demo-zh.json

导入方法:

  1. 进入 Yumina → 我的世界 → 创建新世界
  2. 在编辑器顶部点「更多操作」→「导入包」
  3. 选择下载的 .json 文件
  4. 世界会被创建,所有变量、条目、行为和渲染器都已预配置好
  5. 开一个新会话试试看

包含内容:

  • 1 个变量(current_location 追踪当前地点)
  • 4 个知识条目(村庄 / 森林 / 洞穴 / 集市氛围,默认只启用村庄)
  • 4 条行为(前往村庄 / 前往森林 / 前往洞穴 / 前往集市,各自切换条目 + 渐变切歌 + 请求 AI 描写)
  • 一个消息渲染器(2x2 网格地图面板,当前地点高亮)
  • 4 条 BGM 音轨(需要你自己上传音频文件替换 URL)

这是实战配方 #12

这个配方展示了行为系统 + 消息渲染器的经典组合——用按钮触发行为,行为里同时做变量更新、条目切换、BGM crossfade、AI 回复请求。同样的模式可以用来做楼层导航、房间探索、世界传送门等任何"在多个场景之间移动"的东西。