RFC1:WebGAL 5 流程控制和演出调用草案
第一次提交版本,2023年11月16日。
第二次提交版本,2023年11月17日。修改了有关演出回收管理的细节。
目标
WebGAL 新架构草案主要解决由于依赖于 React useEffect
引发的图形状态同步问题,和状态演算的缺失问题。解决了这些问题,将有助于提高实时预览的流畅性和快进的性能。
关键名称解释
当前视图状态:图形是当前视图状态的映射,所以当前正在显示的图形应当正确地反映“当前视图状态”
演算状态:在 commit 前被脚本不断修改,但不真正被作用到图形的中间状态。
指令:对引擎进行一系列操作的标记。比如 bgm
指令代表修改当前游戏正在播放的背景音乐。
语句:包含一定的指令调用信息的数据结构,不仅描述指令类型,也描述调用指令所需要的信息。
技术方案介绍
流程控制
WebGAL 的基本运行原理,就是不断地,一条一条地读取当前场景的语句,然后将语句根据语句对应的指令类型送进具体的执行函数中,从而推动游戏向前进行。
执行一条语句或一系列由 -next
连接的语句形成的序列,被称为“步进(forward)”
在执行步进时,只修改演算状态,并不将状态提交(commit)到视图。
在时机合适的时候,演算状态被 commit 到当前视图状态,当前视图状态的每个字段皆使用 setter 函数或封装好的用于修改某个状态的函数。任何值的写入都触发依赖对应状态的组件的 diff() 函数。当前视图状态的数据结构未必要与演算状态一致,但是任何子组件都必须根据新传入的数据和自己维护的内部状态判断要更新哪些内容。
说明:例如,像bgm这样的简单数据结构,diff 依赖一个 url 字符串即可(可能还有一些关于播放位置和音量的状态,但是那些都不影响 diff)而类似于立绘数组这样的复杂数据结构,则需要根据立绘的key来判断是要增加、删除立绘还是改变某个立绘的状态(改变变换或改变图像)。
用户操作步进(userFoward)、步进(forward)与步进前工作(preForward)
当用户操作步进时,并不是真的步进,而是根据情况而定:
当步进被阻塞:什么也不做。
当演出未全部结束:结束所有非持续演出,并从当前演出管理器的演出列表中移除。
当演出全部结束:调用“引擎步进”后 commit。
步进(forward) 的流程
1、检查是否可以 forward,只检查当前演出序列是否有阻塞 forward 的演出。如果阻塞则什么也不做。
2、清除当前演出序列中所有非保持演出,对所有已开始的非保持演出调用卸载函数,并移出序列。未开始的非保持演出直接移出序列。
3、开始执行一系列语句,这些语句中有一些仅仅修改演算状态,有一些可能会返回一个演出控制块,这些控制块包含了如何启动一个演出,如何卸载演出以及其他演出相关信息。演出管理器收集这些控制块。
4、这时候,forward 结束,并不 commit。只有 commit 函数调用后,才会向“当前视图状态”提交演算状态,并执行所有当前序列中的所有演出启动函数。这意味着如果 forward 不被阻塞,可以一直循环进行下去,直到一个合适的时机 commit。
语句如何被调用
任何语句调用会引发一下一种或多种效果:
1、改变演算状态(不能直接改变当前视图状态,因为 commit 时机是由其他模块控制的)
2、提交演出到当前演算状态的序列(也就是语句调用的返回值,返回 null 代表没有演出要提交)
3、在极特殊情况下,发出一个要改变 UI 的事件(比如切换电影模式的脚本,要改变 GUI 的主题,这时候需要改变 GUI 状态,而非舞台)
演算状态中的特殊字段:演出
演出的本质是什么,是一个演出控制块,这个演出控制块中最关键的信息是:
1、要调用的函数——任何演出都是一个回调函数
2、销毁演出的函数——在调用这个函数后,在整个游戏实例中,和这个演出有关的一切将荡然无存。
其他信息:演出的类型(是否保持)、演出要阻塞的操作(阻塞自动?阻塞步进?)、演出的id,演出的原始调用信息(指令类型和语句本身)
演出被 commit 的过程
1、一连串 -next
连接的语句,被定义为“同一序列”
2、同一序列的演出同时执行,演出一旦被执行,就被演出管理器收集。值得一提的是,未被 commit 的演出不会进入演出管理器。
3、如果状态演算时,进入了新的序列,即:
因为快进或实时预览等原因,在当前序列没有被 commit 的情况下,一个新的序列抵达了。
例如:
changeFigure:xxx -next;
changeBg:xxx;
changeFigure:xxx; --新的序列抵达了,但是由于一些原因,上一个序列还没有commit
这个时候,先丢弃所有的演算状态中的当前演出字段中不属于保持演出的演出控制块,然后立即开始处理新的序列。
Backlog 记录
当一个类型为对话的脚本被调用后,立即记录当前的“演算状态”和当前演出序列中的所有演出的原始调用信息(且只包括此信息,不包括演出的其他信息),即为一条 Backlog 记录。
存档
立即记录当前的“演算状态”和当前演出序列中的所有演出的原始调用信息(且只包括此信息,不包括演出的其他信息),即为一条存档。
读档和恢复 Backlog 的流程
1、清理一切演出,包括保持演出
2、将读到的状态表直接硬替换当前的演算状态
3、把读到的演出序列全部执行一遍对应的语句
4、commit
演出结束后转到下一句的实现方案
任何演出都可以通过向演出管理器发送一个事件的方式来通知演出管理器,本演出已经结束,并且本演出结束后需要步进。
在这个事件发出后,演出管理器检查当前序列的所有演出。如果当前序列没有任何演出阻塞步进,则立刻调用所有演出的卸载函数,然后步进。
Q&A
如果一直不commit,演出就一直不启动吗?
是的,只有 commit 可以启动演出。如果在 commit 之前下一个序列就到来了,就丢弃所有未 commit 的演出(保持演出除外)。因此,应当给予所有演出控制块一个“是否开始”的字段,用于判断在清理时是否要调用卸载函数(好像从理论上来说没有也没关系,但是最好有,为了双保险)
保持演出只能通过语句来清除,那么不commit也能清除吗?
是的,当清除演出的语句执行时,无论是否commit,都清除指定演出。如果这个演出没有被 commit,无非就是不执行卸载函数。
当前的演出控制块略复杂,有没有必要简化演出控制块?
有必要。原本的逻辑里还包含 isOver
,stopTimeout
这样的字段。实际上,演出时间并不是在演出开始时就可以算出。比如播放视频,在演出开始时很难确定结束时间。因此,应当修改逻辑为:当演出结束时,演出通知演出管理器自己的 ID,然后演出管理器将演出卸载,并从演出列表中清除。
是否要允许仅替换立绘图形,而不重设立绘容器的指令?
听起来是个很实用的功能,在处理立绘差分的时候很有用,应该考虑一下。这样就要求我们要直接对着 Sprite 做一个小的过渡效果了。这个过渡效果(仅限透明的修改)可以在游戏循环中实现,如果到达指定透明度或者 Sprite 不存在了,就停止。对于渐出的情况,则是当透明度为 0 的时候就清除 Sprite。
状态演算可以一直持续下去吗?
当然不可以,比如遇到选项的时候就很显然不能继续演算了,因为缺乏必要的输入条件。从定义上来说,任何阻塞步进的演出都会阻塞状态演算。