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")