diff --git a/.gitignore b/.gitignore index 8d2b38dd22..8248d7cfe1 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,5 @@ temp/ # pending: Sphinx 8.1.4 + deps are verified as working with Arcade # see util/sphinx_static_file_temp_fix.py -.ENABLE_DEVMACHINE_SPHINX_STATIC_FIX \ No newline at end of file +.ENABLE_DEVMACHINE_SPHINX_STATIC_FIX +uv.lock 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..edf32b5b85 --- /dev/null +++ b/arcade/hexagon.py @@ -0,0 +1,343 @@ +"""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 Hex: + """A hexagon in cube coordinates.""" + + 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: "Hex") -> "Hex": + """Add two hexagons.""" + return Hex(self.q + other.q, self.r + other.r, self.s + other.s) + + def __sub__(self, other: "Hex") -> "Hex": + """Subtract two hexagons.""" + return Hex(self.q - other.q, self.r - other.r, self.s - other.s) + + def __mul__(self, k: int) -> "Hex": + """Multiply a hexagon by a scalar.""" + return Hex(self.q * k, self.r * k, self.s * k) + + def __neg__(self) -> "Hex": + """Negate a hexagon.""" + return Hex(-self.q, -self.r, -self.s) + + def __round__(self) -> "Hex": + """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 Hex(qi, ri, si) + + def rotate_left(self) -> "Hex": + """Rotate a hexagon to the left.""" + return Hex(-self.s, -self.q, -self.r) + + def rotate_right(self) -> "Hex": + """Rotate a hexagon to the right.""" + return Hex(-self.r, -self.s, -self.q) + + @staticmethod + def direction(direction: int) -> "Hex": + """Return a relative hexagon in a given direction.""" + hex_directions = [ + Hex(1, 0, -1), + Hex(1, -1, 0), + Hex(0, -1, 1), + Hex(-1, 0, 1), + Hex(-1, 1, 0), + Hex(0, 1, -1), + ] + return hex_directions[direction] + + def neighbor(self, direction: int) -> "Hex": + """Return the neighbor in a given direction.""" + return self + self.direction(direction) + + def neighbors(self) -> list["Hex"]: + """Return the neighbors of a hexagon.""" + return [self.neighbor(i) for i in range(6)] + + def diagonal_neighbor(self, direction: int) -> "Hex": + """Return the diagonal neighbor in a given direction.""" + hex_diagonals = [ + Hex(2, -1, -1), + Hex(1, -2, 1), + Hex(-1, -1, 2), + Hex(-2, 1, 1), + Hex(-1, 2, -1), + Hex(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: "Hex") -> float: + """Return the distance between self and another Hex.""" + return (self - other).length() + + def line_to(self, other: "Hex") -> list["Hex"]: + """Return a list of hexagons between self and another Hex.""" + return line(self, other) + + def lerp_between(self, other: "Hex", t: float) -> "Hex": + """Perform a linear interpolation between self and another Hex.""" + return lerp(self, other, t) + + def to_pixel(self, layout: Layout) -> Vec2: + """Convert a hexagon to pixel coordinates.""" + return hex_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: Hex, b: Hex, t: float) -> Hex: + """Perform a linear interpolation between two hexagons.""" + return Hex( + 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: Hex, b: Hex) -> int: + """Return the distance between two hexagons.""" + return (a - b).length() + + +def line(a: Hex, b: Hex) -> list[Hex]: + """Return a list of hexagons between two hexagons.""" + n = distance(a, b) + # epsilon to nudge points by to falling on an edge + a_nudge = Hex(a.q + 1e-06, a.r + 1e-06, a.s - 2e-06) + b_nudge = Hex(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 hex_to_pixel(h: Hex, 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_hex( + p: Vec2, + layout: Layout, +) -> Hex: + """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 Hex(q, r, -q - r) + + +def hex_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)) + + +hex_corners = tuple[Vec2, Vec2, Vec2, Vec2, Vec2, Vec2] + + +def polygon_corners(h: Hex, layout: Layout) -> hex_corners: + """Return the corners of a hexagon in a list of pixels.""" + corners = [] + center = hex_to_pixel(h, layout) + for i in range(6): + offset = hex_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("hex_corners", result) + + +@dataclass(frozen=True) +class OffsetCoord: + """Offset coordinates.""" + + col: float + row: float + + def to_cube(self, system: offset_system) -> Hex: + """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: Hex, 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]) -> Hex: + """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 Hex(q, r, s) + + +def roffset_from_cube(h: Hex, 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]) -> Hex: + """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 Hex(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 3828c6c9c8..7d1a4a9e07 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: @@ -1032,6 +1110,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. @@ -1065,6 +1144,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, @@ -1075,4 +1157,5 @@ def load_tilemap( offset=offset, texture_atlas=texture_atlas, lazy=lazy, + hex_layout=hex_layout, ) diff --git a/pyproject.toml b/pyproject.toml index ffd028c5d3..3f3b12bb4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,7 +94,7 @@ exclude = [ ".pytest_cache", "temp", "bugs", - "arcade/examples/platform_tutorial", + "arcade/examples/*", ] lint.ignore = [ "E731", # E731 do not assign a lambda expression, use a def diff --git a/tests/unit/test_hexagon.py b/tests/unit/test_hexagon.py new file mode 100644 index 0000000000..fa8029abfc --- /dev/null +++ b/tests/unit/test_hexagon.py @@ -0,0 +1,167 @@ +from arcade.hexagon import ( + Hex, + Layout, + OffsetCoord, + flat_orientation, + hex_to_pixel, + line, + pixel_to_hex, + 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 Hex(3, 4, -7) == Hex(3, 4, -7) + assert Hex(3, 4, -7) != Hex(3, 3, -6) + assert Hex(3, 4, -7) != Hex(0, 0, 0) + assert Hex(3, 4, -7) != Hex(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 = Hex(3, 4, -7) + assert h == round(pixel_to_hex(hex_to_pixel(h, flat), flat)) + assert h == round(pixel_to_hex(hex_to_pixel(h, pointy), pointy)) + + +def test_list_of_hexes(): + assert [ + Hex(0, 0, 0), + Hex(0, -1, 1), + Hex(0, -2, 2), + ] == [ + Hex(0, 0, 0), + Hex(0, -1, 1), + Hex(0, -2, 2), + ] + + assert [Hex(0, 0, 0), Hex(0, -1, 1)] != [Hex(0, 0, 0)] + + assert [Hex(0, 0, 0), Hex(0, -1, 1)] != [Hex(0, -1, 1)] + + assert [Hex(0, 0, 0), Hex(0, -1, 1)] != [Hex(0, -1, 1), Hex(0, 0, 0)] + + assert Hex(0, 0, 0) in [Hex(0, 0, 0), Hex(0, -1, 1)] + + assert Hex(0, 0, 0) not in [Hex(0, -1, 1), Hex(0, -2, 2)] + + +def test_hex_arithmetic(): + assert Hex(4, -10, 6) == Hex(1, -3, 2) + Hex(3, -7, 4) + assert Hex(-2, 4, -2) == Hex(1, -3, 2) - Hex(3, -7, 4) + + +def test_hex_direction(): + assert Hex(0, -1, 1) == Hex.direction(2) + + +def test_hex_neighbor(): + assert Hex(1, -3, 2) == Hex(1, -2, 1).neighbor(2) + + +def test_hex_diagonal(): + assert Hex(-1, -1, 2) == Hex(1, -2, 1).diagonal_neighbor(3) + + +def test_hex_distance(): + assert 7 == Hex(3, -7, 4).distance_to(Hex(0, 0, 0)) + + +def test_hex_rotate_right(): + assert Hex(1, -3, 2).rotate_right() == Hex(3, -2, -1) + + +def test_hex_rotate_left(): + assert Hex(1, -3, 2).rotate_left() == Hex(-2, -1, 3) + + +def test_hex_round(): + a = Hex(0.0, 0.0, 0.0) + b = Hex(1.0, -1.0, 0.0) + c = Hex(0.0, -1.0, 1.0) + assert Hex(5, -10, 5) == round(Hex(0.0, 0.0, 0.0).lerp_between(Hex(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( + Hex( + 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( + Hex( + 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 [ + Hex(0, 0, 0), + Hex(0, -1, 1), + Hex(0, -2, 2), + Hex(1, -3, 2), + Hex(1, -4, 3), + Hex(1, -5, 4), + ] == line(Hex(0, 0, 0), Hex(1, -5, 4)) + + +def test_layout(): + h = Hex(3, 4, -7) + flat = Layout(flat_orientation, Vec2(10.0, 15.0), Vec2(35.0, 71.0)) + + assert h == round(pixel_to_hex(hex_to_pixel(h, flat), flat)) + + pointy = Layout(pointy_orientation, Vec2(10.0, 15.0), Vec2(35.0, 71.0)) + assert h == round(pixel_to_hex(hex_to_pixel(h, pointy), pointy)) + + +def test_offset_roundtrip(): + a = Hex(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) == Hex(1, 2, -3).to_offset("even-q") + + assert OffsetCoord(1, 2) == Hex(1, 2, -3).to_offset("odd-q") + + +def test_offset_to_cube(): + assert Hex(1, 2, -3) == OffsetCoord(1, 3).to_cube("even-q") + + assert Hex(1, 2, -3) == OffsetCoord(1, 2).to_cube("odd-q")