From 617b4504331c7e1346b80a0a31a2980bc155c776 Mon Sep 17 00:00:00 2001 From: Jaskrendix Date: Sun, 23 Mar 2025 13:47:58 +0100 Subject: [PATCH 1/3] typehints --- .github/workflows/python-app.yml | 4 +- README.md | 2 +- apps/demo/demo-stitched.py | 9 ++-- apps/demo/demo.py | 22 +++++---- apps/demo/translate.py | 3 +- apps/tutorial/quest.py | 10 ++-- pyscroll/data.py | 64 +++++++++++++++---------- pyscroll/orthographic.py | 82 +++++++++++++++++--------------- tests/pyscroll/test_pyscroll.py | 5 +- 9 files changed, 113 insertions(+), 88 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 761f5aa..356a9e1 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.10', '3.11', '3.12'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] name: Python ${{ matrix.python-version }} steps: - uses: actions/checkout@v4 @@ -34,4 +34,4 @@ jobs: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test run: | - python -m unittest tests/pyscroll/test_pyscroll.py + python -m unittest discover -s tests/pyscroll -p "test_*.py" diff --git a/README.md b/README.md index ae7c222..711053e 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ map_layer = pyscroll.BufferedRenderer(map_data, map_size) # just an example for clarity. here's a made up game engine: def game_engine_draw(): - surfaces = list() + surfaces = [] for game_object in my_game_engine: # pyscroll uses normal pygame surfaces. diff --git a/apps/demo/demo-stitched.py b/apps/demo/demo-stitched.py index 2a2c76a..46bb848 100644 --- a/apps/demo/demo-stitched.py +++ b/apps/demo/demo-stitched.py @@ -6,6 +6,7 @@ Very basic! No animations. """ + from __future__ import annotations from pathlib import Path @@ -24,7 +25,7 @@ VIDEORESIZE, K_r, ) -from pytmx.util_pygame import load_pygame +from pytmx.util_pygame import load_pygame # type: ignore import pyscroll from pyscroll.data import MapAggregator, TiledMapData @@ -52,7 +53,7 @@ def __init__(self) -> None: self.velocity = [0, 0] self._position = [0.0, 0.0] self._old_position = self.position - self.rect = self.image.get_rect() + self.rect: pygame.Rect = self.image.get_rect() self.feet = pygame.Rect(0, 0, self.rect.width * 0.5, 8) @property @@ -99,7 +100,7 @@ def __init__(self, screen: pygame.Surface) -> None: pyscroll_data = TiledMapData(tmx_data) world_data.add_map(pyscroll_data, offset) - self.map_layer = pyscroll.BufferedRenderer( + self.map_layer = pyscroll.orthographic.BufferedRenderer( data=world_data, size=screen.get_size(), clamp_camera=True, @@ -110,7 +111,7 @@ def __init__(self, screen: pygame.Surface) -> None: # put the hero in the center of the map self.hero = Hero() self.hero.layer = 0 - self.hero.position = (400, 400) + self.hero.position = [400.0, 400.0] # add our hero to the group self.group.add(self.hero) diff --git a/apps/demo/demo.py b/apps/demo/demo.py index c0a032c..4d8dd89 100644 --- a/apps/demo/demo.py +++ b/apps/demo/demo.py @@ -10,12 +10,14 @@ See the "Quest" tutorial for a more simple use with pygame sprites and groups. """ + import collections import logging +from typing import Deque import pygame from pygame.locals import * -from pytmx.util_pygame import load_pygame +from pytmx.util_pygame import load_pygame # type: ignore import pyscroll import pyscroll.data @@ -31,7 +33,7 @@ # simple wrapper to keep the screen resizeable -def init_screen(width, height): +def init_screen(width: int, height: int) -> pygame.Surface: return pygame.display.set_mode((width, height), pygame.RESIZABLE) @@ -42,7 +44,7 @@ class ScrollTest: """ - def __init__(self, filename) -> None: + def __init__(self, filename: str) -> None: # load data from pytmx tmx_data = load_pygame(filename) @@ -60,7 +62,7 @@ def __init__(self, filename) -> None: t = ["scroll demo. press escape to quit", "arrow keys move"] # save the rendered text - self.text_overlay = [f.render(i, 1, (180, 180, 0)) for i in t] + self.text_overlay = [f.render(i, True, (180, 180, 0)) for i in t] # set our initial viewpoint in the center of the map self.center = [ @@ -71,12 +73,12 @@ def __init__(self, filename) -> None: # the camera vector is used to handle camera movement self.camera_acc = [0, 0, 0] self.camera_vel = [0, 0, 0] - self.last_update_time = 0 + self.last_update_time = 0.0 # true when running self.running = False - def draw(self, surface) -> None: + def draw(self, surface: pygame.Surface) -> None: # tell the map_layer (BufferedRenderer) to draw to the surface # the draw function requires a rect to draw to. @@ -85,7 +87,7 @@ def draw(self, surface) -> None: # blit our text over the map self.draw_text(surface) - def draw_text(self, surface) -> None: + def draw_text(self, surface: pygame.Surface) -> None: y = 0 for text in self.text_overlay: surface.blit(text, (0, y)) @@ -129,7 +131,7 @@ def handle_input(self) -> None: else: self.camera_acc[0] = 0 - def update(self, td) -> None: + def update(self, td: float) -> None: self.last_update_time = td friction = pow(0.0001, self.last_update_time) @@ -165,13 +167,13 @@ def update(self, td) -> None: # set the center somewhere else # in a game, you would set center to a playable character - self.map_layer.center(self.center) + self.map_layer.center((self.center[0], self.center[1])) def run(self) -> None: clock = pygame.time.Clock() self.running = True fps = 60.0 - fps_log = collections.deque(maxlen=20) + fps_log: Deque[float] = collections.deque(maxlen=20) try: while self.running: diff --git a/apps/demo/translate.py b/apps/demo/translate.py index d827fbd..cbf8c1e 100644 --- a/apps/demo/translate.py +++ b/apps/demo/translate.py @@ -3,6 +3,7 @@ incomplete """ + import pygame @@ -18,7 +19,7 @@ def run(self) -> None: r = self._map_layer.translate_point(spr.rect.topleft) pygame.draw.circle(surface, (20, 20, 20), r, 3) - spr_list = list() + spr_list = [] for spr in self.sprites(): spr_list.append(spr.rect) diff --git a/apps/tutorial/quest.py b/apps/tutorial/quest.py index bfb7a29..5d7d6ae 100644 --- a/apps/tutorial/quest.py +++ b/apps/tutorial/quest.py @@ -8,6 +8,7 @@ pip install pytmx """ + from __future__ import annotations from pathlib import Path @@ -26,7 +27,7 @@ VIDEORESIZE, K_r, ) -from pytmx.util_pygame import load_pygame +from pytmx.util_pygame import load_pygame # type: ignore import pyscroll import pyscroll.data @@ -76,7 +77,7 @@ def __init__(self) -> None: self.velocity = [0, 0] self._position = [0.0, 0.0] self._old_position = self.position - self.rect = self.image.get_rect() + self.rect: pygame.Rect = self.image.get_rect() self.feet = pygame.Rect(0, 0, self.rect.width * 0.5, 8) @property @@ -131,7 +132,7 @@ def __init__(self, screen: pygame.Surface) -> None: self.walls.append(pygame.Rect(obj.x, obj.y, obj.width, obj.height)) # create new renderer (camera) - self.map_layer = pyscroll.BufferedRenderer( + self.map_layer = pyscroll.orthographic.BufferedRenderer( data=pyscroll.data.TiledMapData(tmx_data), size=screen.get_size(), clamp_camera=False, @@ -147,7 +148,8 @@ def __init__(self, screen: pygame.Surface) -> None: # put the hero in the center of the map self.hero = Hero() - self.hero.position = self.map_layer.map_rect.center + _center = self.map_layer.map_rect.center + self.hero.position = [float(i) for i in _center] # add our hero to the group self.group.add(self.hero) diff --git a/pyscroll/data.py b/pyscroll/data.py index 860f031..cb5d279 100644 --- a/pyscroll/data.py +++ b/pyscroll/data.py @@ -4,14 +4,17 @@ If you are developing your own map format, please use this as a template. Just fill in values that work for your game. """ + from __future__ import annotations import time +from collections.abc import Iterable from heapq import heappop, heappush from itertools import product +from typing import Any import pygame -from pygame import Surface +from pygame import Rect, Surface try: # optional pytmx support @@ -43,22 +46,29 @@ class PyscrollDataAdapter: # or properties. they are listed here as class # instances, but use as properties is fine, too. - tile_size = None # (int, int): size of each tile in pixels - map_size = None # (int, int): size of map in tiles - visible_tile_layers = None # list of visible layer integers + # (int, int): size of each tile in pixels + tile_size: Vector2DInt = (0, 0) + # (int, int): size of map in tiles + map_size: Vector2DInt = (0, 0) + # list of visible layer integers + visible_tile_layers: list[int] = [] def __init__(self) -> None: - self._last_time = None # last time map animations were updated - self._animation_queue = list() # list of animation tokens - self._animated_tile = dict() # mapping of tile substitutions when animated - self._tracked_tiles = set() # track the tiles on screen with animations + # last time map animations were updated + self._last_time = 0.0 + # list of animation tokens + self._animation_queue: list[AnimationToken] = [] + # mapping of tile substitutions when animated + self._animated_tile: dict[tuple[int, int, int], Surface] = {} + # track the tiles on screen with animations + self._tracked_tiles = set() def reload_data(self) -> None: raise NotImplementedError def process_animation_queue( self, - tile_view: RectLike, + tile_view: Rect, ) -> list[tuple[int, int, int, Surface]]: """ Given the time and the tile view, process tile changes and return them @@ -67,7 +77,7 @@ def process_animation_queue( tile_view: Rect representing tiles on the screen """ - new_tiles = list() + new_tiles = [] # verify that there are tile substitutions ready self._update_time() @@ -131,7 +141,7 @@ def _update_time(self) -> None: """ self._last_time = time.time() * 1000 - def prepare_tiles(self, tiles: RectLike): + def prepare_tiles(self, tiles: RectLike) -> None: """ Somewhat experimental: The renderer will advise data layer of its view @@ -155,14 +165,13 @@ def reload_animations(self) -> None: """ self._update_time() - self._animation_queue = list() - self._tracked_gids = set() - self._animation_map = dict() + self._tracked_gids: set[int] = set() + self._animation_map: dict[int, AnimationToken] = {} for gid, frame_data in self.get_animations(): self._tracked_gids.add(gid) - frames = list() + frames: list[AnimationFrame] = [] for frame_gid, frame_duration in frame_data: image = self._get_tile_image_by_id(frame_gid) frames.append(AnimationFrame(image, frame_duration)) @@ -174,7 +183,7 @@ def reload_animations(self) -> None: # locations of an animation, but searching for their locations # is slow. so it will be updated as the map is drawn. - positions = set() + positions: set[tuple[int, int, int]] = set() ani = AnimationToken(positions, frames, self._last_time) self._animation_map[gid] = ani heappush(self._animation_queue, ani) @@ -221,7 +230,7 @@ def _get_tile_image(self, x: int, y: int, l: int) -> Surface: """ raise NotImplementedError - def _get_tile_image_by_id(self, id): + def _get_tile_image_by_id(self, id: int) -> Any: """ Return Image by a custom ID. @@ -245,6 +254,9 @@ def get_animations(self) -> None: Where Frames is: [ (ID, Duration), ... ] + [tuple[int, tuple[int, float]]] + [tuple[gid, tuple[frame_gid, frame_duration]]] + And ID is a reference to a tile image. This will be something accessible using _get_tile_image_by_id @@ -289,7 +301,7 @@ class TiledMapData(PyscrollDataAdapter): """ - def __init__(self, tmx) -> None: + def __init__(self, tmx: pytmx.TiledMap) -> None: super(TiledMapData, self).__init__() self.tmx = tmx self.reload_animations() @@ -308,7 +320,7 @@ def get_animations(self): yield gid, frames def convert_surfaces(self, parent: Surface, alpha: bool = False) -> None: - images = list() + images = [] for i in self.tmx.images: try: if alpha: @@ -320,11 +332,11 @@ def convert_surfaces(self, parent: Surface, alpha: bool = False) -> None: self.tmx.images = images @property - def tile_size(self): + def tile_size(self) -> Vector2DInt: return self.tmx.tilewidth, self.tmx.tileheight @property - def map_size(self): + def map_size(self) -> Vector2DInt: return self.tmx.width, self.tmx.height @property @@ -332,7 +344,7 @@ def visible_tile_layers(self): return self.tmx.visible_tile_layers @property - def visible_object_layers(self): + def visible_object_layers(self) -> Iterable[pytmx.TiledObjectGroup]: return ( layer for layer in self.tmx.visible_layers @@ -345,11 +357,11 @@ def _get_tile_image(self, x: int, y: int, l: int): except ValueError: return None - def _get_tile_image_by_id(self, id) -> Surface: + def _get_tile_image_by_id(self, id: int) -> Surface: return self.tmx.images[id] def get_tile_images_by_rect(self, rect: RectLike): - def rev(seq, start, stop): + def rev(seq: list[int], start: int, stop: int) -> enumerate[int]: if start < 0: start = 0 return enumerate(seq[start : stop + 1], start) @@ -397,7 +409,7 @@ def __init__(self, tile_size) -> None: super().__init__() self.tile_size = tile_size self.map_size = 0, 0 - self.maps = list() + self.maps = [] self._min_x = 0 self._min_y = 0 @@ -408,7 +420,7 @@ def _get_tile_image(self, x: int, y: int, l: int) -> Surface: """ pass - def _get_tile_image_by_id(self, id) -> None: + def _get_tile_image_by_id(self, id: int) -> None: """ Required for sprite collation - not implemented diff --git a/pyscroll/orthographic.py b/pyscroll/orthographic.py index 19ae364..b894a59 100644 --- a/pyscroll/orthographic.py +++ b/pyscroll/orthographic.py @@ -5,7 +5,7 @@ import time from collections.abc import Callable from itertools import chain, product -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional import pygame from pygame import Rect, Surface @@ -40,7 +40,7 @@ def __init__( clamp_camera: bool = True, colorkey=None, alpha: bool = False, - time_source: Callable = time.time, + time_source: Callable[[], float] = time.time, scaling_function: Callable = pygame.transform.scale, tall_sprites: int = 0, sprite_damage_height: int = 0, @@ -73,7 +73,7 @@ def __init__( self.scaling_function = scaling_function self.tall_sprites = tall_sprites self.sprite_damage_height = sprite_damage_height - self.map_rect = None + self.map_rect = Rect(0, 0, 0, 0) # internal private defaults if colorkey and alpha: @@ -87,34 +87,39 @@ def __init__( self._clear_color = None # private attributes - self._anchored_view = True # if true, map is fixed to upper left corner - self._previous_blit = ( - None # rect of the previous map blit when map edges are visible - ) - self._size = None # actual pixel size of the view, as it occupies the screen - self._redraw_cutoff = ( - None # size of dirty tile edge that will trigger full redraw - ) - self._x_offset = None # offsets are used to scroll map in sub-tile increments - self._y_offset = None - self._buffer = None # complete rendering of tilemap - self._tile_view = None # this rect represents each tile on the buffer - self._half_width = None # 'half x' attributes are used to reduce division ops. - self._half_height = None - self._tile_queue = None # tiles queued to be draw onto buffer - self._animation_queue = ( - None # heap queue of animation token; schedules tile changes - ) - self._layer_quadtree = None # used to draw tiles that overlap optional surfaces - self._zoom_buffer = None # used to speed up zoom operations + # if true, map is fixed to upper left corner + self._anchored_view = True + # rect of the previous map blit when map edges are visible + self._previous_blit = Rect(0, 0, 0, 0) + # actual pixel size of the view, as it occupies the screen + self._size: Vector2DInt = (0, 0) + # size of dirty tile edge that will trigger full redraw + self._redraw_cutoff = 0 + # offsets are used to scroll map in sub-tile increments + self._x_offset = 0 + self._y_offset = 0 + # complete rendering of tilemap + self._buffer: Optional[Surface] = None + # this rect represents each tile on the buffer + self._tile_view = Rect(0, 0, 0, 0) + # 'half x' attributes are used to reduce division ops. + self._half_width = 0 + self._half_height = 0 + # tiles queued to be draw onto buffer + self._tile_queue = None + # heap queue of animation token; schedules tile changes + self._animation_queue = None + # used to draw tiles that overlap optional surfaces + self._layer_quadtree: Optional[FastQuadTree] = None + # used to speed up zoom operations + self._zoom_buffer = None self._zoom_level = zoom - self._real_ratio_x = ( - 1.0 # zooming slightly changes aspect ratio; this compensates - ) - self._real_ratio_y = ( - 1.0 # zooming slightly changes aspect ratio; this compensates - ) - self.view_rect = Rect(0, 0, 0, 0) # this represents the viewable map pixels + # zooming slightly changes aspect ratio; this compensates + self._real_ratio_x = 1.0 + # zooming slightly changes aspect ratio; this compensates + self._real_ratio_y = 1.0 + # this represents the viewable map pixels + self.view_rect = Rect(0, 0, 0, 0) self.set_size(size) @@ -216,7 +221,7 @@ def center(self, coords: Vector2D) -> None: self._tile_view.move_ip(dx, dy) self.redraw_tiles(self._buffer) - def draw(self, surface: Surface, rect: RectLike, surfaces: list[Surface] = None): + def draw(self, surface: Surface, rect: Rect, surfaces: list[Surface] = []) -> Rect: """ Draw the map onto a surface. @@ -245,6 +250,7 @@ def draw(self, surface: Surface, rect: RectLike, surfaces: list[Surface] = None) if self._zoom_level == 1.0: self._render_map(surface, rect, surfaces) else: + assert self._zoom_buffer self._render_map(self._zoom_buffer, self._zoom_buffer.get_rect(), surfaces) self.scaling_function(self._zoom_buffer, rect.size, surface) return self._previous_blit.copy() @@ -355,12 +361,12 @@ def translate_points(self, points: list[Vector2D]) -> list[Vector2DInt]: points: points to translate """ - retval = list() + retval: list[Vector2DInt] = [] append = retval.append sx, sy = self.get_center_offset() if self._zoom_level == 1.0: for c in points: - append((c[0] + sx, c[1] + sy)) + append((int(c[0]) + sx, int(c[1]) + sy)) else: rx = self._real_ratio_x ry = self._real_ratio_y @@ -376,7 +382,7 @@ def translate_rects(self, rects: list[Rect]) -> list[Rect]: rects: rects to translate """ - retval = list() + retval: list[Rect] = [] append = retval.append sx, sy = self.get_center_offset() if self._zoom_level == 1.0: @@ -399,7 +405,7 @@ def translate_rects(self, rects: list[Rect]) -> list[Rect]: return retval def _render_map( - self, surface: Surface, rect: RectLike, surfaces: list[Surface] + self, surface: Surface, rect: Rect, surfaces: list[Surface] ) -> None: """ Render the map and optional surfaces to destination surface. @@ -425,7 +431,7 @@ def _render_map( surfaces_offset = -offset[0], -offset[1] self._draw_surfaces(surface, surfaces_offset, surfaces) - def _clear_surface(self, surface: Surface, area: RectLike = None) -> None: + def _clear_surface(self, surface: Surface, area: Optional[RectLike] = None) -> None: """ Clear the surface using the right clear color. @@ -569,7 +575,7 @@ def append(rect) -> None: append((v.left, v.top, v.width, -dy)) @staticmethod - def _calculate_zoom_buffer_size(size: Vector2DInt, value: float) -> tuple[int, int]: + def _calculate_zoom_buffer_size(size: Vector2DInt, value: float) -> Vector2DInt: if value <= 0: log.error("zoom level cannot be zero or less") raise ValueError @@ -614,7 +620,7 @@ def _initialize_buffers(self, view_size: Vector2DInt) -> None: """ - def make_rect(x, y) -> Rect: + def make_rect(x: int, y: int) -> Rect: return Rect((x * tw, y * th), (tw, th)) tw, th = self.data.tile_size diff --git a/tests/pyscroll/test_pyscroll.py b/tests/pyscroll/test_pyscroll.py index c7f2cef..8b7abc4 100644 --- a/tests/pyscroll/test_pyscroll.py +++ b/tests/pyscroll/test_pyscroll.py @@ -3,6 +3,7 @@ import pygame +from pyscroll.common import Vector2DInt from pyscroll.data import PyscrollDataAdapter from pyscroll.orthographic import BufferedRenderer @@ -13,7 +14,7 @@ class DummyDataAdapter(PyscrollDataAdapter): visible_tile_layers = [1] def get_animations(self): - return list() + return [] def get_tile_image(self, *position): return position[0] * position[1] @@ -32,7 +33,7 @@ def setUp(self) -> None: self.mock = DummyBufferer() self.queue = BufferedRenderer._queue_edge_tiles - def verify_queue(self, expected: set[tuple[int, int]]) -> None: + def verify_queue(self, expected: set[Vector2DInt]) -> None: queue = {i[:2] for i in self.mock._tile_queue} self.assertEqual(queue, set(expected)) From df8560716faef5286101e8fda89f517542a39c61 Mon Sep 17 00:00:00 2001 From: JaskRendix Date: Thu, 24 Jul 2025 22:12:06 +0200 Subject: [PATCH 2/3] type hints --- apps/demo/demo-stitched.py | 131 ++++++++---------- apps/demo/demo.py | 225 ++++++++++++------------------- apps/demo/translate.py | 95 ++++++++++--- apps/demo/translate_bufferedpy | 99 ++++++++++++++ pyscroll/animation.py | 42 +++++- pyscroll/isometric.py | 28 +++- pyscroll/orthographic.py | 2 +- pyscroll/quadtree.py | 1 + tests/pyscroll/test_isometric.py | 41 ++++++ 9 files changed, 425 insertions(+), 239 deletions(-) create mode 100644 apps/demo/translate_bufferedpy create mode 100644 tests/pyscroll/test_isometric.py diff --git a/apps/demo/demo-stitched.py b/apps/demo/demo-stitched.py index 46bb848..960f0a8 100644 --- a/apps/demo/demo-stitched.py +++ b/apps/demo/demo-stitched.py @@ -34,27 +34,36 @@ # define configuration variables here CURRENT_DIR = Path(__file__).parent RESOURCES_DIR = CURRENT_DIR +WINDOW_SIZE = (800, 600) HERO_MOVE_SPEED = 200 # pixels per second +ZOOM_STEP = 0.25 +INITIAL_ZOOM = 2.0 def init_screen(width: int, height: int) -> pygame.Surface: - screen = pygame.display.set_mode((width, height), pygame.RESIZABLE) - return screen + return pygame.display.set_mode((width, height), pygame.RESIZABLE) def load_image(filename: str) -> pygame.Surface: - return pygame.image.load(str(RESOURCES_DIR / filename)) + path = RESOURCES_DIR / filename + if not path.exists(): + raise FileNotFoundError(f"Image not found: {path}") + return pygame.image.load(str(path)).convert_alpha() class Hero(pygame.sprite.Sprite): def __init__(self) -> None: super().__init__() - self.image = load_image("hero.png").convert_alpha() - self.velocity = [0, 0] - self._position = [0.0, 0.0] - self._old_position = self.position - self.rect: pygame.Rect = self.image.get_rect() + self.image = load_image("hero.png") + self.velocity = pygame.Vector2(0, 0) + self.position = pygame.Vector2(400.0, 400.0) + self.old_position = self.position.copy() + self.rect = self.image.get_rect(topleft=self.position) self.feet = pygame.Rect(0, 0, self.rect.width * 0.5, 8) + self.update_feet() + + def update_feet(self) -> None: + self.feet.midbottom = self.rect.midbottom @property def position(self) -> list[float]: @@ -65,27 +74,25 @@ def position(self, value: list[float]) -> None: self._position = list(value) def update(self, dt: float) -> None: - self._old_position = self._position[:] - self._position[0] += self.velocity[0] * dt - self._position[1] += self.velocity[1] * dt - self.rect.topleft = self._position - self.feet.midbottom = self.rect.midbottom + self.old_position = self.position.copy() + self.position += self.velocity * dt + self.rect.topleft = self.position + self.update_feet() def move_back(self, dt: float) -> None: - self._position = self._old_position - self.rect.topleft = self._position - self.feet.midbottom = self.rect.midbottom + self.position = self.old_position.copy() + self.rect.topleft = self.position + self.update_feet() class QuestGame: - map_path = RESOURCES_DIR / "grasslands.tmx" - def __init__(self, screen: pygame.Surface) -> None: self.screen = screen self.running = False + # Load and stitch maps world_data = MapAggregator((16, 16)) - for filename, offset in [ + stitched_maps = [ ("stitched0.tmx", (-20, -20)), ("stitched1.tmx", (0, -20)), ("stitched2.tmx", (20, -20)), @@ -95,25 +102,26 @@ def __init__(self, screen: pygame.Surface) -> None: ("stitched6.tmx", (-20, 20)), ("stitched7.tmx", (0, 20)), ("stitched8.tmx", (20, 20)), - ]: - tmx_data = load_pygame(RESOURCES_DIR / filename) - pyscroll_data = TiledMapData(tmx_data) - world_data.add_map(pyscroll_data, offset) + ] + + for filename, offset in stitched_maps: + path = RESOURCES_DIR / filename + if not path.exists(): + raise FileNotFoundError(f"TMX map not found: {path}") + tmx_data = load_pygame(str(path)) + world_data.add_map(TiledMapData(tmx_data), offset) self.map_layer = pyscroll.orthographic.BufferedRenderer( data=world_data, size=screen.get_size(), clamp_camera=True, ) - self.map_layer.zoom = 2 + self.map_layer.zoom = INITIAL_ZOOM self.group = PyscrollGroup(map_layer=self.map_layer, default_layer=0) - # put the hero in the center of the map + # Hero setup self.hero = Hero() self.hero.layer = 0 - self.hero.position = [400.0, 400.0] - - # add our hero to the group self.group.add(self.hero) def draw(self) -> None: @@ -123,84 +131,63 @@ def draw(self) -> None: def handle_input(self) -> None: """ Handle pygame input events - """ for event in pygame.event.get(): - if event.type == QUIT: + if event.type == QUIT or (event.type == KEYDOWN and event.key == K_ESCAPE): self.running = False - break - elif event.type == KEYDOWN: - if event.key == K_ESCAPE: - self.running = False - break - - elif event.key == K_r: + if event.key == K_r: self.map_layer.reload() - elif event.key == K_EQUALS: - self.map_layer.zoom += 0.25 - + self.map_layer.zoom += ZOOM_STEP elif event.key == K_MINUS: - value = self.map_layer.zoom - 0.25 - if value > 0: - self.map_layer.zoom = value - - # this will be handled if the window is resized + new_zoom = self.map_layer.zoom - ZOOM_STEP + if new_zoom > 0: + self.map_layer.zoom = new_zoom elif event.type == VIDEORESIZE: self.screen = init_screen(event.w, event.h) self.map_layer.set_size((event.w, event.h)) - # use `get_pressed` for an easy way to detect held keys pressed = pygame.key.get_pressed() + self.hero.velocity.x = 0 + self.hero.velocity.y = 0 + if pressed[K_UP]: - self.hero.velocity[1] = -HERO_MOVE_SPEED + self.hero.velocity.y = -HERO_MOVE_SPEED elif pressed[K_DOWN]: - self.hero.velocity[1] = HERO_MOVE_SPEED - else: - self.hero.velocity[1] = 0 + self.hero.velocity.y = HERO_MOVE_SPEED if pressed[K_LEFT]: - self.hero.velocity[0] = -HERO_MOVE_SPEED + self.hero.velocity.x = -HERO_MOVE_SPEED elif pressed[K_RIGHT]: - self.hero.velocity[0] = HERO_MOVE_SPEED - else: - self.hero.velocity[0] = 0 + self.hero.velocity.x = HERO_MOVE_SPEED def update(self, dt: float) -> None: - """ - Tasks that occur over time should be handled here - - """ self.group.update(dt) def run(self) -> None: clock = pygame.time.Clock() self.running = True - try: - while self.running: - dt = clock.tick() / 1000.0 - self.handle_input() - self.update(dt) - self.draw() - pygame.display.flip() - - except KeyboardInterrupt: - self.running = False + while self.running: + dt = clock.tick(60) / 1000.0 + self.handle_input() + self.update(dt) + self.draw() + pygame.display.flip() def main() -> None: pygame.init() pygame.font.init() - screen = init_screen(800, 600) - pygame.display.set_caption("Quest - An epic journey.") + screen = init_screen(*WINDOW_SIZE) + pygame.display.set_caption("Quest - An Epic Journey") try: game = QuestGame(screen) game.run() - except KeyboardInterrupt: - pass + except Exception as e: + print(f"Error: {e}") finally: pygame.quit() diff --git a/apps/demo/demo.py b/apps/demo/demo.py index 4d8dd89..8ede161 100644 --- a/apps/demo/demo.py +++ b/apps/demo/demo.py @@ -11,8 +11,9 @@ pygame sprites and groups. """ -import collections import logging +from collections import deque +from pathlib import Path from typing import Deque import pygame @@ -24,12 +25,14 @@ import pyscroll.orthographic logger = logging.getLogger(__name__) -ch = logging.StreamHandler() -ch.setLevel(logging.INFO) -logger.addHandler(ch) logger.setLevel(logging.INFO) +logger.addHandler(logging.StreamHandler()) SCROLL_SPEED = 5000 +FONT_SIZE = 20 +TEXT_COLOR = (180, 180, 0) +FRICTION_BASE = 0.0001 +WINDOW_SIZE = (800, 600) # simple wrapper to keep the screen resizeable @@ -44,175 +47,123 @@ class ScrollTest: """ - def __init__(self, filename: str) -> None: + def __init__(self, filename: Path, screen: pygame.Surface) -> None: + self.screen = screen - # load data from pytmx - tmx_data = load_pygame(filename) - - # create new data source + # Load TMX map + tmx_data = load_pygame(filename.as_posix()) map_data = pyscroll.data.TiledMapData(tmx_data) - - # create new renderer self.map_layer = pyscroll.orthographic.BufferedRenderer( map_data, screen.get_size() ) - # create a font and pre-render some text to be displayed over the map - f = pygame.font.Font(pygame.font.get_default_font(), 20) - t = ["scroll demo. press escape to quit", "arrow keys move"] - - # save the rendered text - self.text_overlay = [f.render(i, True, (180, 180, 0)) for i in t] - - # set our initial viewpoint in the center of the map - self.center = [ - self.map_layer.map_rect.width / 2, - self.map_layer.map_rect.height / 2, - ] + # Text overlay + font = pygame.font.Font(pygame.font.get_default_font(), FONT_SIZE) + messages = ["Scroll demo. Press ESC to quit", "Arrow keys or WASD to move"] + self.text_overlay = [font.render(msg, True, TEXT_COLOR) for msg in messages] + self.font = font - # the camera vector is used to handle camera movement - self.camera_acc = [0, 0, 0] - self.camera_vel = [0, 0, 0] + # Camera setup + self.center = pygame.Vector2( + self.map_layer.map_rect.width / 2, self.map_layer.map_rect.height / 2 + ) + self.camera_acc = pygame.Vector2(0, 0) + self.camera_vel = pygame.Vector2(0, 0) self.last_update_time = 0.0 - # true when running self.running = False - def draw(self, surface: pygame.Surface) -> None: - - # tell the map_layer (BufferedRenderer) to draw to the surface - # the draw function requires a rect to draw to. - self.map_layer.draw(surface, surface.get_rect()) + def draw(self) -> None: + self.map_layer.draw(self.screen, self.screen.get_rect()) + self.draw_text() - # blit our text over the map - self.draw_text(surface) - - def draw_text(self, surface: pygame.Surface) -> None: + def draw_text(self) -> None: y = 0 for text in self.text_overlay: - surface.blit(text, (0, y)) + self.screen.blit(text, (0, y)) y += text.get_height() + fps_text = self.font.render(f"FPS: {int(self.fps)}", True, TEXT_COLOR) + self.screen.blit(fps_text, (self.screen.get_width() - 100, 0)) + def handle_input(self) -> None: """Simply handle pygame input events""" for event in pygame.event.get(): - if event.type == QUIT: + if event.type == QUIT or (event.type == KEYDOWN and event.key == K_ESCAPE): self.running = False - break - - elif event.type == KEYDOWN: - if event.key == K_ESCAPE: - self.running = False - break - - # this will be handled if the window is resized elif event.type == VIDEORESIZE: - init_screen(event.w, event.h) + self.screen = init_screen(event.w, event.h) self.map_layer.set_size((event.w, event.h)) - # these keys will change the camera vector - # the camera vector changes the center of the viewport, - # which causes the map to scroll - - # using get_pressed is slightly less accurate than testing for events - # but is much easier to use. pressed = pygame.key.get_pressed() - if pressed[K_UP]: - self.camera_acc[1] = -SCROLL_SPEED * self.last_update_time - elif pressed[K_DOWN]: - self.camera_acc[1] = SCROLL_SPEED * self.last_update_time - else: - self.camera_acc[1] = 0 - - if pressed[K_LEFT]: - self.camera_acc[0] = -SCROLL_SPEED * self.last_update_time - elif pressed[K_RIGHT]: - self.camera_acc[0] = SCROLL_SPEED * self.last_update_time - else: - self.camera_acc[0] = 0 - - def update(self, td: float) -> None: - self.last_update_time = td - - friction = pow(0.0001, self.last_update_time) - - # update the camera vector - self.camera_vel[0] += self.camera_acc[0] * td - self.camera_vel[1] += self.camera_acc[1] * td - - self.camera_vel[0] *= friction - self.camera_vel[1] *= friction - - # make sure the movement vector stops when scrolling off the screen - if self.center[0] < 0: - self.center[0] -= self.camera_vel[0] - self.camera_acc[0] = 0 - self.camera_vel[0] = 0 - if self.center[0] >= self.map_layer.map_rect.width: - self.center[0] -= self.camera_vel[0] - self.camera_acc[0] = 0 - self.camera_vel[0] = 0 - - if self.center[1] < 0: - self.center[1] -= self.camera_vel[1] - self.camera_acc[1] = 0 - self.camera_vel[1] = 0 - if self.center[1] >= self.map_layer.map_rect.height: - self.center[1] -= self.camera_vel[1] - self.camera_acc[1] = 0 - self.camera_vel[1] = 0 - - self.center[0] += self.camera_vel[0] - self.center[1] += self.camera_vel[1] - - # set the center somewhere else - # in a game, you would set center to a playable character - self.map_layer.center((self.center[0], self.center[1])) + self.camera_acc = pygame.Vector2(0, 0) - def run(self) -> None: - clock = pygame.time.Clock() - self.running = True - fps = 60.0 - fps_log: Deque[float] = collections.deque(maxlen=20) + if pressed[K_UP] or pressed[K_w]: + self.camera_acc.y = -SCROLL_SPEED * self.last_update_time + elif pressed[K_DOWN] or pressed[K_s]: + self.camera_acc.y = SCROLL_SPEED * self.last_update_time - try: - while self.running: - # somewhat smoother way to get fps and limit the framerate - clock.tick(fps * 2) + if pressed[K_LEFT] or pressed[K_a]: + self.camera_acc.x = -SCROLL_SPEED * self.last_update_time + elif pressed[K_RIGHT] or pressed[K_d]: + self.camera_acc.x = SCROLL_SPEED * self.last_update_time - try: - fps_log.append(clock.get_fps()) - fps = sum(fps_log) / len(fps_log) - dt = 1 / fps - except ZeroDivisionError: - continue + def update(self, dt: float) -> None: + self.last_update_time = dt + friction = FRICTION_BASE**dt - self.handle_input() - self.update(dt) - self.draw(screen) - pygame.display.flip() + self.camera_vel += self.camera_acc * dt + self.camera_vel *= friction + self.center += self.camera_vel - except KeyboardInterrupt: - self.running = False + # Clamp to map bounds + self.center.x = max(0, min(self.center.x, self.map_layer.map_rect.width)) + self.center.y = max(0, min(self.center.y, self.map_layer.map_rect.height)) + self.map_layer.center(self.center) + + def run(self) -> None: + clock = pygame.time.Clock() + self.running = True + fps_log: Deque[float] = deque(maxlen=20) + + while self.running: + clock.tick(120) + try: + fps_log.append(clock.get_fps()) + self.fps = sum(fps_log) / len(fps_log) + dt = 1 / self.fps if self.fps > 0 else 0.016 + except ZeroDivisionError: + continue + + self.handle_input() + self.update(dt) + self.draw() + pygame.display.flip() -if __name__ == "__main__": - import sys +def main() -> None: pygame.init() pygame.font.init() - screen = init_screen(800, 600) + screen = init_screen(*WINDOW_SIZE) pygame.display.set_caption("pyscroll Test") - try: - filename = sys.argv[1] - except IndexError: - logger.info("no TMX map specified, using default") - filename = "desert.tmx" + script_dir = Path(__file__).parent.resolve() + filename = script_dir / "desert.tmx" + + if not filename.exists(): + logger.error(f"TMX file not found: {filename}") + sys.exit(1) try: - test = ScrollTest(filename) - test.run() - except: + ScrollTest(filename, screen).run() + except Exception as e: + logger.exception("An error occurred during execution.") pygame.quit() - raise + raise e + + +if __name__ == "__main__": + import sys + + main() diff --git a/apps/demo/translate.py b/apps/demo/translate.py index cbf8c1e..6a5d1c1 100644 --- a/apps/demo/translate.py +++ b/apps/demo/translate.py @@ -1,30 +1,93 @@ """ -For testing the translate methods +Test for pyscroll map_layer translate methods. -incomplete +Draws translated rectangles and points for dummy sprites. """ import pygame +from pygame.locals import QUIT + + +class DummySprite(pygame.sprite.Sprite): + def __init__(self, x: int, y: int, w: int = 32, h: int = 32) -> None: + super().__init__() + self.rect = pygame.Rect(x, y, w, h) + + +class DummyMapLayer: + """Mock map layer with translate methods for testing.""" + + def __init__(self, offset: pygame.Vector2 = pygame.Vector2(100, 100)) -> None: + self.offset = offset + + def translate_rect(self, rect: pygame.Rect) -> pygame.Rect: + return rect.move(-self.offset.x, -self.offset.y) + + def translate_point(self, point: tuple[int, int]) -> tuple[int, int]: + return (point[0] - int(self.offset.x), point[1] - int(self.offset.y)) + + def translate_rects(self, rects: list[pygame.Rect]) -> list[pygame.Rect]: + return [self.translate_rect(r) for r in rects] + + def translate_points(self, points: list[tuple[int, int]]) -> list[tuple[int, int]]: + return [self.translate_point(p) for p in points] class Dummy: + def __init__(self, screen: pygame.Surface) -> None: + self.screen = screen + self._map_layer = DummyMapLayer() + self._sprites = pygame.sprite.Group( + DummySprite(150, 150), + DummySprite(200, 180), + DummySprite(250, 220), + ) + + def sprites(self) -> list[DummySprite]: + return list(self._sprites) + def run(self) -> None: - surface = None + clock = pygame.time.Clock() + running = True + + while running: + for event in pygame.event.get(): + if event.type == QUIT: + running = False + + self.screen.fill((30, 30, 30)) + + # Draw translated rects + for spr in self.sprites(): + r = self._map_layer.translate_rect(spr.rect) + pygame.draw.rect(self.screen, (20, 200, 20), r, 2) + + # Draw translated points + for spr in self.sprites(): + p = self._map_layer.translate_point(spr.rect.topleft) + pygame.draw.circle(self.screen, (20, 20, 200), p, 4) + + # Batch rects + rects = [spr.rect for spr in self.sprites()] + for r in self._map_layer.translate_rects(rects): + pygame.draw.rect(self.screen, (200, 10, 10), r, 1) + + # Batch points + points = [r.topleft for r in rects] + for p in self._map_layer.translate_points(points): + pygame.draw.circle(self.screen, (200, 10, 10), p, 3) - for spr in self.sprites(): - r = self._map_layer.translate_rect(spr.rect) - pygame.draw.rect(surface, (20, 20, 20), r, 1) + pygame.display.flip() + clock.tick(60) - for spr in self.sprites(): - r = self._map_layer.translate_point(spr.rect.topleft) - pygame.draw.circle(surface, (20, 20, 20), r, 3) - spr_list = [] - for spr in self.sprites(): - spr_list.append(spr.rect) +def main() -> None: + pygame.init() + screen = pygame.display.set_mode((640, 480)) + pygame.display.set_caption("Translate Test") + Dummy(screen).run() + pygame.quit() - for r in self._map_layer.translate_rects(spr_list): - pygame.draw.rect(surface, (200, 10, 10), r, 1) - for p in self._map_layer.translate_points([i.topleft for i in spr_list]): - pygame.draw.circle(surface, (200, 10, 10), p, 3) +if __name__ == "__main__": + main() diff --git a/apps/demo/translate_bufferedpy b/apps/demo/translate_bufferedpy new file mode 100644 index 0000000..0f87579 --- /dev/null +++ b/apps/demo/translate_bufferedpy @@ -0,0 +1,99 @@ +""" +Translate test using real pyscroll BufferedRenderer. + +Draws translated rectangles and points for dummy sprites on a TMX map. +""" + +from pathlib import Path + +import pygame +from pygame.locals import QUIT +from pytmx.util_pygame import load_pygame # type: ignore + +import pyscroll +from pyscroll.data import TiledMapData + +# Constants +RESOURCES_DIR = Path(__file__).parent +TMX_FILE = RESOURCES_DIR / "desert.tmx" +WINDOW_SIZE = (800, 600) + + +class DummySprite(pygame.sprite.Sprite): + def __init__(self, x: int, y: int, w: int = 32, h: int = 32) -> None: + super().__init__() + self.rect = pygame.Rect(x, y, w, h) + + +class TranslateTest: + def __init__(self, screen: pygame.Surface) -> None: + self.screen = screen + + # Load TMX map + if not TMX_FILE.exists(): + raise FileNotFoundError(f"TMX file not found: {TMX_FILE}") + tmx_data = load_pygame(str(TMX_FILE)) + map_data = TiledMapData(tmx_data) + + # Create real renderer + self.map_layer = pyscroll.orthographic.BufferedRenderer( + map_data, screen.get_size() + ) + self.map_layer.zoom = 1.5 + self.map_layer.center((400, 300)) # Center camera + + # Dummy sprites + self.sprites = pygame.sprite.Group( + DummySprite(420, 320), + DummySprite(460, 360), + DummySprite(500, 400), + ) + + def run(self) -> None: + clock = pygame.time.Clock() + running = True + + while running: + for event in pygame.event.get(): + if event.type == QUIT: + running = False + + self.screen.fill((30, 30, 30)) + + # Draw map + self.map_layer.draw(self.screen, self.screen.get_rect()) + + # Draw translated rects + for spr in self.sprites: + r = self.map_layer.translate_rect(spr.rect) + pygame.draw.rect(self.screen, (20, 200, 20), r, 2) + + # Draw translated points + for spr in self.sprites: + p = self.map_layer.translate_point(spr.rect.topleft) + pygame.draw.circle(self.screen, (20, 20, 200), p, 4) + + # Batch rects + rects = [spr.rect for spr in self.sprites] + for r in self.map_layer.translate_rects(rects): + pygame.draw.rect(self.screen, (200, 10, 10), r, 1) + + # Batch points + points = [r.topleft for r in rects] + for p in self.map_layer.translate_points(points): + pygame.draw.circle(self.screen, (200, 10, 10), p, 3) + + pygame.display.flip() + clock.tick(60) + + +def main() -> None: + pygame.init() + screen = pygame.display.set_mode(WINDOW_SIZE) + pygame.display.set_caption("Real Translate Test") + TranslateTest(screen).run() + pygame.quit() + + +if __name__ == "__main__": + main() diff --git a/pyscroll/animation.py b/pyscroll/animation.py index 504d1ab..5ca1bae 100644 --- a/pyscroll/animation.py +++ b/pyscroll/animation.py @@ -1,10 +1,16 @@ from __future__ import annotations -from collections import namedtuple from collections.abc import Sequence -from typing import Union +from typing import NamedTuple, Union + +from pygame import Surface + + +class AnimationFrame(NamedTuple): + image: Surface + duration: float + -AnimationFrame = namedtuple("AnimationFrame", "image duration") TimeLike = Union[float, int] __all__ = ("AnimationFrame", "AnimationToken") @@ -13,7 +19,12 @@ class AnimationToken: __slots__ = ["next", "positions", "frames", "index"] - def __init__(self, positions, frames: Sequence, initial_time: int = 0) -> None: + def __init__( + self, + positions: set[tuple[int, int, int]], + frames: Sequence[AnimationFrame], + initial_time: float = 0.0, + ) -> None: """ Constructor @@ -22,14 +33,19 @@ def __init__(self, positions, frames: Sequence, initial_time: int = 0) -> None: frames: Sequence of frames that compromise the animation initial_time: Used to compensate time between starting and changing animations + Raises: + ValueError: If the frames sequence is empty """ - frames = tuple(AnimationFrame(*i) for i in frames) + if not frames: + raise ValueError("Frames sequence cannot be empty") + + frames = tuple(AnimationFrame(*frame_data) for frame_data in frames) self.positions = positions self.frames = frames self.next = frames[0].duration + initial_time self.index = 0 - def advance(self, last_time: TimeLike): + def advance(self, last_time: TimeLike) -> AnimationFrame: """ Advance the frame, and set timer for next frame @@ -43,6 +59,8 @@ def advance(self, last_time: TimeLike): Args: last_time: Duration of the last frame + Returns: + AnimationFrame: The next frame in the animation """ # advance the animation frame index, looping by default if self.index == len(self.frames) - 1: @@ -56,7 +74,19 @@ def advance(self, last_time: TimeLike): return next_frame def __lt__(self, other): + """ + Compare the animation token with another object based on the next frame time + + Args: + other: The object to compare with + + Returns: + bool: True if the next frame time is less than the other object's time + """ try: return self.next < other.next except AttributeError: return self.next < other + + def __repr__(self) -> str: + return f"AnimationToken(positions={self.positions}, frames={self.frames})" diff --git a/pyscroll/isometric.py b/pyscroll/isometric.py index fec5065..19c99f7 100644 --- a/pyscroll/isometric.py +++ b/pyscroll/isometric.py @@ -1,20 +1,33 @@ import logging +from pyscroll.common import Vector2D, Vector2DInt from pyscroll.orthographic import BufferedRenderer log = logging.getLogger(__file__) -def vector3_to_iso(vector3): - offset = 0, 0 +def vector3_to_iso( + vector3: tuple[int, int, int], offset: tuple[int, int] = (0, 0) +) -> tuple[int, int]: + """ + Convert 3D cartesian coordinates to isometric coordinates. + """ + if len(vector3) != 3: + raise ValueError("Input tuple must have exactly 3 elements") return ( (vector3[0] - vector3[1]) + offset[0], ((vector3[0] + vector3[1]) >> 1) - vector3[2] + offset[1], ) -def vector2_to_iso(vector2): - offset = 0, 0 +def vector2_to_iso( + vector2: tuple[int, int], offset: tuple[int, int] = (0, 0) +) -> tuple[int, int]: + """ + Convert 2D cartesian coordinates to isometric coordinates. + """ + if len(vector2) != 2: + raise ValueError("Input tuple must have exactly 2 elements") return ( (vector2[0] - vector2[1]) + offset[0], ((vector2[0] + vector2[1]) >> 1) + offset[1], @@ -34,7 +47,7 @@ def _draw_surfaces(self, surface, rect, surfaces) -> None: if surfaces is not None: [(surface.blit(i[0], i[1]), i[2]) for i in surfaces] - def _initialize_buffers(self, view_size) -> None: + def _initialize_buffers(self, view_size: Vector2DInt) -> None: """Create the buffers to cache tile drawing :param view_size: (int, int): size of the draw area @@ -85,7 +98,7 @@ def _flush_tile_queue(self) -> None: iso_y = (x + y) * thh surface_blit(tile, (iso_x, iso_y)) - def center(self, coords) -> None: + def center(self, coords: Vector2D) -> None: """center the map on a "map pixel" """ x, y = [round(i, 0) for i in coords] self.view_rect.center = x, y @@ -102,6 +115,7 @@ def center(self, coords) -> None: self._y_offset = iso[1] print(self._tile_view.size) + assert self._buffer print(self._buffer.get_size()) # center the buffer on the screen @@ -134,7 +148,7 @@ def center(self, coords) -> None: # self._buffer.fill(self._clear_color) # # v = self._tile_view - # self._tile_queue = list() + # self._tile_queue = [] # for x in range(v.left, v.right): # for y in range(v.top, v.bottom): # ix, iy = vector2_to_iso((x, y)) diff --git a/pyscroll/orthographic.py b/pyscroll/orthographic.py index b894a59..e3ce191 100644 --- a/pyscroll/orthographic.py +++ b/pyscroll/orthographic.py @@ -330,7 +330,7 @@ def translate_point(self, point: Vector2D) -> Vector2DInt: return int(point[0] + mx), int(point[1] + my) else: return ( - int(round((point[0] + mx)) * self._real_ratio_x), + int(round((point[0] + mx) * self._real_ratio_x)), int(round((point[1] + my) * self._real_ratio_y)), ) diff --git a/pyscroll/quadtree.py b/pyscroll/quadtree.py index 3209993..b67198b 100644 --- a/pyscroll/quadtree.py +++ b/pyscroll/quadtree.py @@ -3,6 +3,7 @@ A quadtree is used with pyscroll to detect overlapping tiles. """ + from __future__ import annotations import itertools diff --git a/tests/pyscroll/test_isometric.py b/tests/pyscroll/test_isometric.py new file mode 100644 index 0000000..a43f793 --- /dev/null +++ b/tests/pyscroll/test_isometric.py @@ -0,0 +1,41 @@ +import unittest + +from pyscroll.isometric import vector2_to_iso, vector3_to_iso + + +class IsometricFunctionsTest(unittest.TestCase): + def test_vector3_to_iso(self): + self.assertEqual(vector3_to_iso((1, 1, 0)), (0, 1)) + self.assertEqual(vector3_to_iso((2, 1, 0)), (1, 1)) + self.assertEqual(vector3_to_iso((1, 2, 0)), (-1, 1)) + self.assertEqual(vector3_to_iso((1, 1, 1)), (0, 0)) + self.assertEqual(vector3_to_iso((-1, -1, 0)), (0, -1)) + self.assertEqual(vector3_to_iso((-2, -1, 0)), (-1, -2)) + self.assertEqual(vector3_to_iso((-1, -2, 0)), (1, -2)) + self.assertEqual(vector3_to_iso((-1, -1, -1)), (0, 0)) + self.assertEqual(vector3_to_iso((0, 0, 0)), (0, 0)) + self.assertEqual(vector3_to_iso((100, 100, 0)), (0, 100)) + self.assertEqual(vector3_to_iso((200, 100, 0)), (100, 150)) + self.assertEqual(vector3_to_iso((100, 200, 0)), (-100, 150)) + self.assertEqual(vector3_to_iso((100, 100, 100)), (0, 0)) + + def test_vector2_to_iso(self): + self.assertEqual(vector2_to_iso((1, 1)), (0, 1)) + self.assertEqual(vector2_to_iso((2, 1)), (1, 1)) + self.assertEqual(vector2_to_iso((1, 2)), (-1, 1)) + self.assertEqual(vector2_to_iso((0, 0)), (0, 0)) + self.assertEqual(vector2_to_iso((-1, -1)), (0, -1)) + self.assertEqual(vector2_to_iso((-2, -1)), (-1, -2)) + self.assertEqual(vector2_to_iso((-1, -2)), (1, -2)) + self.assertEqual(vector2_to_iso((0, 0)), (0, 0)) + self.assertEqual(vector2_to_iso((100, 100)), (0, 100)) + self.assertEqual(vector2_to_iso((200, 100)), (100, 150)) + self.assertEqual(vector2_to_iso((100, 200)), (-100, 150)) + + def test_vector3_to_iso_invalid_inputs(self): + with self.assertRaises(ValueError): + vector3_to_iso((1, 2)) + + def test_vector2_to_iso_invalid_inputs(self): + with self.assertRaises(ValueError): + vector2_to_iso((1, 2, 3)) From a7b26728f7c03e9026f8f0861c3cea7e61c18b91 Mon Sep 17 00:00:00 2001 From: JaskRendix Date: Fri, 25 Jul 2025 14:47:03 +0200 Subject: [PATCH 3/3] type hints --- .github/workflows/python-app.yml | 2 +- README.md | 2 +- apps/demo/demo-stitched.py | 18 ++++--- apps/demo/demo.py | 32 +++++++++---- apps/demo/translate.py | 18 ++++--- apps/demo/translate_bufferedpy | 11 +++-- apps/tutorial/quest.py | 79 ++++++++++++++---------------- pyscroll/data.py | 8 ++-- pyscroll/orthographic.py | 82 +++++++++++++------------------- tests/pyscroll/test_isometric.py | 1 + tests/pyscroll/test_pyscroll.py | 6 +-- 11 files changed, 131 insertions(+), 128 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 356a9e1..0c713a4 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.9', '3.10', '3.11', '3.12'] name: Python ${{ matrix.python-version }} steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 711053e..ae7c222 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ map_layer = pyscroll.BufferedRenderer(map_data, map_size) # just an example for clarity. here's a made up game engine: def game_engine_draw(): - surfaces = [] + surfaces = list() for game_object in my_game_engine: # pyscroll uses normal pygame surfaces. diff --git a/apps/demo/demo-stitched.py b/apps/demo/demo-stitched.py index 960f0a8..56ace1d 100644 --- a/apps/demo/demo-stitched.py +++ b/apps/demo/demo-stitched.py @@ -25,6 +25,10 @@ VIDEORESIZE, K_r, ) +from pygame.math import Vector2 +from pygame.rect import Rect +from pygame.sprite import Sprite +from pygame.surface import Surface from pytmx.util_pygame import load_pygame # type: ignore import pyscroll @@ -40,26 +44,26 @@ INITIAL_ZOOM = 2.0 -def init_screen(width: int, height: int) -> pygame.Surface: +def init_screen(width: int, height: int) -> Surface: return pygame.display.set_mode((width, height), pygame.RESIZABLE) -def load_image(filename: str) -> pygame.Surface: +def load_image(filename: str) -> Surface: path = RESOURCES_DIR / filename if not path.exists(): raise FileNotFoundError(f"Image not found: {path}") return pygame.image.load(str(path)).convert_alpha() -class Hero(pygame.sprite.Sprite): +class Hero(Sprite): def __init__(self) -> None: super().__init__() self.image = load_image("hero.png") - self.velocity = pygame.Vector2(0, 0) - self.position = pygame.Vector2(400.0, 400.0) + self.velocity = Vector2(0, 0) + self.position = Vector2(400.0, 400.0) self.old_position = self.position.copy() self.rect = self.image.get_rect(topleft=self.position) - self.feet = pygame.Rect(0, 0, self.rect.width * 0.5, 8) + self.feet = Rect(0, 0, self.rect.width * 0.5, 8) self.update_feet() def update_feet(self) -> None: @@ -86,7 +90,7 @@ def move_back(self, dt: float) -> None: class QuestGame: - def __init__(self, screen: pygame.Surface) -> None: + def __init__(self, screen: Surface) -> None: self.screen = screen self.running = False diff --git a/apps/demo/demo.py b/apps/demo/demo.py index 8ede161..7675762 100644 --- a/apps/demo/demo.py +++ b/apps/demo/demo.py @@ -17,7 +17,23 @@ from typing import Deque import pygame -from pygame.locals import * +from pygame.font import Font +from pygame.locals import ( + K_DOWN, + K_ESCAPE, + K_LEFT, + K_RIGHT, + K_UP, + KEYDOWN, + QUIT, + VIDEORESIZE, + K_a, + K_d, + K_s, + K_w, +) +from pygame.math import Vector2 +from pygame.surface import Surface from pytmx.util_pygame import load_pygame # type: ignore import pyscroll @@ -36,7 +52,7 @@ # simple wrapper to keep the screen resizeable -def init_screen(width: int, height: int) -> pygame.Surface: +def init_screen(width: int, height: int) -> Surface: return pygame.display.set_mode((width, height), pygame.RESIZABLE) @@ -47,7 +63,7 @@ class ScrollTest: """ - def __init__(self, filename: Path, screen: pygame.Surface) -> None: + def __init__(self, filename: Path, screen: Surface) -> None: self.screen = screen # Load TMX map @@ -58,17 +74,17 @@ def __init__(self, filename: Path, screen: pygame.Surface) -> None: ) # Text overlay - font = pygame.font.Font(pygame.font.get_default_font(), FONT_SIZE) + font = Font(pygame.font.get_default_font(), FONT_SIZE) messages = ["Scroll demo. Press ESC to quit", "Arrow keys or WASD to move"] self.text_overlay = [font.render(msg, True, TEXT_COLOR) for msg in messages] self.font = font # Camera setup - self.center = pygame.Vector2( + self.center = Vector2( self.map_layer.map_rect.width / 2, self.map_layer.map_rect.height / 2 ) - self.camera_acc = pygame.Vector2(0, 0) - self.camera_vel = pygame.Vector2(0, 0) + self.camera_acc = Vector2(0, 0) + self.camera_vel = Vector2(0, 0) self.last_update_time = 0.0 self.running = False @@ -96,7 +112,7 @@ def handle_input(self) -> None: self.map_layer.set_size((event.w, event.h)) pressed = pygame.key.get_pressed() - self.camera_acc = pygame.Vector2(0, 0) + self.camera_acc = Vector2(0, 0) if pressed[K_UP] or pressed[K_w]: self.camera_acc.y = -SCROLL_SPEED * self.last_update_time diff --git a/apps/demo/translate.py b/apps/demo/translate.py index 6a5d1c1..cb10310 100644 --- a/apps/demo/translate.py +++ b/apps/demo/translate.py @@ -6,27 +6,31 @@ import pygame from pygame.locals import QUIT +from pygame.math import Vector2 +from pygame.rect import Rect +from pygame.sprite import Group, Sprite +from pygame.surface import Surface -class DummySprite(pygame.sprite.Sprite): +class DummySprite(Sprite): def __init__(self, x: int, y: int, w: int = 32, h: int = 32) -> None: super().__init__() - self.rect = pygame.Rect(x, y, w, h) + self.rect = Rect(x, y, w, h) class DummyMapLayer: """Mock map layer with translate methods for testing.""" - def __init__(self, offset: pygame.Vector2 = pygame.Vector2(100, 100)) -> None: + def __init__(self, offset: Vector2 = Vector2(100, 100)) -> None: self.offset = offset - def translate_rect(self, rect: pygame.Rect) -> pygame.Rect: + def translate_rect(self, rect: Rect) -> Rect: return rect.move(-self.offset.x, -self.offset.y) def translate_point(self, point: tuple[int, int]) -> tuple[int, int]: return (point[0] - int(self.offset.x), point[1] - int(self.offset.y)) - def translate_rects(self, rects: list[pygame.Rect]) -> list[pygame.Rect]: + def translate_rects(self, rects: list[Rect]) -> list[Rect]: return [self.translate_rect(r) for r in rects] def translate_points(self, points: list[tuple[int, int]]) -> list[tuple[int, int]]: @@ -34,10 +38,10 @@ def translate_points(self, points: list[tuple[int, int]]) -> list[tuple[int, int class Dummy: - def __init__(self, screen: pygame.Surface) -> None: + def __init__(self, screen: Surface) -> None: self.screen = screen self._map_layer = DummyMapLayer() - self._sprites = pygame.sprite.Group( + self._sprites = Group( DummySprite(150, 150), DummySprite(200, 180), DummySprite(250, 220), diff --git a/apps/demo/translate_bufferedpy b/apps/demo/translate_bufferedpy index 0f87579..cf1a49a 100644 --- a/apps/demo/translate_bufferedpy +++ b/apps/demo/translate_bufferedpy @@ -7,6 +7,9 @@ Draws translated rectangles and points for dummy sprites on a TMX map. from pathlib import Path import pygame +from pygame.sprite import Sprite, Group +from pygame.rect import Rect +from pygame.surface import Surface from pygame.locals import QUIT from pytmx.util_pygame import load_pygame # type: ignore @@ -19,14 +22,14 @@ TMX_FILE = RESOURCES_DIR / "desert.tmx" WINDOW_SIZE = (800, 600) -class DummySprite(pygame.sprite.Sprite): +class DummySprite(Sprite): def __init__(self, x: int, y: int, w: int = 32, h: int = 32) -> None: super().__init__() - self.rect = pygame.Rect(x, y, w, h) + self.rect = Rect(x, y, w, h) class TranslateTest: - def __init__(self, screen: pygame.Surface) -> None: + def __init__(self, screen: Surface) -> None: self.screen = screen # Load TMX map @@ -43,7 +46,7 @@ class TranslateTest: self.map_layer.center((400, 300)) # Center camera # Dummy sprites - self.sprites = pygame.sprite.Group( + self.sprites = Group( DummySprite(420, 320), DummySprite(460, 360), DummySprite(500, 400), diff --git a/apps/tutorial/quest.py b/apps/tutorial/quest.py index 5d7d6ae..b2cdb2b 100644 --- a/apps/tutorial/quest.py +++ b/apps/tutorial/quest.py @@ -27,6 +27,9 @@ VIDEORESIZE, K_r, ) +from pygame.rect import Rect +from pygame.sprite import Sprite +from pygame.surface import Surface from pytmx.util_pygame import load_pygame # type: ignore import pyscroll @@ -40,17 +43,17 @@ # simple wrapper to keep the screen resizeable -def init_screen(width: int, height: int) -> pygame.Surface: +def init_screen(width: int, height: int) -> Surface: screen = pygame.display.set_mode((width, height), pygame.RESIZABLE) return screen # make loading images a little easier -def load_image(filename: str) -> pygame.Surface: - return pygame.image.load(str(RESOURCES_DIR / filename)) +def load_image(filename: str) -> Surface: + return pygame.image.load(RESOURCES_DIR / filename).convert_alpha() -class Hero(pygame.sprite.Sprite): +class Hero(Sprite): """ Our Hero @@ -68,40 +71,38 @@ class Hero(pygame.sprite.Sprite): There is also an old_rect that is used to reposition the sprite if it collides with level walls. - """ def __init__(self) -> None: super().__init__() - self.image = load_image("hero.png").convert_alpha() - self.velocity = [0, 0] - self._position = [0.0, 0.0] - self._old_position = self.position - self.rect: pygame.Rect = self.image.get_rect() - self.feet = pygame.Rect(0, 0, self.rect.width * 0.5, 8) + self.image: Surface = load_image("hero.png") + self.velocity: list[float] = [0.0, 0.0] + self._position: list[float] = [0.0, 0.0] + self._old_position: list[float] = self._position.copy() + self.rect: Rect = self.image.get_rect() + self.feet: Rect = Rect(0, 0, self.rect.width * 0.5, 8) @property def position(self) -> list[float]: - return list(self._position) + return self._position.copy() @position.setter def position(self, value: list[float]) -> None: - self._position = list(value) + self._position = value.copy() def update(self, dt: float) -> None: - self._old_position = self._position[:] + self._old_position = self._position.copy() self._position[0] += self.velocity[0] * dt self._position[1] += self.velocity[1] * dt - self.rect.topleft = self._position + self.rect.topleft = (int(self._position[0]), int(self._position[1])) self.feet.midbottom = self.rect.midbottom def move_back(self, dt: float) -> None: """ If called after an update, the sprite can move back - """ - self._position = self._old_position - self.rect.topleft = self._position + self._position = self._old_position.copy() + self.rect.topleft = (int(self._position[0]), int(self._position[1])) self.feet.midbottom = self.rect.midbottom @@ -112,24 +113,21 @@ class QuestGame: This class will load data, create a pyscroll group, a hero object. It also reads input and moves the Hero around the map. Finally, it uses a pyscroll group to render the map and Hero. - """ map_path = RESOURCES_DIR / "grasslands.tmx" - def __init__(self, screen: pygame.Surface) -> None: + def __init__(self, screen: Surface) -> None: self.screen = screen - - # true while running self.running = False # load data from pytmx tmx_data = load_pygame(self.map_path) # setup level geometry with simple pygame rects, loaded from pytmx - self.walls = [] - for obj in tmx_data.objects: - self.walls.append(pygame.Rect(obj.x, obj.y, obj.width, obj.height)) + self.walls = [ + Rect(obj.x, obj.y, obj.width, obj.height) for obj in tmx_data.objects + ] # create new renderer (camera) self.map_layer = pyscroll.orthographic.BufferedRenderer( @@ -147,15 +145,14 @@ def __init__(self, screen: pygame.Surface) -> None: self.group = PyscrollGroup(map_layer=self.map_layer, default_layer=2) # put the hero in the center of the map + cx, cy = self.map_layer.map_rect.center self.hero = Hero() - _center = self.map_layer.map_rect.center - self.hero.position = [float(i) for i in _center] + self.hero.position = [float(cx), float(cy)] # add our hero to the group self.group.add(self.hero) def draw(self) -> None: - # center the map/screen on our Hero self.group.center(self.hero.rect.center) @@ -165,7 +162,6 @@ def draw(self) -> None: def handle_input(self) -> None: """ Handle pygame input events - """ for event in pygame.event.get(): if event.type == QUIT: @@ -195,24 +191,20 @@ def handle_input(self) -> None: # use `get_pressed` for an easy way to detect held keys pressed = pygame.key.get_pressed() - if pressed[K_UP]: - self.hero.velocity[1] = -HERO_MOVE_SPEED - elif pressed[K_DOWN]: - self.hero.velocity[1] = HERO_MOVE_SPEED - else: - self.hero.velocity[1] = 0 - - if pressed[K_LEFT]: - self.hero.velocity[0] = -HERO_MOVE_SPEED - elif pressed[K_RIGHT]: - self.hero.velocity[0] = HERO_MOVE_SPEED - else: - self.hero.velocity[0] = 0 + self.hero.velocity[1] = ( + -HERO_MOVE_SPEED + if pressed[K_UP] + else HERO_MOVE_SPEED if pressed[K_DOWN] else 0 + ) + self.hero.velocity[0] = ( + -HERO_MOVE_SPEED + if pressed[K_LEFT] + else HERO_MOVE_SPEED if pressed[K_RIGHT] else 0 + ) def update(self, dt: float) -> None: """ Tasks that occur over time should be handled here - """ self.group.update(dt) @@ -226,7 +218,6 @@ def update(self, dt: float) -> None: def run(self) -> None: """ Run the game loop - """ clock = pygame.time.Clock() self.running = True diff --git a/pyscroll/data.py b/pyscroll/data.py index cb5d279..9549511 100644 --- a/pyscroll/data.py +++ b/pyscroll/data.py @@ -55,7 +55,7 @@ class PyscrollDataAdapter: def __init__(self) -> None: # last time map animations were updated - self._last_time = 0.0 + self._last_time: float = 0.0 # list of animation tokens self._animation_queue: list[AnimationToken] = [] # mapping of tile substitutions when animated @@ -77,7 +77,7 @@ def process_animation_queue( tile_view: Rect representing tiles on the screen """ - new_tiles = [] + new_tiles = list() # verify that there are tile substitutions ready self._update_time() @@ -320,7 +320,7 @@ def get_animations(self): yield gid, frames def convert_surfaces(self, parent: Surface, alpha: bool = False) -> None: - images = [] + images = list() for i in self.tmx.images: try: if alpha: @@ -409,7 +409,7 @@ def __init__(self, tile_size) -> None: super().__init__() self.tile_size = tile_size self.map_size = 0, 0 - self.maps = [] + self.maps = list() self._min_x = 0 self._min_y = 0 diff --git a/pyscroll/orthographic.py b/pyscroll/orthographic.py index e3ce191..abfc0d0 100644 --- a/pyscroll/orthographic.py +++ b/pyscroll/orthographic.py @@ -14,6 +14,7 @@ from .quadtree import FastQuadTree if TYPE_CHECKING: + from .animation import AnimationToken from .data import PyscrollDataAdapter log = logging.getLogger(__file__) @@ -27,7 +28,6 @@ class BufferedRenderer: shape, and animation information. See the data class api in pyscroll.data, or use the built-in pytmx support for loading maps created with Tiled. - """ _rgba_clear_color = 0, 0, 0, 0 @@ -38,10 +38,12 @@ def __init__( data: PyscrollDataAdapter, size: Vector2DInt, clamp_camera: bool = True, - colorkey=None, + colorkey: Optional[tuple[int, int, int]] = None, alpha: bool = False, time_source: Callable[[], float] = time.time, - scaling_function: Callable = pygame.transform.scale, + scaling_function: Callable[ + [Surface, Vector2DInt], Surface + ] = pygame.transform.scale, tall_sprites: int = 0, sprite_damage_height: int = 0, zoom: float = 1.0, @@ -49,21 +51,21 @@ def __init__( """ Constructor - NOTE: `colorkey` and `alpha` are special purpose and not related - to the transparency values for tiles. In 99.99% of cases, - you do not need to set them + NOTE: `colorkey` and `alpha` are special-purpose transparency flags + and are not related to tile alpha values. In most cases, you + do not need to set them. Args: - data: reference to data source - size: if true, cannot scroll past map edge - clamp_camera: determines how tile animations are processed - colorkey: used for enabling transparent layers. not needed usually - alpha: used for enabling transparent layers. not needed usually - time_source: used to get time when cycling tile animations - scaling_function: what function to use when scaling the zoom buffer - tall_sprites: deprecated. will be removed in future - sprite_damage_height: modify when sprites are drawn over tiles on another layer - zoom: negative numbers make map smaller, positive numbers zoom in + data: Map data source that provides tile, shape, and animation info. + size: Size of the visible screen area in pixels (width, height). + clamp_camera: If True, restricts camera from scrolling beyond map bounds. + colorkey: RGB tuple to set transparency color. Rarely needed. + alpha: Enables RGBA transparency buffer. Rarely needed. + time_source: Callable for time tracking (used in tile animation updates). + scaling_function: Function used to scale the final render buffer when zoomed. + tall_sprites: Deprecated. Included for compatibility but not supported. + sprite_damage_height: Adjusts layering of tall sprites vs tile layers. + zoom: View scaling factor. Use values >1 to zoom in, values <1 to zoom out. """ # default options @@ -94,30 +96,29 @@ def __init__( # actual pixel size of the view, as it occupies the screen self._size: Vector2DInt = (0, 0) # size of dirty tile edge that will trigger full redraw - self._redraw_cutoff = 0 + self._redraw_cutoff: int = 0 # offsets are used to scroll map in sub-tile increments - self._x_offset = 0 - self._y_offset = 0 + self._x_offset: int = 0 + self._y_offset: int = 0 # complete rendering of tilemap self._buffer: Optional[Surface] = None # this rect represents each tile on the buffer self._tile_view = Rect(0, 0, 0, 0) # 'half x' attributes are used to reduce division ops. - self._half_width = 0 - self._half_height = 0 + self._half_width: int = 0 + self._half_height: int = 0 # tiles queued to be draw onto buffer - self._tile_queue = None + self._tile_queue: Optional[list[tuple[int, int, int, Surface]]] = None # heap queue of animation token; schedules tile changes - self._animation_queue = None + self._animation_queue: Optional[list[AnimationToken]] = None # used to draw tiles that overlap optional surfaces self._layer_quadtree: Optional[FastQuadTree] = None # used to speed up zoom operations - self._zoom_buffer = None + self._zoom_buffer: Optional[Surface] = None self._zoom_level = zoom # zooming slightly changes aspect ratio; this compensates - self._real_ratio_x = 1.0 - # zooming slightly changes aspect ratio; this compensates - self._real_ratio_y = 1.0 + self._real_ratio_x: float = 1.0 + self._real_ratio_y: float = 1.0 # this represents the viewable map pixels self.view_rect = Rect(0, 0, 0, 0) @@ -129,7 +130,6 @@ def __init__( def reload(self) -> None: """ Reload tiles and animations for the data source. - """ self.data.reload_data() self.data.reload_animations() @@ -141,7 +141,6 @@ def scroll(self, vector: Vector2DInt) -> None: Args: vector: x, y - """ self.center( (vector[0] + self.view_rect.centerx, vector[1] + self.view_rect.centery) @@ -155,7 +154,6 @@ def center(self, coords: Vector2D) -> None: Args: coords: x, y - """ x, y = round(coords[0]), round(coords[1]) self.view_rect.center = x, y @@ -245,7 +243,6 @@ def draw(self, surface: Surface, rect: Rect, surfaces: list[Surface] = []) -> Re rect: area to draw to surfaces: optional sequence of surfaces to interlace between tiles rect: area that was drawn over - """ if self._zoom_level == 1.0: self._render_map(surface, rect, surfaces) @@ -265,7 +262,6 @@ def zoom(self) -> float: Default value is 1.0 This value cannot be negative or 0.0 - """ return self._zoom_level @@ -287,7 +283,6 @@ def set_size(self, size: Vector2DInt) -> None: Args: size: pixel size of camera/view of the group - """ buffer_size = self._calculate_zoom_buffer_size(size, self._zoom_level) self._size = size @@ -299,7 +294,6 @@ def redraw_tiles(self, surface: Surface) -> None: Args: surface: where to draw - """ # TODO/BUG: Animated tiles are getting reset here log.debug("pyscroll buffer redraw") @@ -310,7 +304,6 @@ def redraw_tiles(self, surface: Surface) -> None: def get_center_offset(self) -> Vector2DInt: """ Return x, y pair that will change world coords to screen coords. - """ return ( -self.view_rect.centerx + self._half_width, @@ -323,7 +316,6 @@ def translate_point(self, point: Vector2D) -> Vector2DInt: Args: point: point to translate - """ mx, my = self.get_center_offset() if self._zoom_level == 1.0: @@ -340,7 +332,6 @@ def translate_rect(self, rect: RectLike) -> Rect: Args: rect: rect to translate - """ mx, my = self.get_center_offset() rx = self._real_ratio_x @@ -359,7 +350,6 @@ def translate_points(self, points: list[Vector2D]) -> list[Vector2DInt]: Args: points: points to translate - """ retval: list[Vector2DInt] = [] append = retval.append @@ -380,7 +370,6 @@ def translate_rects(self, rects: list[Rect]) -> list[Rect]: Args: rects: rects to translate - """ retval: list[Rect] = [] append = retval.append @@ -414,7 +403,6 @@ def _render_map( surface: pygame surface to draw to rect: area to draw to surfaces: optional sequence of surfaces to interlace between tiles - """ self._tile_queue = self.data.process_animation_queue(self._tile_view) self._tile_queue and self._flush_tile_queue(self._buffer) @@ -438,7 +426,6 @@ def _clear_surface(self, surface: Surface, area: Optional[RectLike] = None) -> N Args: surface: surface to clear area: area to clear - """ clear_color = ( self._rgb_clear_color if self._clear_color is None else self._clear_color @@ -453,7 +440,6 @@ def _draw_surfaces(self, surface: Surface, offset: Vector2DInt, surfaces) -> Non surface: destination offset: offset to compensate for buffer alignment surfaces: sequence of surfaces to blit - """ ox, oy = offset left, top = self._tile_view.topleft @@ -541,7 +527,6 @@ def _queue_edge_tiles(self, dx: int, dy: int) -> None: Args: dx: Edge along X axis to enqueue dy: Edge along Y axis to enqueue - """ v = self._tile_view tw, th = self.data.tile_size @@ -589,7 +574,6 @@ def _create_buffers(self, view_size: Vector2DInt, buffer_size: Vector2DInt) -> N Args: view_size: pixel size of the view buffer_size: pixel size of the buffer - """ requires_zoom_buffer = not view_size == buffer_size self._zoom_buffer = None @@ -617,7 +601,6 @@ def _initialize_buffers(self, view_size: Vector2DInt) -> None: Args: view_size: size of the draw area - """ def make_rect(x: int, y: int) -> Rect: @@ -645,9 +628,11 @@ def make_rect(x: int, y: int) -> Rect: for i in product(range(buffer_tile_width), range(buffer_tile_height)) ] - # TODO: figure out what depth -actually- does - # values <= 8 tend to reduce performance - self._layer_quadtree = FastQuadTree(rects, 4) + # Depth controls quadtree recursion. Higher values produce finer spatial + # partitions. Depth=4 offers a balance for most tile maps. Values ≤2 reduce + # collision precision, while values ≥6 can degrade performance due to + # fragmentation and overhead. + self._layer_quadtree = FastQuadTree(items=rects, depth=4) self.redraw_tiles(self._buffer) @@ -657,7 +642,6 @@ def _flush_tile_queue(self, surface: Surface) -> None: Args: surface: surface to draw onto - """ tw, th = self.data.tile_size ltw = self._tile_view.left * tw diff --git a/tests/pyscroll/test_isometric.py b/tests/pyscroll/test_isometric.py index bab98cb..a43f793 100644 --- a/tests/pyscroll/test_isometric.py +++ b/tests/pyscroll/test_isometric.py @@ -2,6 +2,7 @@ from pyscroll.isometric import vector2_to_iso, vector3_to_iso + class IsometricFunctionsTest(unittest.TestCase): def test_vector3_to_iso(self): self.assertEqual(vector3_to_iso((1, 1, 0)), (0, 1)) diff --git a/tests/pyscroll/test_pyscroll.py b/tests/pyscroll/test_pyscroll.py index 8b7abc4..83341fb 100644 --- a/tests/pyscroll/test_pyscroll.py +++ b/tests/pyscroll/test_pyscroll.py @@ -1,7 +1,7 @@ import unittest from unittest import mock -import pygame +from pygame.rect import Rect from pyscroll.common import Vector2DInt from pyscroll.data import PyscrollDataAdapter @@ -14,14 +14,14 @@ class DummyDataAdapter(PyscrollDataAdapter): visible_tile_layers = [1] def get_animations(self): - return [] + return list() def get_tile_image(self, *position): return position[0] * position[1] class DummyBufferer: - _tile_view = pygame.Rect(2, 2, 2, 2) + _tile_view = Rect(2, 2, 2, 2) _clear_color = None _buffer = mock.Mock() _clear_surface = mock.Mock()