Доброго времени суток, дорогой читатель. Скажу сразу – опыта разработки игр, а тем более разработки игр на 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
выделись. Всё зависит только от вашей фантазии.
Процесс сохранения.
-
Ищем все Actor’ы, которые реализуют
ISavableObject
-
Извлекаем из них
FSaveDataRecord
-
Создаём
GameSaveObject
. Внутри которого будем хранить только массив сFSaveDataRecord
в бинарном виде. Наконец-то идём строго по доке UE -
Сохраняем в Slot с именем
Процесс загрузки
-
Загружаем сохранение из Slot по имени
-
Десириализуем наш массив с
FSaveRecords
-
Удаляем всех Actor’ов, которых сохранили (всех которые реализуют наш интерфейс). Да, можно без этого.
-
Спавним 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
Спасибо всем за внимание. Я понимаю, что данных подход имеет ряд фатальных недостатков, но хотелось написать именно так, как это всё рождалось в голове. И я думаю, что статья может послужить шаблоном или отправной точкой к реализации вашей системы сохранения и загрузки игры и пояснить несколько совсем не очевидных моментов.
ссылка на оригинал статьи https://habr.com/ru/post/723270/
Добавить комментарий