游戏中谈的存档就是一种数据持久化。游戏在运行时会产生大量数据,如果没有存档功能,这些数据实际上在关闭游戏之后——更别说关闭电脑之后就从内存中消失了。我们希望在下一次打开游戏时,能够恢复这些数据。因此最简单的办法就是把我们需要的游戏数据保存到磁盘上——磁盘上的文件不像内存中的数据那样,断电后就没有了。
也许你在日常生活中混同“内存”(RAM、memory)和“外存”(电脑上的各种硬盘、手机上的“内部存储空间”),日常生活中,我觉得无所谓,但是这里有必要加以区分。RAM(随机存储器)在中国大陆使用的简体中文环境下一般称内存,繁体中文常称记忆体。它是计算机程序运行时程序自身和相关数据所使用的主要的存储器,也就是主存储器,亦称主存。它的特点就是速度快,但是断电后(关机后)数据就没了。相应的,外存,或者说辅助存储器,辅存。辅助存储器一般来说速度会比内存慢,但是它容量大,且断电后数据不会丢失。日常生活中常见的外存就是各种硬盘。不得不吐槽一句,“运行内存”这个编出来的说法实在是令人汗颜,它就好像是商家为了弥补长期误用内存一词造成的误导,实在不行才编出来这样一个说法。
我们每天都在做数据持久化,我们用的各种软件启动后会加载存在磁盘上的必要文件——即使这些数据是通过网络获得的,它在服务器上也得放在某种存储设备上。当我们在软件中新建一个文件,进行编辑,最后也希望把它保存在磁盘上便于下一次存取。
内存中的数据不是直接就可以从内存中挪动到外存中。即使在这个过程中有大量的程序会帮助我们在内存、操作系统、外村之间架起桥梁,我们仍然需要做出决定,我们要对哪些数据进行存储,我们要以何种格式进行存储。实际上走到这一步,我们就有数不尽的方法可以选择。
下面以实现一个简单的游戏存档读档为例,讲解一下相关的知识。
一般来说,我们不需要将整个游戏的状态(或者说整个运行的游戏在内存中的状态)保存到我们的存档文件中。我们很多时候可能只需要保存一些具体的数据就可以在下次启动游戏时恢复状态。
例如一些游戏提供了固定的存档点或者所谓的检查点,玩家可以随时从这个地方继续游戏。因此,我们可以在存档中记录玩家存档时的位置,然后保存和玩家自身有关的状态,比如HP、道具栏等等。
我们做一个简单的存档点——当然我这里叫它检查点,玩家碰到检查点时游戏就会自动保存。我们新建场景和脚本,相关的信号响应方法暂时留空。我用宝石的spritesheet给它做了个简单的效果:
为了提示正在保存再给它一个简单的UI让它显示Saving字样,这里简单地加入一个Label,并默认隐藏。
脚本中没有太复杂的代码,主要是连接Area2D的body_entered信号,有玩家进入时我们会做出相应的保存操作。先稍微tween一下Label的各种属性,具体的存档代码我们稍后实现。
我们这个游戏目前比较简单,实际上只需要保存一下玩家在哪个位置,当前有多少HP。碰到这个存档点或者说检查点的时候我们就进行保存。
前面已经使用过的资源有一大特点就是它可以通过磁盘存取。这不正是我们想要的吗?
我们来新建一个资源,叫它SaveData或者其它你喜欢的名字,并给它一个脚本。
和往常一样,我们在这里定义资源的各种属性。这里我们暂时只定义两个变量,一个是玩家存档时的位置,一个是玩家当时的HP:
我们希望在碰到存档点时,可以保存玩家此时的状态。因此在这里我们就要保存(在必要时还需要新建)SaveData资源到磁盘上。
ResourceSaver类顾名思义就是用来保存资源的。ResourceSaver是一个单例(singleton)类,单例是一种设计模式(design pattern),一个用来实现单例模式的类至多只能有一个实例。单例有利于在任何时候任何地方访问同一个东西——就像一个全局变量一样,并且防止创建多个实例。当然,关于单例模式本身也有很多批评,这里不多说。
ResourceSaver本身暴露出来的方法并不多,这里主要是使用它的save方法来进行存档。从文档中可以看出,save方法是一个实例方法而不是静态方法。
save方法有三个参数,第一个参数是要保存的资源,第二个参数是保存路径,第三个是一些保存选项。我们着重看前两个参数。
第一个参数是一个资源的实例,在我们这里的问题中,就是一个SaveData的实例。存档时直接调用new方法构造一个实例即可。
要在Godot中访问(保存在磁盘上的)资源涉及两种情况,一种是访问Godot项目内部的各种资源,一种是游戏运行时可能要访问玩家电脑上的一些资源。
项目中的文件路径以res://开头。实际上可以在文件系统面板的树形菜单最上方的根节点上看出这一点。例如:
这个资源文件的路径就是"res://resources/save_data.tres" ,实际上你把文件拖到代码编辑器中会自动转换为对应的文件路径。选中文件后右键菜单中也可以在操作系统的文件管理器中找到它。总之,res://对应的路径就是Godot项目目录的根目录。
但是我们至今从来没有通过文件路径来加载一些资源,因为实际上在开发过程中我们可能随时会移动各种资源文件的位置,所以在代码中用写死的文件路径来引用资源很容易出现问题。当然,在一些可能需要在游戏运行时通过代码按照一定模式来动态加载资源时,我们可能还是要使用这种手段。
除了res路径之外的另一种路径就是user://路径。从名字可以看出,它是用户文件所在的目录的路径。它对应的操作系统路径如图,第一大行是它的默认路径:
其中%APPDATA%是Windows上命令提示符引用环境变量的写法(PowerShell是$env:APPDATA),APPDATA一般来说就对应着C:\Users\用户名\AppData\Roaming。你也可以直接在文件资源管理器的地址栏中输入%APPDATA%访问。~是UNIX系系统home文件夹的简写,macOS上就是那个图标是个房子(home)的用户文件夹。
当然,最简单的找到这个文件夹的方法就是选择编辑器菜单中的Project/Open User Data Folder。
默认路径没什么问题,如果你想改成其他文件夹也可以在项目设置中启用高级设置后在Config中修改。
在游戏运行过程中res路径下的文件可以读,但是不能随便写,所以游戏运行时要进行存取的一些用户数据我们应当在user目录下进行操作。
在SaveData中我们定义一个常量来指代存档文件夹的路径,然后又用另一个常量来指代存档文件的路径。这里暂时只考虑单个存档的情况,因此这里存档文件本身的路径也用的一个常量来表示。注意,尽管SAVE_FILE_PATH的值是通过运算得出的,但是它也是一个合法的常量。因为参与运算的只有另一个常量和一个字符串字面量,它们都可以在代码编译时确定,因此这个表达式可以作为常量合法的初始值。
如果你想让游戏支持多个存档,那么这里可能就要动态地根据存档文件夹路径来构造存档文件名。
接下来给它写一个save方法,它主要就是调用ResourceSaver的save方法来保存自己:
ResourceSaver的save方法有返回值。这个返回值的类型为Error,它是一个枚举类型,对应着不同类型的错误。不过,Error的值为0的时候代表OK——没有错误,所以这里我把它的返回值取名为result而不是error来消除一些误会。
剩下的就是在检查点的脚本中,在玩家碰到时调用这个方法:
如果现在运行游戏,走到检查点旁边,我们会发现报错了。如果你尝试print一下result的值会发现是19,对应着的就是无法打开文件的错误。GDScript的枚举类型目前非常简陋,在定义枚举的文件外部操作枚举值比较麻烦,没法拿到枚举类型的名称,具体的值很多时候只能当成整数处理。不过这个Error枚举类型实际上是在全局作用域中的,所以里面具体的值在任何地方都可以访问,比如OK直接写出来也会有提示。直接把这些枚举值当成数字显示出来其实没有多大用处,而且很麻烦。Godot提供了一个error_string函数,可以把Error的可选值转换为字符串。
这里可以简单做一下错误处理,OS的alert方法可以调用操作系统弹一个窗口显示一些警告信息。如果存储过程中发生错误可以提示一下玩家。当然这里如果真的发生了什么错误,你其实很难解决。
当然这里错误的原因很简单,我们的存档路径中有一个save文件夹目前是不存在的。当然你可以自己建一个,但是游戏运行时我们不能假设这个文件夹已经存在。
因此我们需要一种办法来检查文件夹存在与否,并且在不存在时创建文件夹。DirAccess类提供了各种文件夹访问的方法。DirAccess的一些方法都提供了静态方法和实例方法两种版本,实例方法主要是在使用open方法成功打开一个文件夹后在它的基础上进行操作。注意DirAccess的实例无法(不应该)通过new方法直接构造,一般来说都需要通过open方法来获得DirAccess的实例。
对于我们的需求来说,我们首先需要检查一个文件夹是否存在,所以我们需要通过静态方法来检查。DirAccess的dir_exists_absolute会检查一个绝对路径对应的文件夹是否存在。什么是绝对路径和相对路径呢?例如,如果一个表示路径的字符串中只有"file.txt"这样的内容,它通常会被视作"./file.txt"。在主流的操作系统中,路径中的点表示“当前目录”,或者说得专业一点叫“当前工作目录”(Current Working Directory),顾名思义就是这个程序目前正在哪个目录下工作,很多操作会假定在这个目录中进行。此外,两个点表示上一级目录。例如../file.txt表示当前目录的上级目录中的这个文件。以这种通配符开头的路径一般视作相对路径,因为它们具体代表的路径要根据当前目录的相对关系来确定。
相应的,绝对路径的开头必须是一个确定的路径。在Windows中一个绝对路径应该以一个盘符开头比如C:\Program Files\;在UNIX系系统中(macOS也算),绝对路径应该以根目录开头,比如/user/bin。需要注意的是,Windows的路径分隔符和UNIX系系统并不一样,UNIX系是斜杠/,Windows是反斜杠\。但是实际上在前面的代码中你也看到了,我用的也是斜杠。
实际上如今在常规的开发工作中可以忽略这一点,甚至应该优先考虑使用斜杠而不是反斜杠。现在的通用编程语言都会涉及在不同的操作系统上运行,它们最终会为你考虑这种差异。而反斜杠在很多编程语言的字符串字面量中都有特殊含义,也就是用来提示接下来的内容是一个转义字符。所以在包括GDScript的编程语言中,字符串中的"C:\source\next"中的\s和\n会被视作两个转义字符而不是路径的一部分。通常要防止反斜杠被转义,必须写成\\。所以这样写很麻烦。另一方面,Windows系统本身也能够容忍路径中的斜杠。在PowerShell中,在需要路径作为参数的命令中如果以字符串形式传入用斜杠表示的路径它还是会正常执行的,甚至于你在文件资源管理器的地址栏中输入以斜杠分割的路径它也会正常操作。
言归正传,在Godot中,user和res实际上都会被Godot转换成一个绝对路径,所以这里不用担心。当我们发现这个save文件夹不存在时,我们就创建这个文件夹。利用DirAccess的make_dir方法即可创建文件夹:
在实际调用ResourceSaver的save方法之前的两行代码就可以保证我们的save文件夹是存在的,然后就不怕无法保存了。现在尝试运行游戏,碰到检查点后相关代码执行完毕后,我们就可以在对应的文件夹中看到这个文件,它就是我们的存档文件。
现在我们已经有了存档,那么如何读档呢。我们需要考虑的是,第一次启动游戏时我们必然没有存档文件,此时我们应当有所表示。
我在这里选择在没有存档文件时禁用继续游戏按钮,你也可以选择尝试一下在没有存档文件时直接隐藏继续游戏按钮。但是无论如何,我们需要判断文件是否存在。
在主菜单场景的ready中检查一下存档文件是否存在。文件的操作有一个类似于DirAccess的FileAccess类,它也有个类似的检查文件是否存在的方法:
那么自然,如果发现存在有存档文件,那么点击继续按钮时就需要加载这个资源。聪明的你可能想到,既然有个ResourceSaver那么是不是有个ResourceLoader呢?
确实有,不过Godot提供了一个更简单的load函数,大部分时候都可以用它完成工作。load函数唯一的参数就是资源的路径。这里我们在SaveData中添加一个load静态方法,让它调用全局函数load来加载我们的存档。
不过先别急着修改SaveData的代码。现在我们需要修改主场景的代码让它能够处理存档以继续游戏。其实最简单粗暴的办法就是进入场景后在ready中再次尝试读取存档。但是实际情况中,我们的游戏极有可能涉及多个关卡(场景),在存档中我们会记录玩到了那个场景,场景需要知道到底是从头开始还是在存档处继续。
要解决这个问题其实也不止一种办法。这里先介绍一种简单的办法。在SaveData中定义一个静态属性用来表示“是否存在任何存档”,同时再提供另一个静态变量表示最近的存档。存在存档的情况一个是我们开始游戏时加载了存档,一个是我们在游戏过程中保存过存档。进入某个场景后我们可以直接访问ever_saved属性来检查是否已经加载存档。如果已经加载了存档我们就通过latest_save属性来获得需要的信息。
还记得静态成员的特点吗?它们不会和某个类的实例相关联,而是和类本身相关联。这里的ever_saved是一个静态只读属性,只有getter没有setter。它就是单纯返回latest_save是不是null。这样我们就不用每次都手动写判断条件了,也不用每次修改存档时手动赋值了。
同样地,在保存存档的save方法处我们也把新的存档赋值给latest_save。
最后在主场景中修改ready的逻辑。由于玩家会在存档点开始游戏,所以不应该在一开头的PlayerSpawner那里生成。当然,这里具体的做法依然取决于你的设计。这里我直接简单粗暴地把那个spawner的位置直接挪到存档时的位置——偏左一点(以避免读档生成玩家后再次存档,虽然即使这样做了也无伤大雅),然后生成玩家并设置其hp。这样一来读档后就算玩家死了也会在这个位置重生!
注意如果你和的代码是一样的的话,那么最下面初始化HP指示物时应该把之前的max_hp改成hp,因为如果是读档开始游戏的话玩家的hp应该是和存档中的值保持一致。由于hp值默认为max_hp所以不用额外判断。
最后,如果一个场景中有多个检查点,我们可以在复活的时候先把player_spawner搬到最近一次存档的检查点附近:
功能基本实现了,但是我们还有一些问题。使用资源作为存档文件是在Godot体系下很容易想到的一种解决方案。它的优点很明显,它可以很好地集成到Godot中,可以直接在检视面板中编辑它的属性,也有各种内置的类和方法来进行存取,还可以用脚本给它提供各种各样的方法。总之就是实现起来很简单。
但是相应的,资源非常任意被篡改。以tres为后缀名保存的资源文件是文本形式的。如果你用记事本或者代码编辑器打开你刚才的后缀名为tres的存档文件,你会发现它就是非常普通的文本文件:
以文本形式保存的文件本身有很多好处,它们可以被人类识读(但是有些时候是坏处),git等版本控制软件也可以更好地跟踪文件变化。但是这样一来有些不怀好意的玩家可以随意修改其中的hp甚至加入其他内容。
读者朋友可能有所耳闻,那就是计算机无论如何最终还是在操作二进制数据,各种数据最终还是以二进制形式保存的。但是很多时候我们还是会将文件分为两大类,那就是文本文件和二进制文件。不过文本文件不过是按照特定格式编码的一些二进制文件,它们能够被各种应用软件按照对应的编码解释为人类看得懂的文本。我们常说的“打开方式不对”得到的乱码很有可能就是强行将二进制文件以某种文字编码打开后得到的错误结果(也有可能是以错误的文本编码打开得到的)。
如果我们把之前脚本中的存档文件的后缀名从tres改为res,保存得到的存档文件就是二进制形式的,打开后可以看到数据部分是乱码:
相比之下,这种格式可以一定程度上保护文件不被玩家随意识读并修改,但是这并不妨碍玩家根据Godot的源码了解资源的二进制格式是如何的,然后自行解析并篡改。
如果你想给它加一下密要怎么做呢?Godot也有内置的简单加密方法。
这种情况下我们有无数种方法可以来加密这些存档数据。不过Godot也内置了简单的加密方法,这里简单给出一段代码以供参考:
注意!上述代码的写法在Godot目前的版本中无法正常工作!预计在4.3之后的版本后可以正常运作。 这个问题 是已经确认且得到修复的bug。你也可以尝试下载最新的4.3beta版来尝试。 FileAccess的open_encrypted_with_pass可以通过一个密码来存取一个加密文件。第一个参数依然是文件路径,第二个参数ModeFlags代表要对文件进行哪种操作,第三个参数是密码。此静态方法返回一个FileAccess实例。
store_var顾名思义会在文件中存储一个变量,它的第一个参数是任意对象,第二个参数将允许我们写入复杂的对象(比如我们的自定义类和各种脚本),Godot会按照一定规则将其转换为二进制数据。
随后关闭文件并以读取模式打开然后通过get_var获得存入其中的数据。
FileAccess和ResourceLoader不一样,它不止供资源使用,它是各种文件通用的类。因此存取数据的方法更复杂。为了暂时解决目前保存一整个自定义类型对象存在的问题,可以使用它提供的具体的数据存取方法来代替:
文件是按顺序存取的,多次调用get_var也会按照store_var的顺序返回其中的数据。
评论区
共 3 条评论热门最新