diff --git a/apps/demo/demo-stitched.py b/apps/demo/demo-stitched.py index 2a2c76a..56ace1d 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,11 @@ VIDEORESIZE, K_r, ) -from pytmx.util_pygame import load_pygame +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 from pyscroll.data import MapAggregator, TiledMapData @@ -33,27 +38,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 +def init_screen(width: int, height: int) -> Surface: + return pygame.display.set_mode((width, height), pygame.RESIZABLE) -def load_image(filename: str) -> pygame.Surface: - return pygame.image.load(str(RESOURCES_DIR / filename)) +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").convert_alpha() - self.velocity = [0, 0] - self._position = [0.0, 0.0] - self._old_position = self.position - self.rect = self.image.get_rect() - self.feet = pygame.Rect(0, 0, self.rect.width * 0.5, 8) + self.image = load_image("hero.png") + 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 = 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]: @@ -64,27 +78,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: + def __init__(self, screen: 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)), @@ -94,25 +106,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) + ] - self.map_layer = pyscroll.BufferedRenderer( + 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, 400) - - # add our hero to the group self.group.add(self.hero) def draw(self) -> None: @@ -122,84 +135,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 c0a032c..7675762 100644 --- a/apps/demo/demo.py +++ b/apps/demo/demo.py @@ -10,28 +10,49 @@ See the "Quest" tutorial for a more simple use with pygame sprites and groups. """ -import collections + import logging +from collections import deque +from pathlib import Path +from typing import Deque import pygame -from pygame.locals import * -from pytmx.util_pygame import load_pygame +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 import pyscroll.data 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 -def init_screen(width, height): +def init_screen(width: int, height: int) -> Surface: return pygame.display.set_mode((width, height), pygame.RESIZABLE) @@ -42,175 +63,123 @@ class ScrollTest: """ - def __init__(self, filename) -> None: - - # load data from pytmx - tmx_data = load_pygame(filename) + def __init__(self, filename: Path, screen: Surface) -> None: + self.screen = screen - # 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, 1, (180, 180, 0)) for i in t] + # Text overlay + 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 - # 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, - ] - - # 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 + # Camera setup + self.center = Vector2( + self.map_layer.map_rect.width / 2, self.map_layer.map_rect.height / 2 + ) + self.camera_acc = Vector2(0, 0) + self.camera_vel = Vector2(0, 0) + self.last_update_time = 0.0 - # true when running self.running = False - def draw(self, 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()) - - # blit our text over the map - self.draw_text(surface) + def draw(self) -> None: + self.map_layer.draw(self.screen, self.screen.get_rect()) + self.draw_text() - def draw_text(self, 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) -> 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.camera_acc = Vector2(0, 0) + + 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 + + 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 + + def update(self, dt: float) -> None: + self.last_update_time = dt + friction = FRICTION_BASE**dt + + self.camera_vel += self.camera_acc * dt + self.camera_vel *= friction + self.center += self.camera_vel + + # 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 = 60.0 - fps_log = collections.deque(maxlen=20) - - try: - while self.running: - # somewhat smoother way to get fps and limit the framerate - clock.tick(fps * 2) + fps_log: Deque[float] = deque(maxlen=20) - try: - fps_log.append(clock.get_fps()) - fps = sum(fps_log) / len(fps_log) - dt = 1 / fps - except ZeroDivisionError: - continue + 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(screen) - pygame.display.flip() + self.handle_input() + self.update(dt) + self.draw() + pygame.display.flip() - except KeyboardInterrupt: - self.running = False - - -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 d827fbd..cb10310 100644 --- a/apps/demo/translate.py +++ b/apps/demo/translate.py @@ -1,29 +1,97 @@ """ -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 +from pygame.math import Vector2 +from pygame.rect import Rect +from pygame.sprite import Group, Sprite +from pygame.surface import Surface + + +class DummySprite(Sprite): + def __init__(self, x: int, y: int, w: int = 32, h: int = 32) -> None: + super().__init__() + self.rect = Rect(x, y, w, h) + + +class DummyMapLayer: + """Mock map layer with translate methods for testing.""" + + def __init__(self, offset: Vector2 = Vector2(100, 100)) -> None: + self.offset = offset + + 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[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]]: + return [self.translate_point(p) for p in points] class Dummy: + def __init__(self, screen: Surface) -> None: + self.screen = screen + self._map_layer = DummyMapLayer() + self._sprites = 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 = 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..cf1a49a --- /dev/null +++ b/apps/demo/translate_bufferedpy @@ -0,0 +1,102 @@ +""" +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.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 + +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(Sprite): + def __init__(self, x: int, y: int, w: int = 32, h: int = 32) -> None: + super().__init__() + self.rect = Rect(x, y, w, h) + + +class TranslateTest: + def __init__(self, screen: 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 = 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/apps/tutorial/quest.py b/apps/tutorial/quest.py index bfb7a29..b2cdb2b 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,10 @@ VIDEORESIZE, K_r, ) -from pytmx.util_pygame import load_pygame +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 import pyscroll.data @@ -39,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 @@ -67,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 = 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 @@ -111,27 +113,24 @@ 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.BufferedRenderer( + self.map_layer = pyscroll.orthographic.BufferedRenderer( data=pyscroll.data.TiledMapData(tmx_data), size=screen.get_size(), clamp_camera=False, @@ -146,14 +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() - self.hero.position = self.map_layer.map_rect.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) @@ -163,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: @@ -193,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) @@ -224,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 860f031..9549511 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: float = 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 @@ -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() @@ -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) @@ -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 e0fa07e..abfc0d0 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 @@ -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 = time.time, - scaling_function: Callable = pygame.transform.scale, + time_source: Callable[[], float] = time.time, + 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 @@ -73,7 +75,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 +89,38 @@ 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: int = 0 + # offsets are used to scroll map in sub-tile increments + 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: int = 0 + self._half_height: int = 0 + # tiles queued to be draw onto buffer + self._tile_queue: Optional[list[tuple[int, int, int, Surface]]] = None + # heap queue of animation token; schedules tile changes + 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: Optional[Surface] = 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: float = 1.0 + self._real_ratio_y: float = 1.0 + # this represents the viewable map pixels + self.view_rect = Rect(0, 0, 0, 0) self.set_size(size) @@ -124,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() @@ -136,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) @@ -150,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 @@ -216,7 +219,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. @@ -240,11 +243,11 @@ def draw(self, surface: Surface, rect: RectLike, surfaces: list[Surface] = None) 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) 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() @@ -259,7 +262,6 @@ def zoom(self) -> float: Default value is 1.0 This value cannot be negative or 0.0 - """ return self._zoom_level @@ -281,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 @@ -293,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") @@ -304,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, @@ -317,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: @@ -334,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 @@ -353,14 +350,13 @@ def translate_points(self, points: list[Vector2D]) -> list[Vector2DInt]: Args: 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 @@ -374,9 +370,8 @@ def translate_rects(self, rects: list[Rect]) -> list[Rect]: Args: 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 +394,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. @@ -408,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) @@ -425,14 +419,13 @@ 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. 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 @@ -447,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 @@ -535,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 @@ -569,7 +560,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 @@ -583,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 @@ -611,10 +601,9 @@ def _initialize_buffers(self, view_size: Vector2DInt) -> None: Args: view_size: size of the draw area - """ - 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 @@ -639,9 +628,11 @@ def make_rect(x, y) -> 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) @@ -651,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/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 index eba741d..a43f793 100644 --- a/tests/pyscroll/test_isometric.py +++ b/tests/pyscroll/test_isometric.py @@ -1,6 +1,8 @@ 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)) diff --git a/tests/pyscroll/test_pyscroll.py b/tests/pyscroll/test_pyscroll.py index c7f2cef..83341fb 100644 --- a/tests/pyscroll/test_pyscroll.py +++ b/tests/pyscroll/test_pyscroll.py @@ -1,8 +1,9 @@ import unittest from unittest import mock -import pygame +from pygame.rect import Rect +from pyscroll.common import Vector2DInt from pyscroll.data import PyscrollDataAdapter from pyscroll.orthographic import BufferedRenderer @@ -20,7 +21,7 @@ def get_tile_image(self, *position): 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() @@ -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))