zh CN Tutorial Game Dialogue - chiba233/yume-dsl-token-walker GitHub Wiki
构建一个视觉小说对话系统——DSL 标签变成类型化指令,不是 HTML 字符串。打字机引擎逐帧消费这些指令,实现逐字显示、抖动效果、变色、暂停。
你将学到:
- 定义自定义
TNode指令类型(不是字符串!) - 构建同步 interpreter,yield 类型化指令
- 构建异步 interpreter,fetch 角色立绘
- 用
flattenText提取字幕/对话日志
前置条件: npm install yume-dsl-rich-text yume-dsl-token-walker
$$speaker(Alice)*
Hello! $$shake(This is important!)$$
$$wait(500)$$
$$color(#ff6b6b | And this is red.)$$
*end$$
写手写可读的标记。你的引擎把它变成逐帧指令。
你的 TNode 不是 string——是类型化指令的判别联合:
// commands.ts
export type DialogueCommand =
| { kind: "text"; value: string }
| { kind: "shake"; children: DialogueCommand[] }
| { kind: "color"; hex: string; children: DialogueCommand[] }
| { kind: "wait"; ms: number }
| { kind: "speaker"; name: string; children: DialogueCommand[] };// parser.ts
import {
createParser,
createSimpleInlineHandlers,
createSimpleBlockHandlers,
createPipeHandlers,
declareMultilineTags,
} from "yume-dsl-rich-text";
export const parser = createParser({
handlers: {
...createSimpleInlineHandlers(["shake"]),
...createSimpleBlockHandlers(["speaker"]),
...createPipeHandlers({
color: {
inline: (args, ctx) => ({
type: "color",
hex: args.text(0, "#ffffff"),
value: args.materializedTailTokens(1),
}),
},
wait: {
inline: (args, ctx) => ({
type: "wait",
ms: parseInt(args.text(0, "0"), 10) || 0,
value: "",
}),
},
}),
},
blockTags: declareMultilineTags(["speaker"]),
});// interpret.ts
import type { DialogueCommand } from "./commands";
import { createRuleset, fromHandlerMap, interpretTokens, collectNodes } from "yume-dsl-token-walker";
import { parser } from "./parser";
const ruleset = createRuleset<DialogueCommand, void>({
createText: (text) => ({ kind: "text", value: text }),
interpret: fromHandlerMap({
shake: (token, h) => ({
type: "nodes",
nodes: [{ kind: "shake", children: Array.from(h.interpretChildren(token.value)) }],
}),
color: (token, h) => ({
type: "nodes",
nodes: [{
kind: "color",
hex: typeof token.hex === "string" ? token.hex : "#ffffff",
children: Array.from(h.interpretChildren(token.value)),
}],
}),
wait: (token) => ({
type: "nodes",
nodes: [{
kind: "wait",
ms: typeof token.ms === "number" ? token.ms : 0,
}],
}),
speaker: (token, h) => ({
type: "nodes",
nodes: [{
kind: "speaker",
name: typeof token.arg === "string" ? token.arg : "???",
children: Array.from(h.interpretChildren(token.value)),
}],
}),
}),
onUnhandled: "flatten",
});
export function parseDialogue(dslSource: string): DialogueCommand[] {
return collectNodes(interpretTokens(parser.parse(dslSource), ruleset, undefined));
}打字机消费 DialogueCommand[] 并动画播放:
// typewriter.ts
import type { DialogueCommand } from "./commands";
async function playCommands(commands: DialogueCommand[], context: {
typeChar: (char: string, style?: { shake?: boolean; color?: string }) => void;
showSpeaker: (name: string) => void;
delay: (ms: number) => Promise<void>;
}) {
for (const cmd of commands) {
switch (cmd.kind) {
case "text":
for (const char of cmd.value) {
context.typeChar(char);
await context.delay(30); // 每字 30ms
}
break;
case "shake":
await playCommandsWithStyle(cmd.children, context, { shake: true });
break;
case "color":
await playCommandsWithStyle(cmd.children, context, { color: cmd.hex });
break;
case "wait":
await context.delay(cmd.ms);
break;
case "speaker":
context.showSpeaker(cmd.name);
await playCommands(cmd.children, context);
break;
}
}
}import { flattenText } from "yume-dsl-token-walker";
const tokens = parser.parse("$$speaker(Alice)* Hello $$shake(world)$$! *end$$");
const subtitle = flattenText(tokens);
// → " Hello world! " — 用于字幕轨道或对话日志如果 speaker handler 需要从 CDN 获取立绘:
import type { AsyncInterpretRuleset } from "yume-dsl-token-walker";
import { interpretTokensAsync, collectNodesAsync } from "yume-dsl-token-walker";
const asyncRuleset: AsyncInterpretRuleset<DialogueCommand, void> = {
createText: (text) => ({ kind: "text", value: text }),
interpret: async (token, helpers) => {
if (token.type === "speaker") {
const name = typeof token.arg === "string" ? token.arg : "???";
// 这里可以 fetch 立绘 URL:
// const portrait = await fetch(`/api/portraits/${name}`);
const children: DialogueCommand[] = [];
for await (const child of helpers.interpretChildren(token.value)) {
children.push(child);
}
return {
type: "nodes",
nodes: [{ kind: "speaker", name, children }],
};
}
// ... 其他 handler
return { type: "unhandled" };
},
};
const commands = await collectNodesAsync(
interpretTokensAsync(parser.parse(dslSource), asyncRuleset, undefined),
);写手的 DSL 文本
│
▼
parser.parse() → TextToken[]
│
▼
interpretTokens(tokens, ruleset) → DialogueCommand[]
│
▼
打字机引擎 → 动画输出
关键洞察: TNode 是你想要的任何东西。字符串用于 HTML,指令用于游戏,VNode 用于 React——interpreter 不关心。
- 教程:博客渲染器 — Web 场景的字符串输出
- 教程:编辑器 Lint + 自动修复 — 用自定义规则校验 DSL 源码
- 异步解释 API — 完整异步 API 参考