[Перевод] Карты из шестиугольников в Unity: части 1-3

image


От переводчика: эта статья — первая из подробной (27 частей) серии туториалов о создании карт из шестиугольников. Вот, что должно получиться в самом конце туториалов.

Оглавление


  • Преобразуем квадраты в шестиугольники.
  • Триангулируем сетку из шестиугольников.
  • Работаем с кубическими координатами.
  • Взаимодействуем с ячейками сетки.
  • Создаём внутриигровой редактор.


Этот туториал является началом серии о картах из шестиугольников. Сетки из шестиугольников используются во многих играх, особенно в стратегиях, в том числе в Age of Wonders 3, Civilization 5 и Endless Legend. Мы начнём с основ, будем постепенно добавлять новые возможности и в результате создадим сложный рельеф на основе шестиугольников.
В этом туториале предполагается, что вы уже изучили серию Mesh Basics, которая начинается с Procedural Grid. Она была создана на Unity 5.3.1. В серии используются несколько версий Unity. Последняя часть сделана на Unity 2017.3.0p3.

54e065633ba41c99214f720278b68336.png


Простая карта из шестиугольников.

О шестиугольниках


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

48fd4f9b87d5aa2381d9b03dbae704ae.png


Квадрат и его соседи.

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

Каково расстояние между центрами соседних квадратных ячеек сетки? Если длина ребра равна 1, то для горизонтальных и вертикальных соседей ответ равен 1. Но для диагональных соседей ответ равен √2.

Различие между двумя видами соседей приводит к сложностям. Если мы используем дискретное движение, то как воспринимать перемещение по диагонали? Разрешать ли его вообще? Как сделать внешний вид более органичным? В разных играх используются различные подходы со своими преимуществами и недостатками. Один из подходов — не использовать квадратную сетку вообще, а вместо неё применять шестиугольники.

77247a9a1784bdbe1b736672460b95bf.png


Шестиугольник и его соседи.

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

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

cdba2f82fb30358f0d2ca31c95f133d5.png


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

Также существует и внутренний радиус, который является расстоянием от центра до каждого из рёбер. Этот параметр важен, потому что расстояние между центрами соседей равно этому значению, умноженному на два. Внутренний радиус равен $\frac{\sqrt{3}}{2}$ от внешнего радиуса, то есть в нашем случае $5 \sqrt{3}$. Давайте для удобства поместим эти параметры в статический класс.

using UnityEngine;

public static class HexMetrics {

        public const float outerRadius = 10f;

        public const float innerRadius = outerRadius * 0.866025404f;
}


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

Поэтому для длины ребра $e$ внутренний радиус равен $\sqrt{e^2 - (e/2)^2} = \sqrt{3e^2/4} = e \sqrt{3}/2 \approx 0.886e$.


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

5b20eb95ac844ed93a117b7d37f90e11.png


Возможные ориентации.

  public static Vector3[] corners = {
                new Vector3(0f, 0f, outerRadius),
                new Vector3(innerRadius, 0f, 0.5f * outerRadius),
                new Vector3(innerRadius, 0f, -0.5f * outerRadius),
                new Vector3(0f, 0f, -outerRadius),
                new Vector3(-innerRadius, 0f, -0.5f * outerRadius),
                new Vector3(-innerRadius, 0f, 0.5f * outerRadius)
        };


unitypackage

Построение сетки


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

using UnityEngine;

public class HexCell : MonoBehaviour {
}


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

b61f1f3ab3210349434c2aff8c9ac374.png


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

Теперь займёмся сеткой. Создадим простой компонент с общими переменными ширины, высоты и префаба ячейки. Затем добавим в сцену игровой объект с этим компонентом.

using UnityEngine;

public class HexGrid : MonoBehaviour {

        public int width = 6;
        public int height = 6;

        public HexCell cellPrefab;

}


cad74912e6a951918b4a3d0f4b9bb6bb.png


Объект сетки из шестиугольников.

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

Поскольку плоскости по умолчанию имеют размер 10 на 10 единиц, то сместим каждую ячейку на эту величину.

  HexCell[] cells;

        void Awake () {
                cells = new HexCell[height * width];

                for (int z = 0, i = 0; z < height; z++) {
                        for (int x = 0; x < width; x++) {
                                CreateCell(x, z, i++);
                        }
                }
        }
        
        void CreateCell (int x, int z, int i) {
                Vector3 position;
                position.x = x * 10f;
                position.y = 0f;
                position.z = z * 10f;

                HexCell cell = cells[i] = Instantiate(cellPrefab);
                cell.transform.SetParent(transform, false);
                cell.transform.localPosition = position;
        }


d11560321e63ac581846776aa5b658b6.png


Квадратная сетка из плоскостей.

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

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


Добавим в сцену canvas, выбрав GameObject / UI / Canvas, и сделаем его дочерним элементом нашего объекта сетки. Так как этот canvas нужен только для информации, удалим его компонент raycaster. Также можно удалить объект event system, который автоматически был добавлен в сцену, потому что пока он нам не потребуется.

Выберите для Render Mode значение World Space и поверните на 90 градусов по оси X, чтобы canvas наложился на сетку. Задайте для pivot и для позиции значение zero. Придайте ему небольшое вертикальное смещение, чтобы его содержимое находилось наверху. Ширина и высота нам не важны, потому что мы располагаем содержимое самостоятельно. Мы можем присвоить значение 0, чтобы избавиться от большого прямоугольника в окне сцены.

В качестве финального штриха увеличим Dynamic Pixels Per Unit до 10. Так мы гарантируем, что текстовые объекты будут использовать достаточное разрешение текстур.

d37d5e34e417e5d703d2915ef2e6ad19.png


374c37273c1e8e61befc30543efca5b2.png


Canvas для координат сетки шестиугольников.

Для отображения координат создадим объект Text (GameObject / UI / Text) и превратим его в префаб. Отцентрируйте его anchors и pivot, задайте размер 5 на 15. Текст тоже должен быть горизонтально и вертикально выровнен по центру. Зададим размер шрифта 4. Наконец, мы не хотим использовать текст по умолчанию и не будем использовать Rich Text. Также нам не важно, включен ли Raycast Target, потому что для нашего canvas он всё равно не понадобится.

f15c09d55444941d69d563940806340f.png


d6d0dfab20e21276e48a2ca5bdc36503.png


Префаб метки ячейки.

Теперь нам нужно сообщить сетке о canvas и префабе. Добавим в начало её скрипта using UnityEngine.UI;, чтобы удобно получить доступ к типу UnityEngine.UI.Text. Для префаба метки нужна общая переменная, а canvas можно найти вызовом GetComponentInChildren.

  public Text cellLabelPrefab;

        Canvas gridCanvas;

        void Awake () {
                gridCanvas = GetComponentInChildren();
                
                …
        }


faeb3059923e68e4b072a5b401086072.png


Соединение префаба метки.

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

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

                Text label = Instantiate(cellLabelPrefab);
                label.rectTransform.SetParent(gridCanvas.transform, false);
                label.rectTransform.anchoredPosition =
                        new Vector2(position.x, position.z);
                label.text = x.ToString() + "\n" + z.ToString();
        }


0e81e29b0fd80afb2f7056a5b8ed829d.png


Отображение координат.

Позиции шестиугольников


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

6e4269f40ead27d2ddb0dd75a1154d99.png


Геометрия соседних шестиугольников.

          position.x = x * (HexMetrics.innerRadius * 2f);
                position.y = 0f;
                position.z = z * (HexMetrics.outerRadius * 1.5f);


5cad03034d4aacacf5ccd49dccf0af3a.png


Применяем расстояния между шестиугольниками без смещений.

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

          position.x = (x + z * 0.5f) * (HexMetrics.innerRadius * 2f);


75ce06f65db02737bddf223d45f6b8f3.png


Правильное размещение шестиугольников создаёт сетку в виде ромба.

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

          position.x = (x + z * 0.5f - z / 2) * (HexMetrics.innerRadius * 2f);


51942930aa48daab518847429e4c8b74.png


Расположение шестиугольников в прямоугольной области.

unitypackage

Рендеринг шестиугольников


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

48f32a214e6ef61f580a0147a5f310da.png


Плоскостей больше нет.

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

Создайте новый компонент HexMesh, который займётся нашим мешем. Для него потребуются mesh filter и renderer, у него есть меш и списки для вершин и треугольников.

using UnityEngine;
using System.Collections.Generic;

[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public class HexMesh : MonoBehaviour {

        Mesh hexMesh;
        List vertices;
        List triangles;

        void Awake () {
                GetComponent().mesh = hexMesh = new Mesh();
                hexMesh.name = "Hex Mesh";
                vertices = new List();
                triangles = new List();
        }
}


Создадим для нашей сетки новый дочерний объект с этим компонентом. Он автоматически получит mesh renderer, но ему не будет назначен материал. Поэтому добавим к нему материал по умолчанию.

f2a8312ab61274a7563aeeb7860c3609.png


8abbf17103e93485e696daf3a94bb277.png


Объект Hex mesh.

Теперь HexGrid сможет получить его меш шестиугольников таким же образом, как он находил canvas.

  HexMesh hexMesh;

        void Awake () {
                gridCanvas = GetComponentInChildren();
                hexMesh = GetComponentInChildren();
                
                …
        }


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

  void Start () {
                hexMesh.Triangulate(cells);
        }


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

  public void Triangulate (HexCell[] cells) {
                hexMesh.Clear();
                vertices.Clear();
                triangles.Clear();
                for (int i = 0; i < cells.Length; i++) {
                        Triangulate(cells[i]);
                }
                hexMesh.vertices = vertices.ToArray();
                hexMesh.triangles = triangles.ToArray();
                hexMesh.RecalculateNormals();
        }
        
        void Triangulate (HexCell cell) {
        }


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

  void AddTriangle (Vector3 v1, Vector3 v2, Vector3 v3) {
                int vertexIndex = vertices.Count;
                vertices.Add(v1);
                vertices.Add(v2);
                vertices.Add(v3);
                triangles.Add(vertexIndex);
                triangles.Add(vertexIndex + 1);
                triangles.Add(vertexIndex + 2);
        }


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

  void Triangulate (HexCell cell) {
                Vector3 center = cell.transform.localPosition;
                AddTriangle(
                        center,
                        center + HexMetrics.corners[0],
                        center + HexMetrics.corners[1]
                );
        }


860d2bca3ec0d1c95bd66082735021f7.png


Первый треугольник каждой ячейки.

Это сработало, поэтому давайте обойдём в цикле все шесть треугольников.

          Vector3 center = cell.transform.localPosition;
                for (int i = 0; i < 6; i++) {
                        AddTriangle(
                                center,
                                center + HexMetrics.corners[i],
                                center + HexMetrics.corners[i + 1]
                        );
                }


Можно ли сделать вершины общими?

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


К сожалению, этот процесс приведёт к IndexOutOfRangeException. Так происходит потому, что последний треугольник пытается получить седьмой угол, которого не существует. Разумеется, он должен вернуться назад и использовать в качестве последней вершины первого угла. Или же мы можем дублировать первый угол в HexMetrics.corners, чтобы не выходить за границы.

  public static Vector3[] corners = {
                new Vector3(0f, 0f, outerRadius),
                new Vector3(innerRadius, 0f, 0.5f * outerRadius),
                new Vector3(innerRadius, 0f, -0.5f * outerRadius),
                new Vector3(0f, 0f, -outerRadius),
                new Vector3(-innerRadius, 0f, -0.5f * outerRadius),
                new Vector3(-innerRadius, 0f, 0.5f * outerRadius),
                new Vector3(0f, 0f, outerRadius)
        };


c8d277e139df24953e9b381fabca2a42.png


Шестиугольники полностью.

unitypackage

Шестиугольные координаты


Давайте снова взглянем на координаты ячеек, теперь в контексте сетки шестиугольников. Координата Z выглядит нормально, а координата X двигается зигзагами. Это побочный эффект смещения строк для покрытия прямоугольной области.

73f745502f1d8d20245bd22edc99a3ce.png


Смещённые координаты с выделенными нулевыми строками.

При работе с шестиугольниками такие смещённые координаты обрабатывать непросто. Давайте добавим struct HexCoordinates, которую можно будет использовать для преобразования в другую систему координат. Сделаем её сериализуемой, чтобы Unity мог хранить её и она переживала рекомпиляцию в режиме Play. Также сделаем эти координаты immutable, воспользовавшись свойствами public readonly.

using UnityEngine;

[System.Serializable]
public struct HexCoordinates {

        public int X { get; private set; }

        public int Z { get; private set; }

        public HexCoordinates (int x, int z) {
                X = x;
                Z = z;
        }
}


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

  public static HexCoordinates FromOffsetCoordinates (int x, int z) {
                return new HexCoordinates(x, z);
        }
}


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

  public override string ToString () {
                return "(" + X.ToString() + ", " + Z.ToString() + ")";
        }

        public string ToStringOnSeparateLines () {
                return X.ToString() + "\n" + Z.ToString();
        }


Теперь мы можем передать множество координат нашему компоненту HexCell.

public class HexCell : MonoBehaviour {

        public HexCoordinates coordinates;
}


Изменим HexGrid.CreateCell так, чтобы он мог воспользоваться новыми координатами.

          HexCell cell = cells[i] = Instantiate(cellPrefab);
                cell.transform.SetParent(transform, false);
                cell.transform.localPosition = position;
                cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z);
                
                Text label = Instantiate(cellLabelPrefab);
                label.rectTransform.SetParent(gridCanvas.transform, false);
                label.rectTransform.anchoredPosition =
                        new Vector2(position.x, position.z);
                label.text = cell.coordinates.ToStringOnSeparateLines();


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

  public static HexCoordinates FromOffsetCoordinates (int x, int z) {
                return new HexCoordinates(x - z / 2, z);
        }


4331bf29dbf016e466a3636b5cd90311.png


4ab03d7afdef480fe4657ae86901fd1a.png


Осевые координаты.

Эта двухмерная система координат позволяет нам последовательно описывать движение смещения в четырёх направлениях. Однако особого внимания по-прежнему требуют два оставшихся направления. Это даёт нам понять, что существует третье измерение. И в самом деле, еслы бы мы горизонтально перевернули измерение X, то получили бы недостающее измерение Y.

32c39b9b7705099d7bcb8e525aa6fe48.png


Появляется измерение Y.

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

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

  public int Y {
                get {
                        return -X - Z;
                }
        }

        public override string ToString () {
                return "(" +
                        X.ToString() + ", " + Y.ToString() + ", " + Z.ToString() + ")";
        }

        public string ToStringOnSeparateLines () {
                return X.ToString() + "\n" + Y.ToString() + "\n" + Z.ToString();
        }


c36ad9d322193e811fd6c59b5d76249a.png


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

Координаты в инспекторе


Выберите в режиме Play одну из ячеек сетки. Оказывается, что инспектор не отображает её координаты, показывается только метка префикса HexCell.coordinates.

d02a1da0e906223abcb9aeeb46ce7477.png


Инспектор не отображает координаты.

Хотя большой проблемы в этом нет, было бы здорово отображать координаты. Unity не показывает координаты, потому что они не помечены как сериализуемые поля. Чтобы отобразить их, нужно явным образом задать сериализируемые поля для X и Z.

  [SerializeField]
        private int x, z;

        public int X {
                get {
                        return x;
                }
        }

        public int Z {
                get {
                        return z;
                }
        }

        public HexCoordinates (int x, int z) {
                this.x = x;
                this.z = z;
        }


5ea264fffa724e10205c41c8191ad78b.png


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

Мы можем сделать лучше: определить собственный property drawer для типа HexCoordinates. Создадим скрипт HexCoordinatesDrawer и вставим его в папку Editor, потому что это скрипт только для редактора.

Класс должен расширять UnityEditor.PropertyDrawer и ему требуется атрибут UnityEditor.CustomPropertyDrawer, чтобы ассоциировать его с подходящим типом.

using UnityEngine;
using UnityEditor;

[CustomPropertyDrawer(typeof(HexCoordinates))]
public class HexCoordinatesDrawer : PropertyDrawer {
}


Property drawers отображают своё содержимое с помощью метода OnGUI. Этот метод позволил отрисовывать внутри экранного прямоугольника сериализуемые данные свойства и метку поля, к которой они принадлежат.

  public override void OnGUI (
                Rect position, SerializedProperty property, GUIContent label
        ) {
        }


Извлечём из свойства значения x и z, а затем используем их для создания нового множества координат. Затем отрисуем в выбранной позиции метку GUI с помощью нашего метода HexCoordinates.ToString.

  public override void OnGUI (
                Rect position, SerializedProperty property, GUIContent label
        ) {
                HexCoordinates coordinates = new HexCoordinates(
                        property.FindPropertyRelative("x").intValue,
                        property.FindPropertyRelative("z").intValue
                );
                
                GUI.Label(position, coordinates.ToString());
        }


1d6d0f852f744b6b5080a54478a3f4bf.png


Координаты без метки префикса.

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

          position = EditorGUI.PrefixLabel(position, label);
                GUI.Label(position, coordinates.ToString());


a6cd348569c891e063c6fc515d075d04.png


Координаты с меткой.

unitypackage

Касаемся ячеек


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

Чтобы коснуться ячейки, можно испускать в сцену лучи из позиции курсора мыши. Мы можем использовать тот же подход, что и в туториале Mesh Deformation.

  void Update () {
                if (Input.GetMouseButton(0)) {
                        HandleInput();
                }
        }

        void HandleInput () {
                Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition);
                RaycastHit hit;
                if (Physics.Raycast(inputRay, out hit)) {
                        TouchCell(hit.point);
                }
        }
        
        void TouchCell (Vector3 position) {
                position = transform.InverseTransformPoint(position);
                Debug.Log("touched at " + position);
        }


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

  MeshCollider meshCollider;

        void Awake () {
                GetComponent().mesh = hexMesh = new Mesh();
                meshCollider = gameObject.AddComponent();
                …
        }


После завершения триангуляции назначим меш коллайдеру.

  public void Triangulate (HexCell[] cells) {
                …
                meshCollider.sharedMesh = hexMesh;
        }


Разве мы не можем просто использовать box collider?

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


Теперь мы можем касаться сетки! Но какой ячейки мы касаемся? Чтобы узнать это, нам нужно преобразовать позицию касания в координаты шестиугольников. Это работа для HexCoordinates, поэтому объявим, что у него есть статический метод FromPosition.

  public void TouchCell (Vector3 position) {
                position = transform.InverseTransformPoint(position);
                HexCoordinates coordinates = HexCoordinates.FromPosition(position);
                Debug.Log("touched at " + coordinates.ToString());
        }


Как этот метод будет определять, какая координата принадлежит позиции? Мы можем начать с того, что разделим x на горизонтальную ширину шестиугольника. А поскольку координата Y является зеркальным отражением координаты X, отрицательное значение x даёт нам y.

  public static HexCoordinates FromPosition (Vector3 position) {
                float x = position.x / (HexMetrics.innerRadius * 2f);
                float y = -x;
        }


Разумеется, это давало бы нам верные координаты, если бы Z была равна нулю. Мы снова должны выполнять сдвиг при движении вдоль Z. Через каждые две строки мы должны сдвигаться влево на всю единицу.

          float offset = position.z / (HexMetrics.outerRadius * 3f);
                x -= offset;
                y -= offset;


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

          int iX = Mathf.RoundToInt(x);
                int iY = Mathf.RoundToInt(y);
                int iZ = Mathf.RoundToInt(-x -y);

                return new HexCoordinates(iX, iZ);


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

          if (iX + iY + iZ != 0) {
                        Debug.LogWarning("rounding error!");
                }
                
                return new HexCoordinates(iX, iZ);


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

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

          if (iX + iY + iZ != 0) {
                        float dX = Mathf.Abs(x - iX);
                        float dY = Mathf.Abs(y - iY);
                        float dZ = Mathf.Abs(-x -y - iZ);

                        if (dX > dY && dX > dZ) {
                                iX = -iY - iZ;
                        }
                        else if (dZ > dY) {
                                iZ = -iX - iY;
                        }
                }


Раскраска шестиугольников


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

  public Color defaultColor = Color.white;
        public Color touchedColor = Color.magenta;


33d89ad0522ace10075800607aac71b2.png


Выбор цвета ячеек.

Добавим к HexCell общее поле цвета.

public class HexCell : MonoBehaviour {

        public HexCoordinates coordinates;

        public Color color;
}


Назначим ему в HexGrid.CreateCell цвет по умолчанию.

  void CreateCell (int x, int z, int i) {
                …
                cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z);
                cell.color = defaultColor;
                …
        }


Также нам нужно добавить к HexMesh информацию о цвете.

  List colors;

        void Awake () {
                …
                vertices = new List();
                colors = new List();
                …
        }

        public void Triangulate (HexCell[] cells) {
                hexMesh.Clear();
                vertices.Clear();
                colors.Clear();
                …
                hexMesh.vertices = vertices.ToArray();
                hexMesh.colors = colors.ToArray();
                …
        }


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

  void Triangulate (HexCell cell) {
                Vector3 center = cell.transform.localPosition;
                for (int i = 0; i < 6; i++) {
                        AddTriangle(
                                center,
                                center + HexMetrics.corners[i],
                                center + HexMetrics.corners[i + 1]
                        );
                        AddTriangleColor(cell.color);
                }
        }

        void AddTriangleColor (Color color) {
                colors.Add(color);
                colors.Add(color);
                colors.Add(color);
        }


Вернёмся к HexGrid.TouchCell. Сначала преобразуем координаты ячейки в соответствующий индекс массива. Для квадратной сетки это было бы просто X плюс Z умножить на ширину, но в нашем случае придётся прибавить ещё и смещение в половину Z. Затем мы берём ячейку, меняем её цвет и снова триангулируем меш.

Действительно ли нам нужно заново триангулировать весь меш?

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

  public void TouchCell (Vector3 position) {
                position = transform.InverseTransformPoint(position);
                HexCoordinates coordinates = HexCoordinates.FromPosition(position);
                int index = coordinates.X + coordinates.Z * width + coordinates.Z / 2;
                HexCell cell = cells[index];
                cell.color = touchedColor;
                hexMesh.Triangulate(cells);
        }


Хотя теперь мы и можем раскрашивать ячейки, визуальных изменений пока не видно. Так получилось потому, что шейдер по умолчанию не использует цвета вершин. Нам придётся написать собственный. Создадим новый default shader (Assets / Create / Shader / Default Surface Shader). В него нужно внести всего два изменения. Во-первых, добавим к его входной struct данные цвета. Во-вторых, умножим albedo на этот цвет. Нас интересуют только каналы RGB, потому что материал непрозрачен.

Shader "Custom/VertexColors" {
        Properties {
                _Color ("Color", Color) = (1,1,1,1)
                _MainTex ("Albedo (RGB)", 2D) = "white" {}
                _Glossiness ("Smoothness", Range(0,1)) = 0.5
                _Metallic ("Metallic", Range(0,1)) = 0.0
        }
        SubShader {
                Tags { "RenderType"="Opaque" }
                LOD 200
                
                CGPROGRAM
                #pragma surface surf Standard fullforwardshadows
                #pragma target 3.0

                sampler2D _MainTex;

                struct Input {
                        float2 uv_MainTex;
                        float4 color : COLOR;
                };

                half _Glossiness;
                half _Metallic;
                fixed4 _Color;

                void surf (Input IN, inout SurfaceOutputStandard o) {
                        fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
                        o.Albedo = c.rgb * IN.color;
                        o.Metallic = _Metallic;
                        o.Smoothness = _Glossiness;
                        o.Alpha = c.a;
                }
                ENDCG
        }
        FallBack "Diffuse"
}


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

223efebe1bdda83c9b383dac32197c6e.png


Раскрашенные ячейки.

У меня возникают странные артефакты теней!

В некоторых версиях Unity создаваемые пользователем шейдеры могут вызывать проблемы с тенями. Если у вас возникают некрасивый дизеринг или полосы на тенях, то это значит, что присутствует Z-конфликт. Для решения этой проблемы достаточно изменить отклонение тени от источника направленного освещения.


unitypackage

Редактор карты


Теперь, когда мы знаем, как изменять цвета, давайте создадим простой внутриигровой редактор. Этот функционал не относится к возможностями HexGrid, поэтому превратим TouchCell в общий метод с дополнительным параметром цвета. Также удалим поле touchedColor.

public void ColorCell (Vector3 position, Color color) {
                position = transform.InverseTransformPoint(position);
                HexCoordinates coordinates = HexCoordinates.FromPosition(position);
                int index = coordinates.X + coordinates.Z * width + coordinates.Z / 2;
                HexCell cell = cells[index];
                cell.color = color;
                hexMesh.Triangulate(cells);
        }


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

using UnityEngine;

public class HexMapEditor : MonoBehaviour {

        public Color[] colors;

        public HexGrid hexGrid;

        private Color activeColor;

        void Awake () {
                SelectColor(0);
        }

        void Update () {
                if (Input.GetMouseButton(0)) {
                        HandleInput();
                }
        }

        void HandleInput () {
                Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition);
                RaycastHit hit;
                if (Physics.Raycast(inputRay, out hi
    
            

© Habrahabr.ru