Современное хранение игрового прогресса в контексте Unity. Часть 3

ggdev ggdev 9 Сентября 2024

Часть 1
Часть 2

Комбинация хранилищ данных:
Использование облачных технологий в подходах сохранения прогресса предоставляет много полезных возможностей и для игроков, и для разработчиков. Особенно для WebGL, где игрок вообще не привязан к конкретному устройству и браузеру.

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

Комбинированный подход позволяет сгладить недостатки каждого типа хранилища. От способа комбинации зависит то, какие недостатки и какие достоинства каждого из хранилищ проявят себя сильнее.

Часто под комбинацией имеется в виду подобная схема:

  • На старте приложения получаем актуальные данные из удалённого хранилища;
  • Помещаем данные в локальное хранилище;
  • Работаем в игре с локальным хранилищем;
  • Через N сек или M операций или по спец. событиям отправляем накопленный прогресс в удалённое хранилище.
  • Если игра закроется до того, как последние данные будут синхронизированы, при следующем запуске она сравнит, в каком хранилище данные более свежие, и будет использовать их.

Описанные процессы — весьма упрощены. В проектах разных масштабов используются разнообразные хитрости и усложнения. Обычно они направлены на защиту от разного рода жульничества со стороны игроков, но могут быть и нацелены на решение других проблем.

Накопление чего-то с целью последующей обработки одним большим пакетом — это батчинг. А сам пакет — батч.

public sealed class Batch
{
    private readonly Dictionary _writeArgs = new();
    private readonly HashSet _deleteArgs = new();

    public IEnumerable> WriteArgs => _writeArgs;
    public IEnumerable DeleteArgs => _deleteArgs;

    public void CollectWriteOp(string key, stirng serializedData)
    {
        _deleteArgs.Remove(key);
        _writeArgs[key] = serializedData;
    }

    public void CollectDeleteOp(string key)
    {
        _writeArgs.Remove(key);
        _deleteArgs.Add(key);
    }

    public void Clear()
    {
        _writeArgs.Clear();
        _deleteArgs.Clear();    
    }
}

Хранилище, основанное на комбинации подобного рода, будем называть BatchDataStorage. А базовую абстракцию с возможностью переопределения условия отправки батча можно представить в таком виде:

public abstract class BatchDataStorage : IDataStorage
{
    private readonly IDataStorage _hotStorage;
    private readonly IDataStorage _coldStorage;

    private readonly Batch _batch = new();

    protected BatchDataStorage(
        IDataStorage hotStorage, IDataStorage coldStorage)
    {
        _hotStorage = hotStorage;
        _coldStorage = coldStorage;
    }

    public async UniTask InitializeAsync()
    {
        Dictionary allData =
            await _coldStorage.LoadAllAsync();
        await _hotStorage.WriteAsync(allData);
    }

    public UniTask ReadAsync(string key) =>
        _hotStorage.ReadAsync(key);

    public UniTask WriteAsync(string key, string serializedData) =>
        _hotStorage.WriteAsync(key, serializedData).ContinueWith(() =>
        {
            _batch.CollectWriteOp(key, serializedData);
            OnBatchUpdated();
        });

    public UniTask DeleteAsync(string key) =>
        _hotStorage.DeleteAsync(key).ContinueWith(() =>
        {
            _batch.CollectDeleteOp(key);
            OnBatchUpdated();
        });

    public UniTask ExistsAsync(string key) =>
        _hotStorage.ExistsAsync(key);

    protected abstract void OnBatchUpdated();

    protected async UniTask CommitBatchAsync()
    {
        foreach ((string key, string serializedData) in _batch.WriteArgs)
            await _coldStorage.WriteAsync(key, serializedData);

        foreach (string key in _batch.DeteleArgs)
            await _coldStorage.DeleteAsync(key);

        _batch.Clear();
    }
}

Соответственно из этого можно сделать вариант с накоплением батча за счёт некоторой задержки:

public sealed class DelayedBatchDataStorage : BatchDataStorage
{
    private readonly float _batchDelay;

    private bool _batchDelayed;

    public BatchDataStorageWithDelay(IDataStorage hotStorage,
        IDataStorage coldStorage, float batchDelay)
        : base(hotStorage, coldStorage) =>
            _batchDelay = batchDelay;

    protected override void OnBatchUpdated()
    {
        if (!_batchDelayed)
            DelayBatchAsync();
    }

    private async UniTask DelayBatchAsync()
    {
        _batchDelayed = true;
        await UniTask.WaitForSeconds(_batchDelay);
        await CommitBatchAsync();
        _batchDelayed = false;
    }

Или вариант с отправкой батча через N операций:

public sealed class LimitedBatchDataStorage : BatchDataStorage
{
    private readonly int _updatesLimit;

    private int _updatesCounter;

    public BatchDataStorageWithLimit(IDataStorage hotStorage,
        IDataStorage coldStorage, int updatesLimit)
        : base(hotStorage, coldStorage) =>
            _updatesLimit = updatesLimit;

    protected override void OnBatchUpdated()
    {
        _updatesCounter++;
        if (_updatesCounter >= _updatesLimit)
        {
            _updatesCounter = 0;
            CommitBatchAsync().Forget();
        }
    }
}

Существует множество вариантов условий отправки батча или комбинаций использования локального и удалённого хранилищ. Главное, что потребуется, это настроенный контракт в виде IDataStorage, который можно заменить на локальное, удалённое или любое другое хранилище.

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

public sealed class SaveSystem : ISaveSystem
{
    private readonly ISerializer _serializer;
    private readonly IDataStorage _dataStorage;
    private readonly IKeysProvider _keysProvider;

    public SaveSystem(ISerializer serializer,
        IDataStorage dataStorage, IKeysProvider keysProvider)
    {
        _serializer = serializer;
        _dataStorage = dataStorage;
        _keysProvider = keysProvider;
    }

    public async UniTask SaveAsync(TData data) where TData : ISaveData
    {
        string dataKey = _keysProvider.Provide();
        string serializedData = await _serializer.SerializeAsync(data);
        await _dataStorage.WriteAsync(dataKey, serializedData);
    }

    public async UniTask LoadAsync() where TData : ISaveData
    {
        string dataKey = _keysProvider.Provide();
        string serializedData = await _dataStorage.ReadAsync(dataKey);
        return await _serializer.DeserializeAsync(serializedData);
    }
}

За счёт использования абстракций и принципа внедрения зависимостей есть возможность задавать различные варианты поведения, не меняя при этом саму систему, которая в целом сохранила свою простоту: в каждой операции по 3 действия с 3 модулями на 3 строчки.

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

Реализация какого-нибудь инструмента конфигурирования для системы позволяет наделать несколько пресетов зависимостей под различные режимы сборки и на этапе загрузки DI-фреймворком это всё собрать и запустить.

4.jpg

Заключение
Приведённый вариант реализации — тепличный и демонстрационный. Можно ли его заиспользовать — можно. Но нужно ли?

Условия задачи построения системы сохранений на каждом проекте различаются. В зависимости от этих условий итоговая система может оказаться как сильно проще, так и сильно сложнее.
Может проекту и не нужны ни облака, ни гибкость, ни масштабируемость, и прямого использования PlayerPrefs хватает «за глаза». И это нормально, если условия позволяют. Простое решение — лучшее решение.

Пытаться строить универсальную систему на все случаи жизни — на мой взгляд, дело гиблое. Получится дорогой монстр (если вообще получится).
В этой статье я хотел поделиться не готовым решением, а последовательностью мыслей, которые привели к итоговому решению.

Важно, из каких элементов состоит создаваемая система, как они друг с другом взаимодействуют и как их можно использовать для удовлетворения условий. А условия уже сами продиктуют реализацию.

Автор

Опубликовано в Туториалы по Unity
Для ответа вы можете авторизоваться