import abc
from abc import abstractmethod
from typing import Union, Tuple, List
import miniworlds.appearances.managers.font_manager as font_manager
import miniworlds.appearances.managers.image_manager as image_manager
import miniworlds.appearances.managers.transformations_manager as transformations_manager
import miniworlds.worlds.world as world
import miniworlds.tools.binding as binding
import miniworlds.tools.color as color_mod
import numpy
import pygame
from miniworlds.base.exceptions import MiniworldsError
class MetaAppearance(abc.ABCMeta):
def __call__(cls, *args, **kwargs):
instance = type.__call__(
cls, *args, **kwargs
) # create a new Appearance of type...
instance.after_init()
return instance
[docs]
class Appearance(metaclass=MetaAppearance):
"""Base class of actor costumes and world backgrounds
The class contains all methods and attributes to display and animate images of the objects, render text
on the images or display overlays.
"""
counter = 0
RELOAD_ACTUAL_IMAGE = 1
LOAD_NEW_IMAGE = 2
def __init__(self):
self.id = Appearance.counter + 1
Appearance.counter += 1
self.initialized = False
self._flag_transformation_pipeline = False
self.parent = None
self.draw_shapes = []
self.draw_images = []
self._is_flipped = False
self._is_animated = False
self._is_textured = False
self._is_centered = True
self._is_upscaled = False
self._is_scaled = False
self._is_scaled_to_width = False
self._is_scaled_to_height = False
self._is_rotatable = False
self._orientation = False
self._coloring = None # Color for colorize operation
self._transparency = False
self._border = 0
self._is_filled = False
self._fill_color = (255, 0, 255, 255)
self._border_color = None
self._alpha = 255
self._dirty = 0
self._image = pygame.Surface((0, 0)) # size set in image()-method
self.surface_loaded = False
self.last_image = None
self.font_manager = font_manager.FontManager(self)
self.image_manager: "image_manager.ImageManager" = image_manager.ImageManager(
self
)
self.transformations_manager = transformations_manager.TransformationsManager(
self
)
self.image_manager.add_default_image()
# properties
self._texture_size = (0, 0)
self._animation_speed = 10 #: The animation speed for animations
self.loop = False
self.animation_length = 0
self._animation_start_frame = 0
def _set_defaults(
self,
rotatable,
is_animated,
animation_speed,
is_upscaled,
is_scaled_to_width,
is_scaled_to_height,
is_scaled,
is_flipped,
border,
) -> "Appearance":
if rotatable:
self._is_rotatable = rotatable
if is_animated:
self._is_animated = is_animated
if animation_speed:
self._animation_speed = animation_speed
if is_upscaled:
self._is_upscaled = is_upscaled
if is_scaled_to_width:
self._is_scaled_to_width = is_scaled_to_width
if is_scaled_to_height:
self._is_scaled_to_height = is_scaled_to_height
if is_scaled:
self._is_scaled = is_scaled
if is_flipped:
self._is_flipped = is_flipped
if border is not None:
self._border = border
self.set_dirty("all", self.LOAD_NEW_IMAGE)
return self
[docs]
def set_image(self, source: Union[int, "Appearance", tuple]) -> bool:
"""Sets the displayed image of costume/background to selected index
Args:
source: The image index or an image.
Returns:
True, if image index exists
Examples:
Add two images two background and switch to image 2
.. code-block:: python
from miniworldmaker import *
board = Board()
background = board.add_background("images/1.png")
background.add_image("images/2.png")
background.set_image(1)
board.run()
"""
if isinstance(source, int):
return self.image_manager.set_image_index(source)
elif isinstance(source, tuple):
surface = image_manager.ImageManager.get_surface_from_color(source)
self.image_manager.replace_image(
surface, image_manager.ImageManager.COLOR, source
)
[docs]
def after_init(self):
# Called in metaclass
self.set_dirty("all", Appearance.LOAD_NEW_IMAGE)
self.initialized = True
@property
def font_size(self):
return self.font_manager.font_size
@font_size.setter
def font_size(self, value):
self.font_manager.set_font_size(value, update=True)
def _set_font(self, font, font_size):
self.font_manager.font_path = font
self.font_manager.font_size = font_size
@property
def texture_size(self):
self._texture_size
@texture_size.setter
def texture_size(self, value):
self._texture_size = value
self.set_dirty("texture", Appearance.RELOAD_ACTUAL_IMAGE)
@property
def animation_speed(self):
return self._animation_speed
@animation_speed.setter
def animation_speed(self, value):
self._animation_speed = value
def _set_animation_speed(self, value):
self.animation_speed = value
[docs]
def set_mode(self, **kwargs):
if "texture_size" in kwargs:
self._texture_size = kwargs["texture_size"]
if "animation_speed" in kwargs:
self.animation_speed = kwargs["animation_speed"]
if "mode" in kwargs:
mode = kwargs["mode"]
if isinstance(mode, str):
mode = [mode]
if "textured" in mode:
self._set_textured(True)
elif "scaled" in mode:
self._set_scaled(True)
elif "scaled_to_width" in mode:
self._set_scaled_to_width(True)
elif "scaled_to_height" in mode:
self._set_scaled_to_height(True)
elif "upscaled" in mode:
self._set_upscaled(True)
elif "filled" in mode:
self.set_filled(True)
elif "flipped" in mode:
self._set_flipped(True)
elif "animated" in mode:
self.is_animated(True)
elif "rotatable" in mode:
self._is_rotatable(True)
elif "centered" in mode:
self._set_centered(True)
[docs]
def get_modes(self):
modes = {
"textured": self._is_textured,
"scaled": self._is_scaled,
"scaled_to_width": self._is_scaled_to_width,
"scaled_to_height": self._is_scaled_to_height,
"upscaled": self._is_upscaled,
"filled": self._is_filled,
"flipped": self._is_flipped,
"animated": self._is_animated,
"rotatable": self._is_rotatable,
"centered": self._is_centered,
}
return modes
@property
def is_textured(self):
"""
bool: If True, the image is tiled over the background.
Examples:
Texture the board with the given image:
.. code-block:: python
from miniworldmaker import *
board = Board()
background = board.add_background("images/stone.png")
background.is_textured = True
board.run()
.. image:: ../_images/is_textured.png
:alt: Textured image>
Set texture size
.. code-block:: python
from miniworldmaker import *
board = Board()
background = board.add_background("images/stone.png")
background.is_textured = True
background.texture_size = (15,15)
board.run()
.. image:: ../_images/is_textured1.png
:alt: Textured image
"""
return self._is_textured
@is_textured.setter
def is_textured(self, value):
self._set_textured(value)
def _set_textured(self, value: bool):
"""bool: If True, the image is tiled over the background.
Args:
value: True, if image should be displayed as textured.
"""
self._is_textured = value
self.set_dirty("texture", Appearance.RELOAD_ACTUAL_IMAGE)
@property
def is_rotatable(self):
"""If True, costume will be rotated with token direction
"""
return self._is_rotatable
@is_rotatable.setter
def is_rotatable(self, value):
self._set_rotatable(value)
@property
def is_centered(self):
return self._is_centered
@is_centered.setter
def is_centered(self, value):
self._is_centered = value
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
@property
def is_filled(self):
return self._is_filled
@is_filled.setter
def is_filled(self, value):
self._set_filled(value)
@property
def is_flipped(self):
"""Flips the costume or background. The image is mirrored over the y-axis of costume/background.
Examples:
Flips actor:
.. code-block:: python
from miniworldmaker import *
board = Board()
token = Token()
token.add_costume("images/alien1.png")
token.height= 400
token.width = 100
token.is_rotatable = False
@token.register
def act(self):
if self.board.frame % 100 == 0:
if self.costume.is_flipped:
self.costume.is_flipped = False
else:
self.costume.is_flipped = True
board.run()
.. image:: ../_images/flip1.png
:alt: Textured image
.. image:: ../_images/flip2.png
:alt: Textured image
"""
return self._is_flipped
@is_flipped.setter
def is_flipped(self, value):
self._set_flipped(value)
@property
def is_upscaled(self):
"""If True, the image will be upscaled remaining aspect-ratio.
"""
return self._is_upscaled
@is_upscaled.setter
def is_upscaled(self, value):
self._set_upscaled(value)
@property
def is_scaled_to_width(self):
return self._is_scaled_to_width
@is_scaled_to_width.setter
def is_scaled_to_width(self, value):
self._set_scaled_to_width(value)
@property
def is_scaled_to_height(self):
return self._is_scaled_to_height
@is_scaled_to_height.setter
def is_scaled_to_height(self, value):
self._set_scaled_to_height(value)
@property
def fill_color(self):
return self._fill_color
@fill_color.setter
def fill_color(self, value):
self._fill_color = value
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
@property
def is_scaled(self):
"""Scales the token to parent-size without remaining aspect-ratio.
"""
return self._is_scaled
@is_scaled.setter
def is_scaled(self, value):
self._set_scaled(value)
@property
def orientation(self):
"""bool: If True, the image will be rotated by parent orientation before it is rotated.
Examples:
Both actors are moving up. The image of t2 is correctly aligned. t1 is looking in the wrong direction.
.. code-block:: python
from miniworlds import *
world = TiledWorld()
t1 = Actor((4,4))
t1.add_costume("images/player.png")
t1.move()
t2 = Actor((4,5))
t2.add_costume("images/player.png")
t2.orientation = - 90
t2.move()
@t1.register
def act(self):
self.move()
@t2.register
def act(self):
self.move()
world.run()
.. image:: ../_images/orientation.png
:alt: Textured image
"""
return self._orientation
@orientation.setter
def orientation(self, value):
self._orientation = value
self.set_dirty("orientation", Appearance.RELOAD_ACTUAL_IMAGE)
@property
def fill_color(self):
return self._fill_color
@fill_color.setter
def fill_color(self, value):
self._fill_color = value
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
@property
def coloring(self):
"""Defines a colored layer.
`coloring` can be True or false.
The color is defined by the attribute `appearance.color`.
"""
return self._coloring
@coloring.setter
def coloring(self, value):
self._coloring = value
self.set_dirty("coloring", Appearance.RELOAD_ACTUAL_IMAGE)
@property
def transparency(self):
"""Defines a transparency.
If ``transparency``is ``True``, the che transparency value
is defined by the attribute ``appearance.alpha``
"""
return self._transparency
@transparency.setter
def transparency(self, value):
self._transparency = value
self.set_dirty("transparency", Appearance.RELOAD_ACTUAL_IMAGE)
@property
def alpha(self):
"""defines transparency of Actor: 0: transparent, 255: visible
If value < 1, it will be multiplied with 255.
Examples:
.. code-block:: python
from miniworlds import *
world = World(800,400)
t = Actor((600,250))
t.add_costume("images/alien1.png")
t.costume.alpha = 50
t.width = 40
t.border = 1
world.run()
.. image:: ../_images/alpha.png
:alt: Textured image
"""
return self._alpha
@alpha.setter
def alpha(self, value):
self._alpha = value
if 0 < value < 1:
value = value * 255
if value == 255:
self.transparency = False
else:
self.transparency = True
@property
def is_animated(self):
"""If True, the costume will be animated.
.. code-block:: python
from miniworlds import *
world = World(80,40)
robo = Actor()
robo.costume.add_images(["images/1.png"])
robo.costume.add_images(["images/2.png","images/3.png","images/4.png"])
robo.costume.animation_speed = 20
robo.costume.is_animated = True
world.run()
.. video:: ../_static/animate.webm
:autoplay:
:width: 300
:height: 100
"""
return self._is_animated
@is_animated.setter
def is_animated(self, value: bool):
self.set_animated(value)
[docs]
def set_animated(self, value: bool):
self._is_animated = value
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
@property
def color(self):
"""->See fill color"""
return self._fill_color
@color.setter
def color(self, value):
value = color_mod.Color.create(value).get()
self._fill_color = value
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
@property
def stroke_color(self):
"""see border color"""
return self._border_color
@stroke_color.setter
def stroke_color(self, value):
self.border_color = value
@property
def border_color(self):
"""border color of actor"""
return self._border_color
@border_color.setter
def border_color(self, value: int):
if value:
self._border_color = value
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
else:
self.border = None
@property
def border(self):
"""The border-size of actor.
The value is 0, if actor has no border
Returns:
_type_: int
"""
return self._border
@border.setter
def border(self, value: Union[int, None]):
if not value:
value = 0
if not isinstance(value, int):
raise TypeError("border value should be of type int")
self._border = value
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
[docs]
def flip(self, value):
self.is_flipped = value
@property
def images(self):
return self.image_manager.images_list
@property
def image(self) -> pygame.Surface:
"""Performs all actions in image pipeline"""
return self.get_image()
def _set_rotatable(self, value: bool):
"""
If set to True, costume will be rotated with actor direction
Args:
value: True, if image should be rotated with Actor direction
Returns:
"""
self._is_rotatable = value
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
def _set_centered(self, value):
self._is_centered = value
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
def _set_flipped(self, value: bool):
"""
Flips the costume or background. The image is mirrored over the y-axis of costume/background.
Args:
value: True, if Appearance should be displayed as flipped.
Returns:
"""
self._is_flipped = value
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
def _set_filled(self, value: bool):
"""
Flips the costume or background. The image is mirrored over the y-axis of costume/background.
Args:
value: True, if Appearance should be displayed as flipped.
Returns:
"""
self._is_filled = value
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
def _set_scaled(self, value: bool):
"""
Sets the actor to parenz-size **without** remaining aspect-ratio.
Args:
value: True or False
"""
if value:
self._is_upscaled = False
self._is_scaled_to_height = False
self._is_scaled_to_width = False
self._is_scaled = value
self.set_dirty("scale", Appearance.RELOAD_ACTUAL_IMAGE)
def _set_upscaled(self, value: bool):
"""
If set to True, the image will be upscaled remaining aspect-ratio.
Args:
value: True or False
"""
if value:
self._is_scaled = False
self._is_scaled_to_height = False
self._is_scaled_to_width = False
self._is_upscaled = value
self.set_dirty("scale", Appearance.RELOAD_ACTUAL_IMAGE)
def _set_scaled_to_width(self, value: bool):
if value:
self._is_upscaled = False
self.is_scaled = False
self._is_scaled_to_height = False
self.is_scaled = False
self._is_scaled_to_width = value
self.set_dirty("scale", Appearance.RELOAD_ACTUAL_IMAGE)
def _set_scaled_to_height(self, value):
if value:
self._is_upscaled = False
self.is_scaled = False
self._is_scaled_to_width = False
self.is_scaled = False
self._is_scaled_to_height = value
self.set_dirty("scale", Appearance.RELOAD_ACTUAL_IMAGE)
[docs]
def remove_last_image(self):
self.image_manager.remove_last_image()
[docs]
def add_image(self, source: Union[str, Tuple, pygame.Surface]) -> int:
"""Adds an image to the appearance
Returns:
Index of the created image.
"""
if type(source) not in [str, pygame.Surface, tuple]:
raise MiniworldsError(
f"Error: Image source has wrong format (expected str or pygame.Surface, got {type(source)}"
)
return self.image_manager.add_image(source)
def _set_image(self, source: Union[int, "Appearance", tuple]) -> bool:
"""Sets the displayed image of costume/background to selected index
Args:
source: The image index or an image.
Returns:
True, if image index exists
Examples:
Add two images two background and switch to image 2
.. code-block:: python
from miniworlds import *
world = World()
background = world.add_background("images/1.png")
background.add_image("images/2.png")
background._set_image(1)
world.run()
"""
if isinstance(source, int):
return self.image_manager._set_image_index(source)
elif type(source) == tuple:
surface = image_manager.ImageManager.get_surface_from_color(source)
self.image_manager.replace_image(
surface, image_manager.ImageManager.COLOR, source
)
[docs]
def add_images(self, sources: list):
"""Adds multiple images to background/costume.
Each source in sources parameter must be a valid parameter for :py:attr:`Appearance.cimage`
"""
assert isinstance(sources, list)
for source in sources:
self.add_image(source)
[docs]
def animate(self, loop=False):
"""Animates the costume
Args:
loop: If loop = True, the animation will be processed as loop. (you can stop this with self.loop)
.. code-block:: python
from miniworlds import *
world = World(80,40)
robo = Actor()
robo.costume.add_images(["images/1.png"])
robo.costume.add_images(["images/2.png","images/3.png","images/4.png"])
robo.costume.animation_speed = 20
robo.costume.is_animated = True
world.run()
.. video:: ../_static/animate.webm
:autoplay:
:width: 300
:height: 100
"""
self._animation_start_frame = self.world.frame
self.is_animated = True
if loop:
self.loop = True
[docs]
def after_animation(self):
"""
the method is overwritten in subclasses costume and appearance
Examples:
The actor will be removed after the animation - This can be used for explosions.
.. code-block:: python
from miniworlds import *
world = World()
actor = Actor()
costume = actor.add_costume("images/1.png")
costume.add_image("images/2.png")
costume.animate()
@costume.register
def after_animation(self):
self.parent.remove()
world.run()
"""
pass
[docs]
def to_colors_array(self) -> numpy.ndarray:
"""Create an array from costume or background.
The array can be re-written to appearance with ``.from_array``
Examples:
Convert a background image to grayscale
.. code-block:: python
from miniworlds import *
world = World(600,400)
world.add_background("images/sunflower.jpg")
arr = world.background.to_colors_array()
def brightness(r, g, b):
return (int(r) + int(g) + int(b)) / 3
for x in range(len(arr)):
for y in range(len(arr[0])):
arr[x][y] = brightness(arr[x][y][0], arr[x][y][1], arr[x][y][2])
world.background.from_array(arr)
world.run()
Output:
.. image:: ../_images/sunflower5grey.png
:alt: converted image
"""
return pygame.surfarray.array3d(self.image)
[docs]
def from_array(self, arr: numpy.ndarray):
"""Create a background or costume from array. The array must be a ``numpy.ndarray,
which can be created with ``.to_colors_array``
Examples:
Convert grey default-background to gradient
.. code-block:: python
from miniworlds import *
world = World()
arr = world.background.to_colors_array()
for x in range(len(arr)):
for y in range(len(arr[0])):
arr[x][y][0] = ((x +1 ) / world.width) * 255
arr[x][y][1] = ((y +1 ) /world.width) * 255
world.background.from_array(arr)
world.run()
world.background.from_array(arr)
world.run()
Output:
.. image:: ../_images/gradient3.png
:alt: converted image
"""
surf = pygame.surfarray.make_surface(arr)
self.image_manager.replace_image(surf, image_manager.ImageManager.SURFACE, None)
[docs]
def fill(self, value):
"""Set default fill color for borders and lines"""
self._is_filled = value
if self.is_filled:
self.fill_color = color_mod.Color(value).get()
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
[docs]
def set_filled(self, value):
self._is_filled = value
[docs]
def get_color(self, position):
x = int(position[0])
y = int(position[1])
if 0 <= x < self.image.get_width() and 0 <= y < self.image.get_height():
return self.image.get_at((x, y))
else:
return None
[docs]
def get_rect(self):
return self.image.get_rect()
[docs]
def draw(self, source, position, width, height):
if isinstance(source, str):
self.draw_on_image(source, position, width, height)
elif isinstance(source, tuple):
self.draw_color_on_image(source, position, width, height)
[docs]
def draw_on_image(self, path, position, width, height):
file = self.image_manager.find_image_file(path)
surface = self.image_manager.load_image(file)
self.draw_image_append(
surface, pygame.Rect(position[0], position[1], width, height)
)
self.set_dirty("draw_images", Appearance.RELOAD_ACTUAL_IMAGE)
[docs]
def draw_color_on_image(self, color, position, width, height):
surface = pygame.Surface((width, height))
surface.fill(color)
self.draw_image_append(
surface, pygame.Rect(position[0], position[1], width, height)
)
self.set_dirty("draw_images", Appearance.RELOAD_ACTUAL_IMAGE)
def __str__(self):
return (
self.__class__.__name__
+ "with ID ["
+ str(self.id)
+ "] for parent:["
+ str(self.parent)
+ "], images: "
+ str(self.image_manager.images_list)
)
[docs]
def get_image(self):
"""If dirty, the image will be reloaded.
The image pipeline will be processed, defined by "set_dirty"
"""
if (
self.dirty >= self.RELOAD_ACTUAL_IMAGE
and not self._flag_transformation_pipeline
):
self.dirty = 0
self._flag_transformation_pipeline = True
self._before_transformation_pipeline()
image = self.image_manager.load_image_from_image_index()
image = self.transformations_manager.process_transformation_pipeline(
image, self
)
self._after_transformation_pipeline()
self._flag_transformation_pipeline = False
self._image = image
return self._image
def _before_transformation_pipeline(self):
"""Called in `get_image`, if image is "dirty" (e.g. size, rotation, ... has changed)
after image transformation pipeline is processed
"""
pass
def _after_transformation_pipeline(self) -> None:
"""Called in `get_image`, if image is "dirty" (e.g. size, rotation, ... has changed)
before image transformation pipeline is processed
"""
pass
[docs]
def update(self):
"""Loads the next image,
called 1/frame"""
if self.parent:
self._load_image()
return 1
def _load_image(self):
"""Loads the image,
* switches image if necessary
* processes transformations pipeline if necessary
"""
if self.is_animated and self._animation_start_frame != self.world.frame:
if self.world.frame != 0 and self.world.frame % self.animation_speed == 0:
self.image_manager.next_image()
self.get_image()
[docs]
def register(self, method: callable):
"""
Register method for decorator. Registers method to actor or background.
"""
bound_method = binding.bind_method(self, method)
return bound_method
[docs]
def draw_shape_append(self, shape, arguments):
self.draw_shapes.append((shape, arguments))
[docs]
def draw_shape_set(self, shape, arguments):
self.draw_shapes = [(shape, arguments)]
[docs]
def draw_image_append(self, surface, rect):
self.draw_images.append((surface, rect))
[docs]
def draw_image_set(self, surface, rect):
self.draw_images = [(surface, rect)]
@property
def dirty(self):
return self._dirty
@dirty.setter
def dirty(self, value):
if value == 0:
self._dirty = 0
else:
self.set_dirty(value)
[docs]
def set_dirty(self, value="all", status=1):
if self.parent and hasattr(self, "transformations_manager"):
if value and self.images and self.parent.is_display_initialized:
self._update_draw_shape()
self.transformations_manager.flag_reload_actions_for_transformation_pipeline(
value
)
if status >= self._dirty:
self._dirty = status
self.parent.dirty = 1
@property
@abstractmethod
def world(self) -> "world.World":
"""Implemented in subclasses Costume and Background"""
def _update_draw_shape(self) -> None:
self.draw_shapes = []
if self.parent and self._inner_shape() and self.image_manager:
if self._is_filled and not self.image_manager.is_image():
self.draw_shape_append(
self._inner_shape()[0], self._inner_shape_arguments()
)
if self.parent and self._outer_shape() and self.border:
self.draw_shape_append(
self._outer_shape()[0], self._outer_shape_arguments()
)
def _inner_shape(self) -> tuple:
"""Returns inner shape of costume
Returns:
pygame.Rect: Inner shape (Rectangle with size of actor)
"""
return pygame.draw.rect, [
pygame.Rect(0, 0, self.parent.size[0], self.parent.size[1]),
0,
]
def _outer_shape(self) -> tuple:
"""Returns outer shape of costume
Returns:
pygame.Rect: Outer shape (Rectangle with size of actors without filling.)
"""
return pygame.draw.rect, [
pygame.Rect(0, 0, self.parent.size[0], self.parent.size[1]),
self.border,
]
def _inner_shape_arguments(self) -> List:
"""def setGets arguments for inner shape
Returns:
List[]: List of arguments
"""
color = self.fill_color
return [
color,
] + self._inner_shape()[1]
def _outer_shape_arguments(self) -> List:
"""Gets arguments for outer shape
Returns:
List[]: List of arguments
"""
color = self.border_color
return [
color,
] + self._outer_shape()[1]