Skip to content

视觉小说模式 ​

一个全屏的视觉小说:场景背景、角色立绘、对话框和选项按钮,都跟着 AI 在剧情里改的变量走。界面代码(index.tsx)不用 <Chat />,自己画整个画面,再把 <MessageInput /> 放在玩家打字的地方。只用 Tailwind 和内联样式,不需要组件库。


不写代码的部分 ​

视觉小说里有好几样东西已经不用写界面代码了:

  • 角色立绘:在画布上右键一个角色的设定,选 立绘…。聊天里这个角色说的每一句上面,都会显示这张图和他的名字。见视觉与音频
  • 像书一样的排版:在玩家界面里选中 对话 那一块,把 消息样子 设成 小说排版
  • 跟着变量换的背景或立绘:在玩家界面里加一个 图片 部件,把 图片来源 设成 跟着「<变量>」换。变量里存的是哪个 @asset: 引用,它就显示哪张图,所以 AI 改了变量,图就跟着换
  • 剧情到了某一刻出图:场景图在它 什么时候展示 那句话成立时会自己出来
  • 选项按钮:玩家界面上的按钮,步骤选 替玩家说一句

这一页剩下的部分是用代码搭整个画面,适合你想要这些的时候:对话框读取回复、把旁白和对话分开,选项按钮从回复的文字里取,整个布局都由你控制。


你要做的东西 ​

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

  • 场景背景:AI 通过改一个变量来切换背景图(教室、街道、夜空……),界面代码把它显示成全屏的 background-image
  • 角色立绘:AI 设置当前说话的人和表情,界面代码在画面中间显示对应的立绘
  • 对话框:画面底部一个半透明的框,显示角色名和台词。斜体文字 算旁白或内心独白,普通文字算角色台词
  • 选项按钮:AI 给出选择时,界面代码在画面上叠一层可以点的按钮
  • 全屏模式:界面代码直接返回自己的全屏布局,不套 <Chat />,所以完全没有普通的聊天气泡

怎么运作 ​

AI 每次回复都用指令控制画面:

AI 的回复:
[current_bg: set "@asset:your-classroom-reference"]
[current_speaker: set "Yuki"]
[speaker_emotion: set "happy"]

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

Yuki 转过身来对你微笑:

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

引擎解析完这些指令以后:

  1. current_bg 变成教室那张图 → 界面代码把 background-image 换成教室
  2. current_speaker 变成 "Yuki" → 对话框显示名字「Yuki」
  3. speaker_emotion 变成 "happy" → 界面代码找到 Yuki 开心的立绘并显示出来
  4. 界面代码解析最新一条消息的文字:斜体 部分显示成旁白,普通文字显示成角色台词
引擎处理流程:
  AI 回复 → 引擎提取指令 → 更新变量 → 界面代码读取变量并重画
    → 背景层:<div style={{ backgroundImage: ... }} />
    → 立绘层:<img src={SPRITES[speaker + "_" + emotion]} />
    → 对话框层:区分旁白(灰色斜体)和台词(白色正体)
    → 选项层:show_choices = true 时显示按钮

一步一步来 ​

第 1 步:建变量 ​

控制视觉小说的画面需要 4 个变量。

在画布上点卡底下 还可以加 里的 + 变量,每个变量点一次。名字照下面填,界面代码和 AI 都是按名字找变量的。

变量 1:当前背景 ​

字段值原因
变量名称current_bgAI 写 [current_bg: set "..."] 来切换背景;代码读 api.variables.current_bg
类型文字值是一个 @asset: 引用或图片 URL
初始值第一个场景背景的 @asset: 引用新一局开始、AI 还没改任何东西时显示的画面。做完第 3 步再填
行为规则场景的背景图。场景换到别的地方时就改它,用素材表里对应的引用。告诉 AI 什么时候改、用哪些值

变量 2:当前说话者 ​

字段值原因
变量名称current_speakerAI 写 [current_speaker: set "name"] 来切换说话的人
类型文字值是角色名
初始值Narrator一开始是旁白模式,没有哪个角色在说话
行为规则现在是谁在说话。角色说话时设成这个角色的名字,旁白或内心独白时设成 "Narrator"。告诉 AI 怎么用

变量 3:说话者表情 ​

字段值原因
变量名称speaker_emotionAI 写 [speaker_emotion: set "happy"] 来切换表情
类型文字值是一个表情关键词
初始值neutral一开始是平静的表情
行为规则说话角色的表情。只能是这几个之一:neutral, happy, sad, angry, surprised, shy。表情变了就更新。把表情列出来,AI 就不会编出你没有立绘的表情

变量 4:显示选项 ​

字段值原因
变量名称show_choicesAI 写 [show_choices: set true] 来显示选项按钮
类型开关只有两种状态:显示 / 隐藏
初始值关选项按钮默认藏着
行为规则要给玩家一个选择时打开它,其它时候保持关着。告诉 AI 只在需要玩家做选择时才打开

[名字: set 值] 这种格式不用你教 AI,引擎已经教好了。行为规则里只要写每个值什么时候变。

AI 不会执行代码

AI 写指令,引擎把指令应用到变量上,界面代码读变量再重画:AI 写指令 → 引擎解析 → 变量更新 → 界面代码重新渲染。


第 2 步:加一条写着视觉小说说明的设定 ​

AI 需要知道它是在给一个视觉小说画面写东西,以及文字该怎么排。

在画布上的 角色和世界 那一块(每回合都发)右上角点 +,加一条设定:

字段值原因
名称视觉小说系统说明给你自己看的
什么时候发给 AI角色和世界(每回合都发)格式规则每次回复都要用

内容:

[视觉小说模式]
你在为一个视觉小说引擎写内容。每次回复都必须带上控制画面的指令。

格式规则:
1. 在回复开头用指令设置场景:
   [current_bg: set "背景引用"]
   [current_speaker: set "角色名"]
   [speaker_emotion: set "表情"]

2. 文字格式:
   - *斜体文字* = 旁白或内心独白。用来描写环境、角色动作、内心想法。
   - 普通文字(不加任何格式)= 角色说的话。
   - 台词不要加引号,直接写普通文字。

3. 想给玩家一个选择时:
   - 用 [show_choices: set true]
   - 在文字最后按这个格式列出选项:
     A) 选项文字
     B) 选项文字
     C) 选项文字

4. 每次回复只写一个场景片段(3 到 5 句)。节奏要紧凑,像真正的视觉小说。

5. 可用的表情:neutral, happy, sad, angry, surprised, shy

6. 换场景时一定要更新 current_bg。角色说话时一定要更新 current_speaker 和 speaker_emotion。

AI 不知道你的界面代码是怎么读它的文字的,所以要把格式写明白:斜体是旁白,普通文字是台词。不然它可能随便排,代码就分不清哪是旁白哪是台词。


第 3 步:准备并上传素材 ​

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

  • 方式 A(推荐):上传到 Yumina 的素材库,用 @asset: 引用。稳定,不会过期
  • 方式 B:用外部的 https:// 图片链接(你自己的服务器、图床)。更简单,但链接可能会失效

把素材传到 Yumina ​

  1. 打开 面板 → 资源
  2. 把图片文件 拖进去,或者点 上传
  3. 每个上传的文件都会有一个 @asset: 引用(比如 @asset:a1b2c3d4-e5f6-7890)
  4. 在素材上用 复制引用 把它的引用复制下来

@asset: 引用是什么? 它是 Yumina 内部的素材标识。在界面代码里,<img src="@asset:xxx" /> 和内联样式里的 @asset: 在画出来的时候会变成真实的 CDN 地址,api.resolveAssetUrl("@asset:xxx") 能把地址作为字符串给你。变量里也可以存 @asset: 值。

建议准备的素材 ​

背景图(建议 16:9,1920×1080 或更高):

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

上传以后,记下每张背景的 @asset: 引用。你会把它们写进那条设定里,让 AI 知道哪个引用对应哪个场景。

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

每个角色的每种表情准备一张立绘。命名统一成 characterName_emotion.png,第 5 步对起来方便。

角色示例文件名示例引用
Yuki(开心)yuki_happy.png@asset:abc123...
Yuki(难过)yuki_sad.png@asset:def456...
老师(平静)teacher_neutral.png@asset:ghi789...

告诉 AI 用哪些背景 ​

在第 2 步那条视觉小说说明的设定里加一张引用表,让 AI 知道哪个 @asset: 引用对应哪个场景。立绘不用写在这里:AI 只负责设说话的人和表情,用哪张立绘由你的代码来挑。

[背景引用表]
- 教室白天:@asset:your-classroom-reference
- 学校走廊:@asset:your-hallway-reference
- 街道傍晚:@asset:your-street-reference
- 卧室夜晚:@asset:your-bedroom-reference

切换背景时,用上面的引用作为值。例如:
[current_bg: set "@asset:your-classroom-reference"]

然后回到 current_bg,把 初始值 设成教室的引用。

还没有素材?照样可以测试

没有图片的时候,界面代码会显示纯色背景。先把逻辑跑通,素材之后再加。想快速搭个原型,用图库的 https:// 链接也行。


第 4 步:写开场白 ​

开场白就是视觉小说的第一个场景。在画布上点开场白那一行,写进去:

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

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

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

*转校生?你不记得班上有她这样的人。*

开场白里不用指令来设变量。新的一局从每个变量的 初始值 开始,所以 current_bg 一开始就指向教室。如果你再加几个从别的地方开始的开场白,在画布上把开场白拖到变量上,就能给这个开场白单独设一个初始值(见开场白和设定)。


第 5 步:写视觉小说的界面代码 ​

界面代码接管整个画面。它不套 <Chat />,而是自己画背景、立绘、对话框和选项按钮。玩家输入交给放在画面底部的 <MessageInput />。

打开 面板 → 前端代码 → index.tsx,把里面的内容换成下面这段。SPRITES 里填上你在第 3 步拿到的引用:

tsx
// 每个 "名字_表情"(名字小写)对应的立绘。引用从 面板 → 资源 里复制。
const SPRITES = {
  yuki_neutral: "@asset:your-yuki-neutral-reference",
  yuki_happy: "@asset:your-yuki-happy-reference",
  yuki_sad: "@asset:your-yuki-sad-reference",
  teacher_neutral: "@asset:your-teacher-reference",
};

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 bgRef = String(api.variables.current_bg || "");
  const bgUrl = bgRef ? api.resolveAssetUrl(bgRef) : "";
  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 };
  });

  // ---- 立绘(按角色名和表情查找) ----
  const spriteRef = speaker !== "Narrator"
    ? SPRITES[`${speaker.toLowerCase()}_${emotion}`]
    : null;
  const spriteUrl = spriteRef ? api.resolveAssetUrl(spriteRef) : 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: bgUrl ? `url(${bgUrl})` : "linear-gradient(135deg, #1e293b, #0f172a)",
        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 让换背景时有淡入淡出
  • 立绘层:按 speaker 和 emotion 在 SPRITES 里找立绘。onError 会把加载失败的图片藏起来。旁白模式下,或者这个名字和表情没有对应的条目时,不显示立绘
  • 对话框层:底部的半透明框。speaker 不是「Narrator」时,对话框上方会出现角色名标签。旁白是灰色斜体,台词是白色正体
  • 选项按钮层:show_choices 为 true,而且文字里有 A) B) C) 格式的选项时,按钮出现在画面中间。点一个按钮会藏起选项(show_choices 设成 false),并把玩家选的内容发出去

立绘怎么找

SPRITES 的键是小写的角色名、一个下划线、再加表情:yuki_happy。你上传的每张立绘都加一行。角色名不是纯英文字母的话,键随便起一个,再告诉 AI 在 current_speaker 里一字不差地写这个名字。值也可以是一个 https:// 链接。


第 6 步:全屏布局,还是聊天加自定义气泡 ​

上面的代码返回的是它自己的布局,所以会占满画面。根元素用的是 width: "100%" 和 minHeight: "500px";想让它永远铺满窗口,就用 100vh。没有单独的全屏开关。

两种写法对比:

  • 普通聊天 + 自定义气泡:return <Chat renderBubble={...} />,保留平台的聊天外壳,只改气泡的样子
  • 纯视觉小说(本食谱):return <div>...所有视觉小说元素...<MessageInput /></div>,完全不用 <Chat />,所有东西都由你的代码来画

在聊天和视觉小说之间切换:想要「平时是普通聊天,特殊时刻切成视觉小说」,就在界面代码里按一个变量(比如 vn_mode)分支。关着时返回 <Chat />,开着时返回视觉小说的布局。AI 或者一条行为去切这个变量,玩到一半画面就换过来了。


第 7 步:AI 怎么驱动画面:指令示例 ​

下面是玩的时候 AI 的回复怎么改变画面。

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

AI 的回复:

[current_bg: set "@asset:your-classroom-reference"]
[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 "@asset:your-hallway-reference"]
[current_speaker: set "Narrator"]

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

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

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

显示结果:背景过渡到走廊(带淡入淡出)+ 旁白 + Yuki 开心的立绘 + 台词。


第 8 步:斜体旁白和普通台词:解析规则 ​

界面代码用一条简单的规则区分两种文字:

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

这些规则已经在那条设定里告诉 AI 了。如果它偶尔把格式写错(比如用斜体写台词),不是以 * 开头的都会被当成台词,所以不会出问题。

为什么用斜体,不用 > 引用块或 **粗体**? 大多数模型在角色扮演里本来就用斜体写旁白和动作,所以这是它们最能稳定照做的格式。


第 9 步:保存并测试 ​

  1. 点顶栏的 保存
  2. 点 试玩 开始试玩(试玩会花额度)
  3. 你应该能看到全屏的视觉小说画面:背景 + 对话框 + 开场旁白
  4. 在输入框里打一句话(比如「跟她打个招呼」)
  5. AI 的回复应该带着指令:背景可能会换,角色出现,台词显示在对话框里
  6. 如果 AI 给了选项,按钮会出现在画面中间。点一个试试
  7. 接着聊,看 AI 换场景时会不会更新 current_bg,角色说话时会不会更新 current_speaker 和 speaker_emotion。试玩里的 这回合 能看到某个值为什么变了或者没变

如果出了问题:

现象可能的原因怎么改
背景是黑的,或者只是一片渐变色current_bg 是空的,或者不是有效的图片引用在试玩里看一下这个变量的值。它应该是 面板 → 资源 里的 @asset: 引用,或者一个 https:// 链接
看不到立绘SPRITES 里没有这个名字和表情的条目检查键是不是小写名字 + _ + 表情(yuki_happy),引用对不对
指令行显示在画面上指令格式不标准,正则没匹配上确认格式是 [variableName: set "value"],注意冒号后面有空格
全都是旁白 / 全都是台词AI 没照格式规则写检查设定里的格式说明写清楚了没有。也可以在行为规则里再强调一下
选项按钮没出现show_choices 没设成 true,或者没有 A) 格式的选项检查 AI 的回复里有没有 [show_choices: set true] 和 A) 格式的选项
画面没有占满根元素没铺满可见区域在最外层的 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"]
*教室瞬间安静了下来。*

引擎按顺序应用这些指令,所以画面最后显示的是最后一个 current_speaker 的立绘。想让每一段都显示各自角色的立绘,就改代码,让每个段落去读它前面最近的那条 [current_speaker: set ...] 指令(从消息的原始文字里读)。

过渡效果 ​

背景层的 CSS 里有 transition: background-image 0.8s ease,换背景时会淡入淡出。不同类型的场景也可以用不同的过渡:

  • 普通切换:淡入淡出(已经有了)
  • 闪回 / 回忆:加一层白色闪光遮罩
  • 紧张的场景:加一个画面震动动画

搭配音效和 BGM ​

给每个场景配上自己的音乐。可以在音轨上用 条件 BGM(current_bg 是走廊的引用时放走廊的曲子,见音频设计),也可以加一条行为:什么时候触发 → 变量变化 盯 current_bg,如果…… current_bg 等于这个场景的引用,效果 播放音乐。昼夜循环食谱用同样的办法按时段换音乐。


速查 ​

你想要怎么做
换背景AI 发 [current_bg: set "@asset:..."]
换说话的人AI 发 [current_speaker: set "characterName"]
换表情AI 发 [speaker_emotion: set "emotion"]
显示选项按钮AI 发 [show_choices: set true],再按 A) B) C) 格式列出选项
区分旁白和台词*斜体* = 旁白,普通文字 = 台词
全屏的视觉小说index.tsx 直接返回视觉小说布局(不用 <Chat />),底部放 <MessageInput /> 给玩家输入
角色立绘传到 面板 → 资源,把引用按 名字_表情 列进 SPRITES
玩家点选项时发消息按钮的 onClick 调用 api.sendMessage(choiceText)
不写代码做立绘、跟着变量换的背景、选项按钮设定 → 立绘…;玩家界面的 图片 部件设成 跟着 某个变量换;玩家界面上的按钮