/
docNemo
/
hex-map-editor
Обзор
Документация
Войти
/
docNemo
/
hex-map-editor
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
5
CI/CD
Аналитика
Безопасность
main
src/core/layout.ts
171 строка
8 KB
docNemo
feat(layout)!: ориентация flat-top вместо pointy-top
03 авг 2026, 15:19
03 авг 2026, 15:19
c0336aa
Код
Авторство
О чём код?
import { FIRST_EDGE_DEGREES, hexRound, HEX_EDGE_COUNT, type HexCoord, type HexDirection, } from './hex'; /** * Перевод координат гекса в пиксели и обратно для ориентации flat-top. * * Единицей измерения выбрана ВЫСОТА гекса. Это не произвол: для flat-top-сетки * Foundry `grid.size` — именно высота (замерено, см. docs/FOUNDRY-COMPATIBILITY.md), * и пересчёт между сеткой сцены и раскладкой получается тождественным. * * У pointy-top единицей была ширина — ровно по той же причине, там `grid.size` * означал её. */ /** * Отношение ширины flat-top гекса к его высоте, равное 2/√3. * * Чистая геометрия правильного шестиугольника — величина не зависит ни от Foundry, * ни от настроек, и именно она задаёт пропорции спрайта. */ export const HEX_ASPECT_RATIO = 2 / Math.sqrt(3); /** * Коэффициент увеличения спрайта при отрисовке, устраняющий швы между гексами. * * Художник рисует ровно шестиугольник, без вылета за границу: наложение * полупрозрачных краёв давало бы двойное затемнение по стыкам. Вместо этого спрайт * рисуется чуть крупнее, и щели от округления координат закрываются. * См. design.md — «Спрайт: без вылета, со сглаживанием швов при отрисовке». */ export const SPRITE_OVERSCALE = 1.015; /** Горизонтальный шаг между столбцами в долях ширины гекса. */ const COLUMN_STEP_RATIO = 3 / 4; export interface PixelPoint { readonly x: number; readonly y: number; } export interface PixelSize { readonly width: number; readonly height: number; } /** * Раскладка гекс-сетки в пикселях. * * `hexHeight` — расстояние между центрами соседей внутри столбца, оно же высота * спрайта в масштабе 1:1. */ export class HexLayout { readonly hexHeight: number; readonly hexWidth: number; constructor(hexHeight: number) { if (!(hexHeight > 0)) { throw new Error(`HexLayout: высота гекса должна быть положительной, получено ${hexHeight}`); } this.hexHeight = hexHeight; this.hexWidth = hexHeight * HEX_ASPECT_RATIO; } /** Центр гекса в пикселях относительно центра гекса `{ q: 0, r: 0 }`. */ center({ q, r }: HexCoord): PixelPoint { return { x: this.hexWidth * COLUMN_STEP_RATIO * q, y: this.hexHeight * (q / 2 + r), }; } /** Гекс, которому принадлежит точка. Обратное к `center`. */ hexAt({ x, y }: PixelPoint): HexCoord { const q = x / (this.hexWidth * COLUMN_STEP_RATIO); const r = y / this.hexHeight - q / 2; return hexRound({ q, r }); } /** Размер спрайта гекса с учётом коэффициента перекрытия швов. */ spriteSize(): PixelSize { return { width: this.hexWidth * SPRITE_OVERSCALE, height: this.hexHeight * SPRITE_OVERSCALE, }; } /** * Габариты прямоугольной карты в пикселях. * * Учитывает сдвиг нечётных столбцов на половину высоты и выступ левой и правой * вершин за пределы шагов между столбцами. */ mapSize(columns: number, rows: number): PixelSize { const shift = columns > 1 ? this.hexHeight / 2 : 0; return { width: (columns - 1) * this.hexWidth * COLUMN_STEP_RATIO + this.hexWidth, height: rows * this.hexHeight + shift, }; } } /** Градусов на одно направление ребра. */ const DEGREES_PER_DIRECTION = 360 / HEX_EDGE_COUNT; /** * Направление ребра, ближайшего к точке, заданной смещением от центра гекса. * * Обратно к `edgeRotationDegrees`: ребро `d` смотрит под углом `30° + d × 60°`, * где ноль отсчитывается от востока, а угол растёт по часовой стрелке, потому что * ось `y` на сцене направлена вниз. * * Нужно для правки рек и дорог мышью: щелчок приходится в гекс, а править требуется * ребро, и выбирается то, к которому щелчок ближе. */ export function edgeDirectionFromOffset(dx: number, dy: number): HexDirection { const degrees = (Math.atan2(dy, dx) * 180) / Math.PI; const shifted = (degrees - FIRST_EDGE_DEGREES + 360) % 360; const sector = Math.round(shifted / DEGREES_PER_DIRECTION); return (sector % HEX_EDGE_COUNT) as HexDirection; } /** * Высота гекса, соответствующая размеру сетки Foundry. * * ПОДТВЕРЖДЕНО ЗАМЕРОМ на Foundry 14.365, сетка `HEXODDQ`, `grid.size = 100`: * * ``` * grid.sizeX = 115.4701 — ширина, то есть высота × 2/√3 * grid.sizeY = 100 — высота, совпадает с grid.size * grid.columns = true — flat-top * grid.even = false — odd-q * ``` * * То есть `grid.size` для flat-top-сетки — это ВЫСОТА гекса, и пересчёт * тождественный. Разности центров: шаг по столбцу даёт (+86.6026, +50), то есть * ширина × ¾ и половина высоты; переход на следующий ряд — (0, +100). */ export function hexHeightFromGridSize(gridSize: number): number { return gridSize; } /** Размер сетки Foundry, соответствующий заданной высоте гекса. Обратное к предыдущей. */ export function gridSizeFromHexHeight(hexHeight: number): number { return hexHeight; } /** * Смещение начала координат между нашей раскладкой и Foundry. * * `HexLayout.center` помещает центр гекса `{ q: 0, r: 0 }` в начало координат, а * Foundry — в точку `(ширина / 2, 0)`: у него левая вершина первого гекса касается * левого края сцены. Разница постоянна и целиком укладывается в это смещение. * * Замер на Foundry 14.365 подтвердил совпадение по всем проверенным гексам, * включая (1, 1) и (0, 2) — то есть ошибка не накапливается. */ export function foundryOriginOffset(hexHeight: number): PixelPoint { return { x: (hexHeight * HEX_ASPECT_RATIO) / 2, y: 0 }; } /** Центр гекса в координатах сцены Foundry. */ export function foundryCenter(layout: HexLayout, coord: HexCoord): PixelPoint { const centre = layout.center(coord); const offset = foundryOriginOffset(layout.hexHeight); return { x: centre.x + offset.x, y: centre.y + offset.y }; }