Спасибо Винсенту Куорлзу за помощь в рецензировании этой статьи.
В этом уроке мы завершим реализацию функции сохранения и загрузки в нашей игре. В предыдущем уроке по сохранению и загрузке игровых данных игрока в Unity мы успешно сохранили и загрузили относящиеся к игроку данные, такие как статистика и инвентарь, но теперь мы рассмотрим самую сложную часть — объекты мира. Последняя система должна напоминать игры The Elder Scrolls — каждый объект сохранялся точно там, где он был, в течение неопределенного периода времени.
Если вам нужен проект для практики, вот версия проекта, которую мы завершили в последнем уроке. Он был улучшен парой интерактивных объектов, которые порождают предметы — одно зелье и один меч. Их можно порождать и собирать ( удалять ), и нам нужно правильно сохранять и загружать их состояние. Готовую версию проекта (с полностью реализованной системой сохранения) можно найти в нижней части этой статьи.
Загрузите стартовые файлы проекта
Страница проекта GitHub
Проект ZIP Скачать
Теория реализации
Нам нужно сломать систему сохранения и загрузки объектов, прежде чем мы ее реализуем. Прежде всего, нам нужен какой-то мастер-
объект уровня, который будет порождать и удалять объекты. Он должен порождать сохраненные объекты на уровне (если мы загружаем уровень и не запускаем заново), убирать выбранные объекты, уведомлять объекты, которые им нужны для сохранения, и управлять списками объектов. Звучит как много, поэтому давайте поместим это в блок-схему:
По сути, вся логика заключается в сохранении списка объектов на жестком диске, который, при следующей загрузке уровня, будет пройден, и все объекты с него появятся, когда мы начнем играть. Звучит легко, но дьявол кроется в деталях: как мы узнаем, какие объекты нужно сохранить и как мы их возвращаем?
Делегаты и события
В предыдущей статье я упоминал, что мы будем использовать систему делегатов-событий для уведомления объектов, которые им необходимо сохранить самим. Давайте сначала объясним, что такое делегаты и события .
Вы можете прочитать документацию Официального делегата и документацию Официальных мероприятий . Но не волнуйтесь: даже я не понимаю много технической надписи в официальной документации, поэтому я изложу ее простым английским языком:
делегат
Вы можете думать о делегате как о функциональной схеме. Он описывает, как должна выглядеть функция: каков ее тип возвращаемого значения и какие аргументы она принимает. Например:
public delegate void SaveDelegate(object sender, EventArgs args);
Этот делегат описывает функцию, которая ничего не возвращает (void) и принимает два стандартных аргумента для платформы .NET / Mono: универсальный объект, представляющий отправителя события, и возможные аргументы, которые можно использовать для передачи различных данных. Вам не нужно беспокоиться об этом, мы можем просто передать (null, null) в качестве аргументов, но они должны быть там.
Так как это связано с событиями ?
События
Вы можете думать о событии как о блоке функций
. Он принимает только те функции, которые соответствуют делегату (план), и вы можете помещать и удалять функции из него во время выполнения, как вам угодно.
Затем в любое время вы можете запустить событие, что означает запуск всех функций, которые в данный момент находятся в окне, — сразу. Рассмотрим следующую декларацию события:
public event SaveDelegate SaveEvent;
Этот синтаксис гласит: объявить публичное событие (любой может подписаться на него — мы вернемся к этому позже), которое принимает функции, как описано делегатом SaveDelegate (см. Выше), и оно называется SaveEvent .
Подписаться и отписаться
Подписка на событие в основном означает «положить функцию в поле». Синтаксис довольно прост. Давайте предположим, что наше событие объявлено в нашем известном классе GlobalObject
и что у нас есть некоторый класс объектов Potion с именем PotionDroppable,
который должен подписаться на событие:
//In PotionDroppable's Start or Awake function: GlobalObject.Instance.SaveEvent += SaveFunction; //In PotionDroppable's OnDestroy() function: GlobalObject.Instance.SaveEvent -= SaveFunction; //[...] public void SaveFunction (object sender, EventArgs args) { //Here is code that saves this instance of an object. }
Давайте объясним синтаксис здесь. Сначала нам нужно иметь функцию, которая соответствует описанным стандартам делегатов. В сценарии объекта есть такая функция с именем SaveFunction . Мы напишем нашу собственную позже, но сейчас давайте просто предположим, что это рабочая функция для сохранения объекта на жесткий диск, чтобы его можно было загрузить позже.
Когда у нас это есть, мы просто помещаем эту функцию в поле при запуске или пробуждении скрипта и удаляем его, когда он уничтожается. (Отказ от подписки очень важен: если вы попытаетесь вызвать функцию уничтоженного объекта, вы получите нулевые ссылочные исключения во время выполнения). Мы делаем это путем доступа к объявленному объекту Event и использования оператора сложения, за которым следует имя функции.
Примечание. Мы не вызываем функцию, используя скобки или аргументы; мы просто используем имя функции, больше ничего.
Итак, давайте объясним, что все это в конечном итоге делает на примере игрового потока.
Логический поток
Предположим, что в ходе игры в результате действий игрока в мире появилось два меча и два зелья (например, игрок открыл сундук с добычей).
Эти четыре объекта регистрируют свои функции в событии Save :
Теперь давайте предположим, что игрок поднимает один меч и одно зелье из мира. По мере того, как объекты «подхватываются», они эффективно вызывают изменение инвентаря игрока, а затем уничтожают себя (вид разрушает магию, я знаю):
А потом, предположим, игрок решает сохранить игру — возможно, ему действительно нужно ответить на тот телефон, который звонил три раза (эй, ты сделал отличную игру):
По сути, происходит то, что функции в окне запускаются одна за другой, и игра никуда не денется, пока все функции не будут выполнены. Каждый объект, который «сохраняет себя», в основном записывает себя в список, который в следующей игре Load будет проверяться объектом уровня Master, и все объекты, которые находятся в списке, будут созданы (порождены). Вот и все: если вы до сих пор следили за статьей, вы в принципе готовы начать ее реализацию сразу же. Тем не менее, здесь мы рассмотрим несколько конкретных примеров кода, и, как всегда, в конце статьи вас ждет готовый проект, если вы хотите увидеть, как все это должно выглядеть.
Код
Давайте сначала пройдемся по существующему проекту и ознакомимся с тем, что уже есть внутри.
Как вы можете видеть, у нас есть эти красиво оформленные боксы, которые выступают в роли производителей для двух объектов, которые мы уже упоминали. В каждой из двух сцен есть пара. Вы можете сразу увидеть проблему: если вы перемещаете сцену или используете F5 / F9 для сохранения / загрузки, созданные объекты исчезнут.
Создатели коробок и сами порожденные объекты используют простую интерактивную интерфейсную механику, которая дает нам возможность распознавать лучевую трансляцию по этим объектам, писать текст на экране и взаимодействовать с ними с помощью клавиши [E].
Не намного больше присутствует. Наши задачи здесь:
- Составьте список объектов зелья
- Составьте список объектов Sword
- Реализация глобального события Save
- Подписаться на событие, используя функцию сохранения
- Реализовать мастер уровня объекта
- Сделайте так, чтобы Мастер уровня порождал все сохраненные объекты, если мы загружаем игру.
Как видите, это не так тривиально, как можно было бы надеяться, что такая фундаментальная функция будет. Фактически, ни один из существующих игровых движков (CryENGINE, UDK, Unreal Engine 4 и др.) На самом деле не имеет простой реализации функции сохранения / загрузки, готовой к работе. Это потому, что, как вы можете себе представить, механика сохранения действительно специфична для каждой игры. Существует больше, чем просто классы объектов, которые обычно требуют сохранения; это состояния мира, такие как выполненные / активные квесты, дружелюбие фракции / враждебность, даже текущие погодные условия в некоторых играх. Он становится довольно сложным, но с правильной основой механики сбережений становится проще просто обновить его с большей функциональностью.
Списки классов объектов
Давайте сначала начнем с простых вещей — списков объектов. Данные нашего игрока сохраняются и загружаются с помощью простого представления данных в классе Serializables
.
Аналогичным образом нам нужны некоторые сериализуемые классы, которые будут представлять наши объекты. Чтобы написать их, нам нужно знать, какие свойства нам нужно сохранить — для нашего проигрывателя нам нужно было сохранить множество вещей. К счастью, для объектов вам редко понадобится нечто большее, чем их мировое положение. В нашем примере нам нужно только сохранить положение объектов и ничего больше.
Чтобы хорошо структурировать наш код, мы начнем с простых классов в конце наших Serializables:
[Serializable] public class SavedDroppablePotion { public float PositionX, PositionY, PositionZ; } [Serializable] public class SavedDroppableSword { public float PositionX, PositionY, PositionZ; }
Вы можете быть удивлены, почему мы не просто используем базовый класс. Ответ в том, что мы можем, но вы никогда не знаете, когда вам нужно добавить или изменить определенные свойства элемента, которые нужно сохранить. И кроме того, это намного проще для читабельности кода.
К настоящему времени вы, возможно, заметили, что я часто
использую термин Droppable
. Это связано с тем, что нам необходимо различать сбрасываемые (порождаемые) объекты и помещаемые объекты, которые следуют различным правилам сохранения и порождения. Мы вернемся к этому позже.
Теперь, в отличие от данных об Игроках, где мы знаем, что в каждый момент времени может быть только один игрок, у нас может быть несколько объектов, таких как зелья. Нам нужно создать динамический список и указать, к какой сцене принадлежит этот список: мы не можем создавать объекты Level2 в Level1. Это просто сделать, опять же в Serializables. Напишите это ниже нашего последнего урока:
[Serializable] public class SavedDroppableList { public int SceneID; public List<SavedDroppablePotion> SavedPotions; public List<SavedDroppableSword> SavedSword; public SavedDroppableList(int newSceneID) { this.SceneID = newSceneID; this.SavedPotions = new List<SavedDroppablePotion>(); this.SavedSword = new List<SavedDroppableSword>(); } }
Лучшее место для создания экземпляров этих списков — наш класс GlobalControl:
public List<SavedDroppableList> SavedLists = new List<SavedDroppableList>();
Наши списки в значительной степени хороши: пока мы получим доступ к ним из объекта LevelMaster, когда нам понадобится порождать элементы, и сохраним / загрузим их с жесткого диска из GlobalControl, как мы уже делали с данными игроков.
Делегат и Событие
Ах, наконец. Давайте реализовывать знаменитые события события.
В GlobalControl:
public delegate void SaveDelegate(object sender, EventArgs args); public static event SaveDelegate SaveEvent;
Как вы можете видеть, мы делаем событие статической ссылкой, поэтому с ним будет логичнее и проще работать позже.
Последнее замечание относительно реализации Event: только класс, который содержит объявление события, может вызвать событие. Любой может подписаться на него, GlobalControl.SaveEvent +=...
доступ к GlobalControl.SaveEvent +=...
, но только класс SaveEvent(null, null);
может запустить его, используя SaveEvent(null, null);
, Попытка использовать GlobalControl.SaveEvent(null, null);
в другом месте приведет к ошибке компилятора!
И это все для реализации мероприятия! Давайте подпишемся на это!
Подписка на события
Теперь, когда у нас есть наше событие, наши объекты должны подписаться на него или, другими словами, начать слушать
событие и реагировать на его срабатывание.
Нам нужна функция, которая будет запускаться при возникновении события — для каждого объекта. Давайте перейдем к сценарию PotionDroppable в папке Pickups
. Примечание: Sword еще не настроил свой скрипт; мы сделаем это через мгновение!
В PotionDroppable добавьте это:
public void Start() { GlobalControl.SaveEvent += SaveFunction; } public void OnDestroy() { GlobalControl.SaveEvent -= SaveFunction; } public void SaveFunction(object sender, EventArgs args) { }
Мы правильно сделали подписку и отписались на событие. Теперь остается вопрос, как сохранить этот объект в списке, точно?
Сначала нам нужно убедиться, что у нас есть список объектов, инициализированных для текущей сцены.
В GlobalControl.cs:
public void InitializeSceneList() { if (SavedLists == null) { print("Saved lists was null"); SavedLists = new List (); } bool found = false; //We need to find if we already have a list of saved items for this level: for (int i = 0; i
public void InitializeSceneList() { if (SavedLists == null) { print("Saved lists was null"); SavedLists = new List (); } bool found = false; //We need to find if we already have a list of saved items for this level: for (int i = 0; i
Эту функцию нужно запускать один раз за уровень. Проблема в том, что наш GlobalControl проходит через уровни, а его функции Start и Awake запускаются только один раз. Мы справимся с этим, просто вызвав эту функцию из нашего объекта Master уровня, который мы создадим через мгновение.
Нам также понадобится небольшая вспомогательная функция для возврата текущего списка активных сцен. В GlobalControl.cs:
public SavedDroppableList GetListForScene() { for (int i = 0; i
Теперь мы уверены, что у нас всегда есть список для сохранения наших товаров. Давайте вернемся к нашему сценарию зелья:
public void SaveFunction(object sender, EventArgs args) { SavedDroppablePotion potion = new SavedDroppablePotion(); potion.PositionX = transform.position.x; potion.PositionY = transform.position.y; potion.PositionZ = transform.position.z; GlobalControl.Instance.GetListForScene().SavedPotions.Add(potion); }
Именно здесь все наше синтаксическое сахарное покрытие действительно сияет. Это очень легко читается, легко понять и легко изменить для собственных нужд, когда вам это нужно! Короче говоря, мы создаем новое представление «зелья» и сохраняем его в фактическом списке.
Создание главного объекта уровня
Сначала немного подготовки. В нашем существующем проекте у нас есть глобальная переменная, которая сообщает нам, загружается ли сцена. Но у нас нет такой переменной, чтобы сказать нам, если сцена перемещается с помощью двери. Мы ожидаем, что все отброшенные объекты все еще будут там, когда мы вернемся в предыдущую комнату, даже если мы не сохранили / не загрузили нашу игру где-то между ними.
Для этого нам нужно внести небольшое изменение в Global Control:
public bool IsSceneBeingTransitioned = false;
В TransitionScript:
public void Interact() { //Assign the transition target location. GlobalControl.Instance.TransitionTarget.position = TargetPlayerLocation.position; //NEW: GlobalControl.Instance.IsSceneBeingTransitioned = true; GlobalControl.Instance.FireSaveEvent(); Application.LoadLevel(TargetedSceneIndex); }
Мы готовы сделать объект LevelMaster, который будет работать нормально.
Теперь нам нужно только читать списки и порождать объекты из них при загрузке игры. Это то, что будет делать объект уровня Master. Давайте создадим новый скрипт и назовем его LevelMaster
:
public class LevelMaster : MonoBehaviour { public GameObject PotionPrefab; public GameObject SwordPrefab; void Start () { GlobalControl.Instance.InitializeSceneList(); if (GlobalControl.Instance.IsSceneBeingLoaded || GlobalControl.Instance.IsSceneBeingTransitioned) { SavedDroppableList localList = GlobalControl.Instance.GetListForScene(); if (localList != null) { print("Saved potions count: " + localList.SavedPotions.Count); for (int i = 0; i < localList.SavedPotions.Count; i++) { GameObject spawnedPotion = (GameObject)Instantiate(PotionPrefab); spawnedPotion.transform.position = new Vector3(localList.SavedPotions[i].PositionX, localList.SavedPotions[i].PositionY, localList.SavedPotions[i].PositionZ); } } else print("Local List was null!"); } } }
Это много кода, поэтому давайте разберем его.
Код запускается только в начале, который мы используем для инициализации сохраненных списков в GlobalControl, если это необходимо. Затем мы спрашиваем GlobalControl, загружаем ли мы или переносим сцену. Если мы начинаем сцену заново (как Новая Игра или что-то подобное), это не имеет значения — мы не создаем никаких объектов.
Если мы загружаем сцену, нам нужно извлечь нашу локальную копию списка сохраненных объектов (просто чтобы сэкономить немного производительности при повторном доступе к GlobalControl и сделать синтаксис более читабельным).
Затем мы просто просматриваем список и порождаем все объекты зелья внутри. Точный синтаксис для порождения в основном является одной из перегрузок метода Instantiate . Мы должны привести результат метода Instantiate в GameObject
(по какой-то причине тип возвращаемого значения по умолчанию — простой Object ), чтобы мы могли получить доступ к его преобразованию и изменить его положение.
Это то место, где объект порождается: если вам нужно изменить какие-либо другие значения во время появления, это место для этого.
Нам нужно поместить мастера уровня в каждую сцену и назначить для него действительные префабы:
Теперь нам не хватает только одной важной части: нам нужно запустить событие, сериализовать списки на жесткий диск и прочитать их. Мы просто сделаем это в наших существующих функциях сохранения и загрузки в GlobalControl:
public void FireSaveEvent() { GetListForScene().SavedPotions = new List<SavedDroppablePotion>(); GetListForScene().SavedSword = new List<SavedDroppableSword>(); //If we have any functions in the event: if (SaveEvent != null) SaveEvent(null, null); } public void SaveData() { if (!Directory.Exists("Saves")) Directory.CreateDirectory("Saves"); FireSaveEvent(); BinaryFormatter formatter = new BinaryFormatter(); FileStream saveFile = File.Create("Saves/save.binary"); FileStream SaveObjects = File.Create("saves/saveObjects.binary"); LocalCopyOfData = PlayerState.Instance.localPlayerData; formatter.Serialize(saveFile, LocalCopyOfData); formatter.Serialize(SaveObjects, SavedLists); saveFile.Close(); SaveObjects.Close(); print("Saved!"); } public void LoadData() { BinaryFormatter formatter = new BinaryFormatter(); FileStream saveFile = File.Open("Saves/save.binary", FileMode.Open); FileStream saveObjects = File.Open("Saves/saveObjects.binary", FileMode.Open); LocalCopyOfData = (PlayerStatistics)formatter.Deserialize(saveFile); SavedLists = (List<SavedDroppableList>)formatter.Deserialize(saveObjects); saveFile.Close(); saveObjects.Close(); print("Loaded"); }
Это также кажется большим количеством кода, но большая часть этого уже была там. (Если вы следовали моему предыдущему руководству, вы узнаете команды двоичной сериализации; единственной новой функцией здесь является функция FireSaveEvent и один дополнительный файл, который сохраняет наши списки. Вот и все!
Начальное тестирование
Если вы запустите проект сейчас, объекты с зельем будут правильно сохраняться и загружаться каждый раз, когда вы нажимаете F5 и F9 или проходите через дверь (и любую их комбинацию).
Однако, есть еще одна проблема, которую нужно решить: мы не спасаем мечи.
Это просто для демонстрации того, как добавить новые сохраняемые объекты в ваш проект, если у вас есть схожие основы.
Расширение системы
Итак, предположим, у вас уже есть новая система порождения объектов — как мы уже сделали с объектами меча. В настоящее время они практически не взаимодействуют (помимо базовой физики), поэтому нам нужно написать сценарий, похожий на зелье, который позволит нам «подобрать» меч и правильно его сохранить.
Префаб меча, который в настоящее время порождается, находится в папке Assets> Prefabs .
Давайте заставим это работать. Перейдите в Assets> Scripts> Pickups и там вы увидите скрипт PotionDroppable
. Рядом с ним создайте новый скрипт SwordDroppable :
public class SwordDroppable : MonoBehaviour, IInteractable { public void Start() { GlobalControl.SaveEvent += SaveFunction; } public void OnDestroy() { GlobalControl.SaveEvent -= SaveFunction; } public void SaveFunction(object sender, EventArgs args) { SavedDroppableSword sword = new SavedDroppableSword(); sword.PositionX = transform.position.x; sword.PositionY = transform.position.y; sword.PositionZ = transform.position.z; GlobalControl.Instance.GetListForScene().SavedSword.Add(sword); } public void Interact() { Destroy(gameObject); } public void LookAt() { HUDScript.AimedObjectString = "Pick up: Sword"; } }
Не забудьте реализацию интерфейса «Интерактивный». Это очень важно: без него ваш меч не будет распознан камерой и будет оставаться неуправляемым. Кроме того, дважды проверьте, что сборный меч принадлежит слою Предметов . В противном случае он снова будет игнорироваться радиопередачей. Теперь добавьте этот скрипт к первому дочернему элементу префаба Sword (который на самом деле имеет Mesh Renderer и другие компоненты):
Теперь нам нужно их порождать. В Level Master, под нашим циклом for
который порождает зелья:
for (int i = 0; i < localList.SavedSword.Count; i++) { GameObject spawnedSword = (GameObject)Instantiate(SwordPrefab); spawnedSword.transform.position = new Vector3(localList.SavedSword[i].PositionX, localList.SavedSword[i].PositionY, localList.SavedSword[i].PositionZ); }
… вот и все. Всякий раз, когда вам нужно сохранить новый тип элемента:
- добавить в класс Serializables
- создать скрипт для элемента, который подписывается на событие Сохранить
- добавить логику в мастер уровня.
Вывод
На данный момент система довольно сырая: на жестком диске есть несколько файлов сохранения, вращение объекта не сохраняется (мечи, плеер и т. Д.), И логика для позиционирования игрока во время переходов сцены (но не загрузки) немного странно
Это все мелкие проблемы, которые необходимо решить, как только будет создана надежная система, и я приглашаю вас попытаться поработать с этим руководством и законченным проектом, чтобы посмотреть, сможете ли вы улучшить систему.
Но даже несмотря на это, это уже довольно надежный и надежный метод для выполнения механики сохранения / загрузки в вашей игре — сколь бы сильно он не отличался от этих примеров
проектов.
Как и было обещано, вот готовый проект, если он вам понадобится для справки или потому что вы застряли где-то Система сохранения реализована в соответствии с инструкциями этого руководства и с той же схемой именования.
Скачать готовый проект:
Страница проекта GitHub
Проект ZIP Скачать