我在这行摸爬滚打了差不多七年,Gal引擎用过不少,也亲手写过几个半途而废的。每次项目启动时,团队都要在选型上吵上一轮:用老的日系引擎,文本演出绑定太紧,改演出像做手术;用通用游戏引擎,场景、UI、对话系统全要自己搭,工作量直接翻倍;用轻量脚本引擎,做小Demo很爽,一旦剧本超过几十万字、分支多起来,维护成本就开始失控。
正是在这种“哪个都不顺手”的背景下,我们把市面上主流的方案都试了一遍,最后实在忍不住,决定自己做一款面向叙事的引擎,也就是 NarraLeaf。这篇文章我会把设计思路、核心实现和技术选型完整摊开讲,顺便把我们在实际项目里踩过的坑也一并列出来。
1. 市面上引擎不少,为什么我们还要自己造一个
1.1 主流Gal引擎的现状:选择很多,顺手的很少
先说说我们对现有方案的真实感受。NScripter 和 Kirikiri 系列是很多老作品的选择,它们久经考验,稳定性没问题,但问题也很明显:脚本语法和底层语言绑定很深,想要加一个自定义演出效果,就得去啃 TJS 和系统内部实现,普通剧情脚本作者很难碰这块。
Ren'Py 是后来居上的热门方案,声明式脚本写起来非常顺手,文档也全。但真要做到商用级别的深度定制时,你会发现它所有的高级能力都堆在 Python API 里,底层数据模型也是围绕线性叙述设计的。对分支和演出时序的抽象,和我们想要的仍然有差距。而且 Ren'Py 面向桌面窗口为主,想快速发布到 Web 或其他端,适配成本并不低。
TyranoScript 这类偏向可视化操作的引擎上手很快,但是性能、扩展性和大规模项目管理的能力相对弱一些,适合轻量作品或原型验证。此外还有基于 Unity 的各类对话框架,功能上限高,但学习成本分散在场景搭建、UI系统、动画系统和资源管线上,编剧很难直接上手。
把这些方案放到一起看,会发现一个共性:它们要么把叙事脚本当成“文本配置”,要么把叙事逻辑硬生生塞进通用游戏框架里。我们想要的是一套真正以叙事为第一公民的系统——剧情节点、分支条件、演出时序、状态数据,全都应该是引擎底层原生支持的东西,而不是后期通过 hack 补出来的。
1.2 NarraLeaf 的定位:不是又一个脚本解释器
NarraLeaf 的初衷不是要取代谁,而是想重新定义一条创作链路。我们希望编剧写完剧本后,立刻就能看到画面、立绘、音乐和镜头效果;我们希望导演调整演出参数的时候,引擎能在不重进游戏的情况下热更新;我们更希望整个剧情流程可以变成一张能被检查和回溯的图,而不是藏在黑盒里的一堆 flag。
所以 NarraLeaf 核心就抓三件事:第一,剧本是数据,不是代码;第二,演出与逻辑分层,但同一份脚本里有清晰标记;第三,引擎在开发期必须是可观察、可调试、可回放的。后面的所有技术决策,都是围着这三件事展开的。
2. 技术选型与核心架构:NarraLeaf 是怎么做的
2.1 为什么选 TypeScript + Web 渲染管线做内核
NarraLeaf 的内核最终选择了 TypeScript,外层跑在浏览器和 Electron 双形态上。很多朋友听到这个选型会问我:为什么不用更偏底层的 C# 或 C++?
理由其实很实际。Gal 引擎的核心负载不在物理模拟,也不在复杂渲染,而在文本解析、状态管理、异步演出调度和 UI 呈现。这些活儿在 JS/TS 生态里非常顺手,TypeScript 还给了我们一套比较完整的类型系统,让剧本数据模型在团队协作时能被静态检查,大大减少低级错误。
跨端发布也是重点。基于 Web 管线做,意味着同一个引擎内核可以直接跑在浏览器页面里,也可以打包成桌面客户端。项目最开始就计划要支持 PC 和 Web 双形态,那 Web 技术栈就是最经济的选择。音频、图片、字体、动画这些素材,Web 生态全都有成熟方案,不用从头造轮子。
当然,Web 方案也有它的代价。比如说,图像渲染的上限受限于浏览器和显卡驱动,做极端复杂的后处理特效会比较吃力。但 Gal 游戏核心演出是人物立绘、表情差分、背景切换、镜头推拉和文字时序,WebGL 画布已经能把这些都覆盖得很好。就算以后真需要大规模粒子特效,我们也可以通过自定义 Shader 挂在演出指令里,不需要推翻底层架构。
2.2 DSL 怎么设计:把剧情写成可编译的剧本,而不是可读的字符串
NarraLeaf 没有直接让用户写 JSON 或者 YAML 来做剧情,而是设计了一套专门的剧本 DSL。因为对编剧来说,JSON 结构不够直观;对程序来说,字符串又没法静态分析。DSL 能把两边的需求同时满足。
我们最初定义的剧本 DSL 大致长这样:
scene kitchen_morning: bg "scene/kitchen_morning.png" with fade(0.6) char 夏洛 pose normal pos right char 秋实 pose normal pos left 夏洛: "咖啡煮好了,我难得起这么早。" 秋实: "那我还想再睡半小时,你怎么不叫我。" choice: "老实承认" -> route_confess "岔开话题" -> route_change_subject这段 DSL 是声明式的。它描述的是一个场景里有哪些角色、背景、对白和选项,而不是一步一步“怎么去画”的命令。解析器会把它编译成一颗场景树,每个场景节点、对话线索和选项后续都成为树上的节点和边。这样一来,编剧看到的是剧本,引擎拿到的是结构化数据,编辑器也能基于同一份数据画出流程地图。
DSL 的解析过程分了三层。第一层是词法分析,把文本拆成 token;第二层是语法分析,把 token 组装成抽象语法树;第三层是做语义校验,比如检查角色是否声明过、场景背景素材是否存在、选项跳转的节点是否真实存在。这些校验在保存脚本的瞬间就会执行,而不是等玩家玩到那一帧才爆炸。
2.3 数据模型:为什么剧情是一张有状态的图
很多引擎把 Gal 剧情当成一段顺序播放的文本,遇到分支就靠条件变量跳转。NarraLeaf 从一开始就把整条剧情视作一张有向图,每个节点就是一次叙事状态,节点之间的边承载着触发条件。
这意味着什么?最直观的好处是,编辑器可以把整部游戏画成一棵巨大的流程图。全局变量、角色好感值、路线解锁状态,都作为节点上的条件或者副作用存在。当一个剧本里有两三百个场景节点、上百个分支条件时,光靠人脑去理清关系是不可能的,但图结构配合可视化界面,就能让制作人一眼看出哪些路是死胡同,哪些分支埋了但永远走不到。
在实现上,这张图分为两层。一层是静态的脚本结构,包含场景、路由和选项;另一层是运行时快照,包含当前所在节点、变量集合和演出状态。静态结构在构建期就确定下来,运行时只负责把玩家“移动”到某个节点上,然后根据快照恢复演出现场。这种划分也顺带解决了存档回溯和剧情地图的问题,后面我会专门讲。
3. 实操一个 Demo:从初始化到跑完第一个带分支的场景
3.1 项目骨架:先把目录结构定清楚
NarraLeaf 的项目目录刻意保持简单,方便编剧和美术都能快速理解:
my-story/ config.narraleaf # 项目配置 story/ # 剧情 DSL 源文件 scene_open.dsl routes.dsl assets/ bg/ character/ audio/ i18n/ zh_CN.csvconfig.narraleaf里面主要放窗口大小、默认字体、初始场景和标题信息。
title: 林中小屋 resolution: [1280, 720] default_font: "Noto Sans CJK SC" entry_scene: scene_kitchen_morning启动引擎时,它会先读取配置,再扫描 story 目录下所有.dsl文件,解析后构建整棵剧情树。这个过程通常几十毫秒就完成了,哪怕是一个几十万字的剧本,构建时间也在秒级范围内,完全可以在开发中频繁重启。
3.2 所见即所得的编辑器:边说戏边看演出
我们做 NarraLeaf 编辑器时,目标很明确:要像一个导演监视器,而不是一个文本编辑器。
编辑器左侧是场景树,中间是舞台预览,右侧是当前节点属性。你在 DSL 文件里改动一句台词,保存后舞台预览会立刻刷新;你切换一个角色的表情,立绘图片马上换掉;你调整背景音乐的音量,播放状态也会跟着改。这就是我们常说的热重载。
热重载实现起来其实有一个隐蔽难点:当玩家停留在剧情中段时,如果我们修改了剧本,怎么保证当前上下文不丢?我们的做法是在运行时保存一份“解析上下文快照”,当新脚本加载后,引擎会尝试把当前节点在新树中的对应位置重新建立起来,如果节点不存在了,就退回到最近一个可匹配的上级场景。这样在开发时可以大胆改结构,不用每次从标题画面重来。
3.3 演出指令:从静态图文到动态运镜
只展示图片和文字还不够,NarraLeaf 把演出也做成了一套可编排的指令系统。举一个带演出效果的场景例子:
scene storm_night: bg "bg/storm_night.png" with fade(0.8) char 雨瞳 scared pos right body 雨瞳 shake 0.5 play_se "thunder_01" volume 0.9 word "雷声在屋顶滚过,旧窗户跟着哐当一响。"这条脚本里出现了几类指令:背景淡入、角色立绘切换、角色震动、播放音效、显示叙述文字。每个演出指令在引擎内部都对应一个可异步执行的任务,任务可以顺序执行也可以并行执行。
时间控制上我们做得比较细:每个指令都有 duration、delay 和 easing 参数,支持等待某个指令结束再继续,也支持触发新的指令来打断前一个持续型演出。例如角色一直在摇动,镜头突然切黑,那么震动指令会被标记为可取消,立即终止,而不会被后面的黑场阻塞住。
4. 剧情复杂度上来以后,三个真正需要较劲的模块
4.1 存档系统:不止记录场景,还要恢复完整状态
简单存一个“当前是第几个场景”肯定是不可靠的。很多 Gal 项目里,分支条件是分散的,角色好感度是多个事件累积出来的,演出进度也可能处于半截动画里。NarraLeaf 的存档会把整个运行时的关键状态序列化下来。
{ "schema": 3, "scene": "storm_night", "node": "route_approach_window", "flags": {"rainy": true, "friend_point": 12}, "play_head": 178.42, "history": ["scene_city", "scene_room", "storm_night"] }这里特别注意 schema 字段。我们正式版发布后遇到过旧版本存档不兼容的问题,后来规定所有存档结构必须带 schema 版本号,加载时如果发现版本偏低,就启动一段兼容迁移流程,将旧存档升级到当前结构。这个设计后期救过我们好多次,否则一个体验版玩家更新补丁后,存档全部作废,那口碑就崩了。
4.2 分支回溯与剧情地图:让“回头路”不难走
Gal 游戏经常要提供章节选择或路线回顾功能,本质上就是让玩家在剧情树上自由跳转。NarraLeaf 因为原始数据就是图,所以实现这个功能天然占优势。
我们在编辑器里集成了一个流程地图面板,每个节点显示场景名、进入条件、后续分支和当前玩家是否到达过。玩家在游戏内打开地图时,可以点击已解锁的节点直接回看。回看时引擎需要处理好状态还原:跳到一个早前的场景,意味着后面累积的好感值可能不该继续生效。我们为此设计了一套变量快照机制,节点之间保存进入时的变量基线,回跳后不会残留未来节点的状态。
4.3 多语言管线:不要等中文本地化做完才想翻译
NarraLeaf 从脚本解析阶段就会给每一条台词生成一个唯一 key,再配合导出的 CSV,可以很方便地做多语言翻译。这里有个实战建议:所有对白文本都尽量避免把玩家名字、数字、物品名硬拼进一句话里。比如“获得了 XXX x3”这种句子,就应该用占位符再加参数格式化。翻译文本时,不同语种的词序完全不同,硬拼接会折腾死译者。
5. 常见问题与排查技巧实录
5.1 我们踩过的坑和排查对策
用表格整理一下常见的开发期问题,方便大家对号入座。
| 现象 | 可能原因 | 解决建议 |
|---|---|---|
| 场景加载后没有显示背景 | 背景资源路径写错,或 DSL 中 bg 指令没有放对层级 | 检查 assets 映射表,开启启动时的资源预检日志 |
| 对白出现但立绘不出现 | 角色声明名称和立绘资源 key 不一致 | 在语义校验阶段把角色名与资源映射表做一遍交叉检查 |
| 演出卡在某个动画上不动 | 并行演出指令没有被正确触发结束信号 | 了持续型指令统一加超时保护,开发期自动告警 |
| 分支回溯后变量错乱 | 没有使用节点变量基线,而是读写了全局变量 | 统一通过变量管理器读写,避免剧本里直接访问底层状态 |
| 热重载后编辑器崩溃 | 新的 DSL 结构解析失败,但旧运行时还没退出 | 在热载入前先跑一遍完整语法校验,失败时禁止替换运行态 |
5.2 调试的小技巧:让运行时现出原形
NarraLeaf 开发版内置一个调试面板,可以通过快捷键呼出,实时查看当前节点、已触发条件、变量集合和最近十条演出日志。这个面板看起来很朴素,但真的排查概率性问题时极其有用。
比如团队反馈说“某个选项偶尔失灵”,你光看脚本不一定找得到原因,但调试面板会把选项到达条件、变量当前值和判定结果一条条列出来,往往一眼就能定位到是变量条件写反了,还是某个前置场景没有正确地设置标志位。我们后来甚至给剧情脚本作者也开放了调试面板,毕竟他们也需要自查逻辑,不一定要等程序介入。
6. 关于 Gal 引擎和创作工具本身的一点心得
做了 NarraLeaf 之后,我对“引擎”这个词的理解其实发生了一点变化。以前觉得引擎就是把素材渲染出来、把对白放出来的程序,后来才意识到,一款好的叙事引擎更重要的是怎么组织创作关系:编剧应该关注剧情结构,导演应该关注节奏演出,程序应该关注稳定性与扩展性,而这些协作关系最终都会映射到引擎的数据模型和编辑器上。
NarraLeaf 选择一条稍微辛苦一点的路,把叙事语言、运行时和图谱编辑器放在一起设计。好处是在我们自己的作品里,改剧情、调分支、重新剪辑演出都变得非常快。我也知道它目前还不算完美,比如插件体系的开放程度、跨平台砖桥的成熟度都还有待打磨,但至少我们从“迁就引擎”变成了“用顺手工具的人”。
如果你也在做类似方向的项目,我的建议是不要急着把代码写飞,先花足够时间想明白三个问题:你的剧情数据结构是什么,演出调度模型是什么,编剧和程序之间如何协作。这三个问题想透了,用什么语言、什么渲染引擎反而不是最重要的事。