播放器架构
为什么 Romergo Player 使用普通 DOM 运行
总体而言,视觉小说引擎不像 3D 射击游戏或即时战略游戏的引擎那样复杂。它不需要每秒计算庞大世界的物理、数百个对象的行为以及复杂的光照。但这并不意味着只要点击时切换图片就足够了。
一个常见的视觉小说场景由一组相当直观的元素组成:
- 一个或多个背景;
- 角色及其位置;
- 台词、说话者的姓名和头像;
- 视觉效果与转场;
- 音乐和音效;
- 选项或玩家的其他操作。
所有魔法都来自这些参数随时间的切换。重要的不只是显示什么,还包括何时显示、哪些内容保持不变、如何让音乐跨场景继续播放、选择哪种语言,以及玩家操作后前往哪里。
我找了很久的引擎,最后选择了浏览器
我尝试过使用 Godot、PixiJS 和 Three.js 构建场景。它们都以不同程度的成功运行起来,引擎本身并没有问题。但对于我创建 quest 的方式来说,它们增加了一层必须单独协调的结构。
我需要的不只是一个完成的游戏画面。我希望能在 Builder 中立即看到任何改动,不必构建整个游戏就能运行单个场景,直接在预览中选择和移动元素,然后在已发布的 Player 中得到完全相同的行为。
最终,最高效的方案恰恰是最简单的方案:浏览器的普通 DOM。
背景、角色和对话仍然是熟悉的浏览器图层。大部分构图和效果通过 CSS 实现。Canvas 2D 用于个别视觉元素,而只有在确实需要图片之间的着色器转场时才接入 WebGL canvas。
声音通过浏览器的 HTMLAudioElement 工作。Runtime 为当前活动的片段创建音频,控制音量和循环,并在 asset 没有变化时让同一段音乐在场景切换中继续播放。
这不是适用于所有游戏的通用配方。但对于视觉小说来说,DOM 不是妥协,而是一件非常精准的工具。
简单的场景与聪明的 runtime
我在概念上把 Player 分成两部分:
- “笨”场景只负责显示和播放收到的内容;
- “聪明”runtime 保存故事状态并决定接下来发生什么。
场景不应该知道为什么某个角色出现、哪个条件解锁了某个回答,或者下一章是什么。它接收片段、当前时间、语言、屏幕模式和媒体引用,然后渲染结果并返回玩家的操作。
Runtime 接收 quest 的描述和游玩状态。它知道当前章节、节点和场景,变量值、已经做出的选择、检查点以及已完成的章节。当玩家点击场景或选择回答时,runtime 应用效果、找到下一步,再次把准备好的状态交给场景。
高度简化后,这个循环如下:
Quest JSON + 存档
↓
runtime 确定当前步骤
↓
场景显示片段和媒体
↓
玩家操作返回 runtime
↓
进入下一步,并循环直到结局
在已发布的游戏中,Player 会加载整个 quest 的固定 runtime 快照。在 Builder 中,预览会从当前草稿构建兼容的 runtime。因此,这不是两个相似但会逐渐产生行为差异的播放器,而是同一个 RuntimePlayer、SceneStage 和 viewport 被放进不同的外壳中。
如何保持场景之间的连续性
切换时,runtime 会重新计算新场景的状态。重复出现的音乐通过 asset ID 识别,并且不会重新开始,也不会再次 fade-in。图片由浏览器加载并缓存,因此再次使用同一背景并不意味着要从网络重新下载文件。
所以,优化并不在某个巨大的“什么都不要发送”条件里,而是在正确的层级上:runtime 保持播放连续性,resolver 返回稳定资源,浏览器缓存则不会再次下载已经知道的媒体。
一个场景,两种屏幕
对我来说,最麻烦的任务不是渲染本身,而是适配手机。简单缩小横向场景会让角色太小、对话区域太拥挤,也很容易让背景中的重要细节跑到画面之外。
在 Romergo 中,一个场景有两个固定的虚拟 viewport:横向 1280 × 720 和纵向 390 × 693。Player 会根据设备和方向选择合适的模式,然后把完成的场景作为整体缩放。
Desktop 布局仍然是主要布局。手机可以单独设置角色的位置和缩放;如果没有覆盖设置,就会使用经过额外缩小的 desktop 版本。因此,作者编辑的不是两个独立场景,而是一个带有针对性移动端调整的场景。
背景的 bgX、bgY 和缩放在两种模式中共用,而纵向 viewport 会以自己的方式裁剪同一张图片。因此,发布前必须检查两个格式中的取景,并选定一个共同的视觉焦点。单独的移动端背景设置不属于 runtime 合约。
重复性的角色调整可以通过 MCP 交给 AI 代理,再在两个 viewport 的真实预览中检查。因此,我把这种适配称为半自动,而不是全自动。
如何避免让玩家看到黑屏
如果所需图片还没有通过网络到达,简单的场景也无济于事。因此,加载也成为 runtime 合约的一部分。
在第一次渲染之前,Player 会收集起始场景中的活动媒体并等待它们加载。此时玩家看到的是正常的进度指示器,而不是空白背景。启动后,runtime 会稍作等待,然后预热当前场景以及图中接下来两个步骤内可到达场景的资源。默认深度为向前两个转换步骤,并包括这两个步骤上的所有可能分支。
预加载与转场图绑定,其深度可以单独配置。这里不使用固定的场景数量:线性序列和分支即使形式上都有相同数量的后续步骤,也会产生不同的负载。
离线游玩还有另一种模式:用户可以预先下载整个 quest。这样,runtime 快照、媒体和 PWA 外壳会保留在浏览器缓存中,并能在无网络时打开。预加载下一个场景负责流畅的在线游玩,而完整下载才提供真正的 offline 模式。
Telegram 是外壳,Discord 是独立系统
在把 Player 嵌入 Telegram 和 Discord 时,我对浏览器的喜爱尤其得到了回报。两种情况下,平台内部打开的都是同一个 web runtime,因此无需为新的游戏引擎重写场景和游玩规则。
Telegram 主要需要平台会话、启动 quest、本地进度,以及在 Mini App 和普通 Player 之间跳转。游戏本身没有改变。
Discord 更复杂,因为多个人需要看到故事中的同一个进度点。每位参与者确实都运行自己的本地 runtime,但 host 是事实来源。Host 的 Player 会在状态变化时以及大约每 750 毫秒发送一次状态。Realtime 房间通过 WebSocket 接收命令、保存快照并广播给参与者。观众的 runtime 应用远程状态、恢复场景,并在两次同步之间继续进行本地计时。
这种分离带来了一个有用的结果:故事状态是共享的,但语言和屏幕尺寸仍然是本地的。一个参与者可以在手机上用中文观看场景,另一个则在大屏幕上用英语观看,两人都能与 host 保持同步。观众还可以为回答选项投票,而不会因此变成第二个 host。
所有模式共用一个基础
Player 早已超越“背景、角色和台词”这一基础组合。现在它包含分支、变量、检查点、回退、交互式界面场景、翻译、offline 以及同步游玩。
但最初的基本决定经受住了增长。场景仍然负责图像和声音,runtime 仍然负责状态和转场。Browser API 负责渲染、媒体、缓存和嵌入,专用层只在确实无法省略的地方加入。
因此,同一个 Player 可以用于 Builder preview、测试、普通浏览器游戏、Telegram 和 Discord。对我来说,这是一个很好的例子:只要正确划分职责边界,最简单、甚至有些笨拙的方案也可能成为最灵活的方案。