diff --git a/arcade/__init__.py b/arcade/__init__.py index 1d21e29cb..5f6100f8c 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -98,6 +98,8 @@ def configure_logging(level: Optional[int] = None): from .application import open_window from .texture import Texture +from .texture import TextureManager +from .texture import SpriteSheet from .texture import load_spritesheet from .texture import load_texture from .texture import load_texture_pair @@ -273,6 +275,8 @@ def configure_logging(level: Optional[int] = None): 'SpriteSolidColor', 'Text', 'Texture', + 'TextureManager', + 'SpriteSheet', 'TextureAtlas', 'load_atlas', 'save_atlas', diff --git a/arcade/resources/__init__.py b/arcade/resources/__init__.py index addb55248..064c46ffb 100644 --- a/arcade/resources/__init__.py +++ b/arcade/resources/__init__.py @@ -106,7 +106,6 @@ def resolve(path: Union[str, Path]) -> Path: except FileNotFoundError: raise FileNotFoundError(f"Cannot locate resource : {path}") - def add_resource_handle(handle: str, path: Union[str, Path]) -> None: """ Add a resource handle or path to an existing handle. diff --git a/arcade/texture/__init__.py b/arcade/texture/__init__.py index 1dd6eebb7..dad5e7003 100644 --- a/arcade/texture/__init__.py +++ b/arcade/texture/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations from .texture import Texture, ImageData +from .spritesheet import SpriteSheet from .loading import ( load_texture, load_textures, @@ -17,6 +18,7 @@ get_default_texture, get_default_image, ) +from .manager import TextureManager __all__ = [ "Texture", @@ -31,4 +33,6 @@ "cleanup_texture_cache", "get_default_texture", "get_default_image", - ] + "TextureManager", + "SpriteSheet", +] diff --git a/arcade/texture/loading.py b/arcade/texture/loading.py index 0aef48ff1..d8e8b51dd 100644 --- a/arcade/texture/loading.py +++ b/arcade/texture/loading.py @@ -137,7 +137,6 @@ def _load_or_get_image( Load an image, or return a cached version :param file_path: Path to image - :param hit_box_algorithm: The hit box algorithm :param hash: Hash of the image :return: Tuple of image data and a boolean indicating if the image was fetched from cache diff --git a/arcade/texture/manager.py b/arcade/texture/manager.py new file mode 100644 index 000000000..f7e4f78b2 --- /dev/null +++ b/arcade/texture/manager.py @@ -0,0 +1,84 @@ +from pathlib import Path +from typing import Union, Optional + +import arcade +from arcade.hitbox import HitBoxAlgorithm +from .texture import Texture +from arcade.cache import ( + TextureCache, + ImageDataCache, + HitBoxCache, +) +from . import SpriteSheet + +class TextureManager: + """ + This class is used to manage textures. It is used to keep track of + textures that have been loaded, and to make sure we don't load the + same texture twice. + + Textures loaded through this manager is cached internally. + """ + def __init__(self): + self._sprite_sheets = {} + self._hit_box_cache = HitBoxCache() + self._image_data_cache = ImageDataCache() + self._texture_cache = TextureCache() + + def texture( + self, + path: Union[str, Path], + hit_box_algorithm: Optional[HitBoxAlgorithm] = None, + cache: bool = True, + ) -> Texture: + """ + Loads a texture or returns a cached version. + + :param path: Path to the file to load. + :param hit_box_algorithm: Algorithm to use to create a hit box for the texture. + """ + # TODO: DON'T CALL arcade.load_texture? + texture = arcade.load_texture(path, hit_box_algorithm=hit_box_algorithm) + # Do caching here + return texture + + def spritesheet(self, path: Union[str, Path], cache: bool = True) -> SpriteSheet: + """ + Loads a spritesheet or returns a cached version. + + :param path: Path to the file to load. + :param cache: If ``True``, the spritesheet will be cached. If ``False``, the + spite sheet will not be cached or returned from the cache. + """ + path = arcade.resources.resolve_resource_path(path) + if path in self._sprite_sheets and cache: + return self._sprite_sheets[path] + + sprite_sheet = SpriteSheet(path) + if cache: + self._sprite_sheets[path] = sprite_sheet + return sprite_sheet + + def flush( + self, + sprite_sheets: bool = True, + textures: bool = True, + image_data: bool = True, + hit_boxes: bool = False, + ): + """ + Remove contents from the texture manager. + + :param sprite_sheets: If ``True``, sprite sheets will be flushed. + :param textures: If ``True``, textures will be flushed. + :param image_data: If ``True``, image data will be flushed. + :param hit_boxes: If ``True``, hit boxes will be flushed. + """ + if sprite_sheets: + self._sprite_sheets.clear() + if textures: + self._texture_cache.clear() + if image_data: + self._image_data_cache.clear() + if hit_boxes: + self._hit_box_cache.clear() diff --git a/arcade/texture/spritesheet.py b/arcade/texture/spritesheet.py new file mode 100644 index 000000000..f334f85e8 --- /dev/null +++ b/arcade/texture/spritesheet.py @@ -0,0 +1,148 @@ +from PIL import Image +from pathlib import Path +from typing import Union, Tuple, Optional, List, TYPE_CHECKING + +# from arcade import Texture +from arcade.types import Rect +from .texture import Texture + +if TYPE_CHECKING: + from arcade.hitbox import HitBoxAlgorithm + + +class SpriteSheet: + """ + Class to hold a sprite sheet. A sprite sheet is a single image that contains + multiple textures. Textures can be created from the sprite sheet by cropping + out sections of the image. + + This is only a utility class. It does not have any special functionality + + :param path: Path to the file to load. + :param image: PIL image to use. + """ + def __init__( + self, + path: Optional[Union[str, Path]] = None, + image: Optional[Image.Image] = None, + ): + from arcade.resources import resolve + self._path = None + if path: + self._path = resolve(path) + self._image = Image.open(self._path).convert("RGBA") + elif image: + self._image = image + else: + raise ValueError("Must provide either path or image") + + self._flip_flags = (False, False) + + @classmethod + def from_image(cls, image: Image.Image): + """ + Create a sprite sheet from a PIL image. + + :param image: PIL image to use. + """ + return cls(image=image) + + @property + def image(self) -> Image.Image: + """ + Get or set the PIL image for this sprite sheet. + """ + return self._image + + @image.setter + def image(self, image: Image.Image): + self._image = image + + @property + def path(self) -> Optional[Path]: + """ + The path to the sprite sheet. + + :return: The path. + """ + return self._path + + @property + def flip_flags(self) -> Tuple[bool, bool]: + """ + Query the orientation of the sprite sheet. + This can be used to determine if the sprite sheet needs to be flipped. + + Default values are ``(False, False)``. Will be modified when + :py:meth:`flip_left_right` or :py:meth:`flip_top_bottom` is called. + + :return: Tuple of booleans ``(flip_left_right, flip_top_bottom)``. + """ + return self._flip_flags + + def flip_left_right(self) -> None: + """ + Flip the sprite sheet left/right. + """ + self._image = self._image.transpose(Image.FLIP_LEFT_RIGHT) + self._flip_flags = (not self._flip_flags[0], self._flip_flags[1]) + + def flip_top_bottom(self): + """ + Flip the sprite sheet up/down. + """ + self._image = self._image.transpose(Image.FLIP_TOP_BOTTOM) + self._flip_flags = (self._flip_flags[0], not self._flip_flags[1]) + + def crop(self, area: Rect): + """ + Crop a texture from the sprite sheet. + + :param area: Area to crop ``(x, y, width, height)`` + """ + pass + + def crop_sections(self, sections: List[Rect]): + """ + Crop multiple textures from the sprite sheet by specifying a list of + areas to crop. + + :param sections: List of areas to crop ``[(x, y, width, height), ...]`` + """ + pass + + def crop_grid( + self, + size: Tuple[int, int], + columns: int, + count: int, + margin: Rect = (0, 0, 0, 0), + hit_box_algorithm: Optional["HitBoxAlgorithm"] = None, + ) -> List[Texture]: + """ + Crop a grid of textures from the sprite sheet. + + :param size: Size of each texture ``(width, height)`` + :param columns: Number of columns in the grid + :param count: Number of textures to crop + :param margin: The margin around each texture ``(left, right, bottom, top)`` + :param hit_box_algorithm: Hit box algorithm to use for the textures. + """ + textures = [] + width, height = size + left, right, bottom, top = margin + + for sprite_no in range(count): + row = sprite_no // columns + column = sprite_no % columns + + x = (width + left + right) * column + y = (height + top + bottom) * row + im = self.image.crop((x, y, x + width, y + height)) + + texture = Texture(im, hit_box_algorithm=hit_box_algorithm) + texture.file_path = self._path + texture.crop_values = x, y, width, height + textures.append(texture) + + return textures diff --git a/arcade/texture/texture.py b/arcade/texture/texture.py index 43a097476..a157715c7 100644 --- a/arcade/texture/texture.py +++ b/arcade/texture/texture.py @@ -2,8 +2,8 @@ import hashlib import logging +from typing import Any, Dict, Optional, Tuple, Type, Union, TYPE_CHECKING from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Type, Union from weakref import WeakSet import PIL.Image @@ -25,6 +25,12 @@ TransposeTransform, TransverseTransform, ) + +from arcade.types import PointList +from arcade.color import TRANSPARENT_BLACK +from arcade.hitbox import HitBoxAlgorithm +from arcade import cache as _cache +from arcade import hitbox from arcade.types import RGBA255, PointList if TYPE_CHECKING: diff --git a/tests/unit/camera/test_camera.py b/tests/unit/camera/test_camera.py new file mode 100644 index 000000000..795c2ecde --- /dev/null +++ b/tests/unit/camera/test_camera.py @@ -0,0 +1,11 @@ +import arcade + + +def test_camera(window): + c1 = arcade.SimpleCamera() + assert c1.viewport == (0, 0, *window.size) + assert c1.projection == (0, window.width, 0, window.height) + + c2 = arcade.Camera() + assert c2.viewport == (0, 0, *window.size) + assert c2.projection == (0, window.width, 0, window.height) diff --git a/tests/unit/texture/test_sprite_sheet.py b/tests/unit/texture/test_sprite_sheet.py new file mode 100644 index 000000000..d75c0bed9 --- /dev/null +++ b/tests/unit/texture/test_sprite_sheet.py @@ -0,0 +1,64 @@ +import pytest +from PIL import Image +import arcade + +SPRITE_SHEET_RESOURCE = ":resources:images/spritesheets/codepage_437.png" +SPRITE_SHEET_PATH = arcade.resources.resolve(SPRITE_SHEET_RESOURCE) + + +@pytest.fixture +def image(): + return Image.new("RGBA", (10, 10)) + + +def test_create_from_path(): + ss = arcade.SpriteSheet(SPRITE_SHEET_RESOURCE) + assert ss.flip_flags == (False, False) + assert ss.image is not None + assert ss.path == SPRITE_SHEET_PATH + + +def test_create_from_image(image): + ss = arcade.SpriteSheet(image=image) + assert ss.flip_flags == (False, False) + assert ss.image == image + assert ss.path is None + + +def test_flip(): + ss = arcade.SpriteSheet(SPRITE_SHEET_RESOURCE) + im = Image.open(SPRITE_SHEET_PATH).convert("RGBA") + assert ss.image == im + assert ss.flip_flags == (False, False) + + ss.flip_left_right() + assert ss.flip_flags == (True, False) + assert ss.image != im + im = im.transpose(Image.FLIP_LEFT_RIGHT) + assert ss.image == im + + ss.flip_top_bottom() + assert ss.flip_flags == (True, True) + assert ss.image != im + im = im.transpose(Image.FLIP_TOP_BOTTOM) + assert ss.image == im + + # Flip back to original + ss.flip_left_right() + ss.flip_top_bottom() + assert ss.flip_flags == (False, False) + + +def test_crop(): + pass + + +def test_crop_grid(): + ss = arcade.SpriteSheet(SPRITE_SHEET_RESOURCE) + textures = ss.crop_grid( + size=(8, 16), + margin=(0, 1, 0, 0), + columns=32, + count=255, + ) + assert len(textures) == 255