/
docNemo
/
hex-map-editor
Обзор
Документация
Войти
/
docNemo
/
hex-map-editor
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
5
CI/CD
Аналитика
Безопасность
main
src/core/hex.ts
263 строки
11 KB
docNemo
feat(layout)!: ориентация flat-top вместо pointy-top
03 авг 2026, 15:19
03 авг 2026, 15:19
c0336aa
Код
Авторство
О чём код?
/** * Математика гексагональной сетки. * * Модуль работает исключительно с ориентацией flat-top (тип сетки Foundry `HEXODDQ`). * См. design.md — «Ориентация гексов». * * Осевая математика от ориентации не зависит: направления, расстояния, кольца * и линии — чистая алгебра кубических координат. Ориентация проявляется только * в переводе смещённых координат и в раскладке по пикселям. * * Внутреннее представление — осевые координаты `{ q, r }`. Кубические координаты * используются там, где нужны расстояния, интерполяция и повороты: в них эти операции * выражаются проще и без частных случаев. * * Здесь нет ничего, что знает о Foundry или о пикселях, — только чистая геометрия. * Перевод в экранные координаты живёт в `layout.ts`. */ /** Осевые координаты гекса. */ export interface HexCoord { readonly q: number; readonly r: number; } /** Кубические координаты гекса. Инвариант: `x + y + z === 0`. */ export interface CubeCoord { readonly x: number; readonly y: number; readonly z: number; } /** Смещённые координаты раскладки odd-q: строка и столбец. */ export interface OffsetCoord { readonly col: number; readonly row: number; } /** * Номер ребра гекса. * * Нумерация начинается с юго-восточного ребра и идёт по часовой стрелке. Спрайт * перехода рисуется художником для него одного, а остальные пять получаются * поворотом на `60° × direction`. См. design.md — «Переходы через поворачиваемые рёбра». * * У flat-top гекса вертикальных рёбер нет вовсе: они стоят под углами * `30° + 60° × direction`. Отсчёт поэтому начинается не с востока, как было * у pointy-top, а с юго-востока — с ребра под 30°. * * ``` * 4 0 — юго-восток 3 — северо-запад * /‾‾‾‾\ 1 — юг 4 — север * 3 | | 5 2 — юго-запад 5 — северо-восток * \____/ * 2 0 * 1 * ``` */ export type HexDirection = 0 | 1 | 2 | 3 | 4 | 5; /** Все шесть направлений в порядке возрастания номера. */ export const HEX_DIRECTIONS: readonly HexDirection[] = [0, 1, 2, 3, 4, 5]; /** Число рёбер у гекса. Битовые маски рёбер занимают младшие шесть бит. */ export const HEX_EDGE_COUNT = 6; const DIRECTION_VECTORS: Readonly<Record<HexDirection, HexCoord>> = { 0: { q: 1, r: 0 }, 1: { q: 0, r: 1 }, 2: { q: -1, r: 1 }, 3: { q: -1, r: 0 }, 4: { q: 0, r: -1 }, 5: { q: 1, r: -1 }, }; /** Противоположное ребро. Ребро `d` одного гекса совпадает с ребром `opposite(d)` соседа. */ export function oppositeDirection(direction: HexDirection): HexDirection { return ((direction + 3) % 6) as HexDirection; } /** * Угол, под которым стоит ребро, в градусах от востока по часовой стрелке. * * У flat-top гекса рёбра стоят под 30°, 90°, 150°, 210°, 270° и 330°: * вертикальных среди них нет вовсе, поэтому отсчёт и начинается с 30°. */ export const FIRST_EDGE_DEGREES = 30; export function edgeAngleDegrees(direction: HexDirection): number { return FIRST_EDGE_DEGREES + direction * 60; } /** * Угол поворота спрайта ребра в градусах. * * Это НЕ угол самого ребра: художник рисует спрайт уже развёрнутым под первое * ребро, то есть под 30°, и повороты отсчитываются от него. У pointy-top первое * ребро стояло под 0°, и две величины совпадали — отсюда лёгкость их перепутать. */ export function edgeRotationDegrees(direction: HexDirection): number { return direction * 60; } export function axialToCube({ q, r }: HexCoord): CubeCoord { return { x: q, y: -q - r, z: r }; } export function cubeToAxial({ x, z }: CubeCoord): HexCoord { return { q: x, r: z }; } /** * Осевые координаты из смещённых для раскладки odd-q: нечётные СТОЛБЦЫ сдвинуты вниз. * Соответствует типу гекс-сетки `HEXODDQ` в Foundry. */ export function offsetToAxial({ col, row }: OffsetCoord): HexCoord { return { q: col, r: row - (col - (col & 1)) / 2 }; } /** Смещённые координаты odd-q из осевых. */ export function axialToOffset({ q, r }: HexCoord): OffsetCoord { return { col: q, row: r + (q - (q & 1)) / 2 }; } export function hexEquals(a: HexCoord, b: HexCoord): boolean { return a.q === b.q && a.r === b.r; } export function hexAdd(a: HexCoord, b: HexCoord): HexCoord { return { q: a.q + b.q, r: a.r + b.r }; } export function hexSubtract(a: HexCoord, b: HexCoord): HexCoord { return { q: a.q - b.q, r: a.r - b.r }; } export function hexScale(hex: HexCoord, factor: number): HexCoord { return { q: hex.q * factor, r: hex.r * factor }; } /** Соседний гекс в заданном направлении. */ export function hexNeighbor(hex: HexCoord, direction: HexDirection): HexCoord { return hexAdd(hex, DIRECTION_VECTORS[direction]); } /** Все шесть соседей в порядке номеров направлений. */ export function hexNeighbors(hex: HexCoord): HexCoord[] { return HEX_DIRECTIONS.map((direction) => hexNeighbor(hex, direction)); } /** * Направление от гекса к его соседу, либо `null`, если гексы не являются соседями. * Нужно при записи рёберных масок рек и дорог. */ export function directionBetween(from: HexCoord, to: HexCoord): HexDirection | null { const delta = hexSubtract(to, from); for (const direction of HEX_DIRECTIONS) { const vector = DIRECTION_VECTORS[direction]; if (vector.q === delta.q && vector.r === delta.r) return direction; } return null; } /** Расстояние в гексах. */ export function hexDistance(a: HexCoord, b: HexCoord): number { const delta = hexSubtract(a, b); const { x, y, z } = axialToCube(delta); return (Math.abs(x) + Math.abs(y) + Math.abs(z)) / 2; } /** * Кольцо гексов заданного радиуса вокруг центра. * Радиус 0 даёт сам центр; радиус N даёт ровно `6 * N` гексов. */ export function hexRing(center: HexCoord, radius: number): HexCoord[] { if (radius < 0) return []; if (radius === 0) return [{ q: center.q, r: center.r }]; const results: HexCoord[] = []; // Старт — гекс на расстоянии `radius` в направлении 4, обход по кольцу // шагами в направлениях 0..5 даёт замкнутый контур. let current = hexAdd(center, hexScale(DIRECTION_VECTORS[4], radius)); for (const direction of HEX_DIRECTIONS) { for (let step = 0; step < radius; step += 1) { results.push(current); current = hexNeighbor(current, direction); } } return results; } /** * Заполненная область — все гексы в пределах радиуса включительно. * Используется кистью редактора: радиус N даёт `1 + 3N(N+1)` гексов. */ export function hexSpiral(center: HexCoord, radius: number): HexCoord[] { const results: HexCoord[] = []; for (let ring = 0; ring <= radius; ring += 1) { results.push(...hexRing(center, ring)); } return results; } function cubeRound({ x, y, z }: CubeCoord): CubeCoord { let rx = Math.round(x); let ry = Math.round(y); let rz = Math.round(z); const dx = Math.abs(rx - x); const dy = Math.abs(ry - y); const dz = Math.abs(rz - z); // Восстанавливаем инвариант x + y + z === 0, жертвуя наименее точной компонентой. if (dx > dy && dx > dz) rx = -ry - rz; else if (dy > dz) ry = -rx - rz; else rz = -rx - ry; return { x: rx, y: ry, z: rz }; } /** * Округление дробных осевых координат до ближайшего гекса. * * Прибавление нуля убирает отрицательный ноль: восстановление инварианта в * `cubeRound` умеет его порождать, а `-0` ломает сравнение через `Object.is` * и сбивает с толку при отладке. На арифметику это не влияет. */ export function hexRound(hex: HexCoord): HexCoord { const { q, r } = cubeToAxial(cubeRound(axialToCube(hex))); return { q: q + 0, r: r + 0 }; } function cubeLerp(a: CubeCoord, b: CubeCoord, t: number): CubeCoord { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, z: a.z + (b.z - a.z) * t, }; } /** * Гексы вдоль прямой от `from` до `to` включительно. * * Нужна расчёту дальней видимости: по этой последовательности строится профиль высот * для проверки перекрытия рельефом. См. specs/distant-visibility — «Перекрытие рельефом». */ export function hexLine(from: HexCoord, to: HexCoord): HexCoord[] { const distance = hexDistance(from, to); if (distance === 0) return [{ q: from.q, r: from.r }]; const start = axialToCube(from); const end = axialToCube(to); const results: HexCoord[] = []; // Микросмещение уводит точку с границы между гексами: без него шаги, попадающие // ровно на ребро, округляются в непредсказуемую сторону и линия рвётся. const nudged: CubeCoord = { x: end.x + 1e-6, y: end.y + 1e-6, z: end.z - 2e-6 }; for (let step = 0; step <= distance; step += 1) { results.push(cubeToAxial(cubeRound(cubeLerp(start, nudged, step / distance)))); } return results; }