Save/Load game architecture

Home  /  Chronicles of Cyberpunk  /  Save/Load game architecture

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

Какие стояли задачи:


Загружать нужную сцену, номер акта, номер шага, позицию игрока, статусы доп. заданий

Часть кода вырезал, оставил самое интересное. Здесь главная сложность была в том, что при загрузке сохранения мы загружаем новую сцену, поэтому нужно было найти объекты со скриптами, чтобы потом к ним обратиться. И если есть метод со строчкой, которая загружает новую сцену, то код, который выполняется после этой строчки, выполняется не для новой загруженной сцены, а для предыдущей. Поэтому нужно было везде проверять, чтобы все выполнялось на нужных сценах. Часть переменных пришлось сделать статическими, а часть кода загрузки вызывать в Awake. И тут была просто нереальная путаница, над которой я сидел неделю.

void Awake()
{
    Time.timeScale = 1;
    gameManagerObj = GameObject.FindGameObjectWithTag("GameManager");
    gameManagerScript = gameManagerObj.GetComponent<GameManager>();
    playerTransform = player.GetComponent<Transform>();

    // Если новая сцена загрузилась после загрузки сохранения
    if (isLoadButtonPressed)
    {
        isLoadButtonPressed = false;
        LoadData();
    }
    InitSavedData();
}

public void ButtonSave()
{
    latestSaveSlot = currentActiveSlot;
    PlayerPrefs.SetFloat(
        "transform position x" + currentActiveSlot, playerTransform.position.x);
    // y z
    PlayerPrefs.SetInt("task 1 completed" + currentActiveSlot, isTask1Completed);
    PlayerPrefs.SetInt("latestSaveSlot", latestSaveSlot);
    PlayerPrefs.SetInt(
        "act number" + currentActiveSlot, gameManagerScript.currentActNumber);
    SaveStepNumber();
    PlayerPrefs.SetString(
        "sceneName" + currentActiveSlot, SceneManager.GetActiveScene().name);
    PlayerPrefs.SetInt("slotImage" + currentActiveSlot, imageNumberForCurrentSlot);
    PlayerPrefs.Save();
}

public void ButtonLoad()
{
    CheckForContinueButton();
    isLoadButtonPressed = true;
    SceneManager.LoadScene(PlayerPrefs.GetString("sceneName" + currentActiveSlot));
    gameManagerScript.currentActNumber = 
        PlayerPrefs.GetInt("act number" + currentActiveSlot);
    LoadStepNumber();
}

public void ButtonContinue()
{
    // Если есть хоть 1 сохраненный слот
    if (latestSaveSlot != 0)
    {
        isItContinueButton = true;
        ButtonLoad();
    }
}

// Метод срабатывает при нажатии на один из 6 слотов сохранения
// Таких методов 6 - для каждого слота
public void ButtonSaveSlot1()
{
    currentActiveSlot = 1;
    PlayerPrefs.SetInt("currentActiveSlot", currentActiveSlot);
    PlayerPrefs.Save();
}

// Загружаем это после каждой загрузки новой сцены
private void InitSavedData()
{
    latestSaveSlot = PlayerPrefs.GetInt("latestSaveSlot");
    LoadTasks();
}

// Загружаем это только если сцена загружена из сохранения
private void LoadData()
{
    currentActiveSlot = PlayerPrefs.GetInt("currentActiveSlot");
    CheckForContinueButton();
    isItContinueButton = false;
    playerTransform.position = new Vector3(
        PlayerPrefs.GetFloat("transform position x" + currentActiveSlot),
        PlayerPrefs.GetFloat("transform position y" + currentActiveSlot),
        PlayerPrefs.GetFloat("transform position z" + currentActiveSlot));
    LoadTasks();
}

// ЗЗагружаем состояния второстепенных заданий
private void LoadTasks()
{
    isTask1Completed = PlayerPrefs.GetInt("task 1 completed" + currentActiveSlot);
    //..
}

// Если нажата кнопка "Продолжить", а не "Загрузить"
private void CheckForContinueButton()
{
    if (isItContinueButton)
        currentActiveSlot = PlayerPrefs.GetInt("latestSaveSlot");
}

private void SaveStepNumber()
{
    switch (gameManagerScript.currentActNumber)
    {
        case 0: PlayerPrefs.SetInt(
            "step number" + currentActiveSlot, Act_0.stepNumber); break;
        case 1: PlayerPrefs.SetInt(
            "step number" + currentActiveSlot, Act_1.stepNumber); break;
        //..
        case 28: PlayerPrefs.SetInt(
            "step number" + currentActiveSlot, Act28.stepNumber); break;
    }
}

private void LoadStepNumber()
{
    switch (gameManagerScript.currentActNumber)
    {
        case 0:
            Act_0.stepNumber = PlayerPrefs.GetInt("step number" + currentActiveSlot);
            gameManagerScript.act0.QuickAct(Act_0.stepNumber); break;
        case 1:
            Act_1.stepNumber = PlayerPrefs.GetInt("step number" + currentActiveSlot);
            gameManagerScript.act1.QuickAct(Act_1.stepNumber); break;
        //...
        case 28:
            Act28.stepNumber = PlayerPrefs.GetInt("step number" + currentActiveSlot);
            gameManagerScript.act28.QuickAct(Act28.stepNumber); break;                    
    }
}

Состояния всех объектов должны быть актуальными для данного шага

Здесь описана архитектура игрового цикла. Для каждого акта есть цепочка шагов, которые игрок проходит один за другим. На акте №2 шагов 22 и если, например, мы на шаге №8 подойдем к док-станции дрона, введем пароль, то дрон улетит на кухню (шаг №11). Если потом начать новую игру и загрузить номер шага №11, то получится, что дрон будет в док-станции, а должен быть на кухне. То есть номер шага мы загружаем корректно, а анимацию перемещения дрона не проигрываем. И таких мелочей куча, хранить их все в переменных (координаты и состояния абсолютно всех объектов для каждого шага), а потом загружать — не вариант. Поэтому я создал еще один метод, который является копией игрового цикла, но убрал оттуда строки, где ожидается активность со стороны игрока (подойти туда-то нажать на то-то). И при загрузке игры мы просто в ускоренном темпе прогоняем все шаги от нулевого до требуемого, устанавливая актуальные состояния для всех объектов. Игра небольшая, поэтому в каждом акте немного шагов, но мне очень интересно, как это реализовано в крупных играх.

// Быстро перебираем все шаги для загрузки сохранения
public void QuickAct(int lastStepNumber)
{
    Time.timeScale = 5;
    for (int i = 0; i < lastStepNumber; i++)
        switch (i)
        {
            case 0:
                stepNumber++;
                break;
            case 1: // Текстовое интро
                startTextCanvas.SetActive(true);
                PrintStartText();
                break;
            case 2: // Инициализация объектов акта
                openCloseEyesCanvas.SetActive(true);
                startTextCanvas.SetActive(false);
                elders.SetActive(true);
                break;
                //..
            case 22: // Выходим на улицу
                break;
        }
    Time.timeScale = 1;
}

Результат:


Исправление неожиданных багов

На некоторых сценах есть движущиеся объекты, такие как машины. У них есть начальное положение, откуда они начинают движение после загрузки сцены. Если объект переместится из данной точки, и затем в этом месте сохранится игрок, то после загрузки сцены координаты обоих объектов будут пересекаться и игрок провалится сквозь пол. Чтобы это предотвратить, я триггерами создал зоны, в которых изначально находятся движущиеся объекты. Если игрок сохранится внутри одного из таких триггеров, то после загрузки он появится не внутри него, а рядом с ним, таким образом объекты не будут пересекаться.

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

Comments are closed.