diff --git a/.gitignore b/.gitignore
index f97fc04787..5aac1e8aed 100644
--- a/.gitignore
+++ b/.gitignore
@@ -64,4 +64,4 @@ temp/
.ENABLE_DEVMACHINE_SPHINX_STATIC_FIX
webplayground/**/*.whl
-webplayground/**/*.zip
\ No newline at end of file
+webplayground/**/*.zip
diff --git a/arcade/examples/hex_map.py b/arcade/examples/hex_map.py
new file mode 100644
index 0000000000..2b7b4305b8
--- /dev/null
+++ b/arcade/examples/hex_map.py
@@ -0,0 +1,195 @@
+"""
+Hex Map Example
+
+If Python and Arcade are installed, this example can be run from the command line with:
+python -m arcade.examples.hex_map
+"""
+
+import math
+from operator import add
+
+from pyglet.math import Vec2
+
+import arcade
+from arcade import hexagon
+
+WINDOW_WIDTH = 1280
+WINDOW_HEIGHT = 720
+WINDOW_TITLE = "Hex Map"
+
+
+class GameView(arcade.View):
+ """
+ Main application class.
+ """
+
+ def __init__(self):
+ super().__init__()
+
+ # Variable to hold our Tiled Map
+ self.tile_map: arcade.TileMap
+
+ # Replacing all of our SpriteLists with a Scene variable
+ self.scene: arcade.Scene
+
+ # A variable to store our camera object
+ self.camera: arcade.camera.Camera2D
+
+ # A variable to store our gui camera object
+ self.gui_camera: arcade.camera.Camera2D
+
+ # Initialize the mouse_pan variable
+ self.mouse_pan = False
+
+ def reset(self):
+ """Reset the game to the initial state."""
+ # Do changes needed to restart the game here
+
+ # Tiled always uses pointy orientations
+ orientation = hexagon.pointy_orientation
+ #
+ hex_size_x = 120 / math.sqrt(3)
+ hex_size_y = 140 / 2
+ map_origin = Vec2(0, 0)
+
+ hex_layout = hexagon.Layout(
+ orientation=orientation,
+ size=Vec2(hex_size_x, hex_size_y),
+ origin=map_origin,
+ )
+
+ # Load our TileMap
+ self.tile_map = arcade.load_tilemap(
+ ":resources:tiled_maps/hex_map.tmj",
+ hex_layout=hex_layout,
+ use_spatial_hash=True,
+ )
+
+ # Create our Scene Based on the TileMap
+ self.scene = arcade.Scene.from_tilemap(self.tile_map) # type: ignore[arg-type]
+
+ # Initialize our camera, setting a viewport the size of our window.
+ self.camera = arcade.camera.Camera2D()
+ self.camera.zoom = 0.5
+
+ # Initialize our gui camera, initial settings are the same as our world camera.
+ self.gui_camera = arcade.camera.Camera2D()
+
+ # Set the background color to a nice red
+ self.background_color = arcade.color.BLACK
+
+ def on_draw(self):
+ """
+ Render the screen.
+ """
+
+ # This command should happen before we start drawing. It will clear
+ # the screen to the background color, and erase what we drew last frame.
+ self.clear()
+
+ with self.camera.activate():
+ self.scene.draw()
+
+ # Call draw() on all your sprite lists below
+
+ def on_update(self, delta_time):
+ """
+ All the logic to move, and the game logic goes here.
+ Normally, you'll call update() on the sprite lists that
+ need it.
+ """
+ pass
+
+ def on_key_press(self, key, key_modifiers):
+ """
+ Called whenever a key on the keyboard is pressed.
+
+ For a full list of keys, see:
+ https://api.arcade.academy/en/latest/arcade.key.html
+ """
+ pass
+
+ def on_key_release(self, key, key_modifiers):
+ """
+ Called whenever the user lets off a previously pressed key.
+ """
+ pass
+
+ def on_mouse_motion(self, x, y, delta_x, delta_y):
+ """
+ Called whenever the mouse moves.
+ """
+ if self.mouse_pan:
+ # If the middle mouse button is pressed, we want to pan the camera
+ # by the amount of pixels the mouse moved, divided by the zoom level
+ # to keep the panning speed consistent regardless of zoom level.
+ # The camera position is updated by adding the delta_x and delta_y
+ # values to the current camera position, divided by the zoom level.
+ # This is done using the add function from the operator module to
+ # add the delta_x and delta_y values to the current camera position.
+ self.camera.position = tuple(
+ map(
+ add,
+ self.camera.position,
+ (-delta_x * 1 / self.camera.zoom, -delta_y * 1 / self.camera.zoom),
+ )
+ )
+ return
+
+ def on_mouse_press(self, x, y, button, key_modifiers):
+ """
+ Called when the user presses a mouse button.
+ """
+ if button == arcade.MOUSE_BUTTON_MIDDLE:
+ self.mouse_pan = True
+ return
+
+ def on_mouse_release(self, x, y, button, key_modifiers):
+ """
+ Called when a user releases a mouse button.
+ """
+ if button == arcade.MOUSE_BUTTON_MIDDLE:
+ self.mouse_pan = False
+ return
+
+ def on_mouse_scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None:
+ """Called whenever the mouse scrolls."""
+ # If the mouse wheel is scrolled, we want to zoom the camera in or out
+ # by the amount of scroll_y. The zoom level is adjusted by adding the
+ # scroll_y value multiplied by a zoom factor (0.1 in this case) to the
+ # current zoom level. This allows for smooth zooming in and out of the
+ # camera view.
+
+ self.camera.zoom += scroll_y * 0.1
+
+ # The zoom level is clamped to a minimum of 0.1 to prevent the camera
+ # from zooming out too far.
+ if self.camera.zoom < 0.1:
+ self.camera.zoom = 0.1
+
+ # The zoom level is clamped to a maximum of 10 to prevent the camera
+ # from zooming in too far.
+ if self.camera.zoom > 2:
+ self.camera.zoom = 2
+
+
+def main():
+ """Main function"""
+ # Create a window class. This is what actually shows up on screen
+ window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE)
+
+ # Create and setup the GameView
+ game = GameView()
+
+ # Show GameView on screen
+ window.show_view(game)
+
+ # Reset the game to the initial state
+ game.reset()
+
+ # Start the arcade game loop
+ arcade.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/arcade/hexagon.py b/arcade/hexagon.py
new file mode 100644
index 0000000000..318a85927e
--- /dev/null
+++ b/arcade/hexagon.py
@@ -0,0 +1,347 @@
+"""Hexagon utilities.
+
+This module started as the Python implementation of the hexagon utilities
+from Red Blob Games.
+
+See: https://www.redblobgames.com/grids/hexagons/
+
+CC0 -- No Rights Reserved
+"""
+
+import math
+from dataclasses import dataclass
+from math import isclose
+from typing import Literal, NamedTuple, cast
+
+from pyglet.math import Vec2
+
+_EVEN: Literal[1] = 1
+_ODD: Literal[-1] = -1
+
+offset_system = Literal["odd-r", "even-r", "odd-q", "even-q"]
+
+
+class _Orientation(NamedTuple):
+ """Helper class to store forward and inverse matrix for hexagon conversion.
+
+ Also stores the start angle for hexagon corners.
+ """
+
+ f0: float
+ f1: float
+ f2: float
+ f3: float
+ b0: float
+ b1: float
+ b2: float
+ b3: float
+ start_angle: float
+
+
+pointy_orientation = _Orientation(
+ math.sqrt(3.0),
+ math.sqrt(3.0) / 2.0,
+ 0.0,
+ 3.0 / 2.0,
+ math.sqrt(3.0) / 3.0,
+ -1.0 / 3.0,
+ 0.0,
+ 2.0 / 3.0,
+ 0.5,
+)
+flat_orientation = _Orientation(
+ 3.0 / 2.0,
+ 0.0,
+ math.sqrt(3.0) / 2.0,
+ math.sqrt(3.0),
+ 2.0 / 3.0,
+ 0.0,
+ -1.0 / 3.0,
+ math.sqrt(3.0) / 3.0,
+ 0.0,
+)
+
+
+class Layout(NamedTuple):
+ """Helper class to store hexagon layout information."""
+
+ orientation: _Orientation
+ size: Vec2
+ origin: Vec2
+
+
+# TODO: should this be a np.array?
+# TODO: should this be in rust?
+# TODO: should this be cached/memoized?
+# TODO: benchmark
+@dataclass(frozen=True)
+class HexTile:
+ """A hexagonal tile in cube coordinates.
+
+ For an introduction to hexagonal grids and cube coordinates, see:
+ https://www.redblobgames.com/grids/hexagons/
+ """
+
+ q: float
+ r: float
+ s: float
+
+ def __post_init__(self) -> None:
+ """Create a hexagon in cube coordinates."""
+ cube_sum = self.q + self.r + self.s
+ assert isclose(0, cube_sum, abs_tol=1e-14), f"q + r + s must be 0, is {cube_sum}"
+
+ def __eq__(self, other: object) -> bool:
+ """Check if two hexagons are equal."""
+ result = self.q == other.q and self.r == other.r and self.s == other.s # type: ignore[attr-defined]
+ assert isinstance(result, bool)
+ return result
+
+ def __add__(self, other: "HexTile") -> "HexTile":
+ """Add two hexagons."""
+ return HexTile(self.q + other.q, self.r + other.r, self.s + other.s)
+
+ def __sub__(self, other: "HexTile") -> "HexTile":
+ """Subtract two hexagons."""
+ return HexTile(self.q - other.q, self.r - other.r, self.s - other.s)
+
+ def __mul__(self, k: int) -> "HexTile":
+ """Multiply a hexagon by a scalar."""
+ return HexTile(self.q * k, self.r * k, self.s * k)
+
+ def __neg__(self) -> "HexTile":
+ """Negate a hexagon."""
+ return HexTile(-self.q, -self.r, -self.s)
+
+ def __round__(self) -> "HexTile":
+ """Round a hexagon."""
+ qi = round(self.q)
+ ri = round(self.r)
+ si = round(self.s)
+ q_diff = abs(qi - self.q)
+ r_diff = abs(ri - self.r)
+ s_diff = abs(si - self.s)
+ if q_diff > r_diff and q_diff > s_diff:
+ qi = -ri - si
+ elif r_diff > s_diff:
+ ri = -qi - si
+ else:
+ si = -qi - ri
+ return HexTile(qi, ri, si)
+
+ def rotate_left(self) -> "HexTile":
+ """Rotate a hexagon to the left."""
+ return HexTile(-self.s, -self.q, -self.r)
+
+ def rotate_right(self) -> "HexTile":
+ """Rotate a hexagon to the right."""
+ return HexTile(-self.r, -self.s, -self.q)
+
+ @staticmethod
+ def direction(direction: int) -> "HexTile":
+ """Return a relative hexagon in a given direction."""
+ hex_directions = [
+ HexTile(1, 0, -1),
+ HexTile(1, -1, 0),
+ HexTile(0, -1, 1),
+ HexTile(-1, 0, 1),
+ HexTile(-1, 1, 0),
+ HexTile(0, 1, -1),
+ ]
+ return hex_directions[direction]
+
+ def neighbor(self, direction: int) -> "HexTile":
+ """Return the neighbor in a given direction."""
+ return self + self.direction(direction)
+
+ def neighbors(self) -> list["HexTile"]:
+ """Return the neighbors of a hexagon."""
+ return [self.neighbor(i) for i in range(6)]
+
+ def diagonal_neighbor(self, direction: int) -> "HexTile":
+ """Return the diagonal neighbor in a given direction."""
+ hex_diagonals = [
+ HexTile(2, -1, -1),
+ HexTile(1, -2, 1),
+ HexTile(-1, -1, 2),
+ HexTile(-2, 1, 1),
+ HexTile(-1, 2, -1),
+ HexTile(1, 1, -2),
+ ]
+ return self + hex_diagonals[direction]
+
+ def length(self) -> int:
+ """Return the length of a hexagon."""
+ return int((abs(self.q) + abs(self.r) + abs(self.s)) // 2)
+
+ def distance_to(self, other: "HexTile") -> float:
+ """Return the distance between self and another HexTile."""
+ return (self - other).length()
+
+ def line_to(self, other: "HexTile") -> list["HexTile"]:
+ """Return a list of hexagons between self and another HexTile."""
+ return line(self, other)
+
+ def lerp_between(self, other: "HexTile", t: float) -> "HexTile":
+ """Perform a linear interpolation between self and another HexTile."""
+ return lerp(self, other, t)
+
+ def to_pixel(self, layout: Layout) -> Vec2:
+ """Convert a hexagon to pixel coordinates."""
+ return hextile_to_pixel(self, layout)
+
+ def to_offset(self, system: offset_system) -> "OffsetCoord":
+ """Convert a hexagon to offset coordinates."""
+ if system == "odd-r":
+ return roffset_from_cube(self, _ODD)
+ if system == "even-r":
+ return roffset_from_cube(self, _EVEN)
+ if system == "odd-q":
+ return qoffset_from_cube(self, _ODD)
+ if system == "even-q":
+ return qoffset_from_cube(self, _EVEN)
+
+ msg = "system must be odd-r, even-r, odd-q, or even-q"
+ raise ValueError(msg)
+
+
+def lerp(a: HexTile, b: HexTile, t: float) -> HexTile:
+ """Perform a linear interpolation between two hexagons."""
+ return HexTile(
+ a.q * (1.0 - t) + b.q * t,
+ a.r * (1.0 - t) + b.r * t,
+ a.s * (1.0 - t) + b.s * t,
+ )
+
+
+def distance(a: HexTile, b: HexTile) -> int:
+ """Return the distance between two hexagons."""
+ return (a - b).length()
+
+
+def line(a: HexTile, b: HexTile) -> list[HexTile]:
+ """Return a list of hexagons between two hexagons."""
+ n = distance(a, b)
+ # epsilon to nudge points by to falling on an edge
+ a_nudge = HexTile(a.q + 1e-06, a.r + 1e-06, a.s - 2e-06)
+ b_nudge = HexTile(b.q + 1e-06, b.r + 1e-06, b.s - 2e-06)
+ step = 1.0 / max(n, 1)
+ return [round(lerp(a_nudge, b_nudge, step * i)) for i in range(n + 1)]
+
+
+def hextile_to_pixel(h: HexTile, layout: Layout) -> Vec2:
+ """Convert axial hexagon coordinates to pixel coordinates."""
+ M = layout.orientation # noqa: N806
+ size = layout.size
+ origin = layout.origin
+ x = (M.f0 * h.q + M.f1 * h.r) * size.x
+ y = (M.f2 * h.q + M.f3 * h.r) * size.y
+ return Vec2(x + origin.x, y + origin.y)
+
+
+def pixel_to_hextile(
+ p: Vec2,
+ layout: Layout,
+) -> HexTile:
+ """Convert pixel coordinates to cubic hexagon coordinates."""
+ M = layout.orientation # noqa: N806
+ size = layout.size
+ origin = layout.origin
+ pt = Vec2((p.x - origin.x) / size.x, (p.y - origin.y) / size.y)
+ q = M.b0 * pt.x + M.b1 * pt.y
+ r = M.b2 * pt.x + M.b3 * pt.y
+ return HexTile(q, r, -q - r)
+
+
+def hextile_corner_offset(corner: int, layout: Layout) -> Vec2:
+ """Return the offset of a hexagon corner."""
+ # Hexagons have 6 corners
+ assert 0 <= corner < 6 # noqa: PLR2004
+ M = layout.orientation # noqa: N806
+ size = layout.size
+ angle = 2.0 * math.pi * (M.start_angle - corner) / 6.0
+ return Vec2(size.x * math.cos(angle), size.y * math.sin(angle))
+
+
+hextile_corners = tuple[Vec2, Vec2, Vec2, Vec2, Vec2, Vec2]
+
+
+def polygon_corners(h: HexTile, layout: Layout) -> hextile_corners:
+ """Return the corners of a hexagon in a list of pixels."""
+ corners = []
+ center = hextile_to_pixel(h, layout)
+ for i in range(6):
+ offset = hextile_corner_offset(i, layout)
+ corners.append(Vec2(center.x + offset.x, center.y + offset.y))
+ result = tuple(corners)
+ # Hexagons have 6 corners
+ assert len(result) == 6 # noqa: PLR2004
+ return cast("hextile_corners", result)
+
+
+@dataclass(frozen=True)
+class OffsetCoord:
+ """Offset coordinates."""
+
+ col: float
+ row: float
+
+ def to_cube(self, system: offset_system) -> HexTile:
+ """Convert offset coordinates to cube coordinates."""
+ if system == "odd-r":
+ return roffset_to_cube(self, _ODD)
+ if system == "even-r":
+ return roffset_to_cube(self, _EVEN)
+ if system == "odd-q":
+ return qoffset_to_cube(self, _ODD)
+ if system == "even-q":
+ return qoffset_to_cube(self, _EVEN)
+
+ msg = "system must be EVEN (+1) or ODD (-1)"
+ raise ValueError(msg)
+
+
+def qoffset_from_cube(h: HexTile, offset: Literal[-1, 1]) -> OffsetCoord:
+ """Convert a hexagon in cube coordinates to q offset coordinates."""
+ if offset not in (_EVEN, _ODD):
+ msg = "offset must be EVEN (+1) or ODD (-1)"
+ raise ValueError(msg)
+
+ col = h.q
+ row = h.r + (h.q + offset * (h.q & 1)) // 2 # type: ignore[operator]
+ return OffsetCoord(col, row)
+
+
+def qoffset_to_cube(h: OffsetCoord, offset: Literal[-1, 1]) -> HexTile:
+ """Convert a hexagon in q offset coordinates to cube coordinates."""
+ if offset not in (_EVEN, _ODD):
+ msg = "offset must be EVEN (+1) or ODD (-1)"
+ raise ValueError(msg)
+
+ q = h.col
+ r = h.row - (h.col + offset * (h.col & 1)) // 2 # type: ignore[operator]
+ s = -q - r
+ return HexTile(q, r, s)
+
+
+def roffset_from_cube(h: HexTile, offset: Literal[-1, 1]) -> OffsetCoord:
+ """Convert a hexagon in cube coordinates to r offset coordinates."""
+ if offset not in (_EVEN, _ODD):
+ msg = "offset must be EVEN (+1) or ODD (-1)"
+ raise ValueError(msg)
+
+ col = h.q + (h.r + offset * (h.r & 1)) // 2 # type: ignore[operator]
+ row = h.r
+ return OffsetCoord(col, row)
+
+
+def roffset_to_cube(h: OffsetCoord, offset: Literal[-1, 1]) -> HexTile:
+ """Convert a hexagon in r offset coordinates to cube coordinates."""
+ if offset not in (_EVEN, _ODD):
+ msg = "offset must be EVEN (+1) or ODD (-1)"
+ raise ValueError(msg)
+
+ q = h.col - (h.row + offset * (h.row & 1)) // 2 # type: ignore[operator]
+ r = h.row
+ s = -q - r
+ return HexTile(q, r, s)
diff --git a/arcade/resources/assets/images/spritesheets/hex_tilesheet.png b/arcade/resources/assets/images/spritesheets/hex_tilesheet.png
new file mode 100644
index 0000000000..28cf56785c
Binary files /dev/null and b/arcade/resources/assets/images/spritesheets/hex_tilesheet.png differ
diff --git a/arcade/resources/assets/tiled_maps/hex_map.tmj b/arcade/resources/assets/tiled_maps/hex_map.tmj
new file mode 100644
index 0000000000..0cec4af117
--- /dev/null
+++ b/arcade/resources/assets/tiled_maps/hex_map.tmj
@@ -0,0 +1,85 @@
+{ "compressionlevel":-1,
+ "height":20,
+ "hexsidelength":70,
+ "infinite":false,
+ "layers":[
+ {
+ "data":[3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
+ 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
+ 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3,
+ 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3,
+ 3, 3, 3, 2, 2, 2, 2, 2, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 2, 2, 2, 3, 3, 3,
+ 3, 3, 3, 2, 2, 2, 2, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 4, 4, 4, 5, 5, 5, 2, 2, 2, 3, 3, 3,
+ 3, 3, 3, 2, 2, 2, 2, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 2, 2, 2, 3, 3,
+ 3, 3, 3, 2, 2, 2, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 1, 1, 1, 1, 4, 5, 5, 2, 2, 2, 3, 3, 3,
+ 3, 3, 3, 2, 2, 2, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 1, 1, 1, 1, 4, 4, 5, 5, 2, 2, 2, 3, 3,
+ 3, 3, 3, 2, 2, 2, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 1, 1, 1, 1, 1, 4, 4, 5, 2, 2, 2, 3, 3, 3,
+ 3, 3, 3, 2, 2, 2, 5, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 1, 1, 1, 1, 4, 4, 5, 5, 2, 2, 2, 3, 3,
+ 3, 3, 3, 2, 2, 2, 5, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 1, 1, 1, 4, 4, 5, 5, 5, 2, 2, 2, 3, 3,
+ 3, 3, 3, 2, 2, 2, 5, 5, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 2, 2, 2, 3, 3,
+ 3, 3, 2, 2, 2, 2, 2, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 4, 4, 4, 5, 5, 5, 5, 2, 2, 2, 2, 3, 3,
+ 3, 3, 3, 2, 2, 2, 2, 2, 2, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 2, 2, 2, 2, 3, 3,
+ 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 5, 5, 5, 5, 5, 5, 5, 2, 2, 2, 2, 2, 3, 3, 3,
+ 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3,
+ 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3,
+ 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3,
+ 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
+ "height":20,
+ "id":1,
+ "name":"TILE",
+ "opacity":1,
+ "type":"tilelayer",
+ "visible":true,
+ "width":30,
+ "x":0,
+ "y":0
+ },
+ {
+ "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 10, 0, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 10, 10, 0, 0, 0, 0, 12, 12, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 10, 10, 7, 0, 0, 9, 12, 12, 12, 0, 16, 0, 6, 14, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 10, 10, 10, 0, 0, 0, 9, 0, 0, 33, 0, 0, 0, 0, 19, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 10, 10, 10, 0, 0, 0, 0, 0, 21, 0, 0, 11, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 10, 10, 7, 0, 0, 0, 0, 34, 34, 0, 0, 0, 29, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 0, 34, 34, 34, 34, 34, 34, 34, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 34, 34, 34, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ "height":20,
+ "id":2,
+ "name":"POI",
+ "opacity":1,
+ "type":"tilelayer",
+ "visible":true,
+ "width":30,
+ "x":0,
+ "y":0
+ }],
+ "nextlayerid":3,
+ "nextobjectid":1,
+ "orientation":"hexagonal",
+ "renderorder":"right-down",
+ "staggeraxis":"y",
+ "staggerindex":"odd",
+ "tiledversion":"1.11.2",
+ "tileheight":140,
+ "tilesets":[
+ {
+ "firstgid":1,
+ "source":"hex_tilesheet.tsj"
+ }],
+ "tilewidth":120,
+ "type":"map",
+ "version":"1.11",
+ "width":30
+}
\ No newline at end of file
diff --git a/arcade/resources/assets/tiled_maps/hex_tilesheet.tsj b/arcade/resources/assets/tiled_maps/hex_tilesheet.tsj
new file mode 100644
index 0000000000..0351002747
--- /dev/null
+++ b/arcade/resources/assets/tiled_maps/hex_tilesheet.tsj
@@ -0,0 +1,91 @@
+{ "columns":5,
+ "image":"..\/images\/spritesheets\/hex_tilesheet.png",
+ "imageheight":994,
+ "imagewidth":610,
+ "margin":1,
+ "name":"tilesheet",
+ "spacing":2,
+ "tilecount":35,
+ "tiledversion":"1.11.2",
+ "tileheight":140,
+ "tiles":[
+ {
+ "id":0,
+ "type":"cement"
+ },
+ {
+ "id":1,
+ "type":"desert"
+ },
+ {
+ "id":2,
+ "type":"wasteland"
+ },
+ {
+ "id":3,
+ "type":"grass"
+ },
+ {
+ "id":4,
+ "type":"dirt"
+ },
+ {
+ "id":5,
+ "type":"yellow_warehouse"
+ },
+ {
+ "id":6,
+ "type":"mine"
+ },
+ {
+ "id":8,
+ "type":"barracks"
+ },
+ {
+ "id":9,
+ "type":"mountain"
+ },
+ {
+ "id":10,
+ "type":"gas station"
+ },
+ {
+ "id":11,
+ "type":"farm"
+ },
+ {
+ "id":13,
+ "type":"house_1"
+ },
+ {
+ "id":15,
+ "type":"corner store"
+ },
+ {
+ "id":18,
+ "type":"house_2"
+ },
+ {
+ "id":20,
+ "type":"camp"
+ },
+ {
+ "id":23,
+ "type":"house_3"
+ },
+ {
+ "id":28,
+ "type":"house_4"
+ },
+ {
+ "id":32,
+ "type":"construction"
+ },
+ {
+ "id":33,
+ "type":"forest"
+ }],
+ "tilewidth":120,
+ "type":"tileset",
+ "version":"1.10"
+}
\ No newline at end of file
diff --git a/arcade/tilemap/tilemap.py b/arcade/tilemap/tilemap.py
index b107df8192..f1415ae86c 100644
--- a/arcade/tilemap/tilemap.py
+++ b/arcade/tilemap/tilemap.py
@@ -29,6 +29,7 @@
TextureAnimationSprite,
TextureKeyframe,
get_window,
+ hexagon,
)
from arcade.hitbox import HitBoxAlgorithm, RotatableHitBox
from arcade.types import RGBA255
@@ -153,6 +154,10 @@ class TileMap:
SpriteLists will be created lazily.
texture_cache_manager:
The texture cache manager to use for loading textures.
+ hex_layout:
+ The hex layout to use for the map. If not supplied, the map will be
+ treated as a square map. If supplied, the map will be treated as a hexagonal map.
+
The ``layer_options`` parameter can be used to specify per layer arguments.
The available options for this are:
@@ -234,6 +239,7 @@ def __init__(
texture_atlas: TextureAtlasBase | None = None,
lazy: bool = False,
texture_cache_manager: arcade.TextureCacheManager | None = None,
+ hex_layout: hexagon.Layout | None = None,
) -> None:
if not map_file and not tiled_map:
raise AttributeError(
@@ -261,6 +267,8 @@ def __init__(
except RuntimeError:
pass
+ self.hex_layout = hex_layout
+
self._lazy = lazy
self.texture_cache_manager = texture_cache_manager or arcade.texture.default_texture_cache
@@ -771,13 +779,79 @@ def _process_tile_layer(
atlas=texture_atlas,
lazy=self._lazy,
)
+
+ if self.hex_layout is None:
+ map_array = layer.data
+ if TYPE_CHECKING:
+ # Can never be None because we already detect and reject infinite maps
+ assert map_array
+
+ # Loop through the layer and add in the list
+ for row_index, row in enumerate(map_array):
+ for column_index, item in enumerate(row):
+ # Check for an empty tile
+ if item == 0:
+ continue
+
+ tile = self._get_tile_by_gid(item)
+ if tile is None:
+ raise ValueError(
+ f"Couldn't find tile for item {item} in layer "
+ f"'{layer.name}' in file '{self.tiled_map.map_file}'"
+ f"at ({column_index}, {row_index})."
+ )
+
+ my_sprite = self._create_sprite_from_tile(
+ tile,
+ scaling=scaling,
+ hit_box_algorithm=hit_box_algorithm,
+ custom_class=custom_class,
+ custom_class_args=custom_class_args,
+ )
+
+ if my_sprite is None:
+ print(
+ f"Warning: Could not create sprite number {item} "
+ f"in layer '{layer.name}' {tile.image}"
+ )
+ else:
+ my_sprite.center_x = (
+ column_index * (self.tiled_map.tile_size[0] * scaling)
+ + my_sprite.width / 2
+ ) + offset[0]
+ my_sprite.center_y = (
+ (self.tiled_map.map_size.height - row_index - 1)
+ * (self.tiled_map.tile_size[1] * scaling)
+ + my_sprite.height / 2
+ ) + offset[1]
+
+ # Tint
+ if layer.tint_color:
+ my_sprite.color = ArcadeColor.from_iterable(layer.tint_color)
+
+ # Opacity
+ opacity = layer.opacity
+ if opacity:
+ my_sprite.alpha = int(opacity * 255)
+
+ sprite_list.visible = layer.visible
+ sprite_list.append(my_sprite)
+
+ if layer.properties:
+ sprite_list.properties = layer.properties
+
+ return sprite_list
+
+ # Hexagonal map
map_array = layer.data
if TYPE_CHECKING:
# Can never be None because we already detect and reject infinite maps
assert map_array
+ # FIXME: get tile size from tileset
+
# Loop through the layer and add in the list
- for row_index, row in enumerate(map_array):
+ for row_index, row in enumerate(reversed(map_array)):
for column_index, item in enumerate(row):
# Check for an empty tile
if item == 0:
@@ -785,11 +859,12 @@ def _process_tile_layer(
tile = self._get_tile_by_gid(item)
if tile is None:
- raise ValueError(
+ msg = (
f"Couldn't find tile for item {item} in layer "
f"'{layer.name}' in file '{self.tiled_map.map_file}'"
f"at ({column_index}, {row_index})."
)
+ raise ValueError(msg)
my_sprite = self._create_sprite_from_tile(
tile,
@@ -805,14 +880,17 @@ def _process_tile_layer(
f"in layer '{layer.name}' {tile.image}"
)
else:
- my_sprite.center_x = (
- column_index * (self.tiled_map.tile_size[0] * scaling) + my_sprite.width / 2
- ) + offset[0]
- my_sprite.center_y = (
- (self.tiled_map.map_size.height - row_index - 1)
- * (self.tiled_map.tile_size[1] * scaling)
- + my_sprite.height / 2
- ) + offset[1]
+ # FIXME: handle map scaling
+ # Convert from odd-r offset to cube coordinates
+ offset_coord = hexagon.OffsetCoord(column_index, row_index)
+ hex_ = offset_coord.to_cube("even-r")
+
+ # Convert hex position to pixel position
+ pixel_pos = hex_.to_pixel(self.hex_layout)
+ # FIXME: why is the y position negative?
+ pixel_pos = hexagon.Vec2(pixel_pos.x, pixel_pos.y)
+ my_sprite.center_x = pixel_pos.x
+ my_sprite.center_y = pixel_pos.y
# Tint
if layer.tint_color:
@@ -1033,6 +1111,7 @@ def load_tilemap(
offset: Vec2 = Vec2(0, 0),
texture_atlas: DefaultTextureAtlas | None = None,
lazy: bool = False,
+ hex_layout: hexagon.Layout | None = None,
) -> TileMap:
"""
Given a .json map file, loads in and returns a `TileMap` object.
@@ -1066,6 +1145,9 @@ def load_tilemap(
If not supplied the global default atlas will be used.
lazy:
SpriteLists will be created lazily.
+ hex_layout:
+ The hex layout to use for the map. If not supplied, the map will be
+ treated as a square map. If supplied, the map will be treated as a hexagonal map.
"""
return TileMap(
map_file=map_file,
@@ -1076,4 +1158,5 @@ def load_tilemap(
offset=offset,
texture_atlas=texture_atlas,
lazy=lazy,
+ hex_layout=hex_layout,
)
diff --git a/doc/tutorials/hex_map/index.rst b/doc/tutorials/hex_map/index.rst
new file mode 100644
index 0000000000..d28d2c1110
--- /dev/null
+++ b/doc/tutorials/hex_map/index.rst
@@ -0,0 +1,267 @@
+.. _hex_map_tutorial:
+
+Working with Hexagonal Tilemaps
+===============================
+
+This tutorial covers how to load and display hexagonal tilemaps in Arcade using
+the `Tiled`_ map editor and Arcade's :mod:`arcade.hexagon` module.
+
+You don't need to understand all the math behind hexagonal grids to follow this
+tutorial, but if you're building something with hexes,
+`Red Blob Games' guide to hexagonal grids `_
+is an invaluable resource for understanding the coordinate systems, algorithms, and
+geometry involved.
+
+.. _Tiled: https://www.mapeditor.org/
+
+Hexagonal Coordinate Systems
+-----------------------------
+
+Hexagonal grids use a different coordinate system than square grids. Arcade's
+:class:`~arcade.hexagon.HexTile` class uses **cube coordinates** (q, r, s) where
+``q + r + s = 0`` always holds true. This constraint means you only need two of the
+three values to uniquely identify a hex, but keeping all three makes the math simpler.
+
+.. code-block:: python
+
+ from arcade.hexagon import HexTile
+
+ # Create a hex tile at the origin
+ origin = HexTile(0, 0, 0)
+
+ # A neighbor one step in the q direction
+ neighbor = HexTile(1, -1, 0)
+
+ # Distance between two hexes
+ dist = origin.distance_to(neighbor) # 1
+
+Cube coordinates are great for math, but map editors like Tiled use **offset
+coordinates** (column, row). Arcade handles the conversion automatically when
+loading maps, but you can also convert manually:
+
+.. code-block:: python
+
+ from arcade.hexagon import HexTile, OffsetCoord
+
+ # Convert from offset to cube
+ offset = OffsetCoord(col=3, row=2)
+ cube = offset.to_cube("even-r")
+
+ # Convert back
+ offset_again = cube.to_offset("even-r")
+
+The offset system must match your map's configuration. Tiled hex maps typically
+use ``"even-r"`` (staggered rows, even rows shifted right).
+
+Hex Layout
+----------
+
+A :class:`~arcade.hexagon.Layout` defines how hex coordinates map to pixel positions
+on screen. It contains three pieces of information:
+
+1. **Orientation** -- pointy-top or flat-top hexagons
+2. **Size** -- the size of each hexagon in pixels
+3. **Origin** -- the pixel position of hex (0, 0, 0)
+
+.. code-block:: python
+
+ from pyglet.math import Vec2
+ from arcade.hexagon import Layout, pointy_orientation, flat_orientation
+
+ # Pointy-top hexagons (what Tiled uses)
+ layout = Layout(
+ orientation=pointy_orientation,
+ size=Vec2(40.0, 40.0),
+ origin=Vec2(0.0, 0.0),
+ )
+
+.. note::
+
+ Tiled always uses **pointy-top** orientation for hexagonal maps.
+ Use ``pointy_orientation`` when loading Tiled hex maps.
+
+Computing the Hex Size from Tiled
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The ``size`` in a Layout is **not** the same as the tile width/height in Tiled.
+For pointy-top hexagons, the conversion is:
+
+.. code-block:: python
+
+ import math
+
+ # From your Tiled map properties
+ tile_width = 120 # tilewidth in the .tmj file
+ tile_height = 140 # tileheight in the .tmj file
+
+ hex_size_x = tile_width / math.sqrt(3)
+ hex_size_y = tile_height / 2
+
+ layout = Layout(
+ orientation=pointy_orientation,
+ size=Vec2(hex_size_x, hex_size_y),
+ origin=Vec2(0, 0),
+ )
+
+These values come from the relationship between a hexagon's size and its bounding
+rectangle. See the `Red Blob Games size reference
+`_ for the full
+derivation.
+
+Loading a Hex Map from Tiled
+-----------------------------
+
+Loading a hexagonal tilemap works just like loading a regular tilemap, with one
+extra parameter: ``hex_layout``. This tells Arcade how to convert hex coordinates
+to pixel positions.
+
+Here is a complete example:
+
+.. code-block:: python
+
+ import math
+ from pyglet.math import Vec2
+
+ import arcade
+ from arcade import hexagon
+
+ WINDOW_WIDTH = 1280
+ WINDOW_HEIGHT = 720
+
+
+ class GameView(arcade.View):
+
+ def __init__(self):
+ super().__init__()
+ self.tile_map: arcade.TileMap
+ self.scene: arcade.Scene
+
+ def reset(self):
+ # Tiled always uses pointy-top orientation
+ orientation = hexagon.pointy_orientation
+
+ # Calculate hex size from the tile dimensions in your .tmj file
+ hex_size_x = 120 / math.sqrt(3)
+ hex_size_y = 140 / 2
+
+ hex_layout = hexagon.Layout(
+ orientation=orientation,
+ size=Vec2(hex_size_x, hex_size_y),
+ origin=Vec2(0, 0),
+ )
+
+ # Load the hex tilemap -- note the hex_layout parameter
+ self.tile_map = arcade.load_tilemap(
+ ":resources:tiled_maps/hex_map.tmj",
+ hex_layout=hex_layout,
+ use_spatial_hash=True,
+ )
+
+ # Create a Scene from the tilemap, just like a square map
+ self.scene = arcade.Scene.from_tilemap(self.tile_map)
+
+ self.background_color = arcade.color.BLACK
+
+ def on_draw(self):
+ self.clear()
+ self.scene.draw()
+
+
+ def main():
+ window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, "Hex Map")
+ game = GameView()
+ window.show_view(game)
+ game.reset()
+ arcade.run()
+
+
+ if __name__ == "__main__":
+ main()
+
+The key difference from a regular tilemap is the ``hex_layout`` parameter passed to
+:func:`arcade.load_tilemap`. Without it, Arcade treats the map as a square grid.
+With it, Arcade uses hex coordinate math to position each tile correctly.
+
+Everything else -- creating a Scene, drawing, using layers -- works the same as
+with square tilemaps. See :ref:`platformer_part_twelve` for the general tilemap
+loading tutorial.
+
+Working with Hex Tiles in Code
+-------------------------------
+
+The :class:`~arcade.hexagon.HexTile` class provides several useful operations:
+
+**Neighbors and Distance**
+
+.. code-block:: python
+
+ tile = HexTile(2, -1, -1)
+
+ # Get all 6 neighbors
+ neighbors = tile.neighbors()
+
+ # Get a specific neighbor by direction (0-5)
+ north = tile.neighbor(0)
+
+ # Distance between two hexes
+ dist = tile.distance_to(HexTile(0, 0, 0)) # 2
+
+**Rotation**
+
+.. code-block:: python
+
+ tile = HexTile(1, -3, 2)
+
+ rotated_right = tile.rotate_right() # HexTile(3, -2, -1)
+ rotated_left = tile.rotate_left() # HexTile(-2, -1, 3)
+
+**Line Drawing**
+
+.. code-block:: python
+
+ from arcade.hexagon import line
+
+ # Get all hexes on a line between two points
+ path = line(HexTile(0, 0, 0), HexTile(3, -3, 0))
+
+**Pixel Conversion**
+
+.. code-block:: python
+
+ from arcade.hexagon import hextile_to_pixel, pixel_to_hextile
+
+ # Convert a hex to pixel coordinates
+ pixel_pos = hextile_to_pixel(tile, layout)
+
+ # Convert a mouse click back to hex coordinates
+ hex_tile = pixel_to_hextile(Vec2(mouse_x, mouse_y), layout)
+ snapped = round(hex_tile) # Round to nearest hex
+
+Creating a Hex Map in Tiled
+-----------------------------
+
+To create your own hexagonal map in Tiled:
+
+1. Open Tiled and select **File > New > New Map**
+2. Set **Map Orientation** to ``Hexagonal``
+3. Set **Tile Render Order** to ``Right Down``
+4. Set **Stagger Axis** to ``Y`` and **Stagger Index** to ``Odd``
+5. Set your desired **Tile Width** and **Tile Height**
+6. Set the **Hex Side Length** (this controls how "tall" the flat edges are)
+7. Create your tileset and start painting
+
+When loading the map in Arcade, make sure your Layout's ``size`` values match
+the tile dimensions as shown in the `Computing the Hex Size from Tiled`_ section
+above.
+
+Full Example
+------------
+
+For a complete working example with camera controls (panning and zooming), see
+the built-in hex map example:
+
+.. code-block:: bash
+
+ python -m arcade.examples.hex_map
+
+Source: ``arcade/examples/hex_map.py``
diff --git a/doc/tutorials/index.rst b/doc/tutorials/index.rst
index d18c5d035a..f5889f7da1 100644
--- a/doc/tutorials/index.rst
+++ b/doc/tutorials/index.rst
@@ -18,4 +18,5 @@ Various tutorials of varying difficulty and complexity.
compiling_with_nuitka/index
shader_tutorials
menu/index
+ hex_map/index
framebuffer/index
diff --git a/tests/unit/test_hexagon.py b/tests/unit/test_hexagon.py
new file mode 100644
index 0000000000..ca19893b68
--- /dev/null
+++ b/tests/unit/test_hexagon.py
@@ -0,0 +1,167 @@
+from arcade.hexagon import (
+ HexTile,
+ Layout,
+ OffsetCoord,
+ flat_orientation,
+ hextile_to_pixel,
+ line,
+ pixel_to_hextile,
+ pointy_orientation,
+)
+from pyglet.math import Vec2
+
+# TODO: grab the rest of the tests from my main machine
+
+
+def equal_offset_coord(name, a, b):
+ assert a.col == b.col and a.row == b.row
+
+
+def equal_doubled_coord(name, a, b):
+ assert a.col == b.col and a.row == b.row
+
+
+def test_hex_equality():
+ assert HexTile(3, 4, -7) == HexTile(3, 4, -7)
+ assert HexTile(3, 4, -7) != HexTile(3, 3, -6)
+ assert HexTile(3, 4, -7) != HexTile(0, 0, 0)
+ assert HexTile(3, 4, -7) != HexTile(4, -7, 3)
+
+
+def test_hex_pixel_roundtrip():
+ flat = Layout(flat_orientation, Vec2(10.0, 15.0), Vec2(35.0, 71.0))
+ pointy = Layout(pointy_orientation, Vec2(10.0, 15.0), Vec2(35.0, 71.0))
+
+ h = HexTile(3, 4, -7)
+ assert h == round(pixel_to_hextile(hextile_to_pixel(h, flat), flat))
+ assert h == round(pixel_to_hextile(hextile_to_pixel(h, pointy), pointy))
+
+
+def test_list_of_hexes():
+ assert [
+ HexTile(0, 0, 0),
+ HexTile(0, -1, 1),
+ HexTile(0, -2, 2),
+ ] == [
+ HexTile(0, 0, 0),
+ HexTile(0, -1, 1),
+ HexTile(0, -2, 2),
+ ]
+
+ assert [HexTile(0, 0, 0), HexTile(0, -1, 1)] != [HexTile(0, 0, 0)]
+
+ assert [HexTile(0, 0, 0), HexTile(0, -1, 1)] != [HexTile(0, -1, 1)]
+
+ assert [HexTile(0, 0, 0), HexTile(0, -1, 1)] != [HexTile(0, -1, 1), HexTile(0, 0, 0)]
+
+ assert HexTile(0, 0, 0) in [HexTile(0, 0, 0), HexTile(0, -1, 1)]
+
+ assert HexTile(0, 0, 0) not in [HexTile(0, -1, 1), HexTile(0, -2, 2)]
+
+
+def test_hex_arithmetic():
+ assert HexTile(4, -10, 6) == HexTile(1, -3, 2) + HexTile(3, -7, 4)
+ assert HexTile(-2, 4, -2) == HexTile(1, -3, 2) - HexTile(3, -7, 4)
+
+
+def test_hex_direction():
+ assert HexTile(0, -1, 1) == HexTile.direction(2)
+
+
+def test_hex_neighbor():
+ assert HexTile(1, -3, 2) == HexTile(1, -2, 1).neighbor(2)
+
+
+def test_hex_diagonal():
+ assert HexTile(-1, -1, 2) == HexTile(1, -2, 1).diagonal_neighbor(3)
+
+
+def test_hex_distance():
+ assert 7 == HexTile(3, -7, 4).distance_to(HexTile(0, 0, 0))
+
+
+def test_hex_rotate_right():
+ assert HexTile(1, -3, 2).rotate_right() == HexTile(3, -2, -1)
+
+
+def test_hex_rotate_left():
+ assert HexTile(1, -3, 2).rotate_left() == HexTile(-2, -1, 3)
+
+
+def test_hex_round():
+ a = HexTile(0.0, 0.0, 0.0)
+ b = HexTile(1.0, -1.0, 0.0)
+ c = HexTile(0.0, -1.0, 1.0)
+ assert HexTile(5, -10, 5) == round(HexTile(0.0, 0.0, 0.0).lerp_between(HexTile(10.0, -20.0, 10.0), 0.5))
+ assert round(a) == round(a.lerp_between(b, 0.499))
+ assert round(b) == round(a.lerp_between(b, 0.501))
+
+ assert round(a) == round(
+ HexTile(
+ a.q * 0.4 + b.q * 0.3 + c.q * 0.3,
+ a.r * 0.4 + b.r * 0.3 + c.r * 0.3,
+ a.s * 0.4 + b.s * 0.3 + c.s * 0.3,
+ )
+ )
+
+ assert round(c) == round(
+ HexTile(
+ a.q * 0.3 + b.q * 0.3 + c.q * 0.4,
+ a.r * 0.3 + b.r * 0.3 + c.r * 0.4,
+ a.s * 0.3 + b.s * 0.3 + c.s * 0.4,
+ )
+ )
+
+
+def test_hex_line_draw():
+ assert [
+ HexTile(0, 0, 0),
+ HexTile(0, -1, 1),
+ HexTile(0, -2, 2),
+ HexTile(1, -3, 2),
+ HexTile(1, -4, 3),
+ HexTile(1, -5, 4),
+ ] == line(HexTile(0, 0, 0), HexTile(1, -5, 4))
+
+
+def test_layout():
+ h = HexTile(3, 4, -7)
+ flat = Layout(flat_orientation, Vec2(10.0, 15.0), Vec2(35.0, 71.0))
+
+ assert h == round(pixel_to_hextile(hextile_to_pixel(h, flat), flat))
+
+ pointy = Layout(pointy_orientation, Vec2(10.0, 15.0), Vec2(35.0, 71.0))
+ assert h == round(pixel_to_hextile(hextile_to_pixel(h, pointy), pointy))
+
+
+def test_offset_roundtrip():
+ a = HexTile(3, 4, -7)
+ b = OffsetCoord(1, -3)
+
+ assert a == a.to_offset("even-q").to_cube("even-q")
+
+ assert b == b.to_cube("even-q").to_offset("even-q")
+
+ assert a == a.to_offset("odd-q").to_cube("odd-q")
+
+ assert b == b.to_cube("odd-q").to_offset("odd-q")
+
+ assert a == a.to_offset("even-r").to_cube("even-r")
+
+ assert b == b.to_cube("even-r").to_offset("even-r")
+
+ assert a == a.to_offset("odd-r").to_cube("odd-r")
+
+ assert b == b.to_cube("odd-r").to_offset("odd-r")
+
+
+def test_offset_from_cube():
+ assert OffsetCoord(1, 3) == HexTile(1, 2, -3).to_offset("even-q")
+
+ assert OffsetCoord(1, 2) == HexTile(1, 2, -3).to_offset("odd-q")
+
+
+def test_offset_to_cube():
+ assert HexTile(1, 2, -3) == OffsetCoord(1, 3).to_cube("even-q")
+
+ assert HexTile(1, 2, -3) == OffsetCoord(1, 2).to_cube("odd-q")