19.10.2026 19 мин чтения Просмотров:0

Гексагональная сетка в Unity: координаты, соседи, расстояние и Tilemap

Кубические и осевые координаты для гексов в Unity: соседи, расстояние, клетка под курсором, линия, зона и кольцо, повороты на 60° и перевод в координаты гексагонального Tilemap. Весь код на C# с тестами.

Гексагональная сетка в Unity: координаты, соседи, расстояние и Tilemap

После этой статьи у вас будет готовая математика гексагональной сетки для 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 и шум Перлина.

Комментарии

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

Пока нет комментариев. Будьте первым!

Смотрите также

GOAP в Unity: планирование ИИ через цели и предусловия
03.10.2026

GOAP в Unity: планирование ИИ через цели и предусловия

Разбираем GOAP в Unity как практический подход к поведению NPC: факты о мире, цели, поиск плана и перепланирование без хрупких деревьев состояний. Показаны архитектура, код на C# и типичные ошибки внедрения.