zh CN Tutorial Game Dialogue - chiba233/yume-dsl-token-walker GitHub Wiki

教程:游戏对话引擎

首页 | 解释 API | 异步解释 API

构建一个视觉小说对话系统——DSL 标签变成类型化指令,不是 HTML 字符串。打字机引擎逐帧消费这些指令,实现逐字显示、抖动效果、变色、暂停。

你将学到:

  1. 定义自定义 TNode 指令类型(不是字符串!)
  2. 构建同步 interpreter,yield 类型化指令
  3. 构建异步 interpreter,fetch 角色立绘
  4. flattenText 提取字幕/对话日志

前置条件: npm install yume-dsl-rich-text yume-dsl-token-walker


写手用的 DSL

$$speaker(Alice)*
Hello! $$shake(This is important!)$$
$$wait(500)$$
$$color(#ff6b6b | And this is red.)$$
*end$$

写手写可读的标记。你的引擎把它变成逐帧指令。


第 1 步:定义指令类型

你的 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[] };

第 2 步:搭建 parser

// 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"]),
});

第 3 步:构建 interpreter

// 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));
}

第 4 步:打字机引擎

打字机消费 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;
        }
    }
}

第 5 步:字幕用纯文本

import { flattenText } from "yume-dsl-token-walker";

const tokens = parser.parse("$$speaker(Alice)* Hello $$shake(world)$$! *end$$");
const subtitle = flattenText(tokens);
// → " Hello world! " — 用于字幕轨道或对话日志

第 6 步(可选):异步 interpreter 获取角色立绘

如果 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 不关心。


下一步

⚠️ **GitHub.com Fallback** ⚠️