Nova GameState 与游戏执行流程
Intro
视觉小说这种游戏类型和动画、漫画很相似,我们都可以很轻松的将它们分为若干个最小的单元片段。动画的最小单位是帧、漫画的最小单位是画格,而视觉小说的最小单位就是一次游戏状态,以下我们将之称为 GameState。
GameState 的作用如下:
- 管理游戏中所有可恢复组件
- 管理游戏的状态
- 控制游戏的进度
基于 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,目前有两种情况:
- 一条对话的脚本可能在相同的初始状态下产生不同的结果,比如小游戏。这时根据不同的 variablesHash 创建不同的 NodeRecord
- 当前 NodeRecord 所覆盖的 GameStateCheckpoint 范围不处于链表末尾,但是需要添加新的 GameStateCheckpoint
这两种情况都会调用 GameState.AppendSameNode
方法增加一个新的 NodeRecord 的子节点。
Bookmark
在拥有了 GameStateCheckpoint 和 NodeRecord 后,我们可以很方便的实现游戏的书签功能,因为书签只需要保存游戏的以下几个数据即可。
- NodeRecord 的索引
- GameStateCheckpoint 的索引
- 当前对话的索引
- 对话的信息
- Bookmark 的创建时间
- Bookmark 的位置
- 游戏图像显示