Гексагональные тайлоыве миры

Тайловость в играх — очень распространенное явление, особенно в играх инди сегмента. Чаще всего используют квадратные тайлы — в них проще всего задать необходимые данные, будь то карта уровня или инвентарь. Однако на квадратных и прямоугольных формах возможности подобной системы не ограничиваются.

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

75c47fd96bf12ca7266e43444264b5df.jpg

Если вы не читали мою предыдущую статью про прямоугольные сетки, то рекомендую ознакомится, потому как я иногда буду опускать некоторые вещи, ссылаясь именно на нее. Дабы не изобретать велосипед, некоторые алгоритмы я взял с этой статьи на английском. Кто не хочет или не может в английский, есть ее перевод на хабре. Все демки и примеры сделаны на движке Godot Engine v 3.2.3 с использованием его встроенного языка.

Думаю в целом его синтаксис ясен, однако оставлю ссылки на некоторые функции:

Система координат

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

  • Такие я буду называть вертикальными (у ячейки есть явный вертикальный сосед):

    2e1580bf5fb5ca9bcf49d6111b3ea0ce.png
  • А такие горизонтальными (у ячейки есть явный горизонтальный сосед):

    8cc7ef09a64f822bb3e30da863478622.png

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

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

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

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

Вообще у сетки шестиугольников есть три ярко выраженных оси:

b4615655046760526371b1bb25d498d8.png

Что то напоминает, не находите? Тут три оси, прям как в пространстве. На самом деле гений тот человек — кто не просто увидел, что если взглянуть на куб под определенным углом, то получится шестиугольник, а еще и додумался использовать трехмерные координаты в двумерной сетке шестиугольников. Правда вот попробовав посчитать координаты на двумерной сетке, могут вскипеть мозги, ведь третья ось тут кажется лишней и ее использование будто только мешает. Для разрешения данной ситуации просто посмотрим откуда взялись кубы:

Для получения сетки шестиугольников надо взглянуть в изометрии на пирамидку, на каждом уменьшении высоты которой увеличивается количество кубиков в ряду на один. Включив фантазию, можно увидеть в каждом кубе шестиугольник. Включив внимательность, можно увидеть в левом нижнем углу координаты. Включив логику, можно увидеть, что сумма этих самых координат всегда равна некоторому числу, которое, кстати говоря, может быть любым, ведь мы сами решаем, откуда начнется система координат. В моем случае это 15, просто потому, что система отсчета в этом редакторе начинается с нижнего дальнего угла (в случае изометрического вида). Нам незачем таскать за собой лишнюю константу, поэтому возьмем ее за ноль. Таким образом, сумма трех координат всегда и везде равна нулю, поэтому третья нам попросту не нужна, ведь мы можем получить ее из двух других. Теперь и получается, что «мешающая» третья ось уходит. Наконец то мы можем спокойно направить базисы и у каждой ячейки всегда будет точный сосед, находящийся вдоль оси:

5957dd07228439c53b18409f7832dfdf.PNG

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

Преобразование координат

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

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

15f07f6f5c9a6b22a01449d8199b5f76.png

Оранжевые (маленькие) диагонали делятся пополам зелеными (большими), а зеленые оранжевыми, если последние провести из середины стороны. Уже получается, что мы можем разделить шестиугольник на 4 прямоугольника. Однако некоторые вершины в таком случае будут лежать где то между углами сетки, а ведь нам хотелось бы, чтобы все они попадали ровно в углы. На самом деле «где то», это ровно по серединке, поэтому разделим большие ячейки еще на пополам, тогда все вершины шестиугольника будут ложиться точно в углы прямоугольной сетки:

58362a71e7bda3ab1123144b6eb1a4e9.png

Желтую сетку в дальнейшем я буду называть вспомогательной. Для задания ее базисов будем использовать такие значения:

# Для горизонтальных шестиугольников
var hex_size = 32

var short = int(size*sqrt(3)/2) # 1/2 from short hex diagonal
var long = int(size/2) # 1/4 from long hex diagonal

Теперь мы можем выразить базисы шестиугольной сетки, используя базисы вспомогательной сетки:

f023958b8465345e02ed18945adcd320.png

Запишем все базисы в коде:

...

# Transorm2D в godot - это матрица 3x2, где последняя строка указыает
# смещение объекта, в дальнейшем она не будет использоваться совсем, 
# поэтому считайте это просто матрицей 2x2. Сделано это для удобства,
# на объяснения никак не повлияет.
# У нее есть два атрибута - x и y. Каждый из них это вектор. X - представляет
# первый столбец матрицы 2x2 (крайняя строка не учитывается), Y - второй столбец.
  
var grid_basis = Transform2D() # Матрица базисов вспомогательной сетки
var hex_basis = Transform2D() # Матрица базисов гексагональной сетки

...
  
# Для вертикальной сетки
grid_basis.x = Vector2(long, 0)
grid_basis.y = Vector2(0, short)

hex_basis.x = grid_basis.x*3 + grid_basis.y
hex_basis.y = grid_basis.y*2

# Для горизонтальной сетки
grid_basis.x = Vector2(short, 0)
grid_basis.y = Vector2(0, long)
			
hex_basis.x = grid_basis.x*2
hex_basis.y = grid_basis.x+grid_basis.y*3

Я пользуюсь именно встроенными средствами Godot для упрощения работы в целом. Все подобные места будут поясняться в общем виде, как бы это делалось без встроенных возможностей.

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

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

func hex2pixel(hex):
	return hex.x*hex_basis.x + hex.y*hex_basis.y

Для получения каждой вершины просто прибавляем по нужным базисам:

819fd9759eede9f74d975122913e618f.png

Тогда для получения вершины в коде прибавляем нужный вектор (см. картинку выше) к центру шестиугольника. Я написал только функцию получения массива вершин, ибо по отдельности они почти никогда не нужны.

Для вертикальных шестиугольников:

func _get_vert_hex_vertices(hex):
	var pixel = hex2pixel(hex)
	return PoolVector2Array([
		pixel+2*grid_basis.x,
		pixel+grid_basis.x+grid_basis.y,
		pixel-grid_basis.x+grid_basis.y,
		pixel-2*grid_basis.x,
		pixel-grid_basis.x-grid_basis.y,
		pixel+grid_basis.x-grid_basis.y
	])

Для горизонтальных шестиугольников:

func _get_hor_hex_vertices(hex):
	var pixel = hex2pixel(hex)
	return PoolVector2Array([
		pixel+grid_basis.x-grid_basis.y,
		pixel+grid_basis.x+grid_basis.y,
		pixel+2*grid_basis.y,
		pixel-grid_basis.x+grid_basis.y,
		pixel-grid_basis.x-grid_basis.y,
		pixel-2*grid_basis.y,
	])

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

Наверно самая интригующая часть поста. На самом деле ничего нового почти не будет, ведь для получения вещественных координат ячейки все также обращаем матрицу и умножаем на нее радиус-вектор пикселя:

Для горизонтальной ориентации
26eb20c1c82848a652f2657999a332cc.png

В коде это записывается так:

func pixel2hex(pixel):
	var x = pixel.x/(2*cw) - pixel.y/(6*ch)
	var y = pixel.y/(3*ch)
	return round_hex(Vector2(x, y))
Для вертикальной ориентации
f8cbf955d3d88342546720b88a603620.png

В коде это записывается так:

func pixel2hex(pixel):
	var x = pixel.x/(3*cw)
	var y = pixel.y/(2*ch) - pixel.x/(6*cw)
	return round_hex(Vector2(x, y))

Однако я буду пользоваться функцией affine_inverse у Transform2D, для того, что бы при изменении базисных векторов постоянно не менять функции преобразований, позже увидите зачем это надо. Вы скорее всего работаете в другой среде (и зря), поэтому вам придется писать обращение матрицы самостоятельно. Кто не знает как это делается, или забыл, может почитать тут, или переписать следующие функции в свой язык:

Функции
func invert_basis(basis:Transform2D): # обращение матрицы
	var det = basis.x.x*basis.y.y - basis.y.x*basis.x.y
	var idet = 1.0/det
	# Я не уверен что Transform2D передается по значению, по этому
	# копирую данные в новый объект
	var res = basis
	res.y.y = basis.x.x*idet
	res.x.x = basis.y.y*idet
	res.x.y = -basis.x.y*idet
	res.y.x = -basis.y.x*idet
	return res
	
func vec_mul_basis(vec:Vector2, basis:Transform2D): # умножение вектора на матрицу
	var x = vec.x*basis.x.x + vec.y*basis.y.x
	var y = vec.x*basis.x.y + vec.y*basis.y.y
	return Vector2(x, y)
	
func pixel2hex(pixel):
	return round_hex(vec_mul_basis(pixel, invert_basis(hex_basis)))

Средствами Godot это можно записать всего в одну строчку:

func pixel2hex(pixel):
	return round_hex(hex_basis.affine_inverse().xform(pixel))

Тут .xform(Vector2) — это метод для умножения матрицы на переданный в него вектор, аналог vec_mul_basis из моего кода. Такой код работает для обеих ориентаций.

Если вы хотя бы бегло прочитали вышеприведенный код, то наверняка заметили функцию round_hex вместо типичных приведений к int. Дело в том, что полных координат у шестиугольника 3, и они обладают условием x + y + z = 0, а после округления каждой из них равенство может нарушиться. Поэтому необходимо задать координату с наибольшей ошибкой округления через две другие, тогда условие выполнится. Да, данный метод полностью слизан отсюда, однако зачем придумывать велосипед, если можно взять готовый? Так же тут используется именно round, а не приведение к int, ведь основание каждой ячейки находится в ее центре, а не в левом верхнем углу, как в случае с прямоугольными сетками:

func round_hex(hex:Vector2):
	var rx = round(hex.x)
	var ry = round(hex.y)
	var rz = round(-hex.x-hex.y) # z = -x-y
	
	var x_diff = abs(hex.x-rx) # Ошибка округления x
	var y_diff = abs(hex.y-ry) # Ошибка округления y
	var z_diff = abs(-hex.x-hex.y-rz) # Ошибка округления z
	if x_diff > y_diff and x_diff > z_diff:
		rx = -ry-rz # Приведение под равенство
	elif y_diff > z_diff:
		ry = -rx-rz # Приведение под равенство
	return Vector2(rx, ry)

Работает все замечательно:

Вертикальная ориентация
5edd7f864a37a232e746cfedfed39ebc.gif
Горизонтальная ориентация
fbd64a3217c3d58b0bc525f3e1f928d7.gif

Однако я надеюсь вы не думаете, что сетки, это вручную нарисованные текстуры. Я не самоубийца.

Рисование сеток

Все примеры и объяснения я буду приводить на горизонтальной сетке, ведь для вертикальной они аналогичны. Для рисования последних я просто оставлю готовую функцию.

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

const hex_map_size = Vector2(7, 7) # размер сетки шестиугольников
var grid_map_size:Vector2 # размер вспомогательной сетки
...
grid_map_size.x = hex_map_size.x*2
grid_map_size.y = hex_map_size.y*3+1

Для вертикальных шестиугольников все аналогично, только формулы для вычисления ширины и высоты меняются местами:

...
grid_map_size.x = hex_map_size.x*3+1
grid_map_size.y = hex_map_size.y*2

Сетку из шестиугольников можно разбить на две части, на вертикальные линии и на паттерн вершин:

009f182acdd9e3d648ef0dade11619e6.png

Будем рисовать каждую составляющую по отдельности. Начнем с вертикальных линий. Можно заметить, что в каждом ряду линии рисуются с интервалом в 2 ячейки, а каждый четный по счету ряд начинается со второй, а не с первой ячейки. Также увидим то, что первый ряд начинается со со смещением в одну ячейку относительно верхей границы, а ряды разделяет одна ячейка. С учетом того, что длина штриха в две ячейки, между верхними концами отрезков находятся три ячейки. Тогда в цикле начинаем с единицы и идем до нижнего края карты с шагом 3, а во втором цикле начинаем со столбца, индекс которого обратен четности ряда, проще говоря 1-i%2, и идем до правого края карты, но на единицу больше, чтобы нарисовать таки крайние линии, с шагом в две ячейки. В кадой итерации второго цикла просто рисуем отрезок высотой две ячейки:

for i in range(1, grid_map_size.y, 3):
	for j in range(1-i%2, grid_map_size.x+1, 2):
		VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i, grid_basis.x*j+grid_basis.y*(i+2), color, width, antialiasing)

Этот код будет рисовать только вертикальные линии. Теперь нужно нарисовать паттерн вершин. Есть всего два вида наклонных линий, от нижнего левого угла к верхнему правому (их я буду называть нижними диагоналями), или от верхнего левого к нижнему правому (их я буду называть верхними диагоналями), причем в одной строке они чередуются, а в следующей строке паттерн меняет четность. Четным я буду называть паттерн, начинающийся с нижней диагонали, нечетным — начинающийся в верхней диагонали.

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

Для рисования паттернов пробегаем каждую третью строку, начиная с нулевой, а в каждой строке пробегаемся по столбцам. Тогда для выбора нужной линии сравниваем четности строки и столбца, если они совпадают, то рисуем нижнюю диагональ, иначе верхнюю. Тут я считаю нужным показать, как задается каждый угол ячейки с координатами {j, i}, где j — столбец (как бы x), i — строка (как бы y). Размер ячейки увеличен только для демонстрации:

faf0a14510a38c54f94103c74264bba6.png

В коде этот алгоритм выглядит так:

# Drawing vertices
for i in range(0, grid_map_size.y, 3): # рисуем на каждой третьей строке
	for j in range(grid_map_size.x): # крайний столбец не захватываем, т.к. в коде прибавляется единица
		if i%2 == j%2: # нижняя диагональ
			Canvas.line(surf, grid_basis.x*j+grid_basis.y*(i+1), grid_basis.x*(j+1)+grid_basis.y*i, color, width, antialiasing)
		else: # верхняя диагональ
			Canvas.line(surf, grid_basis.x*j+grid_basis.y*i-offset, grid_basis.x*(j+1)+grid_basis.y*(i+1), color, width, antialiasing)

Однако просто нарисовав на холсте сетку, получатся непонятки с координатами:

bb94424a8de2f162b56cdc33ed5a81a6.gif

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

Однако и на этом не все. Если просто объеденить весь код выше в одну функцию, то при четных высотах она будет рисовать ненужные хвосты:

1555dd0f7e72577f75d4c361b94c8f06.png

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

Соеденив все вместе, получим такую функцию:

func _draw_hor_rect_grid(surf:RID, color:Color, width=1.0, antialiasing=false):
	var offset = grid_basis.x+grid_basis.y*2
	# Drawing vertical lines
	for i in range(1, grid_map_size.y, 3):
		for j in range(1-i%2, grid_map_size.x+1, 2):
			VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i-offset, grid_basis.x*j+grid_basis.y*(i+2)-offset, color, width, antialiasing)

	# Drawing vertices
	for i in range(0, grid_map_size.y, 3):
		for j in range(grid_map_size.x):
			if int(hex_map_size.y)%2 == 1 or not (i == grid_map_size.y-1 and (j == 0 or j == grid_map_size.x-1)):
				if i%2 == j%2:
					VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(i+1)-offset, grid_basis.x*(j+1)+grid_basis.y*i-offset, color, width, antialiasing)
				else:
					VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i-offset, grid_basis.x*(j+1)+grid_basis.y*(i+1)-offset, color, width, antialiasing)

При рисовании вспомогательной сетки кстати тоже используем смещение. На всякий случай оставлю тут и ее рисование, хотя это есть в моей статье про прямоугольные сетки:

func draw_auxiliary_grid(surf:RID, color:Color, width=1.0, antialiasing=false):
	var offset = grid_basis.x+grid_basis.y*2
	for i in grid_map_size.x+1:
		Canvas.line(surf, grid_basis.x*i-offset, grid_basis.x*i+grid_basis.y*grid_map_size.y-offset, color, width, antialiasing)
	for i in grid_map_size.y+1:
		Canvas.line(surf, grid_basis.y*i-offset, grid_basis.x*grid_map_size.x+grid_basis.y*i-offset, color, width, antialiasing)

И, как и обещал, функция для рисования вертикально-ориентированной сетки:

func _draw_vert_rect_grid(surf:RID, color:Color, width=1.0, antialiasing=false):
	var offset = grid_basis.x*2+grid_basis.y
	# Drawing horizontal lines
	for i in range(1, grid_map_size.x, 3):
		for j in range(1-i%2, grid_map_size.y+1, 2):
			VisualServer.canvas_item_add_line(surf, grid_basis.x*i+grid_basis.y*j-offset, grid_basis.x*(i+2)+grid_basis.y*j-offset, color, width, antialiasing)
			
	# Drawing vertices
	for i in range(0, grid_map_size.x, 3):
		for j in range(grid_map_size.y):
			if int(hex_map_size.x)%2 == 1 or not(i == grid_map_size.x-1 and (j == 0 or j == grid_map_size.y-1)):
				if j%2 == i%2:
					VisualServer.canvas_item_add_line(surf, grid_basis.x*(i+1)+grid_basis.y*j-offset, grid_basis.x*i+grid_basis.y*(j+1)-offset, color, width, antialiasing)
				else:
					VisualServer.canvas_item_add_line(surf, grid_basis.x*i+grid_basis.y*j-offset, grid_basis.x*(i+1)+grid_basis.y*(j+1)-offset, color, width, antialiasing)

Результат вполне неплох, нигде линии не рисуются дважды (сетка рисовалась немного прозрачной на черном фоне, а яркость линий везде оинакова):

Сетка вертикальных шестиугольников
089a595029d85c645337000240a39ed2.png
Сетка горизонтальных шестиугольников
5072d5c4b45ea9dae69ca1a58231bff2.png

Однако рендерить такие сетки в реальном времени довольно затратно, тут рисуется множетсво отдельных отрезков, что сильно замедляет работу. Просто для примера, пустое черно окно у меня имеет fps около 950, а при рисовании белым цветом Color8(255, 255, 255, 200) шестиугольной сетки размера 10×10 и размером шестиугольнкиа 32 пикселя, fps примерно 260. Так что рисовать сетки процедурно резонно только на начальном этапе разработки, потом лучше отрендерить ее заранее и использовать как текстуру.

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

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

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

b1d986c95afef49381eb1d1c6a8483c1.png

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

var hex_map_size = Vector2(5, <не имеет значения>)
...
var diagonal = hex_map_size.x*2-1

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

...
grid_map_size.x = diagonal*2
grid_map_size.y = diagonal*3+1

Для вертикальных значения меняются местами:

grid_map_size.x = diagonal*3+1
grid_map_size.y = diagonal*2

Шестиугольную сетку можно точно также разбить на две части, на паттерн вершин и вертикальные линии:

53565f07bcfe55b32b60aef964a1220f.png

Начнем с рисования вершин. Рисовать каждый слой по-отдельности не имеет сымсла, ведь фигура симметрична. Мы можем разделить всю вспомогательную сетку на четыре части и, нарисовав одну четверть, отобразить ее зеркально на все остальные. Сетка кстати всегда будет делиться ровно, и вот почему. По горизонтали понятно, ведь в формуле ширины мы удваиваем диагональ шестиугольной карты. А эта самая диагональ будет всегда нечетна, ведь мы от четного числа отнимаем единицу (hex_map_size.x*2-1). В формуле высоты вспомогательной сетки мы умножаем эту диагональ на 3, и результат получится тоже нечетным, а после прибавления единицы все выражение становится четным. Таким образом ширина и высота вспомогательной сетки всегда четны, и как следствие, ее можно всегда разделить на четыре одинаковые части:

b9fb91999b1442f40629ae5104a8cdfd.png

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

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

Также вспомним, что каждый следующий паттерн рисуется со смещением в три ячейки от предыдущего, поэтому в цикле идем от нуля до половины высоты вспомогательной сетки с шагом в три, попутно вычисляя смещение для каждого ряда:

for i in range(0, grid_map_size.y/2, 3): # Drawing vertices
  # тут i/3 потому что мы идем со смещением 3, а при расчетах нужен индекс
  start = hex_map_size.x-1 - i/3  

Проходить по ширине будем также до середины вспомогательной сетки, начиная с выщитанного ранее смщения:

for i in range(0, grid_map_size.y/2, 3): # Drawing vertices
  # тут i/3 потому что мы идем со смещением 3, у при расчетах нужен индекс паттерна
  start = hex_map_size.x-1 - i/3  
  for j in range(start, grid_map_size.x/2):
  	pass # Пока ничего не делаем

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

Приведу пример. Мы рисуем нижнюю диагональ, если индексы ряда и колонки совпадают, иначе верхнюю. Поставим размер карты 5. Тогда начальное смещение будет четным, как и индекс первого ряда (i=0). Исходя из условия, рисуем нижнюю диагональ, как и должно быть. Однако поставив четный размер, скажем, 4, начальное смещение будет нечетным, а вот индекс первого ряда по прежнему четным. Тогда взглянув на условие компьютер выберет верхюю диагональ, а ведь нам все еще для начала нужна нижняя. Вот как это будет выглядеть:

5f6a825b2dfb806187935f186f0616b4.png

Тут на самом деле всего лишь надо поменять четность паттерна, тогда все встанет на свои места. Получается, выбор условия рисвания нижней диагонали зависит от четности самого размера карты. Тут можно заметить, что разница четностей столбца и ряда в каждой первой диагонали ряда паттерна обратна четности размера карты. А при рисовании паттерна диагонали просто чередуются, как и чередуется четность столбца, и как следствие чередуется равенство разностей четностей ряда и столбца и четности размера карты. Поэтому для выбора диагонали используем равентво abs(i%2 - j%2) != parity, где parity — это остаток от деления размера карты на два. Если это условие верно, рисуем нижнюю диагональ, иначе верхнюю. Получим то что нужно, осталось отразить по красным линиям:

b65d8d3cf3b1b9627dbacce144e88b19.pngКод рисования четверти всего паттерна
func _draw_hor_hex_grid(surf:RID, color:Color):	
	var parity = int(hex_map_size.x)%2
	var start
	for i in range(0, grid_map_size.y/2, 3): # Drawing vertices
		start = hex_map_size.x - i/3 - 1
		for j in range(start, grid_map_size.x/2):
			if abs(i%2 - j%2) != parity:
				# Down diagonal
				VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(i+1), grid_basis.x*(j+1)+grid_basis.y*i, color)
      else:
				# Top diagonal
				VisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(i), grid_basis.x*(j+1)+grid_basis.y*(i+1), color)

Для отражения точек рисования отнимаем от края вспомогательной сетки индекс точки, ничего сложного. А вот в коде это выглядит громоздко. Взгляните сами:

func _draw_hor_hex_grid(surf:RID, color:Color, width=1.0, antialiasing=false):
	var parity = int(hex_map_size.x)%2
	var start
	for i in range(0, grid_map_size.y/2, 3): # Drawing vertices
		start = hex_map_size.x - i/3 - 1
		for j in range(start, grid_map_size.x/2):
			if abs(i%2 - j%2) != parity:
				# Down diagonal
				VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(i+1), grid_basis.x*(j+1)+grid_basis.y*i, color)
				VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i+1), grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*i, color)

				VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(grid_map_size.y-i-1), grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i), color)
				VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(grid_map_size.y-i-1), grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(grid_map_size.y-i), color)
			else:
				# Top diagonal
				VisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(i), grid_basis.x*(j+1)+grid_basis.y*(i+1), color)
				VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i), grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(i+1), color)

				VisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(grid_map_size.y-i), grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i-1), color)
				VisualServer.ca

Но ничего страшного, ковид пережили — переживем и это. Зато получаем правильный паттерн для сеток с четными и нечетным размером:

64c4c4a9edf3e1e45d90c537b8a53c3d.png

Если вы что то поняли во всей этой мешанине четностей, то либо вы гений, либо у меня получилось что то объяснить. Однако дальше не легче, но радует то, что мы почти нарисовали сетку. Осталось добавить вертикальные линии — это будет финальным боссом в рассказе о рисовании сеток.

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

for i in range(1, grid_map_size.y, 3):
	for j in range(1-i%2, grid_map_size.x+1, 2):
		VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i, grid_basis.x*j+grid_basis.y*(i+2), color, width, antialiasing)

Однако просто скопипастив его в нашу функцию, получим кривое рисование при четных размерах карты, ведь при них первый ряд должен иметь смещение в единицу, а при нечетных этого смещения быть не должно. Это вытекает из смещения первого шестиугольника в первом ряду, при четных значения оно нечетно поэтому и рисуем со смещением, и наоборот. Для выбора смещения сравним четности размера карты и ряда, если они отличаются, то рисуем без смещения, иначе со смещением. Пихать сюда условие не имеет смысла, ведь мы можем выбрать смещение через отличие четности карты и четности столбца конструкцией abs(parity-i%2). Просто напомню — parity это остаток от деления размера карты на два. Проверьте сами, при четных столбцах и нечетных размерах карты получается единица — то самое смещение. Запишем это выражение в смещение в цикле:

for i in range(1, grid_map_size.y, 3):
	for j in range(abs(parity-i%2), grid_map_size.x+1, 2):
		VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i, grid_basis.x*j+grid_basis.y*(i+2), color, width, antialiasing)

Цель почти достигнута, осталось избавиться от лишних линий по углам:

836aae0d68422ec5e51177bfbc81ea93.png

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

...
start = hex_map_size.x-1 - i/3

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

...
start = (i-grid_map_size.y/2)/3

Это мы задали левые границы. Для правых просто отразим их в силу четности размеров вспомогательной сетки:

for i in range(1, grid_map_size.y, 3):
	if i <= grid_map_size.y/2:
		start = hex_map_size.x-1 - i/3
	else:
		start = (i-grid_map_size.y/2)/3

	for j in range(abs(parity-i%2), grid_map_size.x+1, 2):
		if j >= start and j <= grid_map_size.x-start: # избавляемся от лишних линий
			VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i, grid_basis.x*j+grid_basis.y*(i+2), color, width, antialiasing)

Вот и все — финальный босс побежден. Осталось только добавить смещение для расположения сетки в начало координат, offset = grid_basis.x+grid_basis.y*2. Однако тут опять играет роль четность размера карты, так что когда она четна прибавляем к смещению горизонтальный базис ячейки.

Босса то убили, а вот лут забыли. С него мы получили рисование шестиугольных сеток шестиугольников:

Горизонтальная ориентация
func _draw_hor_hex_grid(surf:RID, color:Color, width=1.0, antialiasing=false):
	var parity = int(hex_map_size.x)%2
	var offset = grid_basis.x+grid_basis.y*2 + grid_basis.x*(1-parity)
	var start
	for i in range(0, grid_map_size.y/2, 3): # Drawing vertices
		start = hex_map_size.x - i/3 - 1
		for j in range(start, grid_map_size.x/2):
			if abs(i%2 - j%2) != parity:
				# Down diagonal
				VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(i+1)-offset, grid_basis.x*(j+1)+grid_basis.y*i-offset, color)
				VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i+1)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*i-offset, color)

				VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(grid_map_size.y-i-1)-offset, grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i)-offset, color)
				VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(grid_map_size.y-i-1)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(grid_map_size.y-i)-offset, color)
			else:
				# Top diagonal
				VisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(i)-offset, grid_basis.x*(j+1)+grid_basis.y*(i+1)-offset, color)
				VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(i+1)-offset, color)

				VisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)
				VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)

	for i in range(1, grid_map_size.y, 3):
		if i <= grid_map_size.y/2:
			start = hex_map_size.x-1 - i/3
		else:
			start = (i-grid_map_size.y/2)/3

		for j in range(abs(parity-i%2), grid_map_size.x+1, 2):
			if j >= start and j <= grid_map_size.x-start:
				VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i-offset, grid_basis.x*j+grid_basis.y*(i+2)-offset, color, width, antialiasing)
ca66c259e0cc224c12c3809676e39c6b.gif
Вертикальная ориентация
func _draw_vert_hex_grid(surf:RID, color:Color, width=1.0, antialiasing=false):
	var parity = int(hex_map_size.x)%2
	var offset = grid_basis.x*2+grid_basis.y + (1-parity)*grid_basis.y
	var start
	for j in range(0, grid_map_size.x/2, 3): # Drawing vertices
		start = hex_map_size.x - j/3 - 1
		for i in range(start, grid_map_size.y/2):
			if abs(i%2 - j%2) != parity:
				# Down diagonal
				VisualServer.canvas_item_add_line(surf, grid_basis.x*(j+1)+grid_basis.y*(i)-offset, grid_basis.x*(j)+grid_basis.y*(i+1)-offset, color)
				VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(i)-offset, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i+1)-offset, color)

				VisualServer.canvas_item_add_line(surf, grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(j)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)
				VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)
			else:
				# Top diagonal
				VisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(i)-offset, grid_basis.x*(j+1)+grid_basis.y*(i+1)-offset, color)
				VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(i+1)-offset, color)

				VisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)
				VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)

	for i in range(1, grid_map_size.x, 3):
		if i <= grid_map_size.x/2:
			start = hex_map_size.x-1 - i/3
		else:
			start = (i-grid_map_size.x/2)/3

		for j in range(abs(parity-i%2), grid_map_size.y+1, 2):
			if j >= start and j <= grid_map_size.y-start:
				VisualServer.canvas_item_add_line(surf, grid_basis.x*i+grid_basis.y*j-offset, grid_basis.x*(i+2)+grid_basis.y*(j)-offset, color, width, antialiasing)

Пример:

a1db4ee0450a079e186f48a3226c66f2.gif

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

Тут на самом деле все просто, можете расслабиться. После этой жести с четностями ничего хуже уже не будет. Для рисования шестиугольников вспомним функции для получения вершин, которые я приводил выше и просто соеденим массив вершин шестиугольника линиями:

Функции для получения вершин, если лень мотать неаверх
func _get_vert_hex_vertices(hex):
	var pixel = hex2pixel(hex)
	return PoolVector2Array([
		pixel+2*grid_basis.x,
		pixel+grid_basis.x+grid_basis.y,
		pixel-grid_basis.x+grid_basis.y,
		pixel-2*grid_basis.x,
		pixel-grid_basis.x-grid_basis.y,
		pix
    
            

© Habrahabr.ru