Доброго времени суток, дорогой читатель. Скажу сразу – опыта разработки игр, а тем более разработки игр на 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
Спасибо всем за внимание. Я понимаю, что данных подход имеет ряд фатальных недостатков, но хотелось написать именно так, как это всё рождалось в голове. И я думаю, что статья может послужить шаблоном или отправной точкой к реализации вашей системы сохранения и загрузки игры и пояснить несколько совсем не очевидных моментов.
Pendalf61
Спасибо за подробную статью