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

    Игры надо сохранять. Сохраняемых сущностей может быть великое множество. Например, в последних выпусках TES и Fallout игра помнит расположение каждой закатившейся склянки. Необходимо решение, чтобы:

    1) Написал один раз и используй в любом проекте для любых сущностей. Ну, насколько возможно.
    2) Создал сущность — и она сохраняется сама собою, с минимумом дополнительных усилий.

    Решение пришло из стана синглтонов. Не надоело ли вам писать один и тот же синглтон-код? А меж тем есть generic singleton.

    Вот как он выглядит для MonoBehaviour
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class GenericSingleton<T> : MonoBehaviour {
        static GenericSingleton<T> instance;
        public static GenericSingleton<T> Instance { get { return instance; } }
    
    	void Awake () {
    		if (instance && instance != this)
            {
                Destroy(this);
                return;
            }
            instance = this;
    	}
    }
    

    public class TestSingletoneA : GenericSingleton<TestSingletoneA> {
    
    	// Use this for initialization
    	void Start () {
            Debug.Log("A");
    	}
    }
    


    Т.е. можно сделать Generic класс, статические поля которого будут уникальны для каждого входного типа.

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

    Вот код интерфейса модели. Он примечателен тем, что метод SetValues примет в качестве аргумента только модель такого же (или производного) типа. Не чудо ли?

    AbstractModel
    /// <summary>
    /// Voloshin Game Framework: basic scripts supposed to be reusable
    /// </summary>
    namespace VGF
    {    
        //[System.Serializable]
        public interface AbstractModel<T> where T : AbstractModel<T>, new()
        {
            /// <summary>
            /// Copy fields from target
            /// </summary>
            /// <param name="model">Source model</param>
            void SetValues(T model);            
        }
    
        public static class AbstratModelMethods
        {
            /// <summary>
            /// Initialize model with source, even if model is null
            /// </summary>
            /// <typeparam name="T"></typeparam>
            /// <param name="model">Target model, can be null</param>
            /// <param name="source">Source model</param>
            public static void InitializeWith<T>(this T model, T source) where T: AbstractModel<T>, new ()
            {
                //model = new T();
                if (source == null)
                    return;
                model.SetValues(source);
            }
        }
    }
    


    Для модели также нужен обобщенный контроллер, но с ним связан нижеследующий нюанс, поэтому пока что опустим.

    От этих классов — абстрактной модели и обобщенного контроллера можно наследовать всё, что сохраняется и загружается. Написал модель, унаследовал контроллер — и забыл, всё работает. Отлично!

    А что делать с сохранением и загрузкой? Ведь нужно сохранять и загружать сразу всё. А писать для каждой новой сущности код для сохранения и загрузки в каком-нибудь SaveLoadManager — утомительно и легкозабываемо.

    И тут на помощь приходят статики.

    1) Абстрактный класс с protected функциями сохранения и загрузки
    2) У него — статичная коллекция All, куда каждый экземпляр класса-потомка добавляется при инициализации
    3) И статичные публичные функции сохранения и загрузки, внутри которых перебираются все экземпляры из All и вызываются конкретные методы сохранения и загрузки.

    И вот какой код получается в результате.

    SaveLoadBehaviour
    using System.Collections.Generic;
    using UnityEngine;
    
    namespace VGF
    {
        /* Why abstract class instead of interface?
         * 1) Incapsulate all save, load, init, loadinit functions inside class, make them protected, mnot public
         * 2) Create static ALL collection and static ALL methods
         * */
         //TODO: create a similar abstract class for non-mono classes. For example, PlayerController needs not to be a MonoBehaviour
        /// <summary>
        /// Abstract class for all MonoBehaiour classes that support save and load
        /// </summary>
        public abstract class SaveLoadBehaviour : CachedBehaviour
        {
            /// <summary>
            /// Collection that stores all SaveLoad classes in purpose of providing auto registration and collective save and load
            /// </summary>
            static List<SaveLoadBehaviour> AllSaveLoadObjects = new List<SaveLoadBehaviour>();
    
            protected override void Awake()
            {
                base.Awake();
                Add(this);
            }
    
            static void Add(SaveLoadBehaviour item)
            {
                if (AllSaveLoadObjects.Contains(item))
                {
                    Debug.LogError(item + "  element is already in All list");
                }
                else
                    AllSaveLoadObjects.Add(item);
            }
    
            public static void LoadAll()
            {
                foreach (var item in AllSaveLoadObjects)
                {
                    if (item == null)
                    {
                        Debug.LogError("empty element in All list");
                        continue;
                    }
                    else
                        item.Load();
                }
            }
    
            public static void SaveAll()
            {
                Debug.Log(AllSaveLoadObjects.Count);
                foreach (var item in AllSaveLoadObjects)
                {
                    if (item == null)
                    {
                        Debug.LogError("empty element in All list");
                        continue;
                    }
                    else
                        item.Save();
                }
            }
    
            public static void LoadInitAll()
            {
                foreach (var item in AllSaveLoadObjects)
                {
                    if (item == null)
                    {
                        Debug.LogError("empty element in All list");
                        continue;
                    }
                    else
                        item.LoadInit();
                }
            }
    
            protected abstract void Save();
    
            protected abstract void Load();
    
            protected abstract void Init();
    
            protected abstract void LoadInit();
        }
    }


    GenericModelBehaviour<T>
    using UnityEngine;
    
    namespace VGF
    {
        /// <summary>
        /// Controller for abstract models, providing save, load, reset model
        /// </summary>
        /// <typeparam name="T">AbstractModel child type</typeparam>
        public class GenericModelBehaviour<T> : SaveLoadBehaviour where T: AbstractModel<T>, new()
        {
            [SerializeField]
            protected T InitModel;
            //[SerializeField]
            protected T CurrentModel, SavedModel;
    
            protected override void Awake()
            {
                base.Awake();
                //Init();
            }
    
            void Start()
            {
                Init();
            }
    
            protected override void Init()
            {
                //Debug.Log(InitModel);
                if (InitModel == null)
                    return;
                //Debug.Log(gameObject.name + " : Init current model");
                if (CurrentModel == null)
                    CurrentModel = new T();
                CurrentModel.InitializeWith(InitModel);
                //Debug.Log(CurrentModel);
                //Debug.Log("Init saved model");
                SavedModel = new T();
                SavedModel.InitializeWith(InitModel);
            }
    
            protected override void Load()
            {
                //Debug.Log(gameObject.name + "   saved");
                LoadFrom(SavedModel);
            }
    
            protected override void LoadInit()
            {
                LoadFrom(InitModel);
            }
    
            void LoadFrom(T source)
            {
                if (source == null)
                    return;
                CurrentModel.SetValues(source);
            }
    
            protected override void Save()
            {
                //Debug.Log(gameObject.name + "   saved");
                if (CurrentModel == null)
                    return;
                if (SavedModel == null)
                    SavedModel.InitializeWith(CurrentModel);
                else
                    SavedModel.SetValues(CurrentModel);
            }
        }
    }


    Примеры унаследованных конкретных классов:

    AbstractAliveController : GenericModelBehaviour
    public abstract class AbstractAliveController : GenericModelBehaviour<AliveModelTransform>, IAlive
        {
            //TODO: create separate unity implementation where put all the [SerializeField] attributes
            [SerializeField]
            bool Immortal;
            static Dictionary<Transform, AbstractAliveController> All = new Dictionary<Transform, AbstractAliveController>();
            public static bool GetAliveControllerForTransform(Transform tr, out AbstractAliveController aliveController)
            {
                return All.TryGetValue(tr, out aliveController);
            }
    
            DamageableController[] BodyParts;
            
            public bool IsAlive { get { return Immortal || CurrentModel.HealthCurrent > 0; } }
            public bool IsAvailable { get { return IsAlive && myGO.activeSelf; } }
            public virtual Vector3 Position { get { return myTransform.position; } }
    
            public static event Action<AbstractAliveController> OnDead;
            /// <summary>
            /// Sends the current health of this alive controller
            /// </summary>
            public event Action<int> OnDamaged;
    
            //TODO: create 2 inits
            protected override void Awake()
            {
                base.Awake();
                All.Add(myTransform, this);            
            }
    
            protected override void Init()
            {
                InitModel.Position = myTransform.position;
                InitModel.Rotation = myTransform.rotation;
                base.Init();
                
                BodyParts = GetComponentsInChildren<DamageableController>();
                foreach (var bp in BodyParts)
                    bp.OnDamageTaken += TakeDamage;
            }
    
            protected override void Save()
            {
                CurrentModel.Position = myTransform.position;
                CurrentModel.Rotation = myTransform.rotation;
                base.Save();
            }
    
            protected override void Load()
            {
                base.Load();
                LoadTransform();
            }
    
            protected override void LoadInit()
            {
                base.LoadInit();
                LoadTransform();
            }
    
            void LoadTransform()
            {
                myTransform.position = CurrentModel.Position;
                myTransform.rotation = CurrentModel.Rotation;
                myGO.SetActive(true);
            }
    
            public void Respawn()
            {
                LoadInit();
            }
    
            public void TakeDamage(int damage)
            {
                if (Immortal)
                    return;
                CurrentModel.HealthCurrent -= damage;
                OnDamaged.CallEventIfNotNull(CurrentModel.HealthCurrent);
                if (CurrentModel.HealthCurrent <= 0)
                {
                    OnDead.CallEventIfNotNull(this);
                    Die();
                }
            }
    
            public int CurrentHealth
            {
                get { return CurrentModel == null? InitModel.HealthCurrent: CurrentModel.HealthCurrent; }
            }
    
            protected abstract void Die();
    
        }
    


    AliveModelTransform : AbstractModel
    namespace VGF.Action3d
    {
        [System.Serializable]
        public class AliveModelTransform : AliveModelBasic, AbstractModel<AliveModelTransform>
        {
            [HideInInspector]
            public Vector3 Position;
            [HideInInspector]
            public Quaternion Rotation;
    
            public void SetValues(AliveModelTransform model)
            {
                Position = model.Position;
                Rotation = model.Rotation;
                base.SetValues(model);
            }
        }
    }


    Недостатки решения и способы их исправления.

    1) Сохраняется (перезаписывается) всё. Даже то, что не было изменено.

    Возможное решение: проверять перед сохранением равенство полей у исходной и текущей моделей и сохранять только при необходимости.

    2) Загрузка из файла. Из json, например. Вот есть список моделей. Как загрузчику узнать, какой класс надо создать для этого json-текста?

    Возможное решение: сделать словарь <System.Type, string> где регистрировать типы хардкодом. При загрузке из json берется строковой идентификатор типа и инстанцируется объект нужного класса. При сохранении объект проверяет, есть ли в словаре ключ его типа, и выдает сообщение/ошибку/исключение. Это позволит стороннему программисту не забыть добавить новый тип в словарь.

    Посмотреть мой код с этим и другими хорошими решениями можно здесь (проекты в начальной стадии):

    FPSProject
    Невероятные космические похождения изворотливых котосминогов

    Замечания, улучшения, советы — приветствуются.
    Предложения помощи и совместного творчества приветствуются.
    Предложения о работе крайне приветствуются.


    UPD:
    Вижу, возникают вопросы а ля «Каков профит от твоего решения? Все равно же делать модели, делать сериализацию.»
    Отвечаю:
    Вы пришли на чекпоинт или нажали кнопку сохранить. Кнопка или чекпоинт сообщили классу-менеджеру, что нужно сохранить состояние игры. Что делает менеджер?

    Плохой вариант 1:
    void Save()
    {
        Entity1.Save;
        Entity2.Save;
        Entity3.Save;
        ...
        EntityInfinity.Save;
    }

    Плохой вариант 2: Каждый SaveLoadBehaviour подписывается на событие OnSave менеджера. Или регистрирует себя в каком-то «контейнере».
    Плохо, потому что SaveLoadBehaviour должен знать о существовании менеджера/контейнера. Я же пытался сделать так, чтобы классы были максимально автономны, а все знания об их связях хранились в самом менеджере.

    Плохой вариант 3: менеджер при инициализации ищет все сохраняемые компоненты.
    1) Функция поиска может отличаться между платформами. GameObject.FindObjectsOfType() применима только для MonoBehaviour, а что если мы делаем shared-логику? Реализация должна быть максимально гибкой и кроссплатформенной.
    2) Если мы решим переписать менеджер с нуля (для другой игры, например), то надо обязательно не забыть вставить функцию поиска.

    Мой хороший вариант:
    class GameManager
    {
        void Save()
        {
            AbstractSaveLoadBehaviour.SaveAll();
        }
    }


    Еще мне задали вопрос, что делать, если мы хотим положить на один геймобжект несколько saveloadbehaviour? Как они при загрузке соберутся в один геймобжект?

    Вот решение, которое пришло мне на ум:
    1. В каждый SaveloadBehaviour в функции сохранения и загрузки добавить вызов события.
      void Save()
      {
          if (OnSaveSendToMainBehaviour == null)
          {
              //сохраняем в файл
          }
          else
          OnSaveSendToMainBehaviour(savedModel)
      }
      
    2. ГЛАВНЫЙ контроллер сущности, который при инициализации ищет все компоненты SaveLoadBehaviour и подписывается на их события.
    3. Если он есть, то он агрегирует события от всех контроллеров сохранения, собирает их модели и сохраняет их в файл сам, единолично.
    4. Чтобы проверить, что все контроллеры уже всё отправили, можно сделать счетчик.
      voidOnSaveModelFromDependentController(model partModel)
      {
          currentSaveCount++;        
          model.AddPArtModel(partModel);    
          if (currentSaveCount == TotalSaveCount)
              Save(model);
      }
    5. И даже добавление такого убер-контроллера можно автоматизировать. Каждый saveloadbehaviour на Awake или Start ищет, есть ли другие. Если есть, то ищет убер-контроллер и по необходимости добавляет.
      А убер-контроллер на Awake или Start подписывается на всех.
      Двойной подписки не произойдет, т.к. убер-контроллер будет добавлен лишь единожды, и его Awake/Start тоже лишь единожды будет вызван.
    Поделиться публикацией
    Комментарии 28
      +1
      как вариант можно с помощью атрибутов разметить поля которые требуется сохранять, с помощью T4 который поддерживается в MonoDevelop и VS сгенерить DTO классы и методы копирования этих полей в DTO. А потом нормальным сериализатором положить это в JSON.
      И при этом не писать руками функции сохранения/загрузки.
      btw. синглтоны с глобальным доступом это зло.
        0
        Насчет глобальных синглтонов не знаю. Но я у себя сделал самый закрытый менеджер игры.
        Он у меня — такой мост между всеми подсистемами. Слушает события и вызывает нужные функции. А к самому нему никто доступ не имеет.
        Все, что с ним можно сделать — проинициализировать (ну, чтобы он хоть как-то появился в памяти).

        Максимально закрытый GameManager
        public class GameManager
            {
                static int EnemiesCount;
                static int EnemiesCountCurrent;
                static float MatchStartSeconds;
                static PlayerController player;
        
                public static void Init()
                {
                    player = GameObject.FindObjectOfType<PlayerController>();
                    player.OnDamaged += OnPlayerDamagedHandler;
                    AbstractAliveController.OnDead += OnAliveDeadHandler;
                    EnemiesCount = GameObject.FindObjectsOfType<NPC.Enemy.EnemyController>().Length;
                    UI.UIController.OnRestart += RestartMatch;
                    InitMatch();
                }
        }
        

        +2
        А потом вы добавили новое поле в класс и сохраненное значение развернётся как получится.
        Где то переименовали свойство, где то ещё что-то…

        Я пока не видел идеальных и хороших решений на все случаи жизни.
        Несовместимость разных версий сохранений и приложений — полная печаль, на мой взгляд.
          –2
          Новое поле добавляется в модель. Копирование модели в модель пишешь сам. Хотя по идее должно же быть встроенное средство копирования объектов.
          А каркас остается. Я сначала на геймсджем писал игру про котосминогов и сделал там эту систему сохранения. А потом для тестового задания про шутер просто использовал ее же, заменив модели. До самого последнего момента не знал, заработает ли. Вызвал в GameManager AbstractSaveLoad.LoadInit() для рестарта матча — и все заработало.
          Конечно, лучший код — ненаписанный код и кнопочка «Сделать прекрасно». Но чем богаты.
          Во всяком случае добавление новых сохраняемых сущностей происходит максимально безболезненно, проверено на двух разных проектах.
          +1
          А может быть просто сохранять в json непосредственно сам тип объекта при сохранении? И при загрузке создавать объект примерно таким образом (сам я правда так не пробовал):
          System.Reflection.Assembly.GetExecutingAssembly().CreateInstance(className);
          

            0
            Выглядит очень похоже на правду.
            Я тоже так не пробовал. Если добавить в мое решение автоматическое полное копирование объектов, автоматическую сериализацию и автоопределение типа, то будет полностью завершенное решение.
              0
              Но вообще есть нюанс. Из разряда «нам бы ваши проблемы». Переносимость сохранений из МегаИгра1 в МегаИгра2: Воскрешение.
              Мы не можем гарантировать, что какой-нибудь умник не переименует класс. Да может быть мы сами в процессе разработки что-то переименуем. И, предположим, у нас есть несколько сохранений, чтобы тестировать игру в разных местах (ну вдруг).
              Короче, надо об этом очень сильно помнить.
              +3
              Самый быстрый способ сохранения/загрузки состояния который я знаю и использую примерно такой:
              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)


              Да, есть куча готовых мапперов, но каждый слой абстракции будет добавлять своё замедление.

                0
                1) Спрячте под спойлер, пожалуйста.
                2) Можно ли так сериализовать тип? Чтобы ридер сам знал, что читает из файла?
                3) Можно ли так сериализовать всё в один файл (из коллекции) и потом прочитать это же всё и создать нужные объекты?
                4) насколько удобно это для передачи по сети?
                2-4 — потому что я никогда не работал с бинарной сериализацией, я во многих вопросах еще нуб.
                  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<Enemy> Enemies;
                      public List<Bullet> 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<Enemy>(state.Enemies);
                              writer.WriteCollection<Bullet>(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<Enemy>();
                              state.Bullets = reader.ReadCollection<Bullet>();
                              state.DayTime = reader.ReadDateTime();
                          }
                          return state;
                      }
                  }
                  

                    0
                    Перечитал заголовок статьи, если для любого проекта, тогда лучше прикрутить маппер. Который будет использовать такой же механизм сериализации, но позволит не писать вручную код. Главное не делать чтение/запись полей через Reflection, лучше использовать Emit или ExpressionTree. Или взять что-то готовое из nuget'а.
                      0
                      Суть-то как раз в том, чтобы сделать решение, которое ложится на любой проект. Чтобы одно и то же по сто раз не писать.
                      И у меня пока что нет сохранения в файл, всё хранится в оперативке — и в этом все равно есть смысл, ибо загрузка последнего сохранения (чекпоинта) и рестарт уровня.
                      0
                      В Юнити нельзя предсказать, в какой последовательности объекты сохранятся. И, следовательно, в какой последовательности будут храниться и загружаться.
                      Вообще, может, и можно: создать вручную начальное сохранение, а потом читать из него в заранее заданном порядке в начале игры, в этом же порядке хранить в коллекции и в этом же порядке записывать.
                      Но вообще бывает такая вещь как крафт: о) И расход ресурсов. И смерть (исчезновение) персонажей.
                      В общем случае набор сущностей в игре непостоянный, поэтому «сразу знать, что и где» — негибко.
                        0
                        Посмотрел как делают люди (тут или тут или тут) и везде механизм аналогичный описанному.
                          0
                          1) Я лично слабо представляю, как в PlayerPrefs хранить весь мир Fallout4
                          2) Ну и переносимость сохранений.
                          Так что файлы наше всё
                            0
                            1) Потому что это глупость. В PlayerPrefs надо хранить небольшие данные: настройки и т.п. Никто в здравом уме туда весь мир пихать не станет.

                            Как по мне, самый оптимальный вариант либо Binary Serizlization, либо в json. Разве что ручками всё это писать придётся.
                  +2
                  Больше синглтонов богу синглтонов? Синглтоны и статичные переменные сложных типов — зло. Ищите решение, которое их не использует. Например пусть какой-нибудь менеджер находит все компоненты определенного типа и сохраняет их. Не забываем добавить уникальный идентификатор для каждого такого компонента.
                  Для сериализации есть прекрасная утилита, которая заслуживает упоминания в этой статье:
                  JsonUtility.ToJson();
                  JsonUtility.FromJson<Foo>();
                  

                  которая может сериализовать практически любой класс с приемлемой скоростью.
                  Вот накидал за полчаса примерчик:
                  Примерная реализация без синглтонов
                  Пример менеджера сохраняющего нужные нам данные:
                  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<ISave> _saves = new List<ISave>();
                  
                  	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<Saves>(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<string> Uids = new List<string>();
                  		public List<string> List = new List<string>();
                  
                  		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<TransformData>(json);
                  		_transform.SetPositionAndRotation(deserialized.Position, deserialized.Rotation);
                  		D.Log("Json", json);
                  	}
                  
                  	[Serializable]
                  	private class TransformData
                  	{
                  		public Vector3 Position;
                  		public Quaternion Rotation;
                  	}
                  }
                  



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

                    За утилиту спасибо: о)
                      +3
                      Синглтоны: Повышение связанности кода, невозможностью заменить\удалить и почти всегда применение синглтона говорит о том что с архитектурой что-то не то — участок кода попахивает.
                      http://rsdn.org/forum/design/2615563.flat#2615563

                      Статичные переменные — родственники глобальных переменных. Основная проблема — потеря контроля над значениями. Их может изменить кто угодно и откуда угодно. Я, похоже запутался, и сказал про сложный тип, но имел в виду константы (строки, числа). Т.е. константные статические переменные еще куда не шло, а вот изменяемые значения — зло стопроцентное.

                      Хотя в Unity — использовать константы не круто, потому что сам по себе движок помогает с сериализацией, и в 99% случаев лучше использовать ScriptableObject, а значения менять прямо из редактора.

                      Вообще почитайте про инверсию управления через Dependency Injection. Отличный фреймворк для этих целей под Unity — Zenject. В пару проектов втыкаешь и забываешь про синглтоны.
                        –2
                        Я стараюсь использовать в Unity как можно меньше Unity. Ибо в реальных проектах, которые на работе за зарплат, логика часто shared или серверная.
                        А еще можно взять shared бизнес-логику, скомпилировать в dll и перенести на Unreal. Поэтому имхо чем меньше ScriptableObject, MonoBehaviour и прочего using UnityEngine, тем лучше.
                          0
                          1) Чтобы кто угодно не изменял, делаешь public get protected set
                          2) Я делаю следующим образом: все поля в модели public, но сама модель видна только своему контроллеру
                          3) Статичные публичные — у меня обычно методы. Вот тот же SaveLoadBehaviour. У него
                          protected static List All
                          А вот методы SaveAll, LoadInitAll — публичные.
                          4) Мне надоело писать для синглтонов MyClass.Instance.DoMethod(), я делаю MyClass.DoMethod() и в нем уже на статический инстанс ссылаюсь.
                          Т.е. может дело не в синглтонах, а в том, чтобы правильно их готовить?

                          А повышение связности когда — опять же, помогут прямые руки и ООП. Для заменяемости/удаляемости пользуйся интерфейсами, и будет тебе счастье.
                          В моей статье предложен подход:
                          1) абстрактный класс со статик полями
                          2) от него наследуется обобщенный класс для работы с разными моделями
                          3) от обощенного — конкретная реализация

                          Тот же подход можно применить для синглтонов
                          1) Интерфейс, определяющий желаемое поведение синглтона IAdapter
                          2) Обобщенный синглтон GenericSingletone3) Абстрактный класс, наследующий от синглтона и интерфейса.
                          SingletoneOne: GenericSingletone, IAdapter
                          4) Если мы по примеру 3 сделаем SingletoneTwo, то у них буду разные static Instance
                          5) А вот если мы унаследуем от SingletoneOne, то у SingletoneOne_1 и SingletoneOne_2 статический инстанс будет общим!
                          6) Соответственно, во всем коде работаешь с SingletoneOne.Instance.AdapterMetod()
                          7) А конкретную реализацию меняй как вздумается.
                            +1
                            Я стараюсь использовать в Unity как можно меньше Unity.

                            По статье как-то незаметно, компоненты поверх компонентов, компонентами погоняют :). ScriptableObject'ы легко меняются на обычный класс. Сравни с моей реализацией — там один MB — как точка входа алгоритма, а записывать можно любые данные практически.

                            1 — Есть еще readonly, но с MonoBehaviour такое не провернешь. А можно геттером спрятать.
                            3 и 4 — MyClass.Something уже плохо.
                            Правильно приготовленный синглтон — это синглтон который не был написан.

                            Обращаясь из одного класса к другому через MyClass.Something — ты создаешь зависимость, которую довольно сложно отследить, а потом, при рефакторинге заменить. Привязываешь класс А к классу Б стальными тросами. Тут даже нет речи об интерфейсах и т.д. Сплошное несчастье.

                            Это сложно объяснить, но когда ты столкнешься с этим на практике, то поймешь насколько синглтоны — зло.

                              0

                              Блин, классы взаимодействуют, куда от этого деться? Я предполагаю, что если человек использовал синглтон, значит, ему реально позарез надо обращаться от одного класса к другому. Для примера можно взять игрока. Методы получения урона и гибели одинаковы у игрока и врага. Но на гибель игрока игра должна особым образом реагировать. Посылается событие person.ondead, а игра должна проверить, не игрок ли преставился.
                              Это для примера. Я пытался сказать, что если синглтон кажется разработчику подходящим решением, то недостатки сильной связности можно устранить интерфейсами и наследованиями


                              Ну и компоненты у меня — тоже тут никуда не денешься. Шутер, коллизии, физика, трансформы всякие. Тут единственный способ избавиться от юнити — писать свой движок

                                0
                                А в основном так и происходит, пишут огромные фреймворки над юнити, т.к. стандартные компоненты жутко тормозят и их никак не оптимизировать.
                        0
                        Json конечно же удобен до того момента, как вам придеться делать игры под консоли, которые налагают определенные ограничения на время сохранения. Кроме того, в случае когда в игре много сохранений — всё это дело начинает занимать много места не жестком диске, что для клаудсейвов очень даже критично.
                        +1
                        Синглтоны-синглтоны…
                        У кого сколько было случаев попытки создания второй копии синглтон класса и чтобы вот эти предосторожности помогали? Типо, «Ошибочка, бро! Это же синглтон!!»
                        У меня пока ни разу…
                          0
                          Завязываться на monoBehaviour-синглоны кажется не самой лучшей идеей. Все же лучше такие вещи выносить отдельно и иметь какой-то общий контроллер, который будет заниматься только загрузкой и сохранением.
                          Почитать как сделано в моем хобби-проекте можно тут. Есть единая точка входа, где мы и определяем что и в каком формате должно сохраняться (сейчас это сериализация data-классов в один json-файл). Есть куда это все развивать, но в целом такой подход кажется более подходящим.
                            0
                            Хм. Меня, похоже, не поняли. Синглтон я использую только как демонстрацию наследования статических полей. И попутно показываю, что есть такой способ делать синглтоне, если кому надо. Моя система сохранения не основана на синглтоне. Она основана на обобщённом классе поведения (контроллере), которому можно передать любую модель и таким образом легко получить сохраняемый объект, просто определив поля и не реализуя один и тот же функционал каждый раз. А потом все такие объекты можно сохранить вызовом одной функции.

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое