From 0db425458b42ab6a23f008f8c3ba45da300f7729 Mon Sep 17 00:00:00 2001 From: JaskRendix Date: Thu, 24 Jul 2025 09:42:36 +0200 Subject: [PATCH] util pygame --- .github/workflows/test.yml | 10 +- apps/pygame_demo.py | 54 +-- pytmx/util_pygame.py | 237 +++++++++----- tests/pytmx/test_util_pygame.py | 565 ++++++++++++++++++++++++++++++++ 4 files changed, 748 insertions(+), 118 deletions(-) create mode 100644 tests/pytmx/test_util_pygame.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6048d2b..367409b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,18 +14,12 @@ jobs: strategy: matrix: python-version: ['3.9', '3.10', '3.11', '3.12'] - dependencies: - - pygame pyglet - - pygame - - pyglet - - "null" steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install Requirements - if: ${{ matrix.dependencies != 'null' }} - run: pip install ${{ matrix.dependencies }} + run: pip install pygame pyglet - name: Run Tests - run: python -m unittest tests/pytmx/test_pytmx.py + run: python -m unittest discover -s tests/pytmx -p "test_*.py" diff --git a/apps/pygame_demo.py b/apps/pygame_demo.py index 5e8c375..ac9c7ae 100644 --- a/apps/pygame_demo.py +++ b/apps/pygame_demo.py @@ -16,6 +16,7 @@ """ import logging +from pathlib import Path import pygame from pygame.locals import * @@ -27,27 +28,27 @@ logger = logging.getLogger(__name__) -def init_screen(width, height): +def init_screen(width: int, height: int) -> pygame.Surface: """Set the screen mode This function is used to handle window resize events """ return pygame.display.set_mode((width, height), pygame.RESIZABLE) -class TiledRenderer(object): +class TiledRenderer: """ Super simple way to render a tiled map """ - def __init__(self, filename) -> None: - tm = load_pygame(filename) + def __init__(self, filename: Path) -> None: + tm = load_pygame(filename.as_posix()) # self.size will be the pixel size of the map # this value is used later to render the entire map to a pygame surface self.pixel_size = tm.width * tm.tilewidth, tm.height * tm.tileheight self.tmx_data = tm - def render_map(self, surface) -> None: + def render_map(self, surface: pygame.Surface) -> None: """Render our map to a pygame surface Feel free to use this as a starting point for your pygame app. @@ -76,7 +77,7 @@ def render_map(self, surface) -> None: elif isinstance(layer, TiledImageLayer): self.render_image_layer(surface, layer) - def render_tile_layer(self, surface, layer) -> None: + def render_tile_layer(self, surface: pygame.Surface, layer: TiledTileLayer) -> None: """Render all TiledTiles in this layer""" # deref these heavily used references for speed tw = self.tmx_data.tilewidth @@ -96,7 +97,9 @@ def render_tile_layer(self, surface, layer) -> None: sy = x * th2 + y * th2 surface_blit(image, (sx + ox, sy)) - def render_object_layer(self, surface, layer) -> None: + def render_object_layer( + self, surface: pygame.Surface, layer: TiledObjectGroup + ) -> None: """Render all TiledObjects contained in this layer""" # deref these heavily used references for speed draw_lines = pygame.draw.lines @@ -122,30 +125,31 @@ def render_object_layer(self, surface, layer) -> None: surface, rect_color, obj.closed, obj.apply_transformations(), 3 ) - def render_image_layer(self, surface, layer) -> None: + def render_image_layer( + self, surface: pygame.Surface, layer: TiledImageLayer + ) -> None: if layer.image: surface.blit(layer.image, (0, 0)) -class SimpleTest(object): +class SimpleTest: """Basic app to display a rendered Tiled map""" - def __init__(self, filename) -> None: + def __init__(self, filename: Path) -> None: self.renderer = None self.running = False self.dirty = False self.exit_status = 0 self.load_map(filename) - def load_map(self, filename) -> None: + def load_map(self, filename: Path) -> None: """Create a renderer, load data, and print some debug info""" self.renderer = TiledRenderer(filename) logger.info("Objects in map:") for obj in self.renderer.tmx_data.objects: - logger.info(obj) - for k, v in obj.properties.items(): - logger.info("%s\t%s", k, v) + logger.info("Object: %s", obj) + logger.debug("Properties: %s", vars(obj)) logger.info("GID (tile) properties:") for k, v in self.renderer.tmx_data.tile_properties.items(): @@ -155,7 +159,7 @@ def load_map(self, filename) -> None: for k, v in self.renderer.tmx_data.get_tile_colliders(): logger.info("%s\t%s", k, list(v)) - def draw(self, surface) -> None: + def draw(self, surface: pygame.Surface) -> None: """Draw our map to some surface (probably the display)""" # first we make a temporary surface that will accommodate the entire # size of the map. @@ -198,8 +202,13 @@ def handle_input(self) -> None: self.exit_status = 0 self.running = False - def run(self): - """This is our app main loop""" + def run(self) -> int: + """ + Main loop of the app. + + Returns: + int: Exit status (0 = success, 1 = error) + """ self.dirty = True self.running = True self.exit_status = 1 @@ -219,9 +228,6 @@ def run(self): if __name__ == "__main__": - import glob - import os.path - pygame.init() pygame.font.init() screen = init_screen(600, 600) @@ -232,11 +238,11 @@ def run(self): # loop through a bunch of maps in the maps folder try: - here = os.path.dirname(os.path.abspath(__file__)) - for filename in glob.glob(os.path.join(here, "data", "*.tmx")): - logger.info("Testing %s", filename) + for filename in Path("apps/data").glob("*.tmx"): + logger.info("Testing %s", filename.as_posix()) if not SimpleTest(filename).run(): break - except: + except Exception as e: + logger.exception("Unhandled exception: %s", e) pygame.quit() raise diff --git a/pytmx/util_pygame.py b/pytmx/util_pygame.py index f1dc2ac..ac630f5 100644 --- a/pytmx/util_pygame.py +++ b/pytmx/util_pygame.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -Copyright (C) 2012-2024, Leif Theden +Copyright (C) 2012-2025, Leif Theden This file is part of pytmx. @@ -19,10 +19,11 @@ """ import itertools import logging -from typing import Optional, Union +from collections.abc import Callable +from typing import Any, Optional, Union import pytmx -from pytmx.pytmx import ColorLike, PointLike +from pytmx.pytmx import ColorLike, PointLike, TileFlags logger = logging.getLogger(__name__) @@ -37,8 +38,7 @@ def handle_transformation( - tile: pygame.Surface, - flags: pytmx.TileFlags, + tile: pygame.Surface, flags: pytmx.TileFlags ) -> pygame.Surface: """ Transform tile according to the flags and return a new one @@ -49,70 +49,113 @@ def handle_transformation( Returns: new tile surface - """ if flags.flipped_diagonally: + if tile.get_width() != tile.get_height(): + raise ValueError( + f"Cannot flip tile {tile.get_size()} diagonally if it is not a square" + ) tile = flip(rotate(tile, 270), True, False) if flags.flipped_horizontally or flags.flipped_vertically: tile = flip(tile, flags.flipped_horizontally, flags.flipped_vertically) return tile +def count_colorkey_pixels(surface: pygame.Surface, colorkey: ColorLike) -> int: + """Efficiently count pixels matching the colorkey.""" + try: + import pygame.surfarray + + pixel_array = pygame.surfarray.pixels3d(surface) + r, g, b = colorkey[:3] + return ( + (pixel_array[:, :, 0] == r) + & (pixel_array[:, :, 1] == g) + & (pixel_array[:, :, 2] == b) + ).sum() + except ImportError: + # Slow fallback method + width, height = surface.get_size() + return sum( + 1 + for x in range(width) + for y in range(height) + if surface.get_at((x, y))[:3] == colorkey[:3] + ) + + +def has_transparency(surface: pygame.Surface, threshold: int = 254) -> bool: + """Detects transparency via mask.""" + try: + mask = pygame.mask.from_surface(surface, threshold) + return mask.count() < surface.get_width() * surface.get_height() + except Exception: + return True # Assume transparency if mask fails + + +def log_surface_properties(surface: pygame.Surface, label: str = "Surface") -> None: + """Print diagnostic info (optional for debugging).""" + size = surface.get_size() + flags = surface.get_flags() + bitsize = surface.get_bitsize() + alpha = surface.get_alpha() + logger.info( + f"[{label}] Size: {size}, Flags: {flags}, Bitsize: {bitsize}, Alpha: {alpha}" + ) + + def smart_convert( original: pygame.Surface, colorkey: Optional[ColorLike], pixelalpha: bool, + preserve_alpha_flag: bool = False, ) -> pygame.Surface: """ Return new pygame Surface with optimal pixel/data format - This method does several interactive_tests on a surface to determine the optimal + This method does several interactive tests on a surface to determine the optimal flags and pixel format for each tile surface. Parameters: original: tile surface to inspect colorkey: optional colorkey for the tileset image pixelalpha: if true, prefer per-pixel alpha surfaces + preserve_alpha_flag: if True, retain SRCALPHA format even if transparency isn't detected Returns: new tile surface - """ - # tiled set a colorkey - if colorkey: - tile = original.convert() - tile.set_colorkey(colorkey, pygame.RLEACCEL) - # TODO: if there is a colorkey, count the colorkey pixels to determine if RLEACCEL should be used - - # no colorkey, so use a mask to determine if there are transparent pixels - else: - tile_size = original.get_size() - threshold = 254 # the default + width, height = original.get_size() + tile = None - try: - # count the number of pixels in the tile that are not transparent - px = pygame.mask.from_surface(original, threshold).count() - except: - # pygame_sdl2 will fail because the mask module is not included - # in this case, just convert_alpha and return it - return original.convert_alpha() - - # there are no transparent pixels in the image - if px == tile_size[0] * tile_size[1]: - tile = original.convert() + def force_alpha(): + return original.convert_alpha() - # there are transparent pixels, and set for perpixel alpha - elif pixelalpha: - tile = original.convert_alpha() + if colorkey: + colorkey_pixels = count_colorkey_pixels(original, colorkey) + ratio = colorkey_pixels / (width * height) - # there are transparent pixels, and we won't handle them + if ratio > 0.5: + tile = original.convert() + tile.set_colorkey(colorkey, pygame.RLEACCEL) + else: + tile = force_alpha() if pixelalpha else original.convert() + tile.set_colorkey(colorkey) + else: + if has_transparency(original): + tile = force_alpha() + elif preserve_alpha_flag and original.get_flags() & pygame.SRCALPHA: + tile = force_alpha() else: tile = original.convert() + # Optional: log_surface_properties(tile, label="Converted Tile") return tile -def pygame_image_loader(filename: str, colorkey: Optional[ColorLike], **kwargs): +def pygame_image_loader( + filename: str, colorkey: Optional[ColorLike], **kwargs: Any +) -> Callable[[Optional[pygame.Rect], Optional[TileFlags]], pygame.Surface]: """ pytmx image loader for pygame @@ -122,15 +165,25 @@ def pygame_image_loader(filename: str, colorkey: Optional[ColorLike], **kwargs): Returns: function to load tile images - """ if colorkey: - colorkey = pygame.Color("#{0}".format(colorkey)) + if isinstance(colorkey, str): + if not colorkey.startswith("#") and len(colorkey) in (6, 8): + colorkey = pygame.Color(f"#{colorkey}") + else: + colorkey = pygame.Color(colorkey) + elif isinstance(colorkey, tuple) and 3 <= len(colorkey) <= 4: + colorkey = pygame.Color(colorkey) + else: + logger.error("Invalid colorkey") + raise ValueError("Invalid colorkey") pixelalpha = kwargs.get("pixelalpha", True) image = pygame.image.load(filename) - def load_image(rect=None, flags=None): + def load_image( + rect: Optional[pygame.Rect] = None, flags: Optional[TileFlags] = None + ) -> pygame.Surface: if rect: try: tile = image.subsurface(rect) @@ -149,11 +202,7 @@ def load_image(rect=None, flags=None): return load_image -def load_pygame( - filename: str, - *args, - **kwargs, -) -> pytmx.TiledMap: +def load_pygame(filename: str, *args: Any, **kwargs: Any) -> pytmx.TiledMap: """Load a TMX file, images, and return a TiledMap class PYGAME USERS: Use me. @@ -177,7 +226,6 @@ def load_pygame( Returns: new pytmx.TiledMap object - """ kwargs["image_loader"] = pygame_image_loader return pytmx.TiledMap(filename, *args, **kwargs) @@ -204,54 +252,63 @@ def build_rects( Returns: list of pygame Rect objects - """ if isinstance(tileset, int): try: - tileset = tmxmap.tilesets[tileset] + tileset_obj = tmxmap.tilesets[tileset] except IndexError: - msg = "Tileset #{0} not found in map {1}." - logger.debug(msg.format(tileset, tmxmap)) - raise IndexError + msg = f"Tileset #{tileset} not found in map {tmxmap}." + logger.debug(msg) + raise IndexError(msg) elif isinstance(tileset, str): try: - tileset = [t for t in tmxmap.tilesets if t.name == tileset].pop() - except IndexError: - msg = 'Tileset "{0}" not found in map {1}.' - logger.debug(msg.format(tileset, tmxmap)) - raise ValueError - - elif tileset: - msg = "Tileset must be either a int or string. got: {0}" - logger.debug(msg.format(type(tileset))) - raise TypeError + # Find the tileset with the matching name + tileset_obj = next((t for t in tmxmap.tilesets if t.name == tileset), None) + if tileset_obj is None: + msg = f'Tileset "{tileset}" not found in map {tmxmap}.' + logger.debug(msg) + raise ValueError(msg) + except Exception as e: + msg = f"Error finding tileset: {e}" + logger.debug(msg) + raise ValueError(msg) gid = None if real_gid: try: - gid, flags = tmxmap.map_gid(real_gid)[0] + # Get the map GID and flags + map_gid = tmxmap.map_gid(real_gid) + if map_gid: + gid, flags = map_gid[0] except IndexError: - msg = "GID #{0} not found" - logger.debug(msg.format(real_gid)) - raise ValueError + msg = f"GID #{real_gid} not found" + logger.debug(msg) + raise ValueError(msg) if isinstance(layer, int): layer_data = tmxmap.get_layer_data(layer) elif isinstance(layer, str): try: - layer = [l for l in tmxmap.layers if l.name == layer].pop() - layer_data = layer.data - except IndexError: - msg = 'Layer "{0}" not found in map {1}.' - logger.debug(msg.format(layer, tmxmap)) - raise ValueError - - p = itertools.product(range(tmxmap.width), range(tmxmap.height)) - if gid: - points = [(x, y) for (x, y) in p if layer_data[y][x] == gid] - else: - points = [(x, y) for (x, y) in p if layer_data[y][x]] + # Find the layer with the matching name + layer_obj = next( + (l for l in tmxmap.layers if l.name and l.name == layer), None + ) + if layer_obj is None: + msg = f'Layer "{layer}" not found in map {tmxmap}.' + logger.debug(msg) + raise ValueError(msg) + layer_data = layer_obj.data + except Exception as e: + msg = f"Error finding layer: {e}" + logger.debug(msg) + raise ValueError(msg) + + points = [] + for x, y in itertools.product(range(tmxmap.width), range(tmxmap.height)): + tile_gid = layer_data[y][x] + if (gid is None and tile_gid) or (gid == tile_gid): + points.append((x, y)) rects = simplify(points, tmxmap.tilewidth, tmxmap.tileheight) return rects @@ -304,15 +361,28 @@ def simplify( making a list of rects, one for each tile on the map! """ - def pick_rect(points, rects) -> None: - ox, oy = sorted([(sum(p), p) for p in points])[0][1] + if not all_points: + return [] + + point_set = set(all_points) + + rect_list: list[pygame.Rect] = [] + + def pick_rect(points: set[PointLike], rects: list[pygame.Rect]) -> None: + """ + Recursively pick a rect from the points and add it to the rects list. + """ + if not points: + return + + ox, oy = min(points, key=lambda p: (p[0], p[1])) x = ox y = oy ex = None - while 1: + while True: x += 1 - if not (x, y) in points: + if (x, y) not in points: if ex is None: ex = x - 1 @@ -320,7 +390,6 @@ def pick_rect(points, rects) -> None: if x == ex + 1: y += 1 x = ox - else: y -= 1 break @@ -339,14 +408,10 @@ def pick_rect(points, rects) -> None: rects.append(c_rect) rect = pygame.Rect(ox, oy, ex - ox + 1, y - oy + 1) - kill = [p for p in points if rect.collidepoint(p)] - [points.remove(i) for i in kill] + points.difference_update({p for p in points if rect.collidepoint(p)}) - if points: - pick_rect(points, rects) + pick_rect(points, rects) - rect_list = [] - while all_points: - pick_rect(all_points, rect_list) + pick_rect(point_set, rect_list) return rect_list diff --git a/tests/pytmx/test_util_pygame.py b/tests/pytmx/test_util_pygame.py new file mode 100644 index 0000000..e828d94 --- /dev/null +++ b/tests/pytmx/test_util_pygame.py @@ -0,0 +1,565 @@ +import unittest +from unittest.mock import MagicMock, Mock, patch + +import pygame +from pygame.rect import Rect +from pygame.surface import Surface + +import pytmx +from pytmx.util_pygame import ( + TileFlags, + build_rects, + handle_transformation, + pygame_image_loader, + simplify, + smart_convert, +) + + +class TestPyTMXPygameIntegration(unittest.TestCase): + + @classmethod + def setUpClass(cls): + pygame.init() + + @classmethod + def tearDownClass(cls): + pygame.quit() + + def setUp(self): + self.screen = pygame.display.set_mode((1, 1)) + + def test_handle_transformation_no_flags(self): + tile = Surface((32, 32)) + flags = MagicMock( + flipped_diagonally=False, + flipped_horizontally=False, + flipped_vertically=False, + ) + transformed_tile = handle_transformation(tile, flags) + self.assertEqual(transformed_tile, tile) + + def test_handle_transformation_flipped_diagonally(self): + tile = Surface((32, 32)) + flags = MagicMock( + flipped_diagonally=True, + flipped_horizontally=False, + flipped_vertically=False, + ) + transformed_tile = handle_transformation(tile, flags) + self.assertNotEqual(transformed_tile, tile) + + def test_handle_transformation_flipped_horizontally(self): + tile = Surface((32, 32)) + flags = MagicMock( + flipped_diagonally=False, + flipped_horizontally=True, + flipped_vertically=False, + ) + transformed_tile = handle_transformation(tile, flags) + self.assertNotEqual(transformed_tile, tile) + + def test_handle_transformation_flipped_vertically(self): + tile = Surface((32, 32)) + flags = MagicMock( + flipped_diagonally=False, + flipped_horizontally=False, + flipped_vertically=True, + ) + transformed_tile = handle_transformation(tile, flags) + self.assertNotEqual(transformed_tile, tile) + + def test_smart_convert_no_colorkey(self): + original = Surface((32, 32)) + colorkey = None + pixelalpha = False + converted_tile = smart_convert(original, colorkey, pixelalpha) + self.assertEqual(converted_tile.get_flags() & pygame.SRCALPHA, 0) + + def test_smart_convert_colorkey(self): + original = Surface((32, 32)) + colorkey = (255, 255, 255) + pixelalpha = False + converted_tile = smart_convert(original, colorkey, pixelalpha) + self.assertEqual(converted_tile.get_flags() & pygame.SRCALPHA, 0) + + def test_smart_convert_pixelalpha(self): + original = Surface((32, 32), pygame.SRCALPHA) + colorkey = None + pixelalpha = True + converted_tile = smart_convert(original, colorkey, pixelalpha) + self.assertEqual(converted_tile.get_flags() & pygame.SRCALPHA, pygame.SRCALPHA) + + @patch("pygame.image.load") + def test_pygame_image_loader(self, mock_load): + filename = "test_image.png" + mock_load.return_value = Surface((32, 32)) + load_image_func = pygame_image_loader(filename, None) + tile = load_image_func() + self.assertIsInstance(tile, Surface) + + @patch("pygame.image.load") + def test_pygame_image_loader_rect(self, mock_load): + filename = "test_image.png" + mock_load.return_value = Surface((32, 32)) + load_image_func = pygame_image_loader(filename, None) + rect = pygame.Rect(0, 0, 16, 16) + tile = load_image_func(rect=rect) + self.assertIsInstance(tile, Surface) + + @patch("pygame.image.load") + def test_pygame_image_loader_flags(self, mock_load): + filename = "test_image.png" + mock_load.return_value = Surface((32, 32)) + load_image_func = pygame_image_loader(filename, None) + flags = MagicMock( + flipped_diagonally=True, flipped_horizontally=True, flipped_vertically=True + ) + tile = load_image_func(flags=flags) + self.assertIsInstance(tile, Surface) + + def test_invalid_colorkey_type(self): + with self.assertRaises(ValueError): + pygame_image_loader("fake.png", {"not": "valid"}) + + @patch("pygame.image.load") + def test_colorkey_hex_without_hash(self, mock_load): + mock_load.return_value = Surface((32, 32)) + loader = pygame_image_loader("fake.png", "ff00ff") + self.assertTrue(callable(loader)) + + @patch("pygame.image.load") + def test_pygame_image_loader_out_of_bounds(self, mock_load): + mock_load.return_value = Surface((32, 32)) + loader = pygame_image_loader("fake.png", None) + with self.assertRaises(ValueError): + loader(pygame.Rect(100, 100, 10, 10)) + + @patch("pygame.image.load") + def test_pygame_image_loader_rect_and_flags(self, mock_load): + surface = Surface((64, 64)) + surface.fill((255, 255, 255)) + mock_load.return_value = surface + flags = MagicMock( + flipped_diagonally=True, flipped_horizontally=True, flipped_vertically=True + ) + loader = pygame_image_loader("fake.png", None) + tile = loader(rect=pygame.Rect(0, 0, 32, 32), flags=flags) + self.assertIsInstance(tile, Surface) + + def test_smart_convert_preserve_alpha_flag(self): + surface = Surface((32, 32), pygame.SRCALPHA) + surface.fill((255, 255, 255, 255)) + tile = smart_convert(surface, None, False, preserve_alpha_flag=True) + self.assertTrue(tile.get_flags() & pygame.SRCALPHA) + + +class TestSimplify(unittest.TestCase): + def test_empty_input(self): + self.assertEqual(simplify([], 1, 1), []) + + def test_single_point(self): + points = [(1, 1)] + expected_rects = [Rect(1, 1, 1, 1)] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + def test_adjacent_points(self): + points = [(1, 1), (2, 1), (1, 2), (2, 2)] + expected_rects = [Rect(1, 1, 2, 2)] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + def test_non_adjacent_points(self): + points = [(1, 1), (3, 3), (5, 5)] + expected_rects = [Rect(1, 1, 1, 1), Rect(3, 3, 1, 1), Rect(5, 5, 1, 1)] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + def test_rectangles(self): + points = [(1, 1), (2, 1), (3, 1), (1, 2), (2, 2), (3, 2)] + expected_rects = [Rect(1, 1, 3, 2)] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + def test_large_input(self): + points = [(x, y) for x in range(10) for y in range(10)] + expected_rects = [Rect(0, 0, 10, 10)] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + def test_tile_size(self): + points = [(1, 1), (2, 1), (1, 2), (2, 2)] + expected_rects = [Rect(2, 2, 4, 4)] + self.assertEqual(simplify(points, 2, 2), expected_rects) + + def test_diagonal_points(self): + points = [(1, 1), (2, 2), (3, 3)] + expected_rects = [Rect(1, 1, 1, 1), Rect(2, 2, 1, 1), Rect(3, 3, 1, 1)] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + def test_vertical_line(self): + points = [(1, 1), (1, 2), (1, 3), (1, 4)] + expected_rects = [Rect(1, 1, 1, 4)] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + def test_horizontal_line(self): + points = [(1, 1), (2, 1), (3, 1), (4, 1)] + expected_rects = [Rect(1, 1, 4, 1)] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + def test_l_shape(self): + points = [(1, 1), (2, 1), (3, 1), (3, 2), (3, 3)] + expected_rects = [Rect(1, 1, 3, 1), Rect(3, 2, 1, 2)] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + def test_t_shape(self): + points = [(1, 1), (2, 1), (3, 1), (2, 2), (2, 3)] + expected_rects = [Rect(1, 1, 3, 1), Rect(2, 2, 1, 2)] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + def test_cross_shape(self): + points = [(1, 1), (2, 1), (3, 1), (2, 2), (2, 3), (1, 2), (3, 2)] + expected_rects = [Rect(1, 1, 3, 2), Rect(2, 3, 1, 1)] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + def test_large_l_shape(self): + points = [ + (1, 1), + (2, 1), + (3, 1), + (4, 1), + (5, 1), + (5, 2), + (5, 3), + (5, 4), + (5, 5), + ] + expected_rects = [Rect(1, 1, 5, 1), Rect(5, 2, 1, 4)] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + def test_large_t_shape(self): + points = [ + (1, 1), + (2, 1), + (3, 1), + (4, 1), + (5, 1), + (3, 2), + (3, 3), + (3, 4), + (3, 5), + ] + expected_rects = [Rect(1, 1, 5, 1), Rect(3, 2, 1, 4)] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + def test_overlapping_segments(self): + points = [(1, 1), (2, 1), (2, 2), (3, 2)] + expected_rects = [Rect(1, 1, 2, 1), Rect(2, 2, 2, 1)] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + def test_hollow_rectangle(self): + points = [ + (1, 1), + (2, 1), + (3, 1), + (1, 2), + (3, 2), + (1, 3), + (2, 3), + (3, 3), + ] + expected_rects = [ + Rect(1, 1, 3, 1), + Rect(1, 2, 1, 2), + Rect(2, 3, 2, 1), + Rect(3, 2, 1, 1), + ] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + def test_staggered_columns(self): + points = [(1, 1), (2, 2), (3, 3)] + expected_rects = [Rect(1, 1, 1, 1), Rect(2, 2, 1, 1), Rect(3, 3, 1, 1)] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + def test_scaled_large_block(self): + points = [(x, y) for x in range(5) for y in range(5)] + expected_rects = [Rect(0, 0, 10, 10)] + self.assertEqual(simplify(points, 2, 2), expected_rects) + + def test_row_with_gaps(self): + points = [(1, 1), (2, 1), (4, 1), (5, 1)] + expected_rects = [Rect(1, 1, 2, 1), Rect(4, 1, 2, 1)] + self.assertEqual(simplify(points, 1, 1), expected_rects) + + +class TestBuildRects(unittest.TestCase): + + def setUp(self): + self.tmxmap = Mock(spec=pytmx.TiledMap) + self.tmxmap.width = 10 + self.tmxmap.height = 10 + self.tmxmap.tilewidth = 32 + self.tmxmap.tileheight = 32 + self.tmxmap.tilesets = [ + Mock(spec=pytmx.TiledTileset), + Mock(spec=pytmx.TiledTileset), + ] + self.tmxmap.tilesets[0].name = "tileset1" + self.tmxmap.tilesets[1].name = "tileset2" + self.tmxmap.layers = [ + Mock(spec=pytmx.TiledTileLayer), + Mock(spec=pytmx.TiledTileLayer), + ] + self.tmxmap.layers[0].name = "layer1" + self.tmxmap.layers[0].data = [[1 for _ in range(10)] for _ in range(10)] + self.tmxmap.layers[1].name = "layer2" + self.tmxmap.layers[1].data = [[1 for _ in range(10)] for _ in range(10)] + self.tmxmap.get_layer_data = Mock( + return_value=[[1 for _ in range(10)] for _ in range(10)] + ) + self.tmxmap.map_gid = Mock(return_value=[(1, 0)]) + + def test_build_rects_with_int_layer_and_int_tileset(self): + result = build_rects(self.tmxmap, 0, 0, 1) + self.assertIsInstance(result, list) + self.assertIsInstance(result[0], Rect) + + def test_build_rects_with_str_layer_and_str_tileset(self): + result = build_rects(self.tmxmap, "layer1", "tileset1", 1) + self.assertIsInstance(result, list) + self.assertIsInstance(result[0], Rect) + + def test_build_rects_with_invalid_layer(self): + with self.assertRaises(ValueError): + build_rects(self.tmxmap, "invalid_layer", 0, 1) + + def test_build_rects_with_invalid_tileset(self): + with self.assertRaises(IndexError): + build_rects(self.tmxmap, 0, 2, 1) + + def test_build_rects_with_invalid_tileset_type(self): + with self.assertRaises(ValueError): + build_rects(self.tmxmap, 0, "invalid_type", 1) + + def test_build_rects_with_invalid_gid(self): + self.tmxmap.map_gid = Mock(return_value=[]) + result = build_rects(self.tmxmap, 0, 0, 1) + self.assertIsInstance(result, list) + + def test_build_rects_with_no_gid(self): + result = build_rects(self.tmxmap, 0, 0, None) + self.assertIsInstance(result, list) + self.assertIsInstance(result[0], Rect) + + def test_layer_with_no_name(self): + self.tmxmap.layers[0].name = None + with self.assertRaises(ValueError): + build_rects(self.tmxmap, "layer1", 0, 1) + + def test_tileset_with_no_name(self): + self.tmxmap.tilesets[0].name = None + with self.assertRaises(ValueError): + build_rects(self.tmxmap, "layer1", "tileset1", 1) + + def test_gid_not_in_layer_data(self): + self.tmxmap.map_gid = Mock(return_value=[(999, 0)]) + result = build_rects(self.tmxmap, "layer1", 0, 999) + self.assertEqual(result, []) + + def test_sparse_tile_distribution(self): + data = [[0 for _ in range(10)] for _ in range(10)] + data[1][1] = 1 + data[5][5] = 1 + self.tmxmap.layers[0].data = data + result = build_rects(self.tmxmap, "layer1", 0, None) + self.assertTrue(len(result) >= 2) + + def test_build_rects_without_tileset(self): + result = build_rects(self.tmxmap, "layer1", None, None) + self.assertIsInstance(result, list) + + +class TestHandleTransformation(unittest.TestCase): + + @classmethod + def setUpClass(cls): + pygame.init() + + @classmethod + def tearDownClass(cls): + pygame.quit() + + def setUp(self): + self.tile = pygame.Surface((32, 32)) + self.tile.fill((255, 0, 0)) + + def test_no_flags(self): + flags = TileFlags(False, False, False) + new_tile = handle_transformation(self.tile, flags) + self.assertEqual(new_tile.get_size(), self.tile.get_size()) + + def test_flipped_diagonally(self): + flags = TileFlags(False, False, True) + new_tile = handle_transformation(self.tile, flags) + self.assertEqual(new_tile.get_size(), self.tile.get_size()) + + def test_flipped_diagonally_non_square(self): + non_square_tile = pygame.Surface((32, 64)) + non_square_tile.fill((255, 0, 0)) + flags = TileFlags(False, False, True) + with self.assertRaises(ValueError): + handle_transformation(non_square_tile, flags) + + def test_flipped_horizontally(self): + flags = TileFlags(True, False, False) + new_tile = handle_transformation(self.tile, flags) + self.assertEqual(new_tile.get_size(), self.tile.get_size()) + + def test_flipped_vertically(self): + flags = TileFlags(False, True, False) + new_tile = handle_transformation(self.tile, flags) + self.assertEqual(new_tile.get_size(), self.tile.get_size()) + + def test_flipped_both(self): + flags = TileFlags(True, True, False) + new_tile = handle_transformation(self.tile, flags) + self.assertEqual(new_tile.get_size(), self.tile.get_size()) + + def test_flipped_all(self): + flags = TileFlags(True, True, True) + new_tile = handle_transformation(self.tile, flags) + self.assertEqual(new_tile.get_size(), self.tile.get_size()) + + +class TestSmartConvert(unittest.TestCase): + + @classmethod + def setUpClass(cls): + pygame.init() + cls.screen = pygame.display.set_mode((1, 1)) # Needed for convert() + cls.red = (255, 0, 0, 255) + cls.transparent = (0, 0, 0, 0) + + cls.base_surface = pygame.Surface((32, 32)) + cls.base_surface.fill(cls.red) + + cls.alpha_surface = pygame.Surface((32, 32), pygame.SRCALPHA) + cls.alpha_surface.fill((255, 0, 0, 128)) # semi-transparent + + cls.edge_transparency_surface = pygame.Surface((32, 32), pygame.SRCALPHA) + cls.edge_transparency_surface.fill((255, 255, 255, 255)) + for x in range(32): + cls.edge_transparency_surface.set_at((x, 0), cls.transparent) + + @classmethod + def tearDownClass(cls): + pygame.quit() + + def test_no_colorkey(self): + result = smart_convert(self.base_surface, None, False) + self.assertEqual(result.get_size(), self.base_surface.get_size()) + self.assertFalse(result.get_flags() & pygame.SRCALPHA) + + def test_pixelalpha_enabled(self): + result = smart_convert(self.alpha_surface, None, True) + self.assertTrue(result.get_flags() & pygame.SRCALPHA) + + def test_colorkey_basic(self): + result = smart_convert(self.base_surface, self.red, False) + self.assertEqual(result.get_colorkey(), self.red) + + def test_colorkey_rleaccel(self): + surface = pygame.Surface((32, 32)) + surface.fill(self.red) + result = smart_convert(surface, self.red, False) + self.assertEqual(result.get_colorkey(), self.red) + self.assertIn(result.get_flags() & pygame.RLEACCEL, [0, pygame.RLEACCEL]) + + def test_sparse_colorkey(self): + surface = pygame.Surface((32, 32)) + surface.fill((0, 0, 255)) + surface.set_at((0, 0), self.red) + result = smart_convert(surface, self.red, False) + self.assertEqual(result.get_colorkey(), self.red) + self.assertFalse(result.get_flags() & pygame.RLEACCEL) + + def test_edge_transparency_handling(self): + result = smart_convert(self.edge_transparency_surface, None, True) + self.assertTrue(result.get_flags() & pygame.SRCALPHA) + + def test_colorkey_with_pixelalpha_enabled(self): + result = smart_convert(self.base_surface, self.red, True) + self.assertEqual(result.get_colorkey(), self.red) + + @patch("pygame.mask.from_surface") + def test_mask_failure_fallback(self, mock_mask): + mock_mask.side_effect = Exception("Fake mask failure") + result = smart_convert(self.base_surface, None, False) + self.assertTrue(result.get_flags() & pygame.SRCALPHA) + + def test_returned_surface_format(self): + result = smart_convert(self.base_surface, None, False) + self.assertFalse(result.get_flags() & pygame.SRCALPHA) + + def test_colorkey_and_pixelalpha_combinations(self): + test_cases = [ + {"colorkey": None, "pixelalpha": False}, + {"colorkey": None, "pixelalpha": True}, + {"colorkey": self.red, "pixelalpha": False}, + {"colorkey": self.red, "pixelalpha": True}, + ] + + for case in test_cases: + with self.subTest(case=case): + result = smart_convert( + self.base_surface, case["colorkey"], case["pixelalpha"] + ) + self.assertEqual(result.get_size(), self.base_surface.get_size()) + + def test_conversion_time_large_surface(self): + import timeit + + large = pygame.Surface((512, 512)) + large.fill(self.red) + + # Wrap smart_convert call in a lambda for timing + duration = timeit.timeit(lambda: smart_convert(large, None, False), number=10) + print(f"Conversion time for large surface (512x512): {duration:.4f} seconds") + self.assertLess(duration, 1.0) # Arbitrary performance threshold + + def test_batch_tile_conversion(self): + tiles = [pygame.Surface((32, 32)) for _ in range(50)] + for tile in tiles: + tile.fill(self.red) + results = [smart_convert(tile, self.red, False) for tile in tiles] + for i, result in enumerate(results): + self.assertEqual(result.get_size(), tiles[i].get_size()) + + def test_gradient_alpha_surface(self): + surface = pygame.Surface((32, 32), pygame.SRCALPHA) + for y in range(32): + alpha = int(255 * (y / 31)) # vertical gradient + for x in range(32): + surface.set_at((x, y), (255, 0, 0, alpha)) + result = smart_convert(surface, None, True) + self.assertTrue(result.get_flags() & pygame.SRCALPHA) + + def test_colorkey_near_match(self): + surface = pygame.Surface((32, 32)) + surface.fill((255, 0, 1)) # almost red + result = smart_convert(surface, (255, 0, 0), False) + self.assertNotEqual(result.get_colorkey(), (255, 0, 0)) # should not match + + def test_zero_sized_surface(self): + surface = pygame.Surface((0, 0)) + result = smart_convert(surface, None, False) + self.assertEqual(result.get_size(), (0, 0)) + + def test_preserve_alpha_flag_enabled(self): + surface = pygame.Surface((32, 32), pygame.SRCALPHA) + surface.fill((255, 255, 255, 255)) # fully opaque + result = smart_convert(surface, None, False, preserve_alpha_flag=True) + self.assertTrue(result.get_flags() & pygame.SRCALPHA) + + def test_conversion_consistency(self): + surface = pygame.Surface((32, 32)) + surface.fill((123, 123, 123)) + result1 = smart_convert(surface, None, False) + result2 = smart_convert(surface, None, False) + self.assertEqual(result1.get_flags(), result2.get_flags())