# 前言
本项目始于 2021 年夏,星空列车与白的旅行 (opens new window)移植项目。
迄今为止,项目已经进行了数次 UI 逻辑和核心逻辑的重构,在此编写一个日志,记录这些时间以来对视觉小说开发的经历和经验。
# UI:响应式布局?
对于视觉小说常见的 UI 布局而言,每一个按钮的大小、位置都是固定的,只需要根据屏幕大小保持比例,使用 scale 属性就能良好的实现这个效果。
不要使用依靠%
em
rem
的响应式布局构建界面,嵌套百分比会使得 CSS 难以阅读,浏览器字体不得小于 12px 的限制则最终使这个方法不能实现目的。
# 幕:概念的基石
视觉小说的基本单元是幕,舞台操作的基本单元是命令,一幕包含若干条命令,在幕结束之后,需要等待用户点击进入下一幕,而在幕结束之前再次点击则会立即结束本幕。
自动模式相当于在幕结束后代替用户进行点击操作,但是如果幕中的其他命令都已执行完毕,语音仍在播放,自动模式会等待语音播放完毕后进入下一幕,用户主动点击则会立即进入下一幕。
快进模式相当于在自动模式的基础上再代替用户点击一次,这样每一幕都会立即结束,呈现出来的就是每一幕的最终状态。
# 存档:道路的分岔口
存档是视觉小说系统的关键功能之一,要如何实现它呢?
或许可以想到快进和读档之间存在某种一致性,快进到存档位置相当于进行了读档,这种方式称为状态演算。
根据存档的字面含义,则可以想到存储当前的游戏状态,通过恢复它完成读档,这种方式称为状态存储。
状态演算从剧本第 0 幕开始,在读档过程中,命令效果可能会导致不必要的开销,状态演算需要消除这些副作用。
外部输入,如用户选择的选项,不属于剧本的一部分,使用状态演算的方式也需要存储它们,并在读档过程中填入。
状态演算的缺点在于从第 0 幕开始计算状态,剧本越长读档速度就越慢,过长的剧本需要通过拆分场景来优化性能。
状态存储需要维护一个状态表,这不能够通过简单的获取当前运行状态来实现。
在读档时需要从存档点所在幕的开头开始,所以需要某种方法获得幕开始和结束时的状态,根据状态表恢复状态也需要编写额外的代码。
状态存储的缺点在于状态表与实际舞台的运行时数据结构是分离的,每新增一个舞台元素,都需要对应的实现它在状态表中的存储和恢复。
# 命令:快进、暂停、生成器
在视觉小说中,有些动作不能快进,例如选择分支,有些动作可以快进,例如位移动画,命令执行队列需要反映这种差异,为了在步进、自动、快进的事件触发时进行正确的行为,还需要知道命令是否执行完毕,于是命令执行模式的特征被提取出来。
在状态演算的实现下,还需要关心哪些状态需要计算,哪些可以跳过计算,可以发现有些效果仅在当前幕内生效,例如对话文字,有些效果则会跨越多幕,例如背景音乐,于是命令效果作用域的特征被提取出来。
可以想到使用 Promise 来统计命令的执行时间,当命令进行一个耗时操作时,就 await 一个代表操作完毕的 Promise。
快进和暂停也是命令功能的重要部分,为了实现快进,需要将命令设置到最终状态,为了实现暂停,需要让命令停止执行后续代码。
如果能够拿到 Promise,并自己决定后续代码的执行时机就再好不过了,正巧,JavaScript 中有生成器函数可以实现这个目的。
使用生成器函数实现暂停和快进,可以避免为了统一控制 Promise 编写大量重复的代码,同时通过减少不必要的异步回调,提高了性能。
点击展开/收起代码
// Dynamic核心函数,实现了同步/异步转换
async function runGenerator<TRetrun>(
generator: Generator<Promise<unknown>, TRetrun, void>,
{ rush, stop }: { rush: Promise<unknown>, stop: Promise<unknown> }
): Promise<TRetrun | undefined> {
let flag: 'Normal' | 'Rush' | 'Stop' = 'Normal'
while (true) {
if (flag === 'Stop') return new Promise(noop)
const { value, done } = generator.next()
if (!done) {
if (flag !== 'Rush') {
flag = await Promise.race([
value.then(() => 'Normal' as const),
rush.then(() => 'Rush' as const),
stop.then(() => 'Stop' as const)
])
}
} else return value
}
}
可以进一步发现,将设置命令到最终状态的代码置于统计命令执行时间的 yield 之后是可行的。
当命令正常执行到完毕之后,这段代码对命令的执行没有影响,当触发快进时,这段代码就起到了设置命令到最终状态的作用。
点击展开/收起代码
yield sequence.finished
sequence.seek(sequence.duration)
# 组合:等价的量变与质变
视觉小说中有很多效果都是通过命令的组合实现的,而命令也有可能由更基本的命令组合实现。
在对命令进行了性质上的区分之后,定义和优化的需求也要求命令更加原子化。
对于一个命令执行队列,要做的就是统计队列中所有命令的执行情况,当所有命令都执行完毕,命令队列也就执行完毕。
命令使用 Promise 反映执行情况,命令队列通过 Promise.all 统计执行情况,最终也返回一个 Promise。
当命令执行队列与普通命令接收相同的参数,并返回相同的 Promise 时,就可以认为命令执行队列是一个特殊的,运行命令的命令。
由此,命令执行队列也获得了在队列中运行另一个命令执行队列的能力,由于每一个命令都实现了快进和暂停,命令执行队列便也实现了快进和暂停。
# 剧本:抽象的断裂与弥合
命令执行队列需要统计命令的执行情况,并且队列本身以异步方式运行,这使得命令无法像普通函数一样直接调用,而必须以某种方式提交 Promise 到队列中。
生成器函数在这时又可以发挥它的作用,通过 yield 收集 Promise,就不再需要命令使用方显式统计 Promise。
命令队列需要等待以阻塞模式运行的命令,命令内部实现决定了命令能否快进,而命令队列实际上只是 await 命令返回的 Promise。
为了在命令队列中获取命令的运行模式,命令还需要带有一些特殊标记,这些标记由命令的使用方指定,再传递到命令队列中。
既然命令队列本身不关心命令的运行模式,通过使用异步生成器函数,就可以抛去标记,使用真实的 await 在命令使用方实现阻塞。
但是,异步生成器函数的性质并没有预期的那样理想,在异步生成器函数中 yield 一个 Promise,不会将这个 Promise 作为普通值返回给命令队列,而是会阻塞的等待 Promise 解析,再将 Promise 的结果返回给命令队列。
为了解决这个问题,命令不再直接解析为一个 Promise,而是解析为一个需要 context 的异步函数,调用这个函数后命令开始运行。
由于命令队列本身也没有 context 可以提供,每个队列都会向执行它的父级队列要求 context ,直到根队列由当前幕提供 context 启动。
点击展开/收起代码
type StandardResolvedCommand<R> = Function1<GameRuntimeContext, Promise<R>>
type GameFragmentGenerator<R> = AsyncGenerator<StandardResolvedCommand<unknown>, R, unknown>
type GameFragment<R> = Function1<GameRuntimeContext, GameFragmentGenerator<R>>
function Fork<R>(fn: GameFragment<R>): StandardResolvedCommand<R> {
return async (context) => {
const generator = fn(context)
const arr = Array<Promise<unknown>>()
while (true) {
const { value, done } = await generator.next(arr.slice(-1)[0])
if (done) return Promise.all(arr).then(() => value)
else arr.push(value(context))
}
}
}
通过 yield 传入 context,命令调用方也不再需要手动为命令传入 context,但是 await 不受生成器控制,不能自动传入 context。
如果在写 yield cmd(args)
的同时还需要写 await cmd(args)(context)
,这很容易写错,而且 context 按幕更新,手动维护一个 context 变量是相当大的负担。
在当前实现中,实际上 await yield cmd(args)
与 await cmd(args)(context)
具有相同的含义,只是 TypeScript 不能在生成器函数中表达 yield 的输入类型与输出类型之间的关系,使用 yield 就只能手动指定输出类型,使得这种解决方案也不可用。
但……如果?如果使用编译器把 yield 隐去——总之它也返回与输入类型相同的类型,这样就重新获得了良好的类型提示,顺便每条命令要写一个 yield 的重复性工作也不需要了。
# 事件系统:状态机、回调注册
事件系统最初是为了在幕循环中处理外部输入而创建的,通过使用 await 对代码进行变换,可以更好的描述状态转移过程。
点击展开/收起代码
async function ActLoop(this: StarNightInstance) {
// 等待游戏开始事件
await this.GameEvents.onStart()
// 不能在开始前结束游戏
const onGameStop = this.GameEvents.onStop()
while (true) {
// 如果用户离开游戏界面,等待用户回来
if (!this.isGameVisible()) await this.GameEvents.onActiveChange()
// 开始运行命令队列
await Fork(value)(context)
if (this.state.isFast()) {
// 在快进状态下等待一段时间
await delay(this.context.config.fastreadspeed())
} else if (this.state.isAuto()) {
// 等待额外计时后再等待一段时间,之后如果还处于自动模式则resolve,否则继续等待自动事件
const onAutoNext = Promise.resolve(output.extime())
.then(() => delay(this.context.config.autoreadspeed()))
.then(() => (!this.state.isAuto() ? this.ClickEvents.onAuto() : Promise.resolve()))
// 在自动状态下等待步进、快进事件和 onAutoNext
await Promise.race([this.ClickEvents.onStep(), onAutoNext, this.ClickEvents.onFast()])
} else {
// 在普通状态下等待步进、自动、快进事件
await Promise.race([this.ClickEvents.onStep(), this.ClickEvents.onAuto(), this.ClickEvents.onFast()])
}
// 游戏实例已销毁时退出
if (await PromiseX.isSettled(onGameStop)) return
}
}
后来在实现命令的过程中,又发现需要在游戏运行的特定节点进行操作来实现某些命令行为。
由于多个命令可能共享同一个操作,并且事件总数较多,经过实践,最终也利用事件系统来实现。
通过事件系统注册事件,还可以实现优化:只有在需要某个命令时,才会引入对应的命令文件,这时命令附带的事件就会被自动注册,反之,未使用的命令不会进行不必要的事件注册。
随着各个需求的出现,对应解决需求的事件也被添加到事件系统中。
← 开发计划