Unity: система сохранения для любого проекта

Комментарии (18)

  • 5 июня 2017 в 20:01

    +1

    как вариант можно с помощью атрибутов разметить поля которые требуется сохранять, с помощью T4 который поддерживается в MonoDevelop и VS сгенерить DTO классы и методы копирования этих полей в DTO. А потом нормальным сериализатором положить это в JSON.
    И при этом не писать руками функции сохранения/загрузки.
    btw. синглтоны с глобальным доступом это зло.
    • 5 июня 2017 в 20:08

      0

      Насчет глобальных синглтонов не знаю. Но я у себя сделал самый закрытый менеджер игры.
      Он у меня — такой мост между всеми подсистемами. Слушает события и вызывает нужные функции. А к самому нему никто доступ не имеет.
      Все, что с ним можно сделать — проинициализировать (ну, чтобы он хоть как-то появился в памяти).
      Максимально закрытый GameManager
      public 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

        1. Не успел отредактировать, уже не спрячу.
        2. Можно, но зачем, проще знать порядок записи чтения и следовать ему. Как в примере.
        3. Конечно, по сути это линейная запись байт-массива, можно сохранить любое количество объектов. Кроме того можно унаследовать от IBinaryWriter и IBinaryReader класс который пишет сразу в файл, чтобы не хранить промежуточно байт-массив в оперативе.
        4. Именно для передачи по сети и делал изначально этот подход, т.к. в моем приложении объем данных и скорость сериализации критичны.

        Пример как это выглядит (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

      А в чем проблема с синглтонами и статичными переменными сложных типов?

      За утилиту спасибо: о)

© Habrahabr.ru