..

Nova 存档系统的设计与实现

🌴 Intro

几乎任何游戏都需要存档系统,视觉小说也不例外,但是视觉小说所需要的存档系统和其他游戏有着许多不同,下面让我们来梳理一下视觉小说的存档系统需要实现哪些功能。

  1. 能够将存档数据存储到本地并能够从本地读取存档数据
  2. 存档和读档的时间需要够短
  3. 能够在游戏中途的任意位置进行快速存档和读档
  4. 存档和读档时需要能够保存和恢复剧本中设置的局部变量
  5. 读档时需要恢复文本回顾界面的内容

🛠️ Implementation

要实现一个游戏的存档和读档的功能,前提条件是能够将游戏的状态进行序列化和反序列化。

Nova 的做法是为游戏中每一个会随着游戏进度变化的组件增加了序列化和反序列化接口,使用 json 数据和其他简单数据作为存储状态的媒介。

存档系统中存储的数据有以下几类:

  • GlobalSave:记录当前存档文件的相关信息,例如存档的时间、存档数据的存放位置等。
  • 游戏的全局变量和局部变量:例如好感度等是局部变量,而几周目、是否通关过某些结局等是全局变量。
  • GameState:当前的游戏状态,例如当前在哪条对话、当前游戏前端组件的状态等。

GlobalSave 和 游戏的变量比较好处理,但是序列化与反序列化 GameState 比较困难,尤其是当需要考虑存读档系统的性能问题时。

可恢复组件

为了解决序列化和反序列化的问题,Nova 引入了 IRestoreDataIRestorable 两个接口,分别用于可恢复数据与可恢复组件。通过 IRestoreDataIRestorable 相互配合,我们就可以将游戏状态恢复到任意的时刻。

IRestoreData 类图

classDiagram

class ISerializedData {
	~~interface~~
}

class IRestoreData {
	~~interface~~
}

IRestoreData --> ISerializedData
AutoVoiceRestoreData --> IRestoreData
AudioControllerRestoreData --> IRestoreData
LogControllerRestoreData --> IRestoreData

IRestorable 类图

classDiagram

class IRestorable {
	~~interface~~
	- string restorableName
	- GetRestoreData() IRestoreData
	- Restore(IRestoreData restoreData) void
}

AutoVoice --> IRestorable
AudioController --> IRestorable
CameraController --> IRestorable
MeshController --> IRestorable

单一组件的数据恢复问题解决了,接下来就需要解决如何让所有需要存档的组件统一存档。

计算机领域有一句戏言:“All problems in computer science can be solved by another level of indirection。"(计算机科学中的每个问题都可以用一间接层解决)。

在面对这种让数据统一进行某种操作时,我们的第一反应是将这些数据放在某处进行集中管理,事实上 Nova 也是如此做的,这个管理游戏所有数据的层级就是 GameState。

在场景初始化的时候,可恢复组件会将自身注册进 GameState.restorables,具体实现可以参考 AutoVoice 的例子。

 1// file: GameState.cs
 2private readonly Dictionary<string, IRestorable> restorables =
 3    new Dictionary<string, IRestorable>();
 4
 5public void AddRestorable(IRestorable restorable)
 6{
 7    try
 8    {
 9        restorables.Add(restorable.restorableName, restorable);
10    }
11    catch (ArgumentException e)
12    {
13        throw new ArgumentException(
14            "Nova: A restorable should have an unique and non-null name.", e
15        );
16    }
17}
18
19public void RemoveRestorable(IRestorable restorable)
20{
21    restorables.Remove(restorable.restorableName);
22}
23
24// file: AutoVoice.cs
25private void Awake()
26{
27    gameState = Utils.FindNovaController().GameState;
28
29    foreach (var config in autoVoiceConfigs)
30    {
31        var name = config.characterName;
32        nameToConfig[name] = config;
33        nameToEnabled[name] = false;
34        nameToIndex[name] = 0;
35    }
36
37    if (!string.IsNullOrEmpty(luaName))
38    {
39        LuaRuntime.Instance.BindObject(luaName, this);
40        // 将自身添加进 restorables 中
41        gameState.AddRestorable(this);
42    }
43}

Nova 自带有十六个的可恢复组件,组件的介绍如下表所示。

组件名称介绍
Asset Loader用于在运行时加载资产并管理预加载的资产
PostProcessing用于进行后处理
AutoVoice用于管理游戏中角色的自动语音播放配置
AudioController用于控制游戏中的音频播放
AvatarController用于控制角色的头像渲染
CameraController用于控制角色摄像机
MeshController用于控制MeshRender组件
PrefabLoader用于动态加载和管理游戏对象预制件
RawImageController用于控制原始图像组件
SpriteController用于控制图像组件
VideoController用于控制视频播放
OverlaySpriteController用于覆盖其他图层显示图像
GameViewInput用于管理视觉小说游戏中用户输入
DialogueBoxController用于控制文本框组件
GameViewController用于管理游戏中的 UI
LogController用于显示剧本对话记录

GameStateCheckpoint 与 CheckpointBlock

在 GameState 章节中我们知道了游戏是通过保存和恢复 GameStateCheckpoint 实现的存档与读档,而这一小结,我们需要去了解 GameStateCheckpoint 是如何被存储到硬盘上,又是如何从硬盘中读取出来。

CheckpointBlock 与 Record

查找文件数据有两种方式,一种方式是当需要某个数据时,将该数据全部读入内存中进行查找,这样是最简单的方式,但在数据量很大的情况下,将数据读入,再将数据反写回硬盘会消耗大量的时间。

另一种方式是提前算出数据存放的位置,然后只读取该数据的所在位置。这样做的速度是最快的,但需要提前规划好数据的存储方式。

Nova 所采取的就是第二种方式,框架将存档中的数据分为若干个小块,每一个小块的大小为 4KB,这个小块就被称为 CheckpointBlock。(GameStateCheckpoint 与 CheckpointBlock 是两个东西,需要注意区分)

但是只有 CheckpointBlock 对存档进行分块还是不够,我们还需要提供将数据合理写入存档的方法。

Nova 框架中,提供数据写入与读取功能的脚本叫作 CheckpointSerializer.cs,它提供了 AppendRecord 方法将数据写入到存档最末尾的 CheckpointBlock 中。

需要注意的是,不存在名叫 Record 的数据结构,只是在习惯上,我们将不定长的数据称为 Record,这与我们之后将要提到的 NodeRecord 并不是同一个事物。

以下是Nova的存档文件的数据分布示意图。

global.nsav-phy.svg

第一个CheckpointBlock 开头的 header, 用于标示该文件是 Nova 框架的存档文件,version 则标示该存档文件的版本号,id 存储当前处于哪一个 CheckpointBlock,通过它我们就可以很轻松的获得特定的 CheckpointBlock 。

例如当我们需要读取第六块 CheckpointBlock 的数据时,只需要将 id 序号与 CheckpointBlock 的大小相乘就可以得出数据的偏移量,然后读取该偏移量之后的一个 CheckpointBlock 大小的数据即可。

每次写入一条 Record,该 Record 都会在开头放置一个 int 类型的数据提示自己占据多少个字节,然后将数据放入之后的区域。当读取存档的时候,即可通过该特性来读取特定的 Record。

如果一个 Record 的数据没有办法被当前的 CheckpointBlock 完整的存储,如 Record F 所示,那么存档系统会自动扩充一个新的 CheckpointBlock,让 Record 存储完剩下的数据。而在读取的时候,存档系统也会自动判断当前 Record 的数据是否被全部读出,如果当前 CheckpointBlock 中已经没有数据了,那么存档系统将自动去下一个 CheckpointBlock 中读取剩余的数据。

Global Save Header

它包含了存储存档版本、Reached 链表和 Checkpoint 链表记录、以及全局变量信息。

Checkpoint Header

虽然 GameStateCheckpoint 中已经存储了 dialogueIndex,但是为了更方便判断当前的 GameStateCheckpoint 是我们所需要的 dialogueIndex 的 GameStateCheckpoint,在存档的时候我们选择将该 dialogueIndex 存一份放在开头。

 1// file: CheckpointManager
 2
 3// 存储 GameStateCheckpoint 时
 4public long AppendCheckpoint(int dialogueIndex, GameStateCheckpoint checkpoint)
 5{
 6    var record = globalSave.endCheckpoint;
 7
 8    // 添加 Checkpoint Header
 9    var buf = new ByteSegment(4);
10    buf.WriteInt(0, dialogueIndex);
11    serializer.AppendRecord(record, buf);
12    // 更新 globalSave.endCheckpoint
13    NewCheckpointRecord();
14
15    // 添加 GameStateCheckpoint
16    serializer.SerializeRecord(globalSave.endCheckpoint, checkpoint);
17    // 更新 globalSave.endCheckpoint
18    NewCheckpointRecord();
19    return record;
20}
21
22// 获取 CheckpointDialogue
23public int GetCheckpointDialogue(long offset)
24{
25    return serializer.GetRecord(offset).ReadInt(0);
26}

Thinks

在以上的存档系统中,由于链表的引入,数据的构成有些过于复杂了。而且因为缓存地址的不可变性,在实际的使用中几乎没有办法对于存档数据进行冗余的清理。这对于开发来说是一个不太好的事情。

因此,在后续的版本中,应该考虑设计一套更容易维护的数据结构对数据进行管理。(其中的一个核心就是需要减少数据指针的滥用,这是导致不可维护的核心原因)

如果需要去做这一块的内容。

There is nothing new under the sun.