Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 32 additions & 21 deletions pyscroll/group.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,39 @@
from __future__ import annotations

from typing import TYPE_CHECKING
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.

Args:
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.

Expand All @@ -30,44 +42,43 @@ def center(self, value) -> None:

Args:
value: x, y coordinates to center the camera on

"""
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.

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[SpriteMeta] = []
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))
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)
215 changes: 215 additions & 0 deletions tests/pyscroll/test_pyscroll_group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import unittest
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, 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
def setUpClass(cls):
pygame.init()

@classmethod
def tearDownClass(cls):
pygame.quit()

def setUp(self):
self.surface = 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 = Rect(0, 0, 640, 480)
view = self.group.view
self.assertEqual(view, Rect(0, 0, 640, 480))
self.assertIsNot(view, self.map_layer.view_rect)

def test_draw(self):
sprite1 = MagicMock(spec=Sprite)
sprite1.image = Surface((32, 32))
sprite1.rect = Rect(10, 10, 32, 32)
sprite1.layer = 0

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 = 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=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 = 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=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 = 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=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 = 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=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 = 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=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 = 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=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 = 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])