Skip to content

feat: Базовая система сохранений#310

Open
irazaurus wants to merge 13 commits intodevelopfrom
dev-149-save-points
Open

feat: Базовая система сохранений#310
irazaurus wants to merge 13 commits intodevelopfrom
dev-149-save-points

Conversation

@irazaurus
Copy link
Collaborator

@irazaurus irazaurus commented Mar 8, 2026

Close #149

Итак, система сохранений.

Этим занимается GameInstance через интерфейс - сейчас есть все для сохранения состояния игры, и чисто база для сохранения настроек (ее нужно будет потом дописать, но работать она будет почти так же).

Работает это так: в GameInstance лежат два объекта SaveGame - один для игры, второй для настроек. Отовсюду обращение к ним идет через интерфейс (SaveGameplayInterface для игры, SaveSettingsInterface для настроек). Сначала данные сохраняются в SaveGame объект, потом этот SaveGame анрильскими функциями уже сохраняется в файлик на компьютере.
Этот файлик находится в G2I-Game -> Saved -> SaveGames. Если хотите затереть сохранение, просто удалите оттуда файлик.
Данные сохраняются либо для отдельного объекта (SaveRequestedData) либо для всех акторов (AActor) с интерфейсом сохранения (SavableInterface) на данном уровне (SaveAllData).
С загрузкой то же самое, хотя загрузку лучше всего будет применять все-таки индивидуально для актора по его запросу. Почему? Потому что для разных акторов это будет необходимо в разное время. Они не одновременно спавнятся на уровне, какие-то будут спавниться во время игры, так что в основном будет использоваться LoadRequestedData. Я вижу LoadAllData полезным только в двух случаях: при дебаге и при начальном запуске игры, и то второе не факт.

Так, как этим пользоваться на практике?

Допустим, вам надо, чтобы TestActor сохранял свою позицию и переменную bool.

  1. В G2IGameplaySaveGame добавляем параметры, которые будут сохранены в дальнейшем.
    // Ссылки и имена (GetName) использовать нельзя - используйте для идентификации то, что останется неизменным от запуска к запуску.
    // ОБЯЗАТЕЛЬНО в спецификаторе UPROPERTY указать SaveGame.
    // Если данных для объекта много, оберните их в структуру. См. пример, который есть сейчас для игрока.
  2. Добавляем в TestActor интерфейс IG2ISavableInterface
  3. Имплементируем функции из этого интерфейса (в C++ через _Implement, в БП просто как обычно).
    // Проверять в них ссылку на SaveGame не надо - эта проверка уже есть во всех функциях GameInstance, где Save и Load вызываются. Этот референс никогда не будет nullptr.
    // В этих функциях вы оперируете данными, которые мы добавили к сейв гейму в первом пункте.

То есть, для нашего примера, это будет:

  1. В GameplaySaveGame я добавлю структуру TestActorSaveData, в которой будет два параметра: FTransform и bool. Оба со спецификаторами SaveGame, и сама структура тоже с ним.
  2. В SaveData_Implementation будет что-то типа
    SaveGameRef->TestActorSaveData.FTransform = GetActorTransform();
    SaveGameRef->TestActorSaveData.bool = bool;
  3. В LoadData_Implementation будет наоборот SetActorTransform и присвоение переменной.

Вот и все.
Сохраняются только AActor!!

Чтобы подписаться на делегаты сохранения/загрузки, возьмите их из GameInstance - они хранятся в классе UG2ISaveGameplayDelegates - через функцию GetGameplaySaveDelegates. Полезно для виджетов.

Теперь про чекпоинт.

Одноразовый чекпоинт говорит GameInstance сохранить данные всех акторов, что есть на текущем уровне, и записать это в ячейку. Чтобы это работало правильно, тк у нас будет несколько уровней, при перемещении между ними надо также просить GameInstance сохранить все данные (без сохранения в ячейку, это разные вещи). Ну и выбрать момент, когда их загружать (в BeginPlay акторов или в другом месте).

Дебаг

Я добавила две дебажные кнопки для тестирования системы сохранений:

  • K - все в текущем уровне сохраняет (считайте Keep)
  • L - все для текущего уровня загружает (Load)

На тестовом уровне есть два чекпоинта-триггер бокса.

И сейчас сохраняются/загружаются следующие данные:

  • Трансформ всех игровых персонажей
  • Какой из персонажей был под контролем игрока на момент сохранения
  • Состояния чекпоинтов (были активированы или нет)

@irazaurus irazaurus marked this pull request as ready for review March 10, 2026 12:31
@irazaurus
Copy link
Collaborator Author

изменения, НЕ касающиеся системы сохранений:

  1. убрала логи с прыжком
  2. пофиксила на уровне PassThroughActor, добавив ему материал. иначе при дебаге он вылетал
  3. добавила конструкторы у Лизы в слайдере, иначе у меня не компилилось

@irazaurus irazaurus force-pushed the dev-149-save-points branch from ba25940 to 306cded Compare March 10, 2026 13:22
@irazaurus irazaurus force-pushed the dev-149-save-points branch from 306cded to 442542d Compare March 10, 2026 13:23
Copy link
Owner

@PosAlina PosAlina left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Предложение добавить функцию переключения на конкретного персонажа, а не на следующего. Код в комментарии приложила
  2. А точно нужен интерфейс для GameInstance?
    Возможно, можно вынести систему сохранений в GameInstanceSubsystem (так UIManager выносила), чтобы логчески отделить от game instance. И вместо gameinstance с нужным интерфейсом вызывать GameInstance->GetSubsystem<НужныйКласс>();
  3. И второй интерфейс не удобнее компонентой (возможно, с наследниками) сделать, чтобы не копировать логику функций?
  4. Немного вопросов по написанию синтаксису
  5. Об изменениях в слайдере в общий чат напиши, плиз
  6. Ну и часть вопросов классических по nullptr и ensur-ов для error-логов. Чтоб меньше проверок на nullptr делать, можно в функции передавать ссылки, а не пойнтеры

return OnUnPossessedDelegate;
}

void AG2ICharacterDaughter::SaveData_Implementation(UG2IGameplaySaveGame* SaveGameRef)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Либо проверить SaveGameRef на nullptr
Либо передавать в функцию ссылку, а не указатель

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GameInstance не будет вызывать эти функции, если SaveGame == nullptr.
но могу поменять на ссылку

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Лучше проверку всё же сделать, или на ссылку поменять


void AG2ICharacterDaughter::LoadData_Implementation(const UG2IGameplaySaveGame* SaveGameRef)
{
if (IsPlayerControlled() && !this->IsA(SaveGameRef->PlayersSaveData.CurrentCharacter))
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А зачем this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

хороший вопрос. в моменте так почувствовала

{
if (auto* G2IPlayerState = Cast<AG2IPlayerState>(GetPlayerState()))
{
G2IPlayerState->SelectNextCharacter();
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

В идеале
можно было бы в PlayerState добавить функцию,
SetCharacterByClass(SaveGameRef->PlayersSaveData.CurrentCharacter)
В которой в идеале выбирать персонажа с нужным классом или писать, что нет такого персонажа
Примерно так:

void AG2IPlayerState::SetCharacterByClass(const TSubclassOf<ACharacter> TargetClass)
{
	if (!ensure(TargetClass))
	{
		UE_LOG(LogG2I, Warning, TEXT("An attempt to set character with null class"));
		return;
	}
	
	if (!ensure(!PlayableCharactersRowNames.IsEmpty()))
	{
		UE_LOG(LogG2I, Warning, TEXT("An attempt to set character, when array of playable characters is empty"));
		return;
	}
	
	for (int32 OffsetRowName = 1; OffsetRowName <= PlayableCharactersRowNames.Num(); ++OffsetRowName)
	{
		const int32 NewCharacterIndex = (NumberCurrentCharacter + OffsetRowName) % PlayableCharactersRowNames.Num();
		if (NewCharacterIndex == NumberCurrentCharacter)
		{
			UE_LOG(LogG2I, Warning, TEXT("Character with class %s doesn't founded"), *TargetClass->GetName());
			return;
		}

		if (const FG2IItemCharacter *Row = PlayableCharactersDataTable->FindRow<FG2IItemCharacter>(
		PlayableCharactersRowNames[NewCharacterIndex], TEXT("Playable Character Context")))
		{
			if (Row->CharacterClass == TargetClass)
			{
				//  Строки из SelectNextCharacter, в принципе их можно вынеститож в отдельную функцию
				const int32 OldCharacterNumber = NumberCurrentCharacter;
				NumberCurrentCharacter = NewCharacterIndex;
				if (SetupControllerForPawn(OldCharacterNumber))
				{
					if (SetupControllerForPawn(NumberCurrentCharacter))
					{
						OnNewControllerPossessDelegate.Broadcast(GetPawn(OldCharacterNumber));
						OnNewControllerPossessDelegate.Broadcast(GetPawn(NumberCurrentCharacter));
						break;
					}
					else
					{
						UE_LOG(LogG2I, Warning, TEXT("Character %i doesn't switched on player controller"),
							NumberCurrentCharacter);
						NumberCurrentCharacter = OldCharacterNumber;
						SetupControllerForPawn(NumberCurrentCharacter);
						continue;
					}
				}
				else
				{
					UE_LOG(LogG2I, Warning, TEXT("Character %i doesn't switched on ai controller"),
						OldCharacterNumber);
					NumberCurrentCharacter = OldCharacterNumber;
					continue;
				}
				
			}
		}
		else
		{
			UE_LOG(LogG2I, Error, TEXT("In %s array of row names doesn't sync with %s"), *GetName(),
				*PlayableCharactersDataTable.GetName());
			continue;
		}
	}
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ооо фига, сделаем, спасибо!
(в своем решении я основывалась на том, что у нас уже точно больше двух персонажей не будет)

SaveGameRef->PlayersSaveData.CharactersTransform.Add(GetClass(), GetTransform());
}

void AG2ICharacterDaughter::LoadData_Implementation(const UG2IGameplaySaveGame* SaveGameRef)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Проверка SaveGameRef на nullptr или передавать ссылку в функцию

#include "CoreMinimal.h"
#include "G2ICharacterInterface.h"
#include "GameFramework/Character.h"
#include "Interfaces/SavingSystem/G2ISaveGameplayInterface.h"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Не используется

#include "G2ICharacterInterface.h"
#include "GameFramework/Character.h"
#include "Interfaces/SavingSystem/G2ISaveGameplayInterface.h"
#include "Interfaces/SavingSystem/G2ISavableInterface.h"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Можно кстати без "Interfaces/" этот инклуд писать
В Build.cs путь прописан

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

чтооо

#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/SaveGame.h"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Тут инклуд не используемые убрать можно

TObjectPtr<UG2ISaveGameplayDelegates> SaveGameplayDelegates;

private:
// SaveGame w/ gameplay info
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Имхо, лучше в комментариях не использовать сокращения, чтобы у всех, кто посмотрит код, было однозначное понимание

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ок!


void CreateNewGameplaySaveGameObject_Implementation();

void SaveGameplay_Implementation(bool bAsync);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Лучше для функций, реализующих функции интерфейса писать virtual и override

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

для Implementation это надо делать? мне казалось что это анриловская тема и она не коннектится с этими специальными обозначениями

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Код стайл а-дя
_Implementation говорит компилятору, что это виртуальная функция, но в коде лучше помечать дополнительно virtual
А без override - в принципе любые функции могут и без него работать, но для читаемости и чтоб компилятор проверял, что всё ок написано, лучше ставить

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Контрольные точки (базовая система сохранений)

2 participants