diff --git a/apps/demo/demo-stitched.py b/apps/demo/demo-stitched.py new file mode 100644 index 0000000..dd7c7c5 --- /dev/null +++ b/apps/demo/demo-stitched.py @@ -0,0 +1,207 @@ +""" + +Rendering demo showing 9 TMX maps rendered at once + + +Very basic! No animations. + +""" +from __future__ import annotations + +from pathlib import Path +from typing import List + +import pygame +from pygame.locals import ( + K_UP, + K_DOWN, + K_LEFT, + K_RIGHT, + K_MINUS, + K_EQUALS, + K_ESCAPE, + K_r, +) +from pygame.locals import KEYDOWN, VIDEORESIZE, QUIT +from pytmx.util_pygame import load_pygame + +import pyscroll +from pyscroll.data import MapAggregator, TiledMapData +from pyscroll.group import PyscrollGroup + +# define configuration variables here +CURRENT_DIR = Path(__file__).parent +RESOURCES_DIR = CURRENT_DIR +HERO_MOVE_SPEED = 200 # pixels per second + + +def init_screen(width: int, height: int) -> pygame.Surface: + screen = pygame.display.set_mode((width, height), pygame.RESIZABLE) + return screen + + +def load_image(filename: str) -> pygame.Surface: + return pygame.image.load(str(RESOURCES_DIR / filename)) + + +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 = self.image.get_rect() + self.feet = pygame.Rect(0, 0, self.rect.width * 0.5, 8) + + @property + def position(self) -> List[float]: + return list(self._position) + + @position.setter + 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 + + def move_back(self, dt: float) -> None: + self._position = self._old_position + self.rect.topleft = self._position + self.feet.midbottom = self.rect.midbottom + + +class QuestGame: + map_path = RESOURCES_DIR / "grasslands.tmx" + + def __init__(self, screen: pygame.Surface) -> None: + self.screen = screen + self.running = False + + world_data = MapAggregator((16, 16)) + for filename, offset in [ + ("stitched0.tmx", (-20, -20)), + ("stitched1.tmx", (0, -20)), + ("stitched2.tmx", (20, -20)), + ("stitched3.tmx", (-20, 0)), + ("stitched4.tmx", (0, 0)), + ("stitched5.tmx", (20, 0)), + ("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( + data=world_data, + size=screen.get_size(), + clamp_camera=True, + ) + self.map_layer.zoom = 2 + self.group = PyscrollGroup(map_layer=self.map_layer, default_layer=0) + + # put the hero in the center of the map + 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: + self.group.center(self.hero.rect.center) + self.group.draw(self.screen) + + def handle_input(self) -> None: + """ + Handle pygame input events + + """ + for event in pygame.event.get(): + if event.type == QUIT: + self.running = False + break + + elif event.type == KEYDOWN: + if event.key == K_ESCAPE: + self.running = False + break + + elif event.key == K_r: + self.map_layer.reload() + + elif event.key == K_EQUALS: + self.map_layer.zoom += 0.25 + + 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 + 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() + 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 + + def update(self, dt: float): + """ + Tasks that occur over time should be handled here + + """ + self.group.update(dt) + + def run(self): + 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 + + +def main() -> None: + pygame.init() + pygame.font.init() + screen = init_screen(800, 600) + pygame.display.set_caption("Quest - An epic journey.") + + try: + game = QuestGame(screen) + game.run() + except KeyboardInterrupt: + pass + finally: + pygame.quit() + + +if __name__ == "__main__": + main() diff --git a/apps/demo/hero.png b/apps/demo/hero.png new file mode 100644 index 0000000..bf9592e Binary files /dev/null and b/apps/demo/hero.png differ diff --git a/apps/demo/stitched.world b/apps/demo/stitched.world new file mode 100644 index 0000000..56b7806 --- /dev/null +++ b/apps/demo/stitched.world @@ -0,0 +1,69 @@ +{ + "maps": [ + { + "fileName": "stitched0.tmx", + "height": 320, + "width": 320, + "x": -320, + "y": -320 + }, + { + "fileName": "stitched1.tmx", + "height": 320, + "width": 320, + "x": 0, + "y": -320 + }, + { + "fileName": "stitched2.tmx", + "height": 320, + "width": 320, + "x": 320, + "y": -320 + }, + { + "fileName": "stitched3.tmx", + "height": 320, + "width": 320, + "x": -320, + "y": 0 + }, + { + "fileName": "stitched4.tmx", + "height": 320, + "width": 320, + "x": 0, + "y": 0 + }, + { + "fileName": "stitched5.tmx", + "height": 320, + "width": 320, + "x": 320, + "y": 0 + }, + { + "fileName": "stitched6.tmx", + "height": 320, + "width": 320, + "x": -320, + "y": 320 + }, + { + "fileName": "stitched7.tmx", + "height": 320, + "width": 320, + "x": 0, + "y": 320 + }, + { + "fileName": "stitched8.tmx", + "height": 320, + "width": 320, + "x": 320, + "y": 320 + } + ], + "onlyShowAdjacentMaps": false, + "type": "world" +} diff --git a/apps/demo/stitched0.tmx b/apps/demo/stitched0.tmx new file mode 100644 index 0000000..21f44e5 --- /dev/null +++ b/apps/demo/stitched0.tmx @@ -0,0 +1,9 @@ + + + + + + eJwTYmBgEBrFo3gUj+JRPIpJxAAnqBwh + + + diff --git a/apps/demo/stitched1.tmx b/apps/demo/stitched1.tmx new file mode 100644 index 0000000..59818d4 --- /dev/null +++ b/apps/demo/stitched1.tmx @@ -0,0 +1,9 @@ + + + + + + eJyTY2BgkBvFo3gUj+JRPIpJxADoji7h + + + diff --git a/apps/demo/stitched2.tmx b/apps/demo/stitched2.tmx new file mode 100644 index 0000000..21f44e5 --- /dev/null +++ b/apps/demo/stitched2.tmx @@ -0,0 +1,9 @@ + + + + + + eJwTYmBgEBrFo3gUj+JRPIpJxAAnqBwh + + + diff --git a/apps/demo/stitched3.tmx b/apps/demo/stitched3.tmx new file mode 100644 index 0000000..bd56da0 --- /dev/null +++ b/apps/demo/stitched3.tmx @@ -0,0 +1,9 @@ + + + + + + eJyTYGBgkBjFo3gUj+JRPIpJxACIGyWB + + + diff --git a/apps/demo/stitched4.tmx b/apps/demo/stitched4.tmx new file mode 100644 index 0000000..e9f4b0a --- /dev/null +++ b/apps/demo/stitched4.tmx @@ -0,0 +1,14 @@ + + + + + + eJyTZmBgkB7Fo3gUj+JRPIpJxAA4XCox + + + + + eJztklEKACAIQ7v/fbpfvxLb3OoraCCC2HxZY7ynWQLVT/xqRjU2s+PbQ81MWNUch435sb7Oz2Fx/bqcvDPiYTmR45myonPq7zheKLM+xbP7ML7uvmo/t7u7PfuVawEzDUjU + + + diff --git a/apps/demo/stitched5.tmx b/apps/demo/stitched5.tmx new file mode 100644 index 0000000..bd56da0 --- /dev/null +++ b/apps/demo/stitched5.tmx @@ -0,0 +1,9 @@ + + + + + + eJyTYGBgkBjFo3gUj+JRPIpJxACIGyWB + + + diff --git a/apps/demo/stitched6.tmx b/apps/demo/stitched6.tmx new file mode 100644 index 0000000..21f44e5 --- /dev/null +++ b/apps/demo/stitched6.tmx @@ -0,0 +1,9 @@ + + + + + + eJwTYmBgEBrFo3gUj+JRPIpJxAAnqBwh + + + diff --git a/apps/demo/stitched7.tmx b/apps/demo/stitched7.tmx new file mode 100644 index 0000000..59818d4 --- /dev/null +++ b/apps/demo/stitched7.tmx @@ -0,0 +1,9 @@ + + + + + + eJyTY2BgkBvFo3gUj+JRPIpJxADoji7h + + + diff --git a/apps/demo/stitched8.tmx b/apps/demo/stitched8.tmx new file mode 100644 index 0000000..21f44e5 --- /dev/null +++ b/apps/demo/stitched8.tmx @@ -0,0 +1,9 @@ + + + + + + eJwTYmBgEBrFo3gUj+JRPIpJxAAnqBwh + + + diff --git a/pyscroll/__init__.py b/pyscroll/__init__.py index d25c0fb..b5710fe 100644 --- a/pyscroll/__init__.py +++ b/pyscroll/__init__.py @@ -3,7 +3,7 @@ from .isometric import IsometricBufferedRenderer from .orthographic import BufferedRenderer -__version__ = 2, 28 +__version__ = 2, 29 __author__ = "bitcraft" __author_email__ = "leif.theden@gmail.com" __description__ = "Pygame Scrolling" diff --git a/pyscroll/common.py b/pyscroll/common.py index d7d2e60..a2b8f07 100644 --- a/pyscroll/common.py +++ b/pyscroll/common.py @@ -3,9 +3,11 @@ from contextlib import contextmanager from typing import Any, List, Tuple, Union -from pygame import Rect, Surface +from pygame import Rect, Surface, Vector2 RectLike = Union[Rect, Tuple[Any, Any, Any, Any]] +Vector2D = Union[Tuple[float, float], Tuple[int, int], Vector2] +Vector2DInt = Tuple[int, int] @contextmanager diff --git a/pyscroll/data.py b/pyscroll/data.py index 15f457e..94da885 100644 --- a/pyscroll/data.py +++ b/pyscroll/data.py @@ -20,10 +20,14 @@ except ImportError: pass -from .common import rect_to_bb, RectLike +from .common import rect_to_bb, RectLike, Vector2DInt from .animation import AnimationFrame, AnimationToken -__all__ = ('PyscrollDataAdapter', 'TiledMapData') +__all__ = ( + "PyscrollDataAdapter", + "TiledMapData", + "MapAggregator", +) class PyscrollDataAdapter: @@ -229,17 +233,6 @@ def _get_tile_image_by_id(self, id): """ raise NotImplementedError - def convert_surfaces(self, parent: pygame.Surface, alpha: bool = False): - """ - Convert all images in the data to match the parent. - - Args: - alpha: if True, then do not discard alpha channel - parent: Surface used to convert the others - - """ - raise NotImplementedError - def get_animations(self): """ Get tile animation data. @@ -379,3 +372,107 @@ def rev(seq, start, stop): except KeyError: # not animated, so return surface from data, if any yield x, y, l, images[gid] + + +class MapAggregator(PyscrollDataAdapter): + """ + Combine multiple data sources with an offset + + Currently this is just in a test phase. + + Has the following limitations: + - Tile sizes must be the same for all maps + - No tile animations + - Sprites cannot be under layers + - Cannot remove maps once added + + """ + def __init__(self, tile_size): + super().__init__() + self.tile_size = tile_size + self.map_size = 0, 0 + self.maps = list() + self._min_x = 0 + self._min_y = 0 + + def _get_tile_image(self, x: int, y: int, l: int) -> Surface: + """ + Required for sprite collation - not implemented + + """ + pass + + def _get_tile_image_by_id(self, id): + """ + Required for sprite collation - not implemented + + """ + pass + + def add_map(self, data: PyscrollDataAdapter, offset: Vector2DInt): + """ + Add map data and position it with an offset + + Args: + data: Data Adapater, such as TiledMapData + offset: Where the upper-left corner is, in tiles + + """ + assert data.tile_size == self.tile_size + rect = pygame.Rect(offset, data.map_size) + ox = self._min_x - offset[0] + oy = self._min_y - offset[1] + self._min_x = min(self._min_x, offset[0]) + self._min_y = min(self._min_y, offset[1]) + mx = 0 + my = 0 + # the renderer cannot deal with negative tile coordinates, + # so we must move all the offsets if there is a negative so + # that all the tile coordinates are >= (0, 0) + self.maps.append((data, rect)) + if ox > 0 or oy > 0: + for data, rect in self.maps: + rect.move_ip((ox, oy)) + mx = max(mx, rect.right) + my = max(my, rect.bottom) + else: + rect.move_ip(-self._min_x, -self._min_y) + mx = max(mx, rect.right) + my = max(my, rect.bottom) + self.map_size = mx, my + + def remove_map(self, data: PyscrollDataAdapter): + """ + Remove map - not implemented + + """ + raise NotImplementedError + + def get_animations(self): + """ + Get animations - not implemented + + """ + pass + + def reload_data(self): + """ + Reload the tiles - not implemented + + """ + pass + + @property + def visible_tile_layers(self): + layers = set() + for data, offset in self.maps: + layers.update(list(data.visible_tile_layers)) + return sorted(layers) + + def get_tile_images_by_rect(self, view: RectLike): + view = pygame.Rect(view) + for data, rect in self.maps: + ox, oy = rect.topleft + clipped = rect.clip(view).move(-ox, -oy) + for x, y, l, image in data.get_tile_images_by_rect(clipped): + yield x + ox, y + oy, l, image diff --git a/pyscroll/orthographic.py b/pyscroll/orthographic.py index 7ad6e16..fcc6fc6 100644 --- a/pyscroll/orthographic.py +++ b/pyscroll/orthographic.py @@ -4,20 +4,23 @@ import math import time from itertools import chain, product -from typing import List, Tuple, TYPE_CHECKING, Callable, Union +from typing import List, TYPE_CHECKING, Callable import pygame from pygame import Rect, Surface -from .common import surface_clipping_context, RectLike +from .common import ( + surface_clipping_context, + RectLike, + Vector2D, + Vector2DInt, +) from .quadtree import FastQuadTree if TYPE_CHECKING: from .data import PyscrollDataAdapter log = logging.getLogger(__file__) -Vector2D = Union[Tuple[float, float], Tuple[int, int], pygame.Vector2] -Vector2DInt = Tuple[int, int] class BufferedRenderer: diff --git a/setup.py b/setup.py index b8fa1a1..a924f99 100644 --- a/setup.py +++ b/setup.py @@ -1,13 +1,13 @@ #!/usr/bin/env python # encoding: utf-8 -# pip install wheel +# pip install wheel twine # python3 setup.py sdist bdist_wheel # python3 -m twine upload --repository pypi dist/* from setuptools import setup setup( name="pyscroll", - version="2.28", + version="2.29", description="Fast scrolling maps library for pygame", author="bitcraft", author_email="leif.theden@gmail.com",