..

Nova GameState 与游戏执行流程

Intro

视觉小说这种游戏类型和动画、漫画很相似,我们都可以很轻松的将它们分为若干个最小的单元片段。动画的最小单位是帧、漫画的最小单位是画格,而视觉小说的最小单位就是一次游戏状态,以下我们将之称为 GameState。

GameState 的作用如下:

  1. 管理游戏中所有可恢复组件
  2. 管理游戏的状态
  3. 控制游戏的进度

基于 GameState 在系统中的作用,我们的存档和读档功能的实现也依赖于它。

如何创建 GameState

游戏状态由当前剧本的行号、当前各可恢复组件的状态、当前游戏所存储的变量以及步骤限制检查点组成。

之所以需要步骤限制检查点,是为了避免读档在某些难以恢复的时刻,从而导致奇怪的难以追溯的错误。

举例来说,若存档的位置在某次动画演出的途中,那么存档的数据就很难被简单的复原。因此为了读档后演出能够正常进行,就需要将读档的实际位置回溯到动画过场之前。

步骤限制检查点所提供的就是动画过场之前的演出脚本的行号。

将这些决定了游戏状态的数据打包在一起,就构成了一个 GameStateCheckpoint。

每一个 GameStateCheckpoint 都记录了当前剧本的行号、当前各可恢复组件的状态、当前游戏所存储的变量以及步骤限制检查点。以下是 GameStateCheckpoint 的实现以及创建和恢复 GameStateCheckpoint 的方法。

 1// file: GameState.cs
 2
 3public class GameStateCheckpoint : ISerializedData
 4{
 5    public int dialogueIndex;
 6    public readonly int stepsCheckpointRestrained;
 7    public readonly Dictionary<string, IRestoreData> restoreDatas;
 8    public readonly Variables variables;
 9
10    public GameStateCheckpoint(int dialogueIndex, Dictionary<string, IRestoreData> restoreDatas,
11        Variables variables, int stepsCheckpointRestrained)
12    {
13        this.dialogueIndex = dialogueIndex;
14        this.stepsCheckpointRestrained = stepsCheckpointRestrained;
15        this.restoreDatas = restoreDatas;
16        this.variables = new Variables();
17        this.variables.CloneFrom(variables);
18    }
19}
20
21/// <summary>
22/// Get the current game state as a checkpoint
23/// </summary>
24private GameStateCheckpoint GetCheckpoint()
25{
26    var restoreDatas = new Dictionary<string, IRestoreData>();
27    foreach (var restorable in restorables)
28    {
29        restoreDatas[restorable.Key] = restorable.Value.GetRestoreData();
30    }
31
32    return new GameStateCheckpoint(currentIndex, restoreDatas, variables, stepsCheckpointRestrained);
33}
34
35private void RestoreCheckpoint(GameStateCheckpoint entry)
36{
37    this.RuntimeAssert(entry != null, "Checkpoint is null.");
38    restoreStarts.Invoke(entry == initialCheckpoint);
39
40    currentIndex = entry.dialogueIndex;
41    stepsFromLastCheckpoint = 0;
42    stepsCheckpointRestrained = entry.stepsCheckpointRestrained;
43    checkpointEnsured = false;
44
45    variables.CloneFrom(entry.variables);
46
47    var pairs = restorables.OrderByDescending(x =>
48        (x.Value as IPrioritizedRestorable)?.priority ?? RestorablePriority.Normal);
49    foreach (var pair in pairs)
50    {
51        if (entry.restoreDatas.TryGetValue(pair.Key, out var data))
52        {
53            pair.Value.Restore(data);
54        }
55        else
56        {
57            // fallback to initialCheckpoint state
58            pair.Value.Restore(initialCheckpoint.restoreDatas[pair.Key]);
59        }
60    }
61}

GameState 如何推进进度

游戏状态的推进同样是由 GameState 进行管理的,由 Step() 方法进行控制。

若当前剧本没有到达末尾,则继续向前推进。若到达了末尾,则使用 StepAtEndOfNode() 方法判断该如何处理。

 1public void Step()
 2{
 3    if (!canStepForward)
 4    {
 5        return;
 6    }
 7
 8    // If the next dialogue entry is in the current node, directly step to it
 9    if (currentIndex + 1 < currentNode.dialogueEntryCount)
10    {
11        ++currentIndex;
12        UpdateGameState(false, false);
13    }
14    else
15    {
16        StepAtEndOfNode();
17    }
18}

在调用了 Step() 方法确定向前推进游戏状态后,UpdateGameState() 方法负责实际更新游戏状态。

 1private void UpdateGameState(bool fromCheckpoint, bool nodeChanged)
 2{
 3    if (nodeChanged)
 4    {
 5        // Debug.Log($"Node changed to {currentNode.name}");
 6
 7        this.nodeChanged.Invoke(new NodeChangedData(nodeRecord.name));
 8
 9        // Always get a checkpoint at the beginning of the node
10        checkpointEnsured = true;
11    }
12
13    if (currentNode.dialogueEntryCount > 0)
14    {
15        currentDialogueEntry = currentNode.GetDialogueEntryAt(currentIndex);
16        ExecuteAction(UpdateDialogue(fromCheckpoint, nodeChanged));
17    }
18    else
19    {
20        StepAtEndOfNode();
21    }
22}
23
24private IEnumerator UpdateDialogue(bool fromCheckpoint, bool nodeChanged)
25{
26    if (!fromCheckpoint)
27    {
28        // 如果将下面几行代码放入一个新的例行程序将引入一帧的延迟,所以不要这样做
29        currentDialogueEntry.ExecuteAction(DialogueActionStage.BeforeCheckpoint, isRestoring);
30        while (actionPauseLock.isLocked) yield return null;
31    }
32
33    var isReached = currentIndex < nodeRecord.endDialogue;
34    DialogueSaveCheckpoint(nodeChanged, isReached);
35    // 通知对话即将更改。例如角色控制器就会停止播放语音
36    dialogueWillChange.Invoke();
37
38    currentDialogueEntry.ExecuteAction(DialogueActionStage.Default, isRestoring);
39    while (actionPauseLock.isLocked) yield return null;
40
41    var isReachedAnyHistory = checkpointManager.IsReachedAnyHistory(currentNode.name, currentIndex);
42    var dialogueData = DialogueSaveReachedData(isReachedAnyHistory);
43    var dialogueChangedData = new DialogueChangedData(nodeRecord, checkpointOffset, dialogueData,
44        currentDialogueEntry.GetDisplayData(), isReached, isReachedAnyHistory);
45
46    if (isJumping && !isReachedAnyHistory)
47    {
48        isJumping = false;
49    }
50
51    // 通知对话已经发生了变化
52    dialogueChangedEarly.Invoke(dialogueChangedData);
53    dialogueChanged.Invoke(dialogueChangedData);
54
55    currentDialogueEntry.ExecuteAction(DialogueActionStage.AfterDialogue, isRestoring);
56    while (actionPauseLock.isLocked) yield return null;
57}

游戏状态更新流程如下所示

graph TD

Step --> UpdateGameState --> UpdateDialogue

UpdateDialogue --> ExecuteAction.BeforeCheckpoint
UpdateDialogue --> ExecuteAction.Default
UpdateDialogue --> ExecuteAction.AfterDialogue

GameState 与 NodeRecord

游戏的状态恢复可以使用 GameStateCheckpoint 实现,但如果需要实现更多的效果,例如了解玩家经过了哪些剧本节点、哪些结局达成了等需求,单凭 GameState 就很难实现了。

单一的 GameStateCheckpoint 无法提供更多的信息,因此我们需要一个数据结构来专门存储玩家经过了哪些节点,这个数据结构就是 NodeRecord。

将所有的 NodeRecord 组成一棵树,就可以完整描述玩家的剧情流程。

什么时候会创建 NodeRecord

每隔若干条对话,Nova 框架就会自动创建一个 NodeRecord,覆盖一个流程图节点内的若干条对话,并保存一个对话内生成的 GameStateCheckpoint 的索引。

每一个 NodeRecord 的第一条对话会强制创建一个 GameStateCheckpoint,但 NodeRecord 的结尾不强制创建。

当进行剧情分支跳转的时候,会创建 NodeRecord(如果跳转到同一个节点,即使是不同的分支,也不会创建新的NodeRecord)。

同一个节点内也可能会产生多个NodeRecord,目前有两种情况:

  1. 一条对话的脚本可能在相同的初始状态下产生不同的结果,比如小游戏。这时根据不同的 variablesHash 创建不同的 NodeRecord
  2. 当前 NodeRecord 所覆盖的 GameStateCheckpoint 范围不处于链表末尾,但是需要添加新的 GameStateCheckpoint

这两种情况都会调用 GameState.AppendSameNode 方法增加一个新的 NodeRecord 的子节点。

Bookmark

在拥有了 GameStateCheckpoint 和 NodeRecord 后,我们可以很方便的实现游戏的书签功能,因为书签只需要保存游戏的以下几个数据即可。

  1. NodeRecord 的索引
  2. GameStateCheckpoint 的索引
  3. 当前对话的索引
  4. 对话的信息
  5. Bookmark 的创建时间
  6. Bookmark 的位置
  7. 游戏图像显示
There is nothing new under the sun.