Bläddra i källkod

Basic module structure with tests and scripts.

mdo 1 år sedan
förälder
incheckning
5ce4d64c3c

+ 11 - 0
data/01.yaml

@@ -0,0 +1,11 @@
+scene: test.png
+characters:
+  protagonist:
+    speed: 10
+    height: 50
+    position: [200, 200]
+    keys:
+      forward: d
+      backward: a
+      up: w
+      down: s

+ 16 - 0
data/02.yaml

@@ -0,0 +1,16 @@
+characters:
+  protagonist:
+    speed: 5
+    width: 50
+    position: [50, 300]
+    keys:
+      forward: d
+      backward: a
+      up: w
+      down: s
+      increase: e
+      decrease: q
+  jett:
+    speed: 5
+    width: 150
+    position: [400, 200]

BIN
images/characters/jett.png


BIN
images/characters/protagonist.png


BIN
images/scenes/scene.png


BIN
images/scenes/test.png


+ 7 - 0
jogai/__init__.py

@@ -0,0 +1,7 @@
+import pygame
+
+from jogai.character import Character
+from jogai.scene import Scene
+from jogai.videogame import *
+
+pygame.init()

+ 9 - 0
jogai/_events.py

@@ -0,0 +1,9 @@
+import pygame
+
+
+def _get_events() -> list:
+    return pygame.event.get()
+
+
+def _get_keys() -> list:
+    return pygame.key.get_pressed()

+ 13 - 0
jogai/_globals.py

@@ -0,0 +1,13 @@
+import pygame
+
+from jogai import settings, utils
+
+_clock = pygame.time.Clock()
+
+_default_data = utils._load_yaml_from_filename(
+    settings.DEFAULT_DATA_FILENAME, directory=settings.BASE_DIR
+)
+
+_window = pygame.display.set_mode(
+    (settings.WINDOW_WIDTH, settings.WINDOW_HEIGHT)
+)

+ 425 - 0
jogai/character.py

@@ -0,0 +1,425 @@
+import os
+
+import pygame
+
+from jogai import _events, logger, settings, utils
+from jogai._events import _get_keys
+from jogai._globals import _window
+from jogai.translations import gettext as _
+
+
+class Character(pygame.sprite.Sprite):
+    """
+    A videogame character with a bunch of actions.
+    """
+
+    _predefined_key_actions_allowed = [
+        _('backward'),
+        _('decrease'),
+        _('down'),
+        _('forward'),
+        _('increase'),
+        _('up'),
+    ]
+
+    _predefined_attributes_allowed = [
+        _('height'),
+        _('position'),
+        _('speed'),
+        _('width'),
+        _('keys'),
+    ]
+
+    def __init__(
+        self,
+        image_filename: str,
+        name: str = '',
+    ):
+        if not isinstance(name, str):
+            raise TypeError(_('The character name must be a string.'))
+
+        self.scale_factor = 1.0
+        self.speed = 0
+        self.predefined_keys = {}
+        self._image = None
+        self._original_image = None
+        self._original_rect = None
+        self._rect = None
+        self.name = name
+
+        super().__init__()
+        self.load(image_filename)
+
+    def __repr__(self):
+        return f'{_("Character")}: {self.name}'
+
+    def _load_data(self, data: dict):
+        """
+        Load character attributes from data schema.
+        Only declarated attributes in predefined_attributes_allowed are allowed.
+
+        Parameters:
+            data: input schema
+        """
+
+        for attribute in data.keys():
+            # utils._convert_function_arguments_to_string()
+            if attribute in self._predefined_attributes_allowed:
+                eval(f'self.set_{attribute}(data["{attribute}"])')
+
+    @property
+    def height(self):
+        return self._rect.height
+
+    @property
+    def width(self):
+        return self._rect.width
+
+    @property
+    def x(self):
+        return self._rect.x
+
+    @property
+    def y(self):
+        return self._rect.y
+
+    def _eval_predefined_function(
+        self, function: str, arguments: str | list | dict
+    ):
+        if not isinstance(function, str):
+            raise TypeError(_('The function name must be a string.'))
+
+        processed_arguments = utils._convert_function_arguments_to_string(
+            arguments
+        )
+
+        eval(f'self.{function}({processed_arguments})')
+
+    def move(
+        self,
+        x: int | None = None,
+        y: int | None = None,
+        key: str = '',
+        relative: bool = True,
+    ):
+        """
+        Move the character the number of pixels indicated in the x and y parameters
+
+        Parameters:
+            x: Number of pixels to move on the abscissa axis
+            y: Number of pixels to move on the ordinate axis
+            key: Optional key to perform the action
+            relative: True for a incremental movement, False for an absolute movement
+
+        Examples:
+            >>> ch = Character('protagonist.png')
+            >>> ch.move(5, 3)
+            >>> ch.move()
+        """
+        if x is None:
+            x = self.speed
+        if y is None:
+            y = self.speed
+
+        if not isinstance(x, int) or not isinstance(y, int):
+            raise TypeError(_('Amount to move ​​must be an integer.'))
+
+        if not isinstance(key, str):
+            raise TypeError(_('Key pressed must be a string.'))
+
+        if not key or _get_keys()[utils.eval_key(key)]:
+            if relative:
+                self._rect.move_ip(x, y)
+            else:
+                self._rect.x = x
+                self._rect.y = y
+
+    def move_to(self, x: int, y: int, key: str = ''):
+        """
+        Move the character to (x, y) position.
+
+        Parameters:
+            x: coordinate in horizontal axis
+            y: coordinate in vertical axis
+            key: Optional key to perform the action
+
+        Examples:
+            >>> ch = Character('protagonist.png')
+            >>> ch.move_to(0, 0)
+        """
+        self.move(x, y, key, relative=False)
+
+    def backward(self, amount: int | None = None, key: str = ''):
+        """
+        Moves the character in the negative direction of the abscissa.
+
+        Parameters:
+            amount: Number of pixels to move
+            key: Optional key to perform the action
+
+        Examples:
+            >>> ch = Character('protagonist.png')
+            >>> ch.backward(3)
+            >>> ch.backward()
+        """
+        if amount is None:
+            amount = self.speed
+        self.move(-amount, 0, key)
+
+    def down(self, amount: int | None = None, key: str = ''):
+        """
+        Moves the character in the positive direction of the ordinate.
+
+        Parameters:
+            amount: Number of pixels to move
+            key: Optional key to perform the action
+
+        Examples:
+            >>> ch = Character('protagonist.png')
+            >>> ch.down(3)
+            >>> ch.down()
+        """
+        if amount is None:
+            amount = self.speed
+        self.move(0, amount, key)
+
+    def forward(self, amount: int | None = None, key: str = ''):
+        """
+        Moves the character in the positive direction of the abscissa.
+
+        Parameters:
+            amount: Number of pixels to move
+            key: Optional key to perform the action
+
+        Examples:
+            >>> ch = Character('protagonist.png')
+            >>> ch.forward(3)
+            >>> ch.forward()
+        """
+        if amount is None:
+            amount = self.speed
+        self.move(amount, 0, key)
+
+    def up(self, amount: int | None = None, key: str = ''):
+        """
+        Moves the character in the negative direction of the ordinate.
+
+        Parameters:
+            amount: Number of pixels to move
+            key: Optional key to perform the action
+
+        Examples:
+            >>> ch = Character('protagonist.png')
+            >>> ch.up(3)
+            >>> ch.up()
+        """
+        if amount is None:
+            amount = self.speed
+        self.move(0, -amount, key)
+
+    def decrease(self, decrement: float | int = 0.001, key: str = ''):
+        """
+        Decreases the size of the character.
+
+        Parameters:
+            decrement: amount to increase to the scale factor
+            key: Optional key to perform the action
+
+        Examples:
+            >>> ch = Character('protagonist.png')
+            >>> ch.decrease(0.1)
+        """
+        if isinstance(decrement, int):
+            increment = float(increment)
+
+        if not isinstance(decrement, float):
+            raise TypeError(_('The decrement must be a float number.'))
+
+        self.increase(-decrement, key)
+
+    def increase(self, increment: float | int = 0.001, key: str = ''):
+        """
+        Increases the size of the character.
+
+        Parameters:
+            increment: amount to increase to the scale factor
+            key: Optional key to perform the action
+
+        Examples:
+            >>> ch = Character('protagonist.png')
+            >>> ch.increase(0.1)
+        """
+        if isinstance(increment, int):
+            increment = float(increment)
+
+        if not isinstance(increment, float):
+            raise TypeError(_('The increment must be a float number.'))
+
+        if not key or _get_keys()[utils.eval_key(key)]:
+            self.scale_to(self.scale_factor + increment)
+
+    def scale_to(self, scale_factor: float = 1.0):
+        """
+        Scale the character according to the scale factor.
+
+        Parameters:
+            scale_factor: factor to scale
+
+        Examples:
+            >>> ch = Character('protagonist.png')
+            >>> ch.scale_to(2)
+        """
+        if not isinstance(scale_factor, float) and not isinstance(
+            scale_factor, int
+        ):
+            raise TypeError(
+                _('The scale factor must be a positive number (float or int).')
+            )
+        if scale_factor <= 0:
+            raise ValueError(
+                _('The scale factor must be a positive number (float or int).')
+            )
+
+        self._image = pygame.transform.scale(
+            self._original_image,
+            (
+                int(self._original_rect.width * scale_factor),
+                int(self._original_rect.height * scale_factor),
+            ),
+        )
+        self.scale_factor = scale_factor
+        x, y = self._rect.x, self._rect.y
+        self._rect = self._image.get_rect()
+        self.move_to(x, y)
+
+    def set_height(self, height: int = 0):
+        """
+        Set the character height.
+
+        Parameters:
+            height: the character height in pixels
+
+        Examples:
+            >>> ch = Character('protagonist.png')
+            >>> ch.set_height(100)
+
+        """
+        if not isinstance(height, int):
+            raise TypeError(_('Height must be a not negative integer.'))
+        if height < 1 or self._original_rect.height < 1:
+            raise ValueError(_('Height must be a not negative integer.'))
+
+        self.scale_factor = height / self._original_rect.height
+        self._image = pygame.transform.scale(
+            self._original_image,
+            (int(self._original_rect.width * self.scale_factor), height),
+        )
+        self._rect = self._image.get_rect()
+
+    def set_keys(self, predefined_keys: dict):
+        """
+        Set the character predefined keys to perform actions.
+        Only declarated actions in _predefined_key_actions_allowed are allowed.
+
+        Parameters:
+            predefined_keys: the predefined keys with the associated action.
+
+        Examples:
+            >>> ch = Character('protagonist.png')
+            >>> ch.set_keys({'forward': 'd'})
+        """
+        if not isinstance(predefined_keys, dict):
+            raise TypeError(
+                _('The predefined keys must be a dict. No keys are loaded.')
+            )
+        filtered_predefined_keys = {}
+        for action, key in predefined_keys.items():
+            if action in self._predefined_key_actions_allowed:
+                filtered_predefined_keys.update({action: key})
+        self.predefined_keys.update(filtered_predefined_keys)
+
+    def set_position(self, position: tuple | list, key: str = ''):
+        """
+        Set the character position as tuple. To use as separate coordinates call move_to().
+
+        Parameters:
+            position: a tuple with abscissa and ordinate coordinates
+            key: Optional key to perform the action
+
+        Examples:
+            >>> ch = Character('protagonist.png')
+            >>> ch.set_position((0, 0))
+        """
+        if not isinstance(position, tuple) and not isinstance(position, list):
+            raise TypeError(_('The position must be a tuple or list (x, y).'))
+
+        if len(position) != 2:
+            raise ValueError(_('The position has two coordinates (x, y).'))
+
+        self.move(position[0], position[1], key, relative=False)
+
+    def set_speed(self, speed: int = 0):
+        """
+        Set the character speed.
+
+        Parameters:
+            speed: the character speed
+
+        Examples:
+            >>> ch = Character('protagonist.png')
+            >>> ch.set_speed(10)
+        """
+        if isinstance(speed, int):
+            self.speed = speed
+
+    def set_width(self, width: int = 0):
+        """
+        Set the character width.
+
+        Parameters:
+            width: the character width in pixels
+
+        Examples:
+            >>> ch = Character('protagonist.png')
+            >>> ch.set_width(100)
+        """
+        if not isinstance(width, int):
+            raise TypeError(_('Width must be a not negative integer.'))
+        if width < 1 or self._original_rect.width < 1:
+            raise ValueError(_('Width must be a not negative integer.'))
+
+        self.scale_factor = width / self._original_rect.width
+        self._image = pygame.transform.scale(
+            self._original_image,
+            (width, int(self._original_rect.height * self.scale_factor)),
+        )
+        self._rect = self._image.get_rect()
+
+    def load(self, filename: str) -> bool:
+        """
+        >>> ch = Character('protagonist.png')
+        >>> result = ch.load('protagonist.png')
+        """
+        self._original_image, self._original_rect = utils._load_image(
+            settings.CHARACTERS_DIR, filename
+        )
+
+        self._image = self._original_image
+        self._original_rect = self._original_image.get_rect()
+        self._rect = self._image.get_rect()
+        self.scale_factor = 1
+
+    def paint(self):
+        """
+        Paint the character in the main surface (window).
+
+        Examples:
+            >>> ch = Character('protagonist.png')
+            >>> ch.paint()
+        """
+        if self._image:
+            _window.blit(self._image, self._rect)
+
+    def update(self):
+        for action, key in self.predefined_keys.items():
+            eval(f'self.{action}(key="{key}")')

+ 11 - 0
jogai/default_data.yaml

@@ -0,0 +1,11 @@
+scene: scene.png
+characters:
+  protagonist:
+    speed: 10
+    height: 50
+    position: [200, 200]
+    keys:
+      forward: RIGHT
+      backward: LEFT
+      up: UP
+      down: DOWN

+ 19 - 0
jogai/exceptions.py

@@ -0,0 +1,19 @@
+# Temporary import
+from jogai.translations import gettext as _
+
+
+class UnsupportedFileFormatError(Exception):
+    """Exception raised for unsuppoerted file formats."""
+
+    def __init__(self, msg):
+        self.msg = msg
+
+    def __str__(self):
+        return f"{_('Unsupported file format')}: {self.msg}"
+
+
+class KeyPressedNotFoundError(Exception):
+    """Exception raised for pressed key not found."""
+
+    def __str__(self):
+        return _('The key pressed was not found')

+ 3 - 0
jogai/logger.py

@@ -0,0 +1,3 @@
+import logging
+
+logger = logging.getLogger('jogai')

+ 75 - 0
jogai/scene.py

@@ -0,0 +1,75 @@
+import os
+from typing import Tuple
+
+import pygame
+
+from jogai import settings, utils, videogame
+from jogai._globals import _window
+from jogai.translations import gettext as _
+
+
+class Scene:
+    """
+    A videogame scene with background color and an optional image.
+    """
+
+    _image = None
+    _rect = None
+    filename = ''
+    background = settings.SCENE_DEFAULT_BACKGROUND
+
+    def __init__(
+        self,
+        image_filename: str = '',
+        background: Tuple[int, int, int] = settings.SCENE_DEFAULT_BACKGROUND,
+    ):
+        if not isinstance(image_filename, str):
+            raise TypeError(_('The image filename must be a string'))
+
+        self.change_background(background)
+        if image_filename:
+            self.change_image(image_filename)
+
+    def change_background(self, background: Tuple[int, int, int]):
+        """
+        Change the background color.
+
+        Parameters:
+            background: A tuple with the R,G,B components.
+
+        Examples:
+            >>> sc = Scene()
+            >>> sc.change_background((255, 255, 255))
+        """
+        match background:
+            case r, g, b if (
+                isinstance(r, int)
+                and isinstance(g, int)
+                and isinstance(b, int)
+                and 0 <= r <= 255
+                and 0 <= g <= 255
+                and 0 <= b <= 255
+            ):
+                self.background = (r, g, b)
+            case _:
+                raise ValueError(
+                    _(
+                        'The background color must be a tuple with three integers (r,g,b) between 0 and 255.'
+                    )
+                )
+
+    def change_image(self, filename: str) -> bool:
+        """
+        Change the scene image.
+
+        Parameters:
+            filename: The image filename to be changed.
+
+        Examples:
+            >>> sc = Scene()
+            >>> sc.change_image('test.png')
+        """
+        self._image, self._rect = utils._load_image(
+            settings.SCENES_DIR, filename
+        )
+        self.filename = filename

+ 24 - 0
jogai/settings.py

@@ -0,0 +1,24 @@
+"""
+Videogame settings
+"""
+
+import os
+
+import jogai
+
+KEY_DELAY = 1
+KEY_INTERVAL = 25
+FPS = 60
+
+BASE_DIR = os.path.dirname((os.path.abspath(__file__)))
+DEFAULT_DATA_FILENAME = 'default_data.yaml'
+
+DATA_DIR = 'data'
+SCENE_DEFAULT_BACKGROUND = (0, 0, 0)
+SCENES_DIR = os.path.join('images', 'scenes')
+CHARACTERS_DIR = os.path.join('images', 'characters')
+
+WINDOW_WIDTH = 800
+WINDOW_HEIGHT = 600
+
+LANGUAGE_CODE = 'gl'

+ 107 - 0
jogai/translations.py

@@ -0,0 +1,107 @@
+import gettext as gettext_module
+import os
+import sys
+import warnings
+
+from jogai import settings
+
+
+def to_language(locale):
+    """Turn a locale name (en_US) into a language name (en-us)."""
+    p = locale.find('_')
+    if p >= 0:
+        return locale[:p].lower() + '-' + locale[p + 1 :].lower()
+    else:
+        return locale.lower()
+
+
+def to_locale(language):
+    """Turn a language name (en-us) into a locale name (en_US)."""
+    lang, _, country = language.lower().partition('-')
+    if not country:
+        return language[:3].lower() + language[3:]
+    # A language with > 2 characters after the dash only has its first
+    # character after the dash capitalized; e.g. sr-latn becomes sr_Latn.
+    # A language with 2 characters after the dash has both characters
+    # capitalized; e.g. en-us becomes en_US.
+    country, _, tail = country.partition('-')
+    country = country.title() if len(country) > 2 else country.upper()
+    if tail:
+        country += '-' + tail
+    return lang + '_' + country
+
+
+def gettext(message):
+    """
+    TODO: Implement this function
+
+    Translate the 'message' string.
+    """
+    return message
+
+    """
+    eol_message = message.replace('\r\n', '\n').replace('\r', '\n')
+
+    if eol_message:
+        _translation_object = GameTranslation(settings.LANGUAGE_CODE)
+        result = _translation_object.gettext(eol_message)
+    else:
+        # Return an empty value of the corresponding type if an empty message
+        # is given, instead of metadata, which is the default gettext behavior.
+        result = type(message)('')
+
+    return result
+    """
+
+
+class GameTranslation(gettext_module.GNUTranslations):
+    domain = 'jogai'
+
+    def __init__(self, language, domain=None, localedirs=None):
+        """Create a class to manage game translations"""
+        gettext_module.GNUTranslations.__init__(self)
+        if domain is not None:
+            self.domain = domain
+
+        self.__language = language
+        self.__locale = to_locale(language)
+        self._manager = None
+
+        if self.domain == 'jogai':
+            if localedirs is not None:
+                # A module-level cache is used for caching 'django' translations
+                warnings.warn(
+                    'localedirs is ignored when domain is "jogai".',
+                    RuntimeWarning,
+                )
+                localedirs = None
+            self._manager = self._init_translation_manager()
+
+        if (
+            self.__language == settings.LANGUAGE_CODE
+            and self.domain == 'jogai'
+            and self._manager is None
+        ):
+            # default lang should have a translation file available.
+            raise OSError(
+                f'No translation files found for language {settings.LANGUAGE_CODE}.'
+            )
+
+    def __repr__(self):
+        return f'<GameTranslation lang: {self.__language}'
+
+    def _init_translation_manager(self):
+        settingsfile = sys.modules[settings.__module__].__file__
+        localedir = os.path.join(os.path.dirname(settingsfile), 'locale')
+        return self._new_gnu_trans(localedir)
+
+    def _new_gnu_trans(self, localedir):
+        return gettext_module.translation(
+            domain=self.domain,
+            localedir=localedir,
+            languages=[self.__locale],
+        )
+
+    def language(self):
+        """Return the translation language."""
+        return self.__language

+ 119 - 0
jogai/utils.py

@@ -0,0 +1,119 @@
+import gettext as gettext_module
+import os
+import sys
+import warnings
+from collections import OrderedDict
+from typing import Any
+
+import pygame
+import yaml
+from pygame.locals import *
+
+from jogai import exceptions as exc
+from jogai import logger, settings
+from jogai.translations import gettext as _
+
+
+def _load_image(directory: str, filename: str) -> list:
+    if not isinstance(directory, str) or not isinstance(filename, str):
+        raise TypeError(
+            _('Both the directory and the filename must be strings.')
+        )
+
+    path = os.path.join(directory, filename)
+    try:
+        image = pygame.image.load(path)
+    except FileNotFoundError as fnfe:
+        raise FileNotFoundError(_('Image filename not found'))
+    except pygame.error as pe:
+        raise exc.UnsupportedFileFormatError(filename)
+
+    rect = image.get_rect()
+    return [image, rect]
+
+
+def _load_yaml(stream: str) -> OrderedDict:
+    """Create an OrderedDict from a yaml string.
+
+    Parameters:
+        stream: data input stream
+
+    Return:
+        An OrderedDict with the input data.
+    """
+
+    class OrderedLoader(yaml.SafeLoader):
+        pass
+
+    def construct_mapping(loader, node):
+        loader.flatten_mapping(node)
+        return OrderedDict(loader.construct_pairs(node))
+
+    OrderedLoader.add_constructor(
+        yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, construct_mapping
+    )
+    return yaml.load(stream, OrderedLoader)
+
+
+def _load_yaml_from_filename(
+    filename: str, directory=settings.DATA_DIR
+) -> OrderedDict:
+    path = os.path.join(directory, filename)
+    with open(path) as file:
+        content = file.read()
+        return _load_yaml(content)
+
+
+def _convert_function_arguments_to_string(arguments: Any) -> str:
+    """
+    Convert function arguments from yaml data to string.
+
+    Parameters:
+        arguments: The function arguments from yaml item.
+
+    Returns:
+        A string with the input arguments
+
+    Examples:
+        >>> _convert_function_arguments_to_string({'x': 200, 'y': 400, 'key': 'a'})
+        "x=200, y=400, key='a'"
+        >>> _convert_function_arguments_to_string(['foo', 'bar'])
+        "'foo', 'bar'"
+    """
+
+    if not isinstance(arguments, list) and not isinstance(arguments, dict):
+        arguments = [arguments]
+
+    if isinstance(arguments, list):
+        processed_arguments = ', '.join([repr(v) for v in arguments])
+    if isinstance(arguments, dict):
+        processed_arguments = ', '.join(
+            ['='.join((str(k), repr(v))) for k, v in arguments.items()]
+        )
+    return processed_arguments
+
+
+def eval_key(key: str) -> int:
+    """
+    Evaluate a string as a pygame key.
+
+    Parameters:
+        key: string representation of a key with or without 'K_' prefix
+
+    Examples:
+        >>> key = eval_key('K_a')
+        >>> key = eval_key('a')
+
+    Returns:
+        The integer representing the input key.
+    """
+    if not isinstance(key, str):
+        raise exc.KeyPressedNotFoundError
+
+    try:
+        if key.startswith('K_'):
+            return eval(key)
+        else:
+            return eval(f'K_{key}')
+    except NameError:
+        raise exc.KeyPressedNotFoundError

+ 251 - 0
jogai/videogame.py

@@ -0,0 +1,251 @@
+import builtins
+import os
+import time
+from typing import Tuple, Type
+
+import pygame
+
+import jogai.settings as settings
+from jogai import character, logger, scene, utils
+from jogai._events import _get_events, _get_keys
+from jogai._globals import _clock, _default_data, _window
+from jogai.translations import gettext as _
+
+# --------------- #
+#    GLOBALS      #
+# --------------- #
+
+vg_scene = None
+vg_characters = None
+running = True
+
+# ------------------ #
+#    FUNCTIONS       #
+# ------------------ #
+
+# TODO:
+# Nome do videojogo -> set_caption
+
+
+def _load_scene(data: dict) -> 'scene.Scene':
+    """
+    Load scene from data schema.
+
+    Parameters:
+        data: input schema
+
+    Returns:
+        An object containing the scene
+
+    """
+    if _('scene') in data:
+        return scene.Scene(data[_('scene')])
+    return scene.Scene()
+
+
+def _load_characters(data: dict) -> list:
+    """
+    Load characters from data schema.
+
+    Parameters:
+        data: input schema
+
+    Returns:
+        A list containing all loaded characters
+    """
+    data_characters = {}
+    if not _('characters') in data:
+        return []
+    data_characters = data[_('characters')]
+
+    characters = []
+    for character_name, character_data in data_characters.items():
+        loaded_character = character.Character(
+            f'{character_name}.png',
+            character_name,
+        )
+        loaded_character._load_data(character_data)
+        characters.append(loaded_character)
+    return characters
+
+
+def simulate_key_press(key: [int, str], down: bool = True):
+    """
+    Post a key press simulation to pygame event module.
+
+    Parameters:
+        key: Key to simulate
+        down: Keystroke direction. True for KEYDOWN, False for KEYUP
+
+    Examples:
+        >>> simulate_key_press('a') # doctest: +SKIP
+    """
+    key = utils.eval_key(key)
+    if isinstance(down, bool) and not down:
+        event_type = pygame.KEYUP
+    else:
+        event_type = pygame.KEYDOWN
+    key_event = pygame.event.Event(
+        event_type,
+        key=key,
+    )
+    pygame.event.post(key_event)
+
+
+def add_character(new_character: 'character.Character'):
+    """
+    Add a new character to the videogame.
+
+    Parameters:
+        new_character: the character to add
+
+    Examples:
+        >>> prepare()
+        >>> p = character.Character('protagonist.png')
+        >>> add_character(p)
+    """
+    if not isinstance(new_character, character.Character):
+        raise TypeError(_('Only Character instances can be added.'))
+    vg_characters.append(new_character)
+
+
+def init():
+    """
+    Start the pygame engine if it is not started.
+
+    Examples:
+        >>> init()
+    """
+    global _window
+    if not pygame.get_init():
+        pygame.init()
+    _window = pygame.display.set_mode(
+        (settings.WINDOW_WIDTH, settings.WINDOW_HEIGHT)
+    )
+
+
+def is_running() -> bool:
+    """
+    Check if the game is running. Useful in the main loop.
+
+    Returns:
+        True if the game is running. False otherwise.
+
+    Examples:
+        >>> state = is_running()
+    """
+    return running
+
+
+def listen_events():
+    """
+    Listen for events coming from the keyboard and window clicks.
+
+    Examples:
+        >>> simulate_key_press('ESCAPE')
+        >>> listen_events()
+    """
+    global _events, _keys
+    _events = _get_events()
+    _keys = _get_keys()
+    for event in _events:
+        match event.type:
+            case pygame.KEYDOWN:
+                if event.key == pygame.K_ESCAPE:
+                    quit()
+            case pygame.QUIT:
+                quit()
+
+
+def get_color(x: int, y: int) -> Tuple[int, int, int]:
+    """
+    Get the RGB components for a point in the game window.
+
+    Parameters:
+        x: Position on the abscissa axis
+        y: Position on the ordinate axis
+
+    Returns:
+        A tuple of integers with the RGB components
+
+    Examples:
+        >>> color = get_color(100, 200)
+    """
+    if not isinstance(x, int) or not isinstance(y, int):
+        raise TypeError(
+            _('The coordinates of the point must be a non-negative integers.')
+        )
+    if not (0 <= x <= settings.WINDOW_WIDTH) or not (
+        0 <= y <= settings.WINDOW_HEIGHT
+    ):
+        raise ValueError(
+            _('The coordinates of the point must be a non-negative integers.')
+        )
+    return tuple(_window.get_at((x, y))[:-1])
+
+
+def paint(sprite: Type['scene.Scene'] | Type['character.Character']):
+    if hasattr(sprite, 'background'):
+        _window.fill(sprite.background)
+    if sprite._image and sprite._rect:
+        _window.blit(sprite._image, sprite._rect)
+
+
+def prepare(data_filename: str = ''):
+    """
+    Prepare the game environment before entering the main loop.
+
+    Examples:
+        >>> init()
+        >>> prepare()
+    """
+    global vg_scene, vg_characters
+    pygame.key.set_repeat(settings.KEY_DELAY, settings.KEY_INTERVAL)
+    if data_filename:
+        vg_data = utils._load_yaml_from_filename(data_filename)
+    else:
+        vg_data = _default_data
+
+    vg_scene = _load_scene(vg_data)
+    vg_characters = _load_characters(vg_data)
+    # TODO: Check and filter variable names added to builtins
+    setattr(builtins, 'vg_scene', vg_scene)
+    for vg_character in vg_characters:
+        setattr(builtins, vg_character.name, vg_character)
+
+
+def stop():
+    global running
+    running = False
+
+
+def update():
+    """
+    Refresh the display surface and listen events.
+    """
+    global _clock, _window, vg_scene, vg_characters
+    if vg_scene:
+        paint(vg_scene)
+    if vg_characters:
+        for vg_character in vg_characters:
+            paint(vg_character)
+            vg_character.update()
+    pygame.display.flip()
+    _clock.tick(settings.FPS)
+    listen_events()
+
+
+def quit(wait: int = 0):
+    """
+    Exit the game.
+
+    Parameters:
+        wait: Seconds to wait before exiting
+
+    Examples:
+        >>> quit(1)
+    """
+    if isinstance(wait, int) and wait > 0:
+        time.sleep(wait)
+    pygame.quit()
+    stop()

+ 1 - 1
pyproject.toml

@@ -43,6 +43,6 @@ line_length = 79
 lint = "blue --check --diff . && isort --check --diff ."
 docs = "mkdocs serve"
 pre_test = "task lint"
-test = "pytest -x -s --cov=m8videojogo -vv"
+test = "pytest -x -s --cov=jogai -vv"
 script = "pytest -s scripts/basics.py"
 post_test = "coverage html"

+ 13 - 0
scripts/basics.py

@@ -0,0 +1,13 @@
+from jogai import *
+
+
+def test_example01():
+    def move():
+        ...
+
+    prepare('02.yaml')
+
+    args = utils._convert_function_arguments_to_string({'x': 200, 'y': 400})
+
+    while is_running():
+        update()

+ 11 - 0
tests/conftest.py

@@ -0,0 +1,11 @@
+from pytest import fixture
+
+from jogai import videogame as vg
+
+
+@fixture(scope='module')
+def prepare_videogame():
+    vg.init()
+    vg.prepare()
+    yield
+    vg.quit()

+ 81 - 0
tests/test_character.py

@@ -0,0 +1,81 @@
+import pygame
+import pytest
+
+from jogai import character
+from jogai import videogame as vg
+from jogai.translations import gettext as _
+
+
+def test_height_is_a_non_negative_integer(prepare_videogame):
+    assert protagonist.height > -1
+
+
+def test_width_is_a_non_negative_integer(prepare_videogame):
+    assert protagonist.width > -1
+
+
+def test_x_coordinate_is_an_integer(prepare_videogame):
+    assert isinstance(protagonist.x, int)
+
+
+def test_y_coordinate_is_an_integer(prepare_videogame):
+    assert isinstance(protagonist.y, int)
+
+
+def test_wrong_character_name():
+    with pytest.raises(TypeError):
+        ch = character.Character('protagonist.png', 1)
+
+
+def test_repr_character(prepare_videogame):
+    print(protagonist.__repr__)
+
+
+def test_wrong_predefined_keys_type():
+    with pytest.raises(TypeError):
+        ch = character.Character('protagonist.png', predefined_keys=[])
+
+
+def test_move_with_wrong_coordinates(prepare_videogame):
+    with pytest.raises(TypeError):
+        protagonist.move('foo', 'bar')
+
+
+def test_move_with_wrong_key(prepare_videogame):
+    with pytest.raises(TypeError):
+        protagonist.move(5, 3, 1)
+
+
+def test_scale_with_wrong_factor_type(prepare_videogame):
+    with pytest.raises(TypeError):
+        protagonist.scale_to('foo')
+
+
+def test_scale_with_a_negative_factor(prepare_videogame):
+    with pytest.raises(ValueError):
+        protagonist.scale_to(-1)
+
+
+def test_set_height_with_wrong_type(prepare_videogame):
+    with pytest.raises(TypeError):
+        protagonist.set_height('foo')
+
+
+def test_set_height_with_a_negative_amount(prepare_videogame):
+    with pytest.raises(ValueError):
+        protagonist.set_height(-1)
+
+
+def test_set_position_with_wrong_types(prepare_videogame):
+    with pytest.raises(TypeError):
+        protagonist.set_position('foo', 'bar')
+
+
+def test_set_width_with_wrong_type(prepare_videogame):
+    with pytest.raises(TypeError):
+        protagonist.set_width('a')
+
+
+def test_set_width_with_a_negative_amount(prepare_videogame):
+    with pytest.raises(ValueError):
+        protagonist.set_width(-1)

+ 28 - 0
tests/test_scene.py

@@ -0,0 +1,28 @@
+import os
+
+import pytest
+
+from jogai import *
+
+
+def test_change_background_color_with_components_out_of_range():
+    with pytest.raises(ValueError):
+        sc = Scene()
+        sc.change_background((-1, -1, 300))
+
+
+def test_change_background_color_with_less_components():
+    with pytest.raises(ValueError):
+        sc = Scene()
+        sc.change_background((0, 0))
+
+
+def test_change_background_color_wrong_type():
+    with pytest.raises(ValueError):
+        sc = Scene()
+        sc.change_background('a')
+
+
+def test_create_scene_with_wrong_type():
+    with pytest.raises(TypeError):
+        sc = Scene(-1)

+ 0 - 0
tests/test_translations.py


+ 31 - 0
tests/test_utils.py

@@ -0,0 +1,31 @@
+import pytest
+from pytest import mark
+
+from jogai import exceptions, utils
+
+
+@mark.xfail
+@mark.parametrize('key', ['', 'foo', 'K_', -1, 0, None])
+def test_eval_key(key):
+    utils.eval_key(key)
+
+
+def test_load_image_with_wrong_types():
+    with pytest.raises(TypeError):
+        utils._load_image(-1, -1)
+
+
+def test_load_not_found_image():
+    with pytest.raises(FileNotFoundError):
+        utils._load_image('none', 'none.png')
+
+
+def test_load_image_with_wrong_file_format():
+    with pytest.raises(exceptions.UnsupportedFileFormatError):
+        utils._load_image('data', '01.yaml')
+
+
+"""
+def test_convert_function_argumens_to_string_with_wrong_type():
+    utils._convert_function_arguments_to_string()
+"""

+ 56 - 0
tests/test_videogame.py

@@ -0,0 +1,56 @@
+import pygame
+import pytest
+from pytest import fixture, mark
+
+from jogai import videogame as vg
+
+
+@mark.xfail
+@mark.parametrize(
+    ('key, down'),
+    [('a', False), (-1, True), ('pataca', True), ('', True), (None, True)],
+)
+def test_simulate_key_press(key, down):
+    vg.init()
+    vg.simulate_key_press(key, down)
+    vg.quit()
+
+
+def test_listen_quit_event():
+    quit_event = pygame.event.Event(
+        pygame.QUIT,
+        mod=pygame.locals.KMOD_NONE,
+    )
+    vg.init()
+    pygame.event.post(quit_event)
+    vg.listen_events()
+
+
+def test_get_color_with_wrong_types():
+    with pytest.raises(TypeError):
+        vg.get_color('foo', 'bar')
+
+
+def test_get_color_with_out_of_range_values():
+    with pytest.raises(ValueError):
+        vg.get_color(-3, 100000)
+
+
+@mark.parametrize('wait', [-1, 0, 1, 'foo', None])
+def test_wait_and_quit(wait):
+    vg.quit(wait)
+
+
+def test_prepare_02_yaml():
+    vg.init()
+    vg.prepare('02.yaml')
+    vg.quit()
+
+
+def test_add_character_with_wrong_type():
+    with pytest.raises(TypeError):
+        vg.add_character('foo')
+
+
+def test_update(prepare_videogame):
+    vg.update()