Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

欢迎来到 Mortar 🦀

你好,欢迎来到 Mortar 的世界!

Mortar 是什么?

想象一下,你正在写一个游戏的对话剧本。你可能会遇到这样的困扰:

  • 文字里混杂着各种技术标记,看起来乱糟糟的
  • 想让音效在某个字出现时播放,但不知道怎么标注
  • 写手和程序员总是在互相“踩脚“

Mortar 就是为了解决这些问题而生的。

它是一种专门用来写游戏对话的语言,核心理念是实现文本内容与事件逻辑的严格分离

总的来说,我们的理念很简单:

让文字归文字,让代码归代码。

你可以专心写故事,而程序可以专心处理游戏逻辑——两者井水不犯河水,但又能完美配合。

它能做什么?

Mortar 特别适合这些场景:

  • 🎮 游戏对话系统:RPG 对话、视觉小说
  • 📖 交互小说:文字冒险、分支叙事
  • 📚 教育内容:互动式教学、引导式学习场景
  • 🤖 聊天脚本:结构化对话逻辑
  • 🖼️ 多媒体呈现:文字与媒体事件的同步

为什么选择 Mortar?

与其他对话系统相比,Mortar 有这些特点:

  • 干净清爽:文本里不会有乱七八糟的标记
  • 精准控制:可以指定在第几个字触发事件(比如播放音效)
  • 易于理解:语法设计得像日常写作一样自然
  • 方便集成:编译成 JSON 格式,任何游戏引擎都能用

快速一瞥

这是一段 Mortar 代码的样子:

node OpeningScene {
    text: "欢迎来到魔法世界!"
    with events: [
        0, play_sound("magic_sound.wav")
        7, sparkle_effect()
    ]
    
    text: "准备好开始冒险了吗?"
    
    choice: [
        "当然,我准备好了!" -> AdventureStart,
        "让我再想想..." -> Hesitate
    ]
}

看起来是不是很直观?

接下来


Mortar 是开源项目,采用 MIT/Apache-2.0 双许可证 ❤️

五分钟上手

让我们动手写第一个 Mortar 对话!不用担心,这比你想象的简单。

第一步:创建文件

创建一个叫 hello.mortar 的文件(用任何文本编辑器都行,你也可以先阅读 编辑器支持 学习如何配置编辑器)。

第二步:写一段简单对话

在文件里写下这些:

// 这是注释,这一行内容会被编译器无视,不必担心!
// 我会在注释里为你解释 mortar 代码。

node StartScene {
    text: "你好呀,欢迎来到这个互动故事!"
}

就这么简单!你已经写好了第一个对话节点。

解释一下

  • node 声明一个对话节点(也可以简写成 nd
  • StartScene 是这个节点的名字(使用大驼峰命名法)
  • text: 后面跟着的就是对话内容
  • 别忘了大括号 {},它们把节点的内容包起来

💡 命名规范提示

  • “NodeName“使用大驼峰命名(PascalCase),如 StartSceneForestPath
  • 函数名使用蛇形命名(snake_case),如 play_soundget_player_name
  • 我们建议仅使用 英文、数字、下划线 的组合作为标识符

第三步:加点音效

现在让我们的对话更生动一些。假设程序方和你已经沟通好了——我们要让每句话像打字机一样慢慢打印出来:

node StartScene {
    text: "你好呀,欢迎来到这个互动故事!"
    with events: [
        // 在"你"字出现时播放音效
        0, play_sound("greeting.wav")
        // 在"欢"字出现时显示动画
        4, show_animation("wave")
    ]
}

// 告诉 Mortar 一共有哪些函数可以用
// 程序方需要在项目中绑定下面的函数
fn play_sound(file_name: String)
fn show_animation(anim_name: String)

解释一下

  • with events: 绑定到它上面的那条文本,事件列表用方括号 [] 包起来
  • 0, play_sound("greeting.wav") 表示:在第 0 个索引(我们用打字机关联索引,也就是“你“字)出现时,播放音效
  • 数字就是“索引”,也就是字符的位置,从 0 开始计数。索引可以是小数(浮点数)
  • 这里的索引不是死板的,有的游戏可能用打字机效果,有的游戏可能和语音对齐,所以具体怎么数完全看项目需求
  • 最下面的 fn 是函数声明,告诉编译器这些函数需要什么参数
  • 函数参数名建议用蛇形命名:file_nameanim_name

第四步:添加多段对话

一个节点可以有好几段文字:

node StartScene {
    text: "你好呀,欢迎来到这个互动故事!"
    // ↕ 这个 事件列表 与其上方的 文本 绑定
    with events: [
        0, play_sound("greeting.wav")
    ]
    
    // 第二段文字
    text: "我想你的名字是 Ferris,对吧?"
    
    // 第三段文字
    text: "那我们开始吧!"
}

这三段文字会依次显示出来。其中,第一个文本带有事件,而后两个文本没有事件。

第五步:让玩家做选择

现在让玩家参与进来:

node StartScene {
    text: "你想做什么呢?"
    
    choice: [
        "去森林探险" -> ForestScene,
        "回城里休息" -> TownScene,
        "我不玩了" -> return
    ]
}

node ForestScene {
    text: "你勇敢地走进了森林..."
}

node TownScene {
    text: "你回到了温暖的城镇。"
}

解释一下

  • choice: 表示这里有选项
  • "去森林探险" -> ForestScene 意思是:显示“去森林探险“这个选项,选了就跳到名为 ForestScene 的节点
  • “node“都使用大驼峰命名法的好处在这里就体现出来了――便于识别和维护!
  • return 表示结束当前对话

第六步:编译文件

打开命令行(终端/CMD),输入:

mortar hello.mortar

这会生成一个 hello.mortared 文件,里面是 JSON 格式的数据,你的游戏可以读取它。

显示 “命令未找到”? 那说明你还没有安装 mortar 的编译器!请阅读 安装工具 来安装它。

JSON 缩成一行了? 加上 --pretty 参数:

mortar hello.mortar --pretty

想自定义输出文件名和文件后缀?-o 参数:

mortar hello.mortar -o 我的对话.json

完整示例

把刚才学的组合起来,再“加点细节”:

node WelcomeScene {
    text: "你好呀,欢迎来到魔法学院!"
    with events: [
        0, play_sound("magic_sound.wav")
        7, sparkle_effect()
    ]
    
    text: $"你的名字是{get_player_name()},对吧?"
    
    text: "准备好开始冒险了吗?"
    
    choice: [
        "当然!" -> AdventureStart,
        "让我再想想..." -> Hesitate,
        // 带条件的选项(只有有背包才显示)
        "先看看背包" when has_backpack() -> CheckInventory
    ]
}

node AdventureStart {
    text: "太好了!那我们出发吧!"
}

node Hesitate {
    text: "没关系,慢慢来~"
}

node CheckInventory {
    text: "你的背包里有一些基础道具。"
}

// 函数声明
fn play_sound(file: String)
fn sparkle_effect()
fn get_player_name() -> String
fn has_backpack() -> Bool

新东西解释

  • $"你的名字是{get_player_name()},对吧?" 这叫字符串插值,{} 里的内容会被替换成函数的返回值
  • when 表示这个选项有条件,只有 has_backpack() 返回 true,才“显示”
  • -> String-> Bool 表示函数的返回类型。mortar 会进行静态类型检测,以防止类型混用!

接下来学什么?

恭喜你!你已经学会 Mortar 的基础了 🎉

安装工具

要使用 Mortar,你需要安装编译工具。别担心,过程很简单!

方法一:用 Cargo 安装(推荐)

如果你已经安装了 Rust,那就太方便了:

cargo install mortar_cli

等待安装完成,然后检查是否成功:

mortar --version

看到版本号就说明安装成功了!

方法二:从源码编译(不太适合普通用户)

想体验最新的开发版?可以从源码构建(这也需要 rust 开发环境):

# 下载源码
git clone https://github.com/Bli-AIk/mortar.git
cd mortar

# 编译
cargo build --release

# 编译好的程序在这里
./target/release/mortar --version

提示:编译好的可执行文件位于 target/release/mortar,你可以把它加入环境变量。

方法三:从 GitHub Release 下载(不太适合普通用户)

如果你不想使用 Rust 或 Cargo,也可以直接从 Mortar 的 GitHub Release 页面 下载预编译的二进制文件。

Linux / macOS

  1. 打开 Release 页面,下载对应版本,例如 mortar-x.x.x-linux-x64.tar.gzmortar-x.x.x-macos-x64.tar.gz
  2. 解压到任意目录:
tar -xzf mortar-x.x.x-linux-x64.tar.gz -C ~/mortar
  1. 将可执行文件路径加入环境变量,例如:
export PATH="$HOME/mortar:$PATH"
  1. 检查是否安装成功:
mortar --version

Windows

  1. 下载对应版本的 mortar-x.x.x-windows-x64.zip
  2. 解压到任意目录,例如 D:\mortar
  3. 将目录添加到系统环境变量 PATH:
    • 右键「此电脑」→「属性」→「高级系统设置」→「环境变量」
    • 在「系统变量」或「用户变量」中找到 Path → 编辑 → 添加 D:\mortar
  4. 打开新的命令提示符,检查安装:
mortar --version

⚠️ 注意

  • 需要手动设置环境变量
  • 每次开新终端或修改系统配置时可能会出现问题
  • 对普通用户来说不太友好

因此推荐使用 方法一(Cargo),安装体验更顺畅。

检查安装

运行这个命令测试一下:

mortar --help

你应该能看到帮助信息,说明各种用法。

编辑器支持(可选但推荐)

为了更好的编写体验,可以安装语言服务器:

cargo install mortar_lsp

然后在你喜欢的编辑器里配置它即可。

查看 编辑器支持 了解如何配置你的编辑器。

遇到问题?

“找不到 cargo 命令”

你需要先安装 Rust。访问 https://rust-lang.org/ 按照指引安装。

“编译失败”

确保你的 Rust 版本足够新:

rustup update

其他问题

下一步

安装好了?那就去五分钟上手试试看吧!

理解 Mortar 语言

学会了基础操作,现在让我们深入了解 Mortar 的核心思想。

Mortar 的三个关键组成

写一个 Mortar 脚本,主要就是在处理这几样东西:

1. 节点(Nodes)

想象节点就像是对话中的 “场景” 或 “片段”。每个节点可以包含:

  • 若干段文本
  • 对应的事件
  • 玩家的选择
  • 下一个节点

2. 文本与事件(Text & Events)

这是 Mortar 最核心的特色:

  • 文本:纯粹的对话内容,不掺杂任何 富文本 / 技术标记
  • 事件:在特定字符位置触发的动作(音效、动画等)
  • 它们分开写,但通过索引关联起来。

3. 选择(Choices)

让玩家参与进来的关键:

  • 列出若干选项
  • 每个选项可以跳转到不同节点
  • 可以设置条件(比如必须有某个道具才显示)

Mortar 的设计哲学

分离关注点

传统的对话系统可能长这样:

"你好<sound=greeting.wav>,欢迎<anim=wave>来到这里!"

看起来很乱对吧?写手要记住各种标记,程序员也很难维护。

Mortar 的方式:

text: "你好,欢迎来到这里!"
with events: [
    0, play_sound("greeting.wav")
    3, show_animation("wave")
]

文本就是文本,事件就是事件。清清楚楚!

位置即时间

Mortar 用“字符位置“来控制事件发生的时机:

text: "你好世界!"
with events: [
    0, sound_a()  // 在"你"字时触发
    2, sound_b()  // 在"世"字时触发
    4, sound_c()  // 在"!"时触发
]

这个位置可以是:

  • 整数:适合打字机效果(一个字一个字显示)
  • 小数:适合语音同步(比如在某句话说到 2.5 秒时)

声明式语法

你只需要描述“做什么“,不用管“怎么做“:

choice: [
    "选项A" -> 节点A,
    "选项B" when has_key() -> 节点B
]

has_key() 的代码实现完全由程序方实现。

数据流:从 Mortar 到游戏

让我们看看完整的流程:

写 Mortar    你用 Mortar 语言写对话
   │
   ▼
编译成 JSON    mortar 命令把它编译成 JSON
   │
   ▼
游戏读取并执行    你的游戏引擎读取 JSON 并按照指示执行

各部分详细说明

想深入了解每个部分?

小提示

  • 从简单开始:先写纯文本对话,再慢慢加事件和选项
  • 善用注释:用 // 给自己留笔记
  • 合理命名:“NodeName”、函数名要见名知意
  • 保持干净:Mortar 的优势就是干净,别把逻辑写得太复杂

准备好深入了解了吗?从节点开始吧!

节点:对话的积木块

节点(Node)是 Mortar 中最基本的单位,把它想象成对话中的一个“场景“或“片段“。

最简单的节点

node OpeningScene {
    text: "你好,世界!"
}

就这么简单!一个节点需要:

  • node 关键字(也可以简写成 nd
  • 一个名字(这里是 OpeningScene
  • 大括号 {} 里面的内容

节点命名规范

⚠️ 重要:推荐使用大驼峰命名法(PascalCase)

✅ 推荐的命名方式

node OpeningScene { }       // 大驼峰:每个单词首字母大写
node ForestEntrance { }     // 清晰易读
node BossDialogue { }       // 见名知意
node Chapter1Start { }      // 可以包含数字

⚠️ 不推荐的命名方式

node 开场 { }              // 不建议使用非 ASCII 文本
node opening_scene { }    // 不要用蛇形命名(这是函数的风格)
node openingscene { }     // 全小写不易阅读
node opening-scene { }    // 不建议使用串型命名
node 1stScene { }         // 不要以数字开头

我们建议使用以下命名规范

  • 使用英文单词组合
  • 每个单词首字母大写
  • 名字要有意义,能够描述节点的用途
  • 避免使用特殊字符和非ASCII字符
  • 保持项目内命名风格一致

这么做的原因也很简单

  • 方便大家一起维护代码:用统一的命名方式,团队成员更容易理解每个节点是做什么的
  • 减少电脑和软件之间的问题:有些特殊字符或中文可能在不同操作系统、编辑器里显示不一样,统一用英文单词可以避免这些麻烦
  • 符合常见的编程习惯:大部分编程语言和开源项目都是用这种命名方式,学习和交流更顺畅
  • 方便在代码里查找和跳转:规范的名字可以让 编辑器 / IDE 更容易找到相关节点,提高工作效率

节点里能放什么?

一个节点可以包含:

1. 文本块

node Dialogue {
    text: "这是第一句话。"
    text: "这是第二句话。"
    text: "还可以有第三句。"
}

多段文本会按顺序显示。

2. 事件列表

node Dialogue {
    text: "你好呀!"
    with events: [
        0, play_sound("hi.wav")
        3, show_smile()
    ]
}

事件列表与其上方的文本相关联。

3. 选项

node 选择 {
    text: "你想去哪?"
    
    choice: [
        "森林" -> ForestScene,
        "城镇" -> TownScene
    ]
}

4. 混合使用

node 完整示例 {
    // 第一段文字 + 事件
    text: "欢迎来到魔法学院!"
    with events: [
        0, play_bgm("magic.mp3")
        7, sparkle()
    ]
    
    // 第二段文字
    text: "你准备好了吗?"
    
    // 让玩家做选择
    choice: [
        "准备好了!" -> 开始冒险,
        "再等等..." -> 等待
    ]
}

节点跳转

方式一:箭头跳转

在节点结束后用 -> 指定下一个节点:

node A {
    text: "这是节点 A"
} -> B  // 执行完 A 就跳到 B

node B {
    text: "这是节点 B"
}

方式二:通过选项跳转

node 主菜单 {
    text: "选择一个选项:"
    
    choice: [
        "选项 1" -> 节点1,
        "选项 2" -> 节点2
    ]
}

方式三:Return 结束节点

node 结束 {
    text: "再见!"
    
    choice: [
        "退出" -> return  // 结束当前节点
    ]
}

请注意:节点内 return 只结束当前节点。如果节点有箭头跳转,那么箭头跳转仍然生效!

node A {
    text: "这是节点 A"
    
    choice: [
        "结束当前节点" -> return  // 只结束当前节点
    ]
} -> B  // return 不阻止这里的跳转

node B {
    text: "这是节点 B"
}

解释:

  • return:结束当前节点的执行,不会自动跳到其他节点。
  • 节点外的 -> B:节点 A 执行完后依然会跳转到 B。

节点的执行流程

让我们看一个例子:

node Scene1 {
    text: "第一句"    // 先显示这个
    text: "第二句"    // 再显示这个
    
    choice: [        // 然后显示选项
        "A" -> Scene2,
        "B" -> Scene3
        "C" -> break
    ]
    
    text: "选择后的话" // 4. 只有选了会 break 的选项才到这里
} -> Scene4            // 5. 如果没有中断,最后跳这里

重点

  • 文本块按顺序执行
  • 遇到 choice 时,玩家需要做选择
  • 如果选项有 returnbreak,会影响后续流程。
  • 节点末尾的箭头是“默认出口“

对于 break 关键字,请见 选项:让玩家做选择

常见用法

纯文本节点(没有选项)

node Start {
    text: "故事开始于一个黑暗的夜晚..."
    text: "突然,一声巨响!"
    text: "你决定去看看。"
} -> NextScene

纯选项节点(没有文本)

node ChoiceExample {
    choice: [
        "进攻" -> Attack,
        "逃跑" -> Escape
    ]
}

分段式对话

node Dialogue {
    text: "嗨,很高兴见到你。"
    
    // 第一个选择点
    choice: [
        "你好" -> break
        "再见" -> return
    ]
    
    text: "那么..."  // 只有选了"你好"才会看到
    text: "我们聊聊吧。"
}

常见问题

Q: “NodeName“可以重复吗?

不行!每个“NodeName“必须唯一。

Q: 节点顺序重要吗?

不重要。你可以先定义节点 B,后定义节点 A,只要跳转关系对就行。

Q: 节点可以为空吗?

技术上可以,但没意义:

node 空节点 {
}  // 编译器会警告你

Q: 能从节点 A 跳回节点 A 吗?

可以!循环是允许的:

node Cycle {
    text: "要再来一次吗?"
    
    choice: [
        "再来!" -> Cycle,  // 跳回自己
        "不了" -> return
    ]
}

下一步

文本与事件:分离的艺术

这是 Mortar 最与众不同的地方:文本和事件分开写,但精确关联

为什么要分离?

想象你在写带事件的游戏对话,传统方式可能是:

"你好<sound=hi.wav>,欢迎<anim=wave>来到<color=red>这里</color>!"

问题来了:

  • 😰 写手看到一堆“标记”,难以专注于文字本身
  • 😰 程序员要解析复杂的标记,容易出错
  • 😰 事件增减参数相当麻烦

Mortar 的方式:

text: "你好,欢迎来到这里!"
with events: [
    0, play_sound("hi.wav")
    3, show_animation("wave")
    8, set_color("red")
    9, set_color("normal")
]

干净!清晰!好维护!

文本块基础

最简单的文本

node Example {
    text: "这是一段文本。"
}

多段文本

node Dialogue {
    text: "第一句话。"
    text: "第二句话。"
    text: "第三句话。"
}

它们会按顺序显示。

引号的使用

单引号和双引号都可以:

text: "双引号"
text: '单引号'

事件系统

基本语法

with events: [
    索引, 函数调用
    索引, 函数调用
]

索引,从 0 开始计数,类型为 Number,也就是支持整数和小数(浮点数)。

索引具体的使用方式取决于程序方的实现。

简单示例

以字符索引为例:

text: "你好世界!"
with events: [
    0, sound_a()  // 在"你"字
    2, sound_b()  // 在"世"字  
    4, sound_c()  // 在"!"
]

字符索引

  • “你” = 位置 0
  • “好” = 位置 1
  • “世” = 位置 2
  • “界” = 位置 3
  • “!” = 位置 4

链式调用

可以在同一个位置调用多个函数:

with events: [
    0, play_sound("boom.wav").shake_screen().flash_white()
]

或者分开写:

with events: [
    0, play_sound("boom.wav")
    0, shake_screen()
    0, flash_white()
]

两种方式效果一样。

小数索引

索引可以是小数,这在语音同步时特别有用:

text: "你好,世界!"
with events: [
    0.0, start_voice("hello.wav")   // 开始播放语音
    1.5, blink_eyes()               // 1.5秒时眨眼
    3.2, show_smile()               // 3.2秒时微笑
    5.0, stop_voice()               // 5秒时结束
]

什么时候用小数?

我们的建议是:

  • 打字机效果:用整数(一个字一个触发)
  • 语音同步:用小数(按时间轴触发)
  • 视频同步:用小数(精确到帧)

字符串插值

想在文本中插入变量或函数返回值?用 ${}

text: $"你好,{get_player_name()}!"
text: $"你有 {get_gold()} 金币。"
text: $"今天是 {get_date()}。"

注意

  • 字符串前面要加 $,来声明“可插值字符串”
  • 变量/函数放在 {}
  • 函数要提前声明

实战示例

打字机效果配音效

node 打字机 {
    text: "叮!叮!叮!"
    with events: [
        0, play_sound("ding.wav")  // 第一个"叮"
        2, play_sound("ding.wav")  // 第二个"叮"
        4, play_sound("ding.wav")  // 第三个"叮"
    ]
}

旁白配背景音乐

node 旁白 {
    text: "在一个遥远的王国..."
    with events: [
        0, fade_in_bgm("story_theme.mp3")
        0, dim_lights()
    ]
    
    text: "住着一位勇敢的骑士。"
}

语音同步动画

node Dialogue {
    text: "我要告诉你一个秘密..."
    with events: [
        0.0, play_voice("secret.wav")
        0.0, set_expression("serious")
        2.5, lean_closer()
        4.0, whisper_effect()
        6.0, set_expression("normal")
    ]
}

事件函数声明

用到的所有函数都要先声明:

// 在文件末尾声明
fn play_sound(file: String)
fn shake_screen()
fn flash_white()
fn set_expression(expr: String)
fn get_player_name() -> String
fn get_gold() -> Number

详见函数:连接游戏世界

最佳实践

✅ 好的做法

// 清晰的结构
text: "你好,世界!"
with events: [
    0, greeting_sound()
    2, sparkle_effect()
]

❌ 错误的做法

text: "你好"
text: "世界"
with events: [
    0, say_hello()  // 关联的文本不对!
]

建议

  1. 紧跟原则:events 紧跟在对应的 text 后面
  2. 适度使用:不是每句话都需要事件
  3. 有序排列:事件按位置从小到大写(虽然不强制)
  4. 合理命名:函数名要见名知意

常见问题

Q: 位置超出文本长度会怎样?

编译器会警告,但不会报错。运行时行为由你的游戏决定。

Q: 可以没有 events 吗?

当然可以!不是每段文本都需要事件。但是事件都需要依附于文本。

text: "这是纯文本。"
// 没有 events,完全没问题

Q: 多个事件在同一位置的执行顺序?

按写的顺序执行:

with events: [
    0, first()   // 先执行
    0, second()  // 再执行
    0, third()   // 最后执行
]

下一步

选项:让玩家做选择

选项是让对话变成互动的关键!玩家可以选择不同的路线,走向不同的结局。

最简单的选项

node ChoiceExample {
    text: "你想去哪?"
    
    choice: [
        "森林" -> ForestScene,
        "城镇" -> TownScene
    ]
}

node ForestScene {
    text: "你来到了森林。"
}

node TownScene {
    text: "你来到了城镇。"
}

语法很简单:

  • choice: 关键字
  • 方括号 [] 里面是选项列表
  • 每个选项:"文字" -> 目标节点

选项的各种写法

基本跳转

choice: [
    "选项A" -> NodeA,
    "选项B" -> NodeB,
    "选项C" -> NodeC
]

带条件的选项

只有满足条件才显示:

choice: [
    "正常选项" -> 节点1,
    "有钥匙才显示" when has_key() -> 节点2,
    "等级>=10才显示" when is_level_enough() -> 节点3
]

两种条件写法

// 函数式写法:when 用空格分割

"选项文字" when 条件函数() -> 目标

// 链式写法:用括号括起来

("选项文字").when(条件函数()) -> 目标

特殊行为

Return - 结束节点

choice: [
    "继续" -> 下一节点,
    "退出" -> return  // 直接结束节点
]

Break - 中断选项

choice: [
    "选项1" -> 节点1,
    "选项2" -> 节点2,
    "算了,不选了" -> break  // 跳出选项,继续当前节点
]

text: "好吧,那我们继续。"  // 选了 break 才会来这里

嵌套选项

选项里还可以有选项!

choice: [
    "吃点什么" -> [
        "苹果" -> 吃苹果,
        "面包" -> 吃面包,
        "算了不吃了" -> return
    ],
    "做点什么" -> [
        "休息" -> 休息,
        "探险" -> 探险
    ],
    "离开" -> return
]

玩家先选“吃点什么“,然后会看到第二层选项。

实战示例

简单分支

node ChatWithNPC {
    text: "一个商人向你走来。"
    text: "他说:'需要买点什么吗?'"
    
    choice: [
        "看看商品" -> EnterShop,
        "问问消息" -> AskSth,
        "礼貌拒绝" -> SayNO
    ]
}

带条件的复杂选择

node Door {
    text: "你面前有一扇神秘的门。"
    
    choice: [
        "直接推门" -> PushDoor,
        "用钥匙开门" when has_key() -> OpenDoorWithKey,
        "用魔法破解" when can_use_magic() -> OpenDoorWithMagic,
        "算了,离开吧" -> return
    ]
}

多层嵌套

node Restaurant {
    text: "欢迎光临!想吃点什么?"
    
    choice: [
        "中餐" -> [
            "炒饭" -> End1,
            "面条" -> End2,
            "返回" -> break
        ],
        "西餐" -> [
            "牛排" -> End3,
            "意面" -> End4,
            "返回" -> break
        ],
        "不吃了" -> return
    ],
    
    text: "那再考虑考虑吧~"  // 选了 break 会到这
}

break 的妙用

node 重要选择 {
    text: "这是一个重要的决定。"
    text: "你确定吗?"
    
    choice: [
        "确定!" -> 确定路线,
        "让我再想想..." -> break  // 跳出选项
    ],
    
    text: "好的,慢慢考虑。"
    
    // 可以再来一次选择
    choice: [
        "现在确定了" -> 确定路线,
        "算了,不想选了" -> return
    ]
}

Return vs Break

容易混淆,让我们明确一下:

关键字作用后续执行
return结束当前节点不会执行后面的内容
break跳出当前选项会继续执行后面的内容

Return 示例

node 测试 {
    text: "开始"
    
    choice: [
        "退出" -> return
    ]
    
    text: "这句不会执行"  // 因为上面 return 了
}

Break 示例

node 测试 {
    text: "开始"
    
    choice: [
        "中断" -> break
    ]
    
    text: "这句会执行"  // break 后继续往下
}

条件函数

所有用在 when 后面的函数都必须:

  • 返回 Bool(或 Boolean)类型
  • 提前声明
// 在文件中声明这些函数
fn has_key() -> Bool
fn can_use_magic() -> Boolean
fn is_level_enough() -> Bool

详见函数:连接游戏世界

最佳实践

✅ 好的做法

// 清晰的选项文字
choice: [
    "友好地打招呼" -> 友好,
    "保持警惕" -> 警惕,
    "转身离开" -> 离开
]
// 合理使用条件
choice: [
    "普通选项" -> 节点1,
    "特殊选项" when special_unlock() -> 节点2
]

❌ 不好的做法

// 选项文字太长
choice: [
    "我觉得我们应该先去森林看看,然后再决定接下来怎么办..." -> 节点1
]
// 所有选项都有条件(可能全部不显示!)
choice: [
    "选项1" when cond1() -> A,
    "选项2" when cond2() -> B,
    "选项3" when cond3() -> C
]

建议

  1. 至少一个无条件选项:确保玩家总有选择
  2. 选项文字简洁:一般不超过 20 个字
  3. 逻辑清晰:不要嵌套太深(建议最多 2-3 层)
  4. 提供退路:给玩家“返回“或“取消“的机会

常见问题

Q: 选项可以跳转到自己吗?

可以!这样可以创建循环:

node Cycle {
    text: "要继续吗?"
    
    choice: [
        "继续" -> Cycle,  // 跳回自己
        "停止" -> return
    ]
}

Q: 所有选项都有条件,但都不满足怎么办?

选项的条件检测本质上是 给选项打标记

简单来说:

  • 当条件满足时,选项可用标记为“可选”。
  • 当条件不满足时,选项可用标记为“不可选”。
  • 具体的显示效果(灰色、隐藏、提示文本等)由程序实现决定,而非 mortar 直接控制。

⚠️ 如果所有选项条件都不满足,建议在DSL中保留至少一个无条件选项,以避免出现“无可选项”的情况。

Q: 嵌套选项可以嵌套多深?

技术上没有限制,但建议不超过 3 层,否则大家都会迷糊。

Q: 能在选项文字中使用插值吗?

可以。任何字符串都可以使用字符串插值。

下一步

函数:连接游戏世界

函数是 Mortar 和你的游戏代码之间的桥梁。通过函数声明,你告诉 Mortar:“这些功能我的游戏会实现”。

为什么需要函数声明?

在 Mortar 脚本中,你会调用各种函数:

with events: [
    0, play_sound("boom.wav")
    2, shake_screen()
]

但这些函数在哪里?它们在你的游戏代码里!

函数声明就是一个“约定“:

  • 你告诉 Mortar:我的游戏有这些函数,它们需要什么参数,返回什么
  • Mortar 编译时检查类型,确保你用对了
  • 编译成 JSON 后,你的游戏再实现这些函数

函数命名规范

⚠️ 重要:推荐使用蛇形命名法(snake_case)

✅ 推荐的命名方式

fn play_sound(file_name: String)         // 蛇形:全小写,单词用下划线分隔
fn get_player_name() -> String           // 清晰易读
fn check_inventory_space() -> Bool       // 见名知意
fn calculate_damage(base: Number, modifier: Number) -> Number

⚠️ 不推荐的命名方式

fn playSound() { }              // 避免小驼峰命名(这是其他语言的风格)
fn PlaySound() { }              // 不要用大驼峰(这是节点的风格)
fn play-sound() { }             // 不建议使用串型明明
fn 播放声音() { }               // 不建议使用非 ASCII 文本
fn playsound() { }              // 全小写不易阅读

参数命名规范

// ✅ 好的参数命名
fn move_to(x: Number, y: Number)
fn load_scene(scene_name: String, fade_time: Number)

// ❌ 不好的参数命名
fn move_to(a: Number, b: Number)        // 没有语义
fn load_scene(s: String, t: Number)        // 缩写不清晰

命名建议

  • 使用英文单词,全小写
  • 多个单词用下划线 _ 分隔
  • 动词开头,描述函数功能:get_, set_, check_, play_, show_
  • 参数名要有描述性
  • 保持项目内命名风格一致

基本语法

fn function_name(param: Type) -> ReturnType

无参数无返回值

fn shake_screen()
fn clear_text()
fn show_menu()

有参数无返回值

fn play_sound(file: String)
fn set_color(color: String)
fn move_character(x: Number, y: Number)

有返回值

fn get_player_name() -> String
fn get_gold() -> Number
fn has_key() -> Bool

有参数有返回值

fn calculate(a: Number, b: Number) -> Number
fn find_item(name: String) -> Bool

支持的类型

Mortar 支持 json 中的类型:

类型别名说明示例
String-字符串"你好", "file.wav"
Number-数字(整数或小数)42, 3.14
BoolBoolean布尔值true, false

注意BoolBoolean 是一样的,随便用哪个。

完整示例

// 一个完整的 Mortar 文件

node StartScene {
    text: $"欢迎,{get_player_name()}!"
    with events: [
        0, play_bgm("theme.mp3")
    ]
    
    text: $"你有 {get_gold()} 金币。"
    
    choice: [
        "去商店" when can_shop() -> 商店,
        "去冒险" -> 冒险
    ]
}

node 商店 {
    text: "欢迎来到商店!"
}

node 冒险 {
    text: "冒险开始!"
    with events: [
        0, start_battle("哥布林")
    ]
}

// ===== 函数声明区 =====

// 播放背景音乐
fn play_bgm(music: String)

// 获取玩家名字
fn get_player_name() -> String

// 获取金币数量
fn get_gold() -> Number

// 检查是否能购物
fn can_shop() -> Bool

// 开始战斗
fn start_battle(enemy: String)

在事件中使用

调用无参数函数

with events: [
    0, shake_screen()
    2, flash_white()
]

fn shake_screen()
fn flash_white()

调用有参数函数

with events: [
    0, play_sound("boom.wav")
    2, set_color("#FF0000")
    4, move_to(100, 200)
]

fn play_sound(file: String)
fn set_color(hex: String)
fn move_to(x: Number, y: Number)

链式调用

with events: [
    0, play_sound("boom.wav").shake_screen().flash_white()
]

fn play_sound(file: String)
fn shake_screen()
fn flash_white()

在文本插值中使用

只有返回值的函数才能用在 ${} 中:

text: $"你好,{get_name()}!"
text: $"等级:{get_level()}"
text: $"状态:{get_status()}"

fn get_name() -> String
fn get_level() -> Number
fn get_status() -> String

注意:插值中的函数必须返回 String!

// ❌ 错误:函数无返回值
text: $"结果:{do_something()}"
fn do_something()  // 没有返回值


// ❌ 错误:返回类型不是 String
text: $"结果:{get_hp()}"
fn get_hp() -> Number  // 返回类型错误

// ✅ 正确
text: $"结果:{get_result()}"
fn get_result() -> String

在条件中使用

when 后面的函数必须返回 Bool / Boolean

choice: [
    "特殊选项" when is_unlocked() -> 特殊节点
]

fn is_unlocked() -> Bool

函数声明的位置

习惯上,把所有函数声明放在文件末尾:

// 节点定义
node A { ... }
node B { ... }
node C { ... }

// ===== 函数声明 =====
fn func1()
fn func2()
fn func3()

但其实位置不重要,你可以放在任何地方。

静态类型检查

Mortar 会在编译时检查类型是否正确:

// ✅ 正确
with events: [
    0, play_sound("file.wav")
]
fn play_sound(file: String)

// ❌ 错误:参数类型不对
with events: [
    0, play_sound(123)  // 传了数字,但需要字符串
]
fn play_sound(file: String)

这能帮你提前发现错误!

实现函数(游戏端)

Mortar 只负责声明,真正的实现在你的游戏代码里。

编译后的 JSON 会包含函数信息:

{
  "functions": [
    {
      "name": "play_sound",
      "params": [
        {"name": "file", "type": "String"}
      ]
    },
    {
      "name": "get_player_name",
      "return": "String"
    }
  ]
}

你的游戏读取 JSON,然后实现这些函数。

详见接入游戏

最佳实践

✅ 好的做法

// 清晰的命名
fn play_background_music(file: String)
fn get_player_health() -> Number
fn is_quest_completed(quest_id: Number) -> Bool
// 合理的参数
fn spawn_enemy(name: String, x: Number, y: Number)
fn set_weather(type: String, intensity: Number)

❌ 不好的做法

// 命名不清晰
fn psm(f: String)  // 什么意思?
fn x() -> Number   // x 是什么?
// 参数太多
fn do_complex_thing(a: Number, b: Number, c: String, d: Bool, e: Number, f: String)

建议

  1. 见名知意:函数名应该说明它做什么
  2. 参数适度:一般不超过 7 个参数
  3. 类型明确:所有参数和返回值都要注明类型
  4. 分类整理:相关的函数放在一起,加注释说明

常见问题

Q: 必须声明所有用到的函数吗?

是的!没声明就用会报错。

Q: fn 可以写成 function 吗?

可以!两者完全一样:

fn play_sound(file: String)
function play_sound(file: String)  // 一样的

Q: 能声明但不使用吗?

可以。声明了但没用到的函数,编译器会警告,但不会报错。

Q: 函数可以重载吗?

不可以。每个函数名只能声明一次。

// ❌ 错误:重复声明
fn test(a: String)
fn test(a: Number, b: Number)

Q: 参数可以有默认值吗?

目前不支持。所有参数都是必需的。

下一步

变量、常量与初始状态

Mortar v0.4 引入了脚本全局状态,让对话可以感知游戏进度。所有声明必须写在脚本的顶层(不在 nodefn 中),这样编译器才能在 .mortaredvariables 区域里完整记录它们。

定义变量

使用 let,依次写名称、类型以及可选的初始值。目前支持 StringNumberBool 三种基础类型:

let player_name: String
let player_score: Number = 0
let is_live: Bool = true

规则提示:

  • 没有 null,不赋值的话会提供默认值(空字符串、0、false)。
  • 重新赋值发生在节点内部,例如 player_score = player_score + 10
  • 顶层声明有助于游戏端直接用哈希表/字典来同步所有变量。

公共常量(Key-Value 文本)

通过 pub const 定义 UI 文案、配置或跨语言共享的键值对:

pub const welcome_message: String = "欢迎来到冒险!"
pub const continue_label: String = "继续"
pub const exit_label: String = "退出"

这些常量在 JSON 中会被标记为 public,方便本地化流水线或脚本系统读取。

在节点中使用

可以在节点里更新变量并引用它们:

node AwardPoints {
    player_score = player_score + 5
    text: $"当前分数:{player_score}"
}

序列化后,这些赋值会记录在 pre_statements 或文本内容中,确保执行顺序与脚本一致。结合 枚举分支插值 可以实现更复杂的状态展示,同时保持 Mortar 的声明式特性。

分支插值

Mortar 引入了 Fluent 风格的“非对称本地化”,通过 branch 变量 来管理状态化的文本片段。完整示例可参考 examples/branch_interpolation.mortar

定义 branch 变量

branch 变量和普通 let 一样写在顶层,可由布尔值或枚举驱动:

let is_forest: Bool = true
let is_city: Bool
let current_location: Location

enum Location {
    forest
    city
    town
}

let place: branch [
    is_forest, "森林"
    is_city, "城市"
]

let location: branch<current_location> [
    forest, "森林深处"
    city, "繁华都市"
    town, "宁静小镇"
]

当然,你也可以在节点内部写一次性的 branch 块,但集中定义能让翻译与 QA 更好维护。

节点中的用法

直接在插值字符串中引用 branch 变量:

node LocationDesc {
    text: $"欢迎来到{place}!你现在处于{location}。"
}

序列化后,每个 branch 变量都会成为节点 JSON 中的 branches 条目,运行时代码按布尔或枚举值挑选对应文本。

分支事件

branch case 拥有自己的 events 列表。外层文本的 with events 只针对实际字符,而 branch 的事件索引会在占位符内部从 0 开始计数,这与 examples/branch_interpolation.mortar 的行为一致。

text: $"你看向{object},不禁倒吸一口气!"
with events: [
    1, set_color("#33CCFF")
    6, set_color("#FF6B6B")
]

let object: branch<current_location> [
    forest, "古树", events: [
        0, set_color("#228B22")
    ]
    city, "天际线", events: [
        0, set_color("#A9A9A9")
        1, set_color("#696969")
    ]
]

这样每个分支就能携带独立的演出需求,而无需复制整段节点内容。

与游戏逻辑协作

branch 适合用于:

  • 布尔条件(如 placegreet)切换称谓。
  • 枚举条件(如 locationobject)切换整段描述。
  • if/else 或赋值后立即反映状态变化。

结合 变量控制流,即可在保持脚本整洁的同时呈现丰富的本地化和剧情分支。

本地化策略

Mortar v0.4 建议为每种语言维护独立的 Mortar 脚本,而不是把所有翻译混在同一个文件里。本章节总结了一套易于协作的流程。

每种语言独立目录

推荐的仓库结构:

mortar/
├─ locales/
│  ├─ en/
│  │  └─ story.mortar
│  ├─ zh-Hans/
│  │  └─ story.mortar
│  └─ ja/
│     └─ story.mortar

每个语言文件夹保持相同的节点名称,这样运行时只需根据语言代码选择对应的 .mortared 构建即可。

共享逻辑与常量

通过 pub const 与函数声明共享 UI 文案与逻辑钩子:

pub const continue_label: String = "继续"
fn play_sound(file: String)

翻译人员只需复制声明并修改具体文本。由于常量会写入顶层 JSON,本地化工具也能快速检测缺失项。

文档与脚本的多语言

仓库已经在 book/enbook/zh-Hans 维护对应的 mdBook。编写游戏脚本时同样遵循这个约定,为贡献者提供清晰的落点。

发布流程

  1. 更新源语言(通常是 locales/en)。
  2. 将节点及结构变更同步到其他语言。
  3. 分别运行 cargo run -p mortar_cli -- locales/<lang>/story.mortar --pretty
  4. 将生成的 .mortared 文件与游戏一起发布。

Mortar 天生将文本与逻辑分离,所以本地化过程中无需复制事件或条件,只需维护不同语言的文本内容即可。如需在同一语言中处理性别或地区差异,可结合 分支插值 提供更细致的体验。

节点中的控制流

Mortar 现在支持在节点内部使用 if/else,用于根据变量、函数或枚举判断来显示不同文本。功能刻意保持轻量——暂不包含循环——以保证脚本可读性。

基本语法

let player_score: Number = 123

node ExampleNode {
    if player_score > 100 {
        text: "满分!"
    } else {
        text: "你还得加把劲。"
    }
}

每个分支都可以包含任意节点语句:文本、赋值、选项甚至嵌套 if。序列化后会被折叠成带 condition 字段的 content 项,游戏端只需解析即可。

支持的表达式

可以比较数字、判断布尔值,也可以调用无参数函数:

if has_map() && current_region == forest {
    text: "你摊开地图,研究下一步的路线。"
}

表达式会被解析成 AST(双目运算、单目运算、标识符或字面量)。当条件依赖游戏端数据时,可通过 fn has_map() -> Bool 由运行时代码来提供结果。

最佳实践

  • 让分支保持短小。若需要完全不同的对话流程,建议跳转到其它节点。
  • 在判断前先更新相关变量,确保条件基于最新状态。
  • 变量系统枚举 搭配使用,可构建稳健的状态机。

后续版本计划引入 while 等语句,但当前的 if/else 已足以覆盖大多数动态文本场景,并保持 Mortar 的简洁特性。

事件系统与时间线

事件一直是 Mortar 的核心。v0.4 将它们扩展为可复用的命名实体,并通过时间线进行编排。以下内容与 examples/performance_system.mortar 保持一致,建议配套阅读。

声明事件

顶层 event 用于描述可复用动作:

event SetColor {
    index: 0
    action: set_color("#228B22")
}

event MoveRight {
    action: set_animation("right")
    duration: 2.0
}

event MoveLeft {
    action: set_animation("left")
    duration: 1.5
}

event PlaySound {
    index: 20
    action: play_sound("dramatic-hit.wav")
}

每个事件可以设置默认 index、动作以及可选的 duration,并会写入 JSON 顶层的 events

在节点中运行事件

常见的两种方式:

  1. run EventName:立即执行事件,忽略默认 index(但会尊重 duration)。
  2. with EventNamewith events: [ ... ]:把事件绑定到上一段文本,索引基于文本字符。
node WithEventsExample {
    text: "欢迎来到冒险!"
    with SetColor

    text: "森林被声音与色彩唤醒。"
    with events: [
        MoveRight
        PlaySound
    ]
}

序列化后,这些绑定都会出现在对应文本的 events 数组里。

自定义索引

通过 run EventName with <数值或变量> 可以临时覆盖事件的触发位置;在 with 前加 run 则表示“先运行,再把结果附着到上一段文本”:

let custom_time: Number = 5

node CustomIndexExample {
    text: "安静……直到爆炸声响起!"
    run PlaySound with custom_time

    custom_time = 28
    with run PlaySound with custom_time
}

这些语句在 .mortared 中会生成带 index_overrideContentItem::RunEvent

时间线(Timeline)

时间线由 runwaitnow run 组成:

timeline OpeningCutscene {
    run MoveRight
    wait 1.0
    run MoveLeft
    wait 0.5
    now run PlaySound   // 忽略事件自身的 duration
    wait 10
    run SetColor
}

节点中直接 run OpeningCutscene 即可播放整条时间线,非常适合开场动画或复杂演出。

实用建议

  • 把频繁使用的演出封装成事件,减少重复。
  • 需要和文本对齐时使用 with,需要即时动作时使用 run
  • now run 可以跳过事件的 duration,便于在时间线里快速切换。
  • 通过覆盖 index,让同一个事件在不同上下文拥有不同节奏。

v0.4 JSON 同时输出 eventstimelines,方便工具链和游戏引擎进行可视化或复用。

枚举与结构化状态

枚举是变量系统的重要补充,用来表示一组封闭的状态(章节、好感度、天气等)。它与 分支插值if 搭配时尤其强大。

声明枚举

在顶层使用 enum

enum GameState {
    start
    playing
    game_over
}

所有枚举值都会出现在 .mortaredenums 数组里,引擎可以直接校验或生成对应的原生枚举。

定义枚举变量

let current_state: GameState

节点内部可随时赋值:

node StateMachine {
    if boss_defeated() {
        current_state = game_over
    }
}

结合分支

利用 branch<current_state> 可以把枚举直接映射成文本:

let status: branch<current_state> [
    start, "刚刚启程"
    playing, "冒险途中"
    game_over, "剧情完结"
]

node Status {
    text: $"游戏状态是 {status}。"
}

引擎对接

  1. 加载 enums 后建立注册表,或生成对应的原生枚举。
  2. 将 Mortar 变量(如 current_state)与游戏内状态同步。
  3. 借助赋值或函数调用,把结果反馈回脚本。

这样既能保持设计意图清晰,又能确保状态流转始终合法。

完整示例与讲解

学了那么多,现在让我们看看真实的例子!

这一章节包含三个逐步进阶的示例:

📝 写一段简单对话

最简单的入门例子,适合:

  • 刚开始学 Mortar
  • 想快速看到效果
  • 做一个简单的 NPC 对话

你会学到

  • 基本的节点和文本
  • 简单的事件绑定
  • 基础选项跳转

📖 制作互动故事

一个完整的互动小故事,适合:

  • 想做分支剧情
  • 需要多层选择
  • 制作文字冒险游戏

你会学到

  • 复杂的选择结构
  • 条件判断
  • 多结局设计
  • 字符串插值

🎮 接入你的游戏

实际集成到游戏引擎的完整流程,适合:

  • 准备把 Mortar 用在项目里
  • 需要理解 JSON 输出
  • 想实现自己的解析器

你会学到

  • 编译流程
  • JSON 结构解析
  • 函数调用实现
  • 运行时执行逻辑

建议按顺序阅读,每个示例都比前一个更复杂一些。

准备好了?从写一段简单对话开始吧!

写一段简单对话

让我们从最简单的场景开始:一个 NPC 和玩家的短暂对话。

场景设定

想象你在做一个 RPG 游戏,有个村民 NPC 会跟玩家打招呼,然后问玩家要不要帮忙。

第一版:纯文本对话

最简单的版本,先把对话写出来:

// 村民的问候
node VillagerGreeting {
    text: "你好呀,冒险者!"
    text: "欢迎来到我们的小村庄。"
    text: "需要我帮忙吗?"
    
    choice: [
        "需要帮助" -> OfferHelp,
        "不用了,谢谢" -> PoliteFarewell
    ]
}

node OfferHelp {
    text: "太好了!让我看看能帮你什么..."
    text: "这是一份地图,希望对你有用!"
}

node PoliteFarewell {
    text: "好的,祝你旅途愉快!"
}

运行效果

  1. 显示三段文字
  2. 玩家选择
  3. 根据选择跳转到不同节点

第二版:添加音效

现在让对话更生动,加入音效:

node VillagerGreeting {
    text: "你好呀,冒险者!"
    with events: [
        // 在"你"字出现时播放问候音效
        0, play_sound("greeting.wav")
    ]
    
    text: "欢迎来到我们的小村庄。"
    with events: [
        // 在"小村庄"这几个字时播放温馨音乐
        7, play_music("village_theme.ogg")
    ]
    
    text: "需要我帮忙吗?"
    
    choice: [
        "需要帮助" -> OfferHelp,
        "不用了,谢谢" -> PoliteFarewell
    ]
}

node OfferHelp {
    text: "太好了!让我看看能帮你什么..."
    
    text: "这是一份地图,希望对你有用!"
    with events: [
        // 获得道具时的音效
        0, play_sound("item_get.wav"),
        // 同时显示道具图标
        0, show_item_icon("map")
    ]
}

node PoliteFarewell {
    text: "好的,祝你旅途愉快!"
    with events: [
        0, play_sound("farewell.wav")
    ]
}

// 函数声明
fn play_sound(file_name: String)
fn play_music(filename: String)
fn show_item_icon(item_name: String)

新增内容

  • 每段对话都配上了合适的音效
  • 获得道具时有特殊效果
  • 所有用到的函数都声明了

第三版:动态内容

让对话更个性化,根据玩家名字来问候:

node VillagerGreeting {
    // 使用字符串插值,动态插入玩家名字
    text: $"你好呀,{get_player_name()}!"
    with events: [
        0, play_sound("greeting.wav")
    ]
    
    text: "欢迎来到我们的小村庄。"
    with events: [
        7, play_music("village_theme.ogg")
    ]
    
    text: "需要我帮忙吗?"
    
    choice: [
        "需要帮助" -> OfferHelp,
        "不用了,谢谢" -> PoliteFarewell
    ]
}

node OfferHelp {
    text: "太好了!让我看看能帮你什么..."
    
    text: "这是一份地图,希望对你有用!"
    with events: [
        0, play_sound("item_get.wav"),
        0, show_item_icon("map")
    ]
    
    text: $"祝你好运,{get_player_name()}!"
}

node PoliteFarewell {
    text: "好的,祝你旅途愉快!"
    with events: [
        0, play_sound("farewell.wav")
    ]
}

// 函数声明
fn play_sound(file_name: String)
fn play_music(filename: String)
fn show_item_icon(item_name: String)
fn get_player_name() -> String  // 返回玩家名字

新增内容

  • 使用 $"..." 语法的字符串插值
  • {get_player_name()} 会被替换成实际的玩家名字
  • 更有亲切感

第四版:条件选项

有些玩家可能已经有地图了,我们加个条件判断:

node VillagerGreeting {
    text: $"你好呀,{get_player_name()}!"
    with events: [
        0, play_sound("greeting.wav")
    ]
    
    text: "欢迎来到我们的小村庄。"
    with events: [
        7, play_music("village_theme.ogg")
    ]
    
    text: "需要我帮忙吗?"
    
    choice: [
        // 只有没有地图时才显示这个选项
        "需要帮助" when need_map() -> OfferHelp,
        
        // 已有地图的玩家看到这个
        "我已经有地图了" when has_map() -> AlreadyHasMap,
        
        // 这个选项总是显示
        "不用了,谢谢" -> PoliteFarewell
    ]
}

node OfferHelp {
    text: "太好了!让我看看能帮你什么..."
    text: "这是一份地图,希望对你有用!"
    with events: [
        0, play_sound("item_get.wav"),
        0, show_item_icon("map")
    ]
    text: $"祝你好运,{get_player_name()}!"
}

node AlreadyHasMap {
    text: "哦,看来你准备得很充分!"
    text: "那就祝你一路平安吧!"
}

node PoliteFarewell {
    text: "好的,祝你旅途愉快!"
    with events: [
        0, play_sound("farewell.wav")
    ]
}

// 函数声明
fn play_sound(file_name: String)
fn play_music(filename: String)
fn show_item_icon(item_name: String)
fn get_player_name() -> String
fn need_map() -> Bool      // 判断是否需要地图
fn has_map() -> Bool       // 判断是否已有地图

新增内容

  • 选项带上了 when 条件
  • 根据玩家状态显示不同选项
  • 更符合真实游戏逻辑

编译和使用

保存为 village_npc.mortar,然后编译:

# 编译成JSON
mortar village_npc.mortar

# 如果想看格式化的JSON
mortar village_npc.mortar --pretty

# 指定输出文件名
mortar village_npc.mortar -o npc_dialogue.json

在游戏中实现

你的游戏需要:

  1. 读取 JSON:解析编译后的 JSON 文件

  2. 实现函数:实现所有声明的函数

    // 例如在 Unity C# 中
    void play_sound(string filename) {
        AudioSource.PlayOneShot(Resources.Load<AudioClip>(filename));
    }
    
    string get_player_name() {
        return PlayerData.name;
    }
    
    bool has_map() {
        return PlayerInventory.HasItem("map");
    }
    
  3. 执行对话:按照 JSON 的指示显示文本、触发事件、处理选择

小结

这个例子展示了:

  • ✅ 基本的节点和文本
  • ✅ 事件绑定
  • ✅ 选项跳转
  • ✅ 字符串插值
  • ✅ 条件判断
  • ✅ 函数声明

从这个简单的例子出发,你可以创建更复杂的对话系统!

接下来

制作互动故事

现在让我们创建一个完整的互动小故事,包含多个分支和结局。

故事大纲

《神秘的森林》 - 一个探险者的故事:

  • 玩家在森林里遇到神秘的魔法泉水
  • 根据选择,会有不同的结局
  • 有条件判断和多层嵌套选择

完整代码

// ========== 开场 ==========
node OpeningScene {
    text: "夜幕降临,你独自走在幽暗的森林中。"
    with events: [
        0, play_ambient("forest_night.ogg"),
        3, fade_in_music()
    ]
    
    text: "突然,前方闪烁着奇异的蓝光。"
    with events: [
        3, flash_effect("#0088FF")
    ]
    
    text: "你走近一看,是一池闪闪发光的泉水..."
    
    choice: [
        "谨慎地观察" -> ObserveSpring,
        "直接喝一口" -> DirectDrink,
        "离开这里" -> ChooseLeave
    ]
}

// ========== 观察分支 ==========
node ObserveSpring {
    text: "你蹲下身,仔细观察这池泉水。"
    text: "水面上浮现出古老的文字..."
    with events: [
        0, show_text_effect("ancient_runes")
    ]
    
    text: "文字说:'饮此圣泉者,将获得真知与力量。'"
    
    choice: [
        "那我就喝吧" -> CautiousDrink,
        "感觉有点可怕,还是走吧" -> ChooseLeave,
        
        // 带装备的玩家有特殊选项
        "用魔法瓶收集泉水" when has_magic_bottle() -> CollectWater
    ]
}

node CautiousDrink {
    text: "你小心翼翼地捧起一点泉水,轻轻啜了一口。"
    with events: [
        7, play_sound("drink_water.wav")
    ]
    
    text: "一股清凉的能量涌入体内!"
    with events: [
        0, screen_flash("#00FFFF"),
        0, play_sound("power_up.wav")
    ]
    
    text: $"你感觉到力量在增长... 力量值提升了 {get_power_bonus()} 点!"
    
} -> GoodEndingPower

node CollectWater {
    text: "你拿出珍贵的魔法瓶,小心地收集了泉水。"
    with events: [
        0, play_sound("bottle_fill.wav"),
        10, show_item_obtained("holy_water")
    ]
    
    text: "这可是无价之宝,关键时刻能救命!"
    
} -> GoodEndingWisdom

// ========== 直接饮用分支 ==========
node DirectDrink {
    text: "不管三七二十一,你直接痛饮了一大口!"
    with events: [
        12, play_sound("gulp.wav")
    ]
    
    text: "咕咚咕咚——"
    
    // 检查玩家是否有足够的抗性
    choice: [
        // 有抗性:没事
        "(继续)" when has_magic_resistance() -> DirectDrinkSuccess,
        
        // 没有抗性:糟糕
        "(继续)" -> DirectDrinkFail
    ]
}

node DirectDrinkSuccess {
    text: "多亏你强大的魔法抗性,泉水的力量被完美吸收了!"
    with events: [
        0, play_sound("success.wav")
    ]
    
    text: "你感到前所未有的强大!"
    
} -> GoodEndingPower

node DirectDrinkFail {
    text: "糟糕!魔力太强了,你的身体承受不住!"
    with events: [
        0, screen_shake(),
        0, play_sound("magic_overload.wav")
    ]
    
    text: "你眼前一黑,倒在了地上..."
    
} -> BadEndingUnconscious

// ========== 离开分支 ==========
node ChooseLeave {
    text: "你决定还是保持谨慎,离开这个神秘的地方。"
    
    text: "走了几步,你回头看了一眼..."
    
    text: "那池泉水的光芒渐渐暗淡,仿佛在说:'机会已失。'"
    with events: [
        18, fade_out_effect()
    ]
    
} -> NormalEndingCautious

// ========== 结局节点 ==========
node GoodEndingPower {
    text: "=== 结局:力量觉醒 ==="
    with events: [
        0, play_music("victory_theme.ogg")
    ]
    
    text: "你获得了泉水的祝福,成为了一名强大的战士!"
    text: $"最终力量:{get_final_power()}"
    text: "从此在冒险的道路上所向披靡。"
    
    text: "【游戏结束】"
}

node GoodEndingWisdom {
    text: "=== 结局:智者之路 ==="
    with events: [
        0, play_music("wisdom_theme.ogg")
    ]
    
    text: "你展现了真正的智慧,知道如何利用宝物。"
    text: "圣泉之水成为了你最珍贵的收藏。"
    text: "在后来的冒险中,这瓶水救了你无数次。"
    
    text: "【游戏结束】"
}

node BadEndingUnconscious {
    text: "=== 结局:贪婪的代价 ==="
    with events: [
        0, play_music("bad_ending.ogg"),
        0, screen_fade_black()
    ]
    
    text: "当你醒来时,已经是第二天早上。"
    text: "泉水消失了,你的力量也消失了。"
    text: "你后悔没有更加谨慎..."
    
    text: "【游戏结束】"
}

node NormalEndingCautious {
    text: "=== 结局:平凡之路 ==="
    with events: [
        0, play_music("normal_ending.ogg")
    ]
    
    text: "你选择了安全,放弃了冒险。"
    text: "虽然没有获得力量,但也没有遭遇危险。"
    text: "平平淡淡,也是一种生活方式。"
    
    text: "【游戏结束】"
}

// ========== 函数声明 ==========
// 音效与视效
fn play_ambient(filename: String)
fn play_sound(file_name: String)
fn play_music(filename: String)
fn fade_in_music()
fn fade_out_effect()
fn screen_fade_black()

// 特效
fn flash_effect(color: String)
fn screen_flash(color: String)
fn screen_shake()
fn show_text_effect(effect_name: String)
fn show_item_obtained(item_name: String)

// 条件判断
fn has_magic_bottle() -> Bool
fn has_magic_resistance() -> Bool

// 数值获取
fn get_power_bonus() -> Number
fn get_final_power() -> Number

故事结构图

                    开场
                     │
         ┌───────────┼───────────┐
         │           │           │
      观察泉水    直接饮用    选择离开
         │           │           │
    ┌────┼────┐      │      普通结局_谨慎
    │    │    │      │
 谨慎  收集  离开  检查抗性
 饮用  泉水       /     \
    │    │      成功    失败
    │    │       │       │
    │    │    好结局   坏结局
    └────┴───力量    _昏迷
         │
      好结局
      _智慧

关键技巧解析

1. 多层选择

通过条件判断实现不同玩家看到不同选项:

choice: [
    "普通选项" -> 普通节点,
    "特殊选项" when has_special_item() -> 特殊节点
]

2. 隐藏分支

直接饮用 节点的处理很巧妙:

choice: [
    // 两个选项显示文字相同
    "(继续)" when has_magic_resistance() -> 成功,
    "(继续)" -> 失败
]

玩家看不出区别,但结果不同——这就是隐藏分支!

3. 字符串插值的妙用

动态显示数值:

text: $"力量值提升了 {get_power_bonus()} 点!"
text: $"最终力量:{get_final_power()}"

4. 事件的同步

在同一位置触发多个事件:

with events: [
    0, screen_flash("#00FFFF"),
    0, play_sound("power_up.wav")  // 同时触发
]

5. 章节式组织

用注释分隔不同部分:

// ========== 开场 ==========

// ========== 观察分支 ==========

// ========== 结局节点 ==========

让代码更易读!

编译和测试

# 编译
mortar forest_story.mortar -o story.json --pretty

# 检查生成的 JSON 结构
cat story.json

游戏实现要点

在游戏中需要实现:

1. 条件判断函数

bool has_magic_bottle() {
    return Inventory.HasItem("magic_bottle");
}

bool has_magic_resistance() {
    return Player.Stats.MagicResistance >= 50;
}

2. 数值计算函数

float get_power_bonus() {
    return Player.Level * 10 + 50;
}

float get_final_power() {
    return Player.Stats.Power;
}

3. 音效和特效

void play_sound(string filename) {
    AudioManager.Play(filename);
}

void screen_flash(string color) {
    ScreenEffects.Flash(ColorUtility.TryParseHtmlString(color, out Color c) ? c : Color.white);
}

扩展建议

你可以在此基础上:

  1. 增加更多分支

    • 添加“用手触摸泉水“的选项
    • 加入“向泉水许愿“的神秘分支
  2. 加入状态记录

    • 记录玩家的选择
    • 在结局中展示玩家的决策路径
  3. 多结局变体

    • 根据之前的游戏进度解锁隐藏结局
    • 加入“真结局“需要满足特定条件
  4. 配合游戏系统

    • 结局影响后续剧情
    • 给予不同的奖励

小结

这个例子展示了:

  • ✅ 多分支剧情设计
  • ✅ 条件判断的灵活运用
  • ✅ 隐藏选项和分支
  • ✅ 字符串插值
  • ✅ 多个结局的实现
  • ✅ 音效与特效的配合

这就是 Mortar 的真正威力——让复杂的分支剧情变得清晰易管理!

接下来

接入你的游戏 (WIP)

⚠️ 本章节的内容将会重构。内容仅供有限参考。

Mortar 在未来会提供官方的 Bevy 与 Unity 绑定。

这一章会手把手教你如何把 Mortar 真正用起来——从编译到在游戏中运行。

完整流程概览

1. 编写 Mortar 脚本 (.mortar)
         ↓
2. 使用编译器生成 JSON
         ↓
3. 游戏加载 JSON 文件
         ↓
4. 解析 JSON 数据结构
         ↓
5. 实现函数调用接口
         ↓
6. 编写对话执行引擎
         ↓
7. 运行对话并响应事件

示例:一个简单的对话

先从最简单的例子开始。

步骤1:编写 Mortar 文件

创建 simple.mortar

node StartScene {
    text: "你好!"
    with events: [
        0, play_sound("hi.wav")
    ]
    
    text: $"你是{get_name()}吗?"
    
    choice: [
        "是的" -> Confirm,
        "不是" -> Deny
    ]
}

node Confirm {
    text: "很高兴见到你!"
}

node Deny {
    text: "哦,抱歉认错人了。"
}

fn play_sound(file: String)
fn get_name() -> String

步骤2:编译成 JSON

mortar simple.mortar -o simple.json --pretty

生成的 simple.json 大致长这样:

{
  "nodes": {
    "开始": {
      "texts": [
        {
          "content": "你好!",
          "events": [
            {
              "index": 0,
              "function": "play_sound",
              "args": ["hi.wav"]
            }
          ]
        },
        {
          "content": "你是{get_name()}吗?",
          "interpolated": true,
          "events": []
        }
      ],
      "choices": [
        {
          "text": "是的",
          "target": "确认"
        },
        {
          "text": "不是",
          "target": "否认"
        }
      ]
    },
    "确认": {
      "texts": [
        {
          "content": "很高兴见到你!",
          "events": []
        }
      ]
    },
    "否认": {
      "texts": [
        {
          "content": "哦,抱歉认错人了。",
          "events": []
        }
      ]
    }
  },
  "functions": [
    {
      "name": "play_sound",
      "params": [{"name": "file", "type": "String"}],
      "return_type": null
    },
    {
      "name": "get_name",
      "params": [],
      "return_type": "String"
    }
  ]
}

Unity C# 集成示例

第一步:创建数据结构

using System;
using System.Collections.Generic;
using UnityEngine;

[Serializable]
public class MortarDialogue {
    public Dictionary<string, NodeData> nodes;
    public List<FunctionDeclaration> functions;
}

[Serializable]
public class NodeData {
    public List<TextBlock> texts;
    public List<Choice> choices;
    public string next_node;
}

[Serializable]
public class TextBlock {
    public string content;
    public bool interpolated;
    public List<Event> events;
}

[Serializable]
public class Event {
    public float index;
    public string function;
    public List<object> args;
}

[Serializable]
public class Choice {
    public string text;
    public string target;
    public string condition;
}

[Serializable]
public class FunctionDeclaration {
    public string name;
    public List<Param> @params;
    public string return_type;
}

[Serializable]
public class Param {
    public string name;
    public string type;
}

第二步:加载 JSON

using System.IO;
using UnityEngine;

public class DialogueManager : MonoBehaviour {
    private MortarDialogue dialogue;
    
    public void LoadDialogue(string jsonPath) {
        string json = File.ReadAllText(jsonPath);
        dialogue = JsonUtility.FromJson<MortarDialogue>(json);
        Debug.Log($"加载了 {dialogue.node Dialogue节点");
    }
}

第三步:实现函数接口

using System.Collections.Generic;
using UnityEngine;

public class DialogueFunctions : MonoBehaviour {
    // 存储实际的函数实现
    private Dictionary<string, System.Delegate> functionMap;
    
    void Awake() {
        InitializeFunctions();
    }
    
    void InitializeFunctions() {
        functionMap = new Dictionary<string, System.Delegate>();
        
        // 注册所有函数
        functionMap["play_sound"] = new System.Action<string>(PlaySound);
        functionMap["get_name"] = new System.Func<string>(GetName);
        functionMap["has_item"] = new System.Func<bool>(HasItem);
    }
    
    // ===== 实际的函数实现 =====
    
    void PlaySound(string filename) {
        AudioClip clip = Resources.Load<AudioClip>($"Sounds/{filename}");
        if (clip != null) {
            AudioSource.PlayClipAtPoint(clip, Camera.main.transform.position);
        }
    }
    
    string GetName() {
        return PlayerData.Instance.playerName;
    }
    
    bool HasItem() {
        return Inventory.Instance.HasItem("magic_map");
    }
    
    // ===== 调用函数的通用方法 =====
    
    public object CallFunction(string funcName, List<object> args) {
        if (!functionMap.ContainsKey(funcName)) {
            Debug.LogError($"函数未定义: {funcName}");
            return null;
        }
        
        var func = functionMap[funcName];
        
        // 根据参数数量调用
        if (args == null || args.Count == 0) {
            if (func is System.Func<string>) {
                return ((System.Func<string>)func)();
            } else if (func is System.Func<bool>) {
                return ((System.Func<bool>)func)();
            } else if (func is System.Action) {
                ((System.Action)func)();
                return null;
            }
        } else if (args.Count == 1) {
            if (func is System.Action<string>) {
                ((System.Action<string>)func)((string)args[0]);
                return null;
            }
        }
        
        Debug.LogError($"函数调用失败: {funcName}");
        return null;
    }
}

第四步:实现对话引擎

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;

public class DialogueEngine : MonoBehaviour {
    public TextMeshProUGUI dialogueText;
    public GameObject choiceButtonPrefab;
    public Transform choiceContainer;
    
    private MortarDialogue dialogue;
    private DialogueFunctions functions;
    private string currentNode;
    private int currentTextIndex;
    
    void Start() {
        functions = GetComponent<DialogueFunctions>();
    }
    
    public void StartDialogue(MortarDialogue dlg, string startNode) {
        dialogue = dlg;
        currentNode = startNode;
        currentTextIndex = 0;
        ShowCurrentText();
    }
    
    void ShowCurrentText() {
        var node = dialogue.nodes[currentNode];
        
        if (currentTextIndex < node.texts.Count) {
            var textBlock = node.texts[currentTextIndex];
            StartCoroutine(DisplayText(textBlock));
        } else {
            ShowChoices();
        }
    }
    
    IEnumerator DisplayText(TextBlock textBlock) {
        string text = textBlock.content;
        
        // 处理字符串插值
        if (textBlock.interpolated) {
            text = ProcessInterpolation(text);
        }
        
        // 准备事件
        Dictionary<int, List<Event>> eventMap = new Dictionary<int, List<Event>>();
        foreach (var evt in textBlock.events) {
            int index = Mathf.FloorToInt(evt.index);
            if (!eventMap.ContainsKey(index)) {
                eventMap[index] = new List<Event>();
            }
            eventMap[index].Add(evt);
        }
        
        // 打字机效果
        dialogueText.text = "";
        for (int i = 0; i < text.Length; i++) {
            dialogueText.text += text[i];
            
            // 触发事件
            if (eventMap.ContainsKey(i)) {
                foreach (var evt in eventMap[i]) {
                    functions.CallFunction(evt.function, evt.args);
                }
            }
            
            yield return new WaitForSeconds(0.05f);
        }
        
        yield return new WaitForSeconds(0.5f);
        
        // 下一段文本
        currentTextIndex++;
        ShowCurrentText();
    }
    
    string ProcessInterpolation(string text) {
        // 简单的插值处理:找到 {function_name()} 并替换
        // 实际项目中需要更完善的解析
        int start = text.IndexOf('{');
        while (start >= 0) {
            int end = text.IndexOf('}', start);
            if (end < 0) break;
            
            string funcCall = text.Substring(start + 1, end - start - 1);
            // 移除 () 获取函数名
            string funcName = funcCall.Replace("()", "");
            
            object result = functions.CallFunction(funcName, null);
            text = text.Replace($"{{{funcCall}}}", result?.ToString() ?? "");
            
            start = text.IndexOf('{', start + 1);
        }
        return text;
    }
    
    void ShowChoices() {
        var node = dialogue.nodes[currentNode];
        
        if (node.choices == null || node.choices.Count == 0) {
            // 没有选项,检查是否有下一个节点
            if (!string.IsNullOrEmpty(node.next_node)) {
                currentNode = node.next_node;
                currentTextIndex = 0;
                ShowCurrentText();
            }
            return;
        }
        
        // 清空旧选项
        foreach (Transform child in choiceContainer) {
            Destroy(child.gameObject);
        }
        
        // 创建选项按钮
        foreach (var choice in node.choices) {
            // 检查条件
            if (!string.IsNullOrEmpty(choice.condition)) {
                bool conditionMet = (bool)functions.CallFunction(choice.condition, null);
                if (!conditionMet) continue;
            }
            
            GameObject btn = Instantiate(choiceButtonPrefab, choiceContainer);
            btn.GetComponentInChildren<TextMeshProUGUI>().text = choice.text;
            
            string targetNode = choice.target;
            btn.GetComponent<UnityEngine.UI.Button>().onClick.AddListener(() => {
                OnChoiceSelected(targetNode);
            });
        }
    }
    
    void OnChoiceSelected(string targetNode) {
        if (targetNode == "return") {
            // 结束对话
            gameObject.SetActive(false);
            return;
        }
        
        currentNode = targetNode;
        currentTextIndex = 0;
        ShowCurrentText();
    }
}

第五步:使用

public class GameController : MonoBehaviour {
    public DialogueManager dialogueManager;
    public DialogueEngine dialogueEngine;
    
    void Start() {
        // 加载对话文件
        dialogueManager.LoadDialogue("Assets/Dialogues/simple.json");
        
        // 开始对话
        dialogueEngine.StartDialogue(dialogueManager.GetDialogue(), "开始");
    }
}

Godot GDScript 集成示例

简化版实现

extends Node

# 对话数据
var dialogue_data = {}
var current_node = ""
var current_text_index = 0

# UI 引用
onready var dialogue_label = $DialogueLabel
onready var choice_container = $ChoiceContainer

func load_dialogue(json_path: String):
    var file = File.new()
    file.open(json_path, File.READ)
    var json = file.get_as_text()
    file.close()
    
    dialogue_data = JSON.parse(json).result
    print("加载了 %d 个节点" % dialogue_data.nodes.size())

func start_dialogue(start_node: String):
    current_node = start_node
    current_text_index = 0
    show_current_text()

func show_current_text():
    var node = dialogue_data.nodes[current_node]
    
    if current_text_index < node.texts.size():
        var text_block = node.texts[current_text_index]
        display_text(text_block)
    else:
        show_choices()

func display_text(text_block):
    var text = text_block.content
    
    # 处理插值
    if text_block.get("interpolated", false):
        text = process_interpolation(text)
    
    # 打字机效果
    dialogue_label.text = ""
    for i in range(text.length()):
        dialogue_label.text += text[i]
        
        # 触发事件
        for event in text_block.events:
            if int(event.index) == i:
                call_function(event.function, event.args)
        
        yield(get_tree().create_timer(0.05), "timeout")
    
    current_text_index += 1
    yield(get_tree().create_timer(0.5), "timeout")
    show_current_text()

func process_interpolation(text: String) -> String:
    # 简单的插值处理
    var regex = RegEx.new()
    regex.compile("\\{([^}]+)\\}")
    
    for result in regex.search_all(text):
        var func_call = result.get_string(1).replace("()", "")
        var value = call_function(func_call, [])
        text = text.replace(result.get_string(), str(value))
    
    return text

func show_choices():
    var node = dialogue_data.nodes[current_node]
    
    # 清空旧选项
    for child in choice_container.get_children():
        child.queue_free()
    
    if not node.has("choices"):
        return
    
    # 创建选项按钮
    for choice in node.choices:
        # 检查条件
        if choice.has("condition") and choice.condition != "":
            if not call_function(choice.condition, []):
                continue
        
        var button = Button.new()
        button.text = choice.text
        button.connect("pressed", self, "on_choice_selected", [choice.target])
        choice_container.add_child(button)

func on_choice_selected(target: String):
    if target == "return":
        queue_free()
        return
    
    current_node = target
    current_text_index = 0
    show_current_text()

# ===== 函数调用 =====

func call_function(func_name: String, args: Array):
    match func_name:
        "play_sound":
            return play_sound(args[0])
        "get_name":
            return get_player_name()
        "has_item":
            return check_has_item()
        _:
            print("未知函数: ", func_name)
            return null

func play_sound(filename: String):
    var audio = AudioStreamPlayer.new()
    audio.stream = load("res://sounds/" + filename)
    add_child(audio)
    audio.play()

func get_player_name() -> String:
    return PlayerData.player_name

func check_has_item() -> bool:
    return PlayerData.has_item("magic_map")

通用实现要点

无论用什么引擎,都需要实现这些核心功能:

1. JSON 解析

  • 读取文件
  • 解析成对象结构
  • 处理节点、文本、事件、选项

2. 函数映射

"play_sound" → 你的音效播放函数
"get_name" → 你的获取玩家名函数
...

3. 对话流程控制

  • 显示当前文本
  • 触发对应事件
  • 处理玩家选择
  • 跳转到下一个节点

4. 事件触发

  • 根据索引位置触发
  • 支持同时触发多个事件
  • 处理整数和小数索引

5. 条件判断

  • 检查选项的条件
  • 只显示满足条件的选项

6. 字符串插值

  • 识别 {} 标记
  • 调用对应函数
  • 替换成返回值

性能优化建议

  1. 预加载资源:对话开始前加载所有音效、图片
  2. 对象池:选项按钮使用对象池,避免频繁创建销毁
  3. 异步加载:大型对话文件使用异步加载
  4. 缓存结果:字符串插值的结果可以缓存

常见问题

Q: JSON 太大怎么办?

A: 按场景/章节拆分成多个文件,按需加载。

Q: 怎么实现存档?

A: 保存当前“NodeName“和相关变量即可恢复对话进度。

Q: 怎么支持快进?

A: 跳过打字机效果,直接显示完整文本,快速触发所有事件。

Q: 可以中途打断对话吗?

A: 可以,记录当前状态,下次从断点继续。

小结

集成 Mortar 的关键步骤:

  1. ✅ 编译 Mortar 生成 JSON
  2. ✅ 创建数据结构匹配 JSON
  3. ✅ 实现函数调用映射
  4. ✅ 编写对话执行引擎
  5. ✅ 处理事件和选择

从简单例子开始,逐步增加功能,很快就能上手!

接下来

命令行工具

Mortar 提供了一个简单易用的命令行工具,用来编译 .mortar 文件。

安装

从 crates.io 安装(推荐)

cargo install mortar_cli

从源码构建

git clone https://github.com/Bli-AIk/mortar.git
cd mortar
cargo build --release

# 编译后的可执行文件在:
# target/release/mortar (Linux/macOS)
# target/release/mortar.exe (Windows)

验证安装

mortar --version

应该显示版本号,例如:mortar 0.3.0

基本用法

最简单的编译

mortar 你的文件.mortar

这会生成一个同名的 .mortared 文件(默认是压缩的 JSON)。

例如

mortar hello.mortar
# 生成 hello.mortared

格式化输出

如果想要人类可读的格式化 JSON(带缩进和换行):

mortar hello.mortar --pretty

对比

# 压缩格式(默认)
{"nodes":{"Start":{"texts":[{"content":"Hello"}]}}}

# 格式化输出(--pretty)
{
  "nodes": {
    "Start": {
      "texts": [
        {
          "content": "Hello"
        }
      ]
    }
  }
}

指定输出文件

使用 -o--output 参数:

mortar input.mortar -o output.json

# 也可以写全:
mortar input.mortar --output output.json

组合使用

# 格式化输出到指定文件
mortar hello.mortar -o dialogue.json --pretty

# 或者这样写:
mortar hello.mortar --output dialogue.json --pretty

完整参数列表

mortar [OPTIONS] <INPUT_FILE>

必需参数

  • <INPUT_FILE> - 要编译的 .mortar 文件路径

可选参数

参数简写说明
--output <FILE>-o指定输出文件路径
--pretty-生成格式化的 JSON(带缩进)
--version-v显示版本信息
--help-h显示帮助信息

使用场景

开发阶段

开发时使用 --pretty 方便查看和调试:

mortar story.mortar --pretty

可以直接打开生成的 JSON 查看结构。

生产环境

发布游戏时使用压缩格式,减小文件体积:

mortar story.mortar -o assets/dialogues/story.json

错误处理

语法错误

如果 Mortar 文件有语法错误,编译器会清楚地指出:

Error: Unexpected token
  ┌─ hello.mortar:5:10
  │
5 │     text: Hello"
  │          ^ 缺少引号
  │

错误信息包含:

  • 错误类型
  • 文件名和位置(行号、列号)
  • 相关代码片段
  • 错误提示

未定义的节点

Error: Undefined node 'Unknown'
  ┌─ hello.mortar:10:20
  │
10 │     choice: ["去" -> Unknown]
   │                      ^^^^^^^ 这个节点不存在
   │

类型错误

Error: Type mismatch
  ┌─ hello.mortar:8:15
  │
8 │     0, play_sound(123)
  │                   ^^^ 期望 String,得到 Number
  │

文件不存在

$ mortar notfound.mortar
Error: 文件不存在: notfound.mortar

退出码

Mortar CLI 遵循标准的退出码约定:

  • 0 - 编译成功
  • 1 - 编译失败(语法错误、类型错误等)
  • 2 - 文件读取失败
  • 3 - 文件写入失败

这在 CI/CD 脚本中特别有用:

#!/bin/bash
if mortar dialogue.mortar; then
    echo "✅ 编译成功"
else
    echo "❌ 编译失败"
    exit 1
fi

常见问题

Q: 为什么默认是压缩格式?

A: 压缩格式文件更小,加载更快,适合生产环境。开发时用 --pretty 查看。

Q: 可以编译整个目录吗?

A: 目前不支持,但可以用 shell 脚本批量编译。

Q: 输出文件可以不是 JSON 吗?

A: 目前只支持 JSON 输出。JSON 是通用格式,几乎所有语言和引擎都能解析。

Q: 怎么检查语法但不生成文件?

A: 目前没有专门的检查模式,但可以输出到临时文件:

mortar test.mortar -o /tmp/test.json

小结

Mortar CLI 的关键点:

  • ✅ 简单易用,一个命令搞定
  • ✅ 清晰的错误提示
  • ✅ 支持格式化输出方便调试
  • ✅ 易于集成到开发流程
  • ✅ 快速可靠

接下来

编辑器支持

Mortar 目前主要通过 LSP 提供编辑器支持,帮助你在写对话时更高效。目前尚未提供专门的编辑器插件。

编辑器适配计划

我们的计划是:

  1. 先通过 LSP2IJ 插件适配 JetBrains 系列 IDE(如 IntelliJ IDEA、PyCharm 等)
  2. 随后适配 VS Code

未来编辑器功能预计将包括:

  • 自动更新索引
  • 可视化结构图

Language Server Protocol (LSP)

Mortar 提供了 LSP 服务器,是实现高级编辑器功能的核心工具。

安装 LSP

cargo install mortar_lsp

功能特性

1. 实时错误检查

编辑时即可发现错误,不用等到编译:

node TestNode {
    text: "你好
    // ↑ 显示红色波浪线:缺少引号
}

2. 跳转到定义

支持 Ctrl/Command + 点击节点或函数名跳转到定义:

node Start {
    choice: [
        "下一步" -> NextNode  // ← 点击跳转
    ]
}

node NextNode {  // ← 跳到这里
    text: "到了!"
}

3. 查找引用

右键节点或函数 → “查找所有引用”,列出所有调用位置。

4. 自动补全

提供关键字、已定义节点、函数名和类型名的自动提示。

5. 悬停提示

鼠标悬停在元素上显示类型或函数签名信息。

6. 代码诊断

LSP 会分析代码并给出警告或建议,例如未使用的节点或函数。

在不同编辑器中使用 LSP

  • JetBrains IDE:通过 LSP2IJ 插件适配
  • VS Code:后续将支持通过官方插件启用 LSP
  • Neovim、Emacs、Sublime Text:可使用对应 LSP 插件手动配置

推荐做法

即便没有专门插件,也可以通过 LSP 在 IDE 中获得:

  • 语法高亮(基于已有语言特性或手动配置)
  • 实时错误检查
  • 跳转定义和查找引用
  • 自动补全

未来扩展计划将进一步完善自动索引和结构可视化功能。

小结

  • Mortar 目前主要依赖 LSP 支持编辑器功能
  • JetBrains IDE 将优先获得官方适配
  • VS Code 将随后获得支持
  • 功能覆盖:错误检查、补全、跳转、引用查找
  • 计划新增:自动索引更新、结构可视化

好的工具让写对话更轻松!

接下来

JSON 输出说明

Mortar v0.4 将 .mortared 文件整理成一条有序的执行流。本章对新的结构做英文、中文并行的总结,方便引擎按顺序重放内容。

顶层结构

{
  "metadata": { "version": "0.4.0", "generated_at": "2025-01-31T12:00:00Z" },
  "variables": [ ... ],
  "constants": [ ... ],
  "enums": [ ... ],
  "nodes": [ ... ],
  "functions": [ ... ],
  "events": [ ... ],
  "timelines": [ ... ]
}
  • variables 对应脚本中的 let 声明(v0.4 中正式引入),初始值会直接写入。
  • constants 保存 pub const,并带有 public 标记,便于导出本地化文本。
  • enums 记录枚举及其分支,供 branch 插值使用。

metadata

metadata 中包含 versiongenerated_at,两者均为字符串。时间戳遵循 UTC 的 ISO 8601 格式,可用于缓存或回滚判定。

节点与线性内容

nodes 现在是 数组 而不再是字典。每个节点形如:

{
  "name": "Start",
  "content": [ ... ],
  "branches": [ ... ],
  "variables": [ ... ],
  "next": "NextNode"
}

content 数组就是对话/事件的真实顺序,已经把旧版本的 textsrunschoice_position 全部折叠到一起,引擎只需从头到尾迭代即可。

内容项(Content Item)类型

所有元素都拥有 type 字段:

  1. type: "text" — 对话行或插值文本。

    • value:可直接显示的字符串。
    • interpolated_parts:按片段描述文本/表达式/branch case,方便编辑器回放。
    • condition:当该行来自 if/else 时记录完整条件树。
    • pre_statements:在显示前需要执行的赋值语句。
    • events:与具体字符位置绑定的事件,元素格式为 { "index": 4.2, "index_variable": null, "actions": [{ "type": "set_color", "args": ["#FF6B6B"] }] }
  2. type: "run_event" — 调用顶层 events 中的命名事件。

    • name:事件名。
    • args:序列化后的参数。
    • index_override{ "type": "value" | "variable", "value": "..." },用于重写触发位置(可绑定变量)。
    • ignore_duration:为 true 时忽略事件自带的 duration,立即执行。
  3. type: "run_timeline" — 执行一条 timelines 描述的演出序列(参见计划文档第 5 节的演出系统)。

  4. type: "choice" — 在脚本写入的位置展示选项。

    • options 数组中,每个选项拥有 text、可选的 next、可选的 action"return""break")、可选的嵌套 choice、以及可选的 condition(函数名与参数)。这完全取代了旧版 choices/choice_position

Branch 定义

若节点包含 $"..."branch 插值,编译器会在节点对象中生成 branches。每个 case 自带文本与可选 events,满足 v0.4 中“分支插值拥有独立索引”的要求。

命名事件与时间线

顶层 events 记录可复用的事件:

{
  "name": "ColorYellow",
  "index": 1.0,
  "duration": 0.35,
  "action": {
    "type": "set_color",
    "args": ["#FFFF00"]
  }
}

在节点里 run_event 调用这个定义,从而保证“with events”与“单独运行”两种场景使用同一套参数。

timelines 用于复杂演出:

{
  "name": "IntroScene",
  "statements": [
    { "type": "run", "event_name": "ShowAlice" },
    { "type": "wait", "duration": 2.0 },
    { "type": "run", "event_name": "PlayMusic", "ignore_duration": true }
  ]
}

run 会触发指定事件,wait 则暂停光标,可组合出 Unity Timeline 风格的序列。

节点示例

{
  "name": "Start",
  "content": [
    {
      "type": "text",
      "value": "欢迎来到冒险!",
      "events": [
        {
          "index": 0,
          "actions": [{ "type": "play_music", "args": ["intro.mp3"] }]
        }
      ]
    },
    {
      "type": "choice",
      "options": [
        { "text": "开始", "next": "GameStart" },
        { "text": "再想想", "action": "break" }
      ]
    }
  ],
  "next": "MainMenu"
}

解析建议

建议使用强类型来建模 .mortared 文件,便于在扩展字段时快速升级。下面给出与序列化代码一致的 TypeScript/Python 草图:

type ContentItem =
  | {
      type: "text";
      value: string;
      interpolated_parts?: StringPart[];
      condition?: Condition;
      pre_statements?: Statement[];
      events?: EventTrigger[];
    }
  | {
      type: "run_event";
      name: string;
      args?: string[];
      index_override?: { type: "value" | "variable"; value: string };
      ignore_duration?: boolean;
    }
  | { type: "run_timeline"; name: string }
  | { type: "choice"; options: ChoiceOption[] };

interface MortaredFile {
  metadata: Metadata;
  variables: VariableDecl[];
  constants: ConstantDecl[];
  enums: EnumDecl[];
  nodes: Node[];
  functions: FunctionDecl[];
  events: EventDef[];
  timelines: TimelineDef[];
}
@dataclass
class EventTrigger:
    index: float
    actions: List[Action]
    index_variable: Optional[str] = None

@dataclass
class ContentText:
    type: Literal["text"]
    value: str
    interpolated_parts: Optional[List[StringPart]] = None
    condition: Optional[Condition] = None
    pre_statements: Optional[List[Statement]] = None
    events: Optional[List[EventTrigger]] = None

@dataclass
class ContentRunEvent:
    type: Literal["run_event"]
    name: str
    args: List[str] = field(default_factory=list)
    index_override: Optional[IndexOverride] = None
    ignore_duration: bool = False

@dataclass
class ContentChoice:
    type: Literal["choice"]
    options: List[ChoiceOption]

依照同样的方式定义 timelines、命名事件与选项结构,就能让运行时代码和 Mortar 编译器保持一致。

常见问题解答

在使用 Mortar 的过程中,你可能会遇到一些疑问。这里整理了最常见的问题和解答。

基础概念

Mortar 和其他对话系统有什么区别?

最大的区别是“内容与逻辑分离“

  • 传统系统"你好<sound=hi.wav>,欢迎<color=red>光临</color>!"
  • Mortar:文本是纯文本,事件单独写,通过位置关联

这样做的好处:

  • 写手可以专心写故事,不用管技术标记
  • 程序员可以灵活控制事件,不会破坏文本
  • 文本内容容易翻译和修改

为什么要用字符位置来触发事件?

字符位置让你能精确控制事件触发时机:

text: "轰隆隆!一道闪电划过天空。"
with events: [
    0, shake_screen()      // 在"轰"字时屏幕震动
    3, flash_effect()      // 在"!"时闪光效果
    4, play_thunder()      // 在"一"字时雷声
]

这对于:

  • 打字机效果(逐字显示)
  • 语音同步
  • 音效配合

都特别有用!

我可以不用事件,只写对话吗?

当然可以! 事件是可选的:

node SimpleDialogue {
    text: "你好!"
    text: "欢迎来玩!"
    
    choice: [
        "谢谢" -> Thanks,
        "拜拜" -> return
    ]
}

这样写完全合法,适合简单场景。

语法相关

分号和逗号必须写吗?

大部分情况下可以省略! Mortar 语法很宽松:

// 这三种写法都可以
text: "你好"
text: "你好",
text: "你好";

with events: [
    0, sound_a()
    1, sound_b()
]

with events: [
    0, sound_a(),
    1, sound_b(),
]

with events: [
    0, sound_a();
    1, sound_b();
]

但建议保持一致,选一种风格坚持下去。

字符串必须用双引号吗?

单引号和双引号都可以:

text: "双引号字符串"
text: '单引号字符串'

node 和 nd 有什么区别?

完全一样! nd 只是 node 的简写:

node OpeningScene { }
nd 开场 { }      // 完全相同

类似的简写还有:

  • fn = function
  • Bool = Boolean

怎么写注释?

// 写单行注释,用 /* */ 写多行注释:

// 这是单行注释

/*
这是
多行
注释
*/

node Example {
    text: "对话内容"  // 也可以写在行尾
}

节点与跳转

节点名字有什么要求?

技术上可以使用:

  • 英文字母、数字、下划线
  • 但不能以数字开头

但我们强烈推荐使用大驼峰命名法(PascalCase)

// ✅ 推荐的命名(大驼峰)
node OpeningScene { }
node ForestEntrance { }
node BossDialogue { }
node Chapter1Start { }

// ⚠️ 不推荐但能用
node opening_scene { }  // 蛇形是函数的风格
node forest_1 { }       // 可以,但不如 Forest1

// ❌ 不好的命名
node 开场 { }           // 避免使用 非 ASCII 文本
node 1node { }         // 不能以数字开头
node node-1 { }        // 不能用短横线

为什么推荐大驼峰?

  • 与主流编程语言的类型命名一致
  • 清晰易读,便于识别
  • 避免跨平台编码问题
  • 团队协作更规范

可以跳转到不存在的节点吗?

不行! 编译器会检查所有的跳转:

node A {
    choice: [
        "去B" -> B,      // ✅ B存在,可以
        "去C" -> C       // ❌ C不存在,报错
    ]
}

node B { }

怎么结束对话?

有三种方式:

  1. return - 结束当前节点(如果有后续节点,会继续)
  2. 没有后续跳转 - 对话自然结束
  3. 跳转到特殊节点 - 可以做一个专门的“结束“节点
// 方式1:使用return
node A {
    choice: [
        "结束" -> return
    ]
}

// 方式2:自然结束
node B {
    text: "再见!"
    // 没有跳转,对话结束
}

// 方式3:结束节点
node C {
    choice: [
        "结束" -> EndingNode
    ]
}

node EndingNode {
    text: "谢谢游玩!"
}

选择系统

选项可以嵌套吗?

可以! 而且可以嵌套任意层:

choice: [
    "吃什么?" -> [
        "中餐" -> [
            "米饭" -> End1,
            "面条" -> End2
        ],
        "西餐" -> [
            "牛排" -> End3,
            "意面" -> End4
        ]
    ]
]

when 条件怎么写?

有两种写法:

choice: [
    // 链式写法
    ("选项A").when(has_key) -> A,
    
    // 函数式写法
    "选项B" when has_key -> B
]

条件函数必须返回 Bool 类型:

fn has_key() -> Bool

如果所有选项的条件都不满足怎么办?

这是游戏逻辑需要处理的问题。Mortar 只负责编译,不管运行时逻辑。

建议:

  • 至少留一个没有条件的“默认选项“
  • 在游戏里检查是否有可用选项

事件系统

事件的数字可以是小数吗?

可以! 小数特别适合语音同步:

text: "这段话配了语音。"
with events: [
    0.0, start_voice()
    1.5, highlight_word()   // 1.5秒时
    3.2, another_effect()   // 3.2秒时
]

多个事件可以在同一个位置吗?

可以! 而且会按顺序执行:

with events: [
    0, effect_a()
    0, effect_b()    // 同样在位置0
    0, effect_c()    // 也在位置0
]

游戏运行时会按顺序调用这三个函数。

事件函数必须声明吗?

是的! 所有用到的函数都要声明:

node A {
    with events: [
        0, my_function()   // 使用了函数
    ]
}

// 必须声明
fn my_function()

不声明会编译报错。

函数相关

函数声明只是占位符吗?

是的! 函数的实际实现在你的游戏代码里:

// Mortar 文件里只需要声明
fn play_sound(file: String)

// 真正的实现在你的游戏代码(比如C#/C++/Rust等)
// 例如在Unity中:
// public void play_sound(string file) {
//     AudioSource.PlayClipAtPoint(file);
// }

Mortar 只负责:

  • 检查函数名是否正确
  • 检查参数类型是否匹配
  • 生成JSON让游戏知道该调用什么

支持哪些参数类型?

目前支持这些基本类型:

  • String - 字符串
  • Bool / Boolean - 布尔值(真/假)
  • Number - 数字(整数或小数)
fn example_func(
    name: String,
    age: Number,
    is_active: Bool
) -> String

函数可以没有参数吗?

可以!

fn simple_function()
fn another() -> String

函数可以有多个参数吗?

可以! 用逗号分隔:

fn complex_function(
    param1: String,
    param2: Number,
    param3: Bool
) -> Bool

函数名有什么命名规范?

强烈推荐使用蛇形命名法(snake_case)

// ✅ 推荐的命名(蛇形)
fn play_sound(file_name: String)
fn get_player_name() -> String
fn check_inventory() -> Bool
fn calculate_damage(base: Number, modifier: Number) -> Number

// ⚠️ 不推荐
fn playSound() { }          // 驼峰是其他语言的风格
fn PlaySound() { }          // 大驼峰是节点的风格
fn 播放声音() { }           // 避免使用 非 ASCII 文本

参数名也要用蛇形命名

fn load_scene(scene_name: String, fade_time: Number)  // ✅
fn load_scene(SceneName: String, fadeTime: Number)    // ❌

字符串插值

什么是字符串插值?

在字符串里嵌入变量或函数调用:

text: $"你好,{get_name()}!你有{get_score()}分。"

注意字符串前的 $ 符号!

插值必须是函数吗?

目前 Mortar 的插值主要用于函数调用。插值里的内容会被替换成函数的返回值。

不用 $ 会怎样?

没有 $ 就是普通字符串,{} 会被当作普通字符:

text: "你好,{name}!"    // 就是显示 "你好,{name}!"
text: $"你好,{name}!"   // name会被替换成实际值

编译与输出

编译后的文件是什么格式?

JSON 格式,默认是压缩的(没有空格和换行):

mortar hello.mortar           # 生成压缩JSON
mortar hello.mortar --pretty  # 生成格式化JSON

怎么指定输出文件名?

-o 参数:

mortar input.mortar -o output.json

不指定的话,默认是 input.mortared

JSON 结构是怎样的?

大致结构:

{
  "nodes": {
    ""NodeName"": {
      "texts": [...],
      "events": [...],
      "choices": [...]
    }
  },
  "functions": [...]
}

详细结构看 JSON 输出说明

编译错误怎么看?

Mortar 的错误信息很友好,会指出:

  • 错误位置(行号、列号)
  • 错误原因
  • 相关的代码片段
Error: Undefined node 'Unknown'
  ┌─ hello.mortar:5:20
  │
5 │     choice: ["去" -> Unknown]
  │                      ^^^^^^^ 这个节点不存在
  │

项目实践

多人协作怎么办?

建议:

  1. 使用 Git 管理 Mortar 文件
  2. 按功能模块划分文件,减少冲突
  3. 制定命名规范
  4. 写清楚注释

怎么和游戏引擎配合?

基本流程:

  1. 写好 Mortar 文件
  2. 编译成 JSON
  3. 在游戏里读取 JSON
  4. 实现对应的函数
  5. 按照 JSON 指示执行

详见 接入你的游戏

适合什么类型的游戏?

特别适合:

  • RPG 对话系统
  • 视觉小说
  • 文字冒险游戏
  • 互动故事

基本上任何需要“结构化对话“的游戏都适合!

可以用在非游戏项目吗?

当然! 任何需要结构化文本和事件的场景都可以:

  • 教育软件
  • 聊天机器人
  • 交互式演示
  • 多媒体展示

进阶话题

支持变量吗?

目前不支持内置变量系统,但你可以:

  • 在游戏代码里维护变量
  • 通过函数调用来读写变量
// Mortar 文件
fn get_player_hp() -> Number
fn set_player_hp(hp: Number)

// 游戏代码里实现这些函数

支持表达式吗?

目前不支持复杂表达式,但可以通过函数实现:

// 不支持:
choice: [
    "选项" when hp > 50 && has_key -> Next
]

// 可以这样:
choice: [
    "选项" when can_proceed() -> Next  
]

fn can_proceed() -> Bool  // 在游戏里实现逻辑

本功能即将支持。

怎么做本地化(多语言)?

本功能即将支持。

支持模块化吗?

目前每个 .mortar 文件是独立的,不能互相引用。

建议:

  • 把相关对话写在同一个文件
  • 或者在游戏里加载多个 JSON 文件并整合

本功能即将支持。

故障排查

编译时报“语法错误“怎么办?

  1. 仔细看错误信息指出的位置
  2. 检查是否漏了括号、引号
  3. 检查关键字拼写是否正确
  4. 确保“NodeName“、函数名有效

“未定义的节点“错误?

检查:

  • 跳转目标的节点是否存在
  • “NodeName“大小写是否一致(区分大小写!)
  • 是否有拼写错误

“类型不匹配“错误?

检查:

  • 函数声明的参数类型
  • 调用时传入的参数是否匹配
  • 返回类型是否正确

生成的 JSON 游戏读取不了?

  1. 确保JSON格式正确(用 --pretty 检查)
  2. 检查游戏代码的解析逻辑
  3. 查看是否有编码问题(使用UTF-8)

还有问题?

我们很乐意帮助你!🎉

贡献指南

感谢你对 Mortar 的兴趣!我们欢迎各种形式的贡献。

贡献方式

你可以通过以下方式为 Mortar 做贡献:

  • 🐛 报告 Bug - 发现问题就告诉我们
  • 提议新功能 - 分享你的创意
  • 📝 改进文档 - 让文档更清晰
  • 💻 提交代码 - 修复 Bug 或实现新功能
  • 🌍 翻译 - 帮助翻译文档
  • 💬 回答问题 - 在 Discussions 帮助其他人
  • 分享项目 - 让更多人知道 Mortar

行为准则

参与 Mortar 社区时,请:

  • ✅ 保持友善和尊重
  • ✅ 欢迎新手
  • ✅ 接受建设性批评
  • ✅ 专注于对社区最有益的事情

我们致力于提供一个友好、安全和欢迎所有人的环境。

报告 Bug

在报告前

  1. 搜索已有 Issues - 确认问题是否已被报告
  2. 更新到最新版本 - 确认问题在最新版本中仍然存在
  3. 准备最小复现示例 - 尽可能简化问题

如何报告

前往 GitHub Issues 创建新 Issue。

好的 Bug 报告应该包含

## 描述
简短描述问题

## 复现步骤
1. 创建这样一个文件...
2. 运行这个命令...
3. 看到错误...

## 期望行为
应该发生什么

## 实际行为
实际发生了什么

## 最小复现示例
```mortar
// 能复现问题的最小代码
node TestNode {
    text: "..."
}

环境信息

  • Mortar 版本:0.3.0
  • 操作系统:Windows 11 / macOS 14 / Ubuntu 22.04
  • Rust 版本(如果从源码构建):1.75.0

**示例**:

```markdown
## 描述
编译带有空选项列表的节点时崩溃

## 复现步骤
1. 创建文件 `test.mortar`
2. 写入以下内容:
   ```mortar
   node TestNode {
       text: "你好"
       choice: []
   }
  1. 运行 mortar test.mortar
  2. 程序崩溃

期望行为

应该给出友好的错误提示:“选项列表不能为空”

实际行为

程序直接崩溃,显示:

thread 'main' panicked at 'index out of bounds'

环境信息

  • Mortar 版本:0.3.0
  • 操作系统:Windows 11

## 提议新功能

### 在提议前

1. **搜索已有 Issues** - 确认功能是否已被提议
2. **思考必要性** - 这个功能对大多数用户有用吗?
3. **考虑替代方案** - 是否有其他实现方式?

### 如何提议

前往 [GitHub Discussions](https://github.com/Bli-AIk/mortar/discussions) 发起讨论。

**好的功能提议应该包含**:

```markdown
## 问题/需求
描述你遇到的问题或想解决的需求

## 提议的解决方案
详细描述你希望添加的功能

## 示例
展示功能的使用方式

## 替代方案
是否考虑过其他实现方式?

## 影响
这个功能会影响现有用户吗?

示例

## 问题/需求
写大型对话时,经常需要在多个文件之间共享函数声明,
目前需要在每个文件里重复声明,很麻烦。

## 提议的解决方案
增加 import 语法,可以从其他文件导入函数声明:

```mortar
import functions from "common_functions.mortar"

node MyNode {
    text: "触发共用逻辑"
    with events: [
        0, play_sound("test.wav")  // 这个函数来自 common_functions.mortar
    ]
}

替代方案

  1. 使用预处理器合并文件
  2. 在游戏引擎层面解决

影响

不会影响现有代码,因为现在不支持 import 关键字


## 改进文档

文档在 `docs/` 目录下,使用 Markdown 编写。

### 文档类型

- **教程** - 适合新手的循序渐进指南
- **操作指南** - 解决特定问题的步骤
- **参考** - 详尽的技术说明
- **解释** - 概念和设计思想

### 改进文档的步骤

1. Fork 仓库
2. 创建分支:`git checkout -b improve-docs`
3. 编辑文档文件
4. 本地预览:`mdbook serve docs/zh-Hans` 或 `mdbook serve docs/en`
5. 提交更改:`git commit -m "docs: 改进安装说明"`
6. 推送分支:`git push origin improve-docs`
7. 创建 Pull Request

### 文档风格指南

- **清晰简洁** - 用简单的语言解释复杂概念
- **友好的语气** - 像和朋友聊天一样
- **实际示例** - 提供可运行的代码
- **循序渐进** - 从简单到复杂
- **视觉辅助** - 适当使用图表、emoji
- **代码格式** - 使用语法高亮

**好的文档**:
```markdown
## 创建第一个对话

让我们写一段简单的 NPC 对话:

```mortar
node Villager {
    text: "你好,旅行者!"
}

就这么简单!保存文件后编译:

mortar hello.mortar

**不好的文档**:
```markdown
## 节点创建

创建节点使用 node 关键字,后跟标识符和块。
块内使用 text 字段定义文本内容。
编译使用 mortar 命令加文件名参数。

提交代码

开发环境设置

  1. 安装 Rust(1.70+):

    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
    
  2. 克隆仓库

    git clone https://github.com/Bli-AIk/mortar.git
    cd mortar
    
  3. 构建项目

    cargo build
    
  4. 运行测试

    cargo test
    
  5. 代码检查

    cargo clippy
    cargo fmt --check
    

项目结构

mortar/
├── crates/
│   ├── mortar_compiler/  # 编译器核心
│   ├── mortar_cli/       # 命令行工具
│   ├── mortar_lsp/       # 语言服务器
│   └── mortar_language/  # 主库
├── docs/                 # 文档
└── tests/                # 集成测试

开发流程

  1. 创建 Issue - 描述你要做的改动

  2. Fork 仓库

  3. 创建特性分支

    git checkout -b feature/my-feature
    # 或
    git checkout -b fix/bug-description
    
  4. 进行开发

    • 编写代码
    • 添加测试
    • 运行测试确保通过
    • 使用 clippy 检查代码
  5. 提交更改

    git add .
    git commit -m "feat: 添加新功能"
    
  6. 推送分支

    git push origin feature/my-feature
    
  7. 创建 Pull Request

提交信息规范

使用约定式提交(Conventional Commits):

<类型>(<范围>): <描述>

[可选的正文]

[可选的脚注]

类型

  • feat - 新功能
  • fix - Bug 修复
  • docs - 文档更改
  • style - 代码格式(不影响代码运行)
  • refactor - 重构
  • test - 添加测试
  • chore - 构建过程或辅助工具的变动

示例

feat(compiler): 添加对嵌套选项的支持

增加了解析嵌套选项的能力,现在可以写:
choice: [
    "选项" -> [
        "子选项" -> Node
    ]
]

Closes #42
fix(cli): 修复 Windows 上的路径问题

在 Windows 上编译时路径分隔符错误导致编译失败。
现在使用 std::path::PathBuf 正确处理路径。

Fixes #38

代码风格

遵循 Rust 标准风格:

# 格式化代码
cargo fmt

# 检查代码
cargo clippy -- -D warnings

代码注释

// 好的注释:解释为什么,不是做什么
// 使用哈希表而不是向量,因为需要 O(1) 的查找速度
let mut nodes = HashMap::new();

// 不好的注释:重复代码内容
// 创建一个新的 HashMap
let mut nodes = HashMap::new();

命名规范

// 使用 snake_case
fn parse_node() { }
let node_name = "test";

// 类型使用 PascalCase
struct NodeData { }
enum TokenType { }

// 常量使用 SCREAMING_SNAKE_CASE
const MAX_DEPTH: usize = 10;

测试

为新功能添加测试:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_simple_node() {
        let input = r#"
            node TestNode {
                text: "Hello"
            }
        "#;
        
        let result = parse(input);
        assert!(result.is_ok());
        
        let ast = result.unwrap();
        assert_eq!(ast.nodes.len(), 1);
        assert_eq!(ast.nodes[0].name, "Test");
    }
}

Pull Request

好的 PR 应该

  • ✅ 解决单一问题
  • ✅ 包含测试
  • ✅ 更新相关文档
  • ✅ 通过所有 CI 检查
  • ✅ 有清晰的描述

PR 描述模板

## 改动内容
简短描述这个 PR 做了什么

## 动机
为什么需要这个改动?解决了什么问题?

## 改动类型
- [ ] Bug 修复
- [ ] 新功能
- [ ] 文档更新
- [ ] 代码重构
- [ ] 其他:___

## 测试
如何测试这个改动?

## 相关 Issue
Closes #issue_number

## 截图(如适用)

Code Review

提交 PR 后:

  1. CI 检查 - 确保所有自动化测试通过
  2. 等待审核 - 维护者会审查你的代码
  3. 响应反馈 - 根据建议进行修改
  4. 合并 - 审核通过后会合并到主分支

翻译文档

想帮助翻译文档到其他语言?太好了!

当前支持的语言

  • 🇨🇳 简体中文(zh-Hans)
  • 🇬🇧 English(en)

添加新语言

  1. docs/ 下创建新目录:docs/your-language/
  2. 复制 book.toml 并修改语言设置
  3. 翻译 src/ 目录下的所有 .md 文件
  4. 测试构建:mdbook build docs/your-language
  5. 提交 PR

翻译指南

  • 保持结构一致 - 不要改变文档结构
  • 本地化示例 - 根据文化背景调整示例
  • 专业术语 - 保持术语一致性
  • 代码不翻译 - 代码示例保持英文
  • 链接更新 - 确保内部链接指向对应语言的页面

社区

获取帮助

保持联系

  • ⭐ Star 项目关注更新
  • 👀 Watch 仓库接收通知
  • 🔔 订阅 Release 通知

许可证

贡献的代码将采用与项目相同的许可证:

  • MIT License
  • Apache License 2.0

提交 PR 即表示你同意在这些许可证下分发你的贡献。

致谢

感谢所有贡献者!你们的帮助让 Mortar 变得更好 ❤️

贡献者列表见项目 README。


再次感谢你的贡献!如有任何问题,随时在 Discussions 提问 🎉