Сохранение игр
Введение
Игры с сохранением могут быть сложными. Например, может быть желательно сохранить информацию о нескольких объектах на нескольких уровнях. Продвинутые системы сохранения игр должны позволять сохранять дополнительную информацию о произвольном количестве объектов. Это позволит масштабировать функцию сохранения по мере усложнения игры.
Примечание
Если вы хотите сохранить конфигурацию пользователя, вы можете использовать для этого класс ConfigFile.
См. также
Вы можете увидеть, как сохранение и загрузка работают в действии, используя Демонстрационный проект сохранения и загрузки (сериализации).
Идентификация постоянных объектов
Во-первых, мы должны определить, какие объекты мы хотим сохранить между игровыми сессиями и какую информацию мы хотим сохранить от этих объектов. В этом учебнике мы будем использовать группы для маркировки и обработки объектов для сохранения, но, конечно, возможны и другие методы.
Мы начнем с добавления объектов, которые мы хотим сохранить, в группу "Persist". Мы можем сделать это с помощью графического интерфейса или скрипта. Давайте добавим соответствующие узлы с помощью графического интерфейса:
Когда это будет сделано, когда нам понадобится сохранить игру, мы сможем получить все объекты для сохранения, а затем сказать им всем сохраниться с помощью этого скрипта:
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 }
};
}
Это дает нам словарь со стилем {"имя_переменной":значение_переменной }, который будет полезен при загрузке.
Сохранение и чтение данных
Как уже говорилось в руководстве по Файловая система, нам нужно открыть файл, чтобы можно было записывать в него данные или читать из него. Теперь, когда у нас есть способ вызывать группы и получать их данные, давайте используем класс 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 есть множество ограничений. Если вам нужно хранить более сложное или большое количество игрового состояния, binary serialization может быть лучшим подходом.
Ограничения JSON
Вот несколько важных моментов, которые следует знать при использовании JSON.
Filesize: JSON хранит данные в текстовом формате, который намного больше двоичных форматов.
Data types: JSON поддерживает лишь ограниченный набор типов данных. Если у вас есть типы данных, которых нет в JSON, вам потребуется преобразовать их в типы, поддерживаемые JSON, и обратно. Например, вот некоторые важные типы, которые JSON не может обработать:
Vector2,Vector3,Color,Rect2иQuaternion.Для кодирования/декодирования требуется специальная логика: Если у вас есть какие-либо специальные классы, которые вы хотите хранить с помощью JSON, вам потребуется написать собственную логику для кодирования и декодирования этих классов.
Binary serialization (Двоичная сериализация)
Binary serialization — это альтернативный подход к хранению состояния игры, и вы можете использовать его с функциями get_var и store_var из FileAccess.
Двоичная сериализация должна создавать файлы меньшего размера, чем JSON.
Двоичная сериализация может обрабатывать большинство распространенных типов данных.
Двоичная сериализация требует меньше пользовательской логики для кодирования и декодирования пользовательских классов.
Обратите внимание, что включены не все свойства. Сериализуются только свойства, настроенные с установленным флагом PROPERTY_USAGE_STORAGE. Вы можете добавить новый флаг использования к свойству, переопределив метод _get_property_list в вашем классе. Вы также можете проверить, как настроено использование свойства, вызвав Object._get_property_list. См. PropertyUsageFlags для получения информации о возможных флагах использования.