zh CN 教程 游戏对话 - chiba233/yumeDSL GitHub Wiki
从零开始构建一套完整的视觉小说/游戏对话 DSL。完成后你将拥有 color、shake、wait、speed、speaker 和 choice 标签,以及一个消费 token 树的打字机渲染器。
你正在开发一款视觉小说引擎。文本由作者和策划编写,而非程序员。他们需要一种嵌入脚本文件的小型标记语言:
| 标签 | 形式 | 用途 |
|---|---|---|
$$color(red | This text is red)$$ |
inline,带 pipe | 给一段文字着色 |
$$shake(This text shakes)$$ |
inline | 给文字添加抖动动画 |
$$wait(500)$$ |
inline | 暂停打字机 500 毫秒(无可见输出) |
$$speed(50)$$ |
inline | 将打字机速度设为每字符 50 毫秒 |
$$speaker(Alice)* ... *end$$ |
block | 将一段对话归属到某个角色 |
以下是作者的脚本文件:
$$speaker(Alice)*
Hello! $$color(blue | Nice to meet you)$$.
$$wait(300)$$
Have you seen the $$shake(strange creature)$$ in the forest?
*end$$
$$speaker(Bob)*
$$speed(30)$$Yes... it was $$color(red | terrifying)$$.
*end$$
两个说话人,block 标签内嵌套 inline 标签,句子之间有暂停,还有一次速度变更。本教程的其余部分将展示如何解析这段脚本,并将结果送入打字机渲染器。
写代码之前,先确定每个标签应使用哪种 DSL 形式:
| 标签 | 形式 | 原因 |
|---|---|---|
| color | inline + pipe | 它包裹一段文字,并且需要一个参数(颜色名)。Pipe 分隔参数和内容:$$color(red | text)$$。 |
| shake | inline,无 pipe | 它包裹文字,但不需要参数——标签名本身就是效果。$$shake(text)$$。 |
| wait | inline | 它出现在文本流中(句子之间、对话内部),但不产生可见内容。其 value 为 ""。参数(延迟毫秒数)就是整个 inline 内容。 |
| speed | inline | 与 wait 相同的模式:改变状态的"指令"标签,无可见输出,参数就是内容。 |
| speaker | block | 它包含多行对话及嵌套标签。Block 形式为作者提供了自然的开始/结束结构,并支持对主体的递归解析。 |
"内容"标签(color、shake)与**"指令"标签**(wait、speed)的区别很重要。内容标签包裹子节点并以视觉效果渲染它们。指令标签不产生可见输出——它们向渲染管线注入副作用。两者都是 inline,因为它们出现在文本流中。
import {
createParser,
createPipeHandlers,
materializeTextTokens,
extractText,
declareMultilineTags,
type TextToken,
type DslContext,
type TagHandler,
type TokenDraft,
} from "yume-dsl-rich-text";
// ── color ──────────────────────────────────────────────
// Inline + pipe: $$color(red | This text is red)$$
//
// Pipe 段 0 → 颜色名(纯字符串)
// Pipe 段 1+ → 要着色的内容(token 树,可能包含嵌套标签)
//
// 使用 createPipeHandlers,pipe 分割由框架自动完成。
// materializedTailTokens(1) 将第一个 pipe 之后的所有内容
// 收集为一个 token 数组,这意味着作者可以在着色文本中
// 使用转义的 pipe。
const pipeTags = createPipeHandlers({
color: {
inline: (args, ctx) => ({
type: "color",
color: args.text(0), // "red", "blue", "#ff0"
value: args.materializedTailTokens(1), // 着色内容
}),
},
});
// ── shake ──────────────────────────────────────────────
// Inline,无 pipe: $$shake(This text shakes)$$
//
// 整个 inline 内容就是要加动画的文字。
// materializeTextTokens 解析文本叶节点中的转义序列,
// 同时保留嵌套标签结构(例如 $$shake($$color(red | wow)$$)$$)。
const shakeHandler: Record<string, TagHandler> = {
shake: {
inline: (tokens, ctx) => ({
type: "shake",
value: materializeTextTokens(tokens, ctx),
}),
},
};
// ── wait ───────────────────────────────────────────────
// Inline 指令: $$wait(500)$$
//
// 无可见输出——value 为 ""。
// 括号内的数字是延迟毫秒数。
// extractText 从 token 数组中提取原始文本内容,
// 然后我们将其解析为整数。
//
// 为什么用 inline 而不是 raw?因为 wait 出现在对话文本 *内部*,
// 在句子之间。它与文字和其他 inline 标签处于同一文本流中。
// Raw 形式需要独占一行对,对于嵌在段落中的小指令来说太笨重了。
const waitHandler: Record<string, TagHandler> = {
wait: {
inline: (tokens, ctx) => {
const ms = parseInt(extractText(tokens), 10) || 0;
return { type: "wait", delay: ms, value: "" };
},
},
};
// ── speed ──────────────────────────────────────────────
// Inline 指令: $$speed(50)$$
//
// 与 wait 相同的模式:inline 指令,无可见输出。
// 数字是每字符新的打字机延迟(毫秒)。
const speedHandler: Record<string, TagHandler> = {
speed: {
inline: (tokens, ctx) => {
const ms = parseInt(extractText(tokens), 10) || 0;
return { type: "speed", delay: ms, value: "" };
},
},
};
// ── speaker ────────────────────────────────────────────
// Block 形式: $$speaker(Alice)*\n...\n*end$$
//
// arg 是说话人的名字。
// Block 主体被递归解析,所以作者可以在对话中使用
// color、shake、wait、speed——任何 inline 标签。
//
// 为什么用 block 而不是 inline?因为对话跨越多行。
// Block 形式提供了作者能理解的清晰视觉边界:
// $$speaker(Alice)*
// ...多行对话...
// *end$$
const speakerHandler: Record<string, TagHandler> = {
speaker: {
block: (arg, content, ctx) => ({
type: "speaker",
name: arg ?? "???", // 作者忘记写名字时的回退值
value: content, // 递归解析的对话主体
}),
},
};
// ── 组装解析器 ─────────────────────────────────────────
const dsl = createParser({
handlers: {
...pipeTags,
...shakeHandler,
...waitHandler,
...speedHandler,
...speakerHandler,
},
blockTags: declareMultilineTags(["speaker"]),
});speaker 是一个块级容器标签。在 DSL 中,block 形式的书写方式是:
$$speaker(Alice)*
Hello!
*end$$
作者自然会把 )* 和 *end$$ 各占一行,但这导致原始内容变成了 "\nHello!\n"——首尾各多了一个边界换行。如果不做规范化,渲染时对话框内容的上下方会各多出一个空行。
declareMultilineTags(["speaker"]) 告诉解析器在 block / raw 形式中精确剥掉首尾各一个换行,让内容从第一行实际文字干净地开始、在最后一行实际文字干净地结束。
在大多数场景下,只要标签注册了 block / raw handler,解析器会自动推导;手动声明的意义在于明确意图、覆盖自动推导不够准确的情况。详见 处理器辅助函数 — declareMultilineTags。
将示例对话送入解析器:
const script = `$$speaker(Alice)*
Hello! $$color(blue | Nice to meet you)$$.
$$wait(300)$$
Have you seen the $$shake(strange creature)$$ in the forest?
*end$$
$$speaker(Bob)*
$$speed(30)$$Yes... it was $$color(red | terrifying)$$.
*end$$`;
const tokens = dsl.parse(script);结果是一个顶层 token 数组。逐一分析:
[
// ── Token 0: Alice 的对话块 ──
{
type: "speaker",
name: "Alice",
id: "rt-...",
value: [
// 第 1 行: "Hello! " + color 标签 + "."
{ type: "text", value: "Hello! ", id: "rt-..." },
{
type: "color",
color: "blue",
id: "rt-...",
value: [
{ type: "text", value: "Nice to meet you", id: "rt-..." }
]
},
{ type: "text", value: ".\n", id: "rt-..." },
// 第 2 行: wait 指令(无可见文字)
{ type: "wait", delay: 300, value: "", id: "rt-..." },
// 第 3 行: 文本 + shake 标签 + 文本
{ type: "text", value: "\nHave you seen the ", id: "rt-..." },
{
type: "shake",
id: "rt-...",
value: [
{ type: "text", value: "strange creature", id: "rt-..." }
]
},
{ type: "text", value: " in the forest?\n", id: "rt-..." },
]
},
// ── 块之间的空白 ──
{ type: "text", value: "\n\n", id: "rt-..." },
// ── Token 1: Bob 的对话块 ──
{
type: "speaker",
name: "Bob",
id: "rt-...",
value: [
// 行首的 speed 指令
{ type: "speed", delay: 30, value: "", id: "rt-..." },
// "Yes... it was " + color 标签 + "."
{ type: "text", value: "Yes... it was ", id: "rt-..." },
{
type: "color",
color: "red",
id: "rt-...",
value: [
{ type: "text", value: "terrifying", id: "rt-..." }
]
},
{ type: "text", value: ".\n", id: "rt-..." },
]
},
]关键观察:
-
speaker token 的
value: TextToken[]是递归解析后的对话主体。 -
color 和 shake token 将子节点包裹在
value: TextToken[]中。 -
wait 和 speed token 的
value: ""——它们是指令,不是内容。 - 行之间的换行符出现在相邻文本 token 中。
- 两个 speaker 块之间的空白是一个纯文本 token。
现在将 token 树连接到真实的渲染引擎。这个打字机状态机遍历 token 树,产生一系列带时序的渲染指令。
// 渲染引擎的单条指令
type RenderOp =
| { kind: "char"; char: string; delay: number; styles: StyleStack }
| { kind: "pause"; delay: number }
| { kind: "speaker"; name: string }
| { kind: "linebreak" };
// 样式状态:任意时刻哪些效果处于激活状态
interface StyleState {
color: string | null;
shake: boolean;
}
// 活跃样式栈(用于嵌套:shake 内的 color 等)
type StyleStack = StyleState;import { walkTokens, type TextToken } from "yume-dsl-rich-text";
function buildRenderQueue(tokens: TextToken[]): RenderOp[] {
const ops: RenderOp[] = [];
let speed = 50; // 默认:每字符 50ms
// 我们需要在深入树时跟踪样式上下文。
// walkTokens 以深度优先前序访问,所以可以通过
// 检查父节点链来维护样式栈。
// 辅助函数:从 token 的祖先链计算当前样式。
// 在实际引擎中你会维护一个显式的栈;
// 这里为了清晰使用简化方案。
function getStyles(token: TextToken, parent: TextToken | null): StyleStack {
const state: StyleState = { color: null, shake: false };
// 通过检查 token 上下文沿祖先链向上查找
// 本教程中我们从直接父节点读取 color/shake
if (parent) {
if (parent.type === "color" && typeof parent.color === "string") {
state.color = parent.color;
}
if (parent.type === "shake") {
state.shake = true;
}
}
return state;
}
walkTokens(tokens, {
// ── Speaker:输出说话人头部 ──
speaker: (token) => {
if (typeof token.name === "string") {
ops.push({ kind: "speaker", name: token.name });
}
// 子节点会被 walkTokens 自动访问
},
// ── Text:将每个字符以当前速度入队 ──
text: (token, ctx) => {
if (typeof token.value !== "string") return;
const styles = getStyles(token, ctx.parent);
for (const char of token.value) {
if (char === "\n") {
ops.push({ kind: "linebreak" });
} else {
ops.push({ kind: "char", char, delay: speed, styles });
}
}
},
// ── Wait:插入暂停 ──
wait: (token) => {
const delay = typeof token.delay === "number" ? token.delay : 0;
ops.push({ kind: "pause", delay });
},
// ── Speed:改变字符延迟 ──
speed: (token) => {
const newSpeed = typeof token.delay === "number" ? token.delay : 50;
speed = newSpeed;
},
// color 和 shake 不需要自己的 visitor——
// 它们的效果通过 getStyles() 被 text visitor 捕获。
});
return ops;
}const queue = buildRenderQueue(tokens);
// 示例:在浏览器中播放队列
async function play(queue: RenderOp[]) {
for (const op of queue) {
switch (op.kind) {
case "speaker":
// 显示说话人名牌
showSpeakerName(op.name);
break;
case "char":
// 追加一个带样式的字符
appendChar(op.char, op.styles);
await sleep(op.delay);
break;
case "pause":
// 冻结打字机
await sleep(op.delay);
break;
case "linebreak":
appendLineBreak();
break;
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}解析器不了解打字机、DOM 元素或动画。它产生一棵干净的、类型化的 token 树。渲染器使用 walkTokens 遍历该树,将每个节点翻译为引擎特定的指令。这种分离意味着:
- 作者使用可读的 DSL 标记。
- 解析器验证结构并产生 token。
- 渲染器将 token 映射到平台特定的效果。
你可以把渲染器换成 Unity C# 渲染器、终端渲染器或测试断言——解析器和 DSL 保持不变。
视觉小说需要玩家选项。添加一个 choice 标签,其中每个 pipe 段是一个选项:
$$speaker(Alice)*
What should we do?
$$choice(Run away | Fight the creature | Hide in the bushes)$$
*end$$
这天然适合 createPipeHandlers——每个 pipe 段映射到一个选项:
import { createPipeHandlers } from "yume-dsl-rich-text";
const choiceTag = createPipeHandlers({
choice: {
inline: (args, ctx) => {
// 将所有 pipe 段收集为纯文本字符串。
// args.parts 告诉我们有多少段。
const options: string[] = [];
for (let i = 0; i < args.parts.length; i++) {
options.push(args.text(i));
}
return {
type: "choice",
options, // ["Run away", "Fight the creature", "Hide in the bushes"]
value: "", // 无显示内容——渲染器负责显示按钮
};
},
},
});const dsl = createParser({
handlers: {
...pipeTags,
...shakeHandler,
...waitHandler,
...speedHandler,
...speakerHandler,
...choiceTag, // ← 添加 choice
},
blockTags: declareMultilineTags(["speaker"]),
});在 walkTokens visitor 中为新 token 类型添加处理:
walkTokens(tokens, {
// ... 已有的 handler ...
choice: (token) => {
if (Array.isArray(token.options)) {
ops.push({
kind: "choice" as const,
options: token.options as string[],
});
}
},
});在浏览器渲染器中,choice 操作创建可点击按钮:
case "choice":
// 暂停打字机并显示选项按钮
const chosen = await showChoiceButtons(op.options);
handlePlayerChoice(chosen);
break;Choice 标签出现在对话流中,跟在提问文本后面。它不跨多行内容——它是一条列出选项的指令。Inline 是正确的形式。Pipe 分隔符天然地分隔各选项,所以 createPipeHandlers 处理了拆分工作。
某些游戏引擎使用 $ 做变量插值(例如 $playerName)。让 $$ 作为 DSL 前缀会产生冲突。使用 createEasySyntax 切换到 @@:
import { createEasySyntax, createParser } from "yume-dsl-rich-text";
const syntax = createEasySyntax({ tagPrefix: "@@" });
const dsl = createParser({
syntax,
handlers: {
...pipeTags,
...shakeHandler,
...waitHandler,
...speedHandler,
...speakerHandler,
...choiceTag,
},
blockTags: declareMultilineTags(["speaker"]),
});所有复合 token 自动更新——endTag 变为 )@@,blockClose 变为 *end@@:
@@speaker(Alice)*
Hello! @@color(blue | Nice to meet you)@@.
@@wait(300)@@
Have you seen the @@shake(strange creature)@@ in the forest?
*end@@
@@speaker(Bob)*
@@speed(30)@@Yes... it was @@color(red | terrifying)@@.
*end@@
处理器无需改动。只有面向作者的语法不同。createEasySyntax 从新前缀自动派生所有复合 token:
| Token | 默认($$) |
自定义(@@) |
|---|---|---|
tagPrefix |
$$ |
@@ |
endTag |
)$$ |
)@@ |
rawClose |
%end$$ |
%end@@ |
blockClose |
*end$$ |
*end@@ |
tagOpen、tagClose、tagDivider、rawOpen、blockOpen 和 escapeChar 保持默认值:(、)、|、)%、)*、\。
如果你还想把 *end@@ / %end@@ 里的共享 "end" 一起改掉,也可以继续用 easy 模式:
const syntax = createEasySyntax({ tagPrefix: "@@", closeMiddle: "fin" });
// rawClose -> "%fin@@" blockClose -> "*fin@@"-
createEasySyntax-- 改一两个基础 token,或补一个closeMiddle,让复合 token 自动派生。推荐用于大多数场景。 -
createSyntax-- 完全手动控制。当你的语法不规则时使用(例如不同的开/闭括号类型、非标准 raw/block 标记)。
本教程涵盖了四个核心概念:
| 需求 | 形式 | 示例 |
|---|---|---|
| 用效果包裹文字 | inline |
$$color(red | text)$$、$$shake(text)$$
|
| 在文本流中注入指令 | inline(value "") |
$$wait(500)$$、$$speed(50)$$
|
| 包含多行内容 | block | $$speaker(Alice)* ... *end$$ |
决策归结为:标签是包裹内容还是发出指令?它跨一个短语还是多行?
两者都是 inline,但渲染器对待它们的方式不同:
-
内容标签(color、shake)产生
value: TextToken[]—— 渲染器递归进入子节点并应用视觉效果。 -
指令标签(wait、speed)产生
value: ""—— 渲染器读取标签的元数据字段(delay)并改变内部状态。
解析器对它们一视同仁。语义差异完全存在于你的处理器返回值和渲染器的解释中。
walkTokens 是解析后的 token 与你的引擎之间的桥梁。Visitor 模式让你能够:
- 根据
type分发,对每种标签做不同处理。 - 访问
ctx.parent从祖先标签继承样式。 - 在遍历过程中维护可变状态(如
speed)。
解析器与框架无关。同一棵 token 树可以驱动 DOM 打字机、Unity 协程、终端模拟器或测试断言。
createEasySyntax({ tagPrefix: "@@" }) 改变面向作者的语法而不触及处理器。这让你能避免与引擎现有约定的冲突($ 用于变量、# 用于注释等)。
下一步: 教程:安全 UGC 聊天 -- 白名单标签、屏蔽危险形式、处理用户生成内容系统中的异常输入。