Skip to content

Commit a0833e2

Browse files
add hexagon and tests
1 parent 890193b commit a0833e2

File tree

2 files changed

+510
-0
lines changed

2 files changed

+510
-0
lines changed

arcade/hexagon.py

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
"""Hexagon utilities.
2+
3+
This module started as the Python implementation of the hexagon utilities
4+
from Red Blob Games.
5+
6+
See: https://www.redblobgames.com/grids/hexagons/
7+
8+
CC0 -- No Rights Reserved
9+
"""
10+
11+
import math
12+
from dataclasses import dataclass
13+
from math import isclose
14+
from typing import Literal, NamedTuple, cast
15+
16+
from pyglet.math import Vec2
17+
18+
_EVEN: Literal[1] = 1
19+
_ODD: Literal[-1] = -1
20+
21+
offset_system = Literal["odd-r", "even-r", "odd-q", "even-q"]
22+
23+
24+
class _Orientation(NamedTuple):
25+
"""Helper class to store forward and inverse matrix for hexagon conversion.
26+
27+
Also stores the start angle for hexagon corners.
28+
"""
29+
30+
f0: float
31+
f1: float
32+
f2: float
33+
f3: float
34+
b0: float
35+
b1: float
36+
b2: float
37+
b3: float
38+
start_angle: float
39+
40+
41+
pointy_orientation = _Orientation(
42+
math.sqrt(3.0),
43+
math.sqrt(3.0) / 2.0,
44+
0.0,
45+
3.0 / 2.0,
46+
math.sqrt(3.0) / 3.0,
47+
-1.0 / 3.0,
48+
0.0,
49+
2.0 / 3.0,
50+
0.5,
51+
)
52+
flat_orientation = _Orientation(
53+
3.0 / 2.0,
54+
0.0,
55+
math.sqrt(3.0) / 2.0,
56+
math.sqrt(3.0),
57+
2.0 / 3.0,
58+
0.0,
59+
-1.0 / 3.0,
60+
math.sqrt(3.0) / 3.0,
61+
0.0,
62+
)
63+
64+
65+
class Layout(NamedTuple):
66+
"""Helper class to store hexagon layout information."""
67+
68+
orientation: _Orientation
69+
size: Vec2
70+
origin: Vec2
71+
72+
73+
# TODO: should this be a np.array?
74+
# TODO: should this be in rust?
75+
# TODO: should this be cached/memoized?
76+
# TODO: benchmark
77+
@dataclass(frozen=True)
78+
class Hex:
79+
"""A hexagon in cube coordinates."""
80+
81+
q: float
82+
r: float
83+
s: float
84+
85+
def __post_init__(self) -> None:
86+
"""Create a hexagon in cube coordinates."""
87+
cube_sum = self.q + self.r + self.s
88+
assert isclose(0, cube_sum, abs_tol=1e-14), f"q + r + s must be 0, is {cube_sum}"
89+
90+
def __eq__(self, other: object) -> bool:
91+
"""Check if two hexagons are equal."""
92+
result = self.q == other.q and self.r == other.r and self.s == other.s # type: ignore[attr-defined]
93+
assert isinstance(result, bool)
94+
return result
95+
96+
def __add__(self, other: "Hex") -> "Hex":
97+
"""Add two hexagons."""
98+
return Hex(self.q + other.q, self.r + other.r, self.s + other.s)
99+
100+
def __sub__(self, other: "Hex") -> "Hex":
101+
"""Subtract two hexagons."""
102+
return Hex(self.q - other.q, self.r - other.r, self.s - other.s)
103+
104+
def __mul__(self, k: int) -> "Hex":
105+
"""Multiply a hexagon by a scalar."""
106+
return Hex(self.q * k, self.r * k, self.s * k)
107+
108+
def __neg__(self) -> "Hex":
109+
"""Negate a hexagon."""
110+
return Hex(-self.q, -self.r, -self.s)
111+
112+
def __round__(self) -> "Hex":
113+
"""Round a hexagon."""
114+
qi = round(self.q)
115+
ri = round(self.r)
116+
si = round(self.s)
117+
q_diff = abs(qi - self.q)
118+
r_diff = abs(ri - self.r)
119+
s_diff = abs(si - self.s)
120+
if q_diff > r_diff and q_diff > s_diff:
121+
qi = -ri - si
122+
elif r_diff > s_diff:
123+
ri = -qi - si
124+
else:
125+
si = -qi - ri
126+
return Hex(qi, ri, si)
127+
128+
def rotate_left(self) -> "Hex":
129+
"""Rotate a hexagon to the left."""
130+
return Hex(-self.s, -self.q, -self.r)
131+
132+
def rotate_right(self) -> "Hex":
133+
"""Rotate a hexagon to the right."""
134+
return Hex(-self.r, -self.s, -self.q)
135+
136+
@staticmethod
137+
def direction(direction: int) -> "Hex":
138+
"""Return a relative hexagon in a given direction."""
139+
hex_directions = [
140+
Hex(1, 0, -1),
141+
Hex(1, -1, 0),
142+
Hex(0, -1, 1),
143+
Hex(-1, 0, 1),
144+
Hex(-1, 1, 0),
145+
Hex(0, 1, -1),
146+
]
147+
return hex_directions[direction]
148+
149+
def neighbor(self, direction: int) -> "Hex":
150+
"""Return the neighbor in a given direction."""
151+
return self + self.direction(direction)
152+
153+
def neighbors(self) -> list["Hex"]:
154+
"""Return the neighbors of a hexagon."""
155+
return [self.neighbor(i) for i in range(6)]
156+
157+
def diagonal_neighbor(self, direction: int) -> "Hex":
158+
"""Return the diagonal neighbor in a given direction."""
159+
hex_diagonals = [
160+
Hex(2, -1, -1),
161+
Hex(1, -2, 1),
162+
Hex(-1, -1, 2),
163+
Hex(-2, 1, 1),
164+
Hex(-1, 2, -1),
165+
Hex(1, 1, -2),
166+
]
167+
return self + hex_diagonals[direction]
168+
169+
def length(self) -> int:
170+
"""Return the length of a hexagon."""
171+
return int((abs(self.q) + abs(self.r) + abs(self.s)) // 2)
172+
173+
def distance_to(self, other: "Hex") -> float:
174+
"""Return the distance between self and another Hex."""
175+
return (self - other).length()
176+
177+
def line_to(self, other: "Hex") -> list["Hex"]:
178+
"""Return a list of hexagons between self and another Hex."""
179+
return line(self, other)
180+
181+
def lerp_between(self, other: "Hex", t: float) -> "Hex":
182+
"""Perform a linear interpolation between self and another Hex."""
183+
return lerp(self, other, t)
184+
185+
def to_pixel(self, layout: Layout) -> Vec2:
186+
"""Convert a hexagon to pixel coordinates."""
187+
return hex_to_pixel(self, layout)
188+
189+
def to_offset(self, system: offset_system) -> "OffsetCoord":
190+
"""Convert a hexagon to offset coordinates."""
191+
if system == "odd-r":
192+
return roffset_from_cube(self, _ODD)
193+
if system == "even-r":
194+
return roffset_from_cube(self, _EVEN)
195+
if system == "odd-q":
196+
return qoffset_from_cube(self, _ODD)
197+
if system == "even-q":
198+
return qoffset_from_cube(self, _EVEN)
199+
200+
msg = "system must be odd-r, even-r, odd-q, or even-q"
201+
raise ValueError(msg)
202+
203+
204+
def lerp(a: Hex, b: Hex, t: float) -> Hex:
205+
"""Perform a linear interpolation between two hexagons."""
206+
return Hex(
207+
a.q * (1.0 - t) + b.q * t,
208+
a.r * (1.0 - t) + b.r * t,
209+
a.s * (1.0 - t) + b.s * t,
210+
)
211+
212+
213+
def distance(a: Hex, b: Hex) -> int:
214+
"""Return the distance between two hexagons."""
215+
return (a - b).length()
216+
217+
218+
def line(a: Hex, b: Hex) -> list[Hex]:
219+
"""Return a list of hexagons between two hexagons."""
220+
n = distance(a, b)
221+
# epsilon to nudge points by to falling on an edge
222+
a_nudge = Hex(a.q + 1e-06, a.r + 1e-06, a.s - 2e-06)
223+
b_nudge = Hex(b.q + 1e-06, b.r + 1e-06, b.s - 2e-06)
224+
step = 1.0 / max(n, 1)
225+
return [round(lerp(a_nudge, b_nudge, step * i)) for i in range(n + 1)]
226+
227+
228+
def hex_to_pixel(h: Hex, layout: Layout) -> Vec2:
229+
"""Convert axial hexagon coordinates to pixel coordinates."""
230+
M = layout.orientation # noqa: N806
231+
size = layout.size
232+
origin = layout.origin
233+
x = (M.f0 * h.q + M.f1 * h.r) * size.x
234+
y = (M.f2 * h.q + M.f3 * h.r) * size.y
235+
return Vec2(x + origin.x, y + origin.y)
236+
237+
238+
def pixel_to_hex(
239+
p: Vec2,
240+
layout: Layout,
241+
) -> Hex:
242+
"""Convert pixel coordinates to cubic hexagon coordinates."""
243+
M = layout.orientation # noqa: N806
244+
size = layout.size
245+
origin = layout.origin
246+
pt = Vec2((p.x - origin.x) / size.x, (p.y - origin.y) / size.y)
247+
q = M.b0 * pt.x + M.b1 * pt.y
248+
r = M.b2 * pt.x + M.b3 * pt.y
249+
return Hex(q, r, -q - r)
250+
251+
252+
def hex_corner_offset(corner: int, layout: Layout) -> Vec2:
253+
"""Return the offset of a hexagon corner."""
254+
# Hexagons have 6 corners
255+
assert 0 <= corner < 6 # noqa: PLR2004
256+
M = layout.orientation # noqa: N806
257+
size = layout.size
258+
angle = 2.0 * math.pi * (M.start_angle - corner) / 6.0
259+
return Vec2(size.x * math.cos(angle), size.y * math.sin(angle))
260+
261+
262+
hex_corners = tuple[Vec2, Vec2, Vec2, Vec2, Vec2, Vec2]
263+
264+
265+
def polygon_corners(h: Hex, layout: Layout) -> hex_corners:
266+
"""Return the corners of a hexagon in a list of pixels."""
267+
corners = []
268+
center = hex_to_pixel(h, layout)
269+
for i in range(6):
270+
offset = hex_corner_offset(i, layout)
271+
corners.append(Vec2(center.x + offset.x, center.y + offset.y))
272+
result = tuple(corners)
273+
# Hexagons have 6 corners
274+
assert len(result) == 6 # noqa: PLR2004
275+
return cast("hex_corners", result)
276+
277+
278+
@dataclass(frozen=True)
279+
class OffsetCoord:
280+
"""Offset coordinates."""
281+
282+
col: float
283+
row: float
284+
285+
def to_cube(self, system: offset_system) -> Hex:
286+
"""Convert offset coordinates to cube coordinates."""
287+
if system == "odd-r":
288+
return roffset_to_cube(self, _ODD)
289+
if system == "even-r":
290+
return roffset_to_cube(self, _EVEN)
291+
if system == "odd-q":
292+
return qoffset_to_cube(self, _ODD)
293+
if system == "even-q":
294+
return qoffset_to_cube(self, _EVEN)
295+
296+
msg = "system must be EVEN (+1) or ODD (-1)"
297+
raise ValueError(msg)
298+
299+
300+
def qoffset_from_cube(h: Hex, offset: Literal[-1, 1]) -> OffsetCoord:
301+
"""Convert a hexagon in cube coordinates to q offset coordinates."""
302+
if offset not in (_EVEN, _ODD):
303+
msg = "offset must be EVEN (+1) or ODD (-1)"
304+
raise ValueError(msg)
305+
306+
col = h.q
307+
row = h.r + (h.q + offset * (h.q & 1)) // 2 # type: ignore[operator]
308+
return OffsetCoord(col, row)
309+
310+
311+
def qoffset_to_cube(h: OffsetCoord, offset: Literal[-1, 1]) -> Hex:
312+
"""Convert a hexagon in q offset coordinates to cube coordinates."""
313+
if offset not in (_EVEN, _ODD):
314+
msg = "offset must be EVEN (+1) or ODD (-1)"
315+
raise ValueError(msg)
316+
317+
q = h.col
318+
r = h.row - (h.col + offset * (h.col & 1)) // 2 # type: ignore[operator]
319+
s = -q - r
320+
return Hex(q, r, s)
321+
322+
323+
def roffset_from_cube(h: Hex, offset: Literal[-1, 1]) -> OffsetCoord:
324+
"""Convert a hexagon in cube coordinates to r offset coordinates."""
325+
if offset not in (_EVEN, _ODD):
326+
msg = "offset must be EVEN (+1) or ODD (-1)"
327+
raise ValueError(msg)
328+
329+
col = h.q + (h.r + offset * (h.r & 1)) // 2 # type: ignore[operator]
330+
row = h.r
331+
return OffsetCoord(col, row)
332+
333+
334+
def roffset_to_cube(h: OffsetCoord, offset: Literal[-1, 1]) -> Hex:
335+
"""Convert a hexagon in r offset coordinates to cube coordinates."""
336+
if offset not in (_EVEN, _ODD):
337+
msg = "offset must be EVEN (+1) or ODD (-1)"
338+
raise ValueError(msg)
339+
340+
q = h.col - (h.row + offset * (h.row & 1)) // 2 # type: ignore[operator]
341+
r = h.row
342+
s = -q - r
343+
return Hex(q, r, s)

0 commit comments

Comments
 (0)