import abc
from abc import abstractmethod
from typing import Union, Tuple, List
import miniworldmaker.appearances.managers.font_manager as font_manager
import miniworldmaker.appearances.managers.image_manager as image_manager
import miniworldmaker.appearances.managers.transformations_manager as transformations_manager
import miniworldmaker.boards.board_templates.pixel_board.board as board
import miniworldmaker.tools.binding as binding
import miniworldmaker.tools.color as color_mod
import numpy
import pygame
from miniworldmaker.exceptions.miniworldmaker_exception import MiniworldMakerError
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 token costumes and board 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
[docs]
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 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)
[docs]
def set_font(self, font, font_size):
self.font_manager.font_path = font
self.font_manager.font_size = font_size
@property
def animation_speed(self):
return self._animation_speed
@animation_speed.setter
def animation_speed(self, value):
self._animation_speed = value
[docs]
def set_animation_speed(self, value):
self.animation_speed = value
@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)
[docs]
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)
[docs]
def set_rotatable(self, value: bool):
"""
If set to True, costume will be rotated with token direction
Args:
value: True, if image should be rotated with Token direction
Returns:
"""
self._is_rotatable = value
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
@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 orientation(self):
"""bool: If True, the image will be rotated by parent orientation before it is rotated.
Examples:
Both tokens are moving up. The image of t2 is correctly aligned. t1 is looking in the wrong direction.
.. code-block:: python
from miniworldmaker import *
board = TiledBoard()
t1 = Token((4,4))
t1.add_costume("images/player.png")
t1.move()
t2 = Token((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()
board.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 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)
[docs]
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)
[docs]
def flip(self, value):
self.is_flipped = value
@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)
[docs]
def set_scaled(self, value: bool):
"""
Sets the token 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)
@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)
[docs]
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)
@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)
[docs]
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)
@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_width(value)
[docs]
def set_scaled_to_height(self, value):
if value:
self._is_upscaled = False
self.is_scaled = False
self._is_scaled_to_height = False
self.is_scaled = False
self._is_scaled_to_height = value
self.set_dirty("scale", 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 Token: 0: transparent, 255: visible
If value < 1, it will be multiplied with 255.
Examples:
.. code-block:: python
from miniworldmaker import *
board = Board(800,400)
t = Token((600,250))
t.add_costume("images/alien1.png")
t.costume.alpha = 50
t.width = 40
t.border = 1
board.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
[docs]
def get_text_width(self):
return self.font_manager.get_text_width()
[docs]
def get_text_height(self):
return self.font_manager.get_text_height()
[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 MiniworldMakerError(
f"Error: Image source has wrong format (expected str or pygame.Surface, got {type(source)}"
)
return self.image_manager.add_image(source)
[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 type(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 type(sources) == list
for source in sources:
self.add_image(source)
@property
def is_animated(self):
"""If True, the costume will be animated.
.. code-block:: python
from miniworldmaker import *
board = Board(80,40)
robo = Token()
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
board.run()
.. video:: ../_static/animate.webm
:autoplay:
:width: 300
:height: 100
"""
return self._is_animated
@is_animated.setter
def is_animated(self, value):
self._is_animated = value
[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 miniworldmaker import *
board = Board(80,40)
robo = Token()
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
board.run()
.. video:: ../_static/animate.webm
:autoplay:
:width: 300
:height: 100
"""
self._animation_start_frame = self.board.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 token will be removed after the animation - This can be used for explosions.
.. code-block:: python
from miniworldmaker import *
board = Board()
token = Token()
costume = token.add_costume("images/1.png")
costume.add_image("images/2.png")
costume.animate()
@costume.register
def after_animation(self):
self.parent.remove()
board.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 miniworldmaker import *
board = Board(600,400)
board.add_background("images/sunflower.jpg")
arr = board.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])
board.background.from_array(arr)
board.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 miniworldmaker import *
board = Board()
arr = board.background.to_colors_array()
for x in range(len(arr)):
for y in range(len(arr[0])):
arr[x][y][0] = ((x +1 ) / board.width) * 255
arr[x][y][1] = ((y +1 ) /board.width) * 255
board.background.from_array(arr)
board.run()
board.background.from_array(arr)
board.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)
@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)
[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)
@property
def is_filled(self):
"""Is token filled with color?"""
return self._is_filled
@is_filled.setter
def is_filled(self, value):
"""Defines if costume is filled with a color.
if ``_is_filled`` set to a color-value, ``self.fill_color`` is set to the color.
"""
self._is_filled = value
@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 token"""
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 token.
The value is 0, if token has no border
Returns:
_type_: int
"""
return self._border
@border.setter
def border(self, value: Union[int, None]):
if not value:
value = 0
if type(value) != int:
raise TypeError("border value should be of type int")
self._border = value
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
[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 type(source) == str:
self.draw_on_image(source, position, width, height)
elif type(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)
)
@property
def text(self):
"""Sets text of appearance
Examples:
.. code-block:: python
explosion = Explosion(position=other.position.up(40).left(40))
explosion.costume.is_animated = True
explosion.costume.text_position = (100, 100)
explosion.costume.text = "100"
"""
return self.font_manager.text
@text.setter
def text(self, value):
self.set_text(value)
[docs]
def set_text(self, value):
if value == "":
self.font_manager.text = ""
self.set_dirty("write_text", Appearance.RELOAD_ACTUAL_IMAGE)
else:
self.font_manager.text = value
self.set_dirty("write_text", Appearance.RELOAD_ACTUAL_IMAGE)
@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()
[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.board.frame:
if self.board.frame != 0 and self.board.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 token 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 board(self) -> "board.Board":
"""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 token)
"""
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 tokens 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]