diff --git a/docs/Guides/CameraGuide.md b/docs/Guides/CameraGuide.md new file mode 100644 index 0000000..f7db1e1 --- /dev/null +++ b/docs/Guides/CameraGuide.md @@ -0,0 +1,53 @@ +# Camera Guide + +The word `world` refers in this context to the `pytmx` map, which is the game world where the player interacts with +tiles. + +## Overview + +This document explains how to handle camera offset and scale in a game development context. Understanding these concepts +is crucial for accurate rendering and interaction with the game world. + +## Camera Offset + +- **Purpose**: The camera offset shifts the visible area of the game world to center the player or a specific point of + interest on the screen. +- **Effect**: The player stays centered or the view follows the player. +- **Conversion**: When converting screen (mouse) coordinates to world coordinates, the camera offset must be subtracted + from the mouse position, it's necessary to adjust for the camera offset to get the correct position in the game world. + +## Camera Scale (Zoom) + +- **Purpose**:The camera scale changes the size of the tiles on the screen. +- **Effect**: This affects how the game world is displayed and how the player interacts with it. +- **Conversion**: The scale factor is used to convert between pixel coordinates and grid coordinates. +- To convert between screen and world coordinates, you must divide by the camera scale. + +## Importance + +Properly accounting for camera offset and scale is essential for: + +- Ensuring that the game world is rendered correctly on the screen. +- Allowing accurate interaction with the game world, such as clicking on tiles or objects. +- Maintaining consistency between the visual representation and the underlying game logic. +- Making sure that mouse clicks correspond to the correct positions in the game world. + +## In Code + +```python +# Constants +TILE_SIZE = 16 # Define the size of each tile in pixels +camera_offset_x, camera_offset_y = 100, 50 # Example camera offset values +camera_scale = 1.5 # Example camera scale value + +# Mouse position +mouse_x, mouse_y = 320, 240 # Example mouse coordinates + +# Convert mouse position to world coordinates +world_x = (mouse_x - camera_offset_x) / camera_scale +world_y = (mouse_y - camera_offset_y) / camera_scale + +# Convert world position to grid (tile) coordinates +grid_x = int(world_x // TILE_SIZE) +grid_y = int(world_y // TILE_SIZE) +``` \ No newline at end of file diff --git a/docs/Guides/CoordinateConversionsGuide.md b/docs/Guides/CoordinateConversionsGuide.md new file mode 100644 index 0000000..8cd99b7 --- /dev/null +++ b/docs/Guides/CoordinateConversionsGuide.md @@ -0,0 +1,80 @@ +# Coordinate Conversions Guide + +## Overview + +This document explains how to convert between grid (tile) coordinates and pixel coordinates in the context of game +development. These conversions are essential for translating game logic positions to screen positions and vice versa. + +### Description of the Grid coordinates + +The grid coordinates are a system used to represent the position of tiles on the game board. Each tile is represented by +a pair of coordinates, which can be converted to pixel coordinates for rendering on the screen. + +## Coordinate System + +### Grid (Tile) Coordinates + +- **Description**: Grid coordinates represent the position of a tile on the game board +- **Notation**: The x and y-axis as `(tile_x, tile_y)`, where `tile_x` is the column and `tile_y` is the row. +- **Example**: `(16, 10)` refers to the tile at column 16 and row 10. + +### Pixel Coordinates + +- **Description**: Pixel coordinates represent the exact position on the screen, measured in pixels, +- **Notation**: Denoted as `(pixel_x, pixel_y)`. +- **Example**: `(256, 160)` refers to the pixel at x=256 and y=160. + +## Conversion Between Coordinate System + +```py + +# Constants +TILE_SIZE: int = 16 + +# From grid to pixel conversion +tile_x, tile_y: int = 16, 10 +pixel_x: int = tile_x * TILE_SIZE # 256 +pixel_y: int = tile_y * TILE_SIZE # 160 + +# From pixel to grid conversion +grid_x: int = pixel_x // TILE_SIZE # 16 +grid_y: int = pixel_y // TILE_SIZE # 10 +``` + +## Conversion in mathematical notation + +For grid (position) to pixel conversion: + +$$ +\begin{align*} +\text{pixel}_x &= \text{tile}_x \times \text{TILE_SIZE} \\ +\text{pixel}_y &= \text{tile}_y \times \text{TILE_SIZE} +\end{align*} +$$ + +Given $\text{TILE_SIZE} = 16$, $\text{tile_x} = 16$, and $\text{tile_y} = 10$ + +$$ +\begin{align*} +\text{256}_x &= \text{16}_x \times \text{16} \\ +\text{160}_y &= \text{10}_y \times \text{16} +\end{align*} +$$ + +For pixel (position) to grid conversion: + +$$ +\begin{align*} +\text{grid}_x &= \left\lfloor \frac{\text{pixel}_x}{\text{TILE_SIZE}} \right\rfloor \\ +\text{grid}_y &= \left\lfloor \frac{\text{pixel}_y}{\text{TILE_SIZE}} \right\rfloor +\end{align*} +$$ + +Given $\text{TILE_SIZE} = 16$, $\text{tile_x} = 256$, and $\text{tile_y} = 160$ + +$$ +\begin{align*} +\text{16}_x &= \left\lfloor \frac{\text{256}_x}{\text{16}} \right\rfloor \\ +\text{10}_y &= \left\lfloor \frac{\text{160}_y}{\text{16}} \right\rfloor +\end{align*} +$$ \ No newline at end of file diff --git a/docs/Guides/GridManagerGuide.md b/docs/Guides/GridManagerGuide.md new file mode 100644 index 0000000..7fc83a2 --- /dev/null +++ b/docs/Guides/GridManagerGuide.md @@ -0,0 +1,85 @@ +# Grid Manager Guide + +The word `world` refers in this context to the `pytmx` map, which is the game world where the player interacts with +tiles. + +### Key Features + +- **Grid Drawing**: The grid is drawn on the screen, allowing players to see the layout of the game world. +- **Pathfinding Visualization**: The system can visualize the pathfinding process, showing the route taken by the AI or + player. +- **Edge Case Handling**: The system handles edge cases, such as clicks outside the `pytmx` remain withing the bounds of + the `pytmx map`. +- **Mouse Indicator**: A mouse indicator is rendered to show the current tile under the mouse cursor, helping players + understand where they are clicking. +- **Coordinate Conversion**: The system handles the conversion between grid coordinates and pixel coordinates, ensuring + accurate rendering and interaction with the game world. +- **Customizable Grid Size**: The grid size can be adjusted to fit different game worlds, allowing for flexibility in + design. + +## Controls Documentation + +### Controls Summary + +- **Keyboard**: Press `G` to toggle the grid on and off. +- **Mouse**: When the grid is enabled, clicking on a tile will show its pixel coordinates. + Without it, you can still click on the tile, but it will not show the pixel coordinates and the grid will not be + drawn. + +## Coordinate Systems + +### Mouse Coordinates Summary + +- **Description**: Represents the position of the mouse cursor on the screen. +- **Notation**: Denoted as `(mouse_x, mouse_y)`. + +### World Coordinates Summary + +- **Description**: Represents the position in the game world, accounting for camera offset and scale. +- **Notation**: Denoted as `(world_x, world_y)`. + +### Grid Coordinates Summary + +- **Description**: Represents the position of a tile in the grid, based on the world coordinates. +- **Notation**: Denoted as `(grid_x, grid_y)`. + +### Screen (Pixel) Coordinates Summary + +- **Description**: Represents the position on the screen in pixels. +- **Notation**: Denoted as `(screen_x, screen_y)`. + +## Conversion Processes + +### Mouse to World Coordinates + +- **Description**: Converts mouse coordinates to world coordinates by adjusting for camera offset and scale. + +**Mathematical Notation**: + +$$ +\begin{align*} +\text{world\_x} &= \frac{\text{mouse\_x} - \text{camera\_offset.x}}{\text{camera\_scale}} \\ +\text{world\_y} &= \frac{\text{mouse\_y} - \text{camera\_offset.y}}{\text{camera\_scale}} +\end{align*} +$$ + +**Code Example**: + +```python +def _convert_mouse_to_world(mouse_pos, camera_offset, camera_scale): + world_x = (mouse_pos[0] - camera_offset.x) / camera_scale + world_y = (mouse_pos[1] - camera_offset.y) / camera_scale + return world_x, world_y +``` + +## Running Tests + +Tests in the project use `pytest`. To run the tests: +`pytest tests/test_grid_manager.py` + +## No Collisions Implemented Yet + +- With the rework, you can still move over objects, like 'Islands,' this is intentional and needs to be changed later on + with enhancements. +- The ` 0` is the tile that is walkable, and the `1` is the tile that is not walkable. This is a placeholder for future + collision detection implementation. \ No newline at end of file diff --git a/docs/Guides/PathfindingAStar.md b/docs/Guides/PathfindingAStar.md new file mode 100644 index 0000000..37a353b --- /dev/null +++ b/docs/Guides/PathfindingAStar.md @@ -0,0 +1,71 @@ +# Pathfinding and Movement System + +## Overview + +This document describes the pathfinding and movement system used in the game. The system utilizes the A* algorithm to +find optimal paths on a grid, allowing characters or objects to navigate from a starting point to an endpoint while +avoiding obstacles. + +## Components + +### Coordinate Dataclass + +- **Purpose**: Represents a coordinate in the grid. +- **Attributes**: + - `x`: The x-coordinate on the grid. + - `y`: The y-coordinate on the grid. + +### PathCache Class + +- **Purpose**: Caches paths to avoid recalculating them, improving performance. +- **Methods**: + - `get_cached_path(start, end)`: Retrieves a cached path if the start and end coordinates match. + - `update_cache(start, end, path)`: Updates the cache with a new path. + +### PathFinder Class + +- **Purpose**: Finds paths on a grid using the A* algorithm. +- **Attributes**: + - `grid`: A grid representing the game world, where `0` is walkable and `1` is blocked. + - `_cache`: An instance of `PathCache` to store and retrieve paths. +- **Methods**: + - `find_path(start, end)`: Finds a path from the start to the end coordinates. + - `_calculate_path(start, end)`: Uses the A* algorithm to calculate the path. + +## Pathfinding Algorithm + +### A* Algorithm + +- **Description**: A* is a popular pathfinding algorithm that efficiently finds the shortest path between two points on + a grid. It uses a heuristic to estimate the cost of reaching the goal from each node, which helps in prioritizing the + exploration of promising paths. +- **Heuristic**: The algorithm uses the Euclidean distance or Manhattan distance as a heuristic to estimate the cost. +- **Diagonal Movement**: The algorithm supports diagonal movement, which can be enabled or disabled based on the game's + requirements. + +### Movement Types + +- **DiagonalMovement.always**: Allows movement in all eight possible directions (horizontal, vertical, and diagonal). +- **DiagonalMovement.never**: Restricts movement to four directions (horizontal and vertical only). +- **DiagonalMovement.only_when_no_obstacle**: Allows diagonal movement only when there are no obstacles. +- **DiagonalMovement.if_at_most_one_obstacle**: Allows diagonal movement if there is at most one obstacle. + +## Usage + +### Finding a Path + +To find a path from a starting point to an endpoint, use the `find_path` method of the `PathFinder` class: + +```python +grid_matrix = [ + [0, 0, 0, 0], + [1, 1, 0, 1], + [0, 0, 0, 0], + [0, 0, 0, 0] +] + +path_finder = PathFinder(grid_matrix) +start = (0, 0) +end = (3, 3) +path = path_finder.find_path(start, end) +``` \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d1593ad..460e7c3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ pygame-ce>=2.5.0 -pytmx>=3.32 \ No newline at end of file +pytmx>=3.32 +pathfinding~=1.0.17 +numpy~=2.2.6 \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index 6317e4c..3038c5f 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,3 +1,4 @@ ruff>=0.5.3 mypy>=1.11.1 -pytest>=7.4.4 \ No newline at end of file +pytest>=7.4.4 +hypothesis>=6.80.0 \ No newline at end of file diff --git a/src/game_manager.py b/src/game_manager.py index f766588..c8b6904 100644 --- a/src/game_manager.py +++ b/src/game_manager.py @@ -7,7 +7,7 @@ import pygame # type: ignore -from src.settings import FPS, SCREEN_HEIGHT, SCREEN_WIDTH +from src.settings import SCREEN_HEIGHT, SCREEN_WIDTH # import base state for typehint from src.states.base_state import BaseState @@ -29,7 +29,7 @@ def __init__(self) -> None: # init pygame pygame.init() self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) - pygame.display.set_caption("PyCeas") + # pygame.display.set_caption("PyCeas") self.clock = pygame.Clock() self.running = True @@ -83,4 +83,5 @@ def run(self) -> None: self.states_stack[-1].render(self.screen) # magic value, use 'a' FPS const in settings or delta time - self.clock.tick(FPS) + self.clock.tick() + pygame.display.set_caption(f"{self.clock.get_fps():.2f} FPS") diff --git a/src/settings.py b/src/settings.py index 34f9b4f..bcca3d4 100644 --- a/src/settings.py +++ b/src/settings.py @@ -9,7 +9,7 @@ ANIMATION_SPEED = 4 WORLD_LAYERS = {"water": 0, "bg": 1, "main": 2, "top": 3} -FPS = 60 +FPS = 30 # For some imports like pygame.freetype, Mypy can't infer the type of this attribute, so we suppress the error. diff --git a/src/sprites/animations.py b/src/sprites/animations.py index f6c7509..3d154c7 100644 --- a/src/sprites/animations.py +++ b/src/sprites/animations.py @@ -1,5 +1,5 @@ -import pygame -from pygame.sprite import Group +import pygame # type: ignore +from pygame.sprite import Group # type: ignore from src.settings import ANIMATION_SPEED, WORLD_LAYERS from src.sprites.base import BaseSprite diff --git a/src/sprites/base.py b/src/sprites/base.py index c6b866f..5e1131c 100644 --- a/src/sprites/base.py +++ b/src/sprites/base.py @@ -2,8 +2,8 @@ from abc import ABC -from pygame import FRect, Surface, Vector2 -from pygame.sprite import Group, Sprite +from pygame import Surface, Vector2 # type: ignore +from pygame.sprite import Group, Sprite # type: ignore from src.settings import ANIMATION_SPEED, WORLD_LAYERS @@ -45,7 +45,7 @@ def __init__( raise ValueError("The `surf` parameter must be a valid pygame.Surface.") self.image = surf - self.rect: FRect = self.image.get_frect(topleft=pos) + self.rect = self.image.get_rect(topleft=pos) self.z = z self.frames = frames or [surf] diff --git a/src/sprites/camera/group.py b/src/sprites/camera/group.py index eec8377..4fcb453 100644 --- a/src/sprites/camera/group.py +++ b/src/sprites/camera/group.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod -import pygame -from pygame.sprite import Group +import pygame # type: ignore +from pygame.sprite import Group # type: ignore # class CameraGroup(pygame.sprite.Group, ABC): diff --git a/src/sprites/camera/player.py b/src/sprites/camera/player_camera.py similarity index 71% rename from src/sprites/camera/player.py rename to src/sprites/camera/player_camera.py index 2c6ab44..30dcb2a 100644 --- a/src/sprites/camera/player.py +++ b/src/sprites/camera/player_camera.py @@ -1,7 +1,8 @@ import pygame # ignore -from src.settings import SCREEN_HEIGHT, SCREEN_WIDTH, WORLD_LAYERS +from src.settings import SCREEN_HEIGHT, SCREEN_WIDTH, TILE_SIZE, WORLD_LAYERS from src.sprites.camera.group import AllSprites +from src.sprites.tiles.grid_manager import GridManager class PlayerCamera(AllSprites): @@ -19,11 +20,32 @@ class PlayerCamera(AllSprites): scale (float): The scaling factor for rendering sprites. """ - def draw(self, player_center): + def __init__(self, tmx_map=None, player_start_pos=None): + super().__init__() + self.display_surface = pygame.display.get_surface() + if not self.display_surface: + raise ValueError("Display surface is not initialized") + + if tmx_map is None: + raise ValueError("TMX map cannot be None") + if player_start_pos is None: + raise ValueError("Player start position cannot be None") + + self.player_start_pos = player_start_pos + self.tmx_map = tmx_map + self.offset = pygame.math.Vector2() + self.scale = 2.0 + self.grid = GridManager(tmx_map, tile_size=TILE_SIZE) + self.player_start_pos = player_start_pos + + def draw(self, player_center, show_grid=False): # Calculate offsets self.offset.x = -(player_center[0] * self.scale - SCREEN_WIDTH / 2) self.offset.y = -(player_center[1] * self.scale - SCREEN_HEIGHT / 2) + # print(f"Player Center: {player_center}") + # print(f"Camera Offset: {self.offset}") + # Separate sprites into layers background_sprites = [sprite for sprite in self if sprite.z < WORLD_LAYERS["main"]] main_sprites = [sprite for sprite in self if sprite.z == WORLD_LAYERS["main"]] diff --git a/src/sprites/entities/player.py b/src/sprites/entities/player.py index d6933fc..0052be0 100644 --- a/src/sprites/entities/player.py +++ b/src/sprites/entities/player.py @@ -1,6 +1,6 @@ -import pygame -from pygame import Surface, Vector2 -from pygame.sprite import Group +import pygame # type: ignore +from pygame import FRect, Surface # type: ignore +from pygame.sprite import Group # type: ignore from src.inventory import Inventory from src.settings import TILE_SIZE @@ -8,6 +8,10 @@ class Player(BaseSprite): + """Handles player interaction with the grid, moving with the pathfinding.""" + + rect: FRect + def __init__( self, pos: tuple[int, int], @@ -21,18 +25,18 @@ def __init__( :param groups: Sprite groups the player belongs to. """ - # Use the first frame as the base surface - first_frame = frames[0] if isinstance(frames, (list, tuple)) and frames else Surface((TILE_SIZE, TILE_SIZE)) - first_frame.fill("red") - super().__init__(pos=pos, surf=first_frame, groups=groups) + # Initialize the player sprite + player_square = frames[0] if isinstance(frames, (list, tuple)) and frames else Surface((TILE_SIZE, TILE_SIZE)) + player_square.fill("red") + super().__init__(pos=pos, surf=player_square, groups=groups) # Animation frames self.frames = frames self.frame_index: float = 0.0 - # Ghost preview - self.player_preview = first_frame.copy() - self.player_preview.set_alpha(128) + self.position = pos + # In your __init__ method + self.path: list[tuple[int, int]] = [] # Stores the path to the destination tile # Inventory system self.inventory = Inventory() @@ -40,78 +44,59 @@ def __init__( # Input handling self.mouse_have_been_pressed: bool = False - def input(self) -> None: - """move the player and show a ghost to preview the move""" - - # Reset direction - self.direction = Vector2(0, 0) + # def get_neighbor_tiles(self, grid, blocked_tiles=None): + # """Calculate and return all valid adjacent (neighbor) tiles for the player.""" + # if blocked_tiles is None: + # blocked_tiles = [] + # + # x, y = self.rect.topleft + # tile_size = grid.tile_size + # directions = [ + # (0, -tile_size), (0, tile_size), # Up, Down + # (-tile_size, 0), (tile_size, 0), # Left, Right + # (-tile_size, -tile_size), (tile_size, -tile_size), # Diagonal: Top-left, Top-right + # (-tile_size, tile_size), (tile_size, tile_size) # Diagonal: Bottom-left, Bottom-right + # ] + # self.valid_moves = [ + # (x + dx, y + dy) + # for dx, dy in directions + # if 0 <= x + dx < SCREEN_WIDTH + # and 0 <= y + dy < SCREEN_HEIGHT + # and (x + dx, y + dy) not in blocked_tiles + # ] + + def input(self, grid, camera_offset: pygame.math.Vector2 | None = None, camera_scale: float | None = None) -> None: + """Handle player movement using instant tile-based logic""" # Get mouse position mouse_pos = pygame.mouse.get_pos() - - # get the relative pos of the player from the mouse - # to know on which axis the player will move - delta_x = abs(self.rect.centerx - mouse_pos[0]) - delta_y = abs(self.rect.centery - mouse_pos[1]) - - # # move the ghost on the x-axis - # self.player_preview_rect = self.rect.copy() - # if delta_x > delta_y: - # if delta_x < (TILE_SIZE / 2): - # # don't move the ghost if the mouse is on the player hit box - # self.player_preview_rect.x = self.rect.x - # elif mouse_pos[0] > self.rect.centerx: - # # go right - # self.player_preview_rect.x = self.rect.x + TILE_SIZE - # else: - # # go left - # self.player_preview_rect.x = self.rect.x - TILE_SIZE - # # move the ghost on the y-axis - # else: - # if delta_y < (TILE_SIZE / 2): - # # don't move if the mouse is on the player hit box - # self.player_preview_rect.y = self.rect.y - # elif mouse_pos[1] > self.rect.centery: - # # go down - # self.player_preview_rect.y = self.rect.y + TILE_SIZE - # else: - # # go up - # self.player_preview_rect.y = self.rect.y - TILE_SIZE - - # Handle mouse input for movement if not pygame.mouse.get_pressed()[0]: self.mouse_have_been_pressed = False return if self.mouse_have_been_pressed: return - self.mouse_have_been_pressed = True - # Move on the x-axis or y-axis - if delta_x > delta_y: - if delta_x >= (TILE_SIZE / 2): - if mouse_pos[0] > self.rect.centerx: - self.direction.x = 1 - # if delta_x >= (TILE_SIZE / 2): - # self.direction.x = 1 if mouse_pos[0] > self.rect.centerx else -1 - else: - self.direction.x = -1 - # if delta_y >= (TILE_SIZE / 2): - # self.direction.y = 1 if mouse_pos[1] > self.rect.centery else -1 - else: - if delta_y >= (TILE_SIZE / 2): - if mouse_pos[1] > self.rect.centery: - self.direction.y = 1 - else: - self.direction.y = -1 - - # Update position - self.rect.x += self.direction.x * TILE_SIZE - self.rect.y += self.direction.y * TILE_SIZE - - # return None - - def update(self, dt: float) -> None: + # Calculate the tile coordinates from the grid with camera offset and scale + player_tile = (self.rect.x // grid.tile_size, self.rect.y // grid.tile_size) + target_tile = grid.get_tile_coordinates(mouse_pos, camera_offset, camera_scale) + + # Find a path using A* algorithm + path = grid.find_path(player_tile, target_tile) + if path and len(path) > 1: + # Move to the next tile in the path + self.path = path[1:] + + def update( + self, dt: float, grid=None, camera_offset: pygame.math.Vector2 | None = None, + camera_scale: float | None = None + ) -> None: """Update the player's position and state.""" - self.input() + if grid: + # this method is not used, could be useful when implementing a player switching system + # self.get_neighbor_tiles(grid) + self.input(grid, camera_offset, camera_scale) # Handle input with camera offset and scale + if self.path: + next_tile = self.path.pop(0) + self.rect.topleft = (next_tile[0] * grid.tile_size, next_tile[1] * grid.tile_size) self.animate(dt) diff --git a/src/sprites/tiles/grid_manager.py b/src/sprites/tiles/grid_manager.py new file mode 100644 index 0000000..a27cac8 --- /dev/null +++ b/src/sprites/tiles/grid_manager.py @@ -0,0 +1,215 @@ +import numpy as np +import pygame # type: ignore +import pytmx +from pygame import Surface # type: ignore + +from src.settings import TILE_SIZE +from src.sprites.tiles.pathfinding import PathFinder + + +class GridManager: + def __init__( + self, tmx_map: pytmx.TiledMap = None, tile_size: int = TILE_SIZE, grid_matrix: np.ndarray | None = None + ): + if grid_matrix is not None: + self.grid_matrix = grid_matrix + self.height, self.width = grid_matrix.shape + self.tmx_map = None + else: + if tmx_map is None: + raise ValueError("Either tmx_map or grid_matrix must be provided") + self.tmx_map = tmx_map + self.width = tmx_map.width # Number of tiles wide + self.height = tmx_map.height # Number of tiles high + self.grid_matrix = self.create_grid_matrix() + self.tile_size = tile_size + self.path_finder = PathFinder(self.grid_matrix) + + self.display_surface: Surface | None = pygame.display.get_surface() + self.font = pygame.font.SysFont(None, 12) + self.coordinate_surfaces = self._preload_coordinates_surfaces() + + def _preload_coordinates_surfaces(self): + coordinate_surfaces = {} + for y in range(self.height): + for x in range(self.width): + text_surface = self.font.render(f"{x}, {y}", True, (255, 255, 255)) + coordinate_surfaces[(x, y)] = text_surface.convert_alpha() + return coordinate_surfaces + + def create_grid_matrix(self) -> np.ndarray: + """ + Create a grid matrix from the Tiled map. + Each tile is represented as 0 (walkable) or 1 (non-walkable). + """ + if self.tmx_map is None: + raise ValueError("TMX map must be None when creating grid matrix") + matrix = np.zeros((self.height, self.width), dtype=int) # Initialize with zeros (walkable) + for layer in self.tmx_map.visible_layers: + if isinstance(layer, pytmx.TiledTileLayer): + if layer.name == "Sea": + for x, y, gid in layer: + matrix[y, x] = 0 # Walkable + elif layer.name == "Islands" or layer.name == "Shallow Sea": + for x, y, gid in layer: + matrix[y, x] = 1 # Non-walkable + return matrix + + # Not the best way to do this, but it works for now + def find_path(self, start: tuple[int, int], end: tuple[int, int]) -> list[list[int]]: + return self.path_finder.find_path(start, end) + + def get_tile_coordinates( + self, + mouse_pos: tuple[int, int], + camera_offset: pygame.math.Vector2 | None = None, + camera_scale: float | None = None, + ) -> tuple[int, int]: + """ + Get the tile indices (x, y) based on mouse position. + This is used to determine where the player can move. + + Args: + mouse_pos (tuple[int, int]): The current mouse position. + camera_offset (tuple[int, int], optional): The camera offset from PlayerCamera (Vector2) + camera_scale (float, optional): The camera scale from PlayerCamera (float) + """ + if camera_offset is None: + camera_offset = pygame.math.Vector2() + if camera_scale is None: + camera_scale = 1.0 + world_x, world_y = self._convert_mouse_to_world(mouse_pos, camera_offset, camera_scale) + grid_x, grid_y = self._convert_world_to_grid(world_x, world_y) + return self._clamp_grid_coordinates(grid_x, grid_y) + + @staticmethod + def _convert_mouse_to_world( + mouse_pos: tuple[int, int], camera_offset: pygame.math.Vector2, camera_scale: float + ) -> tuple[float, float]: + # Adjust the mouse position to world coordinates by reversing the camera's position and scale + world_x = (mouse_pos[0] - camera_offset.x) / camera_scale + world_y = (mouse_pos[1] - camera_offset.y) / camera_scale + return world_x, world_y + + def _convert_world_to_grid(self, world_x: float, world_y: float) -> tuple[int, int]: + # Convert world coordinates to grid coordinates + grid_x = int(world_x // self.tile_size) + grid_y = int(world_y // self.tile_size) + return grid_x, grid_y + + def _clamp_grid_coordinates(self, grid_x: int, grid_y: int) -> tuple[int, int]: + # Clamp grid coordinates to the grid boundaries + grid_x = max(0, min(self.width - 1, grid_x)) + grid_y = max(0, min(self.height - 1, grid_y)) + return grid_x, grid_y + + def draw( + self, + player_pos: tuple[int, int], + mouse_pos: tuple[int, int], + camera_offset: pygame.math.Vector2 | None = None, + camera_scale: float | None = None, + visible_radius: int | None = None, + ) -> None: + """ + Draw the grid on the screen. + Highlight the tile under the mouse cursor. + + Args: + player_pos (tuple[int, int]): The current player position. + mouse_pos (tuple[int, int]): The current mouse position. + camera_offset (tuple[int, int], optional): The camera offset from PlayerCamera (Vector2) + camera_scale (float, optional): The camera scale from PlayerCamera (float) + visible_radius (int, optional): The radius of the visible area around the player. + """ + if camera_offset is None: + camera_offset = pygame.math.Vector2() + if camera_scale is None: + camera_scale = 1.0 + if visible_radius is None: + visible_radius = 5 + player_grid_x, player_grid_y = self._convert_world_to_grid(*player_pos) + mouse_grid_x, mouse_grid_y = self.get_tile_coordinates(mouse_pos, camera_offset, camera_scale) + + visible_start_x, visible_start_y, visible_end_x, visible_end_y = self._calculate_visible_area( + player_grid_x, player_grid_y, visible_radius + ) + + self._draw_grid_lines( + visible_start_x, visible_start_y, visible_end_x, visible_end_y, camera_offset, camera_scale + ) + + self._draw_path(player_grid_x, player_grid_y, mouse_grid_x, mouse_grid_y, camera_offset, camera_scale) + + self._draw_mouse_indicator(mouse_grid_x, mouse_grid_y, camera_offset, camera_scale) + + def _calculate_visible_area( + self, player_grid_x: int, player_grid_y: int, visible_radius: int + ) -> tuple[int, int, int, int]: + visible_start_x = max(0, player_grid_x - visible_radius) + visible_start_y = max(0, player_grid_y - visible_radius) + visible_end_x = min(self.width, player_grid_x + visible_radius + 1) + visible_end_y = min(self.height, player_grid_y + visible_radius + 1) + return visible_start_x, visible_start_y, visible_end_x, visible_end_y + + def _draw_grid_lines( + self, + visible_start_x: int, + visible_start_y: int, + visible_end_x: int, + visible_end_y: int, + camera_offset: pygame.math.Vector2, + camera_scale: float, + ) -> None: + for y in range(visible_start_y, visible_end_y): + for x in range(visible_start_x, visible_end_x): + if self.display_surface is None: + raise RuntimeError("Display surface must be initialized") + screen_x, screen_y = self._convert_to_screen_coordinates(x, y, camera_offset, camera_scale) + rect = pygame.Rect(screen_x, screen_y, self.tile_size * camera_scale, self.tile_size * camera_scale) + pygame.draw.rect(self.display_surface, "dark grey", rect, 1) # Draw grid lines + + text_surface = self.coordinate_surfaces[(x, y)] + text_rect = text_surface.get_rect( + center=(screen_x + self.tile_size * camera_scale / 2, screen_y + self.tile_size * camera_scale / 2) + ) + self.display_surface.blit(text_surface, text_rect) + + def _convert_to_screen_coordinates( + self, x: int, y: int, camera_offset: pygame.math.Vector2, camera_scale: float + ) -> tuple[float, float]: + world_x = x * self.tile_size + world_y = y * self.tile_size + screen_x = world_x * camera_scale + camera_offset.x + screen_y = world_y * camera_scale + camera_offset.y + return screen_x, screen_y + + def _draw_path( + self, + start_x: int, + start_y: int, + end_x: int, + end_y: int, + camera_offset: pygame.math.Vector2, + camera_scale: float, + ) -> None: + start = (start_x, start_y) + end = (end_x, end_y) + + if 0 <= start[0] < self.width and 0 <= start[1] < self.height: + path = self.path_finder.find_path(start, end) + for x, y in path: + if self.display_surface is None: + raise RuntimeError("Display surface must be initialized") + screen_x, screen_y = self._convert_to_screen_coordinates(x, y, camera_offset, camera_scale) + rect = pygame.Rect(screen_x, screen_y, self.tile_size * camera_scale, self.tile_size * camera_scale) + pygame.draw.rect(self.display_surface, "green", rect, 2) # Draw path tiles + + def _draw_mouse_indicator( + self, mouse_grid_x: int, mouse_grid_y: int, camera_offset: pygame.math.Vector2, camera_scale: float + ) -> None: + if self.display_surface is None: + raise RuntimeError("Display surface must be initialized") + dot_x = mouse_grid_x * self.tile_size * camera_scale + camera_offset.x + dot_y = mouse_grid_y * self.tile_size * camera_scale + camera_offset.y + pygame.draw.circle(self.display_surface, (0, 255, 0), (dot_x, dot_y), 5) # Green circle at tile coordinates diff --git a/src/sprites/tiles/pathfinding.py b/src/sprites/tiles/pathfinding.py new file mode 100644 index 0000000..d705676 --- /dev/null +++ b/src/sprites/tiles/pathfinding.py @@ -0,0 +1,93 @@ +from dataclasses import dataclass + +from pathfinding.core.diagonal_movement import DiagonalMovement # noqa: F401 +from pathfinding.core.grid import Grid # noqa: F401 +from pathfinding.finder.a_star import AStarFinder # noqa: F401 + + +@dataclass(frozen=True) +class Coordinate: + """A simple data class to represent a coordinate in the grid.""" + + x: int + y: int + + +class PathCache: + def __init__(self) -> None: + """ + Storing the start and end coordinates along with the path to avoid recalculating paths in a cached manner. + """ + self.start: Coordinate | None = None + self.end: Coordinate | None = None + self.path: list[Coordinate] | None = None + + def get_cached_path(self, start: Coordinate, end: Coordinate) -> list[Coordinate] | None: + """ + Retrieve the cached path if the start and end coordinates match. + + :param start: The starting coordinate. + :param end: The ending coordinate. + :return: The cached path or None if not found. + """ + if self.start == start and self.end == end: + return self.path + return None + + def update_cache(self, start: Coordinate, end: Coordinate, path: list[Coordinate]) -> None: + """ + Update the cache with a new path. + + :param start: The starting coordinate. + :param end: The ending coordinate. + :param path: The path to cache. + """ + self.start = start + self.end = end + self.path = path + + +class PathFinder: + MOVEMENT_TYPE = DiagonalMovement.always + + def __init__(self, grid_matrix): + """ + Initialize the PathFinder with a grid matrix. + + :param grid_matrix: A 2D list representing the grid where 0 is walkable and 1 is blocked. + """ + self.grid = Grid(matrix=grid_matrix) + self._cache = PathCache() + + def find_path(self, start: tuple[int, int], end: tuple[int, int]) -> list[list[int]]: + """ + Find a path from start to end using A* algorithm. + + Args: + start (tuple[int, int]): The starting tile coordinates (x, y). + end (tuple[int, int]): The ending tile coordinates (x, y). + """ + + start_coord = Coordinate(start[0], start[1]) + end_coord = Coordinate(end[0], end[1]) + + cached_path = self._cache.get_cached_path(start_coord, end_coord) + if cached_path: + return [[coord.x, coord.y] for coord in cached_path] + + self.grid.cleanup() # Reset the grid state + path = self._calculate_path(start_coord, end_coord) + + path_coordinates = [Coordinate(node.x, node.y) for node in path] + self._cache.update_cache(start_coord, end_coord, path_coordinates) + + return [[coord.x, coord.y] for coord in path_coordinates] + + def _calculate_path(self, start: Coordinate, end: Coordinate) -> list: + """Calculate the path using A* algorithm.""" + + start_node = self.grid.node(start.x, start.y) + end_node = self.grid.node(end.x, end.y) + finder = AStarFinder(diagonal_movement=self.MOVEMENT_TYPE) + path, _ = finder.find_path(start_node, end_node, self.grid) + return path diff --git a/src/sprites/tiles/tile.py b/src/sprites/tiles/tile.py deleted file mode 100644 index 1421869..0000000 --- a/src/sprites/tiles/tile.py +++ /dev/null @@ -1,45 +0,0 @@ -import pygame - -from src.sprites.base import BaseSprite - - -class Tile(BaseSprite): - """Handle tiles for the map""" - - def __init__( - self, - *groups: pygame.sprite.Group, - pos: tuple[float, float], - surf: pygame.Surface, - name: str | None = None, - ) -> None: - r""" - Initialize a Tile instance. - - Args: - *groups (pygame.sprite.Group): One or more sprite groups that this tile will belong to. - pos (Tuple[float, float]): The top-left position where the tile will be placed on the screen. - surf (pygame.Surface): The surface (image) that will be drawn for this tile. - name (Optional[str]): An optional name for the tile. - - Note: - There is a known typing error related to missing type parameters for the generic type 'Group' in pygame. - You may see warnings like: - - src\\base.py:107: error: Missing type parameters for generic type 'Group' [type-arg] - - src\\GUI\\game_manager.py:45: error: Missing type parameters for generic type 'SpriteGroup' [type-arg] - - These errors can be ignored for now, as they are related to type annotations in pygame's codebase. - """ - super().__init__(*groups) - - self.pos = pos - self.surf = surf - self.name = name - - self.image: pygame.Surface = self.surf - self.rect: pygame.FRect = self.image.get_frect(topleft=self.pos) - - def draw(self, display_surface: pygame.Surface, offset: tuple[float, float] = (0, 0)) -> None: - """Could be useful for a camera?""" - offset_rect = self.rect.move(offset) - display_surface.blit(self.image, offset_rect) diff --git a/src/states/game_running.py b/src/states/game_running.py index 35e7788..11889dd 100644 --- a/src/states/game_running.py +++ b/src/states/game_running.py @@ -12,8 +12,9 @@ from src.settings import TILE_SIZE, WORLD_LAYERS from src.sprites.animations import AnimatedSprites from src.sprites.base import BaseSprite -from src.sprites.camera.player import PlayerCamera +from src.sprites.camera.player_camera import PlayerCamera from src.sprites.entities.player import Player +from src.sprites.tiles.grid_manager import GridManager from src.states.base_state import BaseState from src.states.paused import Paused from src.states.shop_state import ShowShop, WindowShop @@ -39,20 +40,37 @@ def __init__(self, game_state_manager) -> None: self.player_inventory = Inventory() self.load_inventory_from_json("data/inventory.json") - self.all_sprites = PlayerCamera() + # Render the grid + self.grid_manager: GridManager | None = None # Initialize grid_manager as None + self.show_grid: bool = True + + sprite_group: pygame.sprite.Group = pygame.sprite.Group() # Initialize sprite group + self.all_sprites: PlayerCamera # Initialize all_sprites as PlayerCamera # The start positions will be one of the 4 islands in the corners of the board - self.setup(player_start_pos="top_left_island") + self.setup(player_start_pos="top_left_island", sprite_group=sprite_group) + + # Create the player camera and add all sprites to it + sprites = list(sprite_group) + self.all_sprites = PlayerCamera(self.tmx_map["map"], self.player.rect.topleft) + for sprite in sprites: + self.all_sprites.add(sprite) self.font = pygame.font.Font(None, 36) self.shop_window = pygame.Surface((800, 600)) self.in_shop = False - def setup(self, player_start_pos: str) -> None: - """ - set up the map and player from the tiled file - """ + def setup(self, player_start_pos: str, sprite_group=None) -> None: + if sprite_group is None: + sprite_group = pygame.sprite.Group() + + # Load the TMX map and make it an attribute of the class self.tmx_map = {"map": load_pygame(os.path.join(".", "data", "new_maps", "100x100_map.tmx"))} + if not self.tmx_map: + raise ValueError("Failed to load the TMX map") + + # Initialize the grid manager + self.grid_manager = GridManager(self.tmx_map["map"], TILE_SIZE) self.world_frames = { "water": import_folder(".", "images", "tilesets", "temporary_water"), @@ -60,15 +78,12 @@ def setup(self, player_start_pos: str) -> None: "ships": all_character_import(".", "images", "tilesets", "ships"), } - # Initialize self.player to None by default - # self.player = None - # Sea for x, y, surface in self.tmx_map["map"].get_layer_by_name("Sea").tiles(): BaseSprite( pos=(x * TILE_SIZE, y * TILE_SIZE), surf=surface, - groups=(self.all_sprites,), + groups=(sprite_group,), z=WORLD_LAYERS["bg"], ) @@ -79,20 +94,18 @@ def setup(self, player_start_pos: str) -> None: AnimatedSprites( pos=(x, y), frames=self.world_frames["water"], - groups=(self.all_sprites,), + groups=(sprite_group,), z=WORLD_LAYERS["water"], ) # Shallow water for x, y, surface in self.tmx_map["map"].get_layer_by_name("Shallow Sea").tiles(): - BaseSprite( - pos=(x * TILE_SIZE, y * TILE_SIZE), surf=surface, groups=(self.all_sprites,), z=WORLD_LAYERS["bg"] - ) + BaseSprite(pos=(x * TILE_SIZE, y * TILE_SIZE), surf=surface, groups=(sprite_group,), z=WORLD_LAYERS["bg"]) # Buildings for x, y, surface in self.tmx_map["map"].get_layer_by_name("Shop").tiles(): self.shop = ShowShop( - pos=(x * TILE_SIZE, y * TILE_SIZE), surface=surface, groups=(self.all_sprites,), z=WORLD_LAYERS["main"] + pos=(x * TILE_SIZE, y * TILE_SIZE), surface=surface, groups=(sprite_group,), z=WORLD_LAYERS["main"] ) # Islands @@ -101,18 +114,22 @@ def setup(self, player_start_pos: str) -> None: BaseSprite( pos=(x * TILE_SIZE, y * TILE_SIZE), surf=surface, - groups=(self.all_sprites,), + groups=(sprite_group,), z=WORLD_LAYERS["bg"], ) - # Enitites - for obj in self.tmx_map["map"].get_layer_by_name("Ships"): - if obj.name == "Player" and obj.properties["pos"] == player_start_pos: - self.player = Player( - pos=(obj.x, obj.y), - frames=self.world_frames["ships"]["player_test_ship"], - groups=(self.all_sprites,), - ) + # Entities + for obj in self.tmx_map["map"].get_layer_by_name("Ships"): + if obj.name == "Player" and obj.properties["pos"] == player_start_pos: + # Cast the player position to int and snap to the grid + grid_x = int(obj.x / TILE_SIZE) * TILE_SIZE + grid_y = int(obj.y / TILE_SIZE) * TILE_SIZE + # print(f"Player Position: ({grid_x, grid_y})") + self.player = Player( + pos=(grid_x, grid_y), + frames=self.world_frames["ships"]["player_test_ship"], + groups=(sprite_group,), + ) # Coast for obj in self.tmx_map["map"].get_layer_by_name("Coast"): @@ -121,7 +138,7 @@ def setup(self, player_start_pos: str) -> None: AnimatedSprites( pos=(obj.x, obj.y), frames=self.world_frames["coast"][terrain][side], - groups=(self.all_sprites,), + groups=(sprite_group,), z=WORLD_LAYERS["bg"], ) @@ -141,41 +158,65 @@ def update(self, events) -> None: update each sprites and handle events """ - collide = self.player.rect.colliderect(self.shop.rect) if self.player else False + collide: bool = ( + self.player is not None + and self.shop is not None + and isinstance(self.player.rect, (pygame.Rect, pygame.FRect)) + and isinstance(self.shop.rect, (pygame.Rect, pygame.FRect)) + and self.player.rect.colliderect(self.shop.rect) + ) dt = self.clock.tick() / 1000 self.all_sprites.update(dt) + # Handle player movement and grid snapping + if isinstance(self.all_sprites, PlayerCamera): + camera_offset = self.all_sprites.offset + scale = self.all_sprites.scale + else: + camera_offset = pygame.math.Vector2() + scale = 1.0 + self.player.update(dt, grid=self.grid_manager, camera_offset=camera_offset, camera_scale=scale) + # get events like keypress or mouse clicks for event in events: if event.type == pygame.KEYDOWN: if event.key == pygame.K_i: # Toggle inventory with "I" key self.game_state_manager.enter_state(Paused(self.game_state_manager, self.player_inventory)) - if collide and event.key == pygame.K_e: + elif event.key == pygame.K_g: # Toggle grid with "G" key + self.show_grid = not self.show_grid + elif collide and event.key == pygame.K_e: self.game_state_manager.enter_state( WindowShop(self.game_state_manager, self.player, self.shop, self.player_inventory) ) def render(self, screen) -> None: - """draw sprites to the canvas""" + """Draw sprites to the canvas.""" screen.fill("#000000") - self.all_sprites.draw(self.player.rect.center) - - # self.welcome_message = self.font.render("Press 'E' to interact!", True, (100, 100, 100)) - # point = self.shop.rect - # collide = self.player.rect.colliderect(point) - # if collide: - # screen.blit(self.welcome_message, (155, 155)) - - # keys = pygame.key.get_pressed() - # if collide and keys[pygame.K_e]: - # self.in_shop = True + if isinstance(self.all_sprites, PlayerCamera): + self.all_sprites.draw(self.player.rect.center, show_grid=self.show_grid) + + # Pass the player's position to the draw method + if self.player and self.grid_manager is not None: + mouse_pos = pygame.mouse.get_pos() + if self.show_grid: + self.grid_manager.draw( + player_pos=(int(self.player.rect.topleft[0]), int(self.player.rect.topleft[1])), + mouse_pos=mouse_pos, + camera_offset=self.all_sprites.offset, + camera_scale=self.all_sprites.scale, + visible_radius=5, + ) + + # Get tile coordinates with camera offset and scale + tile_x, tile_y = self.grid_manager.get_tile_coordinates( + mouse_pos, self.all_sprites.offset, self.all_sprites.scale + ) - # if self.in_shop: - # self.shop_window.fill((0, 0, 0)) - # screen.blit(self.shop_window, (260, 40)) + # Convert grid coordinates to screen coordinates + dot_x = tile_x * TILE_SIZE * self.all_sprites.scale + self.all_sprites.offset.x + dot_y = tile_y * TILE_SIZE * self.all_sprites.scale + self.all_sprites.offset.y - # if keys[pygame.K_q]: - # self.in_shop = False - # print("Exiting shop") + # Draw the green dot at the screen coordinates + pygame.draw.circle(screen, (0, 255, 0), (dot_x, dot_y), 5) # Green circle at tile coordinates pygame.display.update() diff --git a/tests/test_grid_manager.py b/tests/test_grid_manager.py new file mode 100644 index 0000000..e7dfd7e --- /dev/null +++ b/tests/test_grid_manager.py @@ -0,0 +1,121 @@ +import os +import sys + +# Add the project root to sys.path to allow imports to work when running tests directly with `python`. +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +import numpy as np +import pygame +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from src.sprites.tiles.grid_manager import GridManager + + +@pytest.fixture(scope="session", autouse=True) +def pygame_init(): + """Initialize Pygame for the test session.""" + pygame.init() + pygame.font.init() + pygame.display.set_mode((800, 600)) + pygame.display.set_caption("Grid Manager Test") + yield + pygame.quit() + + +@pytest.fixture +def grid_manager(): + grid = np.zeros((10, 10), dtype=int) # 10x10 walkable grid + return GridManager(grid_matrix=grid, tile_size=64) + + +# --- Tests --- +def test_grid_matrix_shape(grid_manager): + assert grid_manager.grid_matrix.shape == (10, 10) + + +def test_grid_matrix_walkable(grid_manager): + # All tiles should be walkable (0) + assert np.all(grid_manager.grid_matrix == 0) + + +@given( + mouse_x=st.integers(min_value=0, max_value=99), + mouse_y=st.integers(min_value=0, max_value=99), + offset_x=st.integers(min_value=-50, max_value=50), + offset_y=st.integers(min_value=-50, max_value=50), + scale=st.floats(min_value=0.5, max_value=2.0), +) +def test_get_tile_coordinates_hypothesis(mouse_x, mouse_y, offset_x, offset_y, scale): + grid = np.zeros((10, 10), dtype=int) # 10x10 walkable grid + grid_manager = GridManager(grid_matrix=grid, tile_size=10) + mouse_pos = (mouse_x, mouse_y) + camera_offset = pygame.math.Vector2(offset_x, offset_y) + camera_scale = scale + x, y = grid_manager.get_tile_coordinates(mouse_pos, camera_offset, camera_scale) + assert 0 <= x < grid_manager.width + assert 0 <= y < grid_manager.height + + +def test_convert_mouse_to_world(grid_manager): + mouse_pos = (10, 20) + camera_offset = pygame.math.Vector2(2, 3) + camera_scale = 2.0 + world_x, world_y = grid_manager._convert_mouse_to_world(mouse_pos, camera_offset, camera_scale) + assert world_x == (10 - 2) / 2.0 + assert world_y == (20 - 3) / 2.0 + + +@pytest.mark.parametrize( + "tile_size, world_x, world_y, expected_x, expected_y", + [ + (1, 5.7, 8.2, 5, 8), + (16, 5.7, 8.2, 0, 0), + (32, 5.7, 8.2, 0, 0), + (64, 5.7, 8.2, 0, 0), + (16, 20.0, 33.0, 1, 2), + (64, 128.0, 192.0, 2, 3), + ], +) +def test_convert_world_to_grid(tile_size, world_x, world_y, expected_x, expected_y): + grid = np.zeros((10, 10), dtype=int) + grid_manager = GridManager(grid_matrix=grid, tile_size=tile_size) + grid_x, grid_y = grid_manager._convert_world_to_grid(world_x, world_y) + assert grid_x == expected_x + assert grid_y == expected_y + + +def test_clamp_grid_coordinates(grid_manager): + # In bounds + assert grid_manager._clamp_grid_coordinates(5, 5) == (5, 5) + # Out of bounds + assert grid_manager._clamp_grid_coordinates(-1, 0) == (0, 0) + assert grid_manager._clamp_grid_coordinates(0, -1) == (0, 0) + assert grid_manager._clamp_grid_coordinates(20, 5) == (9, 5) + assert grid_manager._clamp_grid_coordinates(5, 20) == (5, 9) + + +def test_manual_inspect_grid_manager(grid_manager): + """Creates a pygame window to see the grid manager visually.""" + import time + + print("Manual inspection: Close the window to exit.") + running = True + player_pos = (5, 5) # Example player position + mouse_pos = (0, 0) # Example mouse position + camera_offset = pygame.math.Vector2() + camera_scale = 1.0 + while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + grid_manager.draw( + player_pos=player_pos, + mouse_pos=mouse_pos, + camera_offset=camera_offset, + camera_scale=camera_scale, + visible_radius=5, + ) + pygame.display.flip() + time.sleep(0.01)