保存游戏
前言
保存游戏可能很复杂. 比如, 我们可能会想要储存跨多个关卡的多个物品的信息. 更高级的保存游戏可能需要存储关于具有任意数量的对象的附加信息. 当游戏变得更加复杂时, 这将让保存函数可以随着游戏一同变得更加复杂.
备注
如果你想保存玩家的设置,可以用 ConfigFile 来实现这个目的。
参见
You can see how saving and loading works in action using the Saving and Loading (Serialization) demo project.
识别持久化对象
首先,我们应该确定在游戏会话中要保存那些对象,以及我们要保存这些对象中的哪些信息。本教程中,我们将使用“分组”来标记和处理要保存的对象,但当然也有其他可行的方法。
首先我们将想要保存的对象添加到“Persist”组。我们既可以通过 GUI 也可以通过脚本完成此操作。让我们使用 GUI 来添加相关节点吧:
完成这个操作后,我们需要保存游戏时,就可以获取所有需要保存的对象,然后通过这个脚本让这些对象去保存数据:
GDScriptC#
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.
}
序列化
The next step is to serialize the data. This makes it much easier to read from and store to disk. In this case, we’re assuming each member of group Persist is an instanced node and thus has a path. GDScript has helper class JSON to convert between dictionary and string, Our node needs to contain a save function that returns this data. The save function will look like this:
GDScriptC#
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":that_variables_value }
的字典, 它在加载游戏数据时会被用到.
保存和读取数据
As covered in the 文件系统 tutorial, we’ll need to open a file so we can write to it or read from it. Now that we have a way to call our groups and get their relevant data, let’s use the class JSON to convert it into an easily stored string and store them in a file. Doing it this way ensures that each line is its own object, so we have an easy way to pull the data out of the file as well.
GDScriptC#
# 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);
}
}
Game saved! Now, to load, we’ll read each line. Use the parse method to read the JSON string back to a dictionary, and then iterate over the dict to read our values. But we’ll need to first create the object and we can use the filename and parent values to achieve that. Here is our load function:
GDScriptC#
# 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.get_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对象的子对象. 否则会产生无效路径. 如果这是项目的需求之一, 可以考虑分阶段保存对象(父对象优先), 以便在加载子对象时可用它们将确保它们可用于 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 小。
二进制序列化能够处理大多数常见数据类型。
二进制序列化在编解码自定义类时需要更少的自定义逻辑。
请注意,并非所有属性都包含在内。仅使用:ref:PROPERTY_USAGE_STORAGE<class_@GlobalScope_constant_PROPERTY_USAGE_STORAGE> 标志集将被序列化。通过重写类中的:ref:_get_property_list<class_Object_private_method__get_property-list> 方法,可以向属性添加新的用法标志。你还可以通过调用``Object_get_property_list``来检查如何配置属性使用。请参阅:ref:PropertyUsageFlags<enum_@GlobalScope_PropertyUsageFlags> 以获取可能的使用标志。