概念
Novella 游戏就是数据,由一个运行时来播放。本页面介绍这些数据和这个运行时:文档其余部分所使用的术语,以及决定玩家看到什么的规则。
游戏定义
关于游戏的一切都在一个游戏定义中:
| 部分 | 包含的内容 |
|---|---|
| 配置 | 游戏的行为:标题、分辨率、入口标签、回滚和存档设置、偏好设置的默认值。用 define config.key = value 设置。 |
| 主题 | 游戏的外观:字体、颜色、对话窗口、选项、菜单。用 define theme.key = value 设置。 |
| 故事 | 各个标签以及它们引用的一切。 |
| 资源 | 文件的来源:磁盘上的文件夹、内存中的文件、Divooka 文档的库。 |
脚本、C# 程序和 Divooka 图填写的都是同一个定义,因此它们可以混合使用:程序可以加载脚本、修改其中的某项设置,并添加用 C# 故事构建器编写的标签;图可以用节点构建故事,再向其中添加一个脚本文件。主题设置列出了每一个配置键和主题键。
资源路径是相对路径,使用正斜杠,匹配时不区分大小写,因此游戏可以在文件夹和文档库之间迁移而无需修改。当多个来源包含相同路径时,最后添加的那个优先,补丁文件夹就是这样覆盖文档中的文件的。
故事和标签
故事是一组有序的标签,每个标签都是一个具名的语句块;此外还包括故事的定义:角色、图像、分层图像、变换、转场、音频别名和通道、变量默认值和常量、画廊、音乐空间、成就以及翻译。
多个脚本会合并为一个故事。标签不能冲突(定义了两次的标签是一个错误,错误信息会指出两处位置),而后面脚本中的定义会替换前面同名的定义。从文件夹加载的脚本按路径顺序加载,因此名为 options.novella 或 script_01_definitions.novella 的文件可以排在使用其定义的章节之前。
标签是一个入口点。游戏从 start 开始;jump 转到另一个标签继续执行;call 运行一个标签后再返回,把实参绑定到标签的参数上(label chapter_title(number, title):),并在 _return 中接收 return 的值。与 Ren'Py 一样,执行到标签末尾时会落入同一文件中写在其后的下一个标签;从文件最后一个标签的末尾落出则会结束游戏,如果该标签是被调用的,则返回到调用方。没有调用方的 return 会结束游戏。用 C# 或节点构建的标签不来自任何文件,因此它们永远不会落入下一个标签:执行到末尾时会结束游戏,或返回到调用方。当一个用代码构建的标签可能执行到末尾、而其后的标签也是用代码构建时,lint 会发出警告,因为脚本中的标签在这种位置本会落入下一个标签。
故事还有引擎会查找的入口标签:splashscreen 在启动时、主菜单之前运行一次,main_menu 替换内置主菜单(它返回时开始新游戏),after_load 在加载存档之后被调用。它们都是可选的,并且可以在配置中重命名。
场景
场景是一种无需编写控制流就能编写故事的方式。它是 Novella One 构建游戏所用的配置模型:场景有一个名称、一个背景(通过转场引入,如果没有指定其他转场则为 fade)、音乐、台词,以及一个说明故事接下来去向的结尾:
| 结尾 | 发生什么 |
|---|---|
| 转到场景 | 故事在另一个场景继续。 |
| 选择场景 | 向玩家提供若干选项,每个选项通向一个场景;选项可以只在某个条件成立时提供,并且在被选中时可以修改变量。 |
| 分支到场景 | 由一个条件决定接下来是两个场景中的哪一个。 |
| 结束 | 游戏结束(如果是被调用的场景,则返回)。 |
游戏就是其场景的集合,名为 start 的场景是游戏的开始。对运行时来说,场景并不是一种新东西:它会变成一个与其同名的普通标签,依次包含背景、音乐、台词,最后是一个跳转、一个由跳转组成的菜单,或一个分支。因此,场景的存档、回滚和验证方式与其他任何标签相同,其他标签可以跳转到它,场景、标签和脚本也可以混合在同一个故事中。场景在 Divooka 中用 Scenes 节点编写,在 C# 中用 SceneDefinition 编写。
语句和台词 id
语句沿用 Ren'Py 的:对话(say)、旁白、extend、show、hide、scene、with、pause、jump、call、return、menu、if/elif/else、while、赋值($)、play/queue/stop、voice、movie、input、window、nvl clear、notify、achieve、界面(show screen、call screen)、自定义命令(run),以及在选择启用 Python 的游戏中的 python: 代码块。界面也是故事数据,可以用一种由盒子、条件和循环组成的界面语言编写,每一帧都根据故事的变量重新布局;也可以是游戏注册的 C# 类。脚本语言页面列出了全部语句。
每句对话台词和每个菜单选项都有一个稳定的 id:标签名称加上说话者和文本的哈希值,如果同一句台词在一个标签中出现两次,还会附加一个计数器;也可以是用 id name 写明的 id。id 由内容派生,而不是由位置决定,因此在某句台词上方插入一句新台词,不会改变那句台词的 id,也就不会改变它的已读标记和翻译。改写一句台词会改变它的 id,这是正确的:改写后的台词是未读的,需要重新翻译。需要翻译的游戏通常会写明 id,这样编辑英文原文时就不会让翻译失去对应。
角色
角色表示谁在说话,以及其台词的外观:脚本使用的 id(q);显示的名称("Qinglan",其中可以插入变量,例如 "[player_name]");名称颜色和文本颜色;字体和字号;包裹台词的引号;图像标记;该图像是否作为侧边图像显示在对话窗口中;打字音效;以及一种类型:adv(对话窗口)、nvl(逐行填满的全屏页面)或 centered(显示在屏幕中央、没有窗口的文本)。角色还可以为其台词的每个部分指定专属的样式:与其他所有人都不同的窗口、名称标签、名称和台词。
内置了三个角色:narrator、centered 和 nvl_narrator。没有说话者的台词属于旁白(narrator)。
带有图像标记的角色可以在说话时更换图片:q smile "..." 显示 qinglan smile 并保持显示,q @ smile "..." 只在这一句台词期间显示它。
图像
图像的名称由若干个词组成:第一个词是它的标记(tag),其余的是属性(attribute)。qinglan smile 的标记是 qinglan,属性是 smile。同一标记在舞台上同时只能有一个图像,因此显示 qinglan sad 会替换 qinglan smile。

图像有三种定义方式:
- 根据文件名自动定义。资源中的每个图像文件都会定义一个图像,除非故事自己定义了该名称:名称为去掉扩展名的路径,文件夹和文件名之间用空格连接,全部小写,并去掉名为
images、sprites或characters的顶层文件夹。bg/plum_garden.jpg即bg plum_garden;sprites/qinglan/smile.png即qinglan smile。 - 在脚本中用
image name = source定义,其中来源可以是文件、颜色、影片、占位图、作用于另一个来源的滤镜、合成图像或条件切换。 - 作为分层图像定义,由按属性选择的图层组装而成,因此
show qinglan dress smile blush可以组合三个图层,而无需为这种组合单独准备文件。
内置了两个图像:black 和 white,它们是填满屏幕的纯色,使 scene black 无需任何定义即可使用。如果故事定义了其中任一名称,则使用故事自己的定义。
当脚本请求某些属性时,引擎会查找具有所有这些属性的图像,并优先选择能保留当前已显示属性的图像:在 show qinglan dress smile 之后,如果存在 qinglan dress sad,show qinglan sad 就会找到它。如果没有匹配项,该图像会绘制为占位图(一个剪影,背景则为渐变),并由验证器报告,因此在美术素材完成之前就可以编写和游玩故事。
可识别的图像文件格式有 .png、.jpg、.jpeg、.webp、.bmp、.gif、.tga 和 .qoi。
舞台
舞台是逻辑上的画面:显示了哪些图像,位于哪个图层,以什么顺序,使用哪些变换,以及显示了哪些自定义界面。舞台归运行器所有,表现层只是镜像它,因此存档能精确还原画面,测试也能在不绘制任何东西的情况下检查画面上有什么。

有四个内置图层,onlayer 加上任何其他名称会创建一个同名图层。从下到上依次为:
| 图层 | 绘制位置 | 典型用途 |
|---|---|---|
master |
对话窗口之下 | 背景和角色:show 和 scene 的默认图层。 |
| 任何其他名称 | 位于 master 之上、transient 之下,多个图层按名称顺序排列 |
故事自己创建的图层,例如位于角色和窗口之间的天气效果。 |
transient |
对话窗口之下 | 只显示片刻的图片:与 Ren'Py 一样,在每句台词、每个菜单、每次输入或每次点击继续的暂停之后自动清除。 |
screens |
对话窗口之上 | 必须位于文本之上的图片。 |
overlay |
对话窗口之上 | 边框、暗角、天气效果。 |
在同一图层内,图像按显示的先后顺序堆叠,按 zorder 排序,并可用 behind 调整;scene 清空一个图层。
变换
变换用于放置图像并为其添加动画。带小数点的位置值表示比例(位置相对于屏幕,锚点相对于图像),整数表示像素,因此 xpos 0.5 是正中间,xpos 640 是距左边 640 像素。内置位置有 left、right、center、midleft、midright、truecenter、top、topleft、topright、offscreenleft、offscreenright 和 fullscreen;没有指定变换时使用 default(底部居中)。
变换也可以是 Ren'Py ATL 风格的动画时间线:属性设置、带缓动函数的插值、暂停、更改图像显示内容的图片行、repeat、block、parallel、加权的 choice、time、针对图像被显示、被替换或被隐藏的处理程序、contains 以及 function。图像本身也可以是一条时间线,帧动画就是这样实现的:一支无论显示在哪里都会闪烁的蜡烛。时间线被当作一个纯函数来求值,其输入是图像显示以来经过的时间;每个显示的图像都带有自己的随机种子(保存在存档中),其随机选择都从这个种子中抽取,因此在读档或回滚后,动画能精确还原。时间线中的表达式可以读取变换自身的参数和内置函数,但不能读取游戏的变量,因为动画不能依赖存档无法还原的状态。当为一个已经在显示的图像指定新变换时,新时间线会从图像当前所在的位置开始,因此像 ease 1.0 xalign 0.0 这样的插值会从旧位置平滑移动,而不是先跳回默认位置。
转场
转场为画面的变化添加动画。它可以写成 show、hide、scene 或 window 语句上的 with,写在一句对话台词的末尾(此时它在台词出现之前播放),写在 show screen 或 hide screen 上(被调用的界面则改为在转场时长内淡入),也可以单独成行;它作用于自玩家上次交互以来发生变化的所有内容,从变化前的画面过渡到变化后的画面。转场在模型中指定(dissolve、fade、pixellate、move、slide、push、wipe、iris、image dissolve、zoom、punch、flash、blinds、squares),由表现层绘制。玩家可以在偏好设置中关闭转场,快进时也会跳过转场。
运行器和交互
运行器是故事的虚拟机。它是单线程且同步的:它会一直执行语句,直到某条语句需要玩家参与,然后等待这次交互。交互共有七种:
| 交互 | 由谁产生 | 以什么作答 |
|---|---|---|
| Say | 对话和旁白 | 一次点击 |
| Menu | menu |
所选选项的索引 |
| Input | input |
输入的文本 |
| Pause | pause |
一次点击,或时间耗尽 |
| Transition | with |
转场结束 |
| Movie | movie |
影片结束,或在可跳过时的一次点击 |
| Screen | call screen |
界面返回的值;如果玩家关闭界面,则为空 |
show、hide、scene、音频和赋值会更新状态并继续运行,而不会等待。运行器中没有任何部分进行绘制、播放声音或读取时钟,因此同一个运行器既可以位于窗口之下,也可以用于无头测试和自动通关。一个故事如果在不等待的情况下运行了一百万条语句,就会以错误停止,这样可以捕获其中没有台词或菜单的 while 循环。
变量
故事的变量存在于游戏状态中,通过 $、default、input 以及菜单的 set 列表设置。以 persistent. 开头的名称则存在于持久存储中(见下文),被调用标签的参数则是该次调用的局部变量。有几个变量由引擎设置:
| 变量 | 设置者 |
|---|---|
_return |
return value、run 命令的结果、被调用界面的结果 |
_choice、_choice_index |
刚刚做出的菜单选择的文本和索引 |
default name = value 在开始新游戏时设置变量。define name = value 在启动时设置常量,并在每次读档后再次设置,因此修改后的常量在旧存档中也会生效。
变量可以保存数字、文本、布尔值、空值或列表。在选择启用 Python 的游戏中,python: 代码块会获得故事的变量,并把它设置的、属于上述类型的值交还回来;Python 持有的其他任何数据,例如字典、集合或 init python: 所定义类的对象,每当代码块修改它们时,都会被序列化(pickle)到一个隐藏的故事变量 _python 中,因此它们会与故事自身的变量一起存档、读档和回滚。函数和类是代码,每次游戏启动时都会重新定义,因此它们应放在 init python: 中。那里的 Python 可以使用其标准库(random、os、json、pickle、datetime 等都可以导入),但涉及随机时,故事仍然应使用自己的 random、chance 和 choice,因为它们的抽取来自游戏状态,回滚时会重放;而 Python 的 random 会重新抽取。
检查点和回滚
每句台词、每个菜单、每次输入、每次点击继续的暂停以及每个被调用的界面都是一个检查点:运行器会保存它之前的状态快照,即变量、舞台、每个音频通道正在播放的内容、调用堆栈、历史记录、NVL 页面以及随机数生成器的状态。回滚(鼠标滚轮向上、Page Up,或快捷菜单上的 Back)会还原上一个检查点并再次显示其交互;向前回滚会重放玩家之前给出的回答。不等待点击的台词(以 {nw} 结尾的台词)在显示的瞬间就会再次前进,因此回滚时会越过它,回到它之前的那句台词。当没有可以回滚到的位置时,快捷菜单的 Back 会变暗:位于第一句台词时、回滚已关闭时、处于实时脚本中时,或者位于受 rollback_past_choices = false 保护的菜单时。

由于随机数生成器是状态的一部分,当玩家回滚并重新选择时,随机抽取会被重放:回滚不能用来重新掷骰子。config.rollback_limit 设置玩家最多能回滚多远,config.allow_rollback 关闭回滚,config.rollback_past_choices = false 使回滚在菜单处停止,从而无法重新做出选择。
存档和持久数据
存档是最新的检查点,加上读档界面所显示的信息:时间、标签、存档时所在的台词或菜单、游玩时间、游戏版本以及一张缩略图。读取存档会精确还原画面,重新应用脚本的常量,调用 after_load 标签(如果有),并恢复当时正在播放的音乐。存档位命名为 page-slot(1-3)、auto-n 和 quick-1;每个存档位都是一个 JSON 文件,旁边附有一张 PNG 缩略图,在 Windows 上位于 %AppData%/Novella/<game id>。游戏会在菜单处(最多每三十秒一次)以及玩家退出或返回主菜单时自动存档。


存档以“标签加语句索引路径”的方式记录位置,因此在某个标签内创建的存档不受其他标签修改的影响;但如果在同一标签中、存档位置之前添加或删除了语句,读档时可能会落在另一句台词上。如果存档所在的标签已不存在,该存档就无法读取,并且会告知玩家。
持久数据是在多次通关之间保留下来、永远不会被回滚或被读档替换的所有内容:已读的台词(用于跳过已读文本)、看过的图像(用于画廊)、听过的音频(用于音乐空间)、到达过的标签、在之前通关中做出的选择(显示得较暗)、成就、persistent. 变量以及玩家的偏好设置。它保存在存档旁的 persistent.json 中。凡是玩家在读取旧存档后仍应保留的内容,例如已看过的结局,都应使用持久数据。
用代码编写的故事
用 C# 故事构建器编写或由节点构建的故事,与解析后的脚本完全一样都是数据:由语句组成的标签,其中条件是表达式,对变量的更改是赋值。它的任何部分都不是正在运行的过程,因此它的存档、读档、回滚、跳过已读文本和获得台词 id 的方式都与脚本编写的故事相同。
存档并不包含故事。它保存的是一个位置(一个标签和一条语句索引路径)、变量以及其余状态,读档时会在游戏当前的故事中找到这个位置。脚本每次都提供相同的故事,因为它是同一个文件;程序之所以能提供相同的故事,是因为它每次启动都运行相同的代码。由此得出的规则是:每次运行都要构建相同的故事。如果故事的结构取决于时钟、随机数或会变化的文件,读取旧存档时就会落到错误的位置。
条件或效果可以是一个作用于故事变量的 C# lambda,而不是表达式;在由节点构建的故事中,交给 From Function 节点的 Function 图起着同样的作用,下文所述对它同样适用。lambda 本身永远不会被保存;下次启动时构建的故事会再次持有它,而它读写的是游戏状态中的故事变量,因此它的更改会与其他一切一起回滚和存档。这带来两个结果:
- lambda 应当只触及故事的变量。它在故事之外修改的任何东西(程序的某个字段、某个文件),在玩家回滚时都不会回滚,读档时也不会还原。
- lambda 没有脚本形式。由各部分构建的条件(变量与某个值比较、访问过某个标签、多个条件的组合)与脚本中
if所包含的表达式相同,因此故事可以打印为脚本,再读回来也完全一样;而 lambda 只会打印为诸如a C# condition之类的描述,无法读回。
实时脚本
故事也可以是一个过程,而不是标签。一个 C# 方法或 Divooka 过程式图实现游戏的 Script 回调(或传给 UseLiveScript),开始游戏时会在单独的线程上运行它;在这个线程中,Say、Show、Scene、Menu、Input 等都是阻塞调用:每个调用都会立即通过运行器播放,与脚本语句完全一样,并在玩家作答后返回。Menu 返回所选选项的索引,可直接用于 Branch 或 Switch。这是用节点编写小游戏最直接的方式。
它的限制源自它的本质。正在运行的过程所处的位置不是数据,因此实时脚本无法存档、读档或回滚:存档界面仍然可以打开,但选择存档位或快速存档时,会显示通知 The game cannot be saved here.,并且不写入任何内容;自动存档也会被跳过。Call Label 从实时脚本中运行一个故事标签,之后再返回实时脚本;该标签在其他方面的行为与编写的一致,但只要下方有实时脚本,它同样无法存档或回滚。对于玩家需要存档的游戏,请把故事写成标签或场景(用脚本、C# 故事构建器或节点),而用代码实现命令。
一次会话的流程
游戏打开时,如果有 splashscreen 标签,就先运行它,然后显示主菜单(或运行 main_menu 标签)。Start 从开始标签开始新游戏(或运行实时脚本),Continue 读取最近的存档,游戏菜单(右键单击或 Escape)包含 History、Save、Load、Preferences,以及在故事定义了它们时的 Gallery、Music Room 和 Achievements。故事结束时,游戏会通过 config.end_game_transition 返回主菜单。
控制
单击、Space 或 Enter 前进;滚轮向上或 Page Up 回滚,滚轮向下或 Page Down 向前回滚;右键单击或 Escape 打开游戏菜单;按住 Ctrl 时快进,Tab 切换快进,A 切换自动前进;H 或单击鼠标中键隐藏窗口;F5 和 F9 分别快速存档和快速读档;S 保存屏幕截图;F、F11 或 Alt+Enter 切换全屏;数字键选择菜单选项。在开发者模式下,Shift+O 打开控制台,Shift+R 重新加载脚本。
手柄操作游戏的方式与键盘相同:
| 手柄 | 作用 |
|---|---|
| A | 继续,并按下所选按钮。 |
| B | 返回,或打开游戏菜单。 |
| Start | 打开游戏菜单。 |
| 方向键、左摇杆 | 在按钮之间移动,按住时重复。 |
| 左右肩键 | 回滚和向前回滚。 |
| X | 隐藏窗口。 |
| Y | 切换自动前进。 |
| Back | 切换快进。 |
每个界面都可以用手柄操作,包括游戏自己的界面,因为手柄的按钮对应的正是界面原本就会响应的按键:无需为此编写任何代码。