视觉小说模式
将聊天界面变成一个完整的视觉小说 -- 场景背景、角色立绘、对话框、选项按钮,全部由 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 转过身来对你微笑:
早上好!你今天来得真早。引擎解析这些指令后:
current_bg变为"classroom_morning.jpg"→ Root Component 将background-image切换为教室current_speaker变为"Yuki"→ 对话框显示名字「Yuki」speaker_emotion变为"happy"→ Root Component 从speaker + emotion组合立绘路径并渲染- Root Component 解析最新消息的文本 -- 斜体 部分渲染为旁白,普通文字渲染为角色对话
引擎处理流程:
AI 回复 → 引擎提取指令 → 更新变量 → Root Component 读取变量并重绘
→ 背景层:<div style={{ backgroundImage: ... }} />
→ 立绘层:<img src={`/sprites/${speaker}_${emotion}.png`} />
→ 对话框层:区分旁白(灰色斜体)与对话(白色正体)
→ 选项层:当 show_choices = true 时显示按钮逐步操作
第 1 步:创建变量
你需要 4 个变量来控制视觉小说的显示。
编辑器 → 侧边栏 → 变量 选项卡 → 为每个变量点击「添加变量」
变量 1:当前背景
| 字段 | 值 | 原因 |
|---|---|---|
| 名称 | Current Background | 供你自己参考 |
| ID | current_bg | AI 使用 [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 | 供你自己参考 |
| ID | current_speaker | AI 使用 [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 | 供你自己参考 |
| ID | speaker_emotion | AI 使用 [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 | 供你自己参考 |
| ID | show_choices | AI 使用 [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 | 供你自己参考 |
| 区域 | Presets | Presets 区域的条目每次都会发送给 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
- 打开编辑器 → 侧边栏 → 素材 选项卡
- 拖放 你的图片文件到上传区域(或点击浏览)
- 上传后,每个文件会获得一个
@asset:引用(如@asset:a1b2c3d4-e5f6-7890) - 点击已上传的素材来 复制其引用
什么是
@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 />):
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在切换背景时添加淡入淡出动画 - 立绘层 -- 从
speaker和emotion组合立绘文件路径。onError处理缺失的图片(静默隐藏)。旁白模式下不显示立绘 - 对话框层 -- 底部的半透明框。当
speaker不是「Narrator」时,对话框上方会出现角色名标签。旁白文字为灰色斜体;对话文字为白色正体 - 选项按钮层 -- 当
show_choices为true且文本包含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 步:保存并测试
- 点击编辑器顶部的 保存
- 点击 开始游戏 或返回主页开始新会话
- 你应该看到全屏的视觉小说显示 -- 背景 + 对话框 + 开场旁白
- 在输入框中输入消息(例如「跟她打招呼」)
- AI 的回复应该包含指令 -- 背景可能会切换,角色出现,对话显示在对话框中
- 如果 AI 提供选择,按钮会出现在屏幕中央。点击一个试试
- 继续对话,观察 AI 是否在切换场景时自然地更新
current_bg,在角色说话时更新current_speaker和speaker_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 文件并导入,体验完整效果:
如何导入:
- 前往 Yumina → 我的世界 → 创建新世界
- 在编辑器中,点击 更多操作 → 导入包
- 选择下载的
.json文件 - 一个新世界会被创建,所有变量、条目、行为和 Root Component 都已预配置
- 开始新会话并试用
包含内容:
- 4 个变量(
current_bg背景、current_speaker说话者、speaker_emotion表情、show_choices选项开关) - 1 个知识条目(视觉小说系统指令,告诉 AI 如何使用指令和文本格式)
- 1 条开场白(带有初始指令的视觉小说开场)
- 一个 Root Component(完整的视觉小说界面:背景 + 立绘 + 对话框 + 选项按钮 +
<MessageInput />)
这是食谱 #10
视觉小说模式展示了 Yumina 最强大的能力 -- AI 不只是一个聊天伙伴,它是一个叙事引擎。通过用指令驱动屏幕、用格式约定区分文本类型、让 Root Component 重塑整个界面,你可以将普通的聊天框变成任何你能想象的交互体验。同样的方法适用于冒险游戏、互动漫画,甚至经营模拟。
