From 3395e9ee0220cef346cc5f93bbbba2c2320d1666 Mon Sep 17 00:00:00 2001 From: JaskRendix Date: Thu, 24 Jul 2025 12:29:40 +0200 Subject: [PATCH 1/2] pyscroll group --- .github/workflows/python-app.yml | 2 +- pyscroll/group.py | 13 +-- tests/pyscroll/test_pyscroll_group.py | 146 ++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 tests/pyscroll/test_pyscroll_group.py diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 761f5aa..0c713a4 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -34,4 +34,4 @@ jobs: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test run: | - python -m unittest tests/pyscroll/test_pyscroll.py + python -m unittest discover -s tests/pyscroll -p "test_*.py" diff --git a/pyscroll/group.py b/pyscroll/group.py index c04a88d..cd189ce 100644 --- a/pyscroll/group.py +++ b/pyscroll/group.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import pygame @@ -14,7 +14,6 @@ class PyscrollGroup(pygame.sprite.LayeredUpdates): Args: map_layer: Pyscroll Renderer - """ def __init__(self, map_layer: BufferedRenderer, *args, **kwargs) -> None: @@ -30,7 +29,6 @@ def center(self, value) -> None: Args: value: x, y coordinates to center the camera on - """ self._map_layer.center(value) @@ -38,7 +36,6 @@ def center(self, value) -> None: def view(self) -> pygame.Rect: """ Return a Rect representing visible portion of map. - """ return self._map_layer.view_rect.copy() @@ -48,25 +45,23 @@ def draw(self, surface: pygame.surface.Surface) -> list[pygame.rect.Rect]: Args: surface: Surface to draw to - """ ox, oy = self._map_layer.get_center_offset() draw_area = surface.get_rect() view_rect = self.view - new_surfaces = list() + new_surfaces: list[tuple[pygame.Surface, pygame.Rect, int, Any]] = [] spritedict = self.spritedict gl = self.get_layer_of_sprite - new_surfaces_append = new_surfaces.append for spr in self.sprites(): new_rect = spr.rect.move(ox, oy) if spr.rect.colliderect(view_rect): try: - new_surfaces_append((spr.image, new_rect, gl(spr), spr.blendmode)) + new_surfaces.append((spr.image, new_rect, gl(spr), spr.blendmode)) except AttributeError: # should only fail when no blendmode available - new_surfaces_append((spr.image, new_rect, gl(spr))) + new_surfaces.append((spr.image, new_rect, gl(spr))) spritedict[spr] = new_rect self.lostsprites = [] diff --git a/tests/pyscroll/test_pyscroll_group.py b/tests/pyscroll/test_pyscroll_group.py new file mode 100644 index 0000000..7155689 --- /dev/null +++ b/tests/pyscroll/test_pyscroll_group.py @@ -0,0 +1,146 @@ +import unittest +from unittest.mock import MagicMock + +import pygame + +from pyscroll.group import PyscrollGroup +from pyscroll.orthographic import BufferedRenderer + + +class TestPyscrollGroup(unittest.TestCase): + + @classmethod + def setUpClass(cls): + pygame.init() + + @classmethod + def tearDownClass(cls): + pygame.quit() + + def setUp(self): + self.surface = pygame.Surface((640, 480)) + self.map_layer = MagicMock(spec=BufferedRenderer) + self.group = PyscrollGroup(self.map_layer) + + def test_init(self): + self.assertIsInstance(self.group, PyscrollGroup) + self.assertEqual(self.group._map_layer, self.map_layer) + + def test_center(self): + self.group.center((100, 100)) + self.map_layer.center.assert_called_once_with((100, 100)) + + def test_view(self): + self.map_layer.view_rect = pygame.Rect(0, 0, 640, 480) + view = self.group.view + self.assertEqual(view, pygame.Rect(0, 0, 640, 480)) + self.assertIsNot(view, self.map_layer.view_rect) + + def test_draw(self): + sprite1 = MagicMock(spec=pygame.sprite.Sprite) + sprite1.image = pygame.Surface((32, 32)) + sprite1.rect = pygame.Rect(10, 10, 32, 32) + sprite1.layer = 0 + + sprite2 = MagicMock(spec=pygame.sprite.Sprite) + sprite2.image = pygame.Surface((32, 32)) + sprite2.rect = pygame.Rect(600, 400, 32, 32) + sprite2.layer = 0 + + self.group.add(sprite1, sprite2) + + self.map_layer.get_center_offset.return_value = (0, 0) + self.map_layer.view_rect = pygame.Rect(0, 0, 640, 480) + self.map_layer.draw.return_value = [sprite1.rect, sprite2.rect] + + drawn_rects = self.group.draw(self.surface) + + self.map_layer.draw.assert_called_once() + self.assertEqual(drawn_rects, [sprite1.rect, sprite2.rect]) + + def test_draw_with_offset(self): + sprite1 = MagicMock(spec=pygame.sprite.Sprite) + sprite1.image = pygame.Surface((32, 32)) + sprite1.rect = pygame.Rect(10, 10, 32, 32) + self.group.add(sprite1) + + self.map_layer.get_center_offset.return_value = (50, 50) + self.map_layer.view_rect = pygame.Rect(0, 0, 640, 480) + self.map_layer.draw.return_value = [sprite1.rect.move(50,50)] + + drawn_rects = self.group.draw(self.surface) + + self.map_layer.draw.assert_called_once() + self.assertEqual(drawn_rects, [sprite1.rect.move(50,50)]) + + def test_draw_with_blendmode(self): + sprite1 = MagicMock(spec=pygame.sprite.Sprite) + sprite1.image = pygame.Surface((32, 32)) + sprite1.rect = pygame.Rect(10, 10, 32, 32) + sprite1.blendmode = pygame.BLEND_ADD + self.group.add(sprite1) + + self.map_layer.get_center_offset.return_value = (0, 0) + self.map_layer.view_rect = pygame.Rect(0, 0, 640, 480) + self.map_layer.draw.return_value = [sprite1.rect] + + drawn_rects = self.group.draw(self.surface) + + self.map_layer.draw.assert_called_once() + self.assertEqual(drawn_rects, [sprite1.rect]) + + def test_draw_without_blendmode(self): + sprite1 = MagicMock(spec=pygame.sprite.Sprite) + sprite1.image = pygame.Surface((32, 32)) + sprite1.rect = pygame.Rect(10, 10, 32, 32) + self.group.add(sprite1) + + self.map_layer.get_center_offset.return_value = (0, 0) + self.map_layer.view_rect = pygame.Rect(0, 0, 640, 480) + self.map_layer.draw.return_value = [sprite1.rect] + + drawn_rects = self.group.draw(self.surface) + + self.map_layer.draw.assert_called_once() + self.assertEqual(drawn_rects, [sprite1.rect]) + + def test_lostsprites_reset(self): + sprite = MagicMock(spec=pygame.sprite.Sprite) + sprite.image = pygame.Surface((32, 32)) + sprite.rect = pygame.Rect(10, 10, 32, 32) + self.group.add(sprite) + + self.map_layer.get_center_offset.return_value = (0, 0) + self.map_layer.view_rect = pygame.Rect(0, 0, 640, 480) + self.map_layer.draw.return_value = [sprite.rect] + + self.group.lostsprites = [sprite] # manually set + self.group.draw(self.surface) + self.assertEqual(self.group.lostsprites, []) + + def test_sprite_layer_in_draw(self): + sprite = MagicMock(spec=pygame.sprite.Sprite) + sprite.image = pygame.Surface((32, 32)) + sprite.rect = pygame.Rect(10, 10, 32, 32) + self.group.add(sprite, layer=2) + + self.map_layer.get_center_offset.return_value = (0, 0) + self.map_layer.view_rect = pygame.Rect(0, 0, 640, 480) + self.map_layer.draw.return_value = [sprite.rect] + + self.group.draw(self.surface) + layer = self.group.get_layer_of_sprite(sprite) + self.assertEqual(layer, 2) + + def test_sprite_outside_view_is_skipped(self): + sprite = MagicMock(spec=pygame.sprite.Sprite) + sprite.image = pygame.Surface((32, 32)) + sprite.rect = pygame.Rect(1000, 1000, 32, 32) # way outside + self.group.add(sprite) + + self.map_layer.get_center_offset.return_value = (0, 0) + self.map_layer.view_rect = pygame.Rect(0, 0, 640, 480) + self.map_layer.draw.return_value = [] + + drawn = self.group.draw(self.surface) + self.assertEqual(drawn, []) From 0e555a2e5ad194329834ab40b0fb77564d35b8b9 Mon Sep 17 00:00:00 2001 From: JaskRendix Date: Fri, 25 Jul 2025 16:02:04 +0200 Subject: [PATCH 2/2] pyscroll group --- pyscroll/group.py | 46 ++++++--- tests/pyscroll/test_pyscroll_group.py | 143 +++++++++++++++++++------- 2 files changed, 137 insertions(+), 52 deletions(-) diff --git a/pyscroll/group.py b/pyscroll/group.py index cd189ce..d930569 100644 --- a/pyscroll/group.py +++ b/pyscroll/group.py @@ -1,14 +1,27 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, Any -import pygame +from pygame.rect import Rect +from pygame.sprite import LayeredUpdates +from pygame.surface import Surface + +from pyscroll.common import Vector2D if TYPE_CHECKING: - from .orthographic import BufferedRenderer + from pyscroll.orthographic import BufferedRenderer + +@dataclass +class SpriteMeta: + surface: Surface + rect: Rect + layer: int + blendmode: Any = None -class PyscrollGroup(pygame.sprite.LayeredUpdates): + +class PyscrollGroup(LayeredUpdates): """ Layered Group with ability to center sprites and scrolling map. @@ -16,11 +29,11 @@ class PyscrollGroup(pygame.sprite.LayeredUpdates): map_layer: Pyscroll Renderer """ - def __init__(self, map_layer: BufferedRenderer, *args, **kwargs) -> None: - pygame.sprite.LayeredUpdates.__init__(self, *args, **kwargs) + def __init__(self, map_layer: BufferedRenderer, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) self._map_layer = map_layer - def center(self, value) -> None: + def center(self, value: Vector2D) -> None: """ Center the group/map on a pixel. @@ -33,13 +46,13 @@ def center(self, value) -> None: self._map_layer.center(value) @property - def view(self) -> pygame.Rect: + def view(self) -> Rect: """ Return a Rect representing visible portion of map. """ return self._map_layer.view_rect.copy() - def draw(self, surface: pygame.surface.Surface) -> list[pygame.rect.Rect]: + def draw(self, surface: Surface) -> list[Rect]: """ Draw map and all sprites onto the surface. @@ -50,19 +63,22 @@ def draw(self, surface: pygame.surface.Surface) -> list[pygame.rect.Rect]: draw_area = surface.get_rect() view_rect = self.view - new_surfaces: list[tuple[pygame.Surface, pygame.Rect, int, Any]] = [] + new_surfaces: list[SpriteMeta] = [] spritedict = self.spritedict gl = self.get_layer_of_sprite for spr in self.sprites(): new_rect = spr.rect.move(ox, oy) if spr.rect.colliderect(view_rect): - try: - new_surfaces.append((spr.image, new_rect, gl(spr), spr.blendmode)) - except AttributeError: - # should only fail when no blendmode available - new_surfaces.append((spr.image, new_rect, gl(spr))) + blendmode = getattr(spr, "blendmode", None) + new_surfaces.append(SpriteMeta(spr.image, new_rect, gl(spr), blendmode)) spritedict[spr] = new_rect self.lostsprites = [] - return self._map_layer.draw(surface, draw_area, new_surfaces) + + # Convert dataclass back to tuple before drawing + renderables: list[tuple[Surface, Rect, int, Any]] = [ + (meta.surface, meta.rect, meta.layer, meta.blendmode) + for meta in new_surfaces + ] + return self._map_layer.draw(surface, draw_area, renderables) diff --git a/tests/pyscroll/test_pyscroll_group.py b/tests/pyscroll/test_pyscroll_group.py index 7155689..d6d6cc6 100644 --- a/tests/pyscroll/test_pyscroll_group.py +++ b/tests/pyscroll/test_pyscroll_group.py @@ -2,11 +2,44 @@ from unittest.mock import MagicMock import pygame +from pygame.rect import Rect +from pygame.sprite import Sprite +from pygame.surface import Surface -from pyscroll.group import PyscrollGroup +from pyscroll.group import PyscrollGroup, SpriteMeta from pyscroll.orthographic import BufferedRenderer +class TestSpriteMeta(unittest.TestCase): + + @classmethod + def setUpClass(cls): + pygame.init() + + @classmethod + def tearDownClass(cls): + pygame.quit() + + def test_initialization_with_blendmode(self): + surface = Surface((32, 32)) + rect = Rect(10, 10, 32, 32) + meta = SpriteMeta( + surface=surface, rect=rect, layer=1, blendmode=pygame.BLEND_ADD + ) + + self.assertEqual(meta.surface.get_size(), (32, 32)) + self.assertEqual(meta.rect, rect) + self.assertEqual(meta.layer, 1) + self.assertEqual(meta.blendmode, pygame.BLEND_ADD) + + def test_default_blendmode_is_none(self): + surface = Surface((32, 32)) + rect = Rect(0, 0, 32, 32) + meta = SpriteMeta(surface=surface, rect=rect, layer=0) + + self.assertIsNone(meta.blendmode) + + class TestPyscrollGroup(unittest.TestCase): @classmethod @@ -18,7 +51,7 @@ def tearDownClass(cls): pygame.quit() def setUp(self): - self.surface = pygame.Surface((640, 480)) + self.surface = Surface((640, 480)) self.map_layer = MagicMock(spec=BufferedRenderer) self.group = PyscrollGroup(self.map_layer) @@ -31,26 +64,26 @@ def test_center(self): self.map_layer.center.assert_called_once_with((100, 100)) def test_view(self): - self.map_layer.view_rect = pygame.Rect(0, 0, 640, 480) + self.map_layer.view_rect = Rect(0, 0, 640, 480) view = self.group.view - self.assertEqual(view, pygame.Rect(0, 0, 640, 480)) + self.assertEqual(view, Rect(0, 0, 640, 480)) self.assertIsNot(view, self.map_layer.view_rect) def test_draw(self): - sprite1 = MagicMock(spec=pygame.sprite.Sprite) - sprite1.image = pygame.Surface((32, 32)) - sprite1.rect = pygame.Rect(10, 10, 32, 32) + sprite1 = MagicMock(spec=Sprite) + sprite1.image = Surface((32, 32)) + sprite1.rect = Rect(10, 10, 32, 32) sprite1.layer = 0 - sprite2 = MagicMock(spec=pygame.sprite.Sprite) - sprite2.image = pygame.Surface((32, 32)) - sprite2.rect = pygame.Rect(600, 400, 32, 32) + sprite2 = MagicMock(spec=Sprite) + sprite2.image = Surface((32, 32)) + sprite2.rect = Rect(600, 400, 32, 32) sprite2.layer = 0 self.group.add(sprite1, sprite2) self.map_layer.get_center_offset.return_value = (0, 0) - self.map_layer.view_rect = pygame.Rect(0, 0, 640, 480) + self.map_layer.view_rect = Rect(0, 0, 640, 480) self.map_layer.draw.return_value = [sprite1.rect, sprite2.rect] drawn_rects = self.group.draw(self.surface) @@ -59,29 +92,29 @@ def test_draw(self): self.assertEqual(drawn_rects, [sprite1.rect, sprite2.rect]) def test_draw_with_offset(self): - sprite1 = MagicMock(spec=pygame.sprite.Sprite) - sprite1.image = pygame.Surface((32, 32)) - sprite1.rect = pygame.Rect(10, 10, 32, 32) + sprite1 = MagicMock(spec=Sprite) + sprite1.image = Surface((32, 32)) + sprite1.rect = Rect(10, 10, 32, 32) self.group.add(sprite1) self.map_layer.get_center_offset.return_value = (50, 50) - self.map_layer.view_rect = pygame.Rect(0, 0, 640, 480) - self.map_layer.draw.return_value = [sprite1.rect.move(50,50)] + self.map_layer.view_rect = Rect(0, 0, 640, 480) + self.map_layer.draw.return_value = [sprite1.rect.move(50, 50)] drawn_rects = self.group.draw(self.surface) self.map_layer.draw.assert_called_once() - self.assertEqual(drawn_rects, [sprite1.rect.move(50,50)]) + self.assertEqual(drawn_rects, [sprite1.rect.move(50, 50)]) def test_draw_with_blendmode(self): - sprite1 = MagicMock(spec=pygame.sprite.Sprite) - sprite1.image = pygame.Surface((32, 32)) - sprite1.rect = pygame.Rect(10, 10, 32, 32) + sprite1 = MagicMock(spec=Sprite) + sprite1.image = Surface((32, 32)) + sprite1.rect = Rect(10, 10, 32, 32) sprite1.blendmode = pygame.BLEND_ADD self.group.add(sprite1) self.map_layer.get_center_offset.return_value = (0, 0) - self.map_layer.view_rect = pygame.Rect(0, 0, 640, 480) + self.map_layer.view_rect = Rect(0, 0, 640, 480) self.map_layer.draw.return_value = [sprite1.rect] drawn_rects = self.group.draw(self.surface) @@ -90,13 +123,13 @@ def test_draw_with_blendmode(self): self.assertEqual(drawn_rects, [sprite1.rect]) def test_draw_without_blendmode(self): - sprite1 = MagicMock(spec=pygame.sprite.Sprite) - sprite1.image = pygame.Surface((32, 32)) - sprite1.rect = pygame.Rect(10, 10, 32, 32) + sprite1 = MagicMock(spec=Sprite) + sprite1.image = Surface((32, 32)) + sprite1.rect = Rect(10, 10, 32, 32) self.group.add(sprite1) self.map_layer.get_center_offset.return_value = (0, 0) - self.map_layer.view_rect = pygame.Rect(0, 0, 640, 480) + self.map_layer.view_rect = Rect(0, 0, 640, 480) self.map_layer.draw.return_value = [sprite1.rect] drawn_rects = self.group.draw(self.surface) @@ -105,13 +138,13 @@ def test_draw_without_blendmode(self): self.assertEqual(drawn_rects, [sprite1.rect]) def test_lostsprites_reset(self): - sprite = MagicMock(spec=pygame.sprite.Sprite) - sprite.image = pygame.Surface((32, 32)) - sprite.rect = pygame.Rect(10, 10, 32, 32) + sprite = MagicMock(spec=Sprite) + sprite.image = Surface((32, 32)) + sprite.rect = Rect(10, 10, 32, 32) self.group.add(sprite) self.map_layer.get_center_offset.return_value = (0, 0) - self.map_layer.view_rect = pygame.Rect(0, 0, 640, 480) + self.map_layer.view_rect = Rect(0, 0, 640, 480) self.map_layer.draw.return_value = [sprite.rect] self.group.lostsprites = [sprite] # manually set @@ -119,13 +152,13 @@ def test_lostsprites_reset(self): self.assertEqual(self.group.lostsprites, []) def test_sprite_layer_in_draw(self): - sprite = MagicMock(spec=pygame.sprite.Sprite) - sprite.image = pygame.Surface((32, 32)) - sprite.rect = pygame.Rect(10, 10, 32, 32) + sprite = MagicMock(spec=Sprite) + sprite.image = Surface((32, 32)) + sprite.rect = Rect(10, 10, 32, 32) self.group.add(sprite, layer=2) self.map_layer.get_center_offset.return_value = (0, 0) - self.map_layer.view_rect = pygame.Rect(0, 0, 640, 480) + self.map_layer.view_rect = Rect(0, 0, 640, 480) self.map_layer.draw.return_value = [sprite.rect] self.group.draw(self.surface) @@ -133,14 +166,50 @@ def test_sprite_layer_in_draw(self): self.assertEqual(layer, 2) def test_sprite_outside_view_is_skipped(self): - sprite = MagicMock(spec=pygame.sprite.Sprite) - sprite.image = pygame.Surface((32, 32)) - sprite.rect = pygame.Rect(1000, 1000, 32, 32) # way outside + sprite = MagicMock(spec=Sprite) + sprite.image = Surface((32, 32)) + sprite.rect = Rect(1000, 1000, 32, 32) # way outside self.group.add(sprite) self.map_layer.get_center_offset.return_value = (0, 0) - self.map_layer.view_rect = pygame.Rect(0, 0, 640, 480) + self.map_layer.view_rect = Rect(0, 0, 640, 480) self.map_layer.draw.return_value = [] drawn = self.group.draw(self.surface) self.assertEqual(drawn, []) + + def test_sprite_partially_visible(self): + sprite = MagicMock(spec=Sprite) + sprite.image = Surface((32, 32)) + sprite.rect = Rect(630, 470, 32, 32) # just inside bottom-right + self.group.add(sprite) + + self.map_layer.get_center_offset.return_value = (0, 0) + self.map_layer.view_rect = Rect(0, 0, 640, 480) + self.map_layer.draw.return_value = [sprite.rect] + + drawn = self.group.draw(self.surface) + self.assertEqual(drawn, [sprite.rect]) + + def test_draw_no_sprites(self): + self.map_layer.get_center_offset.return_value = (0, 0) + self.map_layer.view_rect = Rect(0, 0, 640, 480) + self.map_layer.draw.return_value = [] + + drawn = self.group.draw(self.surface) + self.map_layer.draw.assert_called_once() + self.assertEqual(drawn, []) + + def test_draw_sprite_without_blendmode_attribute(self): + sprite = MagicMock(spec=Sprite) + sprite.image = Surface((32, 32)) + sprite.rect = Rect(10, 10, 32, 32) + del sprite.blendmode # simulate absence + self.group.add(sprite) + + self.map_layer.get_center_offset.return_value = (0, 0) + self.map_layer.view_rect = Rect(0, 0, 640, 480) + self.map_layer.draw.return_value = [sprite.rect] + + drawn = self.group.draw(self.surface) + self.assertEqual(drawn, [sprite.rect])