Nova 源码解析

path files code comment blank total
. 312 49,225 2,859 8,490 60,574
CGInc 2 367 5 69 441
Editor 21 6,536 572 1,114 8,222
Lua 25 2,209 121 291 2,621
Settings 2 246 0 0 246
Sources 257 39,584 2,155 6,967 48,706
Sources\Core 82 8,917 887 1,706 11,510
Sources\Core\Animation 21 1,248 65 229 1,542
Sources\Core\Collections 3 268 10 55 333
Sources\Core\Input 7 731 24 119 874
Sources\Core\Restoration 18 1,755 144 330 2,229
Sources\Core\ScriptParsing 12 2,050 327 387 2,764
Sources\Core\VFX 5 415 27 85 527
Sources\Exceptions 3 30 3 6 39
Sources\Generate 1 769 1 6 776
Sources\Scripts 111 9,200 253 1,814 11,267
Sources\Scripts\Controllers 10 1,476 54 306 1,836
Sources\Scripts\Graphics 3 236 16 48 300
Sources\Scripts\SpriteCropping 3 35 1 8 44
Sources\Scripts\UI 68 6,039 159 1,173 7,371
Sources\ThirdParty 60 20,668 1,011 3,435 25,114

剧本解析器

Sources\CoreScriptParsing:2000 line

Parser

Parser首先会创建一个Tokenizer对象负责解析剧本文件的标识符,再根据标识符将剧本文件分为若干块,如提前代码块(EagerCodeBlock)、代码块(CodeBlock)、文本块(TextBlock)、分隔块(SeparatorBlock)。

解析好的块被称为ParsedScript,在游戏运行时被交给 ScriptLoader 进行执行。

graph TD;

Start --> ParseBlock
ParseBlock --> ParseEagerExectionBlock
ParseBlock --> ParseCodeBlock
ParseBlock --> ParseCodeBlockWithAttributes
ParseBlock --> CheckTokenType

CheckTokenType -->|WhiteSpace| WhiteSpace
CheckTokenType -->|NewLine or EOF| NewLineOREOF["NewLine or EOF"]
CheckTokenType -->|Not Whitespace or NewLine or EOF| ParseTextBlock

WhiteSpace --> CheckTokenType
NewLineOREOF --> AddToBlocks
ParseTextBlock --> AddToBlocks

ParseEagerExectionBlock --> ParseCodeBlock
ParseCodeBlockWithAttributes --> ParseCodeBlock

ParseCodeBlock --> AddToBlocks["Add To Blocks"]

AddToBlocks --> ReturnParsedScript["Return ParsedScript"]

FlowChartGraph

在游戏启动时,ScriptLoader 将加载并解析剧情脚本以构建 FlowChartGraph,每一个 FlowChartGraph 由多个 FlowChartNode 构成,每一个 FlowChartNode 又包含有多个 DialogueEntry,当玩家点击下一步时,其实实际上就是在不断地向前加载 DialogueEntry。

flowchart TD

FlowChartGraph --> Node1
FlowChartGraph --> Node2
FlowChartGraph --> Node3

Node1 --> Entry1
Node1 --> Entry2
Node1 --> Entry3


Node2 --> Entry4
Node2 --> Entry5
Node2 --> Entry6

Node3 --> Entry7
Node3 --> Entry8
Node3 --> Entry9

演出控制

Nova通过GameViewInput获取玩家的输入,然后顺序调用一系列管理器,最终更新界面显示。

flowchart LR

subgraph "InputCheck"
	ClickForward("GameViewInput.ClickForward()")
	--> GameViewController.Step("GameViewController.Step()")
end

InputCheck --> GameState

subgraph "GameState"
    direction TB
	GameState.Step("GameState.Step()")
	--> GameState.UpdateGameState("GameState.UpdateGameState()")
	--> GameState.ExecuteAction("GameState.ExecuteAction()")
	--> UpdateDialogue("GameState.UpdateDialogue()")
end

GameState --> DialogueEntry

subgraph "DialogueEntry"
	direction TB

	DialogueEntry.ExecuteAction("DialogueEntry.ExecuteAction()")
	--> DialogueEntry.action.Call("DialogueEntry.action.Call()")
end

VFX

Nova的图像特效,例如淡入淡出等,都是通过VFX实现的,以下我们就将对Nova的VFX系统的代码进行分析。

lua function - vfx

在影视制作中,在真人动作镜头之外创造或操纵图像的过程被称为VFX(Visual effects)。利用影片和电脑生成的图像或影像合成,来创造一个看起来真实的效果或场景。而在视觉小说中,往往是指为图像添加某些视觉效果。

Nova 作为视觉小说框架自然也提供了一些相关的功能,如果我们要为图像对象添加一些特效,可以使用 vfx 方法,以下是一个简单的示例代码,它会为当前的背景图片添加一个模糊的效果。

vfx(bg, 'lens_blur', 1, { _Size = 10 })

我们可以看一看在lua代码中,vfx 是如何实现的。

--- usage:
--- vfx(obj, 'shader_name', [t, { name = value }])
--- vfx(obj, {'shader_name', layer_id}, [t, { name = value }])
function vfx(obj, shader_layer, t, properties)
local shader_name, layer_id = parse_shader_layer(shader_layer)

--- 如果 shader_name 存在,调用 get_mat 函数获取材质、基础着色器名称和其他信息。
--- 如果 shader_name 不存在,调用 get_default_mat 函数获取对象的默认材质,并将其应用到指定层。
if shader_name then
local mat, base_shader_name, _ = get_mat(obj, shader_name)
-- 如果 t 没有提供,则时间默认为1
-- 如果 properties 没有提供,则默认为空表。
t = t or 1
properties = properties or {}

-- 如果 properties 不是 'cont',调用相关方法来设置材质的默认属性和当前属性。
if properties ~= 'cont' then
set_mat_default_properties(mat, base_shader_name, properties)
set_mat_properties(mat, base_shader_name, properties)
end

-- 为材质设置其运行时间
mat:SetFloat('_T', t)
-- 将该材质应用到对象的指定层
set_mat(obj, mat, layer_id)
else
set_mat(obj, get_default_mat(obj), layer_id)
end
end

通过上面的代码我们可以知道,vfx 的原理就是为需要特效的对象添加一个材质,再通过该材质上的着色器属性来实现视觉效果。

但是此时我们仍有许多疑问,例如 lua 究竟是如何在代码中为对象添加材质的,而着色器又是如何生效的,如果我们要创建一个自己的着色器,又应该如何做。

lua function - get_mat

首先我们需要看一下 get_mat 方法,该方法的主要作用是从附加到 GameObjectMaterialPool 中获取材质。

  1. 检查 shader_name 是否为 nil,如果是则返回 nil
  2. 设置 restorable 的默认值为 true
  3. 获取目标 GameObject 的渲染组件和后处理组件。如果两个组件都不存在,则发出警告并返回 nil
  4. 获取完整的着色器名称、基础着色器名称和变体。
  5. 确保 GameObject 有一个材质池,并从材质池中获取可恢复或普通材质。
  6. 如果找不到材质,发出警告并返回 nil。如果找到材质,则返回材质、基础着色器名称和变体。
--- get material from the MaterialPool attached to the GameObject
local function get_mat(obj, shader_name, restorable)
if shader_name == nil then
return nil
end

if restorable == nil then
restorable = true
end

--- 获取渲染组件
local go, renderer, pp = get_renderer_pp(obj)
if renderer == nil and pp == nil then
warn('Cannot find SpriteRenderer or Image or RawImage or PostProcessing for ' .. dump(obj))
return nil
end

--- 获取完整的着色器名称
local full_shader_name, base_shader_name, variant = get_full_shader_name(shader_name, obj, pp)

--- 调用 Nova.MaterialPool.Ensure 函数,确保 GameObject 有一个MaterialPool,并返回材质池。
local pool = Nova.MaterialPool.Ensure(go)

-- 获取材质
local mat
if restorable then
mat = pool:GetRestorableMaterial(full_shader_name)
else
mat = pool:Get(full_shader_name)
end

if mat == nil then
warn('Cannot find material: ' .. shader_name)
return nil
end

return mat, base_shader_name, variant
end

lua function - set_mat

set_mat 用于设置对象的材质(material)。它接受四个参数:obj(目标对象)、mat(材质)、layer_id(层ID,默认值为0)、token(令牌,默认值为-1)。

local function set_mat(obj, mat, layer_id, token)
layer_id = layer_id or 0
token = token or -1

-- 获取目标对象的渲染器(renderer)和后处理组件(pp)
local go, renderer, pp = get_renderer_pp(obj)

--- 设置材质给后处理组件
if renderer then
if layer_id ~= 0 then
warn('layer_id should be 0 for SpriteRenderer or Image or RawImage')
end
renderer.material = mat
return -1
end

--- 如果存在后处理组件(pp)
if pp then
if mat then
return pp:SetLayer(layer_id, mat)
else
pp:ClearLayer(layer_id, token)
return -1
end
end

-- 如果存在 FadeController 组件则提示开发者不能设置 FadeController 的材质
local fade = go:GetComponent(typeof(Nova.FadeController))
if fade then
warn('Cannot set material for FadeController ' .. dump(obj))
return -1
end

warn('Cannot find SpriteRenderer or Image or RawImage or PostProcessing for ' .. dump(obj))
return -1
end

lua function - trans2

trans2 方法由 action_begin、action_middle、action_end 三部分组成,并最终用动画系统将这三部分组合在一起。

--- sprite transition using a shader that hides the texture
--- the shader should implement _MainTex and _T
--- range of _T is (0, 1), _T = 0 shows _MainTex, _T = 1 hides _MainTex
--- usage:
--- trans2(obj, 'image_name', 'shader_name', [duration, { name = value }, duration2, { name = value }, {r, g, b, [a]}])
make_anim_method('trans2', function(self, obj, image_name, shader_layer, times, properties, times2, properties2, color2)
local shader_name, layer_id = parse_shader_layer(shader_layer, cam_trans_layer_id)
-- mat is not RestorableMaterial
local mat, base_shader_name, _ = get_mat(obj, shader_name, false)
local duration, easing = parse_times(times)
properties = properties or {}
local duration2, easing2 = parse_times(times2)
properties2 = properties2 or {}

local action_begin, action_middle, action_end, token
if obj:GetType() == typeof(Nova.CameraController) then
action_begin = function()
set_mat_default_properties(mat, base_shader_name, properties)
set_mat_properties(mat, base_shader_name, properties)
mat:SetFloat('_T', 0)
token = set_mat(obj, mat, layer_id)
end

action_middle = function()
if image_name then
auto_fade_off()
local func = image_name
func()
auto_fade_on()
end

set_mat_properties(mat, properties2)
end

action_end = function()
set_mat(obj, get_default_mat(obj), layer_id, token)
end

else
action_begin = function()
set_mat_default_properties(mat, base_shader_name, properties)
set_mat_properties(mat, base_shader_name, properties)
mat:SetFloat('_T', 0)
token = set_mat(obj, mat)
end

action_middle = function()
if image_name then
show_no_fade(obj, image_name, nil, color2)
end

set_mat_properties(mat, properties2)
end

action_end = function()
set_mat(obj, get_default_mat(obj), nil, token)
end
end

local entry = self:action(action_begin
):_then(Nova.MaterialFloatAnimationProperty(mat, '_T', 1)):_with(easing):_for(duration
):action(action_middle
):_then(Nova.MaterialFloatAnimationProperty(mat, '_T', 0)):_with(easing2):_for(duration2
):action(action_end)
entry.head = self
return entry
end, add_preload_pattern)

MaterialFactory

MaterialFactory 用于创建和管理 Unity 中的 Material 对象以及自定义的 RestorableMaterial 对象。lua 代码中的 get_mat 所使用的 Get 方法其实就是调用的该类中的方法。

其实现原理非常简单,从 Resources 中寻找文件名对应的 Shader,使用该 Shader 创建一个材质,最后返回。

public sealed class MaterialFactory : IDisposable
{
private readonly Dictionary<string, Material> materials;
private readonly Dictionary<string, RestorableMaterial> restorableMaterials;

public MaterialFactory()
{
materials = new Dictionary<string, Material>();
restorableMaterials = new Dictionary<string, RestorableMaterial>();
}

public Material Get(string shaderName)
{
if (materials.TryGetValue(shaderName, out var mat)) return mat;

var shader = Shader.Find(shaderName);
if (shader == null)
throw new ArgumentException($"Nova: Shader not found: {shaderName}");

mat = new Material(shader)
{
name = string.Format("Nova - {0}",
shaderName.Substring(shaderName.IndexOf("/", StringComparison.Ordinal) + 1)),
hideFlags = HideFlags.DontSave
};

materials.Add(shaderName, mat);
return mat;
}

public RestorableMaterial GetRestorableMaterial(string shaderName)
{
if (restorableMaterials.TryGetValue(shaderName, out var resMat)) return resMat;

var shader = Shader.Find(shaderName);
if (shader == null)
throw new ArgumentException($"Nova: Shader not found: {shaderName}");

resMat = new RestorableMaterial(shader)
{
name = string.Format("Nova:Restorable - {0}",
shaderName.Substring(shaderName.IndexOf("/", StringComparison.Ordinal) + 1)),
hideFlags = HideFlags.DontSave
};

restorableMaterials.Add(shaderName, resMat);
return resMat;
}

public void Dispose()
{
foreach (var m in materials.Values)
{
Utils.DestroyObject(m);
}

materials.Clear();

foreach (var m in restorableMaterials.Values)
{
Utils.DestroyObject(m);
}

restorableMaterials.Clear();
}
}

MaterialPool

MaterialPool 使用 MaterialFactory 实例来管理材质对象,旨在与 Unity 引擎中的游戏对象一起使用,提供一种简化的材质管理方式。

[ExportCustomType]
public class MaterialPool : MonoBehaviour
{
// Keep Renderer's default material, used when turning off VFX on the Renderer
// defaultMaterial is null for PostProcessing
private Material _defaultMaterial;

public Material defaultMaterial
{
get => _defaultMaterial;
set
{
if (_defaultMaterial == value)
{
return;
}

Utils.DestroyObject(_defaultMaterial);
_defaultMaterial = value;
}
}

private void Awake()
{
if (TryGetComponent<Renderer>(out var renderer))
{
defaultMaterial = renderer.material;
}
}

private void OnDestroy()
{
defaultMaterial = null;
factory.Dispose();
}

public readonly MaterialFactory factory = new MaterialFactory();

public Material Get(string shaderName)
{
return factory.Get(shaderName);
}

public RestorableMaterial GetRestorableMaterial(string shaderName)
{
return factory.GetRestorableMaterial(shaderName);
}

// Export to Lua
public static MaterialPool Ensure(GameObject gameObject)
{
return gameObject.Ensure<MaterialPool>();
}
}

多语言支持

Nova 的 I18n 可以分为 UI 的 I18n 与剧本的 I18n,需要分别进行讨论。

I18n UI

I18nText & I18nImage

UI组件的I18n支持比较简单,在初始化或者I18n更改事件发生时,通过 inflateKey 读取多语言文件中的对应的 value 并更新显示。

flowchart TD

I18nComponentInit("I18n Component Init")

-->|Get I18n Res with __ function| I18n

subgraph I18n
LoadTranslationBundles("Load I18n Res")
--> GetCurrentLanguage("Get Current Language")
--> ReturnInflateResources("Return Inflate Key Res")
end

I18n --> UpdateI18nComponent("I18nXXX.UpdateXXX")

I18n Scenarios

在Nova中,剧本的多语言采用的是逐行对照的方式,当需要加载某个语言的多语言设置时,系统将尝试读取对应语言的对应章节的对应行。

动画系统

动画系统有 c# 代码中的部分和 lua 中的部分,c# 中主要依靠NovaAnimation与AnimationEntry 两个组件进行控制,而animation.lua 中的内容,主要是对 c# 中的代码进行一层包装,为了方便处理一些参数的默认值。

make_anim_method 它会把一个函数添加到 NovaAnimationAnimationEntrymetatable,这样就可以当method来用。

我们在演出脚本里写 anim:move(xxx) 的时候,其实 animNovaAnimation 的一个实例,move 是它的method,返回的是一个AnimationEntry,所以写anim:move(xxx):tint(xxx)的时候,tint就是AnimationEntrymethod

Nova要保证move之类的函数不管前面是NovaAnimation还是AnimationEntry都能用,所以写了一个make_anim_method 来把它同时添加到两个类

输入系统

存档系统

Nova Wiki: Restoration

global.nsav

为了实现存档和读档的速度,Nova的存档系统将 global.nsav 按照固定长度分块,一个块被称为一个 CheckpointBlock,默认为4kB。

这里需要区分 CheckpointBlockGameStateCheckpoint,前一个是存档文件的分块,后一个是用于恢复游戏状态的数据。

相似的还有 RecordNodeRecord,前一个是数据存储进存档系统的基本单位,后一个是标识剧情进度的数据。

为了避免每次读取数据都从global.nsav文件中读取,Nova的存档系统引入了 LRUCache 机制。(它是一种常见的缓存淘汰策略,在缓存空间有限时,优先淘汰最近最少使用的数据)

// CheckpointSerializer GetBlock
private CheckpointBlock GetBlock(long id)
{
if (!cachedBlocks.TryGetValue(id, out var block))
{
block = CheckpointBlock.FromFile(file, id);
cachedBlocks[id] = block;
}

return block;
}

// CheckpointSerializer AppendBlock
private CheckpointBlock AppendBlock()
{
var id = endBlock++;
var block = new CheckpointBlock(file, id);
cachedBlocks[id] = block;
return block;
}

global.nsav 包含三种类型的数据:

  1. global save 里面记录了存档时间标识和 Reached 数据以及 Checkpoint 数据在文件中的位置索引。
  2. Reached链表:存储所有已达剧情,ReachedEndDataReachedDialogueData
    1. ReachedDialogueData:一条已读对话的信息,包括该对话的语音信息和文本是否需要插值。
    2. ReachedEndData一个已读结局的名称。
  3. Checkpoint链表:存储游戏历史中的所有状态,例如游戏中的音频大小、文本的位置和内容。

global.nsav的文件结构大致如下图所示。

global.nsav

可以参照CheckpointBlock的Flush方法的实现来理解。

// CheckpointBlock Flush
public void Flush()
{
if (!dirty || stream == null)
{
return;
}

var index = 0;
if (id == 0)
{
var version = BitConverter.GetBytes(CheckpointSerializer.Version);
var header = CheckpointSerializer.FileHeader;
Buffer.BlockCopy(header, 0, data, 0, header.Length);
Buffer.BlockCopy(version, 0, data, header.Length, 4);
index += CheckpointSerializer.FileHeaderSize;
}

var x = BitConverter.GetBytes(_nextBlock);
Buffer.BlockCopy(x, 0, data, index, HeaderSize);
stream.Seek(offset, SeekOrigin.Begin);
stream.Write(data, 0, BlockSize);
dirty = false;
}

CheckpointBlock 类图

classDiagram

class CheckpointBlock {
+ BlockSize : int
+ HeaderSize : int
+ DataSize : int
$ GetBlockID(offset : long) long
$ GetBlockIDIndex(offset : long, out int index) long
+ id : long
- offset : long
+ dataOffset : long
+ nextOffset : long
+ segment : ByteSegment
- dirty : bool
- stream : Stream
- data : byte[]
$ FromFile(Stream stream, long id) CheckpointBlock
+ MarkDirty() void
+ Flush() void
+ Dispose()
}

如何向global.nsav中添加记录

AppendRecord 会首先在 Record的开头标记数据的大小,然后再将数据写入。

如果当前CheckpointBlock无法完全容纳数据,则剩余的数据将被写入下一个CheckpointBlock中。

public void AppendRecord(long offset, ByteSegment bytes)
{
var block = GetBlockIndex(offset, out var index);
var segment = block.segment;
if (index + RecordHeader > segment.Count)
{
throw CheckpointCorruptedException.RecordOverflow(offset);
}

segment.WriteInt(index, bytes.Count);
index += RecordHeader;

var pos = 0;
while (pos < bytes.Count)
{
var size = Math.Min(segment.Count - index, bytes.Count - pos);
segment.WriteBytes(index, bytes.Slice(pos, size));
block.MarkDirty();
pos += size;
if (pos < bytes.Count)
{
block = NextBlock(block);
segment = block.segment;
index = 0;
}
}
}

ByteSegment

classDiagram

class ByteSegment {
    - array: byte[]
    %% 表示字节片段的起始位置在数组中的偏移量。
    - offset: int
    %% 表示字节片段的长度
    + Count: int
    + Slice(offset: int, count: int) ByteSegment
    + Slice(offset: int) ByteSegment
    + ToStream() MemoryStream
    + ReadInt(offset: int) int
    + ReadLong(offset: int) long
    + ReadUlong(offset: int) ulong
    + WriteInt(offset: int, value: int) void
    + WriteLong(offset: int, value: long) void
    + WriteUlong(offset: int, value: ulong) void
    + ReadBytes(offset: int, bytes: byte[]) void
    + ReadBytes(offset: int, bytes: ByteSegment) void
    + ReadString(offset: int, count: int) string
    + ReadString(offset: int) string
    + WriteBytes(offset: int, bytes: byte[]) void
    + WriteBytes(offset: int, bytes: ByteSegment) void
    + WriteString(offset: int, str: string) void
}

CheckpointManager & CheckpointSerializer

存储数据的初始化由 CheckpointManager 类负责。在这里,会初始化GlobalSaveReachedEndDataReachedDialogueData

// CheckpointManager Init
public void Init()
{
// ......
serializer = new CheckpointSerializer(globalSavePath);
if (!File.Exists(globalSavePath))
{
ResetGlobalSave();
}
else
{
serializer.Open();
InitGlobalSave();
InitReached();
}

foreach (var fileName in Directory.GetFiles(savePathBase, "sav*.sav*"))
{
var result = Regex.Match(fileName, @"sav([0-9]+)\.sav");
if (result.Groups.Count > 1 && int.TryParse(result.Groups[1].Value, out int id))
{
bookmarksMetadata.Add(id, new BookmarkMetadata
{
saveID = id,
modifiedTime = File.GetLastWriteTime(fileName)
});
}
}

inited = true;
}

private void InitGlobalSave()
{
globalSave = serializer.DeserializeRecord<GlobalSave>(CheckpointSerializer.GlobalSaveOffset);
}

private void InitReached()
{
reachedDialogues.Clear();
reachedEnds.Clear();
for (var cur = globalSave.beginReached;
cur < globalSave.endReached;
cur = serializer.NextRecord(cur))
{
var record = serializer.DeserializeRecord<IReachedData>(cur);
if (record is ReachedEndData end)
{
reachedEnds.Add(end.endName);
}
else if (record is ReachedDialogueData dialogue)
{
SetReachedDialogueData(dialogue);
}
else if (record is NodeUpgradeMaker maker)
{
reachedDialogues.Remove(maker.nodeName);
}
}

// check each reached data is a prefix
foreach (var reachedList in reachedDialogues)
{
if (reachedList.Value.Contains(null))
{
throw CheckpointCorruptedException.BadReachedData(reachedList.Key);
}
}
}

CheckpointSerializer 类是用于序列化和反序列化游戏数据的关键部分。

classDiagram

class ISerializationBinder {
    <<interface>>
    + BindToType(string? assemblyName, string typeName) Type
    + BindToName(Type serializedType, out string? assemblyName, out string? typeName) void
}

JsonTypeBinder --|> ISerializationBinder
class JsonTypeBinder {
    $ Assembly CurAssembly
    $ HashSet~Assembly~ AllowedAssembly
    $ IsPrimitiveType(Type serializedType, bool checkAssembly = true) bool
    $ IsAllowedAssembly(Type serializedType) bool
    - IsGameType(Type serializedType) bool
    - IsAllowedType(Type serializedType) bool
    + BindToName(Type serializedType, out string assemblyName, out string typeName) void
    + BindToType(string assemblyName, string typeName) Type
}
CheckpointJsonSerializer *-- JsonTypeBinder
CheckpointJsonSerializer --> JsonSerializer

CheckpointSerializer *-- CheckpointJsonSerializer
class CheckpointSerializer {
    + Version : int
    %% 存档文件头的字节数据和大小。
    + FileHeader : byte[]
    + FileHeaderSize : int

    + GlobalSaveOffset : int
    - DefaultCompress : bool
    - RecordHeader : int
    %% 一个 CheckpointJsonSerializer 实例,用于 JSON 序列化和反序列化存档数据。
    - jsonSerializer : JsonSerializer
    - path : string
    %% FileStream 实例,用于操作存档文件。
    - file : FileStream
    %% 存档中最后一个块的编号。
    - endBlock : long
    %% 一个 LRUCache,用于缓存存档块数据,提高读取性能。
    - cachedBlocks : LRUCache~long, CheckpointBlock~
    + Open()
    + Dispose()
    - GetBlock(long id) CheckpointBlock
    - GetBlockIndex(long offset, out int index) CheckpointBlock
    + GetRecord(long offset) ByteSegment
    + GetNodeRecord(long offset) NodeRecord
    - AppendBlock() CheckpointBlock
    - NextBlock(CheckpointBlock block) CheckpointBlock
    + BeginRecord() long
    + NextRecord(long offset) long
    + AppendRecord(long offset, ByteSegment bytes) void
    + UpdateNodeRecord(NodeRecord record) void
    + SerializeRecord~T~(long offset, T data, bool compress = DefaultCompress) void
    + DeserializeRecord~T~(long offset, bool compress = DefaultCompress) T
    + ReadBookmark(string path, bool compress = DefaultCompress) Bookmark
    + WriteBookmark(string path, Bookmark obj, bool compress = DefaultCompress) void
}
classDiagram

class CheckpointManager {
    - saveFolder : string

    - frozen : bool

    - savePathBase : string
    - globalSavePath : string
    - backupPath : string

    - globalSave : GlobalSave
    - globalSaveDirty : bool

    - reachedDialogues : Dictionary&lt; string,List~ReachedDialogueData~ &gt;
    - reachedEnds : SerializableHashSet~string~
    - cachedBookmarks : Dictionary~int, Bookmark~
    + bookmarksMetadata : Dictionary~int, BookmarkMetadata~

    - serializer : CheckpointSerializer

    - inited : bool

    + Init() void
    + NextRecord() long
    + InitReached() void
    + NewReached() void
    + SetReachedDialogueData(data : ReachedDialogueData) void
    + AppendReachedRecord(data : IReachedData) void
    + SetReached(data : ReachedDialogueData)
    + SetEndReached(endName : string)
    + IsReachedAnyHistory(string nodeName, int dialogueIndex) bool
    + GetReachedDialogueData(string nodeName, int dialogueIndex) ReachedDialogueData
    + IsEndReached(string endName) bool
    + InvalidateReachedData(string nodeName) void

    + beginNodeOffset : long
    - NewCheckpointRecord() void
    + NextCheckpoint(long offset) long
    + GetNextNode(NodeRecord prevRecord, string name, Variables variables, int beginDialogue) NodeRecord
    + GetNodeRecord(long offset) NodeRecord
    + CanAppendCheckpoint(long checkpointOffset) bool
    + AppendDialogue(NodeRecord nodeRecord, int dialogueIndex, bool shouldSaveCheckpoint) void
    + AppendCheckpoint(int dialogueIndex, GameStateCheckpoint checkpoint) long
    + GetCheckpointDialogue(long offset) int
    + GetCheckpoint(long offset) GameStateCheckpoint

    %% 这里是CheckpointUpgrade,和核心系统关系不大,暂时省略

    - InitGlobalSave() void
    - UpdateGlobalSave() void
    - ResetGlobalSave() void
    + BackupGlobalSave() void
    + RestoreGlobalSave() void

    - GetBookmarkFileName(int saveID) string
    - ReplaceCache(int saveID, Bookmark bookmark) Bookmark
    + SaveBookmark(int saveID, Bookmark bookmark, bool cache = true) void
    + LoadBookmark(int saveID, bool cache = true) Bookmark
    + DeleteBookmark(int saveID) void
    + EagerLoadRange(int beginSaveID, int endSaveID) void
}

Bookmark

存档/读档界面中的每个存档称为书签(Bookmark),记录nodeOffset、checkpointOffset、dialogueIndex

自动存档、快速存档与手动存档的格式是一样的

classDiagram

class Bookmark {
    + ScreenshotWidth : int
    + ScreenshotHeight : int
    + nodeOffset : long
    + checkpointOffset : long
    + dialogueIndex : int
    + description : DialogueDisplayData
    + creationTime : DateTime
    + globalSaveIdentifier : long
    + screenshotBytes : byte[]
    + screenshotTexture : Texture2D
    + screenshot : Texture2D
}

NodeRecord

classDiagram

class NodeRecord {
    - HeaderSize : int
    + offset : long
    + child : long
    + sibling : long
    + beginDialogue : int
    + endDialogue : int
    + lastCheckpointDialogue : int
    + variablesHash : ulong
    + name : string
    + ToByteSegment() ByteSegment
}

RestoreData and SerializedData

GameStateCheckpoint

该类存储游戏对象下所有已注册可还原器在某一步的所有还原信息。

由于脚本语法的设计,只有在运行时才能知道每一步的对象状态。

为了实现后退功能,游戏状态对象应知道每一步的所有 GameStateRestoreEntry,以便执行后退。为使后退功能在从检查点加载后仍能正常工作,CheckpointManager 应存储所有走过的对话框的 GameStateRestoreEntry。

classDiagram

class ISerializedData {
    <<interface>>
}

IRestoreData --|> ISerializedData
class IRestoreData {
    <<interface>>
}

IReachedData --|> ISerializedData
class IReachedData {
    <<interface>>
}

GameStateCheckpoint --> ISerializedData
class GameStateCheckpoint {
    + dialogueIndex : int
    + stepsCheckpointRestrained : int
    + restoreDatas : Dictionary~string, IRestoreData~
    + variables : Variables
}

ReachedEndData --> IReachedData
class ReachedEndData {
    + endName: string
}

ReachedDialogueData --> IReachedData
class ReachedDialogueData {
    + nodeName : string
    + dialogueIndex : int
    + voices : VoiceEntries
    + needInterpolate: bool
    + textHash : string
}


class ReachedDialoguePosition {
    + nodeRecord : NodeRecord
    + checkpointOffset : long
    + dialogueIndex : int
}
classDiagram

class GlobalSave {
    + identifier : long
    + beginReached : long
    + endReached : long
    + beginCheckpoint : long
    + endCheckpoint : long
    + nodeHashes : Dictionary~string, ulong~
    + data : Dictionary~string, object~
}

IRestorable

IRestorable 接口代表一个对象,当 GameState 向后移动时,该对象会恢复其状态。

classDiagram

class IRestorable {
    + restorableName : string
    + GetRestoreData() IRestoreData
    + Restore(restoreData : IRestoreData) void
}

GameState

GameState 记录了AVG的所有状态,因此只要将游戏的 GameState 回退到某个时刻,就能实现游戏的进度回退。

classDiagram

class GameState {
    - scriptPath : string
    - scriptLoader : ScriptLoader
    - flowChartGraph : FlowChartGraph
    - checkpointManager : CheckpointManager
    - initialCheckpoint : GameStateCheckpoint
    - advancedDialogueHelper : AdvancedDialogueHelper
    - coroutineHelper : CoroutineHelper
    - nodeRecord : NodeRecord
    - checkpointOffset : long
    - currentNode : FlowChartNode
    - currentIndex : int
    - currentDialogueEntry : DialogueEntry
    - variables : Variables
    - state : State
    - isEnded : bool
    + ResetGameState() void
    + gameStarted : UnityEvent
    + nodeChanged : NodeChangedEvent
    + dialogueWillChange : UnityEvent
    + dialogueChangedEarly : DialogueChangedEvent
    + dialogueChanged : DialogueChangedEvent
    + selectionOccurs : SelectionOccursEvent
    + routeEnded : RouteEndedEvent
    + restoreStarts : UnityEvent
    - currentVoices Dictionary~string, VoiceEntry~
    + AddVoice(string characterName, VoiceEntry voiceEntry) void
    - actionCoroutine : Coroutine
    - ExecuteAction(IEnumerator coroutine) void
    - CancelAction() void
    - ResetActionContext() void
    - actionPauseLock : CounterLock
    + AcquireActionPause() void
    + ReleaseActionPause() void
    - variablesHashBeforeInterrupt : ulong
    + StartInterrupt() void
    + StopInterrupt() void
    + SignalFence(object value) void
    - UpdateGameState(bool nodeChanged, bool dialogueChanged, bool firstEntryOfNode, bool dialogueStepped, bool fromCheckpoint) void
    - UpdateDialogue(bool firstEntryOfNode, bool dialogueStepped, bool fromCheckpoint) IEnumerator
    - StepCheckpoint(bool isReached) void
    - DialogueSaveCheckpoint(bool firstEntryOfNode, bool dialogueStepped) bool
    - DialogueSaveReachedData(out ReachedDialogueData dialogueData) bool
    - StepAtEndOfNode() void
    - AppendSameNode() void
    - MoveToNextNode(FlowChartNode nextNode) void
    - DoBranch(IEnumerable<BranchInformation> branchInfos) IEnumerator
    - SelectBranch(string branchName) void
    - SaveInitialCheckpoint() void
    - GameStart(FlowChartNode startNode) void
    + GameStart(string nodeName) void
    + GetNode(string name, bool addDeferred = true) FlowChartNode
    + GetStartNodeNames(StartNodeType type = StartNodeType.Normal) IEnumerable~string~
    + canStepForward : bool
    + Step() void
    + RaiseSelections(IReadOnlyList<SelectionOccursData.Selection> selections) void
    - Dictionary~string, IRestorable~ restorables
    + AddRestorable(IRestorable restorable) void
    + RemoveRestorable(IRestorable restorable) void

    %% 并非所有对象的状态都能轻易恢复,比如保持动画。
    %% 我们会存储一些检查点,其他状态可以通过从上一个检查点重新执行来恢复。
    %% 从最后一个检查点开始每隔 maxStepsFromLastCheckpoint 至少会保存一个检查点,保持动画期间除外。
    - maxStepsFromLastCheckpoint : int
    + WarningStepsFromLastCheckpoint : int
    - stepsFromLastCheckpoint : int
    - stepsCheckpointRestrained : int
    - checkpointRestrained : bool

    + RestrainCheckpoint(int steps, bool overridden = false) void
    %% 用于在保持动画开始前强制保存检查点。
    - bool checkpointEnsured
    %% 在使用 anim_hold_begin 时由预载系统使用
    %% 当前对话是否有检查点是在 Lua 代码运行之前决定的、所以我们只能在下一次对话中确保它
    + EnsureCheckpointOnNextDialogue() void

    - appendNodeEnsured : bool
    - atEndOfNodeRecord : bool
    - shouldSaveCheckpoint : bool
    - GetCheckpoint() GameStateCheckpoint
    - RestoreCheckpoint(GameStateCheckpoint entry) void
    - SeekBackStep(int steps, IList~NodeRecord~ nodeHistory, out long newCheckpointOffset, out int newDialogueIndex) void
    - SeekBackStep(int steps, out NodeRecord nodeRecord, out long newCheckpointOffset, out int newDialogueIndex) void
    + isUpgrading : bool
    + isRestoring : bool
    - CheckUnlockInRestoring() bool
    - FastForward(int stepCount) void
    - Move(NodeRecord newNodeRecord, long newCheckpointOffset, int dialogueIndex, bool upgrade) void
    + MoveBackTo(NodeRecord newNodeRecord, long newCheckpointOffset, int dialogueIndex) void
    + MoveToUpgrade(NodeRecord newNodeRecord, int lastDialogue) void
    + MoveBackToFirstDialogue() void
    %% Move to previous/next chapter/branch.
    + MoveToKeyPoint(bool forward, bool allowChapter, bool allowBranch = true) void
    + GetDialogueHistory(int limit = 0) IEnumerable~ReachedDialoguePosition~
    + GetBookmark() Bookmark
    + LoadBookmark(Bookmark bookmark) void
}