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

Игры надо сохранять. Сохраняемых сущностей может быть великое множество. Например, в последних выпусках 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. shai_hulud
    /#10250430 / +1

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

    • Neongrey
      /#10250434

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

      Максимально закрытый 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. MonkAlex
    /#10250440 / +2

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

    Я пока не видел идеальных и хороших решений на все случаи жизни.
    Несовместимость разных версий сохранений и приложений — полная печаль, на мой взгляд.

    • Neongrey
      /#10250454 / -2

      Новое поле добавляется в модель. Копирование модели в модель пишешь сам. Хотя по идее должно же быть встроенное средство копирования объектов.
      А каркас остается. Я сначала на геймсджем писал игру про котосминогов и сделал там эту систему сохранения. А потом для тестового задания про шутер просто использовал ее же, заменив модели. До самого последнего момента не знал, заработает ли. Вызвал в GameManager AbstractSaveLoad.LoadInit() для рестарта матча — и все заработало.
      Конечно, лучший код — ненаписанный код и кнопочка «Сделать прекрасно». Но чем богаты.
      Во всяком случае добавление новых сохраняемых сущностей происходит максимально безболезненно, проверено на двух разных проектах.

  3. igormich88
    /#10250456 / +1

    А может быть просто сохранять в json непосредственно сам тип объекта при сохранении? И при загрузке создавать объект примерно таким образом (сам я правда так не пробовал):

    System.Reflection.Assembly.GetExecutingAssembly().CreateInstance(className);
    

    • Neongrey
      /#10250470

      Выглядит очень похоже на правду.
      Я тоже так не пробовал. Если добавить в мое решение автоматическое полное копирование объектов, автоматическую сериализацию и автоопределение типа, то будет полностью завершенное решение.

    • Neongrey
      /#10250580

      Но вообще есть нюанс. Из разряда «нам бы ваши проблемы». Переносимость сохранений из МегаИгра1 в МегаИгра2: Воскрешение.
      Мы не можем гарантировать, что какой-нибудь умник не переименует класс. Да может быть мы сами в процессе разработки что-то переименуем. И, предположим, у нас есть несколько сохранений, чтобы тестировать игру в разных местах (ну вдруг).
      Короче, надо об этом очень сильно помнить.

  4. Ogoun
    /#10250482 / +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)


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

    • Neongrey
      /#10250486

      1) Спрячте под спойлер, пожалуйста.
      2) Можно ли так сериализовать тип? Чтобы ридер сам знал, что читает из файла?
      3) Можно ли так сериализовать всё в один файл (из коллекции) и потом прочитать это же всё и создать нужные объекты?
      4) насколько удобно это для передачи по сети?
      2-4 — потому что я никогда не работал с бинарной сериализацией, я во многих вопросах еще нуб.

      • Ogoun
        /#10250504

        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;
            }
        }
        

        • Ogoun
          /#10250508

          Перечитал заголовок статьи, если для любого проекта, тогда лучше прикрутить маппер. Который будет использовать такой же механизм сериализации, но позволит не писать вручную код. Главное не делать чтение/запись полей через Reflection, лучше использовать Emit или ExpressionTree. Или взять что-то готовое из nuget'а.

          • Neongrey
            /#10250520

            Суть-то как раз в том, чтобы сделать решение, которое ложится на любой проект. Чтобы одно и то же по сто раз не писать.
            И у меня пока что нет сохранения в файл, всё хранится в оперативке — и в этом все равно есть смысл, ибо загрузка последнего сохранения (чекпоинта) и рестарт уровня.

        • Neongrey
          /#10250524

          В Юнити нельзя предсказать, в какой последовательности объекты сохранятся. И, следовательно, в какой последовательности будут храниться и загружаться.
          Вообще, может, и можно: создать вручную начальное сохранение, а потом читать из него в заранее заданном порядке в начале игры, в этом же порядке хранить в коллекции и в этом же порядке записывать.
          Но вообще бывает такая вещь как крафт: о) И расход ресурсов. И смерть (исчезновение) персонажей.
          В общем случае набор сущностей в игре непостоянный, поэтому «сразу знать, что и где» — негибко.

          • Ogoun
            /#10250526

            Посмотрел как делают люди (тут или тут или тут) и везде механизм аналогичный описанному.

            • Neongrey
              /#10250578

              1) Я лично слабо представляю, как в PlayerPrefs хранить весь мир Fallout4
              2) Ну и переносимость сохранений.
              Так что файлы наше всё

              • Suvitruf
                /#10250668

                1) Потому что это глупость. В PlayerPrefs надо хранить небольшие данные: настройки и т.п. Никто в здравом уме туда весь мир пихать не станет.

                Как по мне, самый оптимальный вариант либо Binary Serizlization, либо в json. Разве что ручками всё это писать придётся.

  5. WeslomPo
    /#10250818 / +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;
    	}
    }
    

    • Neongrey
      /#10250910

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

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

      • WeslomPo
        /#10250982 / +3

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

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

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

        Вообще почитайте про инверсию управления через Dependency Injection. Отличный фреймворк для этих целей под Unity — Zenject. В пару проектов втыкаешь и забываешь про синглтоны.

        • Neongrey
          /#10251136 / -2

          Я стараюсь использовать в Unity как можно меньше Unity. Ибо в реальных проектах, которые на работе за зарплат, логика часто shared или серверная.
          А еще можно взять shared бизнес-логику, скомпилировать в dll и перенести на Unreal. Поэтому имхо чем меньше ScriptableObject, MonoBehaviour и прочего using UnityEngine, тем лучше.

        • Neongrey
          /#10251174

          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) А конкретную реализацию меняй как вздумается.

          • WeslomPo
            /#10251360 / +1

            Я стараюсь использовать в Unity как можно меньше Unity.

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

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

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

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

            • Neongrey
              /#10251374

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


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

              • Anger22
                /#10252408

                А в основном так и происходит, пишут огромные фреймворки над юнити, т.к. стандартные компоненты жутко тормозят и их никак не оптимизировать.

    • Anger22
      /#10251066

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

  6. 2morrowMan
    /#10251064 / +1

    Синглтоны-синглтоны…
    У кого сколько было случаев попытки создания второй копии синглтон класса и чтобы вот эти предосторожности помогали? Типо, «Ошибочка, бро! Это же синглтон!!»
    У меня пока ни разу…

  7. KonH
    /#10259002

    Завязываться на monoBehaviour-синглоны кажется не самой лучшей идеей. Все же лучше такие вещи выносить отдельно и иметь какой-то общий контроллер, который будет заниматься только загрузкой и сохранением.
    Почитать как сделано в моем хобби-проекте можно тут. Есть единая точка входа, где мы и определяем что и в каком формате должно сохраняться (сейчас это сериализация data-классов в один json-файл). Есть куда это все развивать, но в целом такой подход кажется более подходящим.

    • Neongrey
      /#10259048

      Хм. Меня, похоже, не поняли. Синглтон я использую только как демонстрацию наследования статических полей. И попутно показываю, что есть такой способ делать синглтоне, если кому надо. Моя система сохранения не основана на синглтоне. Она основана на обобщённом классе поведения (контроллере), которому можно передать любую модель и таким образом легко получить сохраняемый объект, просто определив поля и не реализуя один и тот же функционал каждый раз. А потом все такие объекты можно сохранить вызовом одной функции.