import abc
from abc import abstractmethod
from functools import cached_property
from typing import TYPE_CHECKING, List, Tuple, Union
import numpy
import pygame
import miniworlds.appearances.appearance_rendering_facade as appearance_rendering_facade
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.tools.binding as binding
import miniworlds.tools.color as color_mod
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
[Doku]
class Appearance(metaclass=MetaAppearance):
"""Base class for actor costumes and world backgrounds.
`Appearance` is the parent class of both `Costume` and `Background`. You
normally access it through `actor.costume` or `world.background`.
Examples:
::
actor.costume.add_image("images/player.png")
actor.costume.fill_color = (255, 0, 0)
actor.costume.border = 2
actor.costume.is_animated = True
actor.costume.alpha = 128
"""
counter = 0
__slots__ = (
# Core attributes
"id",
"initialized",
"_flag_transformation_pipeline",
"parent",
"draw_shapes",
"draw_images",
# State flags
"_is_flipped",
"_is_animated",
"_is_textured",
"_is_centered",
"_is_upscaled",
"_is_scaled",
"_is_scaled_to_width",
"_is_scaled_to_height",
"_is_rotatable",
# Appearance properties
"_orientation",
"_coloring",
"_transparency",
"_border",
"_is_filled",
"_fill_color",
"_border_color",
"_alpha",
"_dirty",
"_image",
"surface_loaded",
"last_image",
# Managers
"font_manager",
"image_manager",
"transformations_manager",
# Properties
"_texture_size",
"_animation_speed",
"loop",
"animation_length",
"_animation_start_frame",
"_cached_rect",
# Note: _rendering_facade is a @cached_property, so it's stored in __dict__
# __dict__ is inherited from object (via metaclass),
# so subclasses can add their own attributes and @cached_property works
"__dict__",
)
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
@cached_property
def _rendering_facade(
self,
) -> appearance_rendering_facade.AppearanceRenderingFacade:
return appearance_rendering_facade.AppearanceRenderingFacade(self)
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
[Doku]
def set_image(self, source: Union[int, "Appearance", tuple]) -> bool:
"""Set the displayed image.
Args:
source: Image index, appearance, or color tuple.
Returns:
`True` if the image index exists.
Examples:
::
background.add_image("images/1.png")
background.add_image("images/2.png")
background.set_image(1)
"""
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
)
[Doku]
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
[Doku]
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)
[Doku]
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: Whether the image is tiled over the parent area.
Examples:
::
background = world.add_background("images/stone.png")
background.is_textured = True
background.texture_size = (15, 15)
"""
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):
"""bool: Whether the image rotates with the parent 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):
"""bool: Whether the image is mirrored horizontally.
Examples:
::
actor.costume.is_flipped = True
"""
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):
"""float: Orientation offset applied before parent rotation.
Examples:
::
actor.costume.orientation = -90
"""
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):
"""tuple | None: Optional color layer.
Examples:
::
actor.costume.coloring = (255, 0, 0)
"""
return self._coloring
@coloring.setter
def coloring(self, value):
self._coloring = value
self.set_dirty("coloring", Appearance.RELOAD_ACTUAL_IMAGE)
@property
def transparency(self):
"""bool: Whether alpha transparency is enabled.
The actual opacity is controlled by `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):
"""bool: Whether the appearance animates through its images.
Examples:
::
actor.costume.add_images(["images/1.png", "images/2.png"])
actor.costume.animation_speed = 20
actor.costume.is_animated = True
"""
return self._is_animated
@is_animated.setter
def is_animated(self, value: bool):
self.set_animated(value)
[Doku]
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):
"""tuple: Alias for `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):
"""tuple: Alias for `border_color`."""
return self._border_color
@stroke_color.setter
def stroke_color(self, value):
self.border_color = value
@property
def border_color(self):
"""tuple: Border color."""
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):
"""int: Border width in pixels.
A value of `0` means no border.
"""
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)
[Doku]
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:
"""pygame.Surface: Rendered image after the transformation pipeline."""
return self.get_image()
def _set_rotatable(self, value: bool):
"""Set whether the appearance rotates with the parent direction.
Args:
value: Whether rotation is enabled.
"""
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):
"""Set whether the image is mirrored horizontally.
Args:
value: Whether flipping is enabled.
"""
self._is_flipped = value
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
def _set_filled(self, value: bool):
"""Set whether shapes are rendered filled.
Args:
value: Whether filling is enabled.
"""
self._is_filled = value
self.set_dirty("all", Appearance.RELOAD_ACTUAL_IMAGE)
def _set_scaled(self, value: bool):
"""Set whether the image scales to parent size without aspect ratio.
Args:
value: Whether scaling is enabled.
"""
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):
"""Set whether small images may be upscaled with aspect ratio kept.
Args:
value: Whether upscaling is enabled.
"""
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)
[Doku]
def remove_last_image(self):
"""Remove the most recently added image."""
self._rendering_facade.remove_last_image()
[Doku]
def add_image(self, source: Union[str, Tuple, pygame.Surface]) -> int:
"""Add an image source and return its index."""
return self._rendering_facade.add_image(source)
def _set_image(self, source: Union[int, "Appearance", tuple]) -> bool:
"""Set the displayed image.
Args:
source: Image index, appearance, or color tuple.
Returns:
`True` if the image index exists.
Examples:
::
background.add_image("images/1.png")
background.add_image("images/2.png")
background._set_image(1)
"""
return self._rendering_facade.set_image(source)
[Doku]
def add_images(self, sources: list):
"""Add multiple image sources.
Each source in `sources` must be a valid input for `add_image`.
Examples:
::
actor.costume.add_images(["images/1.png", "images/2.png"])
"""
self._rendering_facade.add_images(sources)
[Doku]
def animate(self, loop=False):
"""Start appearance animation.
Args:
loop: Whether the animation should repeat.
Examples:
::
actor.costume.add_images(["images/1.png", "images/2.png"])
actor.costume.animate(loop=True)
"""
self._rendering_facade.animate(loop=loop)
[Doku]
def after_animation(self):
"""Hook called after a non-looping animation finishes.
Examples:
::
@costume.register
def after_animation(self):
self.parent.remove()
"""
self._rendering_facade.after_animation()
[Doku]
def to_colors_array(self) -> numpy.ndarray:
"""Return the appearance image as a color array.
Returns:
A NumPy array containing color data.
Examples:
::
arr = world.background.to_colors_array()
arr[0][0] = (255, 0, 0)
world.background.from_array(arr)
"""
return self._rendering_facade.to_colors_array()
[Doku]
def from_array(self, arr: numpy.ndarray):
"""Replace the appearance image from a color array.
Args:
arr: NumPy color array, usually created with `to_colors_array()`.
Examples:
::
arr = world.background.to_colors_array()
arr[0][0] = (255, 0, 0)
world.background.from_array(arr)
"""
self._rendering_facade.from_array(arr)
[Doku]
def fill(self, value):
"""Set default fill color for borders and lines"""
self._rendering_facade.fill(value)
[Doku]
def set_filled(self, value):
"""Set whether shapes are rendered filled."""
self._rendering_facade.set_filled(value)
[Doku]
def get_color(self, position):
"""Return the color at a local pixel position."""
return self._rendering_facade.get_color(position)
[Doku]
def get_rect(self):
"""Return the local rectangle of the rendered image."""
return self._rendering_facade.get_rect()
[Doku]
def draw(self, source, position, width, height):
"""Draw an image source at a local position."""
self._rendering_facade.draw(source, position, width, height)
[Doku]
def draw_on_image(self, path, position, width, height):
"""Queue drawing an image file onto the appearance image."""
self._rendering_facade.draw_on_image(path, position, width, height)
[Doku]
def draw_color_on_image(self, color, position, width, height):
"""Queue drawing a colored rectangle onto the appearance image."""
self._rendering_facade.draw_color_on_image(color, position, width, height)
def __str__(self):
return self._rendering_facade.to_string()
[Doku]
def get_image(self):
"""If dirty, the image will be reloaded.
The image pipeline will be processed, defined by "set_dirty"
"""
return self._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._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._rendering_facade.after_transformation_pipeline()
def update(self):
"""Loads the next image,
called 1/frame"""
return self._rendering_facade.update()
def _load_image(self):
"""Loads the image,
* switches image if necessary
* processes transformations pipeline if necessary
"""
self._rendering_facade.load_image()
[Doku]
def register(self, method: callable):
"""
Register method for decorator. Registers method to actor or background.
"""
return self._rendering_facade.register(method)
[Doku]
def draw_shape_append(self, shape, arguments):
"""Append a shape draw command to the render queue."""
self._rendering_facade.draw_shape_append(shape, arguments)
[Doku]
def draw_shape_set(self, shape, arguments):
"""Replace shape draw commands with a single command."""
self._rendering_facade.draw_shape_set(shape, arguments)
[Doku]
def draw_image_append(self, surface, rect):
"""Append a pre-rendered surface draw command."""
self._rendering_facade.draw_image_append(surface, rect)
[Doku]
def draw_image_set(self, surface, rect):
"""Replace image draw commands with one surface draw command."""
self._rendering_facade.draw_image_set(surface, rect)
@property
def dirty(self):
"""Dirty flag for the current rendering pipeline state."""
return self._rendering_facade.dirty
@dirty.setter
def dirty(self, value):
self._rendering_facade.dirty = value
[Doku]
def set_dirty(self, value="all", status=1):
"""Mark pipeline stages as dirty so the image is re-rendered."""
self._rendering_facade.set_dirty(value=value, status=status)
[Doku]
@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._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._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._rendering_facade.outer_shape()
def _inner_shape_arguments(self) -> List:
"""Gets arguments for inner shape.
Returns:
List[]: List of arguments
"""
return self._rendering_facade.inner_shape_arguments()
def _outer_shape_arguments(self) -> List:
"""Gets arguments for outer shape
Returns:
List[]: List of arguments
"""
return self._rendering_facade.outer_shape_arguments()