После этой статьи у вас будет готовая математика гексагональной сетки для Unity: структура Hex с осевыми координатами, соседи и расстояние в одну строку, перевод из мировых координат в клетку и обратно, линия между гексами, зона радиуса N, кольца, повороты на 60°, дальность хода с препятствиями и переход к координатам гексагонального Tilemap. Весь код — обычный C# без зависимостей, его можно положить в проект и пользоваться сразу.
Почему не хватает обычного массива
Квадратную сетку все пишут одинаково: int x, int y, четыре или восемь соседей, расстояние — модуль разности или максимум. С гексами первый порыв такой же: взять двумерный массив и сдвигать каждый второй ряд на полклетки. Именно так хранит клетки гексагональный Tilemap в Unity, и именно здесь начинаются проблемы.
В сдвинутых («offset») координатах набор соседей зависит от чётности ряда. У клетки в чётном ряду сосед сверху-справа — это (x, y + 1), а в нечётном — (x + 1, y + 1). Расстояние между клетками простой формулой не считается, поворот карты на 60° превращается в таблицу частных случаев, а линия между двумя точками — в набор костылей. Каждый новый алгоритм приходится писать дважды: для чётных и нечётных рядов.
Решение давно известно и хорошо описано у Амита Патела в статье Hexagonal Grids на Red Blob Games: вся логика пишется в кубических или осевых координатах, а offset-координаты используются только на границе — там, где нужно поставить тайл в Tilemap или сохранить карту в прямоугольный массив.
Три системы координат
Offset: удобно хранить, неудобно считать
Обычные столбец и ряд, где каждый второй ряд (или столбец) сдвинут на полклетки. В документации Unity про гексагональные Tilemap сказано прямо: при ориентации Point Top (вершиной вверх) каждый нечётный ряд сдвинут вправо на половину ширины клетки, при Flat Top (плоской стороной вверх) каждый нечётный столбец сдвинут вверх на половину высоты. В терминах Red Blob Games первая схема называется odd-r, вторая — odd-q.
Кубические: три оси и одно правило
Гексагональную сетку можно представить как срез трёхмерной кубической решётки плоскостью q + r + s = 0. У каждой клетки три координаты, и их сумма всегда равна нулю. Шесть направлений — это шесть способов прибавить единицу к одной координате и отнять от другой. Благодаря этому почти все операции сводятся к обычной векторной арифметике: соседи — сложение, расстояние — модуль, поворот — перестановка координат со сменой знака.
Осевые: кубические без лишнего
Раз s = -q - r, хранить третью координату незачем. Осевые координаты — это (q, r), а s вычисляется на лету. Это ровно то же самое, что кубические, только компактнее: два int вместо трёх. Дальше в статье хранить будем две координаты, а считать — через три, когда это удобнее.
Структура Hex
Клетка — это значение, а не объект: её сравнивают, складывают, кладут ключом в словарь. Поэтому readonly struct с реализацией IEquatable, чтобы Dictionary и HashSet не упаковывали её при сравнении.
using System;
using UnityEngine;
[Serializable]
public readonly partial struct Hex : IEquatable<Hex>
{
public readonly int Q;
public readonly int R;
public int S => -Q - R;
public Hex(int q, int r)
{
Q = q;
R = r;
}
public static readonly Hex Zero = new Hex(0, 0);
public static Hex operator +(Hex a, Hex b) => new Hex(a.Q + b.Q, a.R + b.R);
public static Hex operator -(Hex a, Hex b) => new Hex(a.Q - b.Q, a.R - b.R);
public static Hex operator *(Hex a, int k) => new Hex(a.Q * k, a.R * k);
public static bool operator ==(Hex a, Hex b) => a.Q == b.Q && a.R == b.R;
public static bool operator !=(Hex a, Hex b) => !(a == b);
public bool Equals(Hex other) => this == other;
public override bool Equals(object obj) => obj is Hex other && this == other;
public override int GetHashCode() => (Q * 397) ^ R;
public override string ToString() => $"({Q}, {R}, {S})";
}
Атрибут [Serializable] здесь скорее для порядка: сериализатор Unity не работает с readonly-полями, так что для показа в инспекторе понадобится отдельная обёртка с обычными полями. Для логики это не важно, а неизменяемость избавляет от целого класса ошибок, когда клетку случайно поменяли внутри словаря.
Направления и соседи
Договоримся о раскладке: ориентация Point Top, ось Y мира смотрит вверх, координата q растёт вправо, а r — вверх-вправо. Тогда шесть направлений, перечисленные против часовой стрелки начиная с востока, выглядят так:
public static class HexDirections
{
// Против часовой стрелки, начиная с востока (Point Top, Y вверх).
public static readonly Hex[] All =
{
new Hex( 1, 0), // восток
new Hex( 0, 1), // северо-восток
new Hex(-1, 1), // северо-запад
new Hex(-1, 0), // запад
new Hex( 0, -1), // юго-запад
new Hex( 1, -1), // юго-восток
};
}
Все методы ниже добавляются прямо в структуру Hex. Если хотите разнести их по файлам, объявите её как readonly partial struct. Методы соседства получаются тривиальными:
public Hex Neighbor(int direction) => this + HexDirections.All[((direction % 6) + 6) % 6];
public void GetNeighbors(Hex[] buffer)
{
for (int i = 0; i < 6; i++)
buffer[i] = this + HexDirections.All[i];
}
Порядок направлений выбран не случайно: соседний индекс — это соседнее направление. direction + 1 поворачивает на 60° против часовой стрелки, direction + 3 даёт противоположную сторону. Это пригодится для колец, стен между клетками и автотайлинга по соседям. Метод с буфером вместо возврата нового массива нужен, чтобы поиск пути не выделял память на каждой клетке.
Расстояние
В кубических координатах шаг к соседу меняет две координаты на единицу. Отсюда расстояние в шагах — это половина суммы модулей разностей или, что то же самое, максимум из трёх модулей:
public static int Distance(Hex a, Hex b)
{
Hex d = a - b;
return (Math.Abs(d.Q) + Math.Abs(d.R) + Math.Abs(d.S)) / 2;
}
public int Length => (Math.Abs(Q) + Math.Abs(R) + Math.Abs(S)) / 2;
Никаких ветвлений по чётности. Эта же функция — точная и допустимая эвристика для A*: она никогда не переоценивает число шагов на сетке без препятствий, поэтому A* с ней находит кратчайший путь. Если у вас уже есть A* для квадратной сетки, достаточно заменить в нём список соседей и эвристику.
Из клетки в мир и обратно
Центр клетки
Пусть width — расстояние между центрами соседних клеток в одном ряду, а height — полная высота гекса от верхней вершины до нижней. В раскладке Point Top соседние ряды сдвинуты на половину ширины и стоят друг от друга на три четверти высоты. Отсюда:
[Serializable]
public struct HexLayout
{
public float Width; // расстояние между центрами в ряду
public float Height; // от вершины до вершины
public Vector2 Origin;
public static HexLayout Regular(float height) => new HexLayout
{
Width = height * 0.8660254f, // sqrt(3) / 2
Height = height,
Origin = Vector2.zero,
};
public Vector2 ToWorld(Hex h)
{
float x = Width * (h.Q + h.R * 0.5f);
float y = Height * 0.75f * h.R;
return Origin + new Vector2(x, y);
}
}
Для правильного шестиугольника ширина равна высоте, умноженной на √3/2, примерно 0,866. Учтите, что в Unity у Grid с раскладкой Hexagon по умолчанию размер клетки 1×1, то есть гексы слегка растянуты по горизонтали. Если вам нужны правильные шестиугольники, выставьте в Grid cellSize.x ≈ 0.866 при cellSize.y = 1. Формула выше работает при любом соотношении сторон: растянутый гекс — это правильный, отмасштабированный по одной оси, и вся математика сохраняется.
Для 3D-игры на плоскости XZ ничего не меняется, кроме того, куда класть результат: new Vector3(p.x, 0f, p.y).
Клетка под курсором
Обратное преобразование — та же линейная формула, решённая относительно q и r. Результат получается дробным, и его нужно округлить до ближайшей клетки. Округлять q и r по отдельности нельзя: на стыке трёх гексов такая точка уедет не туда. Правильный способ — округлить все три кубические координаты и затем исправить ту, у которой ошибка округления больше всего, чтобы сумма снова стала нулём:
public Hex FromWorld(Vector2 world)
{
Vector2 p = world - Origin;
float r = p.y / (Height * 0.75f);
float q = p.x / Width - r * 0.5f;
return Hex.Round(q, r);
}
// В структуре Hex:
public static Hex Round(float q, float r)
{
float s = -q - r;
int rq = Mathf.RoundToInt(q);
int rr = Mathf.RoundToInt(r);
int rs = Mathf.RoundToInt(s);
float dq = Mathf.Abs(rq - q);
float dr = Mathf.Abs(rr - r);
float ds = Mathf.Abs(rs - s);
if (dq > dr && dq > ds)
rq = -rr - rs;
else if (dr > ds)
rr = -rq - rs;
// иначе неверна только s, а её мы не храним
return new Hex(rq, rr);
}
Использование с мышью и ортографической камерой:
Vector3 mouse = Input.mousePosition; // или Mouse.current.position.ReadValue() в Input System
Vector3 world = cam.ScreenToWorldPoint(new Vector3(mouse.x, mouse.y, -cam.transform.position.z));
Hex hovered = layout.FromWorld(world);
Для перспективной камеры в 3D луч из камеры пересекают с плоскостью земли через Plane.Raycast и передают в FromWorld координаты точки пересечения (x, z).
Связь с гексагональным Tilemap
Если карта рисуется через Tilemap, ему нужны offset-координаты в Vector3Int. Для Point Top в Unity, где сдвинуты нечётные ряды, ряд совпадает с r, а столбец сдвигается на половину номера ряда с округлением вниз:
public static class HexOffset
{
// Point Top, нечётные ряды сдвинуты вправо (odd-r).
public static Vector3Int ToCell(Hex h)
{
int col = h.Q + (h.R >> 1);
int row = h.R;
return new Vector3Int(col, row, 0);
}
public static Hex FromCell(Vector3Int cell)
{
int q = cell.x - (cell.y >> 1);
int r = cell.y;
return new Hex(q, r);
}
}
Обратите внимание на >> 1 вместо / 2. Целочисленное деление в C# округляет к нулю, и для отрицательных рядов -1 / 2 даёт 0 вместо нужного -1. Арифметический сдвиг вправо округляет вниз, и формула остаётся верной по обе стороны от нулевого ряда. Это самая частая ошибка в гексагональном коде: всё работает, пока карта лежит в положительной четверти, и ломается, как только кто-то сдвигает начало координат.
Дальше Tilemap и своя логика живут вместе:
public void Paint(Tilemap tilemap, TileBase tile, IEnumerable<Hex> cells)
{
foreach (Hex h in cells)
tilemap.SetTile(HexOffset.ToCell(h), tile);
}
public Hex CellUnderPoint(Tilemap tilemap, Vector3 world)
{
Vector3Int cell = tilemap.WorldToCell(world);
return HexOffset.FromCell(cell);
}
Здесь округление делает сам Unity через GridLayout.WorldToCell, а мы только переводим результат в осевые координаты. Свой FromWorld нужен там, где Tilemap нет: в 3D-карте из мешей, на сервере, в тестах.
Чтобы убедиться, что раскладки совпадают, проверьте пару клеток: tilemap.GetCellCenterWorld(HexOffset.ToCell(h)) должен совпадать с layout.ToWorld(h) с точностью до смещения Origin, если в HexLayout заданы те же размеры, что в cellSize у Grid. Для Flat Top сдвинуты не ряды, а нечётные столбцы, поэтому формулы выводятся так же, но относительно столбца, и раскладку HexLayout тоже придётся повернуть на 90°. Такую схему обязательно проверьте тем же сравнением на нескольких клетках, включая отрицательные.
Линия между двумя гексами
Линия нужна для прямой видимости, траектории снаряда, превью хода. Алгоритм простой: расстояние N в шагах, N + 1 равномерных точек на отрезке, каждая округляется до клетки.
public static void Line(Hex a, Hex b, List<Hex> result)
{
result.Clear();
int n = Distance(a, b);
if (n == 0)
{
result.Add(a);
return;
}
// Небольшой сдвиг, чтобы точки не падали ровно на границу двух клеток.
float aq = a.Q + 1e-6f, ar = a.R + 1e-6f;
float bq = b.Q + 1e-6f, br = b.R + 1e-6f;
float step = 1f / n;
for (int i = 0; i <= n; i++)
{
float t = i * step;
result.Add(Round(Mathf.Lerp(aq, bq, t), Mathf.Lerp(ar, br, t)));
}
}
Сдвиг на 1e-6 нужен, когда линия идёт ровно вдоль стороны гекса: без него точка попадает на границу, и округление в разных местах линии выбирает то одну, то другую сторону, линия получается рваной. С одинаковым сдвигом в одну сторону выбор становится последовательным. Если для прямой видимости нужно проверять обе клетки на границе, делают две линии — со сдвигом «плюс» и «минус» — и считают клетку видимой, если свободна хотя бы одна.
Проверка видимости — это обход линии до первого препятствия:
public bool HasLineOfSight(Hex from, Hex to, Func<Hex, bool> isBlocking, List<Hex> buffer)
{
Hex.Line(from, to, buffer);
for (int i = 1; i < buffer.Count - 1; i++)
if (isBlocking(buffer[i]))
return false;
return true;
}
Зона, кольцо и спираль
Все клетки в радиусе N
Взрыв, аура, область видимости. В кубических координатах «расстояние не больше N» значит, что каждая из трёх координат лежит в диапазоне от -N до N. Перебираем dq, а диапазон dr подрезаем так, чтобы s тоже не вылезла за границу:
public static void Range(Hex center, int radius, List<Hex> result)
{
result.Clear();
for (int dq = -radius; dq <= radius; dq++)
{
int rMin = Math.Max(-radius, -dq - radius);
int rMax = Math.Min(radius, -dq + radius);
for (int dr = rMin; dr <= rMax; dr++)
result.Add(new Hex(center.Q + dq, center.R + dr));
}
}
В радиусе N ровно 3N(N + 1) + 1 клеток: 7 при N = 1, 19 при N = 2, 37 при N = 3. Это удобно для юнит-теста и для заранее выделенной ёмкости списка.
Пересечение двух зон, например «клетки, куда юнит может дойти и откуда достаёт до цели», тоже считается без перебора: это пересечение трёх диапазонов по q, r и s, то есть снова шестиугольник, только неправильной формы.
Кольцо
Кольцо радиуса N — клетки ровно на расстоянии N. Начинаем с угла в направлении 0 и идём по шести сторонам. С нашим порядком направлений сторона, начинающаяся в углу i, идёт в направлении i + 2:
public static void Ring(Hex center, int radius, List<Hex> result)
{
result.Clear();
if (radius == 0)
{
result.Add(center);
return;
}
Hex h = center + HexDirections.All[0] * radius;
for (int side = 0; side < 6; side++)
{
Hex dir = HexDirections.All[(side + 2) % 6];
for (int step = 0; step < radius; step++)
{
result.Add(h);
h += dir;
}
}
}
В кольце 6N клеток. Спираль — это кольца от 0 до N подряд: получается тот же набор, что у Range, но отсортированный по удалению от центра. Это то, что нужно для поиска ближайшей свободной клетки: идём по кольцам наружу и останавливаемся на первой подходящей, не проверяя всю зону.
public static bool TryFindNearest(Hex center, int maxRadius, Func<Hex, bool> isFree,
List<Hex> buffer, out Hex found)
{
for (int radius = 0; radius <= maxRadius; radius++)
{
Ring(center, radius, buffer);
foreach (Hex h in buffer)
{
if (isFree(h))
{
found = h;
return true;
}
}
}
found = default;
return false;
}
Повороты и отражения
Поворот на 60° вокруг начала координат в кубической системе — это циклическая перестановка координат со сменой знака. Для нашей раскладки (Y вверх) направления такие:
// Против часовой стрелки: (q, r, s) -> (-r, -s, -q)
public Hex RotateLeft() => new Hex(-R, -S);
// По часовой стрелке: (q, r, s) -> (-s, -q, -r)
public Hex RotateRight() => new Hex(-S, -Q);
public Hex RotateAround(Hex pivot, int steps60)
{
Hex v = this - pivot;
int n = ((steps60 % 6) + 6) % 6;
for (int i = 0; i < n; i++)
v = v.RotateLeft();
return pivot + v;
}
Проверить легко: восток (1, 0) после RotateLeft становится (0, 1), то есть северо-востоком, — следующим направлением в нашем массиве. Если в вашей игре ось Y смотрит вниз (так часто бывает в UI-координатах), лево и право меняются местами.
Это закрывает задачи, которые на offset-сетке превращаются в мучение: поворот фигуры из нескольких гексов при строительстве, вращение шаблона атаки вслед за направлением юнита, симметричная генерация карты. Отражение устроено так же просто: это обмен двух кубических координат, например (q, r, s) -> (q, s, r).
Пример: конус атаки, заданный один раз для направления «восток» и повёрнутый под фактическое направление юнита:
static readonly Hex[] ConeEast =
{
new Hex(1, 0),
new Hex(2, -1), new Hex(2, 0), new Hex(1, 1),
};
public void GetCone(Hex origin, int facing, List<Hex> result)
{
result.Clear();
foreach (Hex offset in ConeEast)
result.Add(origin + offset.RotateAround(Hex.Zero, facing));
}
Шаблон описывается один раз, а facing — это просто индекс в массиве направлений от 0 до 5.
Хранение карты
Самый гибкий вариант — словарь. Он без лишних усилий поддерживает карты любой формы: шестиугольные, островные, с дырами.
public class HexMap<T>
{
readonly Dictionary<Hex, T> _cells = new Dictionary<Hex, T>();
public int Count => _cells.Count;
public bool Contains(Hex h) => _cells.ContainsKey(h);
public bool TryGet(Hex h, out T value) => _cells.TryGetValue(h, out value);
public void Set(Hex h, T value) => _cells[h] = value;
public IEnumerable<Hex> Cells => _cells.Keys;
public static HexMap<T> Hexagon(int radius, Func<Hex, T> factory)
{
var map = new HexMap<T>();
var buffer = new List<Hex>(3 * radius * (radius + 1) + 1);
Hex.Range(Hex.Zero, radius, buffer);
foreach (Hex h in buffer)
map.Set(h, factory(h));
return map;
}
}
Если карта прямоугольная и большая, а доступ к клеткам идёт в горячем цикле, словарь можно заменить на плоский массив с индексом по offset-координатам: index = row * width + col, где col и row получены через HexOffset.ToCell. Снаружи интерфейс остаётся тем же — методы принимают Hex, а перевод в индекс спрятан внутри. Вся игровая логика при этом не знает, как карта хранится, и замена словаря на массив ничего не ломает.
Для шестиугольной карты радиуса N есть свой плотный вариант: массив размера (2N + 1) × (2N + 1) с индексом [r + N, q + N]. Часть ячеек в углах пустует, примерно четверть, зато доступ — одна арифметическая операция без хеширования.
Дальность хода с препятствиями
Range отвечает на вопрос «что в радиусе N по прямой», но в тактической игре юнит обходит горы и реки. Клетки, достижимые не более чем за N шагов, ищутся поиском в ширину по слоям:
public static Dictionary<Hex, int> Reachable(Hex start, int maxSteps, Func<Hex, bool> isPassable)
{
var visited = new Dictionary<Hex, int> { [start] = 0 };
var frontier = new List<Hex> { start };
var next = new List<Hex>();
for (int step = 1; step <= maxSteps; step++)
{
next.Clear();
foreach (Hex h in frontier)
{
for (int dir = 0; dir < 6; dir++)
{
Hex n = h.Neighbor(dir);
if (visited.ContainsKey(n) || !isPassable(n))
continue;
visited[n] = step;
next.Add(n);
}
}
(frontier, next) = (next, frontier);
if (frontier.Count == 0)
break;
}
return visited;
}
Словарь хранит, за сколько шагов достижима каждая клетка: это сразу и подсветка зоны хода, и данные для подписи стоимости. Если у клеток разная стоимость (болото — 2 очка, дорога — 1), поиск в ширину заменяют алгоритмом Дейкстры с очередью с приоритетом. Класс PriorityQueue появился только в .NET 6, а в профиле .NET Standard 2.1, на котором работает Unity, его нет, поэтому для Дейкстры понадобится простая двоичная куча, своя или из библиотеки.
Путь до выбранной клетки восстанавливается так же, как в любом поиске в ширину: при добавлении соседа запоминайте, откуда в него пришли, во втором словаре cameFrom и идите от цели к старту.
Подводные камни
- Деление вместо сдвига. Уже упоминалось, но повторю:
r / 2для отрицательныхrломает перевод в offset. Используйтеr >> 1или явныйMathf.FloorToInt(r / 2f). - Плохой хеш.
Q ^ Rдаёт одинаковый хеш для(1, 2)и(2, 1), а также ноль для всей диагоналиq = r. На больших картах словарь деградирует. Умножение одной координаты на простое число, как в примере, решает проблему. - Округление по двум осям.
new Hex(Mathf.RoundToInt(q), Mathf.RoundToInt(r))выглядит правильно и даже почти всегда работает. Ошибки появляются в углах гексов: курсор подсвечивает соседнюю клетку. Округляйте через три координаты. - Нерегулярный размер клетки Grid. Если гексы в Tilemap выглядят приплюснутыми, дело не в спрайтах, а в
cellSizeпо умолчанию. Подгоните его под √3/2 или нарисуйте спрайты под 1×1 — главное, чтобыHexLayoutиспользовал те же числа. - Смешение раскладок. Код из статьи рассчитан на Point Top с осью Y вверх. Для Flat Top меняются формулы
ToWorldиFromWorldи перевод в offset, а массив направлений и вся кубическая логика остаются прежними. Хорошая практика — держать раскладку в одном месте (HexLayout,HexOffset) и нигде больше не трогать offset-координаты. - Числа с плавающей точкой в логике. Вся игровая логика — на
int. Дробные координаты появляются только при переводе в мир и обратно. Тогда карта детерминирована и одинаково считается на клиенте и сервере.
Как это проверить тестами
Гексагональная математика идеально подходит для Edit Mode тестов в Unity Test Framework: всё чистое, без сцены и без кадров. Минимальный набор, который ловит почти все ошибки:
[Test]
public void RoundTrip_OffsetConversion_WorksForNegativeRows()
{
for (int q = -5; q <= 5; q++)
for (int r = -5; r <= 5; r++)
{
var h = new Hex(q, r);
Assert.AreEqual(h, HexOffset.FromCell(HexOffset.ToCell(h)));
}
}
[Test]
public void Range_HasExpectedCount()
{
var list = new List<Hex>();
for (int n = 0; n <= 6; n++)
{
Hex.Range(Hex.Zero, n, list);
Assert.AreEqual(3 * n * (n + 1) + 1, list.Count);
}
}
[Test]
public void FromWorld_ReturnsSameHex_ForCellCenter()
{
var layout = HexLayout.Regular(1f);
var list = new List<Hex>();
Hex.Range(Hex.Zero, 8, list);
foreach (Hex h in list)
Assert.AreEqual(h, layout.FromWorld(layout.ToWorld(h)));
}
[Test]
public void SixLeftRotations_ReturnOriginal()
{
var h = new Hex(3, -1);
Hex v = h;
for (int i = 0; i < 6; i++)
v = v.RotateLeft();
Assert.AreEqual(h, v);
}
Полезно добавить тест, что у каждой клетки ровно шесть соседей на расстоянии 1 и что кольцо радиуса N состоит из 6N уникальных клеток на расстоянии N. Эти четыре-пять тестов работают за миллисекунды и спасают от ошибок, которые иначе всплывут только на краю карты.
Итог
Правило одно: считайте в кубических и осевых координатах, храните как удобно, а в offset переводите только на границе с Tilemap или массивом. Тогда соседи — это сложение, расстояние — одна строка, поворот — перестановка, а зона, кольцо и линия занимают по десятку строк без единого условия на чётность ряда. Структура Hex плюс HexLayout и HexOffset из этой статьи — достаточная основа для тактической стратегии, градостроительного симулятора или головоломки на гексах. Поверх неё ложатся A* с эвристикой Distance, туман войны через HasLineOfSight и генерация карты через Range и шум Перлина.