Skip to content

视觉小说模式

将聊天界面变成一个完整的视觉小说 -- 场景背景、角色立绘、对话框、选项按钮,全部由 AI 指令驱动。Root Component(index.tsx)接管整个屏幕:不再使用 <Chat />,而是用你自己的全屏布局,在玩家需要发言的地方嵌入 <MessageInput />。纯 Tailwind + 内联样式 -- 不需要任何预构建的组件库。


你将构建什么

一个全屏的视觉小说界面:

  • 场景背景 -- AI 通过指令切换背景图片(教室、街道、夜空……),Root Component 将其显示为全屏的 background-image
  • 角色立绘 -- AI 通过指令设置当前说话者和表情,Root Component 在屏幕中央渲染对应的 <img> 立绘
  • 对话框 -- 屏幕底部的半透明框显示角色名称和对话内容。斜体文字 自动视为旁白/内心独白;普通文字是角色对话
  • 选项按钮 -- 当 AI 提供选择时,Root Component 在屏幕上叠加可点击的按钮
  • 全屏模式 -- Root Component 直接返回自己的全屏布局,不嵌套 <Chat />,所以完全没有普通的聊天气泡

工作原理

AI 在每次回复中通过指令控制屏幕:

AI 的回复:
[current_bg: set "classroom_morning.jpg"]
[current_speaker: set "Yuki"]
[speaker_emotion: set "happy"]

*教室沐浴在晨光中。樱花花瓣偶尔从窗户飘进来。*

Yuki 转过身来对你微笑:

早上好!你今天来得真早。

引擎解析这些指令后:

  1. current_bg 变为 "classroom_morning.jpg" → Root Component 将 background-image 切换为教室
  2. current_speaker 变为 "Yuki" → 对话框显示名字「Yuki」
  3. speaker_emotion 变为 "happy" → Root Component 从 speaker + emotion 组合立绘路径并渲染
  4. Root Component 解析最新消息的文本 -- 斜体 部分渲染为旁白,普通文字渲染为角色对话
引擎处理流程:
  AI 回复 → 引擎提取指令 → 更新变量 → Root Component 读取变量并重绘
    → 背景层:<div style={{ backgroundImage: ... }} />
    → 立绘层:<img src={`/sprites/${speaker}_${emotion}.png`} />
    → 对话框层:区分旁白(灰色斜体)与对话(白色正体)
    → 选项层:当 show_choices = true 时显示按钮

逐步操作

第 1 步:创建变量

你需要 4 个变量来控制视觉小说的显示。

编辑器 → 侧边栏 → 变量 选项卡 → 为每个变量点击「添加变量」

变量 1:当前背景

字段原因
名称Current Background供你自己参考
IDcurrent_bgAI 使用 [current_bg: set "xxx"] 来切换背景
类型String值是图片 URL 或文件名
默认值default_bg.jpg新会话开始时的默认背景。替换为你自己的图片 URL
分类Custom专用的视觉小说系统分类
行为规则Use [current_bg: set "imageURL"] to switch the scene background. Update this variable whenever the scene changes.告诉 AI 何时以及如何修改此变量

变量 2:当前说话者

字段原因
名称Current Speaker供你自己参考
IDcurrent_speakerAI 使用 [current_speaker: set "name"] 来切换说话者
类型String值是角色名称
默认值Narrator默认为旁白模式 -- 没有特定角色在说话
分类Custom专用的视觉小说系统分类
行为规则Use [current_speaker: set "characterName"] to set the current speaker. Set to "Narrator" for narration or inner monologue.告诉 AI 使用规则

变量 3:说话者表情

字段原因
名称Speaker Emotion供你自己参考
IDspeaker_emotionAI 使用 [speaker_emotion: set "happy"] 来切换表情
类型String值是表情关键词
默认值neutral默认为中性表情
分类Custom专用的视觉小说系统分类
行为规则Use [speaker_emotion: set "emotion"] to change the character's expression. Available emotions: neutral, happy, sad, angry, surprised, shy. Update whenever the character's emotion changes.列出可用表情可以防止 AI 编造不存在的表情

变量 4:显示选项

字段原因
名称Show Choices供你自己参考
IDshow_choicesAI 使用 [show_choices: set true] 来显示选项按钮
类型Boolean只有两种状态:显示/隐藏
默认值false默认隐藏选项按钮
分类Custom专用的视觉小说系统分类
行为规则Use [show_choices: set true] when you want to offer the player a choice. Keep it false otherwise.告诉 AI 只有在需要玩家选择时才启用

为什么让 AI 用指令控制屏幕?

这是 Yumina 的核心设计 -- AI 不执行代码。相反,它使用结构化指令告诉引擎该做什么。引擎解析指令、更新变量,Root Component 读取变量来更新屏幕。完整链路是:AI 写指令 → 引擎解析 → 变量更新 → Root Component 重新渲染。


第 2 步:创建知识条目 -- 视觉小说系统指令

AI 需要知道它处于视觉小说环境中,以及如何使用指令来控制屏幕。

编辑器 → 知识库 选项卡 → 创建新条目

字段原因
名称Visual Novel System Instructions供你自己参考
区域PresetsPresets 区域的条目每次都会发送给 AI
启用(开启)始终激活

内容:

[Visual Novel Mode]
You are generating content for a visual novel engine. Every response must include directives to control the screen.

Format rules:
1. Set the scene with directives at the start of your response:
   [current_bg: set "backgroundImageURL"]
   [current_speaker: set "characterName"]
   [speaker_emotion: set "emotion"]

2. Text formatting:
   - *Italic text* = narration or inner monologue. Use for describing environments, character actions, inner thoughts.
   - Plain text (no formatting) = character dialogue/speech.
   - Do not wrap dialogue in quotation marks — just write plain text.

3. When you want to give the player a choice:
   - Use [show_choices: set true]
   - List choices at the end of the text in this format:
     A) Choice text
     B) Choice text
     C) Choice text

4. Each response should contain only one scene fragment (3-5 sentences). Keep the pacing tight, like a real visual novel.

5. Available emotions: neutral, happy, sad, angry, surprised, shy

6. Always update current_bg when switching scenes. Always update current_speaker and speaker_emotion when a character speaks.

为什么要这么详细? 因为 AI 不知道你的 Root Component 是如何工作的。你必须明确告诉它「斜体 = 旁白,普通文字 = 对话」-- 否则 AI 可能会使用随机格式,组件将无法正确区分旁白和对话。


第 3 步:准备并上传素材

视觉小说需要背景图片和角色立绘。有两种方式提供:

  • 方式 A(推荐):上传到 Yumina 的素材系统,获取 @asset: 引用 -- 稳定,不会过期
  • 方式 B:使用外部图片 URL(imgur、你自己的服务器)-- 更简单但可能会失效

将素材上传到 Yumina

  1. 打开编辑器 → 侧边栏 → 素材 选项卡
  2. 拖放 你的图片文件到上传区域(或点击浏览)
  3. 上传后,每个文件会获得一个 @asset: 引用(如 @asset:a1b2c3d4-e5f6-7890
  4. 点击已上传的素材来 复制其引用

什么是 @asset: 引用? 它是 Yumina 的内部素材标识符。在 Root Component 的 TSX 代码中,<img src="@asset:xxx" /> 会在渲染时自动解析为真实的 CDN URL。你不需要手动转换 -- 组件会处理。变量也可以存储 @asset:xxx 值,它们也会被自动解析。

建议准备的素材

背景图(建议 16:9 比例,1920x1080 或更高):

场景建议文件名用途
教室(白天)classroom_morning.jpg上课、对话场景
学校走廊hallway.jpg过渡场景
街道(傍晚)street_evening.jpg放学后场景
卧室(夜晚)room_night.jpg夜间场景

上传后,记下每个背景的 @asset: 引用。你会把这些放在知识条目中,让 AI 知道哪个引用对应哪个场景。

角色立绘(建议透明 PNG,高度 1000px 以上):

为每个角色准备多个表情立绘。使用一致的命名格式:characterName_emotion.png

角色示例文件名示例引用
Yuki(开心)yuki_happy.png@asset:abc123...
Yuki(伤心)yuki_sad.png@asset:def456...
老师(中性)teacher_neutral.png@asset:ghi789...

告诉 AI 使用哪些素材

上传后,在第 2 步创建的视觉小说系统指令条目中添加素材引用表。这告诉 AI 哪个 @asset: 引用对应哪个场景或角色:

[Asset Reference Table]
Backgrounds:
- Classroom daytime: @asset:your-classroom-reference
- School hallway: @asset:your-hallway-reference
- Street evening: @asset:your-street-reference

Character sprites (format: @asset:reference):
- Yuki happy: @asset:your-yuki-happy-reference
- Yuki sad: @asset:your-yuki-sad-reference
- Teacher neutral: @asset:your-teacher-reference

When using directives, use the @asset: references above as values. For example:
[current_bg: set "@asset:your-classroom-reference"]

AI 读取这个表格并在指令中使用正确的 @asset: 引用。Root Component 在显示时会自动将 @asset: 转换为真实的图片 URL。

还没有素材?你仍然可以测试

当图片加载失败时,Root Component 会显示纯色背景。先把逻辑跑通 -- 之后再添加素材。你也可以用免费图库的 URL 代替 @asset: 引用来快速原型验证。


第 4 步:编写开场白

开场白是视觉小说的开场。它需要包含指令来设置初始屏幕。

编辑器 → 开场白 选项卡 → 创建开场白

[current_bg: set "classroom_morning.jpg"]
[current_speaker: set "Narrator"]
[speaker_emotion: set "neutral"]

*四月的第一天。樱花季的尾声。*

*你推开教室的门。熟悉的粉笔灰和木头的气味扑面而来。大部分座位还空着 -- 离上课还有十分钟。*

*在靠窗的座位上,一个你从未见过的女孩正安静地望着窗外。*

[current_speaker: set "Narrator"]
*转校生?你不记得班上有她这样的人。*

为什么开场白也要放指令? 因为 Root Component 依赖变量来决定显示什么。开场白的指令会被引擎解析,设置初始背景和角色状态。没有指令的话,默认值会生效(default_bg.jpg + Narrator + neutral),但屏幕可能不符合开场场景。


第 5 步:编写视觉小说 Root Component

这是核心步骤。Root Component 接管整个屏幕 -- 不嵌套 <Chat />,而是绘制自己的背景、立绘、对话框和选项按钮。玩家输入通过放置在屏幕底部的 <MessageInput /> 处理。

编辑器 → 自定义 UI 部分 → 打开 index.tsx → 粘贴以下代码(替换默认的 return <Chat />):

tsx
export default function MyWorld() {
  const api = useYumina();
  const renderMarkdown = api.renderMarkdown;
  const msgs = api.messages || [];
  const lastMsg = msgs[msgs.length - 1];
  const content = lastMsg ? String(lastMsg.content || "") : "";

  // ---- 读取变量 ----
  const bgUrl = String(api.variables.current_bg || "default_bg.jpg");
  const speaker = String(api.variables.current_speaker || "Narrator");
  const emotion = String(api.variables.speaker_emotion || "neutral");
  const showChoices = Boolean(api.variables.show_choices);

  // ---- 清理内容:去掉指令行,只保留叙事文本 ----
  const cleanContent = content
    .split("\n")
    .filter((line) => !line.trim().match(/^\[.+:\s*(set|add|subtract|multiply|toggle|append|merge|push|delete)\s+.+\]$/) && !line.trim().match(/^\[.+:\s*[+-]?\d+\]$/))
    .join("\n")
    .trim();

  // ---- 解析文本:区分旁白(斜体)和对话(普通文字) ----
  // 将文本按段落分割并分类
  const paragraphs = cleanContent
    .split("\n\n")
    .map((p) => p.trim())
    .filter((p) => p.length > 0);

  const parsed = paragraphs.map((p) => {
    // 如果整个段落被 * 包裹,或每行都以 * 开头,则为旁白
    const isNarration = /^\*[^*].*[^*]\*$/.test(p.trim())
      || p.trim().startsWith("*");
    // 检查是否为选项行(A) B) C) 格式)
    const isChoice = /^[A-Z]\)\s/.test(p.trim());
    return { text: p, isNarration, isChoice };
  });

  // ---- 立绘 URL(根据角色名和表情组合) ----
  const spriteUrl = speaker !== "Narrator"
    ? `/sprites/${speaker.toLowerCase()}_${emotion}.png`
    : null;

  // ---- 提取选项 ----
  const choices = parsed
    .filter((p) => p.isChoice)
    .map((p) => p.text.replace(/^[A-Z]\)\s*/, ""));

  // ---- 渲染 ----
  return (
    <div style={{
      position: "relative",
      width: "100%",
      minHeight: "500px",
      borderRadius: "12px",
      overflow: "hidden",
      background: "#000",
    }}>
      {/* ===== 背景层 ===== */}
      <div style={{
        position: "absolute",
        inset: 0,
        backgroundImage: `url(${bgUrl})`,
        backgroundSize: "cover",
        backgroundPosition: "center",
        filter: "brightness(0.7)",
        transition: "background-image 0.8s ease",
      }} />

      {/* ===== 角色立绘层 ===== */}
      {spriteUrl && (
        <div style={{
          position: "absolute",
          bottom: "120px",
          left: "50%",
          transform: "translateX(-50%)",
          zIndex: 2,
          transition: "opacity 0.5s ease",
        }}>
          <img
            src={spriteUrl}
            alt={`${speaker} - ${emotion}`}
            style={{
              maxHeight: "350px",
              objectFit: "contain",
              filter: "drop-shadow(0 4px 12px rgba(0,0,0,0.5))",
            }}
            onError={(e) => { e.target.style.display = "none"; }}
          />
        </div>
      )}

      {/* ===== 对话框层 ===== */}
      <div style={{
        position: "absolute",
        bottom: 0,
        left: 0,
        right: 0,
        zIndex: 3,
        background: "linear-gradient(transparent, rgba(0,0,0,0.85) 30%)",
        padding: "60px 24px 24px",
      }}>
        {/* 角色名标签 */}
        {speaker !== "Narrator" && (
          <div style={{
            display: "inline-block",
            padding: "4px 16px",
            marginBottom: "8px",
            background: "rgba(99,102,241,0.8)",
            borderRadius: "6px 6px 0 0",
            color: "#e0e7ff",
            fontSize: "14px",
            fontWeight: "bold",
            letterSpacing: "0.05em",
          }}>
            {speaker}
          </div>
        )}

        {/* 文本内容 */}
        <div style={{
          background: "rgba(15,23,42,0.9)",
          borderRadius: speaker !== "Narrator" ? "0 12px 12px 12px" : "12px",
          padding: "16px 20px",
          border: "1px solid rgba(148,163,184,0.2)",
          minHeight: "80px",
        }}>
          {parsed
            .filter((p) => !p.isChoice)
            .map((p, i) => (
              <p key={i} style={{
                margin: i > 0 ? "10px 0 0" : "0",
                color: p.isNarration ? "#94a3b8" : "#e2e8f0",
                fontStyle: p.isNarration ? "italic" : "normal",
                fontSize: "15px",
                lineHeight: 1.8,
              }}
              dangerouslySetInnerHTML={{
                __html: renderMarkdown(
                  p.isNarration
                    ? p.text.replace(/^\*|\*$/g, "")
                    : p.text
                ),
              }}
              />
            ))
          }
        </div>
      </div>

      {/* ===== 选项按钮层 ===== */}
      {showChoices && choices.length > 0 && (
        <div style={{
          position: "absolute",
          top: "50%",
          left: "50%",
          transform: "translate(-50%, -50%)",
          zIndex: 4,
          display: "flex",
          flexDirection: "column",
          gap: "10px",
          width: "80%",
          maxWidth: "400px",
        }}>
          {choices.map((choice, i) => (
            <button
              key={i}
              onClick={() => {
                api.setVariable("show_choices", false);
                api.sendMessage(choice);
              }}
              style={{
                padding: "14px 20px",
                background: "rgba(30,27,75,0.9)",
                border: "1px solid rgba(99,102,241,0.6)",
                borderRadius: "10px",
                color: "#c7d2fe",
                fontSize: "15px",
                fontWeight: "600",
                cursor: "pointer",
                textAlign: "left",
                transition: "all 0.2s ease",
                backdropFilter: "blur(8px)",
              }}
              onMouseEnter={(e) => {
                e.target.style.background = "rgba(67,56,202,0.8)";
                e.target.style.borderColor = "#818cf8";
              }}
              onMouseLeave={(e) => {
                e.target.style.background = "rgba(30,27,75,0.9)";
                e.target.style.borderColor = "rgba(99,102,241,0.6)";
              }}
            >
              {choice}
            </button>
          ))}
        </div>
      )}

      {/* ===== 玩家输入层 ===== */}
      {/* 没有 <Chat /> 包裹 -- 直接放入 <MessageInput />,让玩家仍然可以打字 */}
      <div style={{
        position: "absolute",
        bottom: 0,
        left: 0,
        right: 0,
        zIndex: 5,
      }}>
        <MessageInput />
      </div>
    </div>
  );
}

逐块说明:

  • 清理内容 -- cleanContent 过滤掉像 [current_bg: set "xxx"] 这样的指令行(匹配所有操作类型:set/add/subtract/multiply/toggle/append/merge/push/delete,以及简写指令如 [hp: -10])。指令已经被引擎解析,所以组件不需要显示它们
  • 解析段落 -- 按空行将文本分成段落,将每个段落分类为旁白(以 * 开头)、对话(普通文字)或选项(以 A) 格式开头)
  • 背景层 -- 使用 backgroundImage 显示当前场景背景。filter: brightness(0.7) 略微调暗以保持前景文字可读。transition 在切换背景时添加淡入淡出动画
  • 立绘层 -- 从 speakeremotion 组合立绘文件路径。onError 处理缺失的图片(静默隐藏)。旁白模式下不显示立绘
  • 对话框层 -- 底部的半透明框。当 speaker 不是「Narrator」时,对话框上方会出现角色名标签。旁白文字为灰色斜体;对话文字为白色正体
  • 选项按钮层 -- 当 show_choicestrue 且文本包含 A) B) C) 格式的选项时,按钮居中出现在屏幕上。点击按钮会自动隐藏选项(show_choices 设为 false)并发送玩家的选择

自定义立绘路径

代码使用 /sprites/${speaker.toLowerCase()}_${emotion}.png 来组合立绘路径。你可以将其改为任何 URL 格式 -- CDN 链接、本地文件路径或查找表。如果角色名包含非 ASCII 字符,记得进行 URL 编码或使用英文 ID。


第 6 步:无需切换 -- Root Component 已经是全屏的

视觉小说天然就应该占满屏幕。只要你的 index.tsx 根元素使用 width: "100%" + minHeight: "500px"(或 100vh)-- 上面的代码已经这样做了 -- 你的 TSX 就接管了整个画布。不需要单独翻转什么「全屏」开关,因为 Root Component 就是 世界的 UI 入口。

两种模式对比:

  • 普通聊天 + 自定义气泡return <Chat renderBubble={...} /> -- 保留平台的聊天外壳,只重新设计气泡样式
  • 纯视觉小说(本食谱)return <div>...所有 VN 元素...<MessageInput /></div> -- 完全不用 <Chat />;每个像素都由你控制

想在聊天和视觉小说之间切换? 如果你想要「大部分时间是普通聊天,特殊时刻切换为视觉小说」的体验,在 Root Component 中根据一个变量(比如 vn_mode)进行分支。关闭时返回 <Chat />,开启时返回视觉小说全屏布局。AI 通过指令切换变量,你就可以在会话中途切换模式。


第 7 步:AI 如何驱动屏幕 -- 指令示例

让我们看看在实际对话中,AI 如何自然地控制视觉小说屏幕。

场景 1:开场(旁白模式)

AI 的回复:

[current_bg: set "classroom_morning.jpg"]
[current_speaker: set "Narrator"]
[speaker_emotion: set "neutral"]

*四月的早晨。空气中弥漫着樱花的甜香。*

*你走进教室,发现一个不认识的女孩坐在窗边。她用手托着下巴,望着窗外,沉浸在自己的思绪中。*

渲染结果:教室背景 + 无立绘 + 灰色斜体旁白文字。

场景 2:角色对话

AI 的回复:

[current_speaker: set "Yuki"]
[speaker_emotion: set "surprised"]

*她似乎注意到你在看她,转过头来。*

哦,你好。你也是这个班的吗?

[speaker_emotion: set "shy"]

抱歉,我今天刚转学来……还不太认识谁。

渲染结果:背景不变(没有 current_bg 指令意味着保持上一个值)+ Yuki 的立绘先显示惊讶表情然后切换为害羞 + 对话框显示名字「Yuki」+ 斜体旁白和正体对话交替出现。

场景 3:给玩家选择

AI 的回复:

[current_speaker: set "Narrator"]
[show_choices: set true]

*Yuki 看着你,眼中带着一丝期待。*

*你该怎么做?*

A) 自我介绍并开始聊天
B) 简短地点点头,回到自己的座位
C) 提出带她参观教室和学校

渲染结果:旁白文字 + 三个可点击的按钮出现在屏幕中央。当玩家点击其中一个时,按钮消失,选中的文字作为玩家的回复发送给 AI。

场景 4:场景转换

AI 的回复:

[current_bg: set "hallway.jpg"]
[current_speaker: set "Narrator"]

*铃声响起。走廊里瞬间热闹起来,学生们三三两两地涌出教室。*

[current_speaker: set "Yuki"]
[speaker_emotion: set "happy"]

一起去天台吃午饭吧?我发现了一个很不错的地方。

渲染结果:背景过渡到走廊(带淡入淡出动画)+ 旁白 + Yuki 的开心立绘 + 对话。


第 8 步:斜体旁白 vs. 普通对话 -- 解析规则

Root Component 用一个简单规则区分两种文本:

格式识别为显示样式用途
*这是斜体文字*旁白灰色(#94a3b8),斜体环境描述、角色动作、内心独白
这是普通文字对话白色(#e2e8f0),正体角色说的话
A) 选项文字选项按钮可点击的玩家选择

AI 在知识条目中已经被告知了这些规则。但如果 AI 偶尔搞错格式(例如用斜体写对话),后备逻辑会将不确定的文字视为对话 -- 所以至少不会出错。

为什么不用 Markdown 的 > 引用块或 **粗体** 来区分? 因为 *斜体* 是最自然的标记 -- 大多数 AI 模型在角色扮演场景中已经默认使用斜体来表示旁白和动作描述,不需要额外训练。选择 AI 最可能一致遵循的格式,省去和模型较劲的麻烦。


第 9 步:保存并测试

  1. 点击编辑器顶部的 保存
  2. 点击 开始游戏 或返回主页开始新会话
  3. 你应该看到全屏的视觉小说显示 -- 背景 + 对话框 + 开场旁白
  4. 在输入框中输入消息(例如「跟她打招呼」)
  5. AI 的回复应该包含指令 -- 背景可能会切换,角色出现,对话显示在对话框中
  6. 如果 AI 提供选择,按钮会出现在屏幕中央。点击一个试试
  7. 继续对话,观察 AI 是否在切换场景时自然地更新 current_bg,在角色说话时更新 current_speakerspeaker_emotion

如果出了问题:

症状可能原因修复方法
背景是黑色的图片 URL 不正确或图片不存在检查 current_bg 的值是否为有效的图片 URL。尝试在浏览器中直接打开该 URL 确认图片能加载
看不到立绘立绘文件路径不匹配检查 /sprites/characterName_emotion.png 路径是否正确。onError 会静默隐藏加载失败的图片
指令行显示在屏幕上指令格式不标准,正则没有匹配到确认格式为 [variableName: set "value"] -- 注意冒号后面有空格
所有文字都是旁白 / 所有文字都是对话AI 没有遵循格式规则检查知识条目的格式说明是否清晰。你可以在行为规则中加强说明
选项按钮没有出现show_choices 没有设为 true,或者没有 A) 格式的选项检查 AI 的回复是否包含 [show_choices: set true] 以及 A) 格式的选项
屏幕不是全屏的根元素没有填满可见区域在 Root Component 最外层的 div 中添加 minHeight: "100vh"height: "100%",并确保父容器有高度

进阶技巧

多角色对话

你可以在同一回复中切换多个角色:

[current_speaker: set "Yuki"]
[speaker_emotion: set "happy"]
今天天气真好!

[current_speaker: set "Teacher"]
[speaker_emotion: set "neutral"]
好了同学们,开始上课了。请回到座位上。

[current_speaker: set "Narrator"]
*教室瞬间安静了下来。*

Root Component 按顺序处理这些;最终屏幕显示的是最后一个 current_speaker 的立绘。如果你想让每段对话都显示对应角色的立绘,可以修改组件,为每个段落解析最近的前置 [current_speaker: set ...] 指令。

过渡效果

背景层的 CSS 包含 transition: background-image 0.8s ease,为背景切换提供淡入淡出效果。你还可以为不同场景类型使用不同的过渡效果:

  • 普通切换:淡入淡出(已实现)
  • 闪回/回忆:添加白色闪光遮罩
  • 紧张场景:添加屏幕震动动画

搭配音效和 BGM

结合 Recipe #9(昼夜循环)的音频系统,你可以为不同场景分配 BGM。在行为规则中添加:当 current_bg 改变时,播放对应场景的 BGM。


快速参考

你想做什么怎么做
切换背景AI 发送 [current_bg: set "imageURL"]
切换说话者AI 发送 [current_speaker: set "characterName"]
切换表情AI 发送 [speaker_emotion: set "emotion"]
显示选项按钮AI 发送 [show_choices: set true] + 以 A) B) C) 格式列出选项
区分旁白和对话*斜体* = 旁白,普通文字 = 对话
全屏视觉小说体验Root Component index.tsx 直接返回视觉小说全屏布局(不用 <Chat />),底部放 <MessageInput /> 供玩家输入
角色立绘/sprites/ 目录中准备 characterName_emotion.png 文件
玩家点击选项时发送消息按钮的 onClick 调用 api.sendMessage(choiceText)

自己试试 -- 可导入的演示世界

下载此 JSON 文件并导入,体验完整效果:

recipe-10-demo.json

如何导入:

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

包含内容:

  • 4 个变量(current_bg 背景、current_speaker 说话者、speaker_emotion 表情、show_choices 选项开关)
  • 1 个知识条目(视觉小说系统指令,告诉 AI 如何使用指令和文本格式)
  • 1 条开场白(带有初始指令的视觉小说开场)
  • 一个 Root Component(完整的视觉小说界面:背景 + 立绘 + 对话框 + 选项按钮 + <MessageInput />

这是食谱 #10

视觉小说模式展示了 Yumina 最强大的能力 -- AI 不只是一个聊天伙伴,它是一个叙事引擎。通过用指令驱动屏幕、用格式约定区分文本类型、让 Root Component 重塑整个界面,你可以将普通的聊天框变成任何你能想象的交互体验。同样的方法适用于冒险游戏、互动漫画,甚至经营模拟。