Как стать автором
Обновить
782.92
OTUS
Цифровые навыки от ведущих экспертов

Система сохранения на Unity для начинающих

Уровень сложностиПростой
Время на прочтение6 мин
Количество просмотров8.6K

Привет, Хабр! Меня зовут Игорь, и я Unity Developer. В этой статье попытаюсь максимально просто рассказать и показать, как написать систему сохранения в игре в Unity. Скажу сразу, статья ориентирована для тех, кто только начинает свой путь в GameDev. Поэтому некоторые архитектурные принципы будут нарушены с целью упрощения кода. Ближе к концу статьи покажу как сохранять миссии, про которые писал ранее. Ну что ж, поехали...

Репозиторий данных

Прежде чем начать делать систему сохранения, нужно четко определить, что код-база игры и код-база сохранения — это разные вещи, которые между собой не должны быть связаны. Поэтому первым делом выделим класс для сохранения и загрузки данных с диска. Такой класс называется репозиторий:

using System.Collections.Generic;
using Newtonsoft.Json;
using UnityEngine;

public static class Repository
{
    private const string GAME_STATE_KEY = "GameState";

    private static Dictionary<string, string> currentState = new();

    //Загрузить данные с диска
    public static void LoadState()
    {
        if (PlayerPrefs.HasKey(GAME_STATE_KEY))
        {
            var serializedState = PlayerPrefs.GetString(GAME_STATE_KEY);
            currentState = JsonConvert.
              DeserializeObject<Dictionary<string, string>>(serializedState); 
        }
        else
        {
            currentState = new Dictionary<string, string>();
        }
    }

    //Сохранить данные на диск
    public static void SaveState()
    {
        var serializedState = JsonConvert.SerializeObject(currentState);
        PlayerPrefs.SetString(GAME_STATE_KEY, serializedState);
    }

    public static T GetData<T>()
    {
        var serializedData = currentState[typeof(T).Name];
        return JsonConvert.DeserializeObject<T>(serializedData);
    }

    public static void SetData<T>(T value)
    {
        var serializedData = JsonConvert.SerializeObject(value);
        currentState[typeof(T).Name] = serializedData;
    }

    public static bool TryGetData<T>(out T value)
    {
        if (currentState.TryGetValue(typeof(T).Name, out var serializedData))
        {
            value = JsonConvert.DeserializeObject<T>(serializedData);
            return true;
        }

        value = default;
        return false;
    }
}

Данный класс в методах LoadState() & SaveState() загружает и сохраняет данные на диск, используя PlayerPrefs от Unity. Для сериализации данных в формат JSON используется библиотека Json.NET, которая, в отличие от встроенной, умеет сериализовывать словари, массивы и более сложные структуры данных.

Чтобы хранить актуальное состояние игры для загрузки и сохранения, используется static поле currentState. Когда данные в игре будут меняться, например кол-во монет, то будет вызываться метод SetData<T>(), который будет обновлять currentState.

Какие плюсы можно выделить, используя такой репозиторий:

  1. Модульность. Можно повторно использовать класс в разных проектах

  2. Поддерживаемость. Если вы захотите изменить способ сохранения, например, сохранять данные в файл или на сервер, то нужно всего лишь поменять строчки кода в методах LoadState() & SaveState()

Пример сохранения денег

Давайте предположим, что в RPG игре есть деньги игрока, которые нужно сохранять.

Механика денег
Механика денег

И класс кошелька игрока выглядит так:

using System;
using UnityEngine;

//Кошелек игрока в RPG:
public sealed class MoneyStorage : MonoBehaviour
{
    public static MoneyStorage Instance; //Синглтон для начинающих...

    public event Action<int> OnMoneyChanged; //Событие изменения денег

    [SerializeField]
    private int currentMoney; //Текущее кол-во денег

    private void Awake()
    {
        Instance = this;
    }

    //Инициализирует кол-во денег
    public void SetupMoney(int money)
    {
        this.currentMoney = money;
    }

    //Возвращает текущее кол-во денег
    public int GetMoney()
    {
        return this.currentMoney;
    }

    //Добавляет деньги в кошелек
    public void EarnMoney(int range)
    {
        this.currentMoney += range;
        this.OnMoneyChanged?.Invoke(this.currentMoney);
    }

    //Списывает деньги из кошелька
    public void SpendMoney(int range)
    {
        this.currentMoney -= range;
        this.OnMoneyChanged?.Invoke(this.currentMoney);
    }
}

Для того, чтобы сохранять состояние кошелька, нужен специальный адаптер, который будет взаимодействовать с репозиторием. Такой адаптер назовем MoneySaveLoader:

Применение шаблона GRASP: Indirection
Применение шаблона GRASP: Indirection

Пример кода адаптера MoneySaveLoader:

//Данные для сохранения монет
[Serializable]
public struct MoneyData
{
    public int money;
}

public sealed class MoneySaveLoader
{
    //Загружает данные денег из репозитория в кошелек
    public void LoadData()
    {
        MoneyData data = Repository.GetData<MoneyData>();
        MoneyStorage.Instance.SetupMoney(data.money);
    }

    //Сохраняет данные из кошелька в репозиторий
    public void SaveData()
    {
        int money = MoneyStorage.Instance.GetMoney();
        var data = new MoneyData
        {
            money = money
        };
        Repository.SetData(data);
    }
}

MoneySaveLoader позволяет сохранять и загружать данные из игры в репозиторий и обратно, сохраняя независимость кошелька и хранилища друг от друга в проекте. Такая архитектура позволяет проще поддерживать код в долгосрочной перспективе.

Теперь давайте посмотрим, как будут происходить вызовы методов LoadData() & SaveData()...

Менеджер сохранений

Чтобы вызывать методы LoadData() & SaveData() в классе MoneySaveLoader, нужен менеджер сохранений. Менеджер сохранений — это класс, который будет выполнять команды загрузки и сохранения игры, управляя репозиторием и адаптером:

Менеджер сохранения
Менеджер сохранения

Команда загрузки будет состоять из двух пунктов:

1. Загрузить данные с диска в репозиторий

2. Сказать MoneySaveLoader, чтобы он загрузил данные из репозитория в кошелек

Команда сохранения будет состоять тоже из двух пунктов:

1. Сказать MoneySaveLoader, чтобы он сохранил данные из кошелька в репозиторий

2. Сохранить данные в репозитории на диск

В результате код менеджера сохранений будет выглядеть так:

public sealed class SaveLoadManager : MonoBehaviour
{
    private readonly MoneySaveLoader moneySaveLoader = new();
    // private readonly ResourceSaveLoader resourceSaveLoader = new();
    // private readonly MissionSaveLoader missionSaveLoader = new();
    
    [ContextMenu("Load Game")]
    public void LoadGame()
    {
        Repository.LoadState();
        this.moneySaveLoader.LoadData();
        //this.resourceSaveLoader.LoadData();
        //this.missionSaveLoader.LoadData();
    }

    [ContextMenu("Save Game")]
    public void SaveGame()
    {
        this.moneySaveLoader.SaveData();  
        //this.resourceSaveLoader.SaveData();
        //this.missionSaveLoader.SaveData();
        Repository.SaveState();
    }
}

На самом деле, такая архитектура класса будет хрупкая, потому что при добавлении новых сохраняемых фич в игру, придется везде прописывать строчки с их адаптерами...

Поэтому лучшим вариантом будет определить универсальный интерфейс ISaveLoader, который будет загружать и сохранять данные:

//Интерфейс адаптера
public interface ISaveLoader
{
    void LoadData();
    void SaveData();
}


//Теперь менеджер сохранений будет работать через интерфейс
public sealed class SaveLoadManager : MonoBehaviour
{
    private readonly ISaveLoader[] saveLoaders = {
        new MoneySaveLoader(), //В идеале юзать DI
    };
    
    public void LoadGame()
    {
        Repository.LoadState();

        foreach (var saveLoader in this.saveLoaders)
        {
            saveLoader.LoadData();
        }
    }

    public void SaveGame()
    {
        foreach (var saveLoader in this.saveLoaders)
        {
            saveLoader.SaveData();
        }
        
        Repository.SaveState();
    }
}

Таким образом, менеджер будет работать через интерфейс ISaveLoad, тем самым соблюдать принцип Open-Closed, а адаптер сохранения денег будет реализовывать этот интерфейс:

public sealed class MoneySaveLoader : ISaveLoader {
    //Same code...
}

Сохранение миссий

Теперь давайте предположим, что в нашей игре добавились миссии, которые тоже нужно сохранять и загружать. Аналогично логике сохранения денег, диаграмма сохранения миссий будет идентичной:

MissionsSaveLoader — точно такой же адаптер, который будет обеспечивать взаимодействие загрузки и сохранения с репозиторием.

//Данные для сохранения одной миссии
[Serializable]
public struct MissionData
{
    public string id;
    public MissionDifficulty difficulty;
    public float progress;
}

//Адаптер для сохранения миссий:
public sealed class MissionsSaveLoader : ISaveLoader
{
    void ISaveLoader.SaveData()
    {
        //Берем активные миссии из игры:
        Mission[] missions = MissionsManager.Instance.GetMissions();
        int count = missions.Length;

        //Преобразуем миссии в данные:
        MissionData[] dataSet = new MissionData[count]; 
        for (int i = 0; i < count; i++)
        {
            Mission mission = missions[i];
            dataSet[i] = new MissionData
            {
                id = mission.Id,
                difficulty = mission.Difficulty,
                progress = mission.GetProgress()
            };
        }

        //Сохраняем массив миссий в репозиторий:
        Repository.SetData(dataSet);
    }

    void ISaveLoader.LoadData()
    {
        //Загружаем сохраненные миссии в виде данных:
        MissionData[] dataSet = Repository.GetData<MissionData[]>();
        int count = dataSet.Length;
        
        for (int i = 0; i < count; i++)
        {
            MissionData data = dataSet[i];

            //Создаем миссию на основе данных
            Mission mission = MissionFactory.Instance.Create(data);

            //Вставляем миссию в игру
            MissionsManager.Instance.SetMission(data.difficulty, mission);
        }
    }
}

Подключаем MissionsSaveLoader в менеджер миссий:

public sealed class SaveLoadManager : MonoBehaviour
{
    private readonly ISaveLoader[] saveLoaders = {
        new MoneySaveLoader(),
        new MissionsSaveLoader() //Добавляем адаптер миссий
    };

    //Same code...
}

Собрав систему у себя в проекте, убедился, что все работает.

Таким образом, постарался показать, как можно сделать универсальную систему сохранения данных в игре. Самое приятное, что классы Repository, SaveLoaderManager и ISaveLoader можно переиспользовать в других проектах, особенно в простых казуалках. Если у вас возникло желание пощупать получившееся решение, то вы можете скачать архив с проектом.

На этом у меня все. Спасибо за внимание :)


Рекомендую посетить вторую часть занятия, посвещенного соданию Top-Down шутера на Unity с нуля. На ней участники добавят NPC и интерактивные объекты на уровень. Записаться можно на странице курса базового курса по Unity-разработке.

Первая часть мастер-класса доступна в записи по ссылке.

Теги:
Хабы:
Всего голосов 12: ↑7 и ↓5+3
Комментарии4

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS