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.
Checking the stable version of the documentation...
保存游戏
介绍
保存游戏可能很复杂。比如,我们可能会想要储存跨多个关卡的多个对象的信息。更高级的保存游戏系统可能需要存储关于任意数量对象的附加信息。这使得保存函数可以随着游戏变得更加复杂而扩展。
备注
如果你想保存用户的配置,可以用 ConfigFile 类来实现这个目的。
参见
如果你更善于通过查看示例学习,可以在《保存和加载(序列化)演示项目》中找到演示项目。
识别持久化对象
首先,我们应该确定在游戏会话之间要保留哪些对象,以及我们要保留这些对象中的哪些信息。本教程中,我们将使用分组来标记和处理要保存的对象,但当然也有其他可行的方法。
首先我们将想要保存的对象添加到“Persist”组。我们可以通过 GUI 或脚本完成此操作。就使用 GUI 来添加相关节点吧:
完成这个操作后,当需要保存游戏时,我们就可以获取所有需要保存的对象,然后通过这个脚本让这些对象去保存数据:
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.
var saveNodes = GetTree().GetNodesInGroup("Persist");
foreach (Node saveNode in saveNodes)
{
// 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
public Godot.Collections.Dictionary<string, Variant> Save()
{
return new Godot.Collections.Dictionary<string, Variant>()
{
{ "Filename", SceneFilePath },
{ "Parent", GetParent().GetPath() },
{ "PosX", Position.X }, // Vector2 is not supported by JSON
{ "PosY", Position.Y },
{ "Attack", Attack },
{ "Defense", Defense },
{ "CurrentHealth", CurrentHealth },
{ "MaxHealth", MaxHealth },
{ "Damage", Damage },
{ "Regen", Regen },
{ "Experience", Experience },
{ "Tnl", Tnl },
{ "Level", Level },
{ "AttackGrowth", AttackGrowth },
{ "DefenseGrowth", DefenseGrowth },
{ "HealthGrowth", HealthGrowth },
{ "IsAlive", IsAlive },
{ "LastAttack", LastAttack }
};
}
这会让我们得到一个样式为 { "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)
// 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.
public void SaveGame()
{
using var saveFile = FileAccess.Open("user://savegame.save", FileAccess.ModeFlags.Write);
var saveNodes = GetTree().GetNodesInGroup("Persist");
foreach (Node saveNode in saveNodes)
{
// Check the node is an instanced scene so it can be instanced again during load.
if (string.IsNullOrEmpty(saveNode.SceneFilePath))
{
GD.Print($"persistent node '{saveNode.Name}' is not an instanced scene, skipped");
continue;
}
// Check the node has a save function.
if (!saveNode.HasMethod("Save"))
{
GD.Print($"persistent node '{saveNode.Name}' is missing a Save() function, skipped");
continue;
}
// Call the node's save function.
var nodeData = saveNode.Call("Save");
// Json provides a static method to serialized JSON string.
var jsonString = Json.Stringify(nodeData);
// Store the save dictionary as a new line in the save file.
saveFile.StoreLine(jsonString);
}
}
游戏已保存!现在,为了加载,我们将读取每一行。使用 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])
// Note: This can be called from anywhere inside the tree. This function is
// path independent.
public void LoadGame()
{
if (!FileAccess.FileExists("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 saveNodes = GetTree().GetNodesInGroup("Persist");
foreach (Node saveNode in saveNodes)
{
saveNode.QueueFree();
}
// Load the file line by line and process that dictionary to restore the object
// it represents.
using var saveFile = FileAccess.Open("user://savegame.save", FileAccess.ModeFlags.Read);
while (saveFile.GetPosition() < saveFile.GetLength())
{
var jsonString = saveFile.GetLine();
// Creates the helper class to interact with JSON.
var json = new Json();
var parseResult = json.Parse(jsonString);
if (parseResult != Error.Ok)
{
GD.Print($"JSON Parse Error: {json.GetErrorMessage()} in {jsonString} at line {json.GetErrorLine()}");
continue;
}
// Get the data from the JSON object.
var nodeData = new Godot.Collections.Dictionary<string, Variant>((Godot.Collections.Dictionary)json.Data);
// Firstly, we need to create the object and add it to the tree and set its position.
var newObjectScene = GD.Load<PackedScene>(nodeData["Filename"].ToString());
var newObject = newObjectScene.Instantiate<Node>();
GetNode(nodeData["Parent"].ToString()).AddChild(newObject);
newObject.Set(Node2D.PropertyName.Position, new Vector2((float)nodeData["PosX"], (float)nodeData["PosY"]));
// Now we set the remaining variables.
foreach (var (key, value) in nodeData)
{
if (key == "Filename" || key == "Parent" || key == "PosX" || key == "PosY")
{
continue;
}
newObject.Set(key, value);
}
}
}
现在我们可以保存和加载位于场景树中几乎任意位置,任意数量的对象了!每个对象可以根据需要保存的内容存储不同的数据。
一些注意事项
我们略过了如何设置用于加载的游戏状态这一步。说到底,这一步的逻辑安放在哪里取决于项目创建者。这通常很复杂,需要根据每个项目的需求大幅度定制。
另外,此实现假定没有 Persist 对象是其他 Persist 对象的子对象。否则会产生无效路径。若要适应嵌套的 Persist 对象,可以考虑分阶段保存对象。首先加载父对象,这样在加载子对象时就可以调用父对象的 add_child()。由于 NodePath 很可能无效,你还需要某种方式将子项链接到父项。
JSON 与二进制序列化
对于简单的游戏状态,可能可以使用 JSON,它生成的是人类可读、便于调试的文件。
但是 JSON 也存在许多限制。如果你需要存储更复杂或者大量的游戏状态,使用二进制序列化可能是更好的方法。
JSON 的限制
以下是一些使用 JSON 时需要了解的陷阱。
文件大小:JSON 使用文本格式存储数据,比二进制格式要大很多。
数据类型:JSON 只提供了有限的数据类型。如果你用到了 JSON 没有的数据类型,就需要自己在这个类型和 JSON 能够处理的类型之间来回转换。例如 JSON 无法解析以下重要的类型:
Vector2、Vector3、Color、Rect2、Quaternion。编码/解码需要自定义逻辑:如果你想要用 JSON 存储自定义的类,就需要自己编写这些类的编码和解码逻辑。
二进制序列化
也可以使用二进制序列化来存储游戏状态,还可以配合 FileAccess 的 get_var 和 store_var 函数。
二进制序列化应能生成比 JSON 更小的文件。
二进制序列化能够处理大多数常见数据类型。
二进制序列化在编码和解码自定义类时需要的自定义逻辑更少。
请注意,并非所有属性都包括在内。只有配置了 PROPERTY_USAGE_STORAGE 标志集配置的属性才会被序列化。你可以通过在类中重写 _get_property_list 方法,来向属性添加新的使用标志。你还可以通过调用 Object._get_property_list 来检查属性使用是如何配置的。有关可能的使用标志,请参阅 PropertyUsageFlags。