zh CN 教程 游戏对话 - chiba233/yumeDSL GitHub Wiki

教程:游戏对话标签

<< Home

从零开始构建一套完整的视觉小说/游戏对话 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 标签,句子之间有暂停,还有一次速度变更。本教程的其余部分将展示如何解析这段脚本,并将结果送入打字机渲染器。


步骤 1:搭建解析器

先做设计决策

写代码之前,先确定每个标签应使用哪种 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"]),
});

为什么需要 declareMultilineTags

speaker 是一个块级容器标签。在 DSL 中,block 形式的书写方式是:

$$speaker(Alice)*
Hello!
*end$$

作者自然会把 )**end$$ 各占一行,但这导致原始内容变成了 "\nHello!\n"——首尾各多了一个边界换行。如果不做规范化,渲染时对话框内容的上下方会各多出一个空行。

declareMultilineTags(["speaker"]) 告诉解析器在 block / raw 形式中精确剥掉首尾各一个换行,让内容从第一行实际文字干净地开始、在最后一行实际文字干净地结束。

在大多数场景下,只要标签注册了 block / raw handler,解析器会自动推导;手动声明的意义在于明确意图、覆盖自动推导不够准确的情况。详见 处理器辅助函数 — declareMultilineTags


步骤 2:解析脚本

将示例对话送入解析器:

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 数组。逐一分析:

[
  // ── 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-..." },
    ]
  },
]

关键观察:

  1. speaker token 的 value: TextToken[] 是递归解析后的对话主体。
  2. colorshake token 将子节点包裹在 value: TextToken[] 中。
  3. waitspeed token 的 value: "" ——它们是指令,不是内容。
  4. 行之间的换行符出现在相邻文本 token 中。
  5. 两个 speaker 块之间的空白是一个纯文本 token。

步骤 3:构建打字机渲染器

现在将 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 保持不变。


步骤 4:添加自定义标签 -- $$choice(option1 | option2 | option3)$$

视觉小说需要玩家选项。添加一个 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;

为什么用 inline?

Choice 标签出现在对话流中,跟在提问文本后面。它不跨多行内容——它是一条列出选项的指令。Inline 是正确的形式。Pipe 分隔符天然地分隔各选项,所以 createPipeHandlers 处理了拆分工作。


步骤 5:为你的游戏引擎自定义语法

某些游戏引擎使用 $ 做变量插值(例如 $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@@

tagOpentagClosetagDividerrawOpenblockOpenescapeChar 保持默认值:()|)%)*\

如果你还想把 *end@@ / %end@@ 里的共享 "end" 一起改掉,也可以继续用 easy 模式:

const syntax = createEasySyntax({ tagPrefix: "@@", closeMiddle: "fin" });
// rawClose -> "%fin@@"   blockClose -> "*fin@@"

何时使用 createEasySyntax vs createSyntax

  • createEasySyntax -- 改一两个基础 token,或补一个 closeMiddle,让复合 token 自动派生。推荐用于大多数场景。
  • createSyntax -- 完全手动控制。当你的语法不规则时使用(例如不同的开/闭括号类型、非标准 raw/block 标记)。

你学到了什么

本教程涵盖了四个核心概念:

1. 选择正确的标签形式

需求 形式 示例
用效果包裹文字 inline $$color(red | text)$$$$shake(text)$$
在文本流中注入指令 inline(value "" $$wait(500)$$$$speed(50)$$
包含多行内容 block $$speaker(Alice)* ... *end$$

决策归结为:标签是包裹内容还是发出指令?它跨一个短语还是多行?

2. "指令"标签 vs "内容"标签

两者都是 inline,但渲染器对待它们的方式不同:

  • 内容标签(color、shake)产生 value: TextToken[] —— 渲染器递归进入子节点并应用视觉效果。
  • 指令标签(wait、speed)产生 value: "" —— 渲染器读取标签的元数据字段(delay)并改变内部状态。

解析器对它们一视同仁。语义差异完全存在于你的处理器返回值和渲染器的解释中。

3. 将解析器连接到渲染引擎

walkTokens 是解析后的 token 与你的引擎之间的桥梁。Visitor 模式让你能够:

  • 根据 type 分发,对每种标签做不同处理。
  • 访问 ctx.parent 从祖先标签继承样式。
  • 在遍历过程中维护可变状态(如 speed)。

解析器与框架无关。同一棵 token 树可以驱动 DOM 打字机、Unity 协程、终端模拟器或测试断言。

4. 让语法适应你的环境

createEasySyntax({ tagPrefix: "@@" }) 改变面向作者的语法而不触及处理器。这让你能避免与引擎现有约定的冲突($ 用于变量、# 用于注释等)。


下一步: 教程:安全 UGC 聊天 -- 白名单标签、屏蔽危险形式、处理用户生成内容系统中的异常输入。

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