import abc
from abc import abstractmethod
from typing import Union, Tuple, List, TYPE_CHECKING
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.appearances.appearance_rendering_facade as appearance_rendering_facade
import miniworlds.tools.binding as binding
import miniworlds.tools.color as color_mod
import numpy
import pygame
from miniworlds.base.exceptions import MiniworldsError
if TYPE_CHECKING:
import miniworlds.worlds.world as world_mod
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 for actor costumes and world backgrounds.
``Appearance`` is the parent class of both ``Costume`` (used by actors) and
``Background`` (used by worlds). You normally do not create ``Appearance``
instances directly – access them through ``actor.costume`` or
``world.background`` instead.
Typical operations students use:
* Loading an image: ``actor.costume.add_image("my_image.png")``
* Setting a fill color: ``actor.costume.fill_color = (255, 0, 0)``
* Drawing a border: ``actor.costume.border = 2``
* Animating a sprite: ``actor.costume.is_animated = True``
* Making transparent: ``actor.costume.alpha = 128``
* Flipping horizontally: ``actor.costume.is_flipped = True``
"""
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 = 0
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
self._cached_rect = (-1, pygame.Rect(0, 0, 1, 1)) # frame, rect
self._rendering_facade = appearance_rendering_facade.AppearanceRenderingFacade(self)
def _get_rendering_facade(self) -> appearance_rendering_facade.AppearanceRenderingFacade:
facade = getattr(self, "_rendering_facade", None)
if facade is None:
facade = appearance_rendering_facade.AppearanceRenderingFacade(self)
self._rendering_facade = facade
return facade
def _set_defaults(self, **kwargs) -> "Appearance":
for key, value in kwargs.items():
if value is not None:
attr_name = f"_{key}"
if hasattr(self, attr_name):
setattr(self, attr_name, value)
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 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 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):
"""Finalize initialization after the metaclass constructor hook."""
self.set_dirty("all", Appearance.LOAD_NEW_IMAGE)
self.initialized = True
@property
def font_size(self):
"""Current font size used for text rendering."""
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):
"""Texture tile size used when `is_textured` is enabled."""
return 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):
"""Frames between animation steps."""
return self._animation_speed
@animation_speed.setter
def animation_speed(self, value):
if not isinstance(value, (int, float)):
raise TypeError(f"animation_speed must be int or float, got {type(value).__name__}")
if value <= 0:
raise ValueError(f"animation_speed must be > 0, got {value}")
self._animation_speed = value
def _set_animation_speed(self, value):
self.animation_speed = value
[docs]
def set_mode(self, **kwargs):
"""Set multiple appearance mode flags at once.
Supported keyword arguments include `mode`, `texture_size`, and
`animation_speed`.
"""
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):
"""Return all mode flags as a dictionary."""
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 miniworlds import *
world = World()
background = world.add_background("images/stone.png")
background.is_textured = True
world.run()
.. image:: ../_images/is_textured.png
:alt: Textured image>
Set texture size
.. code-block:: python
from miniworlds import *
world = World()
background = world.add_background("images/stone.png")
background.is_textured = True
background.texture_size = (15,15)
world.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):
"""Whether drawing operations are centered on the parent position."""
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):
"""Whether shapes are rendered filled instead of outlined."""
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 miniworlds import *
world = World()
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.world.frame % 100 == 0:
if self.costume.is_flipped:
self.costume.is_flipped = False
else:
self.costume.is_flipped = True
world.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):
"""Whether the image is scaled to parent width and keeps aspect ratio."""
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):
"""Whether the image is scaled to parent height and keeps aspect ratio."""
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):
"""Primary fill color for shape-based rendering."""
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):
"""Primary fill color for shape-based rendering."""
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):
"""Transparency value of the appearance.
Use values from `0` to `255`:
- `0` means fully transparent
- `255` means fully visible
If the value is between `0` and `1`, it is interpreted as a normalized
opacity and converted to the 0..255 range.
"""
return self._alpha
@alpha.setter
def alpha(self, value):
if not isinstance(value, (int, float)):
raise TypeError(f"alpha must be int or float, got {type(value).__name__}")
# Allow normalized 0-1 range
if 0 < value < 1:
value = value * 255
# Validate final range
if not (0 <= value <= 255):
raise ValueError(f"alpha must be 0-255, got {value}")
self._alpha = value
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):
"""Enable or disable frame-based animation."""
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")
if value < 0:
raise ValueError(f"border must be >= 0, got {value}")
self._border = value
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
[docs]
def flip(self, value):
"""Convenience wrapper to set `is_flipped`."""
self.is_flipped = value
@property
def images(self):
"""List of image surfaces managed by this appearance."""
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):
"""
Sets whether the costume or background should be filled with a color.
Args:
value: True, if Appearance should be drawn as filled.
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):
"""Remove the most recently added image."""
self._get_rendering_facade().remove_last_image()
[docs]
def add_image(self, source: Union[str, Tuple, pygame.Surface]) -> int:
"""Add an image source and return its index."""
return self._get_rendering_facade().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()
"""
return self._get_rendering_facade().set_image(source)
[docs]
def add_images(self, sources: list):
"""Adds multiple images to background/costume.
Each source in `sources` must be a valid input for `add_image`.
"""
self._get_rendering_facade().add_images(sources)
[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._get_rendering_facade().animate(loop=loop)
[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()
"""
self._get_rendering_facade().after_animation()
[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 self._get_rendering_facade().to_colors_array()
[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
"""
self._get_rendering_facade().from_array(arr)
[docs]
def fill(self, value):
"""Set default fill color for borders and lines"""
self._get_rendering_facade().fill(value)
[docs]
def set_filled(self, value):
"""Set whether shapes are rendered filled."""
self._get_rendering_facade().set_filled(value)
[docs]
def get_color(self, position):
"""Return the color at a local pixel position."""
return self._get_rendering_facade().get_color(position)
[docs]
def get_rect(self):
"""Return the local rectangle of the rendered image."""
return self._get_rendering_facade().get_rect()
[docs]
def draw(self, source, position, width, height):
"""Draw an image source at a local position."""
self._get_rendering_facade().draw(source, position, width, height)
[docs]
def draw_on_image(self, path, position, width, height):
"""Queue drawing an image file onto the appearance image."""
self._get_rendering_facade().draw_on_image(path, position, width, height)
[docs]
def draw_color_on_image(self, color, position, width, height):
"""Queue drawing a colored rectangle onto the appearance image."""
self._get_rendering_facade().draw_color_on_image(color, position, width, height)
def __str__(self):
return self._get_rendering_facade().to_string()
[docs]
def get_image(self):
"""If dirty, the image will be reloaded.
The image pipeline will be processed, defined by "set_dirty"
"""
return self._get_rendering_facade().get_image()
def _before_transformation_pipeline(self):
"""Called in `get_image` **before** the image transformation pipeline is processed
(e.g. when size, rotation, or other display properties have changed).
"""
self._get_rendering_facade().before_transformation_pipeline()
def _after_transformation_pipeline(self) -> None:
"""Called in `get_image` **after** the image transformation pipeline is processed
(e.g. when size, rotation, or other display properties have changed).
"""
self._get_rendering_facade().after_transformation_pipeline()
[docs]
def update(self):
"""Loads the next image,
called 1/frame"""
return self._get_rendering_facade().update()
def _load_image(self):
"""Loads the image,
* switches image if necessary
* processes transformations pipeline if necessary
"""
self._get_rendering_facade().load_image()
[docs]
def register(self, method: callable):
"""
Register method for decorator. Registers method to actor or background.
"""
return self._get_rendering_facade().register(method)
[docs]
def draw_shape_append(self, shape, arguments):
"""Append a shape draw command to the render queue."""
self._get_rendering_facade().draw_shape_append(shape, arguments)
[docs]
def draw_shape_set(self, shape, arguments):
"""Replace shape draw commands with a single command."""
self._get_rendering_facade().draw_shape_set(shape, arguments)
[docs]
def draw_image_append(self, surface, rect):
"""Append a pre-rendered surface draw command."""
self._get_rendering_facade().draw_image_append(surface, rect)
[docs]
def draw_image_set(self, surface, rect):
"""Replace image draw commands with one surface draw command."""
self._get_rendering_facade().draw_image_set(surface, rect)
@property
def dirty(self):
"""Dirty flag for the current rendering pipeline state."""
return self._get_rendering_facade().dirty
@dirty.setter
def dirty(self, value):
self._get_rendering_facade().dirty = value
[docs]
def set_dirty(self, value="all", status=1):
"""Mark pipeline stages as dirty so the image is re-rendered."""
self._get_rendering_facade().set_dirty(value=value, status=status)
[docs]
@abstractmethod
def get_manager(self):
"""Implemented in subclasses Costume and Background"""
@property
@abstractmethod
def world(self) -> "world_mod.World":
"""Implemented in subclasses Costume and Background"""
def _update_draw_shape(self) -> None:
self._get_rendering_facade().update_draw_shape()
def _inner_shape(self) -> tuple:
"""Returns inner shape of costume
Returns:
pygame.Rect: Inner shape (Rectangle with size of actor)
"""
return self._get_rendering_facade().inner_shape()
def _outer_shape(self) -> tuple:
"""Returns outer shape of costume
Returns:
pygame.Rect: Outer shape (Rectangle with size of actors without filling.)
"""
return self._get_rendering_facade().outer_shape()
def _inner_shape_arguments(self) -> List:
"""Gets arguments for inner shape.
Returns:
List[]: List of arguments
"""
return self._get_rendering_facade().inner_shape_arguments()
def _outer_shape_arguments(self) -> List:
"""Gets arguments for outer shape
Returns:
List[]: List of arguments
"""
return self._get_rendering_facade().outer_shape_arguments()