Attention: Here be dragons

This is the latest (unstable) version of this documentation, which may document features not available in or compatible with released stable versions of Godot.

保存游戏

介绍

保存游戏可能很复杂。比如,我们可能会想要储存跨多个关卡的多个对象的信息。更高级的保存游戏系统可能需要存储关于任意数量对象的附加信息。这使得保存函数可以随着游戏变得更加复杂而扩展。

备注

如果你想保存用户的配置,可以用 ConfigFile 类来实现这个目的。

参见

如果你更善于通过查看示例学习,可以在《保存和加载(序列化)演示项目》中找到演示项目。

识别持久化对象

首先,我们应该确定在游戏会话之间要保留哪些对象,以及我们要保留这些对象中的哪些信息。本教程中,我们将使用分组来标记和处理要保存的对象,但当然也有其他可行的方法。

首先我们将想要保存的对象添加到“Persist”组。我们可以通过 GUI 或脚本完成此操作。就使用 GUI 来添加相关节点吧:

../../_images/groups.webp

完成这个操作后,当需要保存游戏时,我们就可以获取所有需要保存的对象,然后通过这个脚本让这些对象去保存数据:

var save_nodes = get_tree().get_nodes_in_group("Persist")
for node in save_nodes:
    # Now, we can call our save function on each node.

序列化

下一步是序列化数据。这使得从硬盘读取数据和存储数据到硬盘变得更加容易。在这种情况下,我们假设 Persist 组的每个成员都是一个实例化的节点,因此它们都有一个路径。GDScript 有一个辅助类 JSON 来在字典和字符串之间进行转换。我们的节点需要包含一个返回该数据的保存函数。保存函数将如下所示:

func save():
    var save_dict = {
        "filename" : get_scene_file_path(),
        "parent" : get_parent().get_path(),
        "pos_x" : position.x, # Vector2 is not supported by JSON
        "pos_y" : position.y,
        "attack" : attack,
        "defense" : defense,
        "current_health" : current_health,
        "max_health" : max_health,
        "damage" : damage,
        "regen" : regen,
        "experience" : experience,
        "tnl" : tnl,
        "level" : level,
        "attack_growth" : attack_growth,
        "defense_growth" : defense_growth,
        "health_growth" : health_growth,
        "is_alive" : is_alive,
        "last_attack" : last_attack
    }
    return save_dict

这会让我们得到一个样式为 { "variable_name":value_of_variable } 的字典,在加载游戏数据时会用到。

保存和读取数据

正如《文件系统》教程中所述,我们需要打开一个文件,以便对其进行读写。既然我们有了调用分组并获取其相关数据的方法,我们可以使用 JSON 类将数据转换为易于存储的字符串,存储在文件中。这样做可以确保每一行都是单独的对象,也以便我们轻松地从文件中提取数据。

# Note: This can be called from anywhere inside the tree. This function is
# path independent.
# Go through everything in the persist category and ask them to return a
# dict of relevant variables.
func save_game():
    var save_file = FileAccess.open("user://savegame.save", FileAccess.WRITE)
    var save_nodes = get_tree().get_nodes_in_group("Persist")
    for node in save_nodes:
        # Check the node is an instanced scene so it can be instanced again during load.
        if node.scene_file_path.is_empty():
            print("persistent node '%s' is not an instanced scene, skipped" % node.name)
            continue

        # Check the node has a save function.
        if !node.has_method("save"):
            print("persistent node '%s' is missing a save() function, skipped" % node.name)
            continue

        # Call the node's save function.
        var node_data = node.call("save")

        # JSON provides a static method to serialized JSON string.
        var json_string = JSON.stringify(node_data)

        # Store the save dictionary as a new line in the save file.
        save_file.store_line(json_string)

游戏已保存!现在,为了加载,我们将读取每一行。使用 parse 方法将 JSON 字符串读回到字典中,然后遍历该字典以读取我们的值。但我们首先需要创建对象,我们可以使用文件名和父级值来实现这一点。这是我们的加载函数:

# Note: This can be called from anywhere inside the tree. This function
# is path independent.
func load_game():
    if not FileAccess.file_exists("user://savegame.save"):
        return # Error! We don't have a save to load.

    # We need to revert the game state so we're not cloning objects
    # during loading. This will vary wildly depending on the needs of a
    # project, so take care with this step.
    # For our example, we will accomplish this by deleting saveable objects.
    var save_nodes = get_tree().get_nodes_in_group("Persist")
    for i in save_nodes:
        i.queue_free()

    # Load the file line by line and process that dictionary to restore
    # the object it represents.
    var save_file = FileAccess.open("user://savegame.save", FileAccess.READ)
    while save_file.get_position() < save_file.get_length():
        var json_string = save_file.get_line()

        # Creates the helper class to interact with JSON.
        var json = JSON.new()

        # Check if there is any error while parsing the JSON string, skip in case of failure.
        var parse_result = json.parse(json_string)
        if not parse_result == OK:
            print("JSON Parse Error: ", json.get_error_message(), " in ", json_string, " at line ", json.get_error_line())
            continue

        # Get the data from the JSON object.
        var node_data = json.data

        # Firstly, we need to create the object and add it to the tree and set its position.
        var new_object = load(node_data["filename"]).instantiate()
        get_node(node_data["parent"]).add_child(new_object)
        new_object.position = Vector2(node_data["pos_x"], node_data["pos_y"])

        # Now we set the remaining variables.
        for i in node_data.keys():
            if i == "filename" or i == "parent" or i == "pos_x" or i == "pos_y":
                continue
            new_object.set(i, node_data[i])

现在我们可以保存和加载位于场景树中几乎任意位置,任意数量的对象了!每个对象可以根据需要保存的内容存储不同的数据。

一些注意事项

我们略过了如何设置用于加载的游戏状态这一步。说到底,这一步的逻辑安放在哪里取决于项目创建者。这通常很复杂,需要根据每个项目的需求大幅度定制。

另外,此实现假定没有 Persist 对象是其他 Persist 对象的子对象。否则会产生无效路径。若要适应嵌套的 Persist 对象,可以考虑分阶段保存对象。首先加载父对象,这样在加载子对象时就可以调用父对象的 add_child()。由于 NodePath 很可能无效,你还需要某种方式将子项链接到父项。

JSON 与二进制序列化

对于简单的游戏状态,可能可以使用 JSON,它生成的是人类可读、便于调试的文件。

但是 JSON 也存在许多限制。如果你需要存储更复杂或者大量的游戏状态,使用二进制序列化可能是更好的方法。

JSON 的限制

以下是一些使用 JSON 时需要了解的陷阱。

  • 文件大小:JSON 使用文本格式存储数据,比二进制格式要大很多。

  • 数据类型:JSON 只提供了有限的数据类型。如果你用到了 JSON 没有的数据类型,就需要自己在这个类型和 JSON 能够处理的类型之间来回转换。例如 JSON 无法解析以下重要的类型:Vector2Vector3ColorRect2Quaternion

  • 编码/解码需要自定义逻辑:如果你想要用 JSON 存储自定义的类,就需要自己编写这些类的编码和解码逻辑。

二进制序列化

也可以使用二进制序列化来存储游戏状态,还可以配合 FileAccessget_varstore_var 函数。

  • 二进制序列化应能生成比 JSON 更小的文件。

  • 二进制序列化能够处理大多数常见数据类型。

  • 二进制序列化在编码和解码自定义类时需要的自定义逻辑更少。

请注意,并非所有属性都包括在内。只有配置了 PROPERTY_USAGE_STORAGE 标志集配置的属性才会被序列化。你可以通过在类中重写 _get_property_list 方法,来向属性添加新的使用标志。你还可以通过调用 Object._get_property_list 来检查属性使用是如何配置的。有关可能的使用标志,请参阅 PropertyUsageFlags