Если вы пишете не казуалку под веб и не беспощадный суровый рогалик, без сохранения данных на диск не обойтись.
Как это делается в Unity? Вариантов тут достаточно — есть класс PlayerPrefs в библиотеке, можно сериализовать объекты в XML или бинарники, сохранить в *SQL*, можно, в конце-концов, разработать собственный парсер и формат сохранения.
Рассмотрим поподробнее с первые два варианта, и заодно попробуем сделать меню загрузки-сохранения со скриншотами.
Будем считать, что читающий дальше базовыми навыками обращения с этим движком владеет. Но при этом можно не подозревать о сущестовании в его библиотеке PlayerPrefs, GUI, и ещё в принципе не знать о сериализации. С этим всем и разберёмся.
А чтобы эта заметка не стала слишком уж увлекательной и полезной, ориентирована она самый неактуальный в мобильно/планшетно/онлайновый век вариант — сборку под винду (хотя, конечно, более общих моментов достаточно).
Ещё небольшой дисклеймер — я не профи ни в одной из раскрываемых тем, так что если какие-то вещи можно сделать правильнее-проще-удобнее — исправления и дополнения очень приветствуются.
Удобный встроенный класс. Работает с int, float и string. Довольно прозрачный, но мне всё равно встречались на форумах обороты в духе «не могу понять PlayerPrefs» или «надо бы как-нибудь разобраться с PlayerPrefs», так что посмотрим на него на простом примере.
Быстрый пример использования. Допустим, у нас одна сцена и персонаж на ней. Скрипт SaveLoad.cs прикреплен к персонажу. Будем сохранять самое простейшее — его положение.
Конечно, тут применение PlayerPrefs довольно надумано — фактически против обычных переменных оно добавляет нам только возможность загрузки игру с места сохранения после выхода.
Зато весь основной интерфейс класса виден: для каждого из трех типов Get / Set по ключу, проверка вхождения по ключу, очистка. Нет смысла даже разбирать ScriptReference, всё очевидно по названиям функций:
Однако на одной всё же стоит остановиться подробнее, PlayerPrefs.Save. В описании говорится, что вообще дефолтно юнити пишет PlayerPrefs на диск только при закрытии приложения — в общем-то логично, учитывая, что класс ориентирован не на внутренний обмен данными, и на их сохранение между сеансами. Соответственно, Save() предполагается использовать только для периодических сохранений на случай крэша.
Возможно, в некоторых случаях это так и работает. Под Win PlayerPrefs пишутся в реестр, и, как можно легко убедиться, считываются и пишутся сразу.
Как-то так выглядит наш класс в реестре:
Ко всем ключам в конце добавлен их DJBX33X-хеш (Bernshtein hash with XOR).
UnityGraphicsQuality сохраняется всегда автоматически, и действительно при закрытии приложения. Это Quality level из Edit -> Project Settings Quality, оно же
Можно при запущенном приложении модифицировать сохранённое значение в реестре, потом затребовать его из программы — и мы увидим, что вернулся модифицированный вариант. Т.е. не стоит думать что во время работы программы PlayerPrefs — что-то вроде аналога глобальных переменных, а работа с диском не происходит.
Говорим сериализация, подразумеваем бинарный код. Такое встречается, но на самом деле сериализовать можно в любой формат. По сути это перевод структуры данных или состояния объекта в хранимый/передаваемый формат. А десериализация, соответственно — восстановление объекта по сохраненным/полученным данным.
Вообще Mono умеет и бинарную сериализацию, и XML (System.Xml.Serialization), но есть один момент: большинство классов Unity не сериализуются напрямую. Невозможно просто взять и сериализовать GameObject, или класс, наследующий MonoBehavoir: придётся завести дополнительно внутренний сериализуемый класс, содержащий нужные данные, и работаеть, используя его. Но XmlSerializer хотя бы кушает автоматически Vector3, а BinarySerializer, afaik, даже этого не умеет.
Представьте, что вы пишете свой Portal, где герой проходит череду однотипных локаций — но на любую из них может впоследствии вернуться. Причём на каждую он мог оказать воздействие: какие-то ресурсы использовать, что-то сломать, что-то расшвырять. Хочется, эти изменения сохранять, но возвращение на локацию маловероятно и непрогнозируемо, и тащить за собой параметры всех комнат в оперативке нет особого смысла. Будем сериализовать локацию, покидая её — например, по триггеру на двери. А при загрузке локации генерировать либо дефолтную ситуацию, либо, если есть сохраненные данные, восстанавливать по ним.
XmlSerializer умеет работать с классами, данные в которых состоят из других сериализуемых классов, простых типов, большинства элементов Collections[.Generic]. Обязательно наличие у класса пустого конструктора и public-доступ ко всем сериализуемым полям.
Некторые типы из библиотеки Юнити (вроде Vector3, содержащего всего три интовых поля) успешно проходят этот фейсконтроль, но большинство, особенно более сложных, его фейлят.
Допустим, в каждой комнате нам надо сохранять состояния некоторого произвольного набора GameObject'ов. Напрямую сделать этого мы не можем. Значит, нам потребуются дублирующие типы.
Создадим новый скрипт в Standard Assets:
В квадратных скобках идут атрибуты для управления XML-сериализацией. Тут они фактически влияют только на имена тегов в генерируемом *.xml, и строго говоря, необходимости в них нет. Но пусть будут, для наглядности :) Если вам почему-то вдруг важно, как будет выглядеть xml-код, то возможности атрибутов, конечно шире.
Дальше там же добавим базовый класс для предметов из списка и сколько угодно наcледуемых от него. Хотя… для примера хватит и одного:
Итак, сериализуемые классы готовы. Сделаем теперь ещё класс для дополнительного упрощения сериализации созданного типа RoomState.
Тоже в Standard Assets сделаем класс с парой статических методов, которыми будем в дальнейшем пользоваться:
Здесь XmlSerializer мы создаём через конструктор Constructor (Type, Type[])
FileStream открываем по адресу сохранения, передаваемого конкретной локацией.
Итак, все вспомогательные инструменты готовы, можно приступать к самой комнате. На объект комнаты вешаем:
Напоследок, сделаем вызов RoomGen.Dump(). Пусть, например, по триггерам на дверях, которые являются дочерними объектами относительно комнаты (объекта с компонентом RoomGen):
Вот и всё. Здесь опущено собственно взаимодействие с предметами и процесс изменения их состояния, но это несложно добавить. Для первоначального теста можно просто добавить в скрипт пару устанавливащих состояния функций по хоткеям, или ставить на паузу и двигать руками.
При первом запуску генерируется дефолтный вариант, при выходе изменения дампятся в файл, при возвращении последние состояние восстанавливается из файла, в том числе если приложение закрывалось. Works like a charm.
Один из существенных недостатков XML — игрок может легко изменить данные. И если в данном случае мало кого заинтересует расположение раскиданных стульев, то при сохранении более существенных для игрока данных сериализации в XML лучше избегать. Да и в реестре поменять значения не сложно. В таких случаях уже лучше использовать бинарную сериализацию или свой формат.
Наверное, актуальнее было бы реализовать вариант с выбором/созданием пользователя и внутренними автоматическими сохранениями. Если вашей игре требуется серьёзное меню Save/Load, то вряд ли вы сейчас читаете эту статейку для профанов.
Но я жду не дождусь новогодних праздников, когда можно будет наконец увидеться с сестрой и за пару вечеров добить классическую American McGee's Alice, так что сделаем Save/Load почти как там. Со скриншотами. Заодно будет повод покопаться в GUI, текстурах и других увлекательных вещах.
Чтобы сделать загрузку и сохранение через меню, нам, как ни странно, понадобится меню. Конечно, можно сделать его самостоятельно через объекты на сцене, можно заюзать готовые решения ироде NGUI , но мы пока воспользуемся GUI из штатной библиотеки.
Главное меню до и после начала игры
Функция drawSaveLoadMenu() у нас уже вызывается при menutype>0, но пока не написана. Исправим это упущение. Пока просто научимся рисовать наши меню и вызывать собственно функции загрузки/сохранения.
Меню Load на SelectionGrid — внешне ничем не отличается от соответствующего Save
Основное, что мне в этом решении не нравится, это что в меню загрузки не содержащие сохранений слоты остаются относительно активными — внешне отличаются только отсутствием текстуры, реагируют на наведение. Поэтому бонусом — сетка ручками, вместо неактивных слотов рисуем Box, для активных Button.
Заодно добавим резиновости: количество слотов в строке задаётся, размер слотов подстраивается под экран. Правда, тут они уже квадратные, но встроить произвольное соотношение сторон будет несложно :) Ну и заодно min/max width/height из GUILayout и прочая обработка напильником.
Меню Load на Button и Box — теперь пустые слоты неактивны
Итак, с момента создания нашего объекта меню мы будем держать массив текстур. Памяти он занимает немного и нам гарантирован в ним мгновенный доступ. На самом деле, тут и альтернативы особой нет — не пихать же работу с диском в onGUI().
Как мы уже видели, при создании нашего меню создаём и массив:
Сохранять мы будем не только информацию сейвов, но и информацию о них, а точнее — какие именно слоты содержат сохранения. Как хранить — выбор каждого, можно по параметру 0/1 на каждый слот, можно строку из 0/1, но мы сделаем некрасиво :) и возьмём битовый вектор в int. В какой момент и как он сохраняется, увидим позже, пока просто читаем.
Добавим в Start():
Ну и собственно главное в данном вопросе — как скрины сохранять? Напрашивается вариант
Функцию взятия и записи скриншота вызывать будем позже, а пока заранее выделим в Coroutine:
Неприятный нерешенный момент — текстура с текущей сессии и текстура, загруженная с диска, сильно различаются по качеству.
Ниже слева две текущих, справа — две с диска, от предыдущих сессий:
Итак, вроде бы с шелухой разобрались. Научились минимально работе с GUI, сделали простое главное меню, меню Save/Load, научились работать со скриншотами.
Как реализовать взаимодействие между объектами сцены, параметры которых мы будем сохранять и нашим меню?
1. Если мы будем записывать только состояние такого же создаваемого с первой сцены и неразрушаемого далее объекта (например, игрок, его параметры и инвентарь) — можно сразу держать прямую ссылку.
2.
3. < место зарезервировано под иные варианты оптимальнее, предлагайте! >
А пока рассмотрим такой простой вариант. Сохранять будем только сцену и положение игрока. Игрок в каждой сцене пересоздаётся, но всегда вид от первого лица, и соответственно к игроку прикреплена камера.
Через неё и будем получать доступ. В ниже представленной функции вся эта специфика — в двух строках помеченных //!, и её не сложно локально заменить, остальное привязано к уже написанному нами выше коду.
Если делать скриншот заранее, то он во-первых, может не пригодится, а во-вторых, нужно ещё успеть. А так, с учётом заблокированности камеры в режиме меню, результат примерно тот же.
С загрузкой ещё проще. Всё специфику мы снова полностью делегируем, причём даже не будем её напрямую вызывать. Просто загрузим нужный уровень, а дальше они как-нибудь сами :)
Сделаем теперь поведение, который будем вешать на камеры:
Надо заметить „дальше как-нибудь сами“ было определенной степенью лукавства: loadgame() меню и load() объекта определенно обменялись информацией, только вот через известное место — реестр. Сохранять туда откровенно временную переменную — ход не слишком красивый. Можно изменить на прямой вызов load(), а без изменения текущей общей структуры — держать переменную в меню, и в Start() загружаемого объекта добавить поиск объекта меню и получение нужной информации.
Дальше. От созданного базового поведения мы можем унаследовать разные варианты для разных сцен и объектов. Например, вариант с сохранением поворота:
Конечно, здесь данным уже пригодилась бы защита. Поскольку поскольку вся фактическая работа с PlayerPrefs тут выделена в отдельные функции save() / load(), заменить их содержательную часть будет не сложно. На что? Можно аналогично примеру из части 2 держать класс-рефлектор, и сериализовать его через BinarySerializer.
Другой неплохой вариант — прикрутить, например, SQLite. Правда, по слухам, на js с ней работать удобнее, чем на шарпе, но и на последнем всё в конечном итоге заводится. Кто хочет попробовать, начать можно отсюда.
Этот текст никогда бы не получился без:
гугла
docs.unity3d.com
wiki.unity3d.com
forum.unity3d.com
answers.unity3d.com
stackoverflow.com
и хабра. Спасибо им.
Надеюсь, всё это принесёт кому-нибудь пользу, и никому — вреда :)
Как это делается в Unity? Вариантов тут достаточно — есть класс PlayerPrefs в библиотеке, можно сериализовать объекты в XML или бинарники, сохранить в *SQL*, можно, в конце-концов, разработать собственный парсер и формат сохранения.
Рассмотрим поподробнее с первые два варианта, и заодно попробуем сделать меню загрузки-сохранения со скриншотами.
Будем считать, что читающий дальше базовыми навыками обращения с этим движком владеет. Но при этом можно не подозревать о сущестовании в его библиотеке PlayerPrefs, GUI, и ещё в принципе не знать о сериализации. С этим всем и разберёмся.
А чтобы эта заметка не стала слишком уж увлекательной и полезной, ориентирована она самый неактуальный в мобильно/планшетно/онлайновый век вариант — сборку под винду (хотя, конечно, более общих моментов достаточно).
- Кстати, пару недель назад на Хабре была статья, где автор упомянул, что Unity3D проходят в курсе компьютерной графики на кафедре информатики питерского матмеха. Занятный факт, немало говорящий о популярности движка.
Хотя насколько это в целом хорошая идея — на мой взгляд, тема для дискуссии. Может быть, обсудить это было бы даже интереснее вопросов сериализации =)
Ещё небольшой дисклеймер — я не профи ни в одной из раскрываемых тем, так что если какие-то вещи можно сделать правильнее-проще-удобнее — исправления и дополнения очень приветствуются.
1. PlayerPrefs
Удобный встроенный класс. Работает с int, float и string. Довольно прозрачный, но мне всё равно встречались на форумах обороты в духе «не могу понять PlayerPrefs» или «надо бы как-нибудь разобраться с PlayerPrefs», так что посмотрим на него на простом примере.
1.1 Примитивное использование в рамках одной сцены: QuickSave & QuickLoad по хоткеям.
Быстрый пример использования. Допустим, у нас одна сцена и персонаж на ней. Скрипт SaveLoad.cs прикреплен к персонажу. Будем сохранять самое простейшее — его положение.
using UnityEngine;
using System.Collections;
public class SaveLoad : MonoBehaviour {
public Transform CurrentPlayerPosition;
void Update () {
if(Input.GetKeyDown(KeyCode.R))
savePosition();
if(Input.GetKeyDown(KeyCode.L))
if (PlayerPrefs.HasKey("PosX")) // проверяем, есть ли в сохранении подобная информация
loadPosition();
if(Input.GetKeyDown(KeyCode.D))
PlayerPrefs.DeleteAll(); // очистка всей информации для этого приложения
}
public void savePosition(){
Transform CurrentPlayerPosition = this.gameObject.transform;
PlayerPrefs.SetFloat("PosX", CurrentPlayerPosition.position.x); // т.к. автоматической работы
PlayerPrefs.SetFloat("PosY", CurrentPlayerPosition.position.y); // с массивами нет, разбиваем на
PlayerPrefs.SetFloat("PosZ", CurrentPlayerPosition.position.z); // отдельные float и записываем
PlayerPrefs.SetFloat("AngX", CurrentPlayerPosition.eulerAngles.x);
PlayerPrefs.SetFloat("AngY", CurrentPlayerPosition.eulerAngles.y);
PlayerPrefs.SetString("level", Application.loadedLevelName); // ещё можно писать/читать строки
PlayerPrefs.SetInt("level_id", Application.loadedLevel); // и целые
}
public void loadPosition(){
Transform CurrentPlayerPosition = this.gameObject.transform;
Vector3 PlayerPosition = new Vector3(PlayerPrefs.GetFloat("PosX"),
PlayerPrefs.GetFloat("PosY"), PlayerPrefs.GetFloat("PosZ"));
Vector3 PlayerDirection = new Vector3(PlayerPrefs.GetFloat("AngX"), // генерируем новые вектора
PlayerPrefs.GetFloat("AngY"), 0); // на основе загруженных данных
CurrentPlayerPosition.position = PlayerPosition; // и применяем их
CurrentPlayerPosition.eulerAngles = PlayerDirection;
}
}
Конечно, тут применение PlayerPrefs довольно надумано — фактически против обычных переменных оно добавляет нам только возможность загрузки игру с места сохранения после выхода.
Зато весь основной интерфейс класса виден: для каждого из трех типов Get / Set по ключу, проверка вхождения по ключу, очистка. Нет смысла даже разбирать ScriptReference, всё очевидно по названиям функций:
PlayerPrefs
Однако на одной всё же стоит остановиться подробнее, PlayerPrefs.Save. В описании говорится, что вообще дефолтно юнити пишет PlayerPrefs на диск только при закрытии приложения — в общем-то логично, учитывая, что класс ориентирован не на внутренний обмен данными, и на их сохранение между сеансами. Соответственно, Save() предполагается использовать только для периодических сохранений на случай крэша.
Возможно, в некоторых случаях это так и работает. Под Win PlayerPrefs пишутся в реестр, и, как можно легко убедиться, считываются и пишутся сразу.
Как-то так выглядит наш класс в реестре:
Ко всем ключам в конце добавлен их DJBX33X-хеш (Bernshtein hash with XOR).
UnityGraphicsQuality сохраняется всегда автоматически, и действительно при закрытии приложения. Это Quality level из Edit -> Project Settings Quality, оно же
QualitySettings.SetQualityLevel
.Можно при запущенном приложении модифицировать сохранённое значение в реестре, потом затребовать его из программы — и мы увидим, что вернулся модифицированный вариант. Т.е. не стоит думать что во время работы программы PlayerPrefs — что-то вроде аналога глобальных переменных, а работа с диском не происходит.
2. Сериализация в XML
Говорим сериализация, подразумеваем бинарный код. Такое встречается, но на самом деле сериализовать можно в любой формат. По сути это перевод структуры данных или состояния объекта в хранимый/передаваемый формат. А десериализация, соответственно — восстановление объекта по сохраненным/полученным данным.
Вообще Mono умеет и бинарную сериализацию, и XML (System.Xml.Serialization), но есть один момент: большинство классов Unity не сериализуются напрямую. Невозможно просто взять и сериализовать GameObject, или класс, наследующий MonoBehavoir: придётся завести дополнительно внутренний сериализуемый класс, содержащий нужные данные, и работаеть, используя его. Но XmlSerializer хотя бы кушает автоматически Vector3, а BinarySerializer, afaik, даже этого не умеет.
2.1 Суть примера
Представьте, что вы пишете свой Portal, где герой проходит череду однотипных локаций — но на любую из них может впоследствии вернуться. Причём на каждую он мог оказать воздействие: какие-то ресурсы использовать, что-то сломать, что-то расшвырять. Хочется, эти изменения сохранять, но возвращение на локацию маловероятно и непрогнозируемо, и тащить за собой параметры всех комнат в оперативке нет особого смысла. Будем сериализовать локацию, покидая её — например, по триггеру на двери. А при загрузке локации генерировать либо дефолтную ситуацию, либо, если есть сохраненные данные, восстанавливать по ним.
2.2 Сериализуемые классы для данных
XmlSerializer умеет работать с классами, данные в которых состоят из других сериализуемых классов, простых типов, большинства элементов Collections[.Generic]. Обязательно наличие у класса пустого конструктора и public-доступ ко всем сериализуемым полям.
Некторые типы из библиотеки Юнити (вроде Vector3, содержащего всего три интовых поля) успешно проходят этот фейсконтроль, но большинство, особенно более сложных, его фейлят.
Допустим, в каждой комнате нам надо сохранять состояния некоторого произвольного набора GameObject'ов. Напрямую сделать этого мы не можем. Значит, нам потребуются дублирующие типы.
Создадим новый скрипт в Standard Assets:
using UnityEngine;
using System.Collections.Generic;
using System.Xml.Serialization;
using System;
[XmlRoot("RoomState")]
[XmlInclude(typeof(PositData))]
public class RoomState { // класс, содержащий состояние комнаты в целом
[XmlArray("Furniture")]
[XmlArrayItem("FurnitureObject")]
public List<PositData> furniture = new List<PositData>(); // список из перемещаемых предметов
public RoomState() { } // пустой конструктор
public void AddItem(PositData item) { // добавление элементов - будем этим пользоваться
furniture.Add(item); // при генерации дефолтной версии локации
}
public void Update(){ // функция, по которой данные этого класса-дубликата объектов
foreach (PositData felt in furniture) // будут обновляться
felt.Update();
}
}
В квадратных скобках идут атрибуты для управления XML-сериализацией. Тут они фактически влияют только на имена тегов в генерируемом *.xml, и строго говоря, необходимости в них нет. Но пусть будут, для наглядности :) Если вам почему-то вдруг важно, как будет выглядеть xml-код, то возможности атрибутов, конечно шире.
Дальше там же добавим базовый класс для предметов из списка и сколько угодно наcледуемых от него. Хотя… для примера хватит и одного:
[XmlType("PositionData")]
[XmlInclude(typeof(Lamp))]
public class PositData
{
protected GameObject _inst; // тут храним ссылку на отражаемый объект
public GameObject inst { set { _inst = value; } }
[XmlElement("Type")]
public string Name { get; set; } // это будет название префаба из Resourses
[XmlElement("Position")]
public Vector3 position {get; set; }
public PositData() { }
public PositData(string name, Vector3 position)
{
this.Name = name;
this.position = position;
}
public virtual void Estate(){ } // для "доработки" объекта после создания
public virtual void Update(){ // обновление нашего рефлектора
position = _inst.transform.position; // согласно реальной информации об объекте
}
}
[XmlType("Lamp")]
public class Lamp : PositData // лампочка, кроме положения, может ещё быть вкл/выкл
{
[XmlAttribute("Light")]
public bool lightOn { get; set; }
public Lamp() { }
public Lamp(string name, Vector3 position, bool lightOn): base(name, position) {
this.lightOn = lightOn;
}
public override void Estate(){
if (!lightOn) ((Light)(_inst.GetComponentInChildren(typeof(Light)))).enabled = false;
} // исходим из того, что в префабе компонент Light включен
public override void Update(){
base.Update();
lightOn = ((Light)_inst.GetComponentInChildren(typeof(Light))).enabled;
} // lightOn = включен ли компонент Light на лампе
}
Итак, сериализуемые классы готовы. Сделаем теперь ещё класс для дополнительного упрощения сериализации созданного типа RoomState.
2.3 Непосредственно сериализация
Тоже в Standard Assets сделаем класс с парой статических методов, которыми будем в дальнейшем пользоваться:
using System.Xml.Serialization;
using System;
using System.IO;
public class Serializator {
static public void SaveXml(RoomState state, string datapath){
Type[] extraTypes= { typeof(PositData), typeof(Lamp)};
XmlSerializer serializer = new XmlSerializer(typeof(RoomState), extraTypes);
FileStream fs = new FileStream(datapath, FileMode.Create);
serializer.Serialize(fs, state);
fs.Close();
}
static public RoomState DeXml(string datapath){
Type[] extraTypes= { typeof(PositData), typeof(Lamp)};
XmlSerializer serializer = new XmlSerializer(typeof(RoomState), extraTypes);
FileStream fs = new FileStream(datapath, FileMode.Open);
RoomState state = (RoomState)serializer.Deserialize(fs);
fs.Close();
return state;
}
}
Здесь XmlSerializer мы создаём через конструктор Constructor (Type, Type[])
FileStream открываем по адресу сохранения, передаваемого конкретной локацией.
Использование
Итак, все вспомогательные инструменты готовы, можно приступать к самой комнате. На объект комнаты вешаем:
public class RoomGen : MonoBehaviour {
private RoomState state; // отражающий класс
private string datapath; // путь к файлу сохранения для этой локации
void Start () {
datapath = Application.dataPath + "/Saves/SavedData" + Application.loadedLevel + ".xml";
if (File.Exists(datapath)) // если файл сохранения уже существует
state = Serializator.DeXml(datapath); // считываем state оттуда
else
setDefault(); // иначе задаём дефолт
Generate(); // генерируем локацию по информации из state
}
void setDefault(){
state = new RoomState();
// chair, table, lamp - нужные префабы из Resourses
state.AddItem(new PositData("chair", new Vector3(15f, 1f, -4f)));
state.AddItem(new PositData("chair", new Vector3(10f, 1f, 0f)));
state.AddItem(new PositData("table", new Vector3(5f, 1f, 4f)));
state.AddItem(new Lamp("lamp", new Vector3(5f, 4f, 4f), true));
}
void Generate(){
foreach (PositData felt in state.furniture){ // для всех предметов в комнате
felt.inst = Instantiate(Resources.Load(felt.Name), felt.position, Quaternion.identity) as GameObject;
// овеществляем их
felt.Estate(); // и задаём дополнительные параметры
}
}
void Dump() {
state.Update(); // вызов обновления state
Serializator.SaveXml(state, datapath); // и его сериализация
}
}
Напоследок, сделаем вызов RoomGen.Dump(). Пусть, например, по триггерам на дверях, которые являются дочерними объектами относительно комнаты (объекта с компонентом RoomGen):
using UnityEngine;
using System.Collections;
public string nextRoom;
public class Door : MonoBehaviour {
void OnTriggerEnter(Collider hit)
{
if (hit.gameObject.tag == "Player")
{
SendMessageUpwards("Dump");
Application.LoadLevel(nextRoom);
}
}
}
Вот и всё. Здесь опущено собственно взаимодействие с предметами и процесс изменения их состояния, но это несложно добавить. Для первоначального теста можно просто добавить в скрипт пару устанавливащих состояния функций по хоткеям, или ставить на паузу и двигать руками.
При первом запуску генерируется дефолтный вариант, при выходе изменения дампятся в файл, при возвращении последние состояние восстанавливается из файла, в том числе если приложение закрывалось. Works like a charm.
Один из существенных недостатков XML — игрок может легко изменить данные. И если в данном случае мало кого заинтересует расположение раскиданных стульев, то при сохранении более существенных для игрока данных сериализации в XML лучше избегать. Да и в реестре поменять значения не сложно. В таких случаях уже лучше использовать бинарную сериализацию или свой формат.
3. Save/Load через меню
Наверное, актуальнее было бы реализовать вариант с выбором/созданием пользователя и внутренними автоматическими сохранениями. Если вашей игре требуется серьёзное меню Save/Load, то вряд ли вы сейчас читаете эту статейку для профанов.
Но я жду не дождусь новогодних праздников, когда можно будет наконец увидеться с сестрой и за пару вечеров добить классическую American McGee's Alice, так что сделаем Save/Load почти как там. Со скриншотами. Заодно будет повод покопаться в GUI, текстурах и других увлекательных вещах.
3.1 Главное меню
Чтобы сделать загрузку и сохранение через меню, нам, как ни странно, понадобится меню. Конечно, можно сделать его самостоятельно через объекты на сцене, можно заюзать готовые решения ироде NGUI , но мы пока воспользуемся GUI из штатной библиотеки.
- Scripting Reference
Для начала пригодятся:
OnGUI()
— функция MonoBehaviour для отрисовки GUI и обработки связанных с ним событий. Нечто вроде Update(), но специально для GUI и вызываться может чаще, чем каждый фрейм.
GUI.Button
static bool Button(Rect position, String text); static bool Button(Rect position, Textureimage );
функция кнопки. Рисует её в рамках заданного прямоугольника, реагирует на нажатие, возвращая true. Конструкторов больше, но нам хватит этих.
GUI.BeginGroup
, GUI.EndGroup
static void BeginGroup (Rect position); static void EndGroup ();
Группировка элементов гуи, полезна в основном переопределением границ относительно которых вычисляется положение вложенных элементов (дефолтно это границы экрана, в данном случает — прямоугольник position).
- Суть
Сделаем отдельную стартовую сцену, а на ней пустой объект, к которому и прикрепим скрипт для нашего меню. Т.о. меню будет путешествовать сквозь сцены (при загрузке очередной сцены не пересоздаётся, а просто переносится в неё), хэндлить нужные события (вроде кнопки вызова меню), при вызове рисоваться поверх экрана игры.
- по коду с комментариями всё должно быть ясно:
public class MenuScript : MonoBehaviour {
public Texture2D backgroundTexture; // фон для режима меню
public const int mainMenuWidth = 200; // ширина кнопок в главном меню
private int menutype = 0; // текущий тип меню - пригодится дальше
private bool menuMode = true; // включено ли меню
private bool gameMode = false; // запущена ли собственно игра -
// false только до первого Load / New Game
private void Awake(){
DontDestroyOnLoad(this); // объект меню не будет разрушаться при загрузке новых сцен
}
void Update () {
if (Input.GetKeyDown(KeyCode.Escape)){ // warning! Это может быть не совсем очевидно: Input.GetKey()
if(gameMode)
if (menutype == 0 || !menuMode){ // если мы в игре или на нижнем уровне меню
switchGameActivity(); // остановливаем/запускаем игровые события
// (в данном случае просто движение камеры)
menuMode = !menuMode;
}
menutype = 0; // с более глубоких уровней меню возвращает в корень
} // из игрового режима грузит главное меню
}
private void OnGUI(){
if (menuMode){
if(backgroundTexture != null) // очевидно, опционально
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), backgroundTexture);
switch (menutype){
case 0: drawMainMenu(); break;
case 1:
case 2: drawSaveLoadMenu(); break;
}
}
}
private void drawMainMenu(){
GUI.BeginGroup (new Rect (Screen.width / 2 - mainMenuWidth/2, Screen.height / 2 - 180, mainMenuWidth, 240));
if (gameMode)
if(GUI.Button(new Rect(0,0, mainMenuWidth,30) , "Resume")){
menuMode = false;
switchMouseLook();
}
if(GUI.Button(new Rect(0, 40, mainMenuWidth, 30) , "New Game")){
menuMode = false;
gameMode = true;
Application.LoadLevel("first_scene");
}
if (gameMode)
if(GUI.Button(new Rect(0, 2*40, mainMenuWidth, 30) , "Save"))
menutype = 1;
if(GUI.Button(new Rect(0, ((gameMode)? 3 : 2)*40, mainMenuWidth, 30) , "Load")){
menutype = 2; // Почему не "2 + gameMode"? C#, nuff said.
if(GUI.Button(new Rect(0, ((gameMode)? 4 : 3)*40, mainMenuWidth, 30) , "Options")){}
if(GUI.Button(new Rect(0, ((gameMode)? 5 : 4)*40, mainMenuWidth, 30) , "Quit Game")){
Application.Quit();
}
GUI.EndGroup();
}
void switchGameActivity(){ // в данном случае cчитается, что на активной камере есть скрипт MouseLook,
Camera mk = Camera.main; // поворачивающий камеру по движению мыши. Вообще тут стоит
MouseLook ml = mk.GetComponent<MouseLook>(); // приостанавливать обработку всех событий ввода
if (ml != null) ml.enabled = !ml.enabled; // и всю динамику
}
}
Главное меню до и после начала игры
3.2 Рисуем меню загрузки / сохранения
Функция drawSaveLoadMenu() у нас уже вызывается при menutype>0, но пока не написана. Исправим это упущение. Пока просто научимся рисовать наши меню и вызывать собственно функции загрузки/сохранения.
- Scripting Reference
GUI.SelectionGrid
— рисует сетку кнопок, но по сути это одновариантый селект. Всегда выбран один вариант, возвращает номер выбранного.
static int SelectionGrid (Rect position , int defaultSelected, Texture[] images, int elsInRow); static int SelectionGrid (Rect position , int defaultSelected, string[] texts, int elsInRow);
Количество — исходя из размеров передаваемого массива. Вообще предназначен для использования как-то так:
public int selGridInt = 0; // на старте задаём выбранное по дефолту public string[] selStrings = new string[] {"Grid 1", "Grid 2", "Grid 3", "Grid 4"}; void OnGUI() { // и дальше держим и изменяем эту переменную selGridInt = GUI.SelectionGrid(new Rect(25, 25, 100, 30), selGridInt, selStrings, 2); // возвращает selGridInt если ничего не произошло, } // и индекс нового элемента, если был клик по одному из элементов сетки
- Суть
Кажется, это не совсем то, что нам требуется — нам-то нужно выбрать один раз и сразу отреагировать. Но SelectionGrid спокойно ест грязный хак — индекс вне пределов реального массива. Т.е. мы всегда будем передавать, допустим, -1 и тогда сможем отслеживать собственно событие клика.
public const int slotsAmount = 10; // количество слотов загрузки/сохранения
// массив наших скриншотов. подробнее о нём дальше,
private Texture2D[] saveTexture = new Texture2D[slotsAmount]; // пока важно, что если слот содержит
//сохранение, то соответствующий элемент массива содержит текстуру в виде скриншота, иначе null
private void drawSaveLoadMenu(){
if(GUI.Button(new Rect(Screen.width / 2 - 100, Screen.height * 2/3 + 50, 200, 30) , "Back"))
menutype = 0;
int slot = GUI.SelectionGrid(
new Rect( // подстраиваем под размер экрана сетку 5x2
Screen.width / 2 - Screen.height * 5/9, // с соотношением
Screen.height/3, // сторон кнопки 4:3
Screen.height * 10/9,
Screen.height/3
),
-1, // индекс вне пределов массива
saveTexture, // если null, то просто не рисует
5); // количество элементов в строке
if (slot >= 0) // выполнится только в случае клика
if (menutype == 1) savegame(slot); // Если это было меню сохранения - сохраняем
else if (menutype == 2 && saveTexture[slot] != null) loadgame (slot);
} // Если загрузки и слот не пуст - загружаем. Информацию о функциональности слотов можно хранить отдельно,
// но тут мы просто воспользовались тем же массивом текстур
Меню Load на SelectionGrid — внешне ничем не отличается от соответствующего Save
Основное, что мне в этом решении не нравится, это что в меню загрузки не содержащие сохранений слоты остаются относительно активными — внешне отличаются только отсутствием текстуры, реагируют на наведение. Поэтому бонусом — сетка ручками, вместо неактивных слотов рисуем Box, для активных Button.
Заодно добавим резиновости: количество слотов в строке задаётся, размер слотов подстраивается под экран. Правда, тут они уже квадратные, но встроить произвольное соотношение сторон будет несложно :) Ну и заодно min/max width/height из GUILayout и прочая обработка напильником.
public const int slotsAmount = 10;
public const int hN = 5;
public const int margin = 20;
static private int vN = (int)Mathf.Ceil((float)slotsAmount/hN);
private Texture2D[] saveTexture = new Texture2D[slotsAmount];
private int slotSize = ((Screen.width*vN)/(Screen.height*hN) >= 1) ? Screen.height/(vN + 2) : Screen.width/(hN + 2);
private void drawSaveLoadMenu(){
GUI.BeginGroup (new Rect ( Screen.width / 2 - (slotSize*hN - margin) / 2,
Screen.height / 2 - (slotSize*vN - margin) / 2,
slotSize*hN - margin, slotSize*vN + 40));
for (int j = 0; j < vN; j++)
for (int i = 0, curr = j*hN; (curr = j*hN + i) < slotsAmount; i++){
if (menutype == 2 && saveTexture[curr] == null)
GUI.Box(new Rect(slotSize*i, slotSize*j, slotSize - margin, slotSize - margin), "");
else
if(GUI.Button(new Rect(slotSize*i, slotSize*j, slotSize - margin, slotSize - margin),
saveTexture[curr])){
if (menutype == 1) savestuff(curr);
else if (menutype == 2) loadstuff (curr);
}
}
if(GUI.Button(new Rect(slotSize*hN/2 - 100, slotSize*vN , 200, 30) , "Back"))
menutype = 0;
GUI.EndGroup();
}
Меню Load на Button и Box — теперь пустые слоты неактивны
3.3 Текстуры, скриншоты
Итак, с момента создания нашего объекта меню мы будем держать массив текстур. Памяти он занимает немного и нам гарантирован в ним мгновенный доступ. На самом деле, тут и альтернативы особой нет — не пихать же работу с диском в onGUI().
Как мы уже видели, при создании нашего меню создаём и массив:
private Texture2D[] saveTexture = new Texture2D[slotsAmount];
Сохранять мы будем не только информацию сейвов, но и информацию о них, а точнее — какие именно слоты содержат сохранения. Как хранить — выбор каждого, можно по параметру 0/1 на каждый слот, можно строку из 0/1, но мы сделаем некрасиво :) и возьмём битовый вектор в int. В какой момент и как он сохраняется, увидим позже, пока просто читаем.
Добавим в Start():
int gS = PlayerPrefs.GetInt("gamesSaved");
for (int i = 0; i < slotsAmount && gS > 0; i++, gS/=2)
if (gS%2 != 0){
saveTexture[i] = new Texture2D(Screen.width/4, Screen.height/4); // тут тот же размер, в котором пишем на диск
saveTexture[i].LoadImage(System.IO.File.ReadAllBytes(Application.dataPath + "/tb/Slot" + i + ".png"));
} // и адрес, конечно, тоже совпадает с тем, куда сохраняем.
Ну и собственно главное в данном вопросе — как скрины сохранять? Напрашивается вариант
Application.CaptureScreenshot
, но тут сразу два подвоха. Во-первых, они сохраняются в полном размере, а поскольку в кончном итоге понадобятся нам только thumbnails, логичнее сразу сделать ресайз. Во-вторых, мы же держим массив текстур, придётся в него снова считывать с диска? Не очень-то здорово.Функцию взятия и записи скриншота вызывать будем позже, а пока заранее выделим в Coroutine:
IEnumerator readScreen(int i){
yield return new WaitForEndOfFrame(); // так мы и избежим ошибок, и не заскриншотим само меню сохранения :)
int adjustedWidth = Screen.height * 4/3; // допустим, мы хотим держать определенное соотношение сторон
Texture2D tex1 = new Texture2D(adjustedWidth, Screen.height);
// кропаем в свежесозданную текстуру нужный участок экрана
tex1.ReadPixels(new Rect((Screen.width - adjustedWidth)/2, 0, adjustedWidth, Screen.height), 0, 0, true);
tex1.Apply();
//создаем новую текстуру нужного размера
Texture2D tex = new Texture2D(Screen.height/3, Screen.height/4, TextureFormat.RGB24, true);
tex.SetPixels(tex1.GetPixels(2)); // и применяем наш суровый и беспощадный ресайз
Destroy(tex1);
tex.Apply();
saveTexture[i] = tex; // сохраняем в массив текстур
FileStream fs = System.IO.File.Open(Application.dataPath + "/tb/Slot" + i + ".png", FileMode.Create);
BinaryWriter binary = new BinaryWriter(fs);
binary.Write(tex.EncodeToPNG()); // и на диск
fs.Close();
}
Неприятный нерешенный момент — текстура с текущей сессии и текстура, загруженная с диска, сильно различаются по качеству.
Ниже слева две текущих, справа — две с диска, от предыдущих сессий:
3.4 Собственно реализация сохранения загрузки
Итак, вроде бы с шелухой разобрались. Научились минимально работе с GUI, сделали простое главное меню, меню Save/Load, научились работать со скриншотами.
Как реализовать взаимодействие между объектами сцены, параметры которых мы будем сохранять и нашим меню?
1. Если мы будем записывать только состояние такого же создаваемого с первой сцены и неразрушаемого далее объекта (например, игрок, его параметры и инвентарь) — можно сразу держать прямую ссылку.
2.
GameObject.Find
и GameObject.FindWithTag
тут использовать практически не стыдно — загрузка/сохранение — разовое событие. Можно искать напрямую, а поскольку сцены могут содержать разную информацию — то, как вариант, добавлять на каждую специальный объект с определенным тегом, к которому и будет прикручен скрипт сохранения/загрузки собственно данной сцены, тут уже можно держать прямые ссылки на требуемые объекты.3. < место зарезервировано под иные варианты оптимальнее, предлагайте! >
А пока рассмотрим такой простой вариант. Сохранять будем только сцену и положение игрока. Игрок в каждой сцене пересоздаётся, но всегда вид от первого лица, и соответственно к игроку прикреплена камера.
Через неё и будем получать доступ. В ниже представленной функции вся эта специфика — в двух строках помеченных //!, и её не сложно локально заменить, остальное привязано к уже написанному нами выше коду.
void savegame(int i)
{
PlayerPrefs.SetInt("slot" + i + "_Lvl", Application.loadedLevel); // сохраняем текущий уровень
// !
Interface сi = Camera.main.GetComponent<Interface>();
if (сi != null) сi.save(i); // а основную работу с PlayerPrefs делегируем самому объекту
menuMode = false; // выходим из меню
switchGameActivity(); // возобновляем игру
// немного простой битовой магии. Апдейтим информацию о функциональности слотов.
// Как уже сказано, можно использовать и более читаемые варианты.
// в текущей реализации это нам пригодится только при следующем запуске
PlayerPrefs.SetInt("gamesSaved", PlayerPrefs.GetInt("gamesSaved") | (1 << i));
StartCoroutine(readScreen(i)); // делаем скриншот уже после выхода из меню
}
Если делать скриншот заранее, то он во-первых, может не пригодится, а во-вторых, нужно ещё успеть. А так, с учётом заблокированности камеры в режиме меню, результат примерно тот же.
С загрузкой ещё проще. Всё специфику мы снова полностью делегируем, причём даже не будем её напрямую вызывать. Просто загрузим нужный уровень, а дальше они как-нибудь сами :)
void loadgame(int i)
{
if (gameMode) // меню Load доступно сразу, так что тут пригодится проверка
switchGameActivity();
PlayerPrefs.SetInt("Load", i); // сохраняем информацию о том, какой слот загружен
Application.LoadLevel(PlayerPrefs.GetInt("slot" + i + "_Lvl")); // загружаем нужный уровень
menuMode = false;
gameMode = true; // на случай, если игра ещё не запускалась
}
Сделаем теперь поведение, который будем вешать на камеры:
public class Interface : MonoBehaviour {
private Transform CurrentPlayerPosition;
public virtual void Start () {
int load = PlayerPrefs.GetInt("Load"); // проверяем, не создан ли объект
if (load >= 0){ // в рез-те загрузки сохранения
load(load);
PlayerPrefs.SetInt("Load", -1); // обнуляем Load
}
}
public virtual void save(int i)
{ // получаем текущую позицию игрока и сохраняем все её параметры
CurrentPlayerPosition = this.gameObject.transform.parent.transform;
PlayerPrefs.SetFloat("slot" + i + "_PosX", CurrentPlayerPosition.position.x);
PlayerPrefs.SetFloat("slot" + i + "_PosY", CurrentPlayerPosition.position.y);
PlayerPrefs.SetFloat("slot" + i + "_PosZ", CurrentPlayerPosition.position.z);
}
public virtual void load(int i) {
CurrentPlayerPosition = this.gameObject.transform.parent.transform;
// создаем новый вектор на основе загруженных параметров
Vector3 PlayerPosition = new Vector3(PlayerPrefs.GetFloat("slot" + i + "_PosX"),
PlayerPrefs.GetFloat("slot" + i + "_PosY"), PlayerPrefs.GetFloat("slot" + i + "_PosZ"));
CurrentPlayerPosition.position = PlayerPosition; // и применяем его, изменяя
} // положение игрока на сохраненное
}
Надо заметить „дальше как-нибудь сами“ было определенной степенью лукавства: loadgame() меню и load() объекта определенно обменялись информацией, только вот через известное место — реестр. Сохранять туда откровенно временную переменную — ход не слишком красивый. Можно изменить на прямой вызов load(), а без изменения текущей общей структуры — держать переменную в меню, и в Start() загружаемого объекта добавить поиск объекта меню и получение нужной информации.
Дальше. От созданного базового поведения мы можем унаследовать разные варианты для разных сцен и объектов. Например, вариант с сохранением поворота:
public class InterfaceWAng : Interface {
public override void Start () {
base.Start();
}
public override void save(int i)
{
base.save(i);
PlayerPrefs.SetFloat("slot" + i + "_AngX", CurrentPlayerPosition.eulerAngles.x);
PlayerPrefs.SetFloat("slot" + i + "_AngY", CurrentPlayerPosition.eulerAngles.y);
}
public override void load(int i)
{
base.load(i);
Vector3 PlayerDirection = new Vector3(PlayerPrefs.GetFloat("slot" + i + "_AngX"),
PlayerPrefs.GetFloat("slot" + i + "_AngY"), 0);
CurrentPlayerPosition.eulerAngles = PlayerDirection;
}
}
Конечно, здесь данным уже пригодилась бы защита. Поскольку поскольку вся фактическая работа с PlayerPrefs тут выделена в отдельные функции save() / load(), заменить их содержательную часть будет не сложно. На что? Можно аналогично примеру из части 2 держать класс-рефлектор, и сериализовать его через BinarySerializer.
Другой неплохой вариант — прикрутить, например, SQLite. Правда, по слухам, на js с ней работать удобнее, чем на шарпе, но и на последнем всё в конечном итоге заводится. Кто хочет попробовать, начать можно отсюда.
Этот текст никогда бы не получился без:
гугла
docs.unity3d.com
wiki.unity3d.com
forum.unity3d.com
answers.unity3d.com
stackoverflow.com
и хабра. Спасибо им.
Надеюсь, всё это принесёт кому-нибудь пользу, и никому — вреда :)