欢迎来到 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),如
StartScene、ForestPath- 函数名使用蛇形命名(snake_case),如
play_sound、get_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_name、anim_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
- 打开 Release 页面,下载对应版本,例如
mortar-x.x.x-linux-x64.tar.gz或mortar-x.x.x-macos-x64.tar.gz。 - 解压到任意目录:
tar -xzf mortar-x.x.x-linux-x64.tar.gz -C ~/mortar
- 将可执行文件路径加入环境变量,例如:
export PATH="$HOME/mortar:$PATH"
- 检查是否安装成功:
mortar --version
Windows
- 下载对应版本的
mortar-x.x.x-windows-x64.zip。 - 解压到任意目录,例如
D:\mortar。 - 将目录添加到系统环境变量 PATH:
- 右键「此电脑」→「属性」→「高级系统设置」→「环境变量」
- 在「系统变量」或「用户变量」中找到
Path→ 编辑 → 添加D:\mortar
- 打开新的命令提示符,检查安装:
mortar --version
⚠️ 注意:
- 需要手动设置环境变量
- 每次开新终端或修改系统配置时可能会出现问题
- 对普通用户来说不太友好
因此推荐使用 方法一(Cargo),安装体验更顺畅。
检查安装
运行这个命令测试一下:
mortar --help
你应该能看到帮助信息,说明各种用法。
编辑器支持(可选但推荐)
为了更好的编写体验,可以安装语言服务器:
cargo install mortar_lsp
然后在你喜欢的编辑器里配置它即可。
查看 编辑器支持 了解如何配置你的编辑器。
遇到问题?
“找不到 cargo 命令”
你需要先安装 Rust。访问 https://rust-lang.org/ 按照指引安装。
“编译失败”
确保你的 Rust 版本足够新:
rustup update
其他问题
- 查看 GitHub Issues
- 或者在 Discussions 里提问
下一步
安装好了?那就去五分钟上手试试看吧!
理解 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 并按照指示执行
各部分详细说明
想深入了解每个部分?
- 节点:对话的积木块 - 节点的所有用法
- 文本与事件:分离的艺术 - 如何优雅地关联文本和事件
- 选项:让玩家做选择 - 创建分支对话
- 函数:连接游戏世界 - 声明和使用函数
- 变量与常量 - 管理对话状态与公共文案
- 分支插值 - 提供 Fluent 风格的局部差异
- 本地化策略 - 组织多语言脚本与文档
- 节点中的控制流 - 使用
if/else控制文本 - 事件系统与时间线 - 复用命名事件与演出
- 枚举与结构化状态 - 建模有限状态并驱动分支
小提示
- 从简单开始:先写纯文本对话,再慢慢加事件和选项
- 善用注释:用
//给自己留笔记 - 合理命名:“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时,玩家需要做选择 - 如果选项有
return或break,会影响后续流程。 - 节点末尾的箭头是“默认出口“
对于 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() // 关联的文本不对!
]
建议
- 紧跟原则:events 紧跟在对应的 text 后面
- 适度使用:不是每句话都需要事件
- 有序排列:事件按位置从小到大写(虽然不强制)
- 合理命名:函数名要见名知意
常见问题
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
]
建议
- 至少一个无条件选项:确保玩家总有选择
- 选项文字简洁:一般不超过 20 个字
- 逻辑清晰:不要嵌套太深(建议最多 2-3 层)
- 提供退路:给玩家“返回“或“取消“的机会
常见问题
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 |
Bool | Boolean | 布尔值 | true, false |
注意:Bool 和 Boolean 是一样的,随便用哪个。
完整示例
// 一个完整的 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)
建议
- 见名知意:函数名应该说明它做什么
- 参数适度:一般不超过 7 个参数
- 类型明确:所有参数和返回值都要注明类型
- 分类整理:相关的函数放在一起,加注释说明
常见问题
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 引入了脚本全局状态,让对话可以感知游戏进度。所有声明必须写在脚本的顶层(不在 node 或 fn 中),这样编译器才能在 .mortared 的 variables 区域里完整记录它们。
定义变量
使用 let,依次写名称、类型以及可选的初始值。目前支持 String、Number、Bool 三种基础类型:
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 适合用于:
- 布尔条件(如
place、greet)切换称谓。 - 枚举条件(如
location、object)切换整段描述。 - 在
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/en 与 book/zh-Hans 维护对应的 mdBook。编写游戏脚本时同样遵循这个约定,为贡献者提供清晰的落点。
发布流程
- 更新源语言(通常是
locales/en)。 - 将节点及结构变更同步到其他语言。
- 分别运行
cargo run -p mortar_cli -- locales/<lang>/story.mortar --pretty。 - 将生成的
.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。
在节点中运行事件
常见的两种方式:
run EventName:立即执行事件,忽略默认 index(但会尊重 duration)。with EventName或with 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_override 的 ContentItem::RunEvent。
时间线(Timeline)
时间线由 run、wait 和 now 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 同时输出 events 和 timelines,方便工具链和游戏引擎进行可视化或复用。
枚举与结构化状态
枚举是变量系统的重要补充,用来表示一组封闭的状态(章节、好感度、天气等)。它与 分支插值 和 if 搭配时尤其强大。
声明枚举
在顶层使用 enum:
enum GameState {
start
playing
game_over
}
所有枚举值都会出现在 .mortared 的 enums 数组里,引擎可以直接校验或生成对应的原生枚举。
定义枚举变量
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}。"
}
引擎对接
- 加载
enums后建立注册表,或生成对应的原生枚举。 - 将 Mortar 变量(如
current_state)与游戏内状态同步。 - 借助赋值或函数调用,把结果反馈回脚本。
这样既能保持设计意图清晰,又能确保状态流转始终合法。
完整示例与讲解
学了那么多,现在让我们看看真实的例子!
这一章节包含三个逐步进阶的示例:
📝 写一段简单对话
最简单的入门例子,适合:
- 刚开始学 Mortar
- 想快速看到效果
- 做一个简单的 NPC 对话
你会学到:
- 基本的节点和文本
- 简单的事件绑定
- 基础选项跳转
📖 制作互动故事
一个完整的互动小故事,适合:
- 想做分支剧情
- 需要多层选择
- 制作文字冒险游戏
你会学到:
- 复杂的选择结构
- 条件判断
- 多结局设计
- 字符串插值
🎮 接入你的游戏
实际集成到游戏引擎的完整流程,适合:
- 准备把 Mortar 用在项目里
- 需要理解 JSON 输出
- 想实现自己的解析器
你会学到:
- 编译流程
- JSON 结构解析
- 函数调用实现
- 运行时执行逻辑
建议按顺序阅读,每个示例都比前一个更复杂一些。
准备好了?从写一段简单对话开始吧!
写一段简单对话
让我们从最简单的场景开始:一个 NPC 和玩家的短暂对话。
场景设定
想象你在做一个 RPG 游戏,有个村民 NPC 会跟玩家打招呼,然后问玩家要不要帮忙。
第一版:纯文本对话
最简单的版本,先把对话写出来:
// 村民的问候
node VillagerGreeting {
text: "你好呀,冒险者!"
text: "欢迎来到我们的小村庄。"
text: "需要我帮忙吗?"
choice: [
"需要帮助" -> OfferHelp,
"不用了,谢谢" -> PoliteFarewell
]
}
node OfferHelp {
text: "太好了!让我看看能帮你什么..."
text: "这是一份地图,希望对你有用!"
}
node PoliteFarewell {
text: "好的,祝你旅途愉快!"
}
运行效果:
- 显示三段文字
- 玩家选择
- 根据选择跳转到不同节点
第二版:添加音效
现在让对话更生动,加入音效:
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
在游戏中实现
你的游戏需要:
-
读取 JSON:解析编译后的 JSON 文件
-
实现函数:实现所有声明的函数
// 例如在 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"); } -
执行对话:按照 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);
}
扩展建议
你可以在此基础上:
-
增加更多分支
- 添加“用手触摸泉水“的选项
- 加入“向泉水许愿“的神秘分支
-
加入状态记录
- 记录玩家的选择
- 在结局中展示玩家的决策路径
-
多结局变体
- 根据之前的游戏进度解锁隐藏结局
- 加入“真结局“需要满足特定条件
-
配合游戏系统
- 结局影响后续剧情
- 给予不同的奖励
小结
这个例子展示了:
- ✅ 多分支剧情设计
- ✅ 条件判断的灵活运用
- ✅ 隐藏选项和分支
- ✅ 字符串插值
- ✅ 多个结局的实现
- ✅ 音效与特效的配合
这就是 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. 字符串插值
- 识别
{}标记 - 调用对应函数
- 替换成返回值
性能优化建议
- 预加载资源:对话开始前加载所有音效、图片
- 对象池:选项按钮使用对象池,避免频繁创建销毁
- 异步加载:大型对话文件使用异步加载
- 缓存结果:字符串插值的结果可以缓存
常见问题
Q: JSON 太大怎么办?
A: 按场景/章节拆分成多个文件,按需加载。
Q: 怎么实现存档?
A: 保存当前“NodeName“和相关变量即可恢复对话进度。
Q: 怎么支持快进?
A: 跳过打字机效果,直接显示完整文本,快速触发所有事件。
Q: 可以中途打断对话吗?
A: 可以,记录当前状态,下次从断点继续。
小结
集成 Mortar 的关键步骤:
- ✅ 编译 Mortar 生成 JSON
- ✅ 创建数据结构匹配 JSON
- ✅ 实现函数调用映射
- ✅ 编写对话执行引擎
- ✅ 处理事件和选择
从简单例子开始,逐步增加功能,很快就能上手!
接下来
命令行工具
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 提供编辑器支持,帮助你在写对话时更高效。目前尚未提供专门的编辑器插件。
编辑器适配计划
我们的计划是:
- 先通过 LSP2IJ 插件适配 JetBrains 系列 IDE(如 IntelliJ IDEA、PyCharm 等)
- 随后适配 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 中包含 version 与 generated_at,两者均为字符串。时间戳遵循 UTC 的 ISO 8601 格式,可用于缓存或回滚判定。
节点与线性内容
nodes 现在是 数组 而不再是字典。每个节点形如:
{
"name": "Start",
"content": [ ... ],
"branches": [ ... ],
"variables": [ ... ],
"next": "NextNode"
}
content 数组就是对话/事件的真实顺序,已经把旧版本的 texts、runs、choice_position 全部折叠到一起,引擎只需从头到尾迭代即可。
内容项(Content Item)类型
所有元素都拥有 type 字段:
-
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"] }] }。
-
type: "run_event"— 调用顶层events中的命名事件。name:事件名。args:序列化后的参数。index_override:{ "type": "value" | "variable", "value": "..." },用于重写触发位置(可绑定变量)。ignore_duration:为true时忽略事件自带的duration,立即执行。
-
type: "run_timeline"— 执行一条timelines描述的演出序列(参见计划文档第 5 节的演出系统)。 -
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=functionBool=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 { }
怎么结束对话?
有三种方式:
- return - 结束当前节点(如果有后续节点,会继续)
- 没有后续跳转 - 对话自然结束
- 跳转到特殊节点 - 可以做一个专门的“结束“节点
// 方式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]
│ ^^^^^^^ 这个节点不存在
│
项目实践
多人协作怎么办?
建议:
- 使用 Git 管理 Mortar 文件
- 按功能模块划分文件,减少冲突
- 制定命名规范
- 写清楚注释
怎么和游戏引擎配合?
基本流程:
- 写好 Mortar 文件
- 编译成 JSON
- 在游戏里读取 JSON
- 实现对应的函数
- 按照 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 文件并整合
本功能即将支持。
故障排查
编译时报“语法错误“怎么办?
- 仔细看错误信息指出的位置
- 检查是否漏了括号、引号
- 检查关键字拼写是否正确
- 确保“NodeName“、函数名有效
“未定义的节点“错误?
检查:
- 跳转目标的节点是否存在
- “NodeName“大小写是否一致(区分大小写!)
- 是否有拼写错误
“类型不匹配“错误?
检查:
- 函数声明的参数类型
- 调用时传入的参数是否匹配
- 返回类型是否正确
生成的 JSON 游戏读取不了?
- 确保JSON格式正确(用
--pretty检查) - 检查游戏代码的解析逻辑
- 查看是否有编码问题(使用UTF-8)
还有问题?
- 📖 查看 示例代码
- 💬 到 GitHub Discussions 提问
- 🐛 在 GitHub Issues 报告 bug
- 📚 阅读 参考资料
我们很乐意帮助你!🎉
贡献指南
感谢你对 Mortar 的兴趣!我们欢迎各种形式的贡献。
贡献方式
你可以通过以下方式为 Mortar 做贡献:
- 🐛 报告 Bug - 发现问题就告诉我们
- ✨ 提议新功能 - 分享你的创意
- 📝 改进文档 - 让文档更清晰
- 💻 提交代码 - 修复 Bug 或实现新功能
- 🌍 翻译 - 帮助翻译文档
- 💬 回答问题 - 在 Discussions 帮助其他人
- ⭐ 分享项目 - 让更多人知道 Mortar
行为准则
参与 Mortar 社区时,请:
- ✅ 保持友善和尊重
- ✅ 欢迎新手
- ✅ 接受建设性批评
- ✅ 专注于对社区最有益的事情
我们致力于提供一个友好、安全和欢迎所有人的环境。
报告 Bug
在报告前
- 搜索已有 Issues - 确认问题是否已被报告
- 更新到最新版本 - 确认问题在最新版本中仍然存在
- 准备最小复现示例 - 尽可能简化问题
如何报告
前往 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: []
}
- 运行
mortar test.mortar - 程序崩溃
期望行为
应该给出友好的错误提示:“选项列表不能为空”
实际行为
程序直接崩溃,显示:
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
]
}
替代方案
- 使用预处理器合并文件
- 在游戏引擎层面解决
影响
不会影响现有代码,因为现在不支持 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 命令加文件名参数。
提交代码
开发环境设置
-
安装 Rust(1.70+):
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -
克隆仓库:
git clone https://github.com/Bli-AIk/mortar.git cd mortar -
构建项目:
cargo build -
运行测试:
cargo test -
代码检查:
cargo clippy cargo fmt --check
项目结构
mortar/
├── crates/
│ ├── mortar_compiler/ # 编译器核心
│ ├── mortar_cli/ # 命令行工具
│ ├── mortar_lsp/ # 语言服务器
│ └── mortar_language/ # 主库
├── docs/ # 文档
└── tests/ # 集成测试
开发流程
-
创建 Issue - 描述你要做的改动
-
Fork 仓库
-
创建特性分支:
git checkout -b feature/my-feature # 或 git checkout -b fix/bug-description -
进行开发:
- 编写代码
- 添加测试
- 运行测试确保通过
- 使用 clippy 检查代码
-
提交更改:
git add . git commit -m "feat: 添加新功能" -
推送分支:
git push origin feature/my-feature -
创建 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 后:
- CI 检查 - 确保所有自动化测试通过
- 等待审核 - 维护者会审查你的代码
- 响应反馈 - 根据建议进行修改
- 合并 - 审核通过后会合并到主分支
翻译文档
想帮助翻译文档到其他语言?太好了!
当前支持的语言
- 🇨🇳 简体中文(zh-Hans)
- 🇬🇧 English(en)
添加新语言
- 在
docs/下创建新目录:docs/your-language/ - 复制
book.toml并修改语言设置 - 翻译
src/目录下的所有.md文件 - 测试构建:
mdbook build docs/your-language - 提交 PR
翻译指南
- 保持结构一致 - 不要改变文档结构
- 本地化示例 - 根据文化背景调整示例
- 专业术语 - 保持术语一致性
- 代码不翻译 - 代码示例保持英文
- 链接更新 - 确保内部链接指向对应语言的页面
社区
获取帮助
- 💬 GitHub Discussions - 提问和讨论
- 🐛 GitHub Issues - 报告 Bug
- 📧 Email - 见项目 README
保持联系
- ⭐ Star 项目关注更新
- 👀 Watch 仓库接收通知
- 🔔 订阅 Release 通知
许可证
贡献的代码将采用与项目相同的许可证:
- MIT License
- Apache License 2.0
提交 PR 即表示你同意在这些许可证下分发你的贡献。
致谢
感谢所有贡献者!你们的帮助让 Mortar 变得更好 ❤️
贡献者列表见项目 README。
再次感谢你的贡献!如有任何问题,随时在 Discussions 提问 🎉