做视觉小说的人大概都有过这样的经历剧本写了两万字演出效果脑子里已经过了好几遍结果卡在最基础的“怎么让它跑起来”上——用现成的商业工具吧导出体积大、定制演出受限自己从零写吧打字机、立绘淡入、存档回读、移动端适配每一样都是坑。这时候一个基于 HTML5 的 GalGame 引擎就显得特别对味剧本是纯文本演出用网页技术画浏览器打开就能玩改一行剧本刷新就能看到效果。这类引擎比如标题里那种把引擎和示例工程打包在一个仓库里的实现解决的正是“让写故事的人不必先成为前端工程师”这件事。这篇我按自己折腾这类引擎的顺序把剧本解析、演出时序、存档结构、移动端性能、打包发布这几块拆开讲中间夹着实测数据和踩过的坑适合想自己动手做一套、或者想接手二次开发的人参考。1. 引擎的四层职责一次点击背后的完整链路很多人以为 GalGame 引擎的核心是“把文字画到屏幕上”其实那只是最外面一层的表现。真正决定一个引擎好不好用的是它有没有把异步资源加载和确定性状态重放这两件事处理干净。前者关系到玩家会不会看到白屏和跳帧后者关系到读档能不能精确回到玩家离开的那一句台词上。这两件事没做对后面写再多演出效果都是建立在流沙上的。1.1 从玩家按下的那一次点击说起先看一个最普通的交互玩家点了一下屏幕。这一个动作在引擎里至少要走五步判断。第一步判断当前是否处于“文字正在逐字显示”的状态如果是这次点击的语义不是“下一句”而是“立刻显示完整文本”同时把打字机定时器停掉第二步如果文字已经显示完但当前指令后面还挂着还没播完的演出比如立绘位移、背景淡出这次点击要么被吞掉要么作为“跳过演出”的信号取决于引擎的策略第三步如果一切空闲则推进指令指针program counter取下一行第四步对取到的指令做类型分派调用对应的执行函数第五步执行函数可能会发起异步的资源请求此时要进入“等待资源”的锁状态禁止玩家继续点击。这五步里有三个地方特别容易写错。一个是状态判断的顺序如果你先判断“是否空闲”再判断“是否在打字”那玩家永远点不完一句台词因为打字过程中引擎一直认为自己不空闲。另一个是点击事件的去抖移动端浏览器在 touchend 之后还会补发一次 click如果两个都绑了监听玩家一次点击会推进两句台词这个 bug 在真机上几乎必现桌面上用鼠标测根本看不出来。第三个是锁状态的释放时机资源加载失败时如果没有把锁放掉整个游戏就卡死在那一句玩家只能刷新页面。我的建议是把这三件事收敛成一个显式的状态机而不是散落在各个 if 里// 引擎的顶层状态越简单越好状态越多越容易死锁 const STATE { IDLE: idle, // 可以推进下一条指令 TYPING: typing, // 文字还在逐字显示 WAITING: waiting, // 等待资源加载或演出结束 MENU: menu, // 打开了存档/设置等界面 };状态机的好处是你看一眼当前状态就知道这次点击该干什么调试时把状态打到控制台绝大多数“点了没反应”或者“点了跳两句”的问题都能立刻定位。1.2 四层的边界划分与常见越界我把这类引擎的职责拆成四层层次之间只允许单向依赖不允许反向调用。这是我试过几版之后认为最省心的划分方式层级负责什么绝对不该做的事典型越界后果脚本层文本、分支、指令的存储与解析直接操作 DOM换主题时剧本全废运行时层指令指针、flag、存档结构直接改 canvas 像素无法确定性重放表现层文字、立绘、背景、转场决定剧情走向分支逻辑散落各处资源层图片音频的加载、缓存、释放修改运行时状态加载失败时状态卡死最容易越界的是表现层和运行时层。举例来说“某角色第一次登场时要说一句特殊台词”这种需求诱惑很大的写法是在表现层里加一个if (isFirstAppear)。一旦这么干了这个判断就没法被存档保存玩家读档重玩这一段台词就没了。正确做法是把它变成脚本层的一个 flag登场时由脚本指令置位运行时层负责持久化表现层只负责画。资源层反向修改运行时状态也是重灾区。有些实现里图片加载完成后的回调顺手把状态机推到了下一句看起来省事实际上等于让资源层掌握了剧情推进权一旦某张图 404剧情就往前跳或者原地打转。资源层的回调应该只做一件事把加载结果交给运行时层的等待队列由运行时层决定什么时候解锁。1.3 为什么不建议一上来就全套 Canvas新手做这类引擎第一个纠结往往是“用 DOM 还是 Canvas”。我的实测结论是文字层用 DOM演出层用 Canvas 或 CSS两者混着来。原因是文字素材的排版、换行、字体回退、中日文标点挤压交给浏览器排版引擎比你自己写好一万倍而 Canvas 里画中文光是断行算法和字体加载时序就够你折腾一周。但立绘和背景这类大图动效用 DOM 就有点吃力了。一张 1080P 的立绘做淡入淡出用opacity过渡可以走 GPU 合成还算流畅可一旦你要做位移、缩放、旋转的组合动画DOM 的样式重算就开始拖后腿。我在中端安卓机上测过一个纯 DOM 实现的立绘三连动效掉到 38fps 左右换成 Canvas 绘制后稳定在 58fps 上下。折中的工程做法是文字面板、对话框、UI 按钮、菜单全部用 DOM背景和立绘用一个铺满的 Canvas 绘制中间用一个透明的 DOM 层承接点击。这样两边的优势都能吃到缺点是坐标换算要做两套——Canvas 里画的位置要按逻辑分辨率算DOM 里的按钮要按 CSS 像素算中间靠一个缩放系数转换。2. 剧本格式的设计自定义语法还是纯 JSON引擎跑起来之后第二个绕不过去的决策是剧本用什么格式存。这件事看起来是小问题实际上它决定了后续所有工具链的形态改起来成本极高值得在动手前想清楚。2.1 三种方案的取舍我认真试过三种方案各自的代价差别很大纯 JSON 结构。优点是可以直接用 JS 的JSON.parse编辑器能自动补全程序里拿到的就是对象。缺点是写起来极其啰嗦一句台词要写成{type:say,speaker:某人,text:你好}两万字的剧本光括号就够看花眼而且改标点都要小心翼翼漏一个逗号整个文件解析失败。自定义 DSL逐行指令式。一行一句台词命令用前缀区分是绝大多数 GalGame 引擎最后都会走的路。写起来像# 第一章 雨夜 bg school_gate_night fade 600 bgm rain_loop vol0.6 立绘 yuki normal atleft yuki: 你……怎么这么晚还在这里 yuki: (转身) 算了当我没问。可读性接近剧本原稿作者不需要懂编程也能改。代价是你得自己写解析器还要处理引号转义、注释、行号追踪这些细节。混合式。主体是逐行指令但参数部分允许写 JSON 字面量比如show yuki {expr:smile,x:0.3}。灵活度最高实现复杂度也最高不太建议第一版就上。我最终选的是第二种。理由很实际编剧不写代码他们改剧本的频率远高于你改引擎的频率让他们能直接在工作群里发一段文本让你贴进去比让他们理解 JSON 结构重要得多。2.2 解析器怎么写才不留后患逐行解析看着简单实际写起来坑不少。一个能用的解析器至少要处理这几件事识别命令名、拆分参数、支持带引号的含空格参数、跳过空行和注释、记录每行对应的原始行号方便报错时告诉作者“第 137 行有个参数写错了”、处理不同平台的换行符。// 最小可用的逐行指令解析器 function parseScript(source) { const lines source.replace(/^\uFEFF/, ).split(/\r\n|\r|\n/); const out []; lines.forEach((raw, i) { const line raw.trim(); if (!line || line.startsWith(//)) return; // 空行与注释 const m line.match(/^([a-zA-Z_][\w]*)\s*(.*)$/); if (!m) throw new Error(第 ${i 1} 行无法识别的语句 - ${raw}); out.push({ cmd: m[1], args: splitArgs(m[2]), lineNo: i 1, file: currentFile, }); }); return out; } // 按空格切分但引号内的空格保留 function splitArgs(str) { const args []; let cur ; let quote null; for (const ch of str) { if (quote) { if (ch quote) quote null; else cur ch; } else if (ch || ch ) { quote ch; } else if (ch || ch \t) { if (cur) { args.push(cur); cur ; } } else { cur ch; } } if (cur) args.push(cur); return args; }这个splitArgs我在四个项目里都用过够用且没有依赖。注意它是按字符遍历而不是用正则split(/\s/)因为后者没法处理引号里的空格——yuki: 她说明天见这种台词一旦被空格切碎参数就全乱了。2.3 实测踩过的三个解析坑第一个坑是UTF-8 BOM。Windows 上的编辑器保存中文文本时经常带一个不可见的 BOM 头它会被当成第一行内容的一部分导致第一条指令名解析失败。上面代码里那句replace(/^\uFEFF/, )就是专门治它的别省。第二个坑是中文全角空格。作者在中文输入法下敲出的空格是全角的\u3000trim()和\s在大多数现代浏览器里能处理但如果你用自定义的ch 判断就会漏掉。稳妥做法是判断/\s/.test(ch)或者显式把\u3000加进去。第三个坑是行号与热重载的错位。开发期做剧本热重载很爽改一行存盘页面自动跳到那一句。但如果解析后的数组下标和文件行号没有建立映射重载后跳转位置就会偏。所以每条指令都要带着lineNo跳转按行号找而不是按下标找。这个细节第一版不做等到剧本超过五千行你就知道疼了。3. 演出时序打字机、立绘与音频的配合引擎的技术骨架搭好后玩家实际感受到的“质感”几乎全部来自演出层。同一段剧情演出做得好是沉浸做得差就是 PPT。这一层最考验细节手感也是我最愿意花时间磨的地方。3.1 打字机效果的三种实现与代价最朴素的做法是setInterval每几十毫秒往容器里塞一个字。简单但有三个明显问题一是定时器精度受事件循环影响快速连打时会累积漂移一句长台词能慢出好几百毫秒二是浏览器标签页切到后台时定时器会被节流到 1 秒一次切回来时文字是“一段一段蹦”的三是每塞一个字都触发一次重排长文本下 CPU 占用明显。第二种是requestAnimationFrame加时间累加。每一帧读取时间戳算出这一帧应该显示到第几个字符然后一次性把文本更新到位。这个方案的好处是无论帧率怎么波动整体速度都是准的切到后台再回来也能立刻追平。class Typewriter { constructor(el, cps 40) { this.el el; this.cps cps; // 每秒字符数 this.raf 0; this.done false; } play(text, onDone) { cancelAnimationFrame(this.raf); this.full text; this.shown 0; this.startAt performance.now(); this.done false; const tick (now) { const elapsed (now - this.startAt) / 1000; const target Math.min(this.full.length, Math.floor(elapsed * this.cps)); if (target ! this.shown) { this.shown target; this.el.textContent this.full.slice(0, target); } if (target this.full.length) { this.done true; onDone onDone(); return; } this.raf requestAnimationFrame(tick); }; this.raf requestAnimationFrame(tick); } finish() { // 点击时立即补全 cancelAnimationFrame(this.raf); this.el.textContent this.full; this.shown this.full.length; this.done true; } }第三种是 CSS 动画或者逐字包裹span加animation-delay。性能最好但可控性最差——你没法在标点处做停顿也没法在玩家点击时“立刻补全但不打断后续动画”所以只适合做标题文字这类静态展示。提示无论用哪种实现finish()都必须是幂等的。玩家手快连点两下第一下补全、第二下推进如果finish()里又触发了一次onDone剧情就会跳过一句。中文台词还有个小细节值得单独处理标点停顿。真实朗读在逗号、句号、省略号处会有停顿把这个节奏还原到打字机上质感提升非常明显。我的做法是给标点配一张时长表在计算目标字数时把已经过去的停顿时间扣掉标点额外停顿毫秒说明、120短停顿接近呼吸。240句末明显停顿……420情绪停顿常用于沉默——180语气转折实现上不需要修改主循环只要在每帧计算目标字数时把从开始到现在累计的“已过停顿时间”从 elapsed 里减掉即可。3.2 立绘切换的成本与双缓冲立绘是这类游戏里最占资源的元素。一个角色五套表情一套 1080P 带透明通道的 PNG 大约 800KB 到 2MB五个角色就是几十兆。这些图如果等到要显示的那一刻才去加载玩家会先看到一段空白然后图“啪”地出现出戏感极强。预加载策略上我按“当前章节清单 当前场景角色全集”两级来做。进入一章时加载这一章的背景和 BGM进入一个场景时把该场所有角色的所有表情一次性预载完。这样做内存占用会高一些但切换表情时是零等待的手感差别巨大。图片解码也有讲究。图片下载完不等于能立刻绘制浏览器还要做一次解码。现代浏览器提供了img.decode()可以在插入 DOM 之前主动触发解码async function preloadImage(url) { const img new Image(); img.src url; try { await img.decode(); // 提前解码避免首帧卡顿 } catch (e) { console.warn(解码失败降级为普通加载, url); await new Promise((r) (img.onload r)); } return img; }立绘切换动画用双缓冲最稳两个img或两块 Canvas 区域一块显示当前一块在背后准备好下一张然后做交叉淡化。这样切换过程中不会出现“图闪没了再出现”的中间态。动画属性只用opacity和transform绝对不要动width、top、margin这类会触发布局的属性一次布局重算在低端机上就是十几毫秒足够掉两帧。3.3 音频通道管理这件小事音频是最容易被忽视、又最容易翻车的一块。用audio元素播 BGM 听上去很简单实际会遇到三个问题移动端首次播放必须由用户手势触发、多个音效同时播放时通道数不够、音量淡入淡出直接改volume会有明显的“台阶感”爆音。我的处理办法是固定几条通道一条 BGM 通道、一条环境音通道、四到六条音效通道用对象池管理。BGM 切换时做 800 毫秒交叉淡化淡出用旧通道淡入用新通道中间不留静音间隙。音量渐变不要用setInterval改volume改出来是一格一格的听着像卡带如果环境支持用 Web Audio 的GainNode做linearRampToValueAtTime曲线是连续的。移动端的解锁时机也要挑好。不要等到第一句 BGM 该响的时候才播而是在标题界面的“开始游戏”按钮上就绑定一次静音播放把音频上下文解锁掉。这样进入正片时 BGM 是一次性正常响起的不会出现“第一句台词没声音、第二句才响”的尴尬。4. 存档结构读档要精确回到离开的那一秒存档是这类引擎的技术分水岭。做得糙的引擎读档后音乐从头播、立绘表情不对、刚触发的分支又没了做得好的引擎读档就像视频拖回进度条画面、声音、文本、分支状态全都对得上。差别全在存档里存了什么。4.1 快照里该有什么、不该有什么先说不该存的解析后的指令数组体积大且可从剧本重建、DOM 节点引用无法序列化、音频实例和定时器句柄跨会话无意义、Canvas 对象。这些东西一旦进了存档轻则文件膨胀到几兆重则读档直接抛异常。再说该存的我按重要性排了个序字段作用注意事项scriptId当前所在的剧本文件多章节分段加载时必需lineNo当前指令的文件行号用行号而不是数组下标flags所有分支变量纯键值对值限数字/字符串/布尔bg/characters当前画面状态记资源 id 和位置不记图片数据bgm当前音乐及播放位置位置可以粗略到秒避免续播违和history最近若干条已读文本用于回看限制条数防膨胀thumb缩略截图JPEG 质量 0.6宽 320 足够version存档格式版本号迁移老存档的关键缩略图这块要多说一句。用canvas.toDataURL(image/jpeg, 0.6)压到 320px 宽一张大约 8KB 到 15KB二十个存档位也就 300KB 左右完全可以塞进 localStorage。但千万不要用 PNG一张 720P 的 PNG 缩略图能到 800KB二十个存档直接撑爆 5MB 的配额玩家会遇到“存档失败但没有提示”的诡异情况——所以写入 localStorage 一定要包 try/catch配额满了要明确告诉玩家。4.2 分支 flag 与条件求值的安全写法分支条件我见过两种极端写法。一种是直接在剧本里写 JS 表达式然后用new Function或者eval求值灵活是灵活但剧本文件等于有了执行任意代码的能力一旦剧本来自第三方二次创作场景很常见就是个实打实的安全口子。另一种是把所有分支硬编码进引擎改一个条件就要改引擎代码作者完全没法自己维护。我选的是中间路线只支持一张受限的表达式白名单。比较运算、逻辑与或非、括号、变量名、数字和字符串字面量别的都不认。解析成简单的语法树再求值代码量不到两百行安全性可控作者写起来也够用// 剧本里这样写 if trust_yuki 3 and seen_letter true jump chapter2_good else jump chapter2_normal求值器本身不做任何 IO也不接触全局对象变量一律从flags里取取不到就当0或者false。这样即使剧本写错了变量名最坏结果也只是走了 else 分支不会把游戏搞崩。4.3 回滚栈为什么不能直接存整个状态很多玩家习惯用滚轮往回翻几句重看这个功能叫回滚rollback。实现上有两种思路一种是每推进一条指令就把完整状态深拷贝压栈另一种是只记录“可逆的增量”。第一种写起来最省事但两万字剧本跑下来栈里堆几千个状态对象内存直接起飞尤其在移动端会被系统杀掉页面。我的做法是限制栈深度 定期全量快照。栈最多存 100 步每 20 步存一个完整快照中间的步只存增量比如“哪句台词推进了”“哪个 flag 变了”。回滚时先跳到最近的快照再正向重放增量。这样内存是可控的回滚也能做到基本即时。一个必须注意的细节回滚时要跳过副作用。有些指令带副作用比如“播放一次音效”“解锁一张 CG 图鉴”回滚重放时不应该再播一次音效也不该重复解锁。我的做法是给指令打标记重放模式replay mode下只恢复状态不执行副作用。5. 移动端跑起来适配、加载与性能实测桌面浏览器上跑得飞快的东西扔到手机上经常一地鸡毛。这类游戏在移动端的流量占比通常超过七成适配和性能必须当成主线任务而不是收尾工作。5.1 分辨率适配与安全区适配的核心思路是逻辑分辨率 整体缩放。我固定一个 1280×720 或 1920×1080 的逻辑画布然后用 CSStransform: scale()把整个舞台等比缩放到容器大小多余的地方留黑边letterbox。这样做的好处是所有素材坐标和动画参数都可以按逻辑分辨率写死不用到处算百分比缺点是不同宽高比的屏幕上黑边位置不一样UI 按钮不能贴着边放。/* 舞台容器等比缩放 居中 黑边 */ .stage { position: absolute; left: 50%; top: 50%; width: 1920px; height: 1080px; transform-origin: center center; /* scale 由 JS 按容器尺寸实时计算 */ } .stage-wrap { position: fixed; inset: 0; overflow: hidden; background: #000; touch-action: manipulation; /* 干掉双击缩放和 300ms 延迟 */ user-select: none; -webkit-tap-highlight-color: transparent; }安全区这几年越来越重要。带刘海的机型横屏时左右两侧各有几十像素被遮挡如果对话框或者“存档”按钮放在那个区域玩家根本点不到。CSS 的env(safe-area-inset-left)系列变量可以直接用注意要在 viewport 里加上viewport-fitcover才生效。还有两个小细节一是touch-action: manipulation一定要加否则移动端会有 300 毫秒的点击延迟手感很差二是禁掉长按选中和图片拖拽玩家按住对话框想滑动时浏览器会弹出“保存图片”菜单非常出戏。5.2 首屏时间的优化路径我拿一部大约 40 张背景、12 个角色、两小时流程的作品做过测试。最初的做法是进入游戏就把所有资源拉下来总资源量 180MB 左右在没有预热的网络下首屏要等十几秒进度条走得像蜗牛。改成两级加载后首屏资源压到 6MB 以内标题界面背景、UI 素材、第一章的前三张背景和一个角色4G 网络下 2 秒左右能进标题。具体做法是把资源清单按章节拆成多个 JSON进哪一章加载哪一档同时后台空闲时预取下一章。格式上也有优化空间。背景图从 PNG 转 WebP体积普遍能降 60% 到 75%而且现代浏览器支持度已经很好了但要注意带透明通道的立绘转 WebP 时边缘有时会出现轻微色带需要在编码质量上留点余量。音频同理BGM 用 m4a 或者 ogg别的格式比如 wav体积会是十倍以上只在音效这种短素材上才考虑。5.3 真实设备上的性能问题几个印象深的实测问题都是文档里不会写的大图解码失败。超过 2048×2048 的图片在部分中低端安卓设备的浏览器上会直接解码失败表现为一片空白控制台却没有明显报错。解决办法是把立绘控制在 1600px 宽以内或者做成分块。这个坑我在三台不同设备上复现过最后是统一把素材尺寸压下来才彻底解决。内存不释放。切场景时把img从 DOM 里移除了但 JS 变量还引用着垃圾回收根本不会回收。图片解码后的内存占用是文件体积的好几倍几百张图攒下来页面直接被系统清理。我的做法是维护一个资源表切场景时显式把引用置空并调用一次img.src 辅助释放。长时间挂机后卡顿。玩家把页面切到后台放置半小时再回来有时候会卡住几秒。原因是后台节流导致定时器堆积回来时一次性执行。解决思路是把所有依赖时间的逻辑都改成基于时间戳计算而非累加回来时一次性追平而不是重放每一帧。6. 从引擎到能发布的作品打包与协作边界引擎本身只是工具最终要变成玩家能打开的东西。这一步的选择会反过来影响引擎的设计最好在动手前就定下来。6.1 几种分发方式的对比方式优点缺点适合场景纯网页托管零安装一个链接就能玩依赖网络移动端体验一般短篇、试玩版、比赛投稿套桌面壳可离线能读本地存档包体积大一倍以上完整商业作品移动端原生壳可上应用商店能调用原生接口适配和审核成本高有预算的正式发行可安装网页应用体积小能加到桌面各平台支持度不一轻量作品我一般先用纯网页形式把整部作品跑通因为迭代成本最低——改一行剧本刷新就能验发个链接给朋友就能收反馈。等剧情和演出都定了再套壳做离线版。套壳时最容易踩的坑是资源路径网页里用绝对路径/assets/bg.png在壳里会 404开发期就该统一用相对路径或者干脆把所有资源用构建工具打进一个清单里统一管理。存档的迁移也要提前想。网页版的存档在 localStorage 里套壳之后可能变成文件系统两者数据格式必须一致否则老玩家的存档全丢。我在存档结构里放了version字段读档时先跑一遍迁移函数从 v1 升到当前版本这样跨平台迁移就不会出问题。6.2 给剧本作者留一条不碰代码的路一个引擎能不能被长期用下去很大程度上取决于非程序员能不能参与。我在项目里做了三件事效果很好。第一件是资源目录约定死。背景放assets/bg/命名规则是场景名_时间.png立绘放assets/char/角色名/表情名.png。剧本里只需要写bg school_gate_night引擎按约定去拼路径。作者新增一张图只要按命名规则丢进去就能用不用改任何配置。第二件是提供剧本校验脚本。作者写完一章跑一下校验就能知道哪一行指令名写错了、哪个资源文件不存在、哪个 flag 用了但从来没赋过值。这个脚本不难写但对协作效率的提升是数量级的——它把“跑起来发现不对再回头找”变成了“写完立刻知道哪里错”。第三件是给演出参数留默认值。指令的参数能省就省比如bg school_gate_night不带任何参数就默认 400 毫秒淡入第 0 层。作者不需要理解每个参数的含义等需要特殊效果时再查文档。降低门槛的关键从来不是文档写得多全而是默认行为足够合理。6.3 二次创作的接口边界如果打算开放给其他人做同人或二创有两条边界必须提前划清一是剧本文件必须完全外置不能编译进代码里否则别人没法改剧情二是UI 主题要能整体替换把对话框、按钮、菜单的样式集中在一个 CSS 文件或者一张配置表里别人换一套皮肤不用碰引擎逻辑。这两条听起来简单做起来需要一点纪律任何时候引擎内部都不许硬编码颜色和尺寸所有表现参数都从主题配置里读。我一开始没守住这条后来想换一套深色皮肤翻了十几个文件才改完那次之后就把所有视觉常量抽成了配置。最后分享一个我用了很久的小技巧在引擎里内置一个开发者面板用快捷键比如F9呼出能看到当前脚本行号、状态机状态、所有 flag 的值、已加载资源列表和内存占用估算。开发期这个面板能省掉大量console.log上线时用构建开关把它去掉就行。说真的我做这类引擎做到第三版最大的体会不是某个技术点有多难而是可观测性太重要了——一个能直接看到内部状态的面板比任何调试技巧都管用。
阅读完成 · 觉得有帮助?