Unity: система сохранения для любого проекта
Комментарии (18)
5 июня 2017 в 20:01
+1↑
↓
как вариант можно с помощью атрибутов разметить поля которые требуется сохранять, с помощью T4 который поддерживается в MonoDevelop и VS сгенерить DTO классы и методы копирования этих полей в DTO. А потом нормальным сериализатором положить это в JSON.
И при этом не писать руками функции сохранения/загрузки.
btw. синглтоны с глобальным доступом это зло.5 июня 2017 в 20:08
0↑
↓
Насчет глобальных синглтонов не знаю. Но я у себя сделал самый закрытый менеджер игры.
Он у меня — такой мост между всеми подсистемами. Слушает события и вызывает нужные функции. А к самому нему никто доступ не имеет.
Все, что с ним можно сделать — проинициализировать (ну, чтобы он хоть как-то появился в памяти).Максимально закрытый GameManagerpublic class GameManager { static int EnemiesCount; static int EnemiesCountCurrent; static float MatchStartSeconds; static PlayerController player; public static void Init() { player = GameObject.FindObjectOfType
(); player.OnDamaged += OnPlayerDamagedHandler; AbstractAliveController.OnDead += OnAliveDeadHandler; EnemiesCount = GameObject.FindObjectsOfType ().Length; UI.UIController.OnRestart += RestartMatch; InitMatch(); } }
5 июня 2017 в 20:16
0↑
↓
А потом вы добавили новое поле в класс и сохраненное значение развернётся как получится.
Где то переименовали свойство, где то ещё что-то…Я пока не видел идеальных и хороших решений на все случаи жизни.
Несовместимость разных версий сохранений и приложений — полная печаль, на мой взгляд.5 июня 2017 в 20:25
0↑
↓
Новое поле добавляется в модель. Копирование модели в модель пишешь сам. Хотя по идее должно же быть встроенное средство копирования объектов.
А каркас остается. Я сначала на геймсджем писал игру про котосминогов и сделал там эту систему сохранения. А потом для тестового задания про шутер просто использовал ее же, заменив модели. До самого последнего момента не знал, заработает ли. Вызвал в GameManager AbstractSaveLoad.LoadInit () для рестарта матча — и все заработало.
Конечно, лучший код — ненаписанный код и кнопочка «Сделать прекрасно». Но чем богаты.
Во всяком случае добавление новых сохраняемых сущностей происходит максимально безболезненно, проверено на двух разных проектах.
5 июня 2017 в 20:29
+1↑
↓
А может быть просто сохранять в json непосредственно сам тип объекта при сохранении? И при загрузке создавать объект примерно таким образом (сам я правда так не пробовал):System.Reflection.Assembly.GetExecutingAssembly().CreateInstance(className);
5 июня 2017 в 20:40
0↑
↓
Выглядит очень похоже на правду.
Я тоже так не пробовал. Если добавить в мое решение автоматическое полное копирование объектов, автоматическую сериализацию и автоопределение типа, то будет полностью завершенное решение.5 июня 2017 в 22:47
0↑
↓
Но вообще есть нюанс. Из разряда «нам бы ваши проблемы». Переносимость сохранений из МегаИгра1 в МегаИгра2: Воскрешение.
Мы не можем гарантировать, что какой-нибудь умник не переименует класс. Да может быть мы сами в процессе разработки что-то переименуем. И, предположим, у нас есть несколько сохранений, чтобы тестировать игру в разных местах (ну вдруг).
Короче, надо об этом очень сильно помнить.
5 июня 2017 в 21:01
+2↑
↓
Самый быстрый способ сохранения/загрузки состояния который я знаю и использую примерно такой:
1. Создание оберток для MemoryStream по контрактам вида:public interface IBinaryWriter : IDisposable { void WriteBoolean(bool val); void WriteByte(byte val); void WriteBytes(byte[] val); void WriteDouble(double val); void WriteInt32(Int32 number); void WriteLong(Int64 number); void WriteString(string line); void WriteGuid(Guid guid); void WriteDateTime(DateTime datetime); // Плюс такие же методы для коллекций } public interface IBinaryReader: IDisposable { bool ReadBoolean(); byte ReadByte(); byte[] ReadBytes(); Double ReadDouble(); Int32 ReadInt32(); Int64 ReadLong(); string ReadString(); Guid ReadGuid(); DateTime? ReadDateTime(); // Плюс такие же методы для коллекций }
2. Делаем контракт
public interface IBinarySerializable { void Serialize(IBinaryWriter writer); void Deserialize(IBinaryReader reader); }
3. Пример использованияpublic class Location : IBinarySerializable { public Location() { } public Location(IBinaryReader reader) { Deserialize(reader); } public double X; public double Y; public void Deserialize(IBinaryReader reader) { this.X = reader.ReadDouble(); this.Y = reader.ReadDouble(); } public void Serialize(IBinaryWriter writer) { writer.WriteDouble(this.X); writer.WriteDouble(this.Y); } } public class Player : IBinarySerializable { public string Name; public double Health; public Location Position; public void Deserialize(IBinaryReader reader) { this.Name = reader.ReadString(); this.Health = reader.ReadDouble(); Position = new Location(reader); } public void Serialize(IBinaryWriter writer) { writer.WriteString(this.Name); writer.WriteDouble(this.Health); this.Position.Serialize(writer); } }
Минусы:
- нужно быть внимательным
- нет переносимости (т.к. не храним информацию о типах), но если используется только .net и нет динамической генерации типов, это не существенно
Плюсы:
- ~30% экономии по объему памяти для каждого объекта по сравнению с нативной сериализацией (BinaryFormatter)
- ~500% выигрыш по скорости сериализации (по сравнению с тем же BinaryFormatter)
Да, есть куча готовых мапперов, но каждый слой абстракции будет добавлять своё замедление.
5 июня 2017 в 21:06
0↑
↓
1) Спрячте под спойлер, пожалуйста.
2) Можно ли так сериализовать тип? Чтобы ридер сам знал, что читает из файла?
3) Можно ли так сериализовать всё в один файл (из коллекции) и потом прочитать это же всё и создать нужные объекты?
4) насколько удобно это для передачи по сети?
2–4 — потому что я никогда не работал с бинарной сериализацией, я во многих вопросах еще нуб.5 июня 2017 в 21:24
0↑
↓
- Не успел отредактировать, уже не спрячу.
- Можно, но зачем, проще знать порядок записи чтения и следовать ему. Как в примере.
- Конечно, по сути это линейная запись байт-массива, можно сохранить любое количество объектов. Кроме того можно унаследовать от IBinaryWriter и IBinaryReader класс который пишет сразу в файл, чтобы не хранить промежуточно байт-массив в оперативе.
- Именно для передачи по сети и делал изначально этот подход, т.к. в моем приложении объем данных и скорость сериализации критичны.
Пример как это выглядит (Unity не знаю и игры не пишу, поэтому что придумалось то и есть):
Показатьpublic class FileStreamWriter : IBinaryWriter { // ToDo } public class FileStreamReader : IBinaryReader { // ToDo } public class GameState { public Player Player; public List
Enemies; public List Bullets; public DateTime DayTime; } public class GameStateConservator { public void Save(string saveName, GameState state) { using (IBinaryWriter writer = new FileStreamWriter(saveName)) { state.Player.Serialize(writer); writer.WriteCollection (state.Enemies); writer.WriteCollection (state.Bullets); writer.WriteDateTime(state.DayTime); writer.Complete(); } } public GameState Load(string saveName) { var state = new GameState(); using (IBinaryReader reader = new FileStreamReader(saveName)) { state.Player = new Player(reader); state.Enemies = reader.ReadCollection (); state.Bullets = reader.ReadCollection (); state.DayTime = reader.ReadDateTime(); } return state; } } 5 июня 2017 в 21:32
0↑
↓
Перечитал заголовок статьи, если для любого проекта, тогда лучше прикрутить маппер. Который будет использовать такой же механизм сериализации, но позволит не писать вручную код. Главное не делать чтение/запись полей через Reflection, лучше использовать Emit или ExpressionTree. Или взять что-то готовое из nuget’а.5 июня 2017 в 21:36 (комментарий был изменён)
0↑
↓
Суть-то как раз в том, чтобы сделать решение, которое ложится на любой проект. Чтобы одно и то же по сто раз не писать.
И у меня пока что нет сохранения в файл, всё хранится в оперативке — и в этом все равно есть смысл, ибо загрузка последнего сохранения (чекпоинта) и рестарт уровня.
5 июня 2017 в 21:38 (комментарий был изменён)
0↑
↓
В Юнити нельзя предсказать, в какой последовательности объекты сохранятся. И, следовательно, в какой последовательности будут храниться и загружаться.
Вообще, может, и можно: создать вручную начальное сохранение, а потом читать из него в заранее заданном порядке в начале игры, в этом же порядке хранить в коллекции и в этом же порядке записывать.
Но вообще бывает такая вещь как крафт: о) И расход ресурсов. И смерть (исчезновение) персонажей.
В общем случае набор сущностей в игре непостоянный, поэтому «сразу знать, что и где» — негибко.5 июня 2017 в 21:47
0↑
↓
Посмотрел как делают люди (тут или тут или тут) и везде механизм аналогичный описанному.5 июня 2017 в 22:44
0↑
↓
1) Я лично слабо представляю, как в PlayerPrefs хранить весь мир Fallout4
2) Ну и переносимость сохранений.
Так что файлы наше всё6 июня 2017 в 01:45 (комментарий был изменён)
0↑
↓
1) Потому что это глупость. В PlayerPrefs надо хранить небольшие данные: настройки и т.п. Никто в здравом уме туда весь мир пихать не станет.Как по мне, самый оптимальный вариант либо Binary Serizlization, либо в json. Разве что ручками всё это писать придётся.
6 июня 2017 в 08:46 (комментарий был изменён)
0↑
↓
Больше синглтонов богу синглтонов? Синглтоны и статичные переменные сложных типов — зло. Ищите решение, которое их не использует. Например пусть какой-нибудь менеджер находит все компоненты определенного типа и сохраняет их. Не забываем добавить уникальный идентификатор для каждого такого компонента.
Для сериализации есть прекрасная утилита, которая заслуживает упоминания в этой статье:JsonUtility.ToJson(); JsonUtility.FromJson
();
которая может сериализовать практически любой класс с приемлемой скоростью.
Вот накидал за полчаса примерчик:Примерная реализация без синглтоновПример менеджера сохраняющего нужные нам данные:public class TransformSaver : MonoBehaviour { [SerializeField] private Transform[] _transforms; private readonly SaveManager _saveManager = new SaveManager(); private void Start() { for (var index = 0; index < _transforms.Length; index++) _saveManager.Register(new TransformSave(index.ToString(), _transforms[index])); } [ContextMenu("Save")] public void Save() { _saveManager.Save(); } [ContextMenu("Load")] public void Load() { _saveManager.Load(); } }
Остальные классы, нужные для работы сего безобразия:public class SaveManager { private readonly List
_saves = new List (); public void Register(ISave element) { _saves.Add(element); } public void Unregister(ISave element) { _saves.Remove(element); } public void Save() { var saves = new Saves(); foreach (var save in _saves) saves.Add(save.Uid, save.Serialize()); PlayerPrefs.SetString("Save", JsonUtility.ToJson(saves)); PlayerPrefs.Save(); // Force save player prefs } public void Load() { var json = PlayerPrefs.GetString("Save", ""); if (string.IsNullOrEmpty(json)) return; var saves = JsonUtility.FromJson (json); for (var index = 0; index < saves.Uids.Count; index++) { var element = _saves.Single(x => x.Uid == saves.Uids[index]); element.Deserialize(saves.List[index]); } } [Serializable] private class Saves { public List Uids = new List (); public List List = new List (); public void Add(string uid, string value) { if (Uids.Contains(uid)) throw new ArgumentException("Uids has already have \"" + uid + "\""); Uids.Add(uid); List.Add(value); } } } public interface ISave { string Uid { get; } string Serialize(); void Deserialize(string json); } public class TransformSave : ISave { private readonly Transform _transform; public string Uid { get; private set; } public TransformSave(string uid, Transform transform) { Uid = uid; _transform = transform; } public string Serialize() { return JsonUtility.ToJson( new TransformData { Position = _transform.position, Rotation = _transform.rotation } ); } public void Deserialize(string json) { var deserialized = JsonUtility.FromJson (json); _transform.SetPositionAndRotation(deserialized.Position, deserialized.Rotation); D.Log("Json", json); } [Serializable] private class TransformData { public Vector3 Position; public Quaternion Rotation; } } 6 июня 2017 в 10:25
0↑
↓
А в чем проблема с синглтонами и статичными переменными сложных типов?За утилиту спасибо: о)