Pull to refresh

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

Reading time73 min
Views12K
Original author: Jasper Flick
Части 1-3: сетка, цвета и высоты ячеек

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

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

Части 12-15: сохранение и загрузка, текстуры, расстояния

Части 16-19: поиск пути, отряды игрока, анимации

Части 20-23: туман войны, исследование карты, процедурная генерация

Части 24-27: круговорот воды, эрозия, биомы, цилиндрическая карта

Часть 12: сохранение и загрузка


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

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


Загружено из файла 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<HexCell>(cellPrefab);
		cell.transform.localPosition = position;
		cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z);
//		cell.Color = defaultColor;

		…
	}

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


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

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


Уберём из 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;
			}
			…
		}
	}

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


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

unitypackage

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


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

	public void Save () {
	}

	public void Load () {
	}

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


Кнопки Save и Load.

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


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

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

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

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


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

Почему я не могу найти папку 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

Часть 13: управление картами


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

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

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


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

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


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

В 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 кнопку для создания новой карты. Я сделал её большой и разместил под кнопками сохранения и загрузки.


Кнопка New Map.

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


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

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


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

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

		…
	}

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

Можно ли уничтожать так в цикле дочерние элементы?
Конечно. Само уничтожение откладывается до завершения фазы обновления текущего кадра.

Указываем размер в ячейках вместо фрагментов


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

//	public int chunkCountX = 4, chunkCountZ = 3;
	public int cellCountX = 20, cellCountZ = 15;

	…

//	int cellCountX, cellCountZ;
	int chunkCountX, chunkCountZ;

	…
	
	public void CreateMap () {
		…

//		cellCountX = chunkCountX * HexMetrics.chunkSizeX;
//		cellCountZ = chunkCountZ * HexMetrics.chunkSizeZ;
		chunkCountX = cellCountX / HexMetrics.chunkSizeX;
		chunkCountZ = cellCountZ / HexMetrics.chunkSizeZ;
		CreateChunks();
		CreateCells();
	}

Это приведёт к ошибке компиляции, потому что для ограничения своей позиции HexMapCamera использует размеры фрагментов. Изменим HexMapCamera.ClampPosition так, чтобы он использовал непосредственно количество ячеек, которое всё равно ему нужно.

	Vector3 ClampPosition (Vector3 position) {
		float xMax = (grid.cellCountX - 0.5f) * (2f * HexMetrics.innerRadius);
		position.x = Mathf.Clamp(position.x, 0f, xMax);

		float zMax = (grid.cellCountZ - 1) * (1.5f * HexMetrics.outerRadius);
		position.z = Mathf.Clamp(position.z, 0f, zMax);

		return position;
	}

Фрагмент имеет размер 5 на 5 ячеек, а карты по умолчанию имеют размер 4 на 3 фрагмента. Поэтому чтобы карты оставались такими же, нам придётся использовать размер 20 на 15 ячеек. И хотя мы назначили значения по умолчанию в коде, объект сетки всё равно не будет их использовать автоматически, потому что поля уже существовали и по умолчанию имели значение 0.


По умолчанию карта имеет размер 20 на 15.

Произвольные размеры карт


Следующим шагом будет поддержка создания карт любого размера, а не только размера по умолчанию. Для этого нужно добавить в HexGrid.CreateMap параметры X и Z. Они заменят существующие количества ячеек. Внутри Awake мы просто будем вызывать их с текущими количествами ячеек.

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

	public void CreateMap (int x, int z) {
		…

		cellCountX = x;
		cellCountZ = z;
		chunkCountX = cellCountX / HexMetrics.chunkSizeX;
		chunkCountZ = cellCountZ / HexMetrics.chunkSizeZ;
		CreateChunks();
		CreateCells();
	}

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

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

	public void CreateMap (int x, int z) {
		if (
			x <= 0 || x % HexMetrics.chunkSizeX != 0 ||
			z <= 0 || z % HexMetrics.chunkSizeZ != 0
		) {
			Debug.LogError("Unsupported map size.");
			return;
		}
		
		…
	}

Меню новых карт


На текущем этапе кнопка New Map больше не работает, потому что метод HexGrid.CreateMap теперь имеет два параметра. Мы не можем напрямую соединять события Unity с такими методами. Кроме того, для поддержки разных размеров карт нам понадобится несколько кнопок. Вместо того, чтобы добавлять все эти кнопки в основной UI, давайте создадим отдельное всплывающее меню.

Добавим в сцену новый canvas (GameObject / UI / Canvas). Воспользуемся теми же настройками, что и у существующего canvas, за исключением того, что его Sort Order должен быть равен 1. Благодаря этому он окажется поверх UI основного редактора. Я сделал оба canvas и систему событий дочерними элементами нового объекта UI, чтобы иерархия сцены оставалась чистой.



Canvas меню New Map.

Добавим к New Map Menu панель, закрывающую весь экран. Она нужна, чтобы затемнить фон и не позволить курсору взаимодействовать со всем остальным, когда меню открыто. Я придал ей однородный цвет, очистив её Source Image, а в качестве Color задал (0, 0, 0, 200).


Настройки фонового изображения.

Добавим в центр canvas панель меню, аналогично панелям Hex Map Editor. Создадим ей понятную метку и кнопки для маленькой, средней и большой карт. Также добавим ей кнопку отмены на случай, если игрок передумает. Закончив создание дизайна, деактивируем весь New Map Menu.



Меню New Map.

Для управления меню создадим компонент NewMapMenu и добавим его к объекту canvas New Map Menu. Для создания новой карты нам нужен доступ к объекту Hex Grid. Поэтому добавим ему общее поле и подключим его.

using UnityEngine;

public class NewMapMenu : MonoBehaviour {

	public HexGrid hexGrid;
}


Компонент New Map Menu.

Открытие и закрытие


Мы можем открывать и закрывать всплывающее меню, просто активируя и деактивируя объект canvas. Давайте добавим в NewMapMenu два общих метода, которые этим займутся.

	public void Open () {
		gameObject.SetActive(true);
	}

	public void Close () {
		gameObject.SetActive(false);
	}

Теперь соединим кнопку New Map UI редактора к методу Open в объекте New Map Menu.


Открытие меню по нажатию.

Также соединим кнопку Cancel с методом Close. Это позволит нам открывать и закрывать всплывающее меню.

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


Для создания новых карт нам нужно вызывать в объекте Hex Grid метод CreateMap. Кроме того, после этого нам нужно закрывать всплывающее меню. Добавим к NewMapMenu метод, который займётся этим с учётом произвольного размера.

	void CreateMap (int x, int z) {
		hexGrid.CreateMap(x, z);
		Close();
	}

Этот метод не должен быть общим, потому что мы всё равно не можем соединить его напрямую с событиями кнопок. Вместо этого создадим по одному методу на кнопку, которые будут вызывать CreateMap с указанным размером. Для маленькой карты я использовал размер 20 на 15, соответствующий размеру карты по умолчанию. Для средней карты я решил удвоить этот размер, получив 40 на 30, и снова удвоить его для большой карты. Соединим кнопки с соответствующими методами.

	public void CreateSmallMap () {
		CreateMap(20, 15);
	}

	public void CreateMediumMap () {
		CreateMap(40, 30);
	}

	public void CreateLargeMap () {
		CreateMap(80, 60);
	}

Блокировка камеры


Теперь мы можем использовать всплывающее меню для создания новых карт с тремя разными размерами! Всё работает хорошо, но нам нужно позаботиться о небольшой детали. Когда New Map Menu активно, мы больше не можем взаимодействовать с UI редактора и редактировать ячейки. Однако мы по-прежнему можем управлять камерой. В идеале при открытом меню камера должна блокироваться.

Так как камера у нас только одна, быстрым и прагматичным решением будет просто добавить к ней статическое свойство Locked. Для широкого применения это решение не очень подходит, но для нашего простого интерфейса его достаточно. При этом требуется, чтобы мы отслеживали статический экземпляр внутри HexMapCamera, который задаётся при Awake камеры.

	static HexMapCamera instance;
	
	…
	
	void Awake () {
		instance = this;
		swivel = transform.GetChild(0);
		stick = swivel.GetChild(0);
	}

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

	public static bool Locked {
		set {
			instance.enabled = !value;
		}
	}

Теперь NewMapMenu.Open может блокировать камеру, а NewMapMenu.Close — разблокировать её.

	public void Open () {
		gameObject.SetActive(true);
		HexMapCamera.Locked = true;
	}

	public void Close () {
		gameObject.SetActive(false);
		HexMapCamera.Locked = false;
	}

Сохранение правильной позиции камеры


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

Чтобы решить эту проблему, мы можем добавить в HexMapCamera статический метод ValidatePosition. Вызов метода AdjustPosition экземпляра с нулевым смещением принудительно переместит камеру в границы карты. Если камера уже находится внутри границ новой карты, то она останется на месте.

	public static void ValidatePosition () {
		instance.AdjustPosition(0f, 0f);
	}

Вызовем метод внутри NewMapMenu.CreateMap после создания новой карты.

	void CreateMap (int x, int z) {
		hexGrid.CreateMap(x, z);
		HexMapCamera.ValidatePosition();
		Close();
	}

unitypackage

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


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

Для решения этой проблемы перед загрузкой данных ячеек нам нужно создать новую карту соответствующего размера. Давайте предположим, что у нас сохранена маленькая карта. В таком случае всё будет нормально, если мы создадим в начале HexGrid.Load карту размером 20 на 15.

	public void Load (BinaryReader reader) {
		CreateMap(20, 15);

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

Хранение размера карты


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

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

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

	public void Load (BinaryReader reader) {
		CreateMap(reader.ReadInt32(), reader.ReadInt32());

		…
	}

Так как теперь мы можем загружать карты разного размера, то снова сталкиваемся с проблемой позиции камеры. Решим её, проверяя её позицию в HexMapEditor.Load после загрузки карты.

	public void Load () {
		string path = Path.Combine(Application.persistentDataPath, "test.map");
		using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
			int header = reader.ReadInt32();
			if (header == 0) {
				hexGrid.Load(reader, header);
				HexMapCamera.ValidatePosition();
			}
			else {
				Debug.LogWarning("Unknown map format " + header);
			}
		}
	}

Новый формат файлов


Хотя такой подход работает с картами, которые мы будем сохранять в дальнейшем, со старыми он работать не будет. И наоборот — код из предыдущей части туториала не сможет правильно загружать новые файлы карт. Чтобы различать старые и новые форматы, мы увеличим значение integer заголовка. Старый формат сохранения без размера карты имел версию 0. Новый формат с размером карты будет иметь версию 1. Поэтому при записи HexMapEditor.Save должен вместо 0 записывать 1.

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

С текущего момента карты будут сохраняться как версия 1. Если мы попытаемся открыть их в сборке из предыдущего туториала, они откажутся загружаться и сообщат о неизвестном формате карт. На самом деле, так произойдёт, если мы уже попытаемся загрузить такую карту. Нужно изменить метод HexMapEditor.Load так, чтобы он принимал новую версию.

	public void Load () {
		string path = Path.Combine(Application.persistentDataPath, "test.map");
		using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
			int header = reader.ReadInt32();
			if (header == 1) {
				hexGrid.Load(reader);
				HexMapCamera.ValidatePosition();
			}
			else {
				Debug.LogWarning("Unknown map format " + header);
			}
		}
	}

Обратная совместимость


На самом деле, если захотим, мы по-прежнему можем загружать карты версии 0, предполагая, что все они имеют одинаковый размер 20 на 15. То есть заголовок необязательно должен быть равен 1, он может быть и нулём. Так как каждая версия требует своего подхода, HexMapEditor.Load должен передавать заголовок методу HexGrid.Load.

			if (header <= 1) {
				hexGrid.Load(reader, header);
				HexMapCamera.ValidatePosition();
			}

Добавим в HexGrid.Load параметр заголовка и используем его для принятия решений о дальнейших действиях. Если заголовок не меньше 1, то считывать данные размера карты нужно. В противном случае, используем старый фиксированный размер карты 20 на 15 и пропускаем считывание данных размера.

	public void Load (BinaryReader reader, int header) {
		int x = 20, z = 15;
		if (header >= 1) {
			x = reader.ReadInt32();
			z = reader.ReadInt32();
		}
		CreateMap(x, z);

		…
	}

файл карты версии 0

Проверка размера карты


Как и при создании новой карты, теоретически возможно, что нам придётся загружать карту, несовместимую с размером фрагмента. Когда это происходит, мы должны прервать загрузку карты. HexGrid.CreateMap уже отказывается создавать карту и выводит в консоль ошибку. Чтобы сообщить это тому, кто вызывает метод, давайте возвращать bool, сообщающий о том, была ли создана карта.

	public bool CreateMap (int x, int z) {
		if (
			x <= 0 || x % HexMetrics.chunkSizeX != 0 ||
			z <= 0 || z % HexMetrics.chunkSizeZ != 0
		) {
			Debug.LogError("Unsupported map size.");
			return false;
		}

		…
		return true;
	}

Теперь HexGrid.Load тоже может прекращать выполнение при сбое создания карты.

	public void Load (BinaryReader reader, int header) {
		int x = 20, z = 15;
		if (header >= 1) {
			x = reader.ReadInt32();
			z = reader.ReadInt32();
		}
		if (!CreateMap(x, z)) {
			return;
		}

		…
	}

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

		if (x != cellCountX || z != cellCountZ) {
			if (!CreateMap(x, z)) {
				return;
			}
		}

unitypackage

Управление файлами


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

Вместо непосредственного сохранения или загрузки карты мы используем ещё одно всплывающее меню, обеспечивающее расширенное управление файлами. Создадим ещё один canvas, как в New Map Menu, но на этот раз назовём его Save Load Menu. Это меню займётся сохранением и загрузкой карт, в зависимости от кнопки, нажатой для его открытия.

Мы создадим дизайн Save Load Menu таким, как будто это меню сохранения. Позже мы динамически будем превращать его в меню загрузки. Как и у другого меню, у него должен быть фон и панель меню, метка меню и кнопка отмены. Затем добавим к меню scroll view (GameObject / UI / Scroll View) для отображения списка файлов. Ниже вставим поле ввода (GameObject / UI / Input Field) для указания имён новых карт. Также нам понадобится кнопка действия для сохранения карты. И, наконец. добавим кнопку Delete для удаления ненужных карт.



Дизайн Save Load Menu.

По умолчанию scroll view позволяет выполнять и горизонтальную, и вертикальную прокрутку, но нам нужен список только с вертикальной прокруткой. Поэтому отключим прокрутку Horizontal и отсоединим горизонтальную полосу прокрутки. Также зададим для Movement Type значение clamped и отключим Inertia, чтобы список казался более строгим.


Параметры File List.

Удалим дочерний элемент Scrollbar Horizontal из объекта File List, потому что он нам не понадобится. Затем изменим размер Scrollbar Vertical так, чтобы он достигал низа списка.

Текст-заполнитель объекта Name Input можно изменить в его дочернем элементе Placeholder. Я использовал описательный текст, но можно просто оставить его пустым и избавиться от заполнителя.


Изменённый дизайн меню.

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

Управление меню


Чтобы меню заработало, нам нужен другой скрипт, в данном случае — SaveLoadMenu. Как и NewMapMenu, ему требуется ссылка на сетку, а также методы Open и Close.

using UnityEngine;

public class SaveLoadMenu : MonoBehaviour {

	public HexGrid hexGrid;

	public void Open () {
		gameObject.SetActive(true);
		HexMapCamera.Locked = true;
	}

	public void Close () {
		gameObject.SetActive(false);
		HexMapCamera.Locked = false;
	}
}

Добавим этот компонент к SaveLoadMenu и дадим ему ссылку на объект сетки.


Компонент SaveLoadMenu.

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

	bool saveMode;

	public void Open (bool saveMode) {
		this.saveMode = saveMode;
		gameObject.SetActive(true);
		HexMapCamera.Locked = true;
	}

Теперь соединим кнопки Save и Load объекта Hex Map Editor с методом Open объекта Save Load Menu. Проверим булев параметр только для кнопки Save.


Открытие меню в режиме сохранения.

Если вы этого ещё не сделали, соедините событие кнопки Cancel с методом Close. Теперь Save Load Menu может открываться и закрываться.

Изменение внешнего вида


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

using UnityEngine;
using UnityEngine.UI;

public class SaveLoadMenu : MonoBehaviour {

	public Text menuLabel, actionButtonLabel;
	
	…
}


Соединение с метками.

Когда меню открывается в режиме сохранения, мы используем существующие метки, то есть Save Map для меню и Save для кнопки действия. В противном случае мы находимся в режиме загрузки, то есть используем Load Map и Load.

	public void Open (bool saveMode) {
		this.saveMode = saveMode;
		if (saveMode) {
			menuLabel.text = "Save Map";
			actionButtonLabel.text = "Save";
		}
		else {
			menuLabel.text = "Load Map";
			actionButtonLabel.text = "Load";
		}
		gameObject.SetActive(true);
		HexMapCamera.Locked = true;
	}

Ввод имени карты


Оставим пока список файлов. Пользователь может указывать сохраняемый или загружаемый файл, вводя имя карты в поле ввода. Для получения этих данных нам нужна ссылка на компонент InputField объекта Name Input.

	public InputField nameInput;


Соединение с полем ввода.

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

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

public class SaveLoadMenu : MonoBehaviour {

	…

	string GetSelectedPath () {
		string mapName = nameInput.text;
		if (mapName.Length == 0) {
			return null;
		}
		return Path.Combine(Application.persistentDataPath, mapName + ".map");
	}
}

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

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

Сохранение и загрузка


Теперь заниматься сохранением и загрузкой будет SaveLoadMenu. Поэтому переместим методы Save и Load из HexMapEditor в SaveLoadMenu. Больше они не обязаны быть общими, и будут работать с параметром пути вместо фиксированного пути.

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

	void Load (string path) {
//		string path = Path.Combine(Application.persistentDataPath, "test.map");
		using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
			int header = reader.ReadInt32();
			if (header <= 1) {
				hexGrid.Load(reader, header);
				HexMapCamera.ValidatePosition();
			}
			else {
				Debug.LogWarning("Unknown map format " + header);
			}
		}
	}

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

	void Load (string path) {
		if (!File.Exists(path)) {
			Debug.LogError("File does not exist " + path);
			return;
		}
		…
	}

Теперь добавим общий метод Action. Он начинается с получения выбранного пользователем пути. Если путь есть, то выполняем по нему сохранение или загрузку. Затем закрываем меню.

	public void Action () {
		string path = GetSelectedPath();
		if (path == null) {
			return;
		}
		if (saveMode) {
			Save(path);
		}
		else {
			Load(path);
		}
		Close();
	}

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

Пункты списка карт


Далее мы заполним список файлов всеми картами, которые находятся по пути хранения данных. При нажатии на один из пунктов списка он будет использоваться как текст в Name Input. Добавим в SaveLoadMenu для этого общий метод.

	public void SelectItem (string name) {
		nameInput.text = name;
	}

Нам нужно что-то, представляющее собой пункт списка. Подойдёт обычная кнопка. Создадим её и уменьшим высоту до 20 единиц, чтобы она не занимала много места по вертикали. Она не должна выглядеть как кнопка, поэтому очистим ссылку Source Image её компонента Image. При этом она станет полностью белой. Кроме того, сделаем так, чтобы метка выравнивалась по левому краю и чтобы между текстом и левой частью кнопки было пространство. Закончив с дизайном кнопки, превратим её в префаб.



Кнопка-пункт списка.

Мы не можем напрямую подключить событие кнопки к New Map Menu, потому что это префаб и в сцене он пока не существует. Поэтому пункту меню нужна ссылка на меню, чтобы он мог при нажатии вызывать метод SelectItem. Также ему нужно отслеживать представляемое им имя карты, и задавать его текст. Создадим для этого небольшой компонент SaveLoadItem.

using UnityEngine;
using UnityEngine.UI;

public class SaveLoadItem : MonoBehaviour {

	public SaveLoadMenu menu;
	
	public string MapName {
		get {
			return mapName;
		}
		set {
			mapName = value;
			transform.GetChild(0).GetComponent<Text>().text = value;
		}
	}
	
	string mapName;
	
	public void Select () {
		menu.SelectItem(mapName);
	}
}

Добавим компонент к пункту меню и сделаем так, чтобы кнопка вызывала его метод Select.


Компонент пункта.

Заполнение списка


Чтобы заполнить список, SaveLoadMenu нужна ссылка на Content внутри Viewport объекта File List. Также ему нужна ссылка на префаб пункта.

	public RectTransform listContent;
	
	public SaveLoadItem itemPrefab;


Соединение содержимого списка и префаба.

Для заполнения этого списка мы используем новый метод. Первым этапом будет определение существующих файлов карт. Для получения массива всех путей к файлам внутри каталога мы можем воспользоваться методом Directory.GetFiles. У этого метода есть второй параметр, позволяющий отфильтровывать файлы. В нашем случае требуются только файлы, соответствующие маске *.map.

	void FillList () {
		string[] paths =
			Directory.GetFiles(Application.persistentDataPath, "*.map");
	}

К сожалению, порядок файлов не гарантирован. Для отображения их в алфавитном порядке нам нужно отсортировать массив с помощью System.Array.Sort.

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

public class SaveLoadMenu : MonoBehaviour {

	…

	void FillList () {
		string[] paths =
			Directory.GetFiles(Application.persistentDataPath, "*.map");
		Array.Sort(paths);
	}

	…
}

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

		Array.Sort(paths);
		for (int i = 0; i < paths.Length; i++) {
			SaveLoadItem item = Instantiate(itemPrefab);
			item.menu = this;
			item.MapName = paths[i];
			item.transform.SetParent(listContent, false);
		}

Так как Directory.GetFiles возвращает полные пути к файлам, нам нужно их очистить. К счастью, именно это делает удобный метод Path.GetFileNameWithoutExtension.

			item.MapName = Path.GetFileNameWithoutExtension(paths[i]);

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

	public void Open (bool saveMode) {
		…
		FillList();
		gameObject.SetActive(true);
		HexMapCamera.Locked = true;
	}

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

	void FillList () {
		for (int i = 0; i < listContent.childCount; i++) {
			Destroy(listContent.GetChild(i).gameObject);
		}
		…
	}


Пункты без расстановки.

Расстановка пунктов


Теперь в списке будут отображаться пункты, но они будут накладываться друг на друга и находиться в плохой позиции. Чтобы они превратились в вертикальный список, добавим к объекту Content списка компонент Vertical Layout Group (Component / Layout / Vertical Layout Group).

Чтобы расстановка работала правильно, включим Width of both Child Control Size и Child Force Expand. Обе опции Height должны быть отключены.



Использование vertical layout group.

У нас получился красивый список пунктов. Однако размер содержимого списка не подстраивается по истинное количество пунктов. Поэтому полоса прокрутки никогда не меняет размера. Мы можем заставить Content автоматически изменять размер, добавив к нему компонент Content Size Fitter (Component / Layout / Content Size Fitter). Его режим Vertical Fit должен иметь значение Preferred Size.



Использование content size fitter.

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


Полоса прокрутки появляется.

Удаление карт


Теперь мы можем удобно работать с множеством файлов карт. Однако иногда бывает необходимо избавиться от некоторых карт. Для этого можно использовать кнопку Delete. Создадим для этого метод и заставим кнопку его вызывать. Если есть выбранный путь, просто удаляем его с помощью File.Delete.

	public void Delete () {
		string path = GetSelectedPath();
		if (path == null) {
			return;
		}
		File.Delete(path);
	}

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

		if (File.Exists(path)) {
			File.Delete(path);
		}

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

		if (File.Exists(path)) {
			File.Delete(path);
		}
		nameInput.text = "";
		FillList();

unitypackage

Часть 14: текстуры рельефа


  • Используем цвета вершин для создания splat map.
  • Создание ассета массива текстур.
  • Добавление индексов рельефа к мешам.
  • Переходы между текстурами рельефа.

До этого момента мы использовали для раскрашивания карт сплошные цвета. Теперь мы будем применять текстуры.


Рисование текстурами.

Смешение трёх типов


Хотя однородные цвета чётко различимы и вполне справляются с задачей, они не очень интересно выглядят. Использование текстур значительно увеличит привлекательность карт. Разумеется, для этого нам придётся смешивать текстуры, а не просто цвета. В туториале Rendering 3, Combining Textures я рассказывал о том, как смешивать несколько текстур с помощью splat map. В наших картах шестиугольников можно использовать похожий подход.

В туториале Rendering 3 смешивается всего четыре текстуры, а с помощью одной splat map мы можем поддерживать до пяти текстур. В настоящий момент мы используем пять разных цветов, так что это нам вполне подойдёт. Однако позже мы можем добавить другие типы. Поэтому нужна поддержка произвольного количества типов рельефа. При использовании явно заданных свойств текстур это невозможно, поэтому придётся применять массив текстур. Позже мы его создадим.

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

Использование цветов вершин в качестве Splat Maps


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


Треугольник Splat map.

Всегда ли сумма треугольника splat map равна единице?
Да. Три цветовых канала определяют трилинейную интерполяцию по поверхности треугольника. Они используются как барицентрические координаты. Например, у нас может быть три возможные перестановки (1, 0, 0) в углах, варианты (½, ½, 0) в серединах рёбер и (&frac13;, &frac13;, &frac13;) в центре.

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


Три конфигурации splat map.

Мы будем использовать эти конфигурации splat map вне зависимости от того, какие текстуры смешиваются на самом деле. То есть splat map всегда будет одинаковой. Меняться будут только текстуры. Как это сделать, мы выясним позже.

Нам нужно изменить HexGridChunk, чтобы он создавал эти splat maps, а не использовал цвета ячеек. Так как мы часто будем использовать три цвета, создадим для них статические поля.

	static Color color1 = new Color(1f, 0f, 0f);
	static Color color2 = new Color(0f, 1f, 0f);
	static Color color3 = new Color(0f, 0f, 1f);

Центры ячеек


Начнём с замены цвета центров ячеек по умолчанию. Здесь не выполняется смешение, поэтому мы просто используем первый цвет, то есть красный.

	void TriangulateWithoutRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		TriangulateEdgeFan(center, e, color1);

		…
	}


Красные центры ячеек.

Центры ячеек теперь стали красными. Все они используют первую из трёх текстур вне зависимости от того, какая это текстура. Их splat maps одинаковы, вне зависимости от цвета, которым мы раскрашиваем ячейки.

Соседство с реками


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

	void TriangulateAdjacentToRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…

		TriangulateEdgeStrip(m, color1, e, color1);
		TriangulateEdgeFan(center, m, color1);

		…
	}


Красные сегменты по соседству с реками.

Реки


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

	void TriangulateWithRiverBeginOrEnd (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…

		TriangulateEdgeStrip(m, color1, e, color1);
		TriangulateEdgeFan(center, m, color1);

		…
	}

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

	void TriangulateWithRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…

		TriangulateEdgeStrip(m, color1, e, color1);

		terrain.AddTriangle(centerL, m.v1, m.v2);
//		terrain.AddTriangleColor(cell.Color);
		terrain.AddQuad(centerL, center, m.v2, m.v3);
//		terrain.AddQuadColor(cell.Color);
		terrain.AddQuad(center, centerR, m.v3, m.v4);
//		terrain.AddQuadColor(cell.Color);
		terrain.AddTriangle(centerR, m.v4, m.v5);
//		terrain.AddTriangleColor(cell.Color);
		
		terrain.AddTriangleColor(color1);
		terrain.AddQuadColor(color1);
		terrain.AddQuadColor(color1);
		terrain.AddTriangleColor(color1);

		…
	}


Красные реки вдоль ячеек.

Рёбра


Все рёбра разные, потому что они находятся между ячейками, которые могут иметь разные типы рельефа. Мы используем для типа текущей ячейки первый цвет, а для типа соседа — второй цвет. В результате splat map станет красно-зелёным градиентом, даже если обе ячейки имеют одинаковый тип. Если обе ячейки используют одинаковую текстуру, то она просто станет смешением одной текстуры с обеих сторон.

	void TriangulateConnection (
		HexDirection direction, HexCell cell, EdgeVertices e1
	) {
		…

		if (cell.GetEdgeType(direction) == HexEdgeType.Slope) {
			TriangulateEdgeTerraces(e1, cell, e2, neighbor, hasRoad);
		}
		else {
			TriangulateEdgeStrip(e1, color1, e2, color2, hasRoad);
		}

		…
	}


Красно-зелёные рёбра, за исключением уступов.

Разве резкий переход между красным и зелёным не вызовет проблем?
Несмотря на то, что переход на ребре происходит от красного до зелёного, центры ячеек с обеих сторон красные. Поэтому с одной стороны ребра должен появиться разрыв. Но это всего лишь splat map, цвета соседних треугольников не обязаны привязываться к одной текстуре. В этом случае соответствующее зелёному с одной стороны соответствует красному с другой.

Стоит заметить, что это не было бы возможно при треугольниках с общими вершинами.

Рёбра с уступами чуть сложнее, потому что в них есть дополнительные вершины. К счастью, имеющийся код интерполяции отлично работает с цветами splat map. Просто используем первый и второй цвета, а не цвета ячеек начала и конца.

	void TriangulateEdgeTerraces (
		EdgeVertices begin, HexCell beginCell,
		EdgeVertices end, HexCell endCell,
		bool hasRoad
	) {
		EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1);
		Color c2 = HexMetrics.TerraceLerp(color1, color2, 1);

		TriangulateEdgeStrip(begin, color1, e2, c2, hasRoad);

		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			EdgeVertices e1 = e2;
			Color c1 = c2;
			e2 = EdgeVertices.TerraceLerp(begin, end, i);
			c2 = HexMetrics.TerraceLerp(color1, color2, i);
			TriangulateEdgeStrip(e1, c1, e2, c2, hasRoad);
		}

		TriangulateEdgeStrip(e2, c2, end, color2, hasRoad);
	}


Красно-зелёные уступы рёбер.

Углы


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

	void TriangulateCorner (
		Vector3 bottom, HexCell bottomCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		…
		else {
			terrain.AddTriangle(bottom, left, right);
			terrain.AddTriangleColor(color1, color2, color3);
		}

		features.AddWall(bottom, bottomCell, left, leftCell, right, rightCell);
	}


Красно-зелёно-синие углы, за исключением уступов.

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

	void TriangulateCornerTerraces (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		Vector3 v3 = HexMetrics.TerraceLerp(begin, left, 1);
		Vector3 v4 = HexMetrics.TerraceLerp(begin, right, 1);
		Color c3 = HexMetrics.TerraceLerp(color1, color2, 1);
		Color c4 = HexMetrics.TerraceLerp(color1, color3, 1);

		terrain.AddTriangle(begin, v3, v4);
		terrain.AddTriangleColor(color1, c3, c4);

		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			Vector3 v1 = v3;
			Vector3 v2 = v4;
			Color c1 = c3;
			Color c2 = c4;
			v3 = HexMetrics.TerraceLerp(begin, left, i);
			v4 = HexMetrics.TerraceLerp(begin, right, i);
			c3 = HexMetrics.TerraceLerp(color1, color2, i);
			c4 = HexMetrics.TerraceLerp(color1, color3, i);
			terrain.AddQuad(v1, v2, v3, v4);
			terrain.AddQuadColor(c1, c2, c3, c4);
		}

		terrain.AddQuad(v3, v4, left, right);
		terrain.AddQuadColor(c3, c4, color2, color3);
	}


Красно-зелёно-синие угловые уступы, за исключением уступов вдоль обрывов.

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

	void TriangulateBoundaryTriangle (
		Vector3 begin, Color beginColor,
		Vector3 left, Color leftColor,
		Vector3 boundary, Color boundaryColor
	) {
		Vector3 v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, 1));
		Color c2 = HexMetrics.TerraceLerp(beginColor, leftColor, 1);

		terrain.AddTriangleUnperturbed(HexMetrics.Perturb(begin), v2, boundary);
		terrain.AddTriangleColor(beginColor, c2, boundaryColor);

		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			Vector3 v1 = v2;
			Color c1 = c2;
			v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, i));
			c2 = HexMetrics.TerraceLerp(beginColor, leftColor, i);
			terrain.AddTriangleUnperturbed(v1, v2, boundary);
			terrain.AddTriangleColor(c1, c2, boundaryColor);
		}

		terrain.AddTriangleUnperturbed(v2, HexMetrics.Perturb(left), boundary);
		terrain.AddTriangleColor(c2, leftColor, boundaryColor);
	}

Изменим TriangulateCornerTerracesCliff так, чтобы он использовал правильные цвета.

	void TriangulateCornerTerracesCliff (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		…
		Color boundaryColor = Color.Lerp(color1, color3, b);

		TriangulateBoundaryTriangle(
			begin, color1, left, color2, boundary, boundaryColor
		);

		if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) {
			TriangulateBoundaryTriangle(
				left, color2, right, color3, boundary, boundaryColor
			);
		}
		else {
			terrain.AddTriangleUnperturbed(
				HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary
			);
			terrain.AddTriangleColor(color2, color3, boundaryColor);
		}
	}

И сделаем то же самое для TriangulateCornerCliffTerraces.

	void TriangulateCornerCliffTerraces (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		…
		Color boundaryColor = Color.Lerp(color1, color2, b);

		TriangulateBoundaryTriangle(
			right, color3, begin, color1, boundary, boundaryColor
		);

		if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) {
			TriangulateBoundaryTriangle(
				left, color2, right, color3, boundary, boundaryColor
			);
		}
		else {
			terrain.AddTriangleUnperturbed(
				HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary
			);
			terrain.AddTriangleColor(color2, color3, boundaryColor);
		}
	}


Полная splat map рельефа.

unitypackage

Массивы текстур


Теперь, когда у нашего рельефа есть splat map, мы можем передать шейдеру коллекцию текстур. Мы не можем просто назначить шейдеру массив текстур C#, потому что массив должен существовать в памяти GPU как единая сущность. Нам придётся использовать специальный объект Texture2DArray, который поддерживается в Unity начиная с версии 5.4.

Все ли GPU поддерживают массивы текстур?
Современные GPU их поддерживают, но старые и некоторые мобильные могут и не поддерживать. Вот список поддерживаемых платформ согласно документации Unity.
  • Direct3D 11/12 (Windows, Xbox One)
  • OpenGL Core (Mac OS X, Linux)
  • Metal (iOS, Mac OS X)
  • OpenGL ES 3.0 (Android, iOS, WebGL 2.0)
  • PlayStation 4


Мастер


К сожалению поддержка редактором Unity массивов текстур в версии 5.5 минимальна. Мы не можем просто создать ассет массива текстур и назначить ему текстуры. Нам придётся делать это вручную. Мы можем или создать массив текстур в режиме Play, или создать ассет в редакторе. Давайте создадим ассет.

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

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

Для создания массива текстур мы соберём собственный мастер. Создадим скрипт TextureArrayWizard и разместим его внутри папки Editor. Вместо MonoBehaviour он должен расширять тип ScriptableWizard из пространства имён UnityEditor.

using UnityEditor;
using UnityEngine;

public class TextureArrayWizard : ScriptableWizard {
}

Мы можем открыть мастер через обобщённый статический метод ScriptableWizard.DisplayWizard. Его параметрами являются названия окна мастера и его кнопки создания. Будем вызывать этот метод в статическом методе CreateWizard.

	static void CreateWizard () {
		ScriptableWizard.DisplayWizard<TextureArrayWizard>(
			"Create Texture Array", "Create"
		);
	}

Для доступа к мастеру через редактор нам нужно добавить этот метод в меню Unity. Это можно сделать, добавив к методу атрибут MenuItem. Давайте добавим его к меню Assets, а конкретнее в Assets / Create / Texture Array.

	[MenuItem("Assets/Create/Texture Array")]
	static void CreateWizard () {
		…
	}


Наш пользовательский мастер.

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

	public Texture2D[] textures;


Мастер с текстурами.

Что-нибудь создадим


При нажатии кнопки Create мастера она исчезнет. Кроме того, Unity пожалуется, что нет метода OnWizardCreate. Это метод, который вызывается при нажатии кнопки создания, поэтому нам нужно добавить его в мастер.

	void OnWizardCreate () {
	}

Здесь мы создадим наш массив текстур. По крайней мере, если пользователь добавил в мастер текстуры. Если нет, то создавать нечего и работу нужно прекратить.

	void OnWizardCreate () {
		if (textures.Length == 0) {
			return;
		}
	}

Следующим шагом будет запрос места сохранения ассета массива текстур. Панель сохранения файла можно открыть методом EditorUtility.SaveFilePanelInProject. Его параметры определяют имя панели, имя файла по умолчанию, расширение файла и описание. Для массивов текстур используется общее расширение файлов asset.

		if (textures.Length == 0) {
			return;
		}
		EditorUtility.SaveFilePanelInProject(
			"Save Texture Array", "Texture Array", "asset", "Save Texture Array"
		);

SaveFilePanelInProject возвращает выбранный пользователем путь к файлу. Если пользователь нажал на этой панели отмену, то путь будет пустой строкой. Поэтому в этом случае мы должны прервать работу.

		string path = EditorUtility.SaveFilePanelInProject(
			"Save Texture Array", "Texture Array", "asset", "Save Texture Array"
		);
		if (path.Length == 0) {
			return;
		}

Создание массива текстур


Если у нас есть правильный путь, то можно двинуться дальше и создать новый объект Texture2DArray. Его метод-конструктор требует указания ширины и высоты текстуры, длины массива, формата текстур и необходимости mip-текстурирования. Эти параметры должны быть одинаковыми для всех текстур массива. Для конфигурирования объекта мы используем первую текстуру. Пользователь сам должен проверить, что все текстуры имеют одинаковый формат.

		if (path.Length == 0) {
			return;
		}

		Texture2D t = textures[0];
		Texture2DArray textureArray = new Texture2DArray(
			t.width, t.height, textures.Length, t.format, t.mipmapCount > 1
		);

Так как массив текстур — это единый ресурс GPU, он использует одинаковые режимы фильтрации и сворачивания для всех текстур. Здесь мы снова используем для настройки всего этого первую текстуру.

		Texture2DArray textureArray = new Texture2DArray(
			t.width, t.height, textures.Length, t.format, t.mipmapCount > 1
		);
		textureArray.anisoLevel = t.anisoLevel;
		textureArray.filterMode = t.filterMode;
		textureArray.wrapMode = t.wrapMode;

Теперь мы можем скопировать текстуры в массив с помощью метода Graphics.CopyTexture. Метод копирует сырые данные текстур, по одному mip-уровню за раз. Поэтому нам нужно обойти в цикле все текстуры и их mip-уровни. Параметрами метода являются два множества, состоящих из ресурса текстуры, индекса и mip-уровня. Так как исходные текстуры не являются массивами, их индекс всегда равен нулю.

		textureArray.wrapMode = t.wrapMode;

		for (int i = 0; i < textures.Length; i++) {
			for (int m = 0; m < t.mipmapCount; m++) {
				Graphics.CopyTexture(textures[i], 0, m, textureArray, i, m);
			}
		}

На этом этапе у нас есть в памяти правильный массив текстур, но он пока не является ассетом. Последним шагом будет вызов AssetDatabase.CreateAsset с массивом и его путём. При этом данные запишутся в файл в нашем проекте, и он появится в окне проекта.

		for (int i = 0; i < textures.Length; i++) {
			…
		}

		AssetDatabase.CreateAsset(textureArray, path);


Текстуры


Чтобы создать настоящий массив текстур, нам понадобятся исходные текстуры. Вот пять текстур, соответствующих цветам, которые мы использовали до этого момента. Жёлтый становится песком, зелёный — травой, синий становится землёй, оранжевый — камнем, а белый — снегом.






Текстуры песка, травы, земли, камня и снега.

Заметьте, что эти текстуры не являются фотографиями настоящего рельефа. Это лёгкие псевдослучайные паттерны, которые я создал с помощью NumberFlow. Я стремился создать распознаваемые типы и детали рельефа, не конфликтующие с абстрактным полигональным рельефом. Фотореализм оказался для этого неподходящим. Кроме того, несмотря на то, что паттерны добавляют вариантивности, в них мало отчётливых черт, которые сделали бы повторения сразу же заметными.

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



Создание массива текстур.

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


Инспектор массива текстур.

Это простейшее отображение части данных массива текстур. Заметьте, что здесь есть переключатель Is Readable, который изначально включен. Так как нам не нужно считывать из массива данные пикселей, отключим его. Мы не можем сделать это в мастере, потому что у Texture2DArray нет методов или свойств для доступа к этому параметру.

(В Unity 5.6 есть баг, который портит массивы текстур в сборках на нескольких платформах. Обойти его можно, не отключая Is Readable.)

Также стоит заметить, что тут есть поле Color Space, которому присвоено значение 1. Это значит, что текстуры предполагаются находящимися в гамма-пространстве, что верно. Если они должны были находиться в линейном пространстве, то полю нужно было присвоить значение 0. На самом деле у конструктора Texture2DArray есть дополнительный параметр для задания цветового пространства, однако Texture2D не показывает, находится ли оно в линейном пространстве, или нет, поэтому в любом случае нужно задавать значение вручную.

Шейдер


Теперь, когда у нас есть массив текстур, нужно научить шейдер работать с ним. Пока для рендеринга рельефа мы используем шейдер VertexColors. Так как теперь вместо цветов мы применим текстуры, переименуем его в Terrain. Затем превратим его параметр _MainTex в массив текстур и присвоим ему ассет.

Shader "Custom/Terrain" {
	Properties {
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Terrain Texture Array", 2DArray) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
		_Metallic ("Metallic", Range(0,1)) = 0.0
	}
	…
}


Материал рельефа с массивом текстур.

Чтобы включить массивы текстур на всех поддерживающих их платформах, нужно увеличить target level шейдера с 3.0 до 3.5.

		#pragma target 3.5

Так как переменная _MainTex теперь ссылается на массив текстур, нам нужно изменить его тип. Тип зависит от целевой платформы и об этом позаботится макрос UNITY_DECLARE_TEX2DARRAY.

//		sampler2D _MainTex;
		UNITY_DECLARE_TEX2DARRAY(_MainTex);

Как и в других шейдерах, для сэмплирования текстур рельефа нам понадобятся координаты XZ мира. Поэтому добавим в структуру ввода поверхностного шейдера позицию в мире. Удалим также UV-координаты по умолчанию, потому что они нам не нужны.

		struct Input {
//			float2 uv_MainTex;
			float4 color : COLOR;
			float3 worldPos;
		};

Для сэмплирования массива текстур нам нужно использовать макрос UNITY_SAMPLE_TEX2DARRAY. Для сэмплирования массива ему требуются три координаты. Первые две — это обычные UV-координаты. Мы воспользуемся координатами XZ мира, отмасштабированными на 0.02. Так мы получим хорошее разрешение текстур при полном увеличении. Текстуры будут повторяться приблизительно через каждые четыре ячейки.

Третья координата используется как индекс массива текстур, как и в обычном массиве. Так как координаты имеют значения float, перед индексирование массива GPU округляет их. Так как пока мы не знаем, какая текстура нужна, давайте всегда использовать первую. Также на конечный результат не будет влиять цвет вершины, потому что это splat map.

		void surf (Input IN, inout SurfaceOutputStandard o) {
			float2 uv = IN.worldPos.xz * 0.02;
			fixed4 c = UNITY_SAMPLE_TEX2DARRAY(_MainTex, float3(uv, 0));
			Albedo = c.rgb * _Color;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}


Всё стало песком.

unitypackage

Выбор текстур


Нам нужна splat map рельефа, смешивающая три типа на треугольник. У нас есть массив текстур с текстурой для каждого типа рельефа. У нас есть шейдер, сэмплирующий массив текстур. Но пока у нас нет возможности сообщить шейдеру, какие текстуры нужно выбирать для каждого треугольника.

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

Данные мешей


Мы можем использовать для хранения индексов один из наборов UV меша. Так как на каждую вершину хранится по три индекса, то существующих 2D-наборов UV будет недостаточно. К счастью, наборы UV могут содержать до четырёх координат. Поэтому добавим в HexMesh второй список Vector3, на который мы будем ссылаться как на типы рельефа.

	public bool useCollider, useColors, useUVCoordinates, useUV2Coordinates;
	public bool useTerrainTypes;

	[NonSerialized] List<Vector3> vertices, terrainTypes;

Включим типы рельефа для дочернего объекта Terrain префаба Hex Grid Chunk.


Используем типы рельефа.

При необходимости будем брать ещё один список Vector3 для типов рельефа во время очистки меша.

	public void Clear () {
		…
		if (useTerrainTypes) {
			terrainTypes = ListPool<Vector3>.Get();
		}
		triangles = ListPool<int>.Get();
	}

В процессе применения данных меша сохраняем типы рельефа в третьем наборе UV. Благодаря этому они не будут конфликтовать с двумя другими наборами, если мы когда-нибудь решить использовать их вместе.

	public void Apply () {
		…
		if (useTerrainTypes) {
			hexMesh.SetUVs(2, terrainTypes);
			ListPool<Vector3>.Add(terrainTypes);
		}
		hexMesh.SetTriangles(triangles, 0);
		…
	}

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

	public void AddTriangleTerrainTypes (Vector3 types) {
		terrainTypes.Add(types);
		terrainTypes.Add(types);
		terrainTypes.Add(types);
	}

Смешение в quad-е работает аналогично. Все четыре вершины имеют одинаковые типы.

	public void AddQuadTerrainTypes (Vector3 types) {
		terrainTypes.Add(types);
		terrainTypes.Add(types);
		terrainTypes.Add(types);
		terrainTypes.Add(types);
	}

Вееры треугольников рёбер


Теперь нам нужно добавить типы к данным мешей в HexGridChunk. Давайте начнём с TriangulateEdgeFan. Сначала ради лучшей читаемости разделим вызовы методов вершин и цветов. Вспомним, что при каждом вызове этого метода мы передаём ему color1, поэтому можем использовать этот цвет непосредственно, а не применять параметр.

	void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, Color color) {
		terrain.AddTriangle(center, edge.v1, edge.v2);
//		terrain.AddTriangleColor(color);
		terrain.AddTriangle(center, edge.v2, edge.v3);
//		terrain.AddTriangleColor(color);
		terrain.AddTriangle(center, edge.v3, edge.v4);
//		terrain.AddTriangleColor(color);
		terrain.AddTriangle(center, edge.v4, edge.v5);
//		terrain.AddTriangleColor(color);
		
		terrain.AddTriangleColor(color1);
		terrain.AddTriangleColor(color1);
		terrain.AddTriangleColor(color1);
		terrain.AddTriangleColor(color1);
	}

После цветов мы добавляем типы рельефа. Так как типы в треугольнике могут быть разными, это должен быть параметр, заменяющий цвет. Используем этот простой тип для создания Vector3. Нам важны только первые четыре канала, потому что в этом случае splat map всегда красная. Так как всем трём компонентам вектора нужно что-то присвоить, давайте присвоим им один тип.

	void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, float type) {
		…

		Vector3 types;
		types.x = types.y = types.z = type;
		terrain.AddTriangleTerrainTypes(types);
		terrain.AddTriangleTerrainTypes(types);
		terrain.AddTriangleTerrainTypes(types);
		terrain.AddTriangleTerrainTypes(types);
	}

Теперь нам нужно изменить все вызовы этого метода, заменив аргумент цвета на индекс типа рельефа ячейки. Внесём это изменение в TriangulateWithoutRiver, TriangulateAdjacentToRiver и TriangulateWithRiverBeginOrEnd.

//		TriangulateEdgeFan(center, e, color1);
		TriangulateEdgeFan(center, e, cell.TerrainTypeIndex);

На этом этапе при запуске режима Play будут появляться ошибки, сообщающие, что третьи наборы UV мешей выходят за пределы. Так получилось потому, что мы пока не добавляем типы рельефа к каждому треугольнику и quad. Поэтому давайте продолжим изменять HexGridChunk.

Полосы рёбер


Теперь при создании полосы ребра нам нужно знать, какие типы рельефа есть по обе стороны. Поэтому добавим их как параметры, а затем создадим вектор типов, двум каналам которого присвоены эти типы. Третий канал не важен, поэтому просто приравняем его к первому. После добавления цветов добавим к quad-ам типы.

	void TriangulateEdgeStrip (
		EdgeVertices e1, Color c1, float type1,
		EdgeVertices e2, Color c2, float type2,
		bool hasRoad = false
	) {
		terrain.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2);
		terrain.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3);
		terrain.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4);
		terrain.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5);

		terrain.AddQuadColor(c1, c2);
		terrain.AddQuadColor(c1, c2);
		terrain.AddQuadColor(c1, c2);
		terrain.AddQuadColor(c1, c2);

		Vector3 types;
		types.x = types.z = type1;
		types.y = type2;
		terrain.AddQuadTerrainTypes(types);
		terrain.AddQuadTerrainTypes(types);
		terrain.AddQuadTerrainTypes(types);
		terrain.AddQuadTerrainTypes(types);

		if (hasRoad) {
			TriangulateRoadSegment(e1.v2, e1.v3, e1.v4, e2.v2, e2.v3, e2.v4);
		}
	}

Теперь нам нужно изменить вызовы TriangulateEdgeStrip. Сначала TriangulateAdjacentToRiver, TriangulateWithRiverBeginOrEnd и TriangulateWithRiver должны использовать тип ячейки для обеих сторон полосы ребра.

//		TriangulateEdgeStrip(m, color1, e, color1);
		TriangulateEdgeStrip(
			m, color1, cell.TerrainTypeIndex,
			e, color1, cell.TerrainTypeIndex
		);

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

	void TriangulateConnection (
		HexDirection direction, HexCell cell, EdgeVertices e1
	) {
		…
		if (cell.GetEdgeType(direction) == HexEdgeType.Slope) {
			TriangulateEdgeTerraces(e1, cell, e2, neighbor, hasRoad);
		}
		else {
//			TriangulateEdgeStrip(e1, color1, e2, color2, hasRoad);
			TriangulateEdgeStrip(
				e1, color1, cell.TerrainTypeIndex,
				e2, color2, neighbor.TerrainTypeIndex, hasRoad
			);
		}
		…
	}

То же самое относится к TriangulateEdgeTerraces, который трижды вызывает TriangulateEdgeStrip. Типы для уступов являются одинаковыми.

	void TriangulateEdgeTerraces (
		EdgeVertices begin, HexCell beginCell,
		EdgeVertices end, HexCell endCell,
		bool hasRoad
	) {
		EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1);
		Color c2 = HexMetrics.TerraceLerp(color1, color2, 1);
		float t1 = beginCell.TerrainTypeIndex;
		float t2 = endCell.TerrainTypeIndex;

		TriangulateEdgeStrip(begin, color1, t1, e2, c2, t2, hasRoad);

		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			EdgeVertices e1 = e2;
			Color c1 = c2;
			e2 = EdgeVertices.TerraceLerp(begin, end, i);
			c2 = HexMetrics.TerraceLerp(color1, color2, i);
			TriangulateEdgeStrip(e1, c1, t1, e2, c2, t2, hasRoad);
		}

		TriangulateEdgeStrip(e2, c2, t1, end, color2, t2, hasRoad);
	}

Углы


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

	void TriangulateCorner (
		Vector3 bottom, HexCell bottomCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		…
		else {
			terrain.AddTriangle(bottom, left, right);
			terrain.AddTriangleColor(color1, color2, color3);
			Vector3 types;
			types.x = bottomCell.TerrainTypeIndex;
			types.y = leftCell.TerrainTypeIndex;
			types.z = rightCell.TerrainTypeIndex;
			terrain.AddTriangleTerrainTypes(types);
		}

		features.AddWall(bottom, bottomCell, left, leftCell, right, rightCell);
	}

Мы используем тот же подход в TriangulateCornerTerraces, только здесь мы создаём и группу quad-ов.

	void TriangulateCornerTerraces (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		Vector3 v3 = HexMetrics.TerraceLerp(begin, left, 1);
		Vector3 v4 = HexMetrics.TerraceLerp(begin, right, 1);
		Color c3 = HexMetrics.TerraceLerp(color1, color2, 1);
		Color c4 = HexMetrics.TerraceLerp(color1, color3, 1);
		Vector3 types;
		types.x = beginCell.TerrainTypeIndex;
		types.y = leftCell.TerrainTypeIndex;
		types.z = rightCell.TerrainTypeIndex;

		terrain.AddTriangle(begin, v3, v4);
		terrain.AddTriangleColor(color1, c3, c4);
		terrain.AddTriangleTerrainTypes(types);

		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			Vector3 v1 = v3;
			Vector3 v2 = v4;
			Color c1 = c3;
			Color c2 = c4;
			v3 = HexMetrics.TerraceLerp(begin, left, i);
			v4 = HexMetrics.TerraceLerp(begin, right, i);
			c3 = HexMetrics.TerraceLerp(color1, color2, i);
			c4 = HexMetrics.TerraceLerp(color1, color3, i);
			terrain.AddQuad(v1, v2, v3, v4);
			terrain.AddQuadColor(c1, c2, c3, c4);
			terrain.AddQuadTerrainTypes(types);
		}

		terrain.AddQuad(v3, v4, left, right);
		terrain.AddQuadColor(c3, c4, color2, color3);
		terrain.AddQuadTerrainTypes(types);
	}

При смешении уступов и обрывов нам нужно использовать TriangulateBoundaryTriangle. Просто дадим ему параметр вектора типов и добавим его ко всем его треугольникам.

	void TriangulateBoundaryTriangle (
		Vector3 begin, Color beginColor,
		Vector3 left, Color leftColor,
		Vector3 boundary, Color boundaryColor, Vector3 types
	) {
		Vector3 v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, 1));
		Color c2 = HexMetrics.TerraceLerp(beginColor, leftColor, 1);

		terrain.AddTriangleUnperturbed(HexMetrics.Perturb(begin), v2, boundary);
		terrain.AddTriangleColor(beginColor, c2, boundaryColor);
		terrain.AddTriangleTerrainTypes(types);

		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			Vector3 v1 = v2;
			Color c1 = c2;
			v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, i));
			c2 = HexMetrics.TerraceLerp(beginColor, leftColor, i);
			terrain.AddTriangleUnperturbed(v1, v2, boundary);
			terrain.AddTriangleColor(c1, c2, boundaryColor);
			terrain.AddTriangleTerrainTypes(types);
		}

		terrain.AddTriangleUnperturbed(v2, HexMetrics.Perturb(left), boundary);
		terrain.AddTriangleColor(c2, leftColor, boundaryColor);
		terrain.AddTriangleTerrainTypes(types);
	}

В TriangulateCornerTerracesCliff создадим вектор типов на основе переданных ячеек. Затем добавим его к одному треугольнику и передадим в TriangulateBoundaryTriangle.

	void TriangulateCornerTerracesCliff (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		float b = 1f / (rightCell.Elevation - beginCell.Elevation);
		if (b < 0) {
			b = -b;
		}
		Vector3 boundary = Vector3.Lerp(
			HexMetrics.Perturb(begin), HexMetrics.Perturb(right), b
		);
		Color boundaryColor = Color.Lerp(color1, color3, b);
		Vector3 types;
		types.x = beginCell.TerrainTypeIndex;
		types.y = leftCell.TerrainTypeIndex;
		types.z = rightCell.TerrainTypeIndex;

		TriangulateBoundaryTriangle(
			begin, color1, left, color2, boundary, boundaryColor, types
		);

		if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) {
			TriangulateBoundaryTriangle(
				left, color2, right, color3, boundary, boundaryColor, types
			);
		}
		else {
			terrain.AddTriangleUnperturbed(
				HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary
			);
			terrain.AddTriangleColor(color2, color3, boundaryColor);
			terrain.AddTriangleTerrainTypes(types);
		}
	}

То же самое относится к TriangulateCornerCliffTerraces.

	void TriangulateCornerCliffTerraces (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		float b = 1f / (leftCell.Elevation - beginCell.Elevation);
		if (b < 0) {
			b = -b;
		}
		Vector3 boundary = Vector3.Lerp(
			HexMetrics.Perturb(begin), HexMetrics.Perturb(left), b
		);
		Color boundaryColor = Color.Lerp(color1, color2, b);
		Vector3 types;
		types.x = beginCell.TerrainTypeIndex;
		types.y = leftCell.TerrainTypeIndex;
		types.z = rightCell.TerrainTypeIndex;

		TriangulateBoundaryTriangle(
			right, color3, begin, color1, boundary, boundaryColor, types
		);

		if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) {
			TriangulateBoundaryTriangle(
				left, color2, right, color3, boundary, boundaryColor, types
			);
		}
		else {
			terrain.AddTriangleUnperturbed(
				HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary
			);
			terrain.AddTriangleColor(color2, color3, boundaryColor);
			terrain.AddTriangleTerrainTypes(types);
		}
	}

Реки


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

	void TriangulateWithRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…

		terrain.AddTriangleColor(color1);
		terrain.AddQuadColor(color1);
		terrain.AddQuadColor(color1);
		terrain.AddTriangleColor(color1);

		Vector3 types;
		types.x = types.y = types.z = cell.TerrainTypeIndex;
		terrain.AddTriangleTerrainTypes(types);
		terrain.AddQuadTerrainTypes(types);
		terrain.AddQuadTerrainTypes(types);
		terrain.AddTriangleTerrainTypes(types);

		…
	}

Смешение типов


На данном этапе меши содержат необходимые индексы рельефа. Всё, что нам осталось — заставить шейдер Terrain использовать их. Чтобы индексы попали во фрагментный шейдер, нам нужно сначала передать их через вершинный шейдер. Мы можем сделать это в собственной вершинной функции, как мы это делали в шейдере Estuary. В данном случае мы добавляем к структуре ввода поле float3 terrain и копируем в него v.texcoord2.xyz.

		#pragma surface surf Standard fullforwardshadows vertex:vert
		#pragma target 3.5

		…

		struct Input {
			float4 color : COLOR;
			float3 worldPos;
			float3 terrain;
		};
		
		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);
			data.terrain = v.texcoord2.xyz;
		}

Нам нужно сэмплировать массив текстур по три раза на фрагмент. Поэтому давайте создадим удобную функцию для создания координат текстур, сэмплирования массива и модулирования сэмпла со splat map для одного индекса.

		float4 GetTerrainColor (Input IN, int index) {
			float3 uvw = float3(IN.worldPos.xz * 0.02, IN.terrain[index]);
			float4 c = UNITY_SAMPLE_TEX2DARRAY(_MainTex, uvw);
			return c * IN.color[index];
		}

		void surf (Input IN, inout SurfaceOutputStandard o) {
			…
		}

Можем ли мы работать с вектором, как с массивом?
Да. При использовании индекса-константы color[0] эквивалентно color.r. А color[1] эквивалентно color.g, и так далее.

С помощью этой функции нам будет просто сэмплировать массив текстур три раза и скомбинировать результаты.

		void surf (Input IN, inout SurfaceOutputStandard o) {
//			float2 uv = IN.worldPos.xz * 0.02;
			fixed4 c =
				GetTerrainColor(IN, 0) +
				GetTerrainColor(IN, 1) +
				GetTerrainColor(IN, 2);
			o.Albedo = c.rgb * _Color;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}


Текстурированный рельеф.

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

Подчистка


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


Варианты выбора рельефа.

Кроме того, HexCell больше не нужно свойство цвета, поэтому удалим его.

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

Также из HexGrid можно удалить массив цветов и связанный с ним код.

//	public Color[] colors;

	…

	void Awake () {
		HexMetrics.noiseSource = noiseSource;
		HexMetrics.InitializeHashGrid(seed);
//		HexMetrics.colors = colors;
		CreateMap(cellCountX, cellCountZ);
	}
	
	…

	…

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

И наконец, массив цветов не нужен также в HexMetrics.

//	public static Color[] colors;

unitypackage

Часть 15: расстояния


  • Отображаем линии сетки.
  • Переключаемся между режимами редактирования и навигации.
  • Вычисляем расстояния между ячейками.
  • Находим пути в обход препятствий.
  • Учитываем переменные затраты на перемещение.

Создав качественные карты, мы приступим к навигации.


Кратчайший путь — не всегда прямая.

Отображение сетки


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

Текстура сетки


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


Повторяющаяся текстура сетки.

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

Проецирование сетки


Чтобы спроецировать паттерн сетки, нам нужно добавить в шейдер Terrain свойство текстуры.

	Properties {
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Terrain Texture Array", 2DArray) = "white" {}
		_GridTex ("Grid Texture", 2D) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
		_Metallic ("Metallic", Range(0,1)) = 0.0
	}


Материал рельефа с текстурой сетки.

Сэмплируем текстуру с помощью координат XZ мира, а затем умножим её на albedo. Так как линии сетки на текстуре серые, это вплетёт паттерн в рельеф.

		sampler2D _GridTex;

		…

		void surf (Input IN, inout SurfaceOutputStandard o) {
			fixed4 c =
				GetTerrainColor(IN, 0) +
				GetTerrainColor(IN, 1) +
				GetTerrainColor(IN, 2);

			fixed4 grid = tex2D(_GridTex, IN.worldPos.xz);
			
			o.Albedo = c.rgb * grid * _Color;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}


Albedo, умноженное на мелкую сетку.

Нам нужно отмасштабировать паттерн, чтобы он соответствовал ячейкам карты. Расстояние между центрами соседних ячеек равно 15, его нужно удвоить, чтобы переместиться вверх на две ячейки. То есть нам нужно разделить координаты V сетки на 30. Внутренний радиус ячеек равен 5√3, а чтобы переместить две ячейки вправо, нужно в четыре раза больше. Следовательно, нужно разделить координаты U сетки на 20√3.

			float2 gridUV = IN.worldPos.xz;
			gridUV.x *= 1 / (4 * 8.66025404);
			gridUV.y *= 1 / (2 * 15.0);
			fixed4 grid = tex2D(_GridTex, gridUV);


Правильный размер ячеек сетки.

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


Проецирование на ячейки с высотой.

Деформация сетки обычно не так уж плоха, особенно если смотреть на карту с большого расстояния.


Сетка на расстоянии.

Включение сетки


Хотя отображение сетки — это удобно, оно требуется не всегда. Например, стоит отключить её когда делаешь скриншот. Кроме того, не все предпочитают видеть сетку постоянно. Поэтому давайте сделаем её необязательной. Мы добавим к шейдеру директиву multi_compile, чтобы создать варианты с сеткой и без неё. Для этого мы воспользуемся ключевым словом GRID_ON. Условная компиляция шейдеров описана в туториале Rendering 5, Multiple Lights.

		#pragma surface surf Standard fullforwardshadows vertex:vert
		#pragma target 3.5

		#pragma multi_compile _ GRID_ON

При объявлении переменной grid сначала присваиваем ей значение 1. В результате сетка будет отключена. Затем мы будем сэмплировать текстуру сетки только для варианта с определённым ключевым словом GRID_ON.

			fixed4 grid = 1;
			#if defined(GRID_ON)
				float2 gridUV = IN.worldPos.xz;
				gridUV.x *= 1 / (4 * 8.66025404);
				gridUV.y *= 1 / (2 * 15.0);
				grid = tex2D(_GridTex, gridUV);
			#endif
			
			o.Albedo = c.rgb * grid * _Color;

Так как ключевое слово GRID_ON не включено в шейдере рельефа, то сетка пропадёт. Чтобы снова её включить, мы добавим в UI редактора карты переключатель. Чтобы это было возможно, HexMapEditor должен получить ссылку на материал Terrain и метод для включения или отключения ключевого словаGRID_ON.

	public Material terrainMaterial;
	
	…
	
	public void ShowGrid (bool visible) {
		if (visible) {
			terrainMaterial.EnableKeyword("GRID_ON");
		}
		else {
			terrainMaterial.DisableKeyword("GRID_ON");
		}
	}


Редактор март шестиугольников со ссылкой на материал.

Добавим в UI переключатель Grid и соединим его с методом ShowGrid.


Переключатель сетки.

Сохраняем состояние


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

Это происходит потому, что мы изменяем ключевое слово общего материала Terrain. Мы редактируем ассет материала, поэтому изменение сохраняется в редакторе Unity. В сборке оно не сохранится.

Чтобы игра всегда начиналась без сетки, будем отключать ключевое слово GRID_ON в Awake HexMapEditor.

	void Awake () {
		terrainMaterial.DisableKeyword("GRID_ON");
	}

unitypackage

Режим редактирования


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

Переключатель редактирования


Добавим в HexMapEditor булево поле editMode, а также задающий его метод. Затем добавим в UI ещё один переключатель для управления им. Давайте начинать с режима навигации, то есть режим редактирования по умолчанию будет отключен.

	bool editMode;
	
	…

	public void SetEditMode (bool toggle) {
		editMode = toggle;
	}


Переключатель режима редактирования.

Чтобы действительно отключить редактирование, сделаем вызов EditCells зависимым от editMode.

	void HandleInput () {
		Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition);
		RaycastHit hit;
		if (Physics.Raycast(inputRay, out hit)) {
			HexCell currentCell = hexGrid.GetCell(hit.point);
			if (previousCell && previousCell != currentCell) {
				ValidateDrag(currentCell);
			}
			else {
				isDrag = false;
			}
			if (editMode) {
				EditCells(currentCell);
			}
			previousCell = currentCell;
		}
		else {
			previousCell = null;
		}
	}

Отладочные метки


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

	public void SetEditMode (bool toggle) {
		editMode = toggle;
		hexGrid.ShowUI(!toggle);
	}

Так как мы начинаем с режима навигации, метки по умолчанию должны быть включены. В настоящий момент HexGridChunk.Awake отключает их, но больше он не должен этого делать.

	void Awake () {
		gridCanvas = GetComponentInChildren<Canvas>();

		cells = new HexCell[HexMetrics.chunkSizeX * HexMetrics.chunkSizeZ];
//		ShowUI(false);
	}


Метки координат.

Координаты ячеек теперь становятся видимыми сразу после запуска режима Play. Но нам нужны не координаты, мы используем метки для отображения расстояний. Так как для этого понадобится только одно число на ячейку, можно увеличить размер шрифта, чтобы они читались лучше. Изменим префаб Hex Cell Label, чтобы он использовал жирный шрифт с размером 8.


Метки с жирным шрифтом размера 8.

Теперь после запуска режима Play мы будем видеть большие метки. Видимы только первые координаты ячейки, остальные в метку не помещаются.


Большие метки.

Так как координаты нам больше не нужны, удалим в HexGrid.CreateCell присвоение значения label.text.

	void CreateCell (int x, int z, int i) {
		…

		Text label = Instantiate<Text>(cellLabelPrefab);
		label.rectTransform.anchoredPosition =
			new Vector2(position.x, position.z);
//		label.text = cell.coordinates.ToStringOnSeparateLines();
		cell.uiRect = label.rectTransform;

		…
	}

Также можно удалить из UI переключатель Labels и связанный с ним метод HexMapEditor.ShowUI.

//	public void ShowUI (bool visible) {
//		hexGrid.ShowUI(visible);
//	}


Переключателя метода больше нет.

unitypackage

Нахождение расстояний


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

Отображение расстояний


Чтобы отслеживать расстояние до ячейки, добавим в HexCell целочисленное поле distance. Оно будет обозначать расстояние между этой ячейкой и выбранной. Поэтому для самой выбранной ячейки оно будет равно нулю, для непосредственного соседа равно 1, и так далее.

	int distance;

Когда расстояние задано, мы должны обновить метку ячейки, чтобы отобразить её значение. HexCell имеет ссылку на RectTransform объекта UI. Нам нужно будет вызывать его GetComponent<Text>, чтобы добраться до ячейки. Учтите, что Text находится в пространстве имён UnityEngine.UI, поэтому используйте его в начале скрипта.

	void UpdateDistanceLabel () {
		Text label = uiRect.GetComponent<Text>();
		label.text = distance.ToString();
	}

Разве не должны мы хранить прямую ссылку на компонент Text?
Да, это сделать можно. Но я этим не утруждаюсь, потому что метки используются только для того, чтобы показать, что код навигации работает. Убедившись в этом, мы больше не будем их использовать.

Заставим общее свойство получать и задавать расстояние до ячейки, а также обновлять её метку.

	public int Distance {
		get {
			return distance;
		}
		set {
			distance = value;
			UpdateDistanceLabel();
		}
	}

Добавим в HexGrid общий метод FindDistancesTo с параметром ячейки. Пока мы просто будем задавать нулевое расстояние до каждой ячейки.

	public void FindDistancesTo (HexCell cell) {
		for (int i = 0; i < cells.Length; i++) {
			cells[i].Distance = 0;
		}
	}

Если не включен режим редактирования, то HexMapEditor.HandleInput вызываем новый метод с текущей ячейкой.

			if (editMode) {
				EditCells(currentCell);
			}
			else {
				hexGrid.FindDistancesTo(currentCell);
			}

Расстояния между координатами


Теперь в режиме навигации после касания одной из них все ячейки отображают ноль. Но, разумеется, они должны отображать истинное расстояние до ячейки. Для вычисления расстояния до них мы можем использовать координаты ячейки. Поэтому предположим, что HexCoordinates имеет метод DistanceTo, и воспользуемся им в HexGrid.FindDistancesTo.

	public void FindDistancesTo (HexCell cell) {
		for (int i = 0; i < cells.Length; i++) {
			cells[i].Distance =
				cell.coordinates.DistanceTo(cells[i].coordinates);
		}
	}

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

	public int DistanceTo (HexCoordinates other) {
		return x - other.x;
	}

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

		return x < other.x ? other.x - x : x - other.x;


Расстояния по X.

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

		return
			(x < other.x ? other.x - x : x - other.x) +
			(Y < other.Y ? other.Y - Y : Y - other.Y) +
			(z < other.z ? other.z - z : z - other.z);


Сумма расстояний по XYZ.

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

		return
			((x < other.x ? other.x - x : x - other.x) +
			(Y < other.Y ? other.Y - Y : Y - other.Y) +
			(z < other.z ? other.z - z : z - other.z)) / 2;


Настоящие расстояния.

Почему сумма равна удвоенному расстоянию?
Не забывайте, что в нашей сетке используются кубические координаты. Сумма этих координат всегда равна нулю, например (1, −3, 2). Положительные и отрицательные координаты уравновешивают друг друга. Если мы берём их абсолютные значения, то перемещаем все координаты в положительную сторону. Результат равен удвоенной наибольшей абсолютной координате. Также стоит заметить, что расстояние от ячейки до точки начала координат равна наибольшей абсолютной координате. Поэтому можно было использовать и максимум разностей абсолютных координат.


Кубические координаты.

unitypackage

Работа с препятствиями


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

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

Визуализация поиска


Поиск по карте — это итеративный процесс. Чтобы понимать, что мы делаем, полезно было бы видеть каждый этап поиска. Мы можем сделать это, превратив поисковый алгоритм в корутину, для чего нам потребуется пространство поиска System.Collections. Частота обновления в 60 итераций в секунду достаточно мала, чтобы мы видели, что происходит, а поиск на маленькой карте при этом не занимал слишком много времени.

	public void FindDistancesTo (HexCell cell) {
		StartCoroutine(Search(cell));
	}

	IEnumerator Search (HexCell cell) {
		WaitForSeconds delay = new WaitForSeconds(1 / 60f);
		for (int i = 0; i < cells.Length; i++) {
			yield return delay;
			cells[i].Distance =
				cell.coordinates.DistanceTo(cells[i].coordinates);
		}
	}

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

	public void FindDistancesTo (HexCell cell) {
		StopAllCoroutines();
		StartCoroutine(Search(cell));
	}

Кроме того, нам нужно завершить поиск при загрузке новой карты.

	public void Load (BinaryReader reader, int header) {
		StopAllCoroutines();
		…
	}

Поиск в ширину (Breadth-First Search)


Ещё до того, как начнём поиск, мы знаем, что расстояние до выбранной ячейки равно нулю. И, разумеется, расстояние до всех её соседей равно 1, если их можно достичь. Затем мы можем взглянуть на одного из этих соседей. У этой ячейки скорее всего есть собственные соседи, которых можно достичь, и у которых пока не вычислено расстояние. Если это так, то расстояние до этих соседей должно быть равно 2. Мы можем повторять этот процесс для всех соседей на расстоянии 1. После этого мы повторим его для всех соседей на расстоянии 2. И так далее, пока не достигнем всех ячеек.

То есть сначала мы находим все ячейки на расстоянии 1, затем находим все на расстоянии 2, потом на расстоянии 3, и так далее, пока не закончим. Это гарантирует, что мы найдём наименьшее расстояние до каждой достижимой ячейки. Этот алгоритм называется поиском в ширину (breadth-first search).

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

	IEnumerator Search (HexCell cell) {
		for (int i = 0; i < cells.Length; i++) {
			cells[i].Distance = int.MaxValue;
		}
		
		…
	}

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

	void UpdateDistanceLabel () {
		Text label = uiRect.GetComponent<Text>();
		label.text = distance == int.MaxValue ? "" : distance.ToString();
	}

Далее нам нужно отслеживать ячейки, которые нужно посетить, и порядок их посещения. Такую коллекцию часто называют границей или открытым множеством. Нам просто нужно обрабатывать ячейки в том же порядке, в котором мы их встретили. Для этого можно использовать очередь Queue, которая является частью пространства имён System.Collections.Generic. Выбранная ячейка будет первой, помещаемой в эту очередь, и будет иметь расстояние 0.

	IEnumerator Search (HexCell cell) {
		for (int i = 0; i < cells.Length; i++) {
			cells[i].Distance = int.MaxValue;
		}

		WaitForSeconds delay = new WaitForSeconds(1 / 60f);
		Queue<HexCell> frontier = new Queue<HexCell>();
		cell.Distance = 0;
		frontier.Enqueue(cell);
//		for (int i = 0; i < cells.Length; i++) {
//			yield return delay;
//			cells[i].Distance =
//				cell.coordinates.DistanceTo(cells[i].coordinates);
//		}
	}

С этого момента алгоритм выполняет цикл, пока в очереди что-то есть. На каждой итерации из очереди извлекается самая передняя ячейка.

		frontier.Enqueue(cell);
		while (frontier.Count > 0) {
			yield return delay;
			HexCell current = frontier.Dequeue();
		}

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

		while (frontier.Count > 0) {
			yield return delay;
			HexCell current = frontier.Dequeue();
			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = current.GetNeighbor(d);
				if (neighbor != null) {
					neighbor.Distance = current.Distance + 1;
					frontier.Enqueue(neighbor);
				}
			}
		}

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

				if (neighbor != null && neighbor.Distance == int.MaxValue) {
					neighbor.Distance = current.Distance + 1;
					frontier.Enqueue(neighbor);
				}


Поиск в ширину.

Избегаем воду


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

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

			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = current.GetNeighbor(d);
				if (neighbor == null || neighbor.Distance != int.MaxValue) {
					continue;
				}
				neighbor.Distance = current.Distance + 1;
				frontier.Enqueue(neighbor);
			}

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

				if (neighbor == null || neighbor.Distance != int.MaxValue) {
					continue;
				}
				if (neighbor.IsUnderwater) {
					continue;
				}


Расстояния без перемещения по воде.

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

Избегаем обрывов


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

				if (neighbor.IsUnderwater) {
					continue;
				}
				if (current.GetEdgeType(neighbor) == HexEdgeType.Cliff) {
					continue;
				}


Расстояния без пересечения обрывов.

unitypackage

Затраты на перемещение


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

Быстрые дороги


Будет логично, что проще и быстрее путешествовать по дорогам, поэтому давайте сделаем пересечение рёбер с дорогами менее затратным. Так как для задания расстояния движения мы используем целочисленные значения, то затраты на перемещение по дорогам мы оставим равными 1, а затраты на пересечение других рёбер увеличим до 10. Это большая разница, позволяющая нам сразу увидеть, получаем ли мы верные результаты.

				int distance = current.Distance;
				if (current.HasRoadThroughEdge(d)) {
					distance += 1;
				}
				else {
					distance += 10;
				}
				neighbor.Distance = distance;


Дороги с неправильными расстояниями.

Сортировка границы


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

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

		List<HexCell> frontier = new List<HexCell>();
		cell.Distance = 0;
		frontier.Add(cell);
		while (frontier.Count > 0) {
			yield return delay;
			HexCell current = frontier[0];
			frontier.RemoveAt(0);
			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				…
				neighbor.Distance = distance;
				frontier.Add(neighbor);
			}
		}

Разве нельзя использовать ListPool<HexCell>?
Да, разумеется, если хотите. Я не буду использовать здесь эту оптимизацию, чтобы подчеркнуть, что это неоптимизированный код просто для демонстрации алгоритма.

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

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

				frontier.Add(neighbor);
				frontier.Sort((x, y) => x.Distance.CompareTo(y.Distance));

Как работает этот метод Sort?
Мы используем анонимный встраиваемый метод. Это укороченный способ написания методов, который оставляет задачу определения типов параметров компилятору. Приведённый выше код эквивалентен использованию явного статического метода.

				frontier.Sort(CompareDistances);
	
	…
	
	static int CompareDistances (HexCell x, HexCell y) {
		return x.Distance.CompareTo(y.Distance);
	}


Отсортированная граница, пока всё ещё неверная.

Обновление границы


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

				HexCell neighbor = current.GetNeighbor(d);
				if (neighbor == null) {
					continue;
				}
				if (neighbor.IsUnderwater) {
					continue;
				}
				if (current.GetEdgeType(neighbor) == HexEdgeType.Cliff) {
					continue;
				}
				int distance = current.Distance;
				if (current.HasRoadThroughEdge(d)) {
					distance += 1;
				}
				else {
					distance += 10;
				}
				if (neighbor.Distance == int.MaxValue) {
					neighbor.Distance = distance;
					frontier.Add(neighbor);
				}
				else if (distance < neighbor.Distance) {
					neighbor.Distance = distance;
				}
				frontier.Sort((x, y) => x.Distance.CompareTo(y.Distance));


Правильные расстояния.

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

Склоны


Мы не хотим ограничиваться отличающимися затратами только для дорог. Например, можно уменьшить затраты на пересечение плоских рёбер без дорог до 5, оставив склонам без дорог значение 10.

				HexEdgeType edgeType = current.GetEdgeType(neighbor);
				if (edgeType == HexEdgeType.Cliff) {
					continue;
				}
				int distance = current.Distance;
				if (current.HasRoadThroughEdge(d)) {
					distance += 1;
				}
				else {
					distance += edgeType == HexEdgeType.Flat ? 5 : 10;
				}


Для преодоления склонов нужно приложить больше труда, а дороги всегда быстры.

Объекты рельефа


Мы можем добавить затраты в случае присутствия объектов рельефа. Например, во многих играх по лесам перемещаться сложнее. В данном случае мы просто прибавим к расстоянию все уровни объектов. И здесь снова дорога всё ускоряет.

				if (current.HasRoadThroughEdge(d)) {
					distance += 1;
				}
				else {
					distance += edgeType == HexEdgeType.Flat ? 5 : 10;
					distance += neighbor.UrbanLevel + neighbor.FarmLevel +
						neighbor.PlantLevel;
				}


Объекты замедляют движение, если нет дороги.

Стены


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

				if (current.HasRoadThroughEdge(d)) {
					distance += 1;
				}
				else if (current.Walled != neighbor.Walled) {
					continue;
				}
				else {
					distance += edgeType == HexEdgeType.Flat ? 5 : 10;
					distance += neighbor.UrbanLevel + neighbor.FarmLevel +
						neighbor.PlantLevel;
				}


Стены нас не пропускают, нужно искать ворота.

unitypackage
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+13
Comments1

Articles