Легковажна система збереження та завантаження в Godot 4 з C#: Практичний посібник

В останні кілька тижнів я працював над легким механізмом збереження та завантаження для своєї поточної гри, The Librarian. Оскільки сама гра досить проста, я вирішив зробити систему зрозумілою, зосередившись на основних елементах: збереження та завантаження з використанням JSON, керування слотами збережень та збереження сумісності збережень через оновлення (ніхто не любить втрачати свій прогрес після оновлення!)

У цій статті я розповім, як я реалізував цю систему за допомогою C# у Godot 4.3, зосереджуючись на ясності та гнучкості. Повний код, включаючи робоче меню збереження/завантаження, доступний у моєму репозиторії на GitHub. Якщо ви розробляєте невеликий проєкт або тільки починаєте працювати з системами збереження, цей посібник допоможе вам створити надійний фундамент.

pic

Вибір JSON для ваших файлів збереження: Переваги та недоліки

При виборі формату для збереження файлів існує кілька варіантів, таких як ресурси Godot, JSON та інші. Для загального огляду раджу подивитися це відмінне відео від Godotneers. Кожен формат має свої переваги та недоліки, тому важливо обрати той, який найкраще відповідає потребам вашого проєкту.

Я обрав JSON через його зручність для читання людьми та простоту ручного редагування, що було інтуїтивно зрозуміло з урахуванням мого досвіду веб-розробника. Оскільки моя гра досить проста, мене не бентежить додатковий крок, який JSON вимагає для роботи з складними типами даних, такими як Vector2 або об'єкти Resource. Якщо буде потрібно, я планую використовувати власні конвертори JSON для таких випадків.

Незалежно від того, який формат ви оберете, основна реалізація буде досить схожою. Тепер давайте перейдемо до того, як зберігати та завантажувати дані.

Реалізація основної системи збереження та завантаження

Для початку нам потрібні лише два класи:

  • SaveGameManager: Сінглтон, відповідальний за збереження, завантаження та керування файлами збережень.
  • SaveGameData: Контейнер даних, що використовується для збереження інформації, яку ми хочемо зберегти, а також для читання завантажених даних.

Опис класу SaveGameData: Дані для збереження

Почнемо з SaveGameData. Цей об'єкт даних представляє контент, який ви хочете зберегти в поточному форматі файлу збереження. Під час збереження файл міститиме серіалізовану версію екземпляра цього класу. Однак можуть бути ситуації, коли вам потрібно завантажити збереження, створене за допомогою попередньої версії вашої гри. Це буде описано в наступній статті і в основному залежить від властивості SaveVersion.

/// 
   /// Поточний об'єкт даних збереження гри.   ///    
   public class SaveGameData   
   {    
       public int SaveVersion { get; init; }       
       public string SavedAt { get; init; } = string.Empty;       
       public bool CharacterCreated { get; init; }       
       public string CharacterName { get; init; } = "Player";       
       public float CharacterPositionX { get; init; }       
       public float CharacterPositionY { get; init; }       
       public string CharacterDirection { get; init; } = "down";       
       public SaveGameDataResource ToResource()    
       {    
           return new SaveGameDataResource {    
               SavedAt = SavedAt,    
               CharacterCreated = CharacterCreated,    
               CharacterName = CharacterName,    
               CharacterPosition = new Vector2(CharacterPositionX, CharacterPositionY),    
               CharacterDirection = CharacterDirection,    
           };    
       }   
   } 

Є кілька важливих моментів, на які варто звернути увагу в цьому класі:

  • Обробка позиції персонажа:
    Позиція персонажа зберігається як дві окремі властивості (CharacterPositionX і CharacterPositionY), а не як Vector2. Це компроміс, необхідний через формат JSON, який підтримує лише примітивні типи. Складні типи потрібно вручну конвертувати.

  • Метод ToResource():
    Метод ToResource() перетворює екземпляр SaveGameData на об'єкт SaveGameDataResource, клас, який розширює об'єкт Resource у Godot.
    Ця конвертація необхідна, оскільки SaveGameManager випромінює сигнали, що містять завантажені дані, а сигнали Godot можуть передавати лише примітивні типи, вбудовані типи Godot або об'єкти, які успадковують від GodotObject. Сам SaveGameData не розширює Resource, щоб спростити серіалізацію та десеріалізацію.

Цей приклад надає простий спосіб перетворити дані збереження на об'єкт Resource безпосередньо в класі SaveGameData. Однак для складніших структур збереження було б краще обробити це перетворення в окремих класах-конвертерах, щоб зберегти кращу роздільність обов'язків.

Ось як виглядає ресурс:

using Godot;  

/// 
   /// Ресурс, що містить дані збереження гри, які будуть передані до ігрових систем через сигнали.   
   /// Сигнали Godot не підтримують сирі типи C#, тому нам потрібно використовувати ресурси для передачі даних.   
   ///    
   public partial class SaveGameDataResource : Resource   
   {    
       public string SavedAt { get; set; } = string.Empty;       
       public bool CharacterCreated { get; set; }       
       public string CharacterName { get; set; }       
       public Vector2 CharacterPosition { get; set; }       
       public string CharacterDirection { get; set; }   
   } 

Реалізація класу SaveGameManager

Клас SaveGameManager є сінглтоном, зареєстрованим як глобальний вузол у Godot. Це дозволяє звертатися до системи збереження з будь-якої частини проєкту (найчастіше з меню) за допомогою стандартного підходу:

SaveGameManager saveManager = GetNode("/root/SaveGameManager");

Як і очікується, цей клас надає методи для збереження та завантаження даних гри. У цій базовій реалізації об'єкт SaveGameData створюється безпосередньо в класі SaveGameManager. Однак для більш складних проєктів було б краще розділити цю логіку на окремі класи, щоб зробити код більш модульним.

using System;   
using System.Collections.Generic;   
using System.Linq;   
using System.Text.Json;   
using System.Text.Json.Serialization;   
using Godot;      

///    
   /// Керує збереженням та завантаженням даних гри.   
   ///    
   public partial class SaveGameManager : Node   
   {    
       private const int SAVE_VERSION = 1; // Збільшуйте це число, коли змінюється формат збереження гри       
       #region Signals    
       [Signal]    
       public delegate void SaveLoadedEventHandler(SaveGameDataResource saveGameData);    
       #endregion       
       ///     
       /// Зберігає поточний прогрес гри у вказаний файл збереження.    
       ///     
       public void Save(int identifier)    
       {    
           var json = JsonSerializer.Serialize(BuildSaveGameData(), GetJsonSerializerOptions());       
           using var file = FileAccess.Open(GetSaveGameFilePath(identifier), FileAccess.ModeFlags.Write);    
           file.StoreLine(json);    
           file.Flush();       
           GD.Print($"Збереження гри було збережене в {GetSaveGameFilePath(identifier)}");    
       }       
       ///     
       /// Завантажує прогрес гри з вказаного файлу збереження.    
       /// Параметр silent використовується, щоб запобігти виклику сигналу.  

///   
 public void Load(int identifier, bool silent = false)  
 {  
 if (!FileAccess.FileExists(GetSaveGameFilePath(identifier))) {  
 return;  
 }  

 using var saveFile = FileAccess.Open(GetSaveGameFilePath(identifier), FileAccess.ModeFlags.Read);  
 GD.Print($"Завантаження збереження з {GetSaveGameFilePath(identifier)}");  

 var fileContent = saveFile.GetAsText();  

 // Спочатку читаємо дані версії, щоб визначити, як завантажити збережену гру  
 // для цього ми використовуємо легку версію класу, яка містить лише номер версії  
 var versionData = JsonSerializer.Deserialize(fileContent);  
 GD.Print($"Визначено версію збереження #{versionData.SaveVersion}");  

 var loadedData = JsonSerializer.Deserialize(fileContent);  
 if (loadedData == null) {  
 GD.Print("Не вдалося завантажити збереження гри");  
 return;  
 }  

 if (!silent) {  
 EmitSignal(SignalName.SaveLoaded, LoadedSaveGameData);  
 }  
 }  

 /// 
    /// Створює та повертає об'єкт даних для збереження гри, який буде збережений у файл.    
    ///     
    private SaveGameData BuildSaveGameData()    
    {     
        var player = GetTree().GetNodesInGroup(Player.PLAYER_GROUP).FirstOrDefault() as Player;       
        return new SaveGameData    
        {    
            SaveVersion = SAVE_VERSION,    
            SavedAt = DateTime.UtcNow.ToString("o"),    
            CharacterName = player?.CharacterName ?? CurrentSaveSlot.CharacterName,    
            CharacterPositionX = player?.GlobalPosition.X ?? 0,    
            CharacterPositionY = player?.GlobalPosition.Y ?? 0,    
            CharacterDirection = player?.Direction ?? "down"    
        };    
    }       

    private static JsonSerializerOptions GetJsonSerializerOptions()    
    {    
        var options = new JsonSerializerOptions    
        {    
            IncludeFields = true,    
            PropertyNameCaseInsensitive = true,    
            DefaultIgnoreCondition = JsonIgnoreCondition.Never,    
            WriteIndented = true    
        };       
        return options;    
    }       

    private static string GetSaveGameFilePath(int identifier)    
    {    
        return $"user://savegame_{identifier.ToString()}.save";    
    }   
} 

Ось кілька важливих рішень щодо дизайну класу SaveGameManager:

  • Підхід до серіалізації:
    Клас використовує бібліотеку JSON з .NET Standard Library для серіалізації та десеріалізації. Це було обране дизайнерське рішення, але також можна розглянути альтернативи, такі як Newtonsoft.Json.NET або вбудовані інструменти JSON Godot.

  • Параметр silent:
    Метод Load() містить параметр silent, який запобігає виклику сигналу SaveLoaded. Це корисно, коли потрібно завантажити збереження без негайної зміни поточного стану гри. Для простих ігор оновлення стану гри часто можна обробити просто прослуховуючи цей подію (Event Listener).

  • Конструювання даних:
    Метод BuildSaveGameData() безпосередньо генерує об'єкт SaveGameData в межах класу SaveGameManager. Хоча цей підхід добре працює для малих проєктів, для більших або складніших ігор зазвичай краще делегувати цю відповідальність окремому класу.

Після реєстрації SaveGameManager як глобального вузла у вашому проєкті Godot збереження та завантаження стають настільки простими, як:

var saveGameManager = GetNode("/root/SaveGameManager");   
saveGameManager.SaveLoaded += (SaveGameDataResource saveGameData) => {    
    // Цей обробник події викликається, коли збереження завантажене    
    // це хороше місце для оновлення стану гри з використанням завантажених даних    
    GD.Print($"Ім'я персонажа в цьому збереженні: {saveGameData.CharacterName}");   
}      

// Завантажуємо перше збереження   
saveGameManager.Load(1);      

// Зберігаємо стан гри в перше збереження   
saveGameManager.Save(1);   

Що далі?

З цими двома класами ви вже маєте базову, але функціональну систему збереження і завантаження, здатну зберігати прогрес гри.
В майбутньому я розгляну, як розширити цю систему, додавши слоти збережень та реалізувавши належну підтримку версій для сумісності вперед.

До речі, під час реалізації цієї системи я не очікував, що інтерфейс користувача для керування збереженнями забере стільки часу — виявляється, що візуальне управління станами збережень потребує великої уваги до деталей. Управління новими збереженнями, завантаженням, збереженням і продовженням часто включає кілька елементів інтерфейсу користувача, і правильне оброблення змін стану може бути складним. Якщо вам цікаво, ви можете переглянути повну робочу реалізацію цієї системи, включаючи меню збережень та маленьку прототипну гру, на моєму GitHub Repository.

Сподіваюся, цей посібник допоможе вам налаштувати базову систему збереження для ваших проєктів! Якщо ви спробуєте її або адаптуєте далі, не соромтеся поділитися своїми відгуками або покращеннями. Щасливого кодування!

Дякую за прочитання! 🎉

Якщо вам сподобалась ця стаття, не забудьте переглянути інші мої матеріали:
Презентації та оновлення моїх ігор на моєму YouTube каналі.
→_ Перегляньте мої ігри і насолодіться тим, що кожен світ має запропонувати.

Залишайтеся з нами для нових оновлень і не соромтеся залишати відгуки або питання в коментарях. Щасливого кодування!

Перекладено з: Lightweight Saving & Loading System in Godot 4 with C#: A Practical Guide

Leave a Reply

Your email address will not be published. Required fields are marked *