Pull to refresh

Comments 28

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

Максимально закрытый 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();
        }
}

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

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

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


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

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

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

Как по мне, самый оптимальный вариант либо Binary Serizlization, либо в json. Разве что ручками всё это писать придётся.
Больше синглтонов богу синглтонов? Синглтоны и статичные переменные сложных типов — зло. Ищите решение, которое их не использует. Например пусть какой-нибудь менеджер находит все компоненты определенного типа и сохраняет их. Не забываем добавить уникальный идентификатор для каждого такого компонента.
Для сериализации есть прекрасная утилита, которая заслуживает упоминания в этой статье:
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;
	}
}



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

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

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

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

Вообще почитайте про инверсию управления через Dependency Injection. Отличный фреймворк для этих целей под Unity — Zenject. В пару проектов втыкаешь и забываешь про синглтоны.
Я стараюсь использовать в Unity как можно меньше Unity. Ибо в реальных проектах, которые на работе за зарплат, логика часто shared или серверная.
А еще можно взять shared бизнес-логику, скомпилировать в dll и перенести на Unreal. Поэтому имхо чем меньше ScriptableObject, MonoBehaviour и прочего using UnityEngine, тем лучше.
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) А конкретную реализацию меняй как вздумается.
Я стараюсь использовать в Unity как можно меньше Unity.

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

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

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

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

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


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

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

Articles

Change theme settings