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 的原理就是为需要特效的对象添加一个材质,再通过该材质上的着色器属性来实现视觉效果。
但是此时我们仍有许多疑问,例如 lua 究竟是如何在代码中为对象添加材质的,而着色器又是如何生效的,如果我们要创建一个自己的着色器,又应该如何做。
lua function - get_mat
首先我们需要看一下 get_mat
方法,该方法的主要作用是从附加到 GameObject
的 MaterialPool
中获取材质。
- 检查
shader_name
是否为nil
,如果是则返回nil
。 - 设置
restorable
的默认值为true
。 - 获取目标
GameObject
的渲染组件和后处理组件。如果两个组件都不存在,则发出警告并返回nil
。 - 获取完整的着色器名称、基础着色器名称和变体。
- 确保
GameObject
有一个材质池,并从材质池中获取可恢复或普通材质。 - 如果找不到材质,发出警告并返回
nil
。如果找到材质,则返回材质、基础着色器名称和变体。
--- get material from the MaterialPool attached to the GameObject |
lua function - set_mat
set_mat
用于设置对象的材质(material)。它接受四个参数:obj(目标对象)、mat(材质)、layer_id(层ID,默认值为0)、token(令牌,默认值为-1)。
local function set_mat(obj, mat, layer_id, token) |
lua function - trans2
trans2
方法由 action_begin、action_middle、action_end 三部分组成,并最终用动画系统将这三部分组合在一起。
--- sprite transition using a shader that hides the texture |
MaterialFactory
MaterialFactory 用于创建和管理 Unity 中的 Material
对象以及自定义的 RestorableMaterial
对象。lua 代码中的 get_mat
所使用的 Get
方法其实就是调用的该类中的方法。
其实现原理非常简单,从 Resources 中寻找文件名对应的 Shader,使用该 Shader 创建一个材质,最后返回。
public sealed class MaterialFactory : IDisposable |
MaterialPool
MaterialPool 使用 MaterialFactory
实例来管理材质对象,旨在与 Unity 引擎中的游戏对象一起使用,提供一种简化的材质管理方式。
[ ] |
多语言支持
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
它会把一个函数添加到 NovaAnimation
和 AnimationEntry
的 metatable
,这样就可以当method
来用。
我们在演出脚本里写 anim:move(xxx)
的时候,其实 anim
是 NovaAnimation
的一个实例,move
是它的method
,返回的是一个AnimationEntry
,所以写anim:move(xxx):tint(xxx)
的时候,tint
就是AnimationEntry
的method
Nova要保证move
之类的函数不管前面是NovaAnimation
还是AnimationEntry
都能用,所以写了一个make_anim_method
来把它同时添加到两个类
输入系统
存档系统
global.nsav
为了实现存档和读档的速度,Nova的存档系统将 global.nsav
按照固定长度分块,一个块被称为一个 CheckpointBlock,默认为4kB。
这里需要区分
CheckpointBlock
与GameStateCheckpoint
,前一个是存档文件的分块,后一个是用于恢复游戏状态的数据。相似的还有
Record
与NodeRecord
,前一个是数据存储进存档系统的基本单位,后一个是标识剧情进度的数据。
为了避免每次读取数据都从global.nsav文件中读取,Nova的存档系统引入了 LRUCache
机制。(它是一种常见的缓存淘汰策略,在缓存空间有限时,优先淘汰最近最少使用的数据)
// CheckpointSerializer GetBlock |
global.nsav
包含三种类型的数据:
global save
里面记录了存档时间标识和Reached
数据以及Checkpoint
数据在文件中的位置索引。Reached链表
:存储所有已达剧情,ReachedEndData
、ReachedDialogueData
。ReachedDialogueData
:一条已读对话的信息,包括该对话的语音信息和文本是否需要插值。ReachedEndData
一个已读结局的名称。
Checkpoint链表
:存储游戏历史中的所有状态,例如游戏中的音频大小、文本的位置和内容。
global.nsav的文件结构大致如下图所示。
可以参照CheckpointBlock的Flush方法的实现来理解。
// CheckpointBlock Flush |
CheckpointBlock 类图
classDiagram |
如何向global.nsav中添加记录
AppendRecord
会首先在 Record
的开头标记数据的大小,然后再将数据写入。
如果当前CheckpointBlock无法完全容纳数据,则剩余的数据将被写入下一个CheckpointBlock中。
public void AppendRecord(long offset, ByteSegment bytes) |
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
类负责。在这里,会初始化GlobalSave
、ReachedEndData
和ReachedDialogueData
。
// CheckpointManager Init |
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< string,List~ReachedDialogueData~ >
- 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
}