[Перевод] Карты из шестиугольников в Unity: сохранение и загрузка, текстуры, расстояния

Части 1–3: сетка, цвета и высоты ячеек.

Части 4–7: неровности, реки и дороги.

Части 8–11: вода, объекты рельефа и крепостные стены


  • Отслеживаем тип рельефа вместо цвета.
  • Создаём файл.
  • Записываем данные в файл, а затем считываем его.
  • Сериализуем данные ячеек.
  • Уменьшаем размер файла.


Мы уже умеем создавать достаточно интересные карты. Теперь нужно научиться их сохранять.

7bb2ce7409d37733e3b6f96c237ce1e9.jpg


Загружено из файла test.map.

Тип рельефа


При сохранении карты нам не нужно хранить все данные, которые мы отслеживаем в процессе выполнения приложения. Например, нам достаточно запоминать только уровень высоты ячейки. Сама её вертикальная позиция берётся из этих данных, поэтому хранить её не нужно. На самом деле лучше, если мы не будем хранить эти вычисляемые метрики. Таким образом данные карты останутся правильными, даже если позже мы решим изменить смещение высоты. Данные отделены от их представления.

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

Перемещение массива цветов


Если в ячейках больше нет данных о цвете, то он должен храниться где-то ещё. Удобнее всего хранить его в HexMetrics. Поэтому давайте добавим в него массив цветов.

  public static Color[] colors;


Как и все другие глобальные данные, например шум, мы можем инициализировать эти цвета с помощью HexGrid.

  public Color[] colors;
        
        …
        
        void Awake () {
                HexMetrics.noiseSource = noiseSource;
                HexMetrics.InitializeHashGrid(seed);
                HexMetrics.colors = colors;

                …
        }

        …

        void OnEnable () {
                if (!HexMetrics.noiseSource) {
                        HexMetrics.noiseSource = noiseSource;
                        HexMetrics.InitializeHashGrid(seed);
                        HexMetrics.colors = colors;
                }
        }


И так как теперь мы не назначаем цвета непосредственно ячейкам, избавимся от цвета по умолчанию.

//        public Color defaultColor = Color.white;
        
        …
                                        
        void CreateCell (int x, int z, int i) {
                …

                HexCell cell = cells[i] = Instantiate(cellPrefab);
                cell.transform.localPosition = position;
                cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z);
//              cell.Color = defaultColor;

                …
        }


Настроим новые цвета так, чтобы они соответствовали общему массиву редактора карт шестиугольников.

4bf3cb002342f9ab3aa482cf3a526042.png


Цвета, добавляемые в сетку.

Рефакторинг ячеек


Уберём из HexCell поле цвета. Вместо него мы будем хранить индекс. А вместо индекса цвета мы используем более общий индекс типа рельефа.

//        Color color;
        int terrainTypeIndex;


Свойство color может использовать этот индекс только для получения соответствующего цвета. Он теперь не задаётся напрямую, поэтому удалим эту часть. При этом мы получим ошибку компиляции, которую скоро исправим.

  public Color Color {
                get {
                        return HexMetrics.colors[terrainTypeIndex];
                }
//              set {
//                      …
//              }
        }


Добавим новое свойство для получения и задания нового индекса типа рельефа.

  public int TerrainTypeIndex {
                get {
                        return terrainTypeIndex;
                }
                set {
                        if (terrainTypeIndex != value) {
                                terrainTypeIndex = value;
                                Refresh();
                        }
                }
        }


Рефакторинг редактора


Внутри HexMapEditor удалим весь код, касающийся цветов. Это исправит ошибку компиляции.

//        public Color[] colors;

        …

//      Color activeColor;

        …

//      bool applyColor;

…

//      public void SelectColor (int index) {
//              applyColor = index >= 0;
//              if (applyColor) {
//                      activeColor = colors[index];
//              }
//      }

…

//      void Awake () {
//              SelectColor(0);
//      }
        
        …
        
        void EditCell (HexCell cell) {
                if (cell) {
//                      if (applyColor) {
//                              cell.Color = activeColor;
//                      }
                        …
                }
        }


Теперь добавим поле и метод для управления активным индексом типа рельефа.

  int activeTerrainTypeIndex;
        
        …
        
        public void SetTerrainTypeIndex (int index) {
                activeTerrainTypeIndex = index;
        }


Используем этот метод как замену теперь отсутствующему методу SelectColor. Соединим виджеты цветов в UI с SetTerrainTypeIndex, оставив всё остальное без изменений. Это означает, что отрицательный индекс всё ещё используется и обозначает, что цвет не должен меняться.

Изменим EditCell так, чтобы индекс типа рельефа назначался редактируемой ячейке.

  void EditCell (HexCell cell) {
                if (cell) {
                        if (activeTerrainTypeIndex >= 0) {
                                cell.TerrainTypeIndex = activeTerrainTypeIndex;
                        }
                        …
                }
        }


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

8cce7e6acb202e119a6e7d5cdae2db74.png


Жёлтый — новый цвет по умолчанию.

unitypackage

Сохранение данных в файле


Для управления сохранением и загрузкой карты мы используем HexMapEditor. Создадим два метода, которые займутся этим, и пока оставим их пустыми.

  public void Save () {
        }

        public void Load () {
        }


Добавим в UI две кнопки (GameObject / UI / Button). Подключим их к кнопкам и дадим соответствующие метки. Я поместил их в нижнюю часть правой панели.

6eceba96bb5ff18fd0b4ad9472a19695.png


Кнопки Save и Load.

Расположение файла


Для хранения карты нужно её куда-то сохранить. Как делается в большинстве игр, мы будем хранить данные в файле. Но куда в файловой системе поместить этот файл? Ответ зависит от того, в какой операционной системе запущена игра. У каждой ОС есть свои стандарты хранения файлов, относящихся к приложениям.

Нам не нужно знать этих стандартов. Unity знает подходящий путь, который мы можем получить с помощью Application.persistentDataPath. Можете проверить, каким он будет у вас, в методе Save выведя его в консоль и нажав кнопку в режиме Play.

  public void Save () {
                Debug.Log(Application.persistentDataPath);
        }


В настольных системах путь будет содержать название компании и продукта. Этот путь используют и редактор, и сборки. Названия можно настроить в Edit / Project Settings / Player.

656a60056f9d51c5a5f3d81762135b87.png


Название компании и продукта.

Почему я не могу найти папку Library на Mac?

Папка Library часто скрыта. Способ, которым её можно отобразить, зависит от версии OS X. Если у вас не старая версия, выберите в Finder папку home и перейдите в Show View Options. Там есть флажок для папки Library.


Как насчёт WebGL?

Игры на WebGL не могут получать доступ к файловой системе пользователя. Вместо этого все операции с файлами перенаправляются в файловую систему, расположенную в памяти. Она прозрачна для нас. Однако для сохранения данных нужно будет вручную приказать веб-странице сбросить данные в хранилище браузера.


Создание файла


Чтобы создать файл, нам нужно использовать классы из пространства имён System.IO. Поэтому добавим оператор using для него над классом HexMapEditor.

using UnityEngine;
using UnityEngine.EventSystems;
using System.IO;

public class HexMapEditor : MonoBehaviour {
        …
}


Сначала нам нужно создать полный путь к файлу. В качестве имени файла мы используем test.map. Он должен быть добавлен к пути сохраняемых данных. Нужно ли вставлять обычную или обратную косую черту (slash или backslash), зависит от платформы. Этим займётся метод Path.Combine.

  public void Save () {
                string path = Path.Combine(Application.persistentDataPath, "test.map");
        }


Далее нам нужно получить доступ к файлу в этом местоположении. Мы делаем это с помощью метода File.Open. Так как мы хотим записать данные в этот файл, то нужно использовать его режим create. При этом по указанному пути или создастся новый файл, или заменится уже существовавший файл.

          string path = Path.Combine(Application.persistentDataPath, "test.map");
                File.Open(path, FileMode.Create);


Результатом вызова этого метода будет открытый поток данных, связанный с этим файлом. Мы можем использовать его для записи данных в файл. И нужно не забыть закрыть поток, когда он нам больше не нужен.

          string path = Path.Combine(Application.persistentDataPath, "test.map");
                Stream fileStream = File.Open(path, FileMode.Create);
                fileStream.Close();


На этом этапе при нажатии на кнопку Save будет создаваться файл test.map в папке, указанной как путь к хранимым данным. Если изучить этот файл, то он будет пустым и иметь размер 0 байт, потому что пока мы ничего в него не записали.

Запись в файл


Чтобы записать данные в файл, нам нужен способ для потоковой передачи в него данных. Проще всего это сделать с помощью BinaryWriter. Эти объекты позволяют записывать примитивные данные в любой поток.

Создадим новый объект BinaryWriter, а его аргументом будет наш файловый поток. Закрытие writer закрывает и используемый им поток. Поэтому нам не нужно больше хранить прямую ссылку на поток.

          string path = Path.Combine(Application.persistentDataPath, "test.map");
                BinaryWriter writer =
                        new BinaryWriter(File.Open(path, FileMode.Create));
                writer.Close();


Для передачи данных в поток мы можем использовать метод BinaryWriter.Write. Существует вариант метода Write для всех примитивных типов, таких как integer и float. Также он может записывать строки. Давайте попробуем записать integer 123.

          BinaryWriter writer =
                        new BinaryWriter(File.Open(path, FileMode.Create));
                writer.Write(123);
                writer.Close();


Нажмём кнопку Save и снова изучим test.map. Теперь его размер равен 4 байтам, потому что размер integer равен 4 байтам.

Почему мой диспетчер файлов показывает, что файл занимает больше места?

Потому что файловые системы разделяют пространство на блоки байтов. Они не отслеживают отдельные байты. Так как test.map занимает пока только четыре байта, для него требуется один блок пространства накопителя.


Заметьте, что мы сохраняем двоичные данные, а не человекочитаемый текст. Поэтому если мы откроем файл в текстовом редакторе, то увидим набор невнятных символов. Вероятно, вы увидите символ {, за которым нет ничего или есть несколько символов-заполнителей.

Можно открыть файл в hex-редакторе. В этом случае мы увидим 7b 00 00 00. Это четыре байта нашего integer, отображённые в шестнадцатеричной записи. В обычных десятичных числах это 123 0 0 0. В двоичной записи первый байт выглядит как 01111011.

ASCII-код для { равен 123, поэтому в текстовом редакторе может отображаться этот символ. ASCII 0 — это нулевой символ, не соответствующий никаким видимым символам.

Остальные три байта равны нулю, потому что мы записали число меньше 256. Если бы мы записали 256, то в hex-редакторе увидели бы 00 01 00 00.

Разве 123 не должно сохраняться как 00 00 00 7b?
Для сохранения чисел BinaryWriter использует формат little-endian. Это значит, что первыми записываются наименее значимые байты. Этот формат использовала Microsoft при разработке фреймворка .Net. Вероятно, он был выбран потому, что в ЦП Intel используется формат little-endian.

Альтернативой ему является big-endian, в котором первыми хранятся самые значимые байты. Это соответствует обычному порядку цифр в числах. 123 — это сто двадцать три, потому что мы подразумеваем запись big-endian. Если бы это была little-endian, то 123 обозначало бы триста двадцать один.


Делаем так, чтобы ресурсы освобождались


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

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

          using (
                        BinaryWriter writer =
                                new BinaryWriter(File.Open(path, FileMode.Create))
                ) {
                        writer.Write(123);
                }
//              writer.Close();


Это сработает, потому что классы writer и файлового потока реализуют интерфейс IDisposable. Эти объекты имеют метод Dispose, который косвенно вызывается при выходе за пределы области действия using.

Большое преимущество using в том, что он работает вне зависимости от того, как выполнение программы выходит из области действия. Ранние возвраты, исключения и ошибки ему не мешают. Кроме того, он очень лаконичный.

Получение данных


Чтобы считать ранее записанные данные, нам нужно вставить код в метод Load. Как и в случае сохранения, нам нужно создать путь и открыть файловый поток. Разница в том, что теперь мы открываем файл на чтение, а не на запись. И вместо writer нам понадобится BinaryReader.

  public void Load () {
                string path = Path.Combine(Application.persistentDataPath, "test.map");
                using (
                        BinaryReader reader =
                                new BinaryReader(File.Open(path, FileMode.Open))
                ) {
                }
        }


В этом случае мы можем использовать метод File.OpenRead, чтобы открыть файл с целью его чтения.

          using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
                }


Почему мы не можем использовать при записи File.OpenWrite?

Этот метод создаёт поток, который добавляет данные к существующим файлам, а не заменяет их.


При считывании нам нужно явным образом указывать тип получаемых данных. Чтобы считать из потока integer, нам нужно использовать BinaryReader.ReadInt32. Этот метод считывает 32-битный integer, то есть четыре байта.

          using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
                        Debug.Log(reader.ReadInt32());
                }


Нужно учесть, что при получении 123 нам достаточно будет считать один байт. Но при этом в потоке останутся три байта, принадлежащие этому integer. Кроме того, это не сработает для чисел вне интервала 0–255. Поэтому не делайте так.

unitypackage

Запись и чтение данных карты


При сохранении данных важный вопрос заключается в том, нужно ли использовать человекочитаемый формат. Обычно в качестве человекочитаемых форматов используются JSON, XML и простой ASCII с какой-нибудь структурой. Такие файлы можно открывать, интерпретировать и редактировать в текстовых редакторах. Кроме того, они упрощают обмен данными между разными приложениями.

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

В противоположность им двоичные данные компактны и быстры. Это важно при записи больших объёмов данных. Например, при автосохранении большой карты в каждом ходе игры. Поэтому
мы будем использовать двоичный формат. Если вы с этим справитесь, то сможете работать и с более подробными форматами.

А как насчёт автоматической сериализации?

Сразу же в процессе сериализации данных Unity мы можем непосредственно записывать сериализованные классы в поток. Подробности записи отдельных полей будут скрыты от нас. Однако мы не сможем непосредственно сериализовать ячейки. Они являются классами MonoBehaviour, в которых есть данные, которые нам сохранять не нужно. Поэтому нам нужно использовать отдельную иерархию объектов, которая уничтожает простоту автоматической сериализации. Кроме того, так сложнее будет поддерживать будущие изменения кода. Поэтому мы будем придерживаться полного контроля с помощью сериализации вручную. К тому же она заставит нас по-настоящему разобраться в том, что происходит.


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

using UnityEngine;
using System.IO;

public class HexCell : MonoBehaviour {
        
        …
        
        public void Save (BinaryWriter writer) {
        }

        public void Load (BinaryReader reader) {
        }
}


Добавим методы Save и Load и в HexGrid. Эти методы просто обходят все ячейки, вызывая их методы Load и Save.

using UnityEngine;
using UnityEngine.UI;
using System.IO;

public class HexGrid : MonoBehaviour {

        …

        public void Save (BinaryWriter writer) {
                for (int i = 0; i < cells.Length; i++) {
                        cells[i].Save(writer);
                }
        }

        public void Load (BinaryReader reader) {
                for (int i = 0; i < cells.Length; i++) {
                        cells[i].Load(reader);
                }
        }
}


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

  public void Load (BinaryReader reader) {
                for (int i = 0; i < cells.Length; i++) {
                        cells[i].Load(reader);
                }
                for (int i = 0; i < chunks.Length; i++) {
                        chunks[i].Refresh();
                }
        }


Наконец заменим наш тестовый код в HexMapEditor на вызовы методов Save и Load сетки, передавая с ними writer или reader.

  public void Save () {
                string path = Path.Combine(Application.persistentDataPath, "test.map");
                using (
                        BinaryWriter writer =
                                new BinaryWriter(File.Open(path, FileMode.Create))
                ) {
                        hexGrid.Save(writer);
                }
        }

        public void Load () {
                string path = Path.Combine(Application.persistentDataPath, "test.map");
                using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
                        hexGrid.Load(reader);
                }
        }


Сохранение типа рельефа


На текущем этапе повторное сохранение создаёт пустой файл, а загрузка не делает ничего. Давайте начнём постепенно, с записи и загрузки только индекса типа рельефа HexCell.

Напрямую присвоим значение полю terrainTypeIndex. Мы не будем использовать свойства. Так как мы явным образом обновляем все фрагменты, вызовы Refresh свойств не нужны. Кроме того, так как мы сохраняем только правильные карты, то будем предполагать, что все загружаемые карты тоже верны. Поэтому, например, не будем проверять допустима ли река или дорога.

  public void Save (BinaryWriter writer) {
                writer.Write(terrainTypeIndex);
        }
        
        public void Load (BinaryReader reader) {
                terrainTypeIndex = reader.ReadInt32();
        }


При сохранении в этот файл один за другим будет записываться индекс типа рельефа всех ячеек. Так как индекс является integer, его размер равен четырём байтам. Моя карта содержит 300 ячеек, то есть размер файла будет составлять 1200 байт.

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

Сохранение всех Integer


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

  public void Save (BinaryWriter writer) {
                writer.Write(terrainTypeIndex);
                writer.Write(elevation);
                writer.Write(waterLevel);
                writer.Write(urbanLevel);
                writer.Write(farmLevel);
                writer.Write(plantLevel);
                writer.Write(specialIndex);
        }

        public void Load (BinaryReader reader) {
                terrainTypeIndex = reader.ReadInt32();
                elevation = reader.ReadInt32();
                waterLevel = reader.ReadInt32();
                urbanLevel = reader.ReadInt32();
                farmLevel = reader.ReadInt32();
                plantLevel = reader.ReadInt32();
                specialIndex = reader.ReadInt32();
        }


Попробуйте теперь сохранить и загрузить карту, внеся между этими операциями изменения. Всё, что мы включили в сохраняемые данные, восстановилось как можно мы можемнужно, за исключением высоты ячейки. Так получилось потому, что при изменении уровня высоты нужно обновлять и вертикальную позицию ячейки. Это можно сделать, присвоив её свойству, а не полю, значение загруженной высоты. Но это свойство выполняет дополнительную работу, которая нам не нужна. Поэтому давайте извлечём из сеттера Elevation код, обновляющий позицию ячейки и вставим его в отдельный метод RefreshPosition. Единственное изменение, которое здесь нужно внести — заменить value ссылкой на поле elevation.

  void RefreshPosition () {
                Vector3 position = transform.localPosition;
                position.y = elevation * HexMetrics.elevationStep;
                position.y +=
                        (HexMetrics.SampleNoise(position).y * 2f - 1f) *
                        HexMetrics.elevationPerturbStrength;
                transform.localPosition = position;

                Vector3 uiPosition = uiRect.localPosition;
                uiPosition.z = -position.y;
                uiRect.localPosition = uiPosition;
        }


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

  public int Elevation {
                …
                set {
                        if (elevation == value) {
                                return;
                        }
                        elevation = value;
                        RefreshPosition();
                        ValidateRivers();
                        
                        …
                }
        }
        
        …
        
        public void Load (BinaryReader reader) {
                terrainTypeIndex = reader.ReadInt32();
                elevation = reader.ReadInt32();
                RefreshPosition();
                …
        }


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

Сохранение всех данных


Наличие в ячейке стен и входящей/исходящей рек хранится в булевых полях. Мы можем записать их просто как integer. Кроме того, данные дорог — это массив из шести булевых значений, который мы можем записать с помощью цикла.

  public void Save (BinaryWriter writer) {
                writer.Write(terrainTypeIndex);
                writer.Write(elevation);
                writer.Write(waterLevel);
                writer.Write(urbanLevel);
                writer.Write(farmLevel);
                writer.Write(plantLevel);
                writer.Write(specialIndex);
                writer.Write(walled);

                writer.Write(hasIncomingRiver);
                writer.Write(hasOutgoingRiver);

                for (int i = 0; i < roads.Length; i++) {
                        writer.Write(roads[i]);
                }
        }


Направления входящих и исходящих рек хранятся в полях HexDirection. Тип HexDirection — это перечисление, которое внутри хранится как несколько значений integer. Поэтому мы можем сериализовать их тоже как integer с помощью явного преобразования.

          writer.Write(hasIncomingRiver);
                writer.Write((int)incomingRiver);

                writer.Write(hasOutgoingRiver);
                writer.Write((int)outgoingRiver);


Считывание булевых значений выполняется с помощью метода BinaryReader.ReadBoolean. Направления рек являются integer, которые мы должны преобразовать обратно в HexDirection.

  public void Load (BinaryReader reader) {
                terrainTypeIndex = reader.ReadInt32();
                elevation = reader.ReadInt32();
                RefreshPosition();
                waterLevel = reader.ReadInt32();
                urbanLevel = reader.ReadInt32();
                farmLevel = reader.ReadInt32();
                plantLevel = reader.ReadInt32();
                specialIndex = reader.ReadInt32();
                walled = reader.ReadBoolean();

                hasIncomingRiver = reader.ReadBoolean();
                incomingRiver = (HexDirection)reader.ReadInt32();

                hasOutgoingRiver = reader.ReadBoolean();
                outgoingRiver = (HexDirection)reader.ReadInt32();

                for (int i = 0; i < roads.Length; i++) {
                        roads[i] = reader.ReadBoolean();
                }
        }


Теперь мы сохраняем все данные ячеек, которые необходимы для полного сохранения и восстановления карты. Для этого требуется по девять integer и девять булевых значений на ячейку. Каждое булево значение занимает один байт, поэтому всего мы используем 45 байт на ячейку. То есть на карту с 300 ячейками требуется в целом 13 500 байт.

unitypackage

Уменьшаем размер файла


Хотя кажется, что 13 500 байт — это не очень много для 300 ячеек, возможно, мы можем обойтись меньшим объёмом. В конце концов, у нас есть полный контроль над тем, как сериализуются данные. Давайте посмотрим, возможно, найдётся более компактный способ их хранения.

Снижение числового интервала


Различные уровни и индексы ячеек хранятся как integer. Однако они используют только небольшой интервал значений. Каждый из них совершенно точно останется в интервале 0–255. Это означает, что будет использоваться только первый байт каждого integer. Остальные три всегда будут равны нулю. Нет никакого смысла хранить эти пустые байты. Мы можем отбросить их, перед записью в поток преобразовав integer в byte.

          writer.Write((byte)terrainTypeIndex);
                writer.Write((byte)elevation);
                writer.Write((byte)waterLevel);
                writer.Write((byte)urbanLevel);
                writer.Write((byte)farmLevel);
                writer.Write((byte)plantLevel);
                writer.Write((byte)specialIndex);
                writer.Write(walled);

                writer.Write(hasIncomingRiver);
                writer.Write((byte)incomingRiver);

                writer.Write(hasOutgoingRiver);
                writer.Write((byte)outgoingRiver);


Теперь чтобы вернуть эти числа, нам придётся использовать BinaryReader.ReadByte. Преобразование из byte в integer выполняется неявно, поэтому нам не нужно добавлять явные преобразования.

          terrainTypeIndex = reader.ReadByte();
                elevation = reader.ReadByte();
                RefreshPosition();
                waterLevel = reader.ReadByte();
                urbanLevel = reader.ReadByte();
                farmLevel = reader.ReadByte();
                plantLevel = reader.ReadByte();
                specialIndex = reader.ReadByte();
                walled = reader.ReadBoolean();

                hasIncomingRiver = reader.ReadBoolean();
                incomingRiver = (HexDirection)reader.ReadByte();

                hasOutgoingRiver = reader.ReadBoolean();
                outgoingRiver = (HexDirection)reader.ReadByte();


Так мы избавляемся от трёх байт на integer, что даёт экономию в 27 байт на ячейку. Теперь мы тратим 18 байт на ячейку, и всего 5 400 байт на 300 ячеек.

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

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

Объединение байтов рек


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

Кажется очевидным, что нам не нужно хранить направление реки, если её нет. Это значит, что ячейкам без реки нужно на два байта меньше. На самом деле, нам будет достаточно по одному байту на направление реки, вне зависимости от её существования.

У нас есть шесть возможных направлений, которые сохраняются как числа в интервале 0–5. Для этого достаточно трёх бит, потому что в двоичной форме числа от 0 до 5 выглядят как 000, 001, 010, 011, 100, 101 и 110. То есть в одном байте остаются неиспользованными ещё пять бит. Мы можем использовать один из них для обозначения того, существует ли река. Например, можно использовать восьмой бит, соответствующий числу 128.

Для этого будем перед преобразованием направления в байт прибавлять к нему 128. То есть если у нас есть река, текущая на северо-запад, то мы запишем 133, что в двоичной форме равно 10000101. А если реки нет, то мы просто запишем нулевой байт.

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

//                writer.Write(hasIncomingRiver);
//              writer.Write((byte)incomingRiver);
                if (hasIncomingRiver) {
                        writer.Write((byte)(incomingRiver + 128));
                }
                else {
                        writer.Write((byte)0);
                }

//              writer.Write(hasOutgoingRiver);
//              writer.Write((byte)outgoingRiver);
                if (hasOutgoingRiver) {
                        writer.Write((byte)(outgoingRiver + 128));
                }
                else {
                        writer.Write((byte)0);
                }


Чтобы декодировать данные реки, нам сначала нужно считать байт обратно. Если его значение не меньше 128, то это означает, что река есть. Чтобы получить её направление, вычтем 128, а затем преобразуем в HexDirection.

//                hasIncomingRiver = reader.ReadBoolean();
//              incomingRiver = (HexDirection)reader.ReadByte();
                byte riverData = reader.ReadByte();
                if (riverData >= 128) {
                        hasIncomingRiver = true;
                        incomingRiver = (HexDirection)(riverData - 128);
                }
                else {
                        hasIncomingRiver = false;
                }

//              hasOutgoingRiver = reader.ReadBoolean();
//              outgoingRiver = (HexDirection)reader.ReadByte();
                riverData = reader.ReadByte();
                if (riverData >= 128) {
                        hasOutgoingRiver = true;
                        outgoingRiver = (HexDirection)(riverData - 128);
                }
                else {
                        hasOutgoingRiver = false;
                }


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

Сохранение дорог в одном байте


Мы можем использовать похожий трюк для сжатия данных дорог. У нас есть шесть булевых значений, которые можно сохранить в первых шести битах байта. То есть каждое направление дороги представлено числом, являющимся степенью двойки. Это 1, 2, 4, 8, 16 и 32, или в двоичном виде 1, 10, 100, 1000, 10000 и 100000.

Чтобы создать готовый байт, нам нужно задать биты, соответствующие используемым направлениям дорог. Для получения правильного направления для направления мы можем использовать оператор <<. Затем объединить их с помощью оператора побитового ИЛИ. Например, если используются первая, вторая, третья и шестая дороги, то готовый байт будет равен 100111.

          int roadFlags = 0;
                for (int i = 0; i < roads.Length; i++) {
//                      writer.Write(roads[i]);
                        if (roads[i]) {
                                roadFlags |= 1 << i;
                        }
                }
                writer.Write((byte)roadFlags);


Как работает <

Это оператор побитового сдвига влево. Он берёт integer слева и сдвигает всего биты влево. Переполнение отбрасывается. Количество шагов сдвига определяется integer справа. Так как числа двоичные, сдвиг всех битов на один шаг влево удваивает значение числа. То есть 1 << n даёт 2n, что нам и нужно.


Чтобы получить булево значение дороги обратно, надо проверить, задан ли бит. Если это так, то маскируем все другие биты с помощью оператора побитового И с соответствующим числом. Если результат не равен нулю, то бит задан и дорога существует.

          int roadFlags = reader.ReadByte();
                for (int i = 0; i < roads.Length; i++) {
                        roads[i] = (roadFlags & (1 << i)) != 0;
                }


Сжав шесть байт в один, мы получили 11 байт на ячейку. При 300 ячейках это всего 3 300 байт. То есть немного поработав с байтами, мы снизили размер файла на 75%.

Готовимся к будущему


Прежде чем объявить наш формат сохранения завершённым, добавим ещё одну деталь. Перед сохранением данных карты заставим HexMapEditor записывать целочисленный ноль.

  public void Save () {
                string path = Path.Combine(Application.persistentDataPath, "test.map");
                using (
                        BinaryWriter writer =
                                new BinaryWriter(File.Open(path, FileMode.Create))
                ) {
                        writer.Write(0);
                        hexGrid.Save(writer);
                }
        }


Это добавит к началу наших данных четыре пустых байта. То есть перед загрузкой карты нам придётся считывать эти четыре байта.

  public void Load () {
                string path = Path.Combine(Application.persistentDataPath, "test.map");
                using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
                        reader.ReadInt32();
                        hexGrid.Load(reader);
                }
        }


Хотя пока эти байты бесполезны, они используются в качестве заголовка, который обеспечит в будущем обратную совместимость. Если бы мы не добавили эти нулевые байты, то содержимое первых нескольких байтов зависело от первой ячейки карты. Поэтому в будущем нам было бы труднее разобраться, с какой версией формата сохранения мы имеем дело. Теперь мы можем просто проверять первые четыре байта. Если они пустые, то мы имеем дело с версией формата 0. В будущих версиях можно будет добавить туда что-то ещё.

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

          using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
                        int header = reader.ReadInt32();
                        if (header == 0) {
                                hexGrid.Load(reader);
                        }
                        else {
                                Debug.LogWarning("Unknown map format " + header);
                        }
                }

unitypackage


  • Создаём новые карты в режиме Play.
  • Добавляем поддержку различных размеров карт.
  • Добавляем размер карты в сохраняемые данные.
  • Сохраняем и загружаем произвольные карты.
  • Отображаем список карт.


В этой части мы добавим поддержку различных размеров карт, а также сохранение разных файлов.

Начиная с этой части туториалы будут создаваться в Unity 5.5.0.

abd9e21e467d0b291c33f4ad31e96c51.jpg


Начало библиотеки карт.

Создание новых карт


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

В Awake HexGrid инициализируются некоторые метрики, а затем определяется количество ячеек и создаются необходимые фрагменты и ячейки. Создавая новый набор фрагментов и ячеек, мы создаём новую карту. Давайте разделим HexGrid.Awake на две части — исходный код инициализации и общий метод CreateMap.

  void Awake () {
                HexMetrics.noiseSource = noiseSource;
                HexMetrics.InitializeHashGrid(seed);
                HexMetrics.colors = colors;
                CreateMap();
        }

        public void CreateMap () {
                cellCountX = chunkCountX * HexMetrics.chunkSizeX;
                cellCountZ = chunkCountZ * HexMetrics.chunkSizeZ;
                CreateChunks();
                CreateCells();
        }


Добавим в UI кнопку для создания новой карты. Я сделал её большой и разместил под кнопками сохранения и загрузки.

203c02ff4ca668836064c72ad770328c.png


Кнопка New Map.

Соединим событие On Click этой кнопки с методом CreateMap нашего объекта HexGrid. То есть мы будем не проходить через Hex Map Editor, а непосредственно вызывать метод объекта Hex Grid.

36ad04a3ebf94b00faf7aebe8c783b2f.png


Создание карты по нажатию.

Очистка старых данных


Теперь при нажатии на кнопку New Map будет создаваться новый набор фрагментов и ячеек. Однако старые не удаляются автоматически. Поэтому в результате у нас получится несколько наложенных друг на друга мешей карты. Чтобы избежать этого, нам сначала нужно избавиться от старых объектов. Это можно сделать, уничтожая все текущие фрагменты в начале CreateMap.

  public void CreateMap () {
                if (chunks != null) {
                        for (int i = 0; i < chunks.Length; i++) {
                                Destroy(chunks[i].gameObject);
                        }
                }

           
    
            

© Habrahabr.ru