Всё так прекрасно начиналось
Всё так прекрасно начиналось

Доброго времени суток, дорогой читатель. Скажу сразу – опыта разработки игр, а тем более разработки игр на Unreal Engine у меня немного, но опыт работы в других областях есть. К сожалению, вход в удивительную профессию проходит у меня безумно хаотично. Фраза «ориентируемся по приборам» плотно засела у меня в голове и в целом эта статья об этом.

Так о чём статья?

Статья о поисках способа сохранить состояние игры, которое было бы чуть более сложнее, чем простой счётчик очков или позиции Actor’ов. А именно, чтобы можно было маркировать Actor’ов, которых нужно сохранить и маркировать то, что именно в них сохранять, включая жесткие ссылки на других Actor’ов, которые были добавлены на сцену с помощью Unreal Engine Editor.

Если посмотреть в официальную документацию, то можно обнаружить следующие ссылка на оф. доку

В этой статьи буквально говориться о том, что можно создать некий объект, в который можно записать какие-то значения и сохранить это в Slot. Сразу возникает несколько вопросов:

  • А можно сохранить всего Actor’a?

  • А если да, то, как сохранить только нужное?

  • А что нужно сохранять?

  • А как это потом загружать?

  • А как сохранить и загрузить ссылку на другого Actor’a на сцене?

Вопросов много, ответов мало. Поэтому пришлось их искать. Искать на форуме, в Google, смотрел видео на YouTube, использовал СhatGPT и задавал вопросы в RU-комьюнити Unreal Engine в Telegram.

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

Концепция

Я начну с конца и опишу то, что получилось и далее буду гооврить о реализации каждой из деталей.

Для маркировки сохраняемых Actor’в, используется интерфейс ISavableObject.

Добавлена структура FSaveDataRecord, которая содержит в себе всю необходимую информацию для восстановления состояния объекта.

Конкретная реализация механизмов сохранения и загрузки состояния из структуры FSaveDataRecord находится в отдельном SaveLoadComponent.

Сам механизм сохранения и загрузки описан в custom’ом AGameState.

Механизм сохранения и загрузки основан на бинарной сериализации данных как Actor'ов, так и самого GameState и их сохранения с помощью USaveGame объекта.

Реализация. Боль, отчаянье и очень много вопросов

FSaveDataRecord. Структура для хранения состояния объекта

В целом, можно выбрать свой набор уникальных данных для Actor’a, но я остановился на следующем:

USTRUCT(BlueprintType)
struct FSaveDataRecord
{
   GENERATED_BODY()
   
public:
   UPROPERTY(SaveGame)
   UClass* ActorClass;

   UPROPERTY(SaveGame)
   FString ActorName;

   UPROPERTY(SaveGame)
   FTransform ActorTransform;

   UPROPERTY(SaveGame)
   TArray<uint8> BinaryData;
};

Отдельно скажу про BinaryData. Это как раз наш массив байт, куда будет помещать данные бинарный сериализатор.

При загрузке состояния мира, из FSaveDataRecord структур будут создаваться Actor’ы класса ActorClass, с именем ActorName и позицией ActorTransform, после чего будет восстановлено их состояние с помощью BinaryData.

Почему именно так? Потому что Transform, Name и ряд других свойств Actor'а нельзя пометить флагом SaveGame (о нём отдельно будет сказано далее) и именно поэтому оно лежит отдельными свойствами, а не в бинарном массиве.

ISavableObject интерфейс с нюансами

Казалось бы, что может быть проще? Но есть нюансы. Сам интерфейс

UINTERFACE()
class USavableObject : public UInterface
{
   GENERATED_BODY()
};

class SAVELOADSAMPLE_API ISavableObject
{
   GENERATED_BODY()

public:
   UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category=SaveLoad)
   FSaveDataRecord GetFSaveDataRecord();
   virtual FSaveDataRecord GetFSaveDataRecord_Implementation();

   UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category=SaveLoad)
   void LoadFromFSaveDataRecord();
   virtual void LoadFromFSaveDataRecord_Implementation();
};

Теперь о нюансах.

Предположим, нам нужно найти всех Actor'ов на сцене, которые реализуют этот интерфейс.

Создадим BP (Blueprint) Actor и реализуем наш интерфейс. Все это делается в Unreal Engine Editor'е элементарно и я не буду на этом останавливаться.

Затем, вызовем следующий код

TArray<AActor*> FindActors;
UGameplayStatics::GetAllActorsWithInterface(GetWorld(), 
                                            USavableObject::StaticClass(),
                                            FindActors);
for(const auto Actor : FindActors)
{
   const auto SavableActor = Cast<ISavableObject>(Actor);
   if(SavableActor)
      DataRecords.Add(SavableActor->GetFSaveDataRecord());
}

Что происходит?

С помощью GetAllActorsWithInterface находим на сцене всех Actor'ов, которые реализуют ISavableObject. Внимательный читатель мог заметить, что данная функция принимает USavableObject::StaticClass()(в рамках UE у каждого UObject есть свой статически UClass, который хранит в себе всю метаинформацию) класса USavableObject, что не удивительно, т.к. только он наследуется от UObject.

Для того, чтобы нам воспользоваться методами определенными ISavableObject, нужно привести найденные AActor к этому типу с помощьюCast<>

Посмотрим в отладчик

Смотрим в отладчик
Смотрим в отладчик

Cast к ISavableObject найденного Actor'а с помощью GetAllActorsWithInterface и USavableObject::StaticClass() вернулNULL.

Сразу скажу, что в случае, если Actor это C++ класс, а не BP, то все будет работать как ожидается. А теперь ещё раз.

Если BP класс реализует интерфейс, то каст этого BP класса к интерфейсу вернёт NULL (то есть скастовать не получится). Что, является не таким очевидным, как может показаться на первый взгляд, а также этот момент полностью опущен в официальной документации.

Как быть?

Дело в том, что сам класс ISavableObject, после чудо генерации UE содержит в себе Execute методы, которыми можно воспользоваться. Сам Execute метод принимает первым параметром UObject для которого должен быть реализован данный интерфейс, а остальными принимает параметры Execute функции. То есть, чтобы работало для BP классов, нужно сделать примерно вот так (к сожалению в официальной документации этот момент тоже опущен)

TArray<AActor*> FindActors;
UGameplayStatics::GetAllActorsWithInterface(GetWorld(),
                                            USavableObject::StaticClass(),
                                            FindActors);

for(const auto Actor : FindActors)
{
   DataRecords.Add(ISavableObject::Execute_GetFSaveDataRecord(Actor));
}

Более того, как вы могли заметить, UFUNCTION интерфейса ISavableObject являются BlueprintNativeEvent, то есть представляют из себя event или функцию внутри BP, который унаследовал этот интерфейс. И вот если реализация этих функций находится в BP, а вызывается из C++ кода, то единственный легитимный способ вызова этого BP event будет как раз через сгенерированные Execute методы.

SaveLoadComponent. Агрегация вместо наследования

Eщё один нюанс с ISavableObject. Код, который будет заниматься сохранением и восстановлением состояния объекта должен быть написан на C++.

Вот только что делать, если уже выстроена иерархия наследования BP классов? Можно конечно создать C++ базовый класс и наследовать всё от него, но не факт, что логика получения FSaveDataRecord не будет разниться от Actor’а к Actor’у. Поэтому нужна какая-то возможность предоставлять свою реализацию для конкретного BP класса. А так как наследоваться C++ классы от BP классов не могут, то мы непременно разрываем цепочку наследования, если захотим добавить конкретную реализацию нашего интерфейса на C++.

Поэтому всю логику создания скукоживания и  использования раскукоживания FDataSaveObject можно инкапсулировать в отдельном SaveLoad Actor Component’е, который будет добавляться к актору. После написания статьи, автор осознал, что оптимальнее всего это хранить в обычном UObject.

Теперь к реализации

FSaveDataRecord USaveLoadComponent::GetFSaveDataRecord() const
{
   FSaveDataRecord Record = FSaveDataRecord();

   auto OwnerActor = GetOwner();
   if(!OwnerActor)
      return Record;

   Record.ActorClass = OwnerActor->GetClass();
   Record.ActorName = OwnerActor->GetName();
   Record.ActorTransform = OwnerActor->GetTransform();

   FMemoryWriter Writer = FMemoryWriter(Record.BinaryData);
   FObjectAndNameAsStringProxyArchive Ar(Writer, false);
   Ar.ArIsSaveGame = true;

   OwnerActor->Serialize(Ar);
   
   return Record;
   
}

Вот мы и дошли до бинарной сериализации. С помощью неё мы сохраняем каждое свойство Actor’a, которое помечено SaveGame флагом (встроенный в unreal engine флаг)

И в обратную сторону

void USaveLoadComponent::LoadFromFSaveDataRecord(FSaveDataRecord Record) const
{
   auto OwnerActor = GetOwner();
   if(!OwnerActor)
      return;

   FMemoryReader Reader = FMemoryReader(Record.BinaryData);
   FObjectAndNameAsStringProxyArchive Ar(Reader, false);
   Ar.ArIsSaveGame = true;
   
   OwnerActor->Serialize(Ar);
}

Вас тоже смущает, что десиреализация и сериализация запускается с помощью одного и того же метода Serialize? Это только вершина айсберга.

Serialize и FArchive. Видишь документацию? Нет? А она есть.

Давайте по строкам, но с конца. OwnerActor->Serialize(Ar)

Каким образом метод Serialize понимает, что ему делать? Можно подумать, что каким-то образом замешаны типы FMemoryReader и FMemoryWriter и рефлексия, но нет.

Сразу строит сказать, что FMemoryReader и FMemoryWriter это так называемые FArchive которые предназначены для записи разного рода данных в память.

При создании FMemoryWriter внутри происходит переключение тумблера с помощью SetIsSaving(true). Собственно если это FMemoryReader , то происходит не менее удивительное SetIsLoading(true). Эти вызовы не специфичны конкретно для этих типов, а содержатся в базовой реализации FArchive. Одна загадка решена.

Хорошо, но тогда из всего зоопарка архивов, что выбрать? Этот вопрос возникает как раз из-за того, что документации по бинарной сериализации не существуют. Да, вы можете посмотреть на семплы (если вы знаете куда смотреть), но эти примеры обычно специфичны для конкретной игры.

В принципе, если вы будете использовать просто FMemoryWriter/Reader, то оно, может быть, вас и устроит, пока вы не попытаетесь скормить ему strong ref или что-то подобное.

А зачем пытаться сохранить жесткие ссылки?

Это именно для тех случаев, когда вы создаете связи между Actor'ами в Editor'е. То есть вы точно знаете, что эти акторы будут существовать на момент того, как вы попытаетесь загрузить состояние Actor'ов. И да, я знаю, что ID изменяется при старте игры и тем более ссылка, но имя не изменяется и, в принципе, ссылку на Actor возможно восстановить.

Почему не получится через FMemoryWriter/Reader?

В архивы данные добавляются с помощью перегруженного оператора <<. Невероятно удобно, но проблема в том, что в ближайшей реализации (FMemoryArchive - родитель FMemoryReader) этого оператора для UObject* стоит убивалка runtime в виде

virtual FArchive& operator<<( class UObject*& Res ) override
	{
		// Not supported through this archive
		check(0);
		return *this;
	}

Если посмотреть ещё глубже, то разработчики оставили нам замечание в виде

/**
* Serializes an UObject value from or into this archive.
*
* This operator can be implemented by sub-classes that wish to serialize UObject instances.
*
* @param Value The value to serialize.
* @return This instance.
*/

Значит нужно искать архив, который так умеет. Почему я был уверен, что оно так умеет? Да потому что undo/redo в UE Editor работает именно так. Оно может восстановить ссылки, а UE Editor чуть больше чем полностью прошит кодом UE.

В процессе интенсивного гугления был найден FObjectAndNameAsStringProxyArchive, который как раз и предназначен для сериализации UObject*.

На самом деле этот прокси архив наследуется от FNameAsStringProxyArchive который позволяет сериализовывать ещё один неудобный тип FName.

Почему прокси архив? Да потому что он внутри себя должен содержать другой архив, который будет заниматься сериализацией «нормальных» типов данных (формулировка из комментариев в исходном коде)

Собственно, из вот этого всего и вытекает то, как сериализуются и десириализуются данные Actor’ов, в SaveLoadComponent.

А как сохранять всё? GameState

В GameState вынесена основная логика загрузки и сохранения игры. Вы можете её поместить куда угодно, хоть в отельную Subsystem выделись. Всё зависит только от вашей фантазии.

Процесс сохранения.

  1. Ищем все Actor'ы, которые реализуют ISavableObject

  2. Извлекаем из них FSaveDataRecord

  3. Создаём GameSaveObject. Внутри которого будем хранить только массив с FSaveDataRecord в бинарном виде. Наконец-то идём строго по доке UE

  4. Сохраняем в Slot с именем

Процесс загрузки

  1. Загружаем сохранение из Slot по имени

  2. Десириализуем наш массив с FSaveRecords

  3. Удаляем всех Actor'ов, которых сохранили (всех которые реализуют наш интерфейс). Да, можно без этого.

  4. Спавним Actor'ов основываясь на данных из FSaveDataRecord и после спавна, вызываем у них LoadFromFSaveDataRecord для восстановления пользовательских данных.

Кастомный USaveGame выглядит так

class SAVELOADSAMPLE_API UDemoSaveGame : public USaveGame
{
   GENERATED_BODY()
public:
   UPROPERTY()
   TArray<uint8> ByteData;
};

Тут хранятся только бинарные данные, но в целом никто не мешает расширить этот класс и добавить имя игрока и его индекс и т.д.

Код сохранения состояния Actor'ов

DataRecords.Empty();
SaveActors();

TArray<uint8> BinaryData;
FMemoryWriter Writer = FMemoryWriter(BinaryData);
FObjectAndNameAsStringProxyArchive Ar(Writer, false);
Ar.ArIsSaveGame = true;

this->Serialize(Ar);

auto SaveInstance = Cast<UDemoSaveGame>(UGameplayStatics::CreateSaveGameObject(UDemoSaveGame::StaticClass()));
SaveInstance->ByteData = BinaryData;
UGameplayStatics::SaveGameToSlot(SaveInstance, TEXT("TestSave"), 0);

Что происходит? UDemoSaveGame содержит в себе UPROPERTY, которое помечено флагом SaveGame. Это же свойство хранит данные о Actor в виде TArray<FSaveDataRecord> . Поэтому при сериализации UDemoSaveGame, в TArray<uint8> BinaryData будет помещен только TArray<FSaveDataRecords>

Далее полученные бинарные данные мы сохраняем в Slot, как описано в официальной документации.

Метод SaveActors() приведу на всякий случай, но в комментариях он уже не нуждается.

TArray<AActor*> FindActors;
UGameplayStatics::GetAllActorsWithInterface(GetWorld(), USavableObject::StaticClass(), FindActors);
for(const auto Actor : FindActors)
{
   DataRecords.Add(ISavableObject::Execute_GetFSaveDataRecord(Actor));
}

Код загрузки

DataRecords.Empty();
ClearActors();

auto LoadGameInstance = Cast<UDemoSaveGame>(UGameplayStatics::LoadGameFromSlot(SlotName, 0));
if(!LoadGameInstance)
   return;
FMemoryReader Reader = FMemoryReader(LoadGameInstance->ByteData);
FObjectAndNameAsStringProxyArchive Ar(Reader, false);
Ar.ArIsSaveGame = true;

this->Serialize(Ar);

auto World = GetWorld();
for (auto i = 0; i < DataRecords.Num(); ++i)
{
   auto RestoredActor = World->SpawnActor<AActor>(DataRecords[i].ActorClass,
      DataRecords[i].ActorTransform.GetLocation(),
      DataRecords[i].ActorTransform.GetRotation().Rotator());
   RestoredActor->SetActorLabel(DataRecords[i].ActorName);
   if(UKismetSystemLibrary::DoesImplementInterface(RestoredActor, USavableObject::StaticClass()))
      ISavableObject::Execute_LoadFromFSaveDataRecord(RestoredActor, DataRecords[i]);
   
}

DataRecords.Empty();

Что происходит? Как и описано было выше

  • Загружаем из нашего слота бинарные данные

  • Десириализуем их в TArray<FSaveDataRecord>

  • Спавним наших акторов и передаём в них данные для десириализации состояния

Вот так это спроектировано.

Используем. Небольшое Демо

По ссылке github лежит небольшая демка, которая показывает, как работает описанный выше save/load механизм.

В демке есть BP_Placable Actor и BP_Holder Actor.

BP_Placable хранит в себе массив ссылок на BP_Holder к которым он может быть пристыкован.

Для того, чтобы пристыковать BP_Placable к BP_Holder из этого списка, нужно пойди к BP_Placable и нажать на кнопку 1 или 2 или 3. После каждой стыковки изменяется состояние BP_Placable (прибавляется единица) которое отображается в виде текста на самом Actor'е.

Для того того, чтобы сохранить игру - нужно нажать F6. Чтобы загрузить - F9.

Пример под катом показывает сохранение состояния Actor'а, а затем его загрузку с восстановлением его позиции и состояния, а также после загрузки Actor'а можно начать его перемещать к другим Holder'ам, что говорит о том, что массив ссылок на BP_Holder был тоже восстановлен.

Сохранения состояния и загрузка во время игры

Этот пример показывает, что после перезагрузки игры, все данные также восстанавливаются нормально.

Загрузка сохранения после перезапуска игры

Что в итоге?

В итоге это работает и используется и активно допиливается под нужды.

Оставлю тут список ссылок, которые мне показались особенно полезными в моём путешествии:
https://slowburn.dev/blog/polymorphic-serialization-in-unreal-engine/
https://forums.unrealengine.com/t/spawning-actors-from-serialized-data/68278/14
https://www.stevestreeting.com/2020/11/02/ue4-c---interfaces---hints-n-tips/

И очень полезная статья, которую я нашёл после:
https://www.tomlooman.com/unreal-engine-cpp-save-system/

Продублирую ссылку на свою демку:
https://github.com/Antonbreakble/SaveLoadSample

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

Комментарии (1)


  1. Pendalf61
    00.00.0000 00:00

    Спасибо за подробную статью