Kaynağa Gözat

Initial commit

Zero! Studio 7 saat önce
ebeveyn
işleme
b67d27b2ff
67 değiştirilmiş dosya ile 7339 ekleme ve 1659 silme
  1. 2 0
      data/00.yaml
  2. 7 15
      data/02.yaml
  3. 28 0
      data/test.yaml
  4. 21 0
      datos/00.yaml
  5. 20 0
      datos/01.yaml
  6. 36 0
      datos/02.yaml
  7. 13 0
      datos/03.yaml
  8. 1 0
      docs/api/ferramentas.md
  9. 0 1
      docs/api/group.md
  10. 1 0
      docs/api/groups.md
  11. 1 0
      docs/api/grupos.md
  12. 1 0
      docs/api/personaxe.md
  13. 1 0
      docs/api/textos.md
  14. 1 0
      docs/api/texts.md
  15. BIN
      imaxes/escenarios/scene.png
  16. BIN
      imaxes/escenarios/test.png
  17. BIN
      imaxes/escenarios/test_large.png
  18. BIN
      imaxes/escenarios/test_small.png
  19. BIN
      imaxes/personaxes/bloque.png
  20. BIN
      imaxes/personaxes/protagonista.png
  21. BIN
      imaxes/personaxes/protagonista_avanzar.png
  22. BIN
      imaxes/personaxes/protagonista_saltar.png
  23. BIN
      imaxes/personaxes/secundario.png
  24. 46 9
      jogai/__init__.py
  25. 7 5
      jogai/_globals.py
  26. 675 166
      jogai/character.py
  27. 0 20
      jogai/default_data.yaml
  28. 2 2
      jogai/exceptions.py
  29. 18 0
      jogai/exporter/assets/base.css
  30. 21 0
      jogai/exporter/assets/base.html
  31. 64 0
      jogai/exporter/assets/character.js
  32. 27 0
      jogai/exporter/assets/scene.js
  33. 25 0
      jogai/exporter/assets/videogame.js
  34. 95 0
      jogai/exporter/exporter_utils.py
  35. 0 0
      jogai/exporter/videogame.py
  36. 0 236
      jogai/group.py
  37. 354 0
      jogai/groups.py
  38. 83 0
      jogai/locale/gl/escenario.py
  39. 122 0
      jogai/locale/gl/ferramentas.py
  40. 773 0
      jogai/locale/gl/gl.po
  41. 774 0
      jogai/locale/gl/gl.po~
  42. 49 0
      jogai/locale/gl/grupos.py
  43. 827 0
      jogai/locale/gl/personaxe.py
  44. 409 0
      jogai/locale/gl/textos.py
  45. 5 0
      jogai/locale/gl/utils.py
  46. 148 0
      jogai/locale/gl/videoxogo.py
  47. 167 50
      jogai/scene.py
  48. 0 62
      jogai/settings.py
  49. 21 0
      jogai/settings/paths.py
  50. 99 0
      jogai/settings/settings.py
  51. 764 0
      jogai/texts.py
  52. 23 83
      jogai/translations.py
  53. 45 21
      jogai/utils.py
  54. 269 148
      jogai/videogame.py
  55. 164 394
      jogai/widgets.py
  56. 1 1
      mkdocs.yml
  57. 51 72
      poetry.lock
  58. 7 6
      pyproject.toml
  59. 7 2
      scripts/en_basics.py
  60. 39 0
      scripts/gl_basics.py
  61. 62 6
      tests/conftest.py
  62. 413 154
      tests/test_character.py
  63. 0 94
      tests/test_group.py
  64. 197 0
      tests/test_groups.py
  65. 153 42
      tests/test_scene.py
  66. 13 4
      tests/test_utils.py
  67. 187 66
      tests/test_videogame.py

+ 2 - 0
data/00.yaml

@@ -0,0 +1,2 @@
+caption: Test Videogame
+gravity: 5

+ 7 - 15
data/02.yaml

@@ -1,15 +1,16 @@
 caption: Test Videogame
-gravity: 5
+gravity: 0
+boundaries: True
 scene: test_large.png
 characters:
   protagonist:
-    gravitative: False
+    gravitative: True
     chronometer: 30
     score: 10
     lives: 5
-    speed: 20
+    speed: 5
     width: 50
-    position: [265, 300]
+    position: [0, 200]
     keys:
       forward: RIGHT
       backward: LEFT
@@ -20,14 +21,5 @@ characters:
       jump: SPACE
       show_inventory: i
   secondary:
-    speed: 5
-    width: 150
-    position: [700, 200]
-    keys:
-      forward: d
-      backward: a
-      up: w
-      down: s
-  brick:
-    height: 40
-    position: [520, 310]
+    height: 100
+    position: [0, 0]

+ 28 - 0
data/test.yaml

@@ -0,0 +1,28 @@
+caption: Test Videogame
+gravity: 5
+boundaries: True
+scene: test_large.png
+characters:
+  protagonist:
+    gravitative: True
+    chronometer: 30
+    score: 10
+    lives: 5
+    speed: 5
+    width: 50
+    position: [500, 265]
+    keys:
+      forward: RIGHT
+      backward: LEFT
+      up: UP
+      down: DOWN
+      increase: e
+      decrease: q
+      jump: SPACE
+      show_inventory: i
+  secondary:
+  bricks:
+    filename: 'brick.png'
+    height: 40
+    random:
+      amount: 20

+ 21 - 0
datos/00.yaml

@@ -0,0 +1,21 @@
+título: Test Videogame
+gravidade: 5
+fondo: [255, 0, 0]
+personaxes:
+  protagonista:
+    gravitacional: False
+    cronómetro: 30
+    puntos: 10
+    vidas: 5
+    velocidade: 2
+    largura: 50
+    posición: [265, 300]
+    teclas:
+      avanzar: RIGHT
+      retroceder: LEFT
+      subir: UP
+      baixar: DOWN
+      aumentar: e
+      diminuir: q
+      saltar: SPACE
+      mostrar_inventario: i

+ 20 - 0
datos/01.yaml

@@ -0,0 +1,20 @@
+escenario: test.png
+personaxes:
+  protagonista:
+    velocidade: 3
+    altura: 50
+    posición: [200, 200]
+    teclas:
+      avanzar: d
+      retroceder: a
+      subir: w
+      baixar: s
+  secundario:
+    velocidade: 5
+    largura: 50
+    posición: [300, 200]
+    teclas:
+      avanzar: d
+      retroceder: a
+      subir: w
+      baixar: s

+ 36 - 0
datos/02.yaml

@@ -0,0 +1,36 @@
+título: Test Videogame
+gravidade: 5
+bordos: True
+escenario: test_large.png
+#fondo: [255, 0, 0]
+personaxes:
+  protagonista:
+    gravitacional: False
+    cronómetro: 5
+    puntos: 10
+    vidas: 5
+    velocidade: 5
+    largura: 50
+    posición: [400, 300]
+    salto_no_ar: True
+    teclas:
+      avanzar: RIGHT
+      retroceder: LEFT
+      subir: UP
+      baixar: DOWN
+      aumentar: e
+      diminuir: q
+      saltar: SPACE
+      mostrar_inventario: i
+  secundario:
+    velocidade: 5
+    largura: 50
+    posición: [700, 200]
+    teclas:
+      avanzar: d
+      retroceder: a
+      subir: w
+      baixar: s
+  bloque:
+    alto: 40
+    posición: [520, 310]

+ 13 - 0
datos/03.yaml

@@ -0,0 +1,13 @@
+título: Test Videogame
+gravidade: 5
+escenario: test_large.png
+personaxes:
+  protagonista:
+    velocidade: 5
+    largura: 150
+    posición: [700, 200]
+    teclas:
+      avanzar: d
+      retroceder: a
+      subir: w
+      baixar: s

+ 1 - 0
docs/api/ferramentas.md

@@ -0,0 +1 @@
+::: ferramentas

+ 0 - 1
docs/api/group.md

@@ -1 +0,0 @@
-::: group

+ 1 - 0
docs/api/groups.md

@@ -0,0 +1 @@
+::: groups

+ 1 - 0
docs/api/grupos.md

@@ -0,0 +1 @@
+::: grupos

+ 1 - 0
docs/api/personaxe.md

@@ -0,0 +1 @@
+::: personaxe

+ 1 - 0
docs/api/textos.md

@@ -0,0 +1 @@
+::: textos

+ 1 - 0
docs/api/texts.md

@@ -0,0 +1 @@
+::: texts

BIN
imaxes/escenarios/scene.png


BIN
imaxes/escenarios/test.png


BIN
imaxes/escenarios/test_large.png


BIN
imaxes/escenarios/test_small.png


BIN
imaxes/personaxes/bloque.png


BIN
imaxes/personaxes/protagonista.png


BIN
imaxes/personaxes/protagonista_avanzar.png


BIN
imaxes/personaxes/protagonista_saltar.png


BIN
imaxes/personaxes/secundario.png


+ 46 - 9
jogai/__init__.py

@@ -18,21 +18,58 @@
 # Zero! Factorial Studio
 # info@zero.gal
 
+import builtins
+from importlib import import_module
+
 import pygame
 
-from jogai import settings
-from jogai.character import Character
-from jogai.group import *
-from jogai.scene import Scene
-from jogai.videogame import *
-from jogai.widgets import *
+from jogai import _globals
+from jogai.settings import settings
+
+
+def _load_to_builtins(module):
+    module = import_module(module)
+    for name in module.__all__:
+        builtins.__dict__.update({name: eval(f'module.{name}')})
+
+
+_globals.predefined_builtins = builtins.__dict__.copy().keys()
+
+try:
+    _load_to_builtins(
+        'jogai.locale.{}.{}'.format(settings.LANGUAGE_CODE, _('character'))
+    )
+    _load_to_builtins(
+        'jogai.locale.{}.{}'.format(settings.LANGUAGE_CODE, _('groups'))
+    )
+    _load_to_builtins(
+        'jogai.locale.{}.{}'.format(settings.LANGUAGE_CODE, _('scene'))
+    )
+    _load_to_builtins(
+        'jogai.locale.{}.{}'.format(settings.LANGUAGE_CODE, _('texts'))
+    )
+    _load_to_builtins(
+        'jogai.locale.{}.{}'.format(settings.LANGUAGE_CODE, _('videogame'))
+    )
+    _load_to_builtins(
+        'jogai.locale.{}.{}'.format(settings.LANGUAGE_CODE, _('widgets'))
+    )
+except ModuleNotFoundError:
+    from jogai.character import *
+    from jogai.groups import *
+    from jogai.scene import *
+    from jogai.texts import *
+    from jogai.videogame import *
+    from jogai.widgets import *
 
 if not isinstance(settings.LEVELBARS_MAX_PARTS, int):
-    raise TypeError(_('LEVELBARS_MAX_PARTS must be an integer.'))
+    raise TypeError(_('LEVELBARS_MAX_PARTS must be an integer'))
 
 if settings.LEVELBARS_MAX_PARTS <= 0:
-    raise ValueError(_('LEVELBARS_MAX_PARTS must be a positive integer.'))
+    raise ValueError(_('LEVELBARS_MAX_PARTS must be a positive integer'))
 
 pygame.init()
 pygame.font.init()
-_globals.mark_font = pygame.font.SysFont('FreeMono', 10)
+_globals.mark_font = pygame.font.SysFont(
+    settings.MARKS_FONT, settings.MARKS_SIZE
+)

+ 7 - 5
jogai/_globals.py

@@ -18,17 +18,15 @@
 # Zero! Factorial Studio
 # info@zero.gal
 
+from os.path import join
+
 import pygame
 
-from jogai import settings
+from jogai.settings import settings
 from jogai.utils import _load_yaml_from_filename
 
 _clock = pygame.time.Clock()
 
-_default_data = _load_yaml_from_filename(
-    settings.DEFAULT_DATA_FILENAME, directory=settings.BASE_DIR
-)
-
 character_focus = None
 _events = []
 _keys = []
@@ -38,7 +36,9 @@ _gravity = 0
 
 marks = None
 mark_font = None
+vg_title = 'Jogai'
 vg_scene = None
+vg_attributes = {}
 vg_characters = []
 vg_chronometers = []
 vg_groups = []
@@ -46,3 +46,5 @@ vg_levelbars = []
 vg_scoreboards = []
 vg_texts = []
 visible_inventory = None
+environments = {}
+current_environment = ''

Dosya farkı çok büyük olduğundan ihmal edildi
+ 675 - 166
jogai/character.py


+ 0 - 20
jogai/default_data.yaml

@@ -1,20 +0,0 @@
-scene: test_large.png
-characters:
-  protagonist:
-    speed: 10
-    height: 50
-    position: [200, 200]
-    keys:
-      forward: RIGHT
-      backward: LEFT
-      up: UP
-      down: DOWN
-  secondary:
-    speed: 5
-    width: 150
-    position: [400, 200]
-    keys:
-      forward: RIGHT
-      backward: LEFT
-      up: UP
-      down: DOWN

+ 2 - 2
jogai/exceptions.py

@@ -28,11 +28,11 @@ class UnsupportedFileFormatError(Exception):
         self.msg = msg
 
     def __str__(self):
-        return f"{_('Unsupported file format')}: {self.msg}"
+        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')
+        return _('the key pressed was not found')

+ 18 - 0
jogai/exporter/assets/base.css

@@ -0,0 +1,18 @@
+#canvas {
+  position: absolute;
+  width: {{ window_size.0 }}px;
+  height: {{ window_size.1 }}px;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+
+  border: 3px red solid;
+}
+
+.{{ classnames.character }} {
+  visibility: hidden;
+}
+
+#current_scene {
+  visibility: hidden;
+}

+ 21 - 0
jogai/exporter/assets/base.html

@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html lang="{{ language }}" dir="ltr">
+  <head>
+    <meta charset="utf-8">
+    <title></title>
+    <link rel="stylesheet" href="{{ css_path }}">
+  </head>
+  <body>
+    <h1>{{ title }}</h1>
+    <canvas id="canvas"></canvas>
+    <img id="current_scene" src="{{ current_scene.image_path }}" />
+    {% for character in characters %}
+      <img id="{{ character.name }}" class="{{ classnames.character }}" src="{{ character.image_path }}">
+    {% endfor %}
+
+    <script type="text/javascript" src="{{ js_dir }}/scenes.js"></script>
+    <script type="text/javascript" src="{{ js_dir }}/characters.js"></script>
+    <script type="text/javascript" src="{{ js_dir }}/videogame.js"></script>
+
+  </body>
+</html>

+ 64 - 0
jogai/exporter/assets/character.js

@@ -0,0 +1,64 @@
+
+
+class {{ classnames.character }} {
+  constructor(image_filename, name, animation_filenames) {
+    this.angle = 0;
+    this.bouncy = false;
+    this.speech_bubble = null;
+    this.collidable = {{ settings.CHARACTER_DEFAULT_COLLIDABLE | lower }};
+    this.gravitative = {{ settings.CHARACTER_DEFAULT_GRAVITATIVE | lower }};
+    this.jump_counter = {{ settings.CHARACTER_DEFAULT_JUMP_STEPS }};
+    this.jump_force = {{ settings.CHARACTER_DEFAULT_JUMP_FORCE }};
+    this.jump_steps = {{ settings.CHARACTER_DEFAULT_JUMP_STEPS }};
+    this.jump_in_the_air = {{ settings.CHARACTER_DEFAULT_JUMP_IN_THE_AIR | lower }};
+    this.jumping = false;
+    this.lives = null;
+    this.name = name;
+    this.original_image = null;
+    this.original_rect = null;
+    this.scale = 1.0;
+    this.scoreboard = null;
+    this.speaking = false;
+    this.speed = 0;
+    this.touchable = {{ settings.CHARACTER_DEFAULT_TOUCHABLE | lower }};
+    this.animation_filenames = {};
+    this.chronometer = null;
+    this.current_collisions = [];
+    //this.direction = Direction()
+    // this.flipped = {_('horizontal'): false, _('vertical'): false}
+
+    // this.inventory = eval(_('Inventory'))(name)
+    this.predefined_keys = {};
+    this.rect = null;
+    this.thumbnail = null;
+    this.thumbnail_rect = null;
+    this.x = 0;
+    this.y = 0;
+    change_image(image_filename);
+
+    /*
+    super().__init__()
+        self.load_image(image_filename)
+        if not animation_filenames and name:
+            animation_filenames = {
+                action: f'{name}_{action}.png'
+                for action in self._predefined_animations_allowed
+            }
+            animation_filenames.update({_('stop'): f'{name}.png'})
+        self.set_animations(animation_filenames)
+    */
+  }
+
+  change_image(filename) {
+    this.image_filename = filename;
+    this.image.src = {{ paths.CHARACTERS_DIR }}+"/"+filename;
+    //this.image = document.getElementById(name);
+    this.width = this.image.width;
+    this.height = this.image.height;
+
+  }
+
+  draw() {
+    ctx.drawImage(this.image, this.x, this.y, this.width, this.height);
+  }
+}

+ 27 - 0
jogai/exporter/assets/scene.js

@@ -0,0 +1,27 @@
+
+
+class {{ classnames.scene }} {
+  constructor(image_filename, background = {{ settings.SCENE_DEFAULT_BACKGROUND | list }}) {
+    change_background(background);
+    change_image(image_filename)
+    this.height = 0;
+    this.image_filename = image_filename;
+    this.image = null;
+    this.loaded_boundaries = [];
+    this.scene_boundaries = {{ scene.DEFAULT_BOUNDARIES }};
+    this.width = 0;
+  }
+
+  change_background(background) {
+    this.background = background;
+  }
+
+  change_image(filename) {
+    this.image_filename = filename;
+
+  }
+
+  draw() {
+    ctx.drawImage(this.image, this.x, this.y, this.width, this.height);
+  }
+}

+ 25 - 0
jogai/exporter/assets/videogame.js

@@ -0,0 +1,25 @@
+
+const canvas = document.getElementById('canvas');
+const ctx = canvas.getContext('2d');
+const WIDTH = canvas.width = {{ window_size.0 }};
+const HEIGHT = canvas.height = {{ window_size.1 }};
+
+{% for character in characters %}
+const {{ character.name }} = new {{ classnames.character }}("{{ character.image_filename }}", "{{ character.name }}", []);
+{% endfor %}
+
+
+function draw() {
+  ctx.clearRect(0, 0, canvas.width, canvas.height);
+  {% for character in characters %}
+  {{ character.name }}.draw();
+  {% endfor %}
+}
+
+
+function mainLoop() {
+  draw();
+  requestAnimationFrame(mainLoop);
+}
+
+mainLoop();

+ 95 - 0
jogai/exporter/exporter_utils.py

@@ -0,0 +1,95 @@
+from os.path import join
+from pathlib import Path
+from shutil import copytree, rmtree
+
+from jinja2 import Environment, FileSystemLoader
+
+import jogai._globals as _globals
+from jogai.settings import paths, settings
+from jogai.translations import gettext as _
+from jogai.videogame import load_environment, prepare
+
+CLASSNAMES = {
+    'character': _('character').capitalize(),
+    'scene': _('scene').capitalize(),
+}
+
+DIRS = {
+    'html': Path(f'{paths.EXPORT_MAIN_DIR}'),  # /{paths.EXPORT_HTML_SUBDIR}'),
+    'css': Path(f'{paths.EXPORT_MAIN_DIR}/{paths.EXPORT_CSS_SUBDIR}'),
+    'js': Path(f'{paths.EXPORT_MAIN_DIR}/{paths.EXPORT_JS_SUBDIR}'),
+}
+
+
+def create_dirs():
+    for dir_path in DIRS.values():
+        dir_path.mkdir(parents=True)
+    copytree(paths.IMAGES_DIR, paths.EXPORT_MAIN_DIR / paths.IMAGES_DIR)
+
+
+def write_file(filename: str, content: str):
+    filename = Path(filename)
+    file_path = DIRS[filename.suffix.removeprefix('.')] / filename
+    with file_path.open(mode='w', encoding='utf-8') as f:
+        f.write(content)
+
+
+def export(environment: str):
+    load_environment(environment, to_export=True)
+    window_size = (settings.WINDOW_WIDTH, settings.WINDOW_HEIGHT)
+
+    jinja_env = Environment(
+        loader=FileSystemLoader(
+            join(
+                settings.BASE_DIR,
+                paths.EXPORTER_TEMPLATES_PATH,
+            )
+        )
+    )
+
+    template = jinja_env.get_template('base.html')
+    html_content = template.render(
+        characters=_globals.vg_characters,
+        classnames=CLASSNAMES,
+        css_path=f'{paths.EXPORT_CSS_SUBDIR}/{environment}.css',
+        current_scene=_globals.vg_scene,
+        language=settings.LANGUAGE_CODE,
+        js_dir=f'{paths.EXPORT_JS_SUBDIR}',
+        title=_globals.vg_title,
+    )
+
+    template = jinja_env.get_template('base.css')
+    css_content = template.render(
+        classnames=CLASSNAMES,
+        window_size=window_size,
+    )
+
+    template = jinja_env.get_template('videogame.js')
+    js_videogame_content = template.render(
+        characters=_globals.vg_characters,
+        classnames=CLASSNAMES,
+        window_size=window_size,
+    )
+
+    template = jinja_env.get_template('scene.js')
+    js_scenes_content = template.render(
+        classnames=CLASSNAMES,
+        scene=_globals.vg_scene,
+        settings=settings,
+    )
+
+    template = jinja_env.get_template('character.js')
+    js_characters_content = template.render(
+        classnames=CLASSNAMES,
+        settings=settings,
+        paths=paths,
+    )
+
+    rmtree(paths.EXPORT_MAIN_DIR)
+    create_dirs()
+
+    write_file(f'{environment}.html', html_content)
+    write_file(f'{environment}.css', css_content)
+    write_file('videogame.js', js_videogame_content)
+    write_file('scenes.js', js_scenes_content)
+    write_file('characters.js', js_characters_content)

+ 0 - 0
jogai/exporter/videogame.py


+ 0 - 236
jogai/group.py

@@ -1,236 +0,0 @@
-# jogai - Python Game Library
-#
-# This library is free software; you can redistribute it and/or
-# modify it under the terms of the GNU Library General Public
-# License as published by the Free Software Foundation; either
-# version 2 of the License, or (at your option) any later version.
-#
-# This library is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-# Library General Public License for more details.
-#
-# You should have received a copy of the GNU Library General Public
-# License along with this library; if not, write to the Free
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
-#
-# Ramón Palleiro Rodríguez
-# Zero! Factorial Studio
-# info@zero.gal
-
-from random import randint
-from typing import Type
-
-import pygame
-
-from jogai.character import Character
-from jogai.translations import gettext as _
-
-
-class Group(pygame.sprite.Group):
-    """
-    Generic group of sprites.
-
-    Examples:
-        >>> g = Group()
-    """
-
-    def __init__(self, *args, **kwgargs):
-        super().__init__(*args, **kwgargs)
-
-
-class RandomGroup(Group):
-    """
-    Group of randomly distributed characters.
-
-    Parameters:
-        model: character model to repeat
-        amount: amount of characters to spread
-        xrange: tuple with the minimum and maximum on x axis
-        yrange: tuple with the minimum and maximum on y axis
-
-    Examples:
-        >>> protagonist = Character('protagonist.png')
-        >>> rg = RandomGroup(protagonist, 5, (100, 500), (0, 300))
-    """
-
-    def __init__(
-        self,
-        model: Type['Character'],
-        amount: int,
-        xrange: tuple,
-        yrange: tuple,
-    ):
-        # TODO: check model type
-        if not isinstance(amount, int):
-            raise TypeError(
-                _('The amount of characters must be a integer greater than 1.')
-            )
-        if amount < 2:
-            raise ValueError(
-                _('The amount of characters must be a integer greater than 1.')
-            )
-
-        if isinstance(xrange, list):
-            xrange = tuple(xrange)
-        if isinstance(yrange, list):
-            yrange = tuple(yrange)
-        if not isinstance(xrange, tuple) or not isinstance(yrange, tuple):
-            raise TypeError(_('Both x and y ranges must be tuples.'))
-        if len(xrange) != 2 or len(yrange) != 2:
-            raise ValueError(
-                _('Both x and y ranges must have only two items (min, max)')
-            )
-        if xrange[0] > xrange[1] or yrange[0] > yrange[1]:
-            raise ValueError(
-                _('Both x and y ranges must be sorted (min, max)')
-            )
-
-        self.amount = amount
-        self.xrange = xrange
-        self.yrange = yrange
-
-        characters_list = []
-        for pos in range(self.amount):
-            new_character = model.copy()
-            new_character.set_position(
-                (
-                    randint(*xrange),
-                    randint(*yrange),
-                )
-            )
-            characters_list.append(new_character)
-
-        super().__init__(characters_list)
-
-
-class RectangleGroup(Group):
-    """
-    Group of characters forming a rectangle.
-
-    Parameters:
-        model: character model to repeat
-        base: number of characters in the base
-        height: number of characters in the height
-
-    Examples:
-        >>> protagonist = Character('protagonist.png')
-        >>> rg = RectangleGroup(protagonist, 5, 3)
-    """
-
-    def __init__(self, model: Type['Character'], base: int, height: int):
-        # TODO: check model type
-        if not isinstance(base, int) or not isinstance(height, int):
-            raise TypeError(_('Both the base and rectangle must be integers.'))
-        if base < 1 or height < 1:
-            raise ValueError(
-                _('Both the base and rectangle must be positive.')
-            )
-        if base == 1 and height == 1:
-            raise ValueError(
-                _(
-                    'At least one of the dimensions of the rectangle must be greater than 1.'
-                )
-            )
-        self.base = base
-        self.height = height
-        self.amount = base * height
-
-        characters_list = []
-        for row in range(self.height):
-            for col in range(self.base):
-                if not characters_list:
-                    new_character = model
-                else:
-                    new_character = model.copy()
-                new_character.set_position(
-                    (
-                        model.x + model.width * col,
-                        model.y + model.height * row,
-                    )
-                )
-                characters_list.append(new_character)
-        super().__init__(characters_list)
-
-
-class SquareGroup(RectangleGroup):
-    """
-    Group of characters forming a square.
-
-    Parameters:
-        model: character model to repeat
-        side: number of characters by side
-
-    Examples:
-        >>> protagonist = Character('protagonist.png')
-        >>> sg = SquareGroup(protagonist, 3)
-    """
-
-    def __init__(self, model: Type['Character'], side: int):
-        # TODO: check model type
-        if not isinstance(side, int):
-            raise TypeError(_('The side of the square must be an integer.'))
-        if side < 2:
-            raise ValueError(
-                _('The side of the square must be greater than 1.')
-            )
-        self.side = side
-        super().__init__(model, side, side)
-
-
-class StairsGroup(Group):
-    """
-    Group of characters forming stairs.
-
-    Parameters:
-        model: character model to repeat
-        steps: number of steps in the stairs
-        horizontal_flip: True if flipped stairs in horizontal. False, otherwise
-        vertical_flip: True if flipped stairs in vertical. False, otherwise
-
-    Examples:
-        >>> protagonist = Character('protagonist.png')
-        >>> sg = StairsGroup(protagonist, 3)
-    """
-
-    def __init__(
-        self,
-        model: Type['Character'],
-        steps: int,
-        horizontal_flip: bool = False,
-        vertical_flip: bool = False,
-    ):
-        # TODO: check model type and adjust the position of elements
-        if not isinstance(steps, int):
-            raise TypeError(_('The steps of the stairs must be an integer.'))
-        if steps < 2:
-            raise ValueError(
-                _('The steps of the stairs must be greater than 1.')
-            )
-        if not isinstance(horizontal_flip, bool) or not isinstance(
-            vertical_flip, bool
-        ):
-            raise TypeError(
-                _('Both horizontal and vertical flips must be a boolean.')
-            )
-
-        self.steps = steps
-        self.amount = steps * (steps + 1) // 2
-        self.horizontal_flip = horizontal_flip
-        self.vertical_flip = vertical_flip
-
-        horizontal_sign = 1 if horizontal_flip else -1
-        vertical_sign = -1 if vertical_flip else 1
-        characters_list = [model]
-        for row in range(2, self.steps + 1):
-            for col in range(row):
-                new_character = model.copy()
-                new_character.name = model.name
-                new_character.set_position(
-                    (
-                        model.x + model.width * col * horizontal_sign,
-                        model.y + model.height * (row - 1) * vertical_sign,
-                    )
-                )
-                characters_list.append(new_character)
-        super().__init__(characters_list)

+ 354 - 0
jogai/groups.py

@@ -0,0 +1,354 @@
+# jogai - Python Game Library
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Library General Public
+# License as published by the Free Software Foundation; either
+# version 2 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Library General Public License for more details.
+#
+# You should have received a copy of the GNU Library General Public
+# License along with this library; if not, write to the Free
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+# Ramón Palleiro Rodríguez
+# Zero! Factorial Studio
+# info@zero.gal
+
+from random import randint
+from types import MethodType
+from typing import Type
+
+import pygame
+
+from jogai import _globals, utils
+from jogai.character import Character
+from jogai.translations import gettext as _
+
+__all__ = [
+    'Group',
+    'RandomGroup',
+    'RectangleGroup',
+    'SquareGroup',
+    'StairsGroup',
+]
+
+
+def _set_action_for_group(characters_group, *args, action='', **kwargs):
+    for vg_character in characters_group:
+        args_string = utils.tuple_to_args_string(args)
+        kwargs_string = utils.dict_to_kwargs_string(kwargs)
+        eval(
+            _('vg_character.{}({}, {})').format(
+                action, args_string, kwargs_string
+            )
+        )
+
+
+def _set_attribute_for_group(characters_group, value, attribute=''):
+    for vg_character in characters_group:
+        eval(_('vg_character.set_{}({})').format(attribute, value))
+
+
+class Group(pygame.sprite.Group):
+    """
+    Generic group of sprites.
+
+    Examples:
+        >>> test_group = Group('protagonists', protagonist)
+    """
+
+    _predefined_actions_allowed = (
+        _('backward'),
+        _('decrease'),
+        _('down'),
+        _('flip'),
+        _('forward'),
+        _('increase'),
+        _('move'),
+        _('jump'),
+        _('up'),
+    )
+
+    def __contains__(self, vg_character):
+        return vg_character in self._characters
+
+    def __getitem__(self, key):
+        return self.characters[key]
+
+    def __init__(self, name: str, characters: list, *args, **kwargs):
+        if not isinstance(name, str):
+            raise TypeError(_('group name must be a string'))
+        if not isinstance(characters, list):
+            raise TypeError(_('characters in group must be a list'))
+
+        self._name = name
+        self._characters = characters
+
+        self._load_attributes()
+        self._load_actions()
+
+        super().__init__(*args, **kwargs)
+
+    def __iter__(self):
+        return iter(self._characters)
+
+    @property
+    def characters(self):
+        """list of group's characters"""
+        return self._characters
+
+    @property
+    def name(self) -> str:
+        """group's name"""
+        return self._name
+
+    def _load_actions(self):
+        for action in self._predefined_actions_allowed:
+
+            def make_action(action):
+                def method(*args, **kwargs):
+                    return _set_action_for_group(
+                        self.characters,
+                        *args,
+                        action=action,
+                        **kwargs,
+                    )
+
+                method.__name__ = action
+                return method
+
+            setattr(self, action, make_action(action))
+
+    def _load_attributes(self):
+        for attribute in Character._predefined_attributes_allowed:
+            name = _('set_{}').format(attribute)
+
+            def make_attribute(attribute):
+                def method(value):
+                    return _set_attribute_for_group(
+                        self.characters, value, attribute=attribute
+                    )
+
+                method.__name__ = name
+                return method
+
+            setattr(self, name, make_attribute(attribute))
+
+    def remove(self, character):
+        if character in self.characters:
+            self.characters.remove(character)
+
+
+class RandomGroup(Group):
+    """
+    Group of randomly distributed characters.
+
+    Parameters:
+        name: name of this group
+        model: character model to repeat
+        amount: amount of characters to spread
+        x_range: tuple with the minimum and maximum on x axis
+        y_range: tuple with the minimum and maximum on y axis
+
+    Examples:
+        >>> protagonist = Character('protagonist.png')
+        >>> rg = RandomGroup(protagonist, 5, (100, 500), (0, 300))
+    """
+
+    def __init__(
+        self,
+        name: str,
+        model: Type[_('Character')],
+        amount: int,
+        x_range: tuple = (),
+        y_range: tuple = (),
+    ):
+        # TODO: check model type
+        if not type(amount) == int:
+            raise TypeError(
+                _('the amount of characters must be a integer greater than 1')
+            )
+        if amount < 2:
+            raise ValueError(
+                _('the amount of characters must be a integer greater than 1')
+            )
+
+        if not x_range:
+            x_range = (0, _globals.vg_scene.width - model.width)
+
+        if not y_range:
+            y_range = (0, _globals.vg_scene.height - model.height)
+
+        if isinstance(x_range, list):
+            x_range = tuple(x_range)
+        if isinstance(y_range, list):
+            y_range = tuple(y_range)
+
+        if not isinstance(x_range, tuple) or not isinstance(y_range, tuple):
+            raise TypeError(_('both x and y ranges must be tuples'))
+
+        if len(x_range) != 2 or len(y_range) != 2:
+            raise ValueError(
+                _('both x and y ranges must have only two items (min, max)')
+            )
+        if x_range[0] > x_range[1] or y_range[0] > y_range[1]:
+            raise ValueError(
+                _('both x and y ranges must be sorted (min, max)')
+            )
+
+        self.amount = amount
+        self.x_range = x_range
+        self.y_range = y_range
+
+        characters_list = []
+        for pos in range(self.amount):
+            new_character = model.copy()
+            new_character.set_position(
+                (
+                    randint(*x_range),
+                    randint(*y_range),
+                )
+            )
+            characters_list.append(new_character)
+        super().__init__(name, characters_list)
+
+
+class RectangleGroup(Group):
+    """
+    Group of characters forming a rectangle.
+
+    Parameters:
+        name: name of this group
+        model: character model to repeat
+        base: number of characters in the base
+        height: number of characters in the height
+
+    Examples:
+        >>> protagonist = Character('protagonist.png')
+        >>> rg = RectangleGroup(protagonist, 5, 3)
+    """
+
+    def __init__(
+        self, name: str, model: Type['Character'], base: int, height: int
+    ):
+        # TODO: check model type
+        if not type(base) == int or not type(height) == int:
+            raise TypeError(_('both the base and the height must be integers'))
+        if base < 1 or height < 1:
+            raise ValueError(
+                _('both the base and the height must be positive')
+            )
+        if base == 1 and height == 1:
+            raise ValueError(
+                _(
+                    'at least one of the dimensions of the rectangle must be greater than 1'
+                )
+            )
+        self.base = base
+        self.height = height
+        self.amount = base * height
+
+        characters_list = []
+        for row in range(self.height):
+            for col in range(self.base):
+                if not characters_list:
+                    new_character = model
+                else:
+                    new_character = model.copy()
+                new_character.set_position(
+                    (
+                        model.x + model.width * col,
+                        model.y + model.height * row,
+                    )
+                )
+                characters_list.append(new_character)
+        super().__init__(name, characters_list)
+
+
+class SquareGroup(RectangleGroup):
+    """
+    Group of characters forming a square.
+
+    Parameters:
+        name: name of this group
+        model: character model to repeat
+        side: number of characters by side
+
+    Examples:
+        >>> protagonist = Character('protagonist.png')
+        >>> sg = SquareGroup(protagonist, 3)
+    """
+
+    def __init__(self, name: str, model: Type['Character'], side: int):
+        # TODO: check model type
+        if not type(side) == int:
+            raise TypeError(_('the side of the square must be an integer'))
+        if side < 2:
+            raise ValueError(
+                _('the side of the square must be greater than 1')
+            )
+        self.side = side
+        super().__init__(name, model, side, side)
+
+
+class StairsGroup(Group):
+    """
+    Group of characters forming stairs.
+
+    Parameters:
+        name: name of this group
+        model: character model to repeat
+        steps: number of steps in the stairs
+        horizontal_flip: True if flipped stairs in horizontal. False, otherwise
+        vertical_flip: True if flipped stairs in vertical. False, otherwise
+
+    Examples:
+        >>> protagonist = Character('protagonist.png')
+        >>> sg = StairsGroup(protagonist, 3)
+    """
+
+    def __init__(
+        self,
+        name: str,
+        model: Type['Character'],
+        steps: int,
+        horizontal_flip: bool = False,
+        vertical_flip: bool = False,
+    ):
+        # TODO: check model type and adjust the position of elements
+        if not type(steps) == int:
+            raise TypeError(_('the steps of the stairs must be an integer'))
+        if steps < 2:
+            raise ValueError(
+                _('the steps of the stairs must be greater than 1')
+            )
+        if not isinstance(horizontal_flip, bool) or not isinstance(
+            vertical_flip, bool
+        ):
+            raise TypeError(
+                _('both horizontal and vertical flips must be a boolean')
+            )
+
+        self.steps = steps
+        self.amount = steps * (steps + 1) // 2
+        self.horizontal_flip = horizontal_flip
+        self.vertical_flip = vertical_flip
+
+        horizontal_sign = 1 if horizontal_flip else -1
+        vertical_sign = -1 if vertical_flip else 1
+        characters_list = [model]
+        for row in range(2, self.steps + 1):
+            for col in range(row):
+                new_character = model.copy()
+                new_character.set_position(
+                    (
+                        model.x + model.width * col * horizontal_sign,
+                        model.y + model.height * (row - 1) * vertical_sign,
+                    )
+                )
+                characters_list.append(new_character)
+        super().__init__(name, characters_list)

+ 83 - 0
jogai/locale/gl/escenario.py

@@ -0,0 +1,83 @@
+from typing import Tuple
+
+from jogai.scene import Scene
+from jogai.settings import settings
+
+__all__ = ['Escenario']
+
+
+class Escenario(Scene):
+    """
+    Un escenario de videoxogo con unha cor de fondo e unha imaxe opcional.
+    """
+
+    def __init__(
+        self,
+        ficheiro_imaxe: str = '',
+        fondo: Tuple[int, int, int] = settings.SCENE_DEFAULT_BACKGROUND,
+    ):
+        super().__init__(ficheiro_imaxe, fondo)
+
+    @property
+    def altura(self) -> int:
+        """altura do escenario en pixeis"""
+        return self.height
+
+    @property
+    def camiño_imaxe(self) -> str:
+        """camiño ao ficheiro de imaxe do escenario"""
+        self.image_path
+
+    @property
+    def ficheiro_imaxe(self) -> str:
+        """ficheiro de imaxe do escenario"""
+        self.image_filename
+
+    @property
+    def largura(self) -> int:
+        """largura do escenario en pixeis"""
+        return self.width
+
+    def mudar_fondo(self, fondo: Tuple[int, int, int]):
+        """
+        Muda a cor de fondo.
+
+        Parameters:
+            fondo: unha tupla coas componentes de cor (R, G, B)
+
+        Examples:
+            >>> esc = Escenario()
+            >>> esc.mudar_fondo((255,255,255))
+        """
+        self.change_background(fondo)
+
+    def mudar_imaxe(self, ficheiro: str) -> bool:
+        """
+        Muda a imaxe do escenario.
+
+        Parameters:
+            ficheiro: o nome ficheiro de imaxe a mudar
+
+        Examples:
+            >>> esc = Escenario()
+            >>> esc.mudar_imaxe('test.png')
+        """
+        self.change_image(ficheiro)
+
+    def definir_bordos(bordos: bool | list):
+        """
+        Define os límites dentro dos que se poden situar as personaxes.
+
+        Parameters:
+            bordos: unha lista cos bordos en píxeis (enteiros) ou booleanos (arriba, dereita, abaixo, esquerda). Tamén se pode usar un único booleano para activar todos os bordos do escenario.
+
+        Examples:
+            >>> esc = Escenario()
+            >>> esc.definir_bordos([False, True, 500, 100]) # teito sen limite, lado dereito con límite de escenario, chan a 500px, lado esquerdo a 100px
+            >>> esc.definir_bordos(True) # Os limites serán os bordos do escenario
+        """
+        self.set_boundaries(bordos)
+
+    def obter_cor_de_fondo(self, x: int, y: int):
+        """ """
+        return self.get_background_color(x, y)

+ 122 - 0
jogai/locale/gl/ferramentas.py

@@ -0,0 +1,122 @@
+from typing import Type
+
+from jogai.settings import settings
+from jogai.widgets import (
+    Inventory,
+    LevelBar,
+)
+
+__all__ = ['Inventario', 'BarraDeNivel']
+
+
+class Inventario(Inventory):
+    """Un inventario para o videoxogo."""
+
+    def __init__(
+        self,
+        nome,
+        cor_fondo=settings.INVENTORY_DEFAULT_BACKGROUND_COLOR,
+        cor_texto=settings.INVENTORY_DEFAULT_TEXT_COLOR,
+        tipografía='',
+        tamaño=settings.INVENTORY_DEFAULT_FONT_SIZE,
+    ):
+        super().__init__(nome, cor_fondo, cor_texto, tipografía, tamaño)
+
+    @property
+    def elementos(self):
+        """elementos do inventario"""
+        return self.items
+
+    @property
+    def nome(self):
+        """nome deste inventario"""
+        return self.name
+
+    def contar(self, personaxe: Type[_('Character')] | None = None):
+        """Conta o número de personaxes neste inventario"""
+        return self.count(personaxe)
+
+    def engadir(self, elemento: _('Character')):
+        """Engadir unha personaxe a este inventario."""
+        self.add(elemento)
+
+    def mostrar(self):
+        """Mostrar este inventario na xanela do videoxogo."""
+        self.show()
+
+    def ocultar(self):
+        """Ocultar este inventario da xanela do videoxogo."""
+        self.hide()
+
+
+class BarraDeNivel(LevelBar):
+    """Unha barra de nivel para o videoxogo"""
+
+    def __init__(
+        self,
+        partes: int,
+        nivel_inicial: int = None,
+        posición: tuple | list = settings.LEVELBARS_DEFAULT_POSITION,
+        cor_borde: tuple | list = settings.LEVELBARS_DEFAULT_BORDER_COLOR,
+        cor_interior: tuple | list = settings.LEVELBARS_DEFAULT_FILL_COLOR,
+    ):
+        super().__init__(
+            partes, nivel_inicial, posición, cor_borde, cor_interior
+        )
+
+    @property
+    def nivel(self) -> int:
+        """o nivel actual desta barra"""
+        return self.level
+
+    def diminuir(self, decremento: int):
+        """
+        Diminúe o nivel desta barra.
+
+        Parameters:
+            decremento: cantidade a diminuir
+
+        Examples:
+            >>> depósito = BarraDeNivel(5, 3)
+            >>> depósito.diminuir(1)
+        """
+        self.decrease(decremento)
+
+    def incrementar(self, incremento: int):
+        """
+        Incrementa o nivel desta barra.
+
+        Parameters:
+            incremento: cantidade a incrementar
+
+        Examples:
+            >>> depósito = BarraDeNivel(5, 3)
+            >>> depósito.incrementar(1)
+        """
+        self.increase(aumento)
+
+    def definir_nivel(self, nivel: int):
+        """
+        Define un novo nivel para esta barra.
+
+        Parameters:
+            nivel: o novo nivel
+
+        Examples:
+            >>> depósito = BarraDeNivel(5)
+            >>> depósito.definir_nivel(3)
+        """
+        self.set_level(nivel)
+
+    def definir_posición(self, posición: tuple | list):
+        """
+        Set a new position for this level bar.
+
+        Parameters:
+            posición: a tuple with abscissa and ordinate coordinates
+
+        Examples:
+            >>> depósito = BarraDeNivel(5)
+            >>> depósito.definir_posición((200, 200))
+        """
+        self.set_position(posición)

+ 773 - 0
jogai/locale/gl/gl.po

@@ -0,0 +1,773 @@
+# Galician translations for PACKAGE package.
+# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# mdo <info@zero.gal>, 2026.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2026-01-22 09:44+0000\n"
+"PO-Revision-Date: 2026-01-22 09:45+0000\n"
+"Last-Translator: mdo <info@zero.gal>\n"
+"Language-Team: Galician <proxecto@trasno.gal>\n"
+"Language: gl\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+#: ../exceptions.py:38
+msgid "the key pressed was not found"
+msgstr "a tecla premida non foi encontrada"
+
+#: ../videogame.py:36
+msgid "caption"
+msgstr "título"
+
+#: ../videogame.py:37
+msgid "gravity"
+msgstr "gravidade"
+
+#: ../videogame.py:38
+msgid "marks"
+msgstr "marcas"
+
+#: ../videogame.py:76
+msgid "the attributes in the input schema must be a dict"
+msgstr "os atributos no esquema de entrada deben ser un dicionario"
+
+
+#: ../videogame.py:136
+msgid "only Character instances can be added"
+msgstr "apenas instancias Personaxe poden ser engadidas"
+
+#: ../videogame.py:152
+msgid "only Chronometer instances can be added"
+msgstr "apenas instancias Cronómetro poden ser engadidas"
+
+#: ../videogame.py:169
+msgid "only Group instances can be added"
+msgstr "apenas instancias Grupo poden ser engadidas"
+
+#: ../videogame.py:188
+msgid "only LevelBar instances can be added"
+msgstr "apenas instancias BarraDeNivel poden ser engadidas"
+
+#: ../videogame.py:204
+msgid "only Scoreboard instances can be added"
+msgstr "apenas instancias Marcador poden ser engadidas"
+
+#: ../videogame.py:220
+msgid "only Text instances can be added"
+msgstr "apenas instancias Texto poden ser engadidas"
+
+#: ../videogame.py:288 ../videogame.py:294
+msgid "the coordinates of the point must be a non-negative integers"
+msgstr "as coordenadas do punto deben ser números enteiros non negativos"
+
+#: ../videogame.py:363
+msgid "the caption for the videogame must be a string"
+msgstr "o título do videoxogo debe ser unha cadea de texto"
+
+#: ../videogame.py:378
+msgid "the gravity must be an integer"
+msgstr "a gravidade debe ser un número enteiro"
+
+#: ../videogame.py:421
+msgid "the environment name does not exist"
+msgstr "o nome do ambiente non existe"
+
+#: ../videogame.py:459
+msgid "current_scene"
+msgstr "escenario_actual"
+
+#: ../widgets.py:79
+msgid "INVENTORY [{}]"
+msgstr "INVENTARIO [{}]"
+
+#: ../widgets.py:135
+msgid "the amount of parts must be an integer"
+msgstr "a cantidade de partes debe ser un número enteiro"
+
+#: ../widgets.py:138
+msgid "the amount of parts must be between 1 and {}"
+msgstr "a cantidade de partes debe estar entre 1 e {}"
+
+#: ../widgets.py:242
+msgid "the decrease level must be an integer"
+msgstr "o nivel de decremento debe ser un número enteiro"
+
+#: ../widgets.py:244
+msgid "the decrease cannot be less than zero"
+msgstr "o decremento non pode ser menor do que cero"
+
+#: ../widgets.py:259
+msgid "the increase level must be an integer"
+msgstr "o nivel de incremento debe ser un número enteiro"
+
+#: ../widgets.py:261
+msgid "the increase cannot exceed the maximum level"
+msgstr "o incremento non pode exceder o nivel máximo"
+
+#: ../widgets.py:276
+msgid "the level must be an integer"
+msgstr "o nivel debe ser un número enteiro"
+
+#: ../widgets.py:280
+msgid "the level range must be between zero and the number of parts"
+msgstr "o intervalo de nivel debe estar entre cero e o número de partes"
+
+#: ../widgets.py:299 ../character.py:402 ../character.py:442
+msgid "the position must be a tuple or a list with the coordinates (x, y)"
+msgstr "a posición debe ser unha tuple ou unha lista coas coordenadas (x, y)"
+
+#: ../widgets.py:307 ../character.py:410 ../character.py:450
+msgid ""
+"the position must be a tuple or a list of integers with the coordinates (x, "
+"y)"
+msgstr ""
+"a posición debe ser unha tuple ou unha lista de números enteiros coas "
+"coordenadas (x, y)"
+
+#: ../__init__.py:40
+msgid "character"
+msgstr "personaxe"
+
+#: ../__init__.py:43
+msgid "groups"
+msgstr "grupos"
+
+#: ../__init__.py:46 ../scene.py:45 ../scene.py:46
+msgid "scene"
+msgstr "escenario"
+
+#: ../__init__.py:49
+msgid "texts"
+msgstr "textos"
+
+#: ../__init__.py:52
+msgid "videogame"
+msgstr "videoxogo"
+
+#: ../__init__.py:55
+msgid "widgets"
+msgstr "ferramentas"
+
+#: ../__init__.py:66
+msgid "LEVELBARS_MAX_PARTS must be an integer"
+msgstr "BARRADENIVEL_MAX_PARTES debe ser un número enteiro"
+
+#: ../__init__.py:69
+msgid "LEVELBARS_MAX_PARTS must be a positive integer"
+msgstr "BARRADENIVEL_MAX_PARTES debe ser un número enteiro positivo"
+
+#: ../utils.py:42
+msgid "both the directory and the filename must be strings"
+msgstr "tanto o directorio como o nome do ficheiro deben ser cadeas de texto"
+
+#: ../utils.py:49
+msgid "image filename not found: {}"
+msgstr "nome do ficheiro de imaxe non encontrado: {}"
+
+#: ../scene.py:44
+msgid "Scene"
+msgstr "Escenario"
+
+#: ../scene.py:47 ../scene.py:48
+msgid "background"
+msgstr "fondo"
+
+#: ../scene.py:49 ../scene.py:50
+msgid "boundaries"
+msgstr "bordos"
+
+#: ../scene.py:67
+msgid "the image filename must be a string"
+msgstr "o nome do ficheiro de imaxe debe ser unha cadea de texto"
+
+#: ../scene.py:124
+msgid "the x coordinate must be an integer"
+msgstr "a coordenada x debe ser un número enteiro"
+
+#: ../scene.py:158
+msgid ""
+"the background color must be a tuple with three integers (r,g,b) between 0 "
+"and 255"
+msgstr ""
+"a cor de fondo debe ser unha tupla con tres números enteiros (r,g,b) entre 0 "
+"e 255"
+
+#: ../scene.py:201
+msgid "the scene boundaries must be a list or a boolean"
+msgstr "os bordos do escenario deben ser unha lista ou un booleano"
+
+#: ../character.py:40 ../character.py:47
+msgid "random"
+msgstr "aleatorio"
+
+#: ../character.py:40
+msgid "RandomGroup"
+msgstr "GrupoAleatorio"
+
+#: ../character.py:41 ../character.py:48
+msgid "rectangle"
+msgstr "rectángulo"
+
+#: ../character.py:41
+msgid "RectangleGroup"
+msgstr "GrupoRectángulo"
+
+#: ../character.py:42 ../character.py:49
+msgid "square"
+msgstr "cadrado"
+
+#: ../character.py:42
+msgid "SquareGroup"
+msgstr "GrupoCadrado"
+
+#: ../character.py:43 ../character.py:50
+msgid "stairs"
+msgstr "escadas"
+
+#: ../character.py:43
+msgid "StairsGroup"
+msgstr "GrupoEscadas"
+
+#: ../character.py:59
+msgid "the arguments for the {} group must be {}"
+msgstr "os argumentos para o grupo {} deben ser {}"
+
+#: ../character.py:84 ../character.py:86
+msgid "characters"
+msgstr "personaxes"
+
+#: ../character.py:91 ../character.py:92
+msgid "filename"
+msgstr "ficheiro"
+
+#: ../character.py:96 ../groups.py:59
+msgid "Character"
+msgstr "Personaxe"
+
+#: ../character.py:127
+msgid "top"
+msgstr "superior"
+
+#: ../character.py:128
+msgid "bottom"
+msgstr "inferior"
+
+#: ../character.py:129
+msgid "left"
+msgstr "esquerda"
+
+#: ../character.py:130
+msgid "right"
+msgstr "dereita"
+
+#: ../character.py:133 ../character.py:160
+msgid "backward"
+msgstr "retroceder"
+
+#: ../character.py:134 ../character.py:162
+msgid "down"
+msgstr "baixar"
+
+#: ../character.py:135 ../character.py:163
+msgid "forward"
+msgstr "avanzar"
+
+#: ../character.py:136 ../character.py:219 ../character.py:1528
+#: ../character.py:1530
+msgid "stop"
+msgstr "parar"
+
+#: ../character.py:137 ../character.py:166 ../character.py:724
+#: ../character.py:726
+msgid "jump"
+msgstr "saltar"
+
+#: ../character.py:138 ../character.py:167
+msgid "up"
+msgstr "subir"
+
+#: ../character.py:142
+msgid "bounce"
+msgstr "rebotar"
+
+#: ../character.py:143
+msgid "bouncy"
+msgstr "rebotante"
+
+#: ../character.py:144
+msgid "collidable"
+msgstr "colidíbel"
+
+#: ../character.py:145
+msgid "chronometer"
+msgstr "cronómetro"
+
+#: ../character.py:146
+msgid "gravitative"
+msgstr "gravitacional"
+
+#: ../character.py:147
+msgid "height"
+msgstr "altura"
+
+#: ../character.py:147
+msgid "inventory"
+msgstr "inventario"
+
+#: ../character.py:148
+msgid "jump_in_the_air"
+msgstr "salto_no_ar"
+
+#: ../character.py:149
+msgid "keys"
+msgstr "teclas"
+
+#: ../character.py:150
+msgid "lives"
+msgstr "vidas"
+
+#: ../character.py:151 ../texts.py:215 ../texts.py:216 ../texts.py:367
+#: ../texts.py:368 ../texts.py:455 ../texts.py:456
+msgid "position"
+msgstr "posición"
+
+#: ../character.py:152
+msgid "scale"
+msgstr "escala"
+
+#: ../character.py:153
+msgid "score"
+msgstr "puntos"
+
+#: ../character.py:154
+msgid "speed"
+msgstr "velocidade"
+
+#: ../character.py:155
+msgid "touchable"
+msgstr "tanxíbel"
+
+#: ../character.py:156
+msgid "width"
+msgstr "largura"
+
+#: ../character.py:161
+msgid "decrease"
+msgstr "diminuir"
+
+#: ../character.py:164
+msgid "increase"
+msgstr "aumentar"
+
+#: ../character.py:165
+msgid "show_inventory"
+msgstr "mostrar_inventario"
+
+#: ../character.py:177
+msgid "the character name must be a string"
+msgstr "o nome do personaxe debe ser unha cadea de texto"
+
+#: ../character.py:201
+msgid "horizontal"
+msgstr "horizontal"
+
+#: ../character.py:201
+msgid "vertical"
+msgstr "vertical"
+
+#: ../character.py:204 ../character.py:1036
+msgid "Inventory"
+msgstr "Inventario"
+
+#: ../character.py:288
+msgid "no scoreboard or points were defined"
+msgstr "ningún marcador ou puntuación foi definida"
+
+#: ../character.py:339
+msgid "the data input must be a dict"
+msgstr "os datos de entrada deben ser un dicionario"
+
+#: ../character.py:344
+#: ../videogame.py:84
+msgid "set_{}({})"
+msgstr "definir_{}({})"
+
+#: ../character.py:416
+msgid "self._set_initial_{}({})"
+msgstr "self._definir_{}_inicial({})"
+
+#: ../character.py:422
+msgid "self.set_{}({})"
+msgstr "self.definir_{}({})"
+
+#: ../character.py:349
+msgid "the function name must be a string"
+msgstr "o nome da función debe ser unha cadea de texto"
+
+#: ../character.py:482
+msgid "only Character instances are allowed to calculate distance"
+msgstr "apenas as instancias de tipo Personaxe poden calcular distancias"
+
+#: ../character.py:518
+msgid "amount to move ​​must be an integer"
+msgstr "a cantidade a mover debe ser un número enteiro"
+
+#: ../character.py:521 ../character.py:700 ../character.py:1384
+#: ../character.py:1451
+msgid "key pressed must be a string"
+msgstr "a tecla premida debe ser unha cadea de texto"
+
+#: ../character.py:542
+msgid "the {}'s move cannot be performed; it's outside the boundary conditions"
+msgstr "O movemento de {} non pode ser realizada; está fóra das condicións de contorno."
+
+#: ../character.py:694
+msgid "the jump force must be a float number"
+msgstr "a forza do salto debe ser un número flutuante"
+
+#: ../character.py:697
+msgid "the jump force must be a non-negative float"
+msgstr "a forza do salto debe ser un número flutuante non negativo"
+
+#: ../character.py:706 ../character.py:1050
+msgid "in_the_air must be a boolean"
+msgstr "no_ar debe ser un booleano"
+
+#: ../character.py:742
+msgid "the rotation increment must be an integer"
+msgstr "o incremento da rotación debe ser un número enteiro"
+
+#: ../character.py:788
+msgid "the decrement must be a float number"
+msgstr "o decremento debe ser un número flutuante"
+
+#: ../character.py:808
+msgid "the increment must be a float number"
+msgstr "o incremento debe ser un número flutuante"
+
+#: ../character.py:843
+msgid "both the horizontal and vertical axis must be booleans"
+msgstr "ambos os eixos, vertical e horizontal, deben ser booleanos"
+
+#: ../character.py:867
+msgid "the rotation angle must be an integer"
+msgstr "o ángulo de rotación debe ser un número enteiro"
+
+#: ../character.py:913
+msgid "the animations must be a dict"
+msgstr "as animacións deben ser un dicionario"
+
+#: ../character.py:942
+msgid "the bouncy argument must be a boolean"
+msgstr "o argumento rebotante debe ser un booleano"
+
+#: ../character.py:957
+msgid "time must be a integer"
+msgstr "o tempo debe ser un número enteiro"
+
+#: ../character.py:960
+msgid "Chronometer"
+msgstr "Cronómetro"
+
+#: ../character.py:983
+msgid "the collidable argument must be a boolean"
+msgstr "o argumento colidíbel debe ser un booleano"
+
+#: ../character.py:998
+msgid "the gravitative argument must be a boolean"
+msgstr "o argumento gravitacional debe ser un booleano"
+
+#: ../character.py:1013 ../character.py:1015
+msgid "height must be a non-negative integer"
+msgstr "a altura debe ser un número enteiro non negativo"
+
+#: ../character.py:1065
+msgid "LevelBar"
+msgstr "BarraDeNivel"
+
+#: ../character.py:1083
+msgid "the predefined keys must be a dict. No keys are loaded"
+msgstr ""
+"as teclas predefinidas deben ser un dicionario. "
+"Ningunha tecla foi carregada"
+
+#: ../character.py:1107 ../texts.py:117
+msgid "the position must be a tuple or list (x, y)"
+msgstr "a posición debe ser unha tupla ou unha lista (x, y)"
+
+#: ../character.py:1110 ../texts.py:119
+msgid "the position has two coordinates (x, y)"
+msgstr "a posición ten dúas coordenadas (x, y)"
+
+#: ../character.py:1115
+msgid ""
+"the {} position is not within the established boundary conditions. Try "
+"another position or remove the boundary conditions"
+msgstr ""
+"a posición de {} non está dentro das condicións de limite establecidas. Tenta "
+"outra posición ou elimina as limitacións dos bordos"
+
+#: ../character.py:1138
+msgid "the scale factor must be a positive number (float or int)"
+msgstr "o factor de escala debe ser un número positivo (float ou int)"
+
+#: ../character.py:1143
+msgid "the scale factor must be a non-negative number (float or int)"
+msgstr "o factor de escala debe ser un número non negativo (float ou int)"
+
+#: ../character.py:1171 ../texts.py:403
+msgid "the score must be an integer"
+msgstr "os puntos deben ser un número enteiro"
+
+#: ../character.py:1174
+msgid "Scoreboard"
+msgstr "Marcador"
+
+#: ../character.py:1199
+msgid "the speed must be an integer"
+msgstr "a velocidade debe ser un número enteiro"
+
+#: ../character.py:1214
+msgid "the touchable argument must be a boolean"
+msgstr "o argumento tanxíbel debe ser un booleano"
+
+#: ../character.py:1214 ../character.py:1216
+msgid "width must be a non-negative integer"
+msgstr "a largura debe ser un número enteiro non negativo"
+
+#: ../character.py:1238 ../character.py:1254
+msgid "the amount of lives must be an integer"
+msgstr "a cantidade de vidas debe ser un número enteiro"
+
+#: ../character.py:1301
+msgid "the message to be spoken by {} must be a string"
+msgstr "a mensaxe a dicir por {} debe ser unha cadea de texto"
+
+#: ../character.py:1307 ../character.py:1313
+msgid "the time to show the speech bubble of {} must be a positive integer"
+msgstr "o tempo para mostrar o cadro de diálogo de {} debe ser un enteiro positivo"
+
+#: ../character.py:1319
+msgid "SpeechBubble"
+msgstr "Diálogo"
+
+#: ../character.py:1372
+msgid ""
+"the instances to be collided must be a character, a group or a list of "
+"characters"
+msgstr ""
+"as instancias a seren colididas deben ser un personaxe, un grupo "
+"ou unha lista de personaxes"
+
+#: ../character.py:1380
+msgid "all instances to be collided must be characters"
+msgstr "todas as instancias a seren tocadas deben ser personaxes"
+
+#: ../character.py:1439
+msgid ""
+"the instances to be touched must be a character, a group or a list of "
+"characters"
+msgstr ""
+"as instancias a seren tocadas deben ser un personaxe, un grupo "
+"ou unha lista de personaxes"
+
+#: ../character.py:1447
+msgid "all instances to be touched must be characters"
+msgstr "todas as instancias a seren tocadas deben ser personaxes"
+
+#: ../character.py:1549
+msgid "the tracking argument must be a boolean"
+msgstr "o argumento seguimento debe ser un booleano"
+
+#: ../character.py:1587
+msgid "self.{}(key=\"{}\")"
+msgstr "self.{}(tecla=\"{}\")"
+
+#: ../texts.py:28
+msgid "Text"
+msgstr "Texto"
+
+#: ../texts.py:37
+msgid "message"
+msgstr "mensaxe"
+
+#: ../texts.py:39
+msgid "size"
+msgstr "tamaño"
+
+#: ../texts.py:40
+msgid "font"
+msgstr "fonte"
+
+#: ../texts.py:28
+msgid "name"
+msgstr "nome"
+
+#: ../texts.py:37
+msgid "visible"
+msgstr "visíbel"
+
+#: ../texts.py:43
+msgid "tracking"
+msgstr "seguimento"
+
+#: ../texts.py:41
+msgid "the font size must be an integer"
+msgstr "o tamaño da fonte debe ser un número enteiro"
+
+#: ../texts.py:43
+msgid "the font size must be a positive integer"
+msgstr "o tamaño da fonte debe ser un número enteiro positivo"
+
+#: ../texts.py:45
+msgid "the font must be a string"
+msgstr "a fonte debe ser unha cadea de texto"
+
+#: ../texts.py:47
+msgid "the bold argument must be a boolean"
+msgstr "o argumento negriña debe ser booleano"
+
+#: ../texts.py:49
+msgid "the italic argument must be a boolean"
+msgstr "o argumento itálico debe ser un booleano"
+
+#: ../texts.py:149
+msgid "the message for the speech bubble must be a string, a list or a tuple"
+msgstr ""
+"a mensaxe para o cadro de diálogo debe ser unha cadea de texto, "
+"unha lista ou unha tupla"
+
+#: ../texts.py:170
+msgid "the visible attribute must be a boolean"
+msgstr "o atributo visible debe ser un booleano"
+
+#: ../texts.py:202
+msgid "the initial value must be an integer"
+msgstr "o valor inicial debe ser un número enteiro"
+
+#: ../texts.py:204
+msgid "the countdown cannot be negative"
+msgstr "a contaxe regresiva non pode ser negativo"
+
+#: ../texts.py:206
+msgid "in_milliseconds argument must be a boolean"
+msgstr "o argumento en_milisegundos debe ser un booleano"
+
+#: ../texts.py:224
+msgid "Chronometer <{}>"
+msgstr "Cronómetro <{}>"
+
+#: ../texts.py:257
+msgid "include_millis must be a boolean"
+msgstr "incluir_milisegundos debe ser un booleano"
+
+#: ../texts.py:259
+msgid "the delimiter must be a string"
+msgstr "o delimitador debe ser unha cadea de texto"
+
+#: ../texts.py:262
+msgid "the delimiter for milliseconds must be a string"
+msgstr "o delimitador debe ser unha cadea de texto"
+
+#: ../texts.py:363
+msgid "the prefix text must be a string"
+msgstr "o texto de prefixo debe ser unha cadea de texto"
+
+#: ../texts.py:420
+msgid "the decrement must be an integer"
+msgstr "o decremento debe ser un número enteiro"
+
+#: ../texts.py:435
+msgid "the increment must be an integer"
+msgstr "o incremento debe ser un número enteiro"
+
+#: ../texts.py:510
+msgid "the background color for the speech bubble must be a tuple"
+msgstr "a cor de fondo para o cadro de diálogo debe ser unha tupla"
+
+#: ../texts.py:529
+msgid "the border color for the speech bubble must be a tuple"
+msgstr "a cor do bordo para o cadro de diálogo debe ser unha tupla"
+
+#: ../texts.py:548
+msgid "the timeout for the speech bubble must be a non-negative integer"
+msgstr ""
+"o tempo de presentación do cadro de diálogo debe ser "
+"un número enteiro non negativo"
+
+#: ../groups.py:36
+msgid "vg_character.set_{}({})"
+msgstr "vg_character.definir_{}({})"
+
+#: ../groups.py:62
+msgid "set_{}"
+msgstr "definir_{}"
+
+#: ../groups.py:67 ../groups.py:71
+msgid "the amount of characters must be a integer greater than 1"
+msgstr "a cantidade de personaxes debe ser un número enteiro maior que 1"
+
+#: ../groups.py:79
+msgid "both x and y ranges must be tuples"
+msgstr "ambos os intervalos x e y deben ser tuplas"
+
+#: ../groups.py:82
+msgid "both x and y ranges must have only two items (min, max)"
+msgstr "ambos os intervalos x e y deben apenas dous elementos (mín, máx)"
+
+#: ../groups.py:86
+msgid "both x and y ranges must be sorted (min, max)"
+msgstr "ambos os intervalos x e y deben estar em orde (mín, máx) "
+
+#: ../groups.py:124
+msgid "both the base and the height must be integers"
+msgstr "tanto a base como a altura deben ser números enteiros"
+
+#: ../groups.py:127
+msgid "both the base and the height must be positive"
+msgstr "tanto a base como a altura deben ser positivos"
+
+#: ../groups.py:132
+msgid "at least one of the dimensions of the rectangle must be greater than 1"
+msgstr "polo menos unha das dimensións do rectángulo debe ser maior que 1"
+
+#: ../groups.py:172
+msgid "the side of the square must be an integer"
+msgstr "o lado do cadrado debe ser un número enteiro"
+
+#: ../groups.py:175
+msgid "the side of the square must be greater than 1"
+msgstr "o lado do cadrado debe ser maior que 1"
+
+#: ../groups.py:205
+msgid "the steps of the stairs must be an integer"
+msgstr "os chanzos da escada debe ser un número enteiro"
+
+#: ../groups.py:208
+msgid "the steps of the stairs must be greater than 1"
+msgstr "os chanzos da escada debe ser maior que 1"
+
+#: ../groups.py:214
+msgid "both horizontal and vertical flips must be a boolean"
+msgstr "tanto o volteamento horizontal como o vertical deben ser booleanos"
+
+#: ../settings.py:23
+msgid "images"
+msgstr "imaxes"
+
+#: ../settings.py:23
+msgid "scenes"
+msgstr "escenarios"
+
+#: ../settings.py:23
+msgid "fonts"
+msgstr "fontes"
+
+#: ../settings.py:26
+msgid "data"
+msgstr "datos"

+ 774 - 0
jogai/locale/gl/gl.po~

@@ -0,0 +1,774 @@
+# Galician translations for PACKAGE package.
+# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# mdo <info@zero.gal>, 2026.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2026-05-20 22:29+0100\n"
+"PO-Revision-Date: 2026-07-08 19:22+0100\n"
+"Last-Translator: mdo <info@zero.gal>\n"
+"Language-Team: Galician <proxecto@trasno.gal>\n"
+"Language: gl\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+
+#: ../exceptions.py:38
+msgid "the key pressed was not found"
+msgstr "a tecla premida non foi encontrada"
+
+#: ../videogame.py:36
+msgid "caption"
+msgstr "título"
+
+#: ../videogame.py:37
+msgid "gravity"
+msgstr "gravidade"
+
+#: ../videogame.py:38
+msgid "marks"
+msgstr "marcas"
+
+#: ../videogame.py:76
+msgid "the attributes in the input schema must be a dict"
+msgstr "os atributos no esquema de entrada deben ser un dicionario"
+
+
+#: ../videogame.py:136
+msgid "only Character instances can be added"
+msgstr "apenas instancias Personaxe poden ser engadidas"
+
+#: ../videogame.py:152
+msgid "only Chronometer instances can be added"
+msgstr "apenas instancias Cronómetro poden ser engadidas"
+
+#: ../videogame.py:169
+msgid "only Group instances can be added"
+msgstr "apenas instancias Grupo poden ser engadidas"
+
+#: ../videogame.py:188
+msgid "only LevelBar instances can be added"
+msgstr "apenas instancias BarraDeNivel poden ser engadidas"
+
+#: ../videogame.py:204
+msgid "only Scoreboard instances can be added"
+msgstr "apenas instancias Marcador poden ser engadidas"
+
+#: ../videogame.py:220
+msgid "only Text instances can be added"
+msgstr "apenas instancias Texto poden ser engadidas"
+
+#: ../videogame.py:288 ../videogame.py:294
+msgid "the coordinates of the point must be a non-negative integers"
+msgstr "as coordenadas do punto deben ser números enteiros non negativos"
+
+#: ../videogame.py:363
+msgid "the caption for the videogame must be a string"
+msgstr "o título do videoxogo debe ser unha cadea de texto"
+
+#: ../videogame.py:378
+msgid "the gravity must be an integer"
+msgstr "a gravidade debe ser un número enteiro"
+
+#: ../videogame.py:421
+msgid "the environment name does not exist"
+msgstr "o nome do ambiente non existe"
+
+#: ../videogame.py:459
+msgid "current_scene"
+msgstr "escenario_actual"
+
+#: ../widgets.py:79
+msgid "INVENTORY [{}]"
+msgstr "INVENTARIO [{}]"
+
+#: ../widgets.py:135
+msgid "the amount of parts must be an integer"
+msgstr "a cantidade de partes debe ser un número enteiro"
+
+#: ../widgets.py:138
+msgid "the amount of parts must be between 1 and {}"
+msgstr "a cantidade de partes debe estar entre 1 e {}"
+
+#: ../widgets.py:242
+msgid "the decrease level must be an integer"
+msgstr "o nivel de decremento debe ser un número enteiro"
+
+#: ../widgets.py:244
+msgid "the decrease cannot be less than zero"
+msgstr "o decremento non pode ser menor do que cero"
+
+#: ../widgets.py:259
+msgid "the increase level must be an integer"
+msgstr "o nivel de incremento debe ser un número enteiro"
+
+#: ../widgets.py:261
+msgid "the increase cannot exceed the maximum level"
+msgstr "o incremento non pode exceder o nivel máximo"
+
+#: ../widgets.py:276
+msgid "the level must be an integer"
+msgstr "o nivel debe ser un número enteiro"
+
+#: ../widgets.py:280
+msgid "the level range must be between zero and the number of parts"
+msgstr "o intervalo de nivel debe estar entre cero e o número de partes"
+
+#: ../widgets.py:299 ../character.py:402 ../character.py:442
+msgid "the position must be a tuple or a list with the coordinates (x, y)"
+msgstr "a posición debe ser unha tuple ou unha lista coas coordenadas (x, y)"
+
+#: ../widgets.py:307 ../character.py:410 ../character.py:450
+msgid ""
+"the position must be a tuple or a list of integers with the coordinates (x, "
+"y)"
+msgstr ""
+"a posición debe ser unha tuple ou unha lista de números enteiros coas "
+"coordenadas (x, y)"
+
+#: ../__init__.py:40
+msgid "character"
+msgstr "personaxe"
+
+#: ../__init__.py:43
+msgid "groups"
+msgstr "grupos"
+
+#: ../__init__.py:46 ../scene.py:45 ../scene.py:46
+msgid "scene"
+msgstr "escenario"
+
+#: ../__init__.py:49
+msgid "texts"
+msgstr "textos"
+
+#: ../__init__.py:52
+msgid "videogame"
+msgstr "videoxogo"
+
+#: ../__init__.py:55
+msgid "widgets"
+msgstr "ferramentas"
+
+#: ../__init__.py:66
+msgid "LEVELBARS_MAX_PARTS must be an integer"
+msgstr "BARRADENIVEL_MAX_PARTES debe ser un número enteiro"
+
+#: ../__init__.py:69
+msgid "LEVELBARS_MAX_PARTS must be a positive integer"
+msgstr "BARRADENIVEL_MAX_PARTES debe ser un número enteiro positivo"
+
+#: ../utils.py:42
+msgid "both the directory and the filename must be strings"
+msgstr "tanto o directorio como o nome do ficheiro deben ser cadeas de texto"
+
+#: ../utils.py:49
+msgid "image filename not found: {}"
+msgstr "nome do ficheiro de imaxe non encontrado: {}"
+
+#: ../scene.py:44
+msgid "Scene"
+msgstr "Escenario"
+
+#: ../scene.py:47 ../scene.py:48
+msgid "background"
+msgstr "fondo"
+
+#: ../scene.py:49 ../scene.py:50
+msgid "boundaries"
+msgstr "bordos"
+
+#: ../scene.py:67
+msgid "the image filename must be a string"
+msgstr "o nome do ficheiro de imaxe debe ser unha cadea de texto"
+
+#: ../scene.py:124
+msgid "the x coordinate must be an integer"
+msgstr "a coordenada x debe ser un número enteiro"
+
+#: ../scene.py:158
+msgid ""
+"the background color must be a tuple with three integers (r,g,b) between 0 "
+"and 255"
+msgstr ""
+"a cor de fondo debe ser unha tupla con tres números enteiros (r,g,b) entre 0 "
+"e 255"
+
+#: ../scene.py:201
+msgid "the scene boundaries must be a list or a boolean"
+msgstr "os bordos do escenario deben ser unha lista ou un booleano"
+
+#: ../character.py:40 ../character.py:47
+msgid "random"
+msgstr "aleatorio"
+
+#: ../character.py:40
+msgid "RandomGroup"
+msgstr "GrupoAleatorio"
+
+#: ../character.py:41 ../character.py:48
+msgid "rectangle"
+msgstr "rectángulo"
+
+#: ../character.py:41
+msgid "RectangleGroup"
+msgstr "GrupoRectángulo"
+
+#: ../character.py:42 ../character.py:49
+msgid "square"
+msgstr "cadrado"
+
+#: ../character.py:42
+msgid "SquareGroup"
+msgstr "GrupoCadrado"
+
+#: ../character.py:43 ../character.py:50
+msgid "stairs"
+msgstr "escadas"
+
+#: ../character.py:43
+msgid "StairsGroup"
+msgstr "GrupoEscadas"
+
+#: ../character.py:59
+msgid "the arguments for the {} group must be {}"
+msgstr "os argumentos para o grupo {} deben ser {}"
+
+#: ../character.py:84 ../character.py:86
+msgid "characters"
+msgstr "personaxes"
+
+#: ../character.py:91 ../character.py:92
+msgid "filename"
+msgstr "ficheiro"
+
+#: ../character.py:96 ../groups.py:59
+msgid "Character"
+msgstr "Personaxe"
+
+#: ../character.py:127
+msgid "top"
+msgstr "superior"
+
+#: ../character.py:128
+msgid "bottom"
+msgstr "inferior"
+
+#: ../character.py:129
+msgid "left"
+msgstr "esquerda"
+
+#: ../character.py:130
+msgid "right"
+msgstr "dereita"
+
+#: ../character.py:133 ../character.py:160
+msgid "backward"
+msgstr "retroceder"
+
+#: ../character.py:134 ../character.py:162
+msgid "down"
+msgstr "baixar"
+
+#: ../character.py:135 ../character.py:163
+msgid "forward"
+msgstr "avanzar"
+
+#: ../character.py:136 ../character.py:219 ../character.py:1528
+#: ../character.py:1530
+msgid "stop"
+msgstr "parar"
+
+#: ../character.py:137 ../character.py:166 ../character.py:724
+#: ../character.py:726
+msgid "jump"
+msgstr "saltar"
+
+#: ../character.py:138 ../character.py:167
+msgid "up"
+msgstr "subir"
+
+#: ../character.py:142
+msgid "bounce"
+msgstr "rebotar"
+
+#: ../character.py:143
+msgid "bouncy"
+msgstr "rebotante"
+
+#: ../character.py:144
+msgid "collidable"
+msgstr "colidíbel"
+
+#: ../character.py:145
+msgid "chronometer"
+msgstr "cronómetro"
+
+#: ../character.py:146
+msgid "gravitative"
+msgstr "gravitacional"
+
+#: ../character.py:147
+msgid "height"
+msgstr "altura"
+
+#: ../character.py:147
+msgid "inventory"
+msgstr "inventario"
+
+#: ../character.py:148
+msgid "jump_in_the_air"
+msgstr "salto_no_ar"
+
+#: ../character.py:149
+msgid "keys"
+msgstr "teclas"
+
+#: ../character.py:150
+msgid "lives"
+msgstr "vidas"
+
+#: ../character.py:151 ../texts.py:215 ../texts.py:216 ../texts.py:367
+#: ../texts.py:368 ../texts.py:455 ../texts.py:456
+msgid "position"
+msgstr "posición"
+
+#: ../character.py:152
+msgid "scale"
+msgstr "escala"
+
+#: ../character.py:153
+msgid "score"
+msgstr "puntos"
+
+#: ../character.py:154
+msgid "speed"
+msgstr "velocidade"
+
+#: ../character.py:155
+msgid "touchable"
+msgstr "tanxíbel"
+
+#: ../character.py:156
+msgid "width"
+msgstr "largura"
+
+#: ../character.py:161
+msgid "decrease"
+msgstr "diminuir"
+
+#: ../character.py:164
+msgid "increase"
+msgstr "aumentar"
+
+#: ../character.py:165
+msgid "show_inventory"
+msgstr "mostrar_inventario"
+
+#: ../character.py:177
+msgid "the character name must be a string"
+msgstr "o nome do personaxe debe ser unha cadea de texto"
+
+#: ../character.py:201
+msgid "horizontal"
+msgstr "horizontal"
+
+#: ../character.py:201
+msgid "vertical"
+msgstr "vertical"
+
+#: ../character.py:204 ../character.py:1036
+msgid "Inventory"
+msgstr "Inventario"
+
+#: ../character.py:288
+msgid "no scoreboard or points were defined"
+msgstr "ningún marcador ou puntuación foi definida"
+
+#: ../character.py:339
+msgid "the data input must be a dict"
+msgstr "os datos de entrada deben ser un dicionario"
+
+#: ../character.py:344
+#: ../videogame.py:84
+msgid "set_{}({})"
+msgstr "definir_{}({})"
+
+#: ../character.py:416
+msgid "self._set_initial_{}({})"
+msgstr "self._definir_{}_inicial({})"
+
+#: ../character.py:422
+msgid "self.set_{}({})"
+msgstr "self.definir_{}({})"
+
+#: ../character.py:349
+msgid "the function name must be a string"
+msgstr "o nome da función debe ser unha cadea de texto"
+
+#: ../character.py:482
+msgid "only Character instances are allowed to calculate distance"
+msgstr "apenas as instancias de tipo Personaxe poden calcular distancias"
+
+#: ../character.py:518
+msgid "amount to move ​​must be an integer"
+msgstr "a cantidade a mover debe ser un número enteiro"
+
+#: ../character.py:521 ../character.py:700 ../character.py:1384
+#: ../character.py:1451
+msgid "key pressed must be a string"
+msgstr "a tecla premida debe ser unha cadea de texto"
+
+#: ../character.py:542
+msgid "the {}'s move cannot be performed; it's outside the boundary conditions"
+msgstr "O movemento de {} non pode ser realizada; está fóra das condicións de contorno."
+
+#: ../character.py:694
+msgid "the jump force must be a non-negative float number"
+msgstr "a forza do salto debe ser un número flutuante non negativo"
+
+#: ../character.py:697
+msgid "the jump force must be a non-negative float"
+msgstr "a forza do salto debe ser un número flutuante non negativo"
+
+#: ../character.py:706 ../character.py:1050
+msgid "in_the_air must be a boolean"
+msgstr "no_ar debe ser un booleano"
+
+#: ../character.py:742
+msgid "the rotation increment must be an integer"
+msgstr "o incremento da rotación debe ser un número enteiro"
+
+#: ../character.py:788
+msgid "the decrement must be a float number"
+msgstr "o decremento debe ser un número flutuante"
+
+#: ../character.py:808
+msgid "the increment must be a float number"
+msgstr "o incremento debe ser un número flutuante"
+
+#: ../character.py:843
+msgid "both the horizontal and vertical axis must be booleans"
+msgstr "ambos os eixos, vertical e horizontal, deben ser booleanos"
+
+#: ../character.py:867
+msgid "the rotation angle must be an integer"
+msgstr "o ángulo de rotación debe ser un número enteiro"
+
+#: ../character.py:913
+msgid "the animations must be a dict"
+msgstr "as animacións deben ser un dicionario"
+
+#: ../character.py:942
+msgid "the bouncy argument must be a boolean"
+msgstr "o argumento rebotante debe ser un booleano"
+
+#: ../character.py:957
+msgid "time must be a integer"
+msgstr "o tempo debe ser un número enteiro"
+
+#: ../character.py:960
+msgid "Chronometer"
+msgstr "Cronómetro"
+
+#: ../character.py:983
+msgid "the collidable argument must be a boolean"
+msgstr "o argumento colidíbel debe ser un booleano"
+
+#: ../character.py:998
+msgid "the gravitative argument must be a boolean"
+msgstr "o argumento gravitacional debe ser un booleano"
+
+#: ../character.py:1013 ../character.py:1015
+msgid "height must be a non-negative integer"
+msgstr "a altura debe ser un número enteiro non negativo"
+
+#: ../character.py:1065
+msgid "LevelBar"
+msgstr "BarraDeNivel"
+
+#: ../character.py:1083
+msgid "the predefined keys must be a dict. No keys are loaded"
+msgstr ""
+"as teclas predefinidas deben ser un dicionario. "
+"Ningunha tecla foi carregada"
+
+#: ../character.py:1107 ../texts.py:117
+msgid "the position must be a tuple or list (x, y)"
+msgstr "a posición debe ser unha tupla ou unha lista (x, y)"
+
+#: ../character.py:1110 ../texts.py:119
+msgid "the position has two coordinates (x, y)"
+msgstr "a posición ten dúas coordenadas (x, y)"
+
+#: ../character.py:1115
+msgid ""
+"the {} position is not within the established boundary conditions. Try "
+"another position or remove the boundary conditions"
+msgstr ""
+"a posición de {} non está dentro das condicións de limite establecidas. Tenta "
+"outra posición ou elimina as limitacións dos bordos"
+
+#: ../character.py:1138
+msgid "the scale factor must be a positive number (float or int)"
+msgstr "o factor de escala debe ser un número positivo (float ou int)"
+
+#: ../character.py:1143
+msgid "the scale factor must be a non-negative number (float or int)"
+msgstr "o factor de escala debe ser un número non negativo (float ou int)"
+
+#: ../character.py:1171 ../texts.py:403
+msgid "the score must be an integer"
+msgstr "os puntos deben ser un número enteiro"
+
+#: ../character.py:1174
+msgid "Scoreboard"
+msgstr "Marcador"
+
+#: ../character.py:1199
+msgid "the speed must be an integer"
+msgstr "a velocidade debe ser un número enteiro"
+
+#: ../character.py:1214
+msgid "the touchable argument must be a boolean"
+msgstr "o argumento tanxíbel debe ser un booleano"
+
+#: ../character.py:1214 ../character.py:1216
+msgid "width must be a non-negative integer"
+msgstr "a largura debe ser un número enteiro non negativo"
+
+#: ../character.py:1238 ../character.py:1254
+msgid "the amount of lives must be an integer"
+msgstr "a cantidade de vidas debe ser un número enteiro"
+
+#: ../character.py:1301
+msgid "the message to be spoken by {} must be a string"
+msgstr "a mensaxe a dicir por {} debe ser unha cadea de texto"
+
+#: ../character.py:1307 ../character.py:1313
+msgid "the time to show the speech bubble of {} must be a positive integer"
+msgstr "o tempo para mostrar o cadro de diálogo de {} debe ser un enteiro positivo"
+
+#: ../character.py:1319
+msgid "SpeechBubble"
+msgstr "Diálogo"
+
+#: ../character.py:1372
+msgid ""
+"the instances to be collided must be a character, a group or a list of "
+"characters"
+msgstr ""
+"as instancias a seren colididas deben ser un personaxe, un grupo "
+"ou unha lista de personaxes"
+
+#: ../character.py:1380
+msgid "all instances to be collided must be characters"
+msgstr "todas as instancias a seren tocadas deben ser personaxes"
+
+#: ../character.py:1439
+msgid ""
+"the instances to be touched must be a character, a group or a list of "
+"characters"
+msgstr ""
+"as instancias a seren tocadas deben ser un personaxe, un grupo "
+"ou unha lista de personaxes"
+
+#: ../character.py:1447
+msgid "all instances to be touched must be characters"
+msgstr "todas as instancias a seren tocadas deben ser personaxes"
+
+#: ../character.py:1549
+msgid "the tracking argument must be a boolean"
+msgstr "o argumento seguimento debe ser un booleano"
+
+#: ../character.py:1587
+msgid "self.{}(key=\"{}\")"
+msgstr "self.{}(tecla=\"{}\")"
+
+#: ../texts.py:28
+msgid "Text"
+msgstr "Texto"
+
+#: ../texts.py:37
+msgid "message"
+msgstr "mensaxe"
+
+#: ../texts.py:39
+msgid "size"
+msgstr "tamaño"
+
+#: ../texts.py:40
+msgid "font"
+msgstr "fonte"
+
+#: ../texts.py:28
+msgid "name"
+msgstr "nome"
+
+#: ../texts.py:37
+msgid "visible"
+msgstr "visíbel"
+
+#: ../texts.py:43
+msgid "tracking"
+msgstr "seguimento"
+
+#: ../texts.py:41
+msgid "the font size must be an integer"
+msgstr "o tamaño da fonte debe ser un número enteiro"
+
+#: ../texts.py:43
+msgid "the font size must be a positive integer"
+msgstr "o tamaño da fonte debe ser un número enteiro positivo"
+
+#: ../texts.py:45
+msgid "the font must be a string"
+msgstr "a fonte debe ser unha cadea de texto"
+
+#: ../texts.py:47
+msgid "the bold argument must be a boolean"
+msgstr "o argumento negriña debe ser booleano"
+
+#: ../texts.py:49
+msgid "the italic argument must be a boolean"
+msgstr "o argumento itálico debe ser un booleano"
+
+#: ../texts.py:149
+msgid "the message for the speech bubble must be a string, a list or a tuple"
+msgstr ""
+"a mensaxe para o cadro de diálogo debe ser unha cadea de texto, "
+"unha lista ou unha tupla"
+
+#: ../texts.py:170
+msgid "the visible attribute must be a boolean"
+msgstr "o atributo visible debe ser un booleano"
+
+#: ../texts.py:202
+msgid "the initial value must be an integer"
+msgstr "o valor inicial debe ser un número enteiro"
+
+#: ../texts.py:204
+msgid "the countdown cannot be negative"
+msgstr "a contaxe regresiva non pode ser negativo"
+
+#: ../texts.py:206
+msgid "in_milliseconds argument must be a boolean"
+msgstr "o argumento en_milisegundos debe ser un booleano"
+
+#: ../texts.py:224
+msgid "Chronometer <{}>"
+msgstr "Cronómetro <{}>"
+
+#: ../texts.py:257
+msgid "include_millis must be a boolean"
+msgstr "incluir_milisegundos debe ser un booleano"
+
+#: ../texts.py:259
+msgid "the delimiter must be a string"
+msgstr "o delimitador debe ser unha cadea de texto"
+
+#: ../texts.py:262
+msgid "the delimiter for milliseconds must be a string"
+msgstr "o delimitador debe ser unha cadea de texto"
+
+#: ../texts.py:363
+msgid "the prefix text must be a string"
+msgstr "o texto de prefixo debe ser unha cadea de texto"
+
+#: ../texts.py:420
+msgid "the decrement must be an integer"
+msgstr "o decremento debe ser un número enteiro"
+
+#: ../texts.py:435
+msgid "the increment must be an integer"
+msgstr "o incremento debe ser un número enteiro"
+
+#: ../texts.py:510
+msgid "the background color for the speech bubble must be a tuple"
+msgstr "a cor de fondo para o cadro de diálogo debe ser unha tupla"
+
+#: ../texts.py:529
+msgid "the border color for the speech bubble must be a tuple"
+msgstr "a cor do bordo para o cadro de diálogo debe ser unha tupla"
+
+#: ../texts.py:548
+msgid "the timeout for the speech bubble must be a non-negative integer"
+msgstr ""
+"o tempo de presentación do cadro de diálogo debe ser "
+"un número enteiro non negativo"
+
+#: ../groups.py:36
+msgid "vg_character.set_{}({})"
+msgstr "vg_character.definir_{}({})"
+
+#: ../groups.py:62
+msgid "set_{}"
+msgstr "definir_{}"
+
+#: ../groups.py:67 ../groups.py:71
+msgid "the amount of characters must be a integer greater than 1"
+msgstr "a cantidade de personaxes debe ser un número enteiro maior que 1"
+
+#: ../groups.py:79
+msgid "both x and y ranges must be tuples"
+msgstr "ambos os intervalos x e y deben ser tuplas"
+
+#: ../groups.py:82
+msgid "both x and y ranges must have only two items (min, max)"
+msgstr "ambos os intervalos x e y deben apenas dous elementos (mín, máx)"
+
+#: ../groups.py:86
+msgid "both x and y ranges must be sorted (min, max)"
+msgstr "ambos os intervalos x e y deben estar em orde (mín, máx) "
+
+#: ../groups.py:124
+msgid "both the base and the height must be integers"
+msgstr "tanto a base como a altura deben ser números enteiros"
+
+#: ../groups.py:127
+msgid "both the base and the height must be positive"
+msgstr "tanto a base como a altura deben ser positivos"
+
+#: ../groups.py:132
+msgid "at least one of the dimensions of the rectangle must be greater than 1"
+msgstr "polo menos unha das dimensións do rectángulo debe ser maior que 1"
+
+#: ../groups.py:172
+msgid "the side of the square must be an integer"
+msgstr "o lado do cadrado debe ser un número enteiro"
+
+#: ../groups.py:175
+msgid "the side of the square must be greater than 1"
+msgstr "o lado do cadrado debe ser maior que 1"
+
+#: ../groups.py:205
+msgid "the steps of the stairs must be an integer"
+msgstr "os chanzos da escada debe ser un número enteiro"
+
+#: ../groups.py:208
+msgid "the steps of the stairs must be greater than 1"
+msgstr "os chanzos da escada debe ser maior que 1"
+
+#: ../groups.py:214
+msgid "both horizontal and vertical flips must be a boolean"
+msgstr "tanto o volteamento horizontal como o vertical deben ser booleanos"
+
+#: ../settings.py:23
+msgid "images"
+msgstr "imaxes"
+
+#: ../settings.py:23
+msgid "scenes"
+msgstr "escenarios"
+
+#: ../settings.py:23
+msgid "fonts"
+msgstr "fontes"
+
+#: ../settings.py:26
+msgid "data"
+msgstr "datos"

+ 49 - 0
jogai/locale/gl/grupos.py

@@ -0,0 +1,49 @@
+from typing import Type
+
+from jogai.groups import (
+    RandomGroup,
+    RectangleGroup,
+    SquareGroup,
+    StairsGroup,
+)
+from jogai.translations import gettext as _
+
+__all__ = ['GrupoAleatorio', 'GrupoRectángulo', 'GrupoCadrado', 'GrupoEscadas']
+
+
+class GrupoAleatorio(RandomGroup):
+    def __init__(
+        self,
+        nome: str,
+        modelo: Type[_('Character')],
+        cantidade: int,
+        rango_x: tuple,
+        rango_y: tuple,
+    ):
+        super().__init__(nome, modelo, cantidade, rango_x, rango_y)
+
+
+class GrupoRectángulo(RectangleGroup):
+    def __init__(
+        self, nome: str, modelo: Type[_('Character')], base: int, altura: int
+    ):
+        super().__init__(nome, modelo, base, altura)
+
+
+class GrupoCadrado(SquareGroup):
+    def __init__(self, nome: str, modelo: Type[_('Character')], lado: int):
+        super().__init__(nome, modelo, lado)
+
+
+class GrupoEscadas(StairsGroup):
+    def __init__(
+        self,
+        nome: str,
+        modelo: Type[_('Character')],
+        chanzos: int,
+        volteamento_horizontal: bool = False,
+        volteamento_vertical: bool = False,
+    ):
+        super().__init__(
+            nome, modelo, chanzos, volteamento_horizontal, volteamento_vertical
+        )

+ 827 - 0
jogai/locale/gl/personaxe.py

@@ -0,0 +1,827 @@
+from typing import Tuple, Type
+
+from jogai.character import Character, Direction
+from jogai.settings import settings
+
+__all__ = ['Dirección', 'Personaxe']
+
+
+class Dirección(Direction):
+    ...
+
+
+class Personaxe(Character):
+    """
+    A videogame character with a bunch of actions.
+    """
+
+    def __init__(
+        self,
+        ficheiro_imaxe: str,
+        nome: str = '',
+        ficheiros_animación: dict = {},
+    ):
+        super().__init__(ficheiro_imaxe, nome, ficheiros_animación)
+
+    def _update_attributes(self):
+        self.teclas_predefinidas = self.predefined_keys
+        self.imaxe = self.image
+        self.miniatura = self.thumbnail
+        self.miniatura_rect = self.thumbnail_rect
+        self.ficheiros_animación = self.animation_filenames
+        self.forza_do_salto = self.jump_force
+        self.invertido = self.flipped
+        self.cronómetro = self.chronometer
+        self.inventario = self.inventory
+
+    @property
+    def altura(self) -> int:
+        """altura da personaxe en pixeis"""
+        return self.height
+
+    @property
+    def ángulo(self) -> int:
+        """ángulo de inclinación da personaxe"""
+        return self.angle
+
+    @property
+    def camiño_imaxe(self) -> str:
+        """camiño ao ficheiro de imaxe da personaxe"""
+        self.image_path
+
+    @property
+    def centro(self) -> Tuple[int, int]:
+        return self.center
+
+    @property
+    def colidíbel(self) -> bool:
+        """indica se a personaxe pode colidir con outras personaxes"""
+        return self.collidable
+
+    @property
+    def escala(self) -> float:
+        """factor de escala da imaxe do personaxe"""
+        return self.scale
+
+    @property
+    def gravitacional(self) -> bool:
+        """indica se a personaxe é afectado pola gravidade"""
+        return self.gravitative
+
+    @property
+    def ficheiro_imaxe(self) -> str:
+        """ficheiro de imaxe da personaxe"""
+        self.image_filename
+
+    @property
+    def largura(self) -> int:
+        """largura da personaxe en pixeis"""
+        return self.width
+
+    @property
+    def nome(self) -> str:
+        """nome da personaxe"""
+        return self.name
+
+    @property
+    def posición(self) -> Tuple[int, int]:
+        """coordenadas de posición da personaxe"""
+        return self.position
+
+    @property
+    def puntos(self) -> int:
+        """puntos da personaxe"""
+        return self.score
+
+    @property
+    def rebotante(self) -> bool:
+        """indica se a personaxe pode rebotar noutras personaxes e nos bordos"""
+        return self.bouncy
+
+    @property
+    def saltando(self) -> bool:
+        """indica se a personaxe está a saltar"""
+        return self.jumping
+
+    @property
+    def tanxíbel(self) -> bool:
+        """indica se a personaxe pode ser tocada por outras personaxes"""
+        return self.touchable
+
+    @tanxíbel.setter
+    def tanxíbel(self, valor):
+        self.touchable = valor
+
+    @property
+    def visíbel(self) -> bool:
+        """indica se a personaxe pode ser visíbel"""
+        return self.visible
+
+    @visíbel.setter
+    def visíbel(self, valor):
+        self.visible = valor
+
+    @property
+    def velocidade(self) -> int:
+        """velocidade da personaxe en pixeis por ciclo"""
+        return self.speed
+
+    @velocidade.setter
+    def velocidade(self, valor):
+        """define a velocidade da personaxe en pixeis por ciclo"""
+        self._speed = valor
+
+    @property
+    def vidas(self):
+        """vidas da personaxe"""
+        return self.lives
+
+    @vidas.setter
+    def vidas(self, valor):
+        """define as vidas da personaxe"""
+        self.lives = valor
+
+    def _definir_posición_inicial(self, posición: tuple | list):
+        self._set_initial_position(posición)
+
+    def calcular_colisións(self, posición: tuple | list) -> list:
+        """
+        Calcula todas as colisións da personaxe simulando estar na posición indicada
+
+        Parameters:
+            posición: unha tupla coas coordenadas x e y
+
+        Returns:
+            Unha lista con todas as outras personaxes que colidirían coa personaxe.
+        """
+        return self.calculate_collisions(posición)
+
+    def calcular_distancia(self, outra_personaxe):
+        """
+        Calcula a distancia entre os centros desta personaxe e outra.
+
+        Parameters:
+            outra_personaxe: unha instancia doutra personaxe
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> secondaria = Personaxe('secundaria.png')
+            >>> protagonista.calcular_distancia(secundaria)
+        """
+        return self.calculate_distance(outra_personaxe)
+
+    def mover(
+        self,
+        x: int | None = None,
+        y: int | None = None,
+        tecla: str = '',
+        relativo: bool = True,
+    ):
+        """
+        Move a personaxe o número de pixeis indicados por x e y
+
+        Parameters:
+            x: cantidade de pixeis a mover no eixo x
+            y: cantidade de pixeis a mover no eixo y
+            tecla: tecla opcional coa que realizar a acción
+            relativo: True para un movemento incremental, False para un movemento absoluto
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.mover(5, 3)
+            >>> protagonista.mover()
+        """
+        self.move(x, y, tecla, relativo)
+        self._update_attributes()
+
+    def mover_a(self, x: int, y: int, tecla: str = ''):
+        """
+        Move a personaxe até a posición (x, y).
+
+        Parameters:
+            x: coordenada no eixo horizontal
+            y: coordenada no eixo vertical
+            tecla: tecla opcional coa que realizar a acción
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.mover_a(0, 0)
+        """
+        self.move_to(x, y, tecla)
+        self._update_attributes()
+
+    def retroceder(self, cantidade: int | None = None, tecla: str = ''):
+        """
+        Move a personaxe na dirección negativa das abscisas.
+
+        Parameters:
+            cantidade: cantidade de pixeis a mover
+            tecla: tecla opcional coa que realizar a acción
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.retroceder(3)
+            >>> protagonista.retroceder()
+        """
+        self.backward(cantidade, tecla)
+        self._update_attributes()
+
+    def baixar(self, cantidade: int | None = None, tecla: str = ''):
+        """
+        Move a personaxe na dirección positiva das ordenadas.
+
+        Parameters:
+            cantidade: cantidade de pixeis a mover
+            tecla: tecla opcional coa que realizar a acción
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.baixar(3)
+            >>> protagonista.baixar()
+        """
+        self.down(cantidade, tecla)
+        self._update_attributes()
+
+    def avanzar(self, cantidade: int | None = None, tecla: str = ''):
+        """
+        Move a personaxe na dirección positiva das abscisas.
+
+        Parameters:
+            cantidade: cantidade de pixeis a mover
+            tecla: tecla opcional coa que realizar a acción
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.avanzar(3)
+            >>> protagonista.avanzar()
+        """
+        self.forward(cantidade, tecla)
+        self._update_attributes()
+
+    def subir(self, cantidade: int | None = None, tecla: str = ''):
+        """
+        Move a personaxe na dirección negativa das ordenadas.
+
+        Parameters:
+            cantidade: cantidade de pixeis a mover
+            tecla: tecla opcional coa que realizar a acción
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.subir(3)
+            >>> protagonista.subir()
+        """
+        self.up(cantidade, tecla)
+        self._update_attributes()
+
+    def saltar(
+        self,
+        forza: float | None = None,
+        tecla: str = '',
+        no_ar: bool = settings.CHARACTER_DEFAULT_JUMP_IN_THE_AIR,
+    ):
+        """
+        Fai saltar a personaxe.
+
+        Parameters:
+            forza: forza do salto
+            tecla: tecla opcional coa que realizar a acción
+            no_ar: False se esta personaxe só pode saltar sobre outras personaxes. True, sempre pode saltar.
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.saltar()
+        """
+        self.jump(forza, tecla)
+        self._update_attributes()
+
+    def rotar(self, incremento: int = 1):
+        """
+        Rota a personaxe en sentido horario.
+
+        Parameters:
+            incremento: cantidade a rotar
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.rotar(90)
+        """
+        self.rotate(incremento)
+        self._update_attributes()
+
+    def rotar_a(self, ángulo: int):
+        """
+        Define o ángulo de rotación da personaxe. Alias da función definir_ángulo.
+
+        Parameters:
+            ángulo: o novo ángulo
+
+        Examples:
+            >>> protagonista = Character('protagonista.png')
+            >>> protagonista.rotar_a(180)
+        """
+        self.rotate_to(ángulo)
+
+    def virar(self, incremento: int = 1):
+        """
+        Vira a personaxe en sentido horario. Alias da función rotar.
+
+        Parameters:
+            incremento: cantidade a virar
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.virar(90)
+        """
+        self.turn(incremento)
+
+    def diminuir(self, decremento: float | int = 0.001, tecla: str = ''):
+        """
+        Diminúe o tamaño da personaxe.
+
+        Parameters:
+            decremento: cantidade a diminuir no factor de escala
+            tecla: tecla opcional coa que realizar a acción
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.diminuir(0.1)
+        """
+        self.decrease(decremento, tecla)
+        self._update_attributes()
+
+    def aumentar(self, incremento: float | int = 0.001, tecla: str = ''):
+        """
+        Aumenta o tamaño da personaxe.
+
+        Parameters:
+            incremento: cantidade a aumentar no factor de escala
+            tecla: tecla opcional coa que realizar a acción
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.aumentar(0.1)
+        """
+        self.increase(incremento, tecla)
+        self._update_attributes()
+
+    def escalar_a(self, factor_escala: float = 1.0, tecla: str = ''):
+        """
+        Escala a personaxe segundo o factor de escala.
+
+        Parameters:
+            factor_escala: factor de escala
+            tecla: tecla opcional coa que realizar a acción
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.escalar_a(2)
+        """
+        self.scale_to(factor_escala)
+        self._update_attributes()
+
+    def voltear(self, horizontal: bool = True, vertical: bool = True):
+        """
+        Voltea a personaxe nos eixos horizontal e/ou vertical.
+
+        Parameters:
+            horizontal: volteamento no eixo horizontal
+            vertical: volteamento no eixo vertical
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonist.voltear(True, False)
+        """
+        self.flip(horizontal, vertical)
+
+    def sumar_puntos(self, puntos: int):
+        """
+        Suma un certo número de puntos.
+
+        Parameters:
+            puntos: o número de puntos
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.sumar_puntos(3)
+        """
+        self.add_points(puntos)
+
+    def restar_puntos(self, puntos: int):
+        """
+        Resta un certo número de puntos.
+
+        Parameters:
+            puntos: o número de puntos
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.restar_puntos(3)
+        """
+        self.substract_points(puntos)
+
+    def definir_ángulo(self, ángulo: int | None = None):
+        """
+        Define o ángulo de rotación da personaxe.
+
+        Parameters:
+            ángulo: ángulo onde rotar
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_ángulo(90)
+        """
+        self.set_angle(ángulo)
+        self._update_attributes()
+
+    def definir_animacións(self, animacións: dict):
+        """
+        Define os ficheiros de imaxe coas animacións para esta personaxe.
+
+        Parameters:
+            animacións: un dicionario con pares {acción: nome_do_ficheiro}
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_animacións({'saltar': 'protagonista_saltar.png'})
+        """
+        self.set_animations(animacións)
+        self._update_attributes()
+
+    def definir_rebotante(self, rebotante: bool):
+        """
+        Define se esta personaxe pode rebotar contra outras personaxes e contra os bordos.
+
+        Parameters:
+            rebotante: True se pode rebotar, False caso contrario.
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_rebotante(True)
+        """
+        self.set_bouncy(rebotante)
+
+    def definir_rebotar(self, rebotar: bool):
+        """Alias de definir_rebotante."""
+        self.definir_rebotante(rebotar)
+
+    def definir_cronómetro(self, tempo: int):
+        """
+        Define un cronómetro para esta personaxe.
+
+        Parameters:
+            tempo: tempo inicial para a contaxe regresiva. Se for cero, a conta será progresiva.
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_cronómetro(30)
+        """
+        self.set_chronometer(tempo)
+        self._update_attributes()
+
+    def definir_colidíbel(self, colidíbel: bool = True):
+        """
+        Define esta personaxe como colidíbel.
+
+        Parameters:
+            colidíbel: True se é colidíbel, False caso contrario.
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_colidíbel(True)
+        """
+        self.set_collidable(colidíbel)
+        self._update_attributes()
+
+    def definir_gravitacional(self, gravitacional: bool = True):
+        """
+        Define esta personaxe como gravitacional.
+
+        Parameters:
+            gravitacional: True se é gravitacional, False caso contrario.
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_gravitacional(True)
+        """
+        self.set_gravitative(gravitacional)
+        self._update_attributes()
+
+    def definir_altura(self, altura: int):
+        """
+        Define a altura da personaxe.
+
+        Parameters:
+            altura: a altura da personaxe en pixeis
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_altura(100)
+        """
+        self.set_height(altura)
+        self._update_attributes()
+
+    def definir_inventario(self, tecla: str):
+        """
+        Define o inventario para esta personaxe.
+
+        Parameters:
+            tecla: a tecla para mostrar o inventario
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_inventario('i')
+        """
+        self.set_inventory(tecla)
+        self._update_attributes()
+
+    def definir_salto_no_ar(self, no_ar: bool):
+        """
+        Define se a personaxe pode saltar no ar ou non.
+
+        Parameters:
+            no_ar: False se esta personaxe só pode saltar sobre outras personaxes. True, sempre pode saltar.
+
+        Examples:
+            >>> protagonist = Character('protagonist.png')
+            >>> protagonist.definir_salto_no_ar(True)
+        """
+        self.set_jump_in_the_air(no_ar)
+
+    def definir_vidas(self, vidas: int):
+        """
+        Define as vidas da personaxe.
+
+        Parameters:
+            vidas: as vidas da personaxe
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_vidas(5)
+        """
+        self.set_lives(vidas)
+
+    def definir_teclas(self, teclas_predefinidas: dict):
+        """
+        Predefine teclas para executar accións.
+        Apenas as accións declaradas na variábel _predefined_key_actions_allowed son permitidas.
+
+        Parameters:
+            teclas_predefinidas: un dicionario con pares {acción: tecla}
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_teclas({'avanzar': 'd'})
+        """
+        self.set_keys(teclas_predefinidas)
+        self._update_attributes()
+
+    def definir_posición(self, posición: tuple | list, tecla: str = ''):
+        """
+        Define a posición da personaxe como tupla (x, y).
+        Esta función non verifica as colisións,
+        portanto poden darse resultados inesperados durante o bucle do xogo.
+
+        Parameters:
+            posición: unha tupla coas coordenadas
+            tecla: tecla opcional coa que realizar a acción
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_posición((0, 0))
+        """
+        self.set_position(posición, tecla)
+        self._update_attributes()
+
+    def definir_puntos(self, puntos: int):
+        """
+        Define a puntuación para a personaxe canda o seu marcador.
+
+        Parameters:
+            puntos: a nova puntuación para esta personaxe
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_puntos(5)
+        """
+        self.set_score(puntos)
+        self._update_attributes()
+
+    def definir_velocidade(self, velocidade: int):
+        """
+        Define a velocidade da personaxe.
+
+        Parameters:
+            velocidade: velocidade da personaxe
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_velocidade(10)
+        """
+        self.set_speed(velocidade)
+        self._update_attributes()
+
+    def definir_tanxíbel(self, tanxíbel: bool = True):
+        """
+        Define esta personaxe como tanxíbel.
+
+        Parameters:
+            tangíbel: True se é tanxíbel, False caso contrario.
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_tanxíbel(True)
+        """
+        self.set_touchable(tanxíbel)
+        self._update_attributes()
+
+    def definir_visíbel(self, visíbel: bool = True):
+        """
+        Define esta personaxe como visíbel.
+
+        Parameters:
+            tangíbel: True se é visíbel, False caso contrario.
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_visíbel(True)
+        """
+        self.set_visible(visíbel)
+        self._update_attributes()
+
+    def definir_largura(self, largura: int):
+        """
+        Define a largura da personaxe.
+
+        Parameters:
+            largura: a largura da personaxe en pixeis
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_largura(100)
+        """
+        self.set_width(largura)
+        self._update_attributes()
+
+    def gañar_vidas(self, cantidade: int = 1):
+        """
+        A personaxe gaña un certo número de vidas.
+
+        Parameters:
+            cantidade: número de vidas
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.gañar_vidas(2)
+        """
+        self.gain_lives(cantidade)
+
+    def perder_vidas(self, cantidade: int = 1):
+        """
+        A personaxe perde un certo número de vidas.
+
+        Parameters:
+            cantidade: número de vidas
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.definir_vidas(5)
+            >>> protagonista.perder_vidas(2)
+        """
+        self.lose_lives(cantidade)
+
+    def restaurar(self):
+        """
+        Restores the character's original position.
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.mover_a(5, 5)
+            >>> protagonista.restaurar()
+        """
+        self.restore()
+
+    def mostrar(self):
+        """
+        Mostra este personaxe.
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.mostrar()
+        """
+        self.show()
+
+    def mostrar_inventario(self, tecla: str = '', retardo: float = 0.5):
+        """
+        Show and hide the character inventory.
+
+        Parameters:
+            tecla: tecla opcional coa que realizar a acción
+            retardo: retardo entre repetición de teclas para abrir e fechar o inventario
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.mostrar_inventario()
+        """
+        self.show_inventory(tecla, retardo)
+        self._update_attributes()
+
+    def falar(
+        self,
+        mensaxe: str,
+        tempo: int = settings.CHARACTER_DEFAULT_SPEAK_TIME,
+        tecla: str = '',
+        posición: tuple = (),
+    ):
+        self.speak(mensaxe, tempo, tecla, posición)
+
+    def ocultar(self):
+        """
+        Oculta este personaxe.
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.ocultar()
+        """
+        self.hide()
+
+    def perseguir(self, outro: Type[_('Character')]):
+        """
+        Persegue outra personaxe.
+
+        Parameters:
+            other: personaxe a ser perseguida
+
+        Example:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> secundaria = Personaxe('secundaria.png')
+            >>> protagonista.perseguir(secundaria)
+        """
+        self.chase(outro)
+
+    def tocar(
+        self,
+        outras_personaxes: Type[_('Character')]
+        | Type['jogai.group.Group']
+        | list,
+        reciprocamente: bool = True,
+        tecla: str = '',
+    ) -> str:
+        """
+        Detecta se esta personaxe está a tocar outra(s), opcionalmente se é tocada por outra(s), e o(s) correspondente(s) lado(s) de interacción.
+
+        Parameters:
+            outras_personaxes: personaxe ou lista de personaxes a detectar
+            reciprocamente: False, para incluír apenas as personaxes que son tocadas. True, para incluír tamén as outras personaxes que tocan a esta.
+            tecla: tecla opcional coa que realizar a acción
+
+        Returns:
+            Unha lista de tuplas con tres campos (quen toca, quen é tocada, o lado).
+            Unha lista baleira se ninguén toca ninguén
+            Os lados de toque posíbeis son arriba, abaixo, esquerda e dereita.
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> secundario = Personaxe('secundario.png')
+            >>> protagonista.tocar(secundario)
+            'estática'
+        """
+        res = self.touch(outras_personaxes, reciprocamente, tecla)
+        self._update_attributes()
+        return res
+
+    def gerar_miniatura(
+        self, altura=settings.CHARACTER_DEFAULT_THUMBNAIL_SIZE
+    ):
+        self.generate_thumbnail(altura)
+        self._update_attributes()
+
+    def cargar_imaxe(
+        self, ficheiro: str, preservar_tamaño: bool = True, tecla: str = ''
+    ):
+        """
+        Carrega unha nova imaxe para esta personaxe.
+
+        Parameters:
+            ficheiro: o nome de ficheiro da nova imaxe
+            preservar_tamaño: True se o tamaño actual debe ser preservado, False en caso contrario
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> protagonista.cargar_imaxe('secondario.png')
+        """
+        self.load_image(ficheiro, preservar_tamaño, tecla)
+        self._update_attributes()
+
+    def copiar(self):
+        """
+        Crea unha copia desta personaxe, mantendo o seu tamaño e a súa posición.
+
+        Examples:
+            >>> protagonista = Personaxe('protagonista.png')
+            >>> outra = protagonista.copiar()
+        """
+        self.copy()
+
+    def actualizar(self):
+        self.update()

+ 409 - 0
jogai/locale/gl/textos.py

@@ -0,0 +1,409 @@
+from jogai.settings import settings
+from jogai.texts import (
+    Chronometer,
+    Scoreboard,
+    SpeechBubble,
+    Text,
+)
+
+__all__ = ['Texto', 'Cronómetro', 'Marcador', 'Diálogo']
+
+
+class Texto(Text):
+    """Un texto para o videoxogo."""
+
+    def __init__(
+        self,
+        mensaxe: str = '',
+        tamaño: int = settings.TEXT_DEFAULT_FONT_SIZE,
+        cor: tuple | list = settings.TEXT_DEFAULT_COLOR,
+        cor_sombra: tuple | list = settings.TEXT_SHADOW_DEFAULT_COLOR,
+        posición: tuple | list = settings.TEXT_DEFAULT_POSITION,
+        fonte: str = '',
+        groso: bool = False,
+        itálico: bool = False,
+        nome: str = '',
+    ):
+        # TODO: Implement shadow
+        super().__init__(
+            mensaxe,
+            tamaño,
+            cor,
+            cor_sombra,
+            posición,
+            fonte,
+            groso,
+            itálico,
+            nome,
+        )
+
+    @property
+    def cor_fondo(self):
+        """cor de fondo"""
+        return self.background_color
+
+    @property
+    def cor(self) -> tuple:
+        """cor do texto"""
+        return self.color
+
+    @property
+    def fonte(self):
+        """fonte do texto"""
+        return self.font
+
+    @property
+    def mensaxe(self) -> str:
+        """mensaxe do texto"""
+        return self.message
+
+    @property
+    def nome(self):
+        """nome do texto"""
+        return self.name
+
+    @property
+    def cor_sombra(self):
+        """cor da sombra do texto"""
+        return self.shadow_color
+
+    @property
+    def seguimento(self):
+        """seguimento do texto"""
+        return self.tracking
+
+    @seguimento.setter
+    def seguimento(self, value):
+        """define seguimento do texto"""
+        self.tracking = value
+
+    @property
+    def tamaño(self):
+        """tamaño da fonte"""
+        return self.size
+
+    @property
+    def visíbel(self):
+        """visibilidade do texto"""
+        return self.visible
+
+    @visíbel.setter
+    def visíbel(self, value):
+        """define a visibilidade do texto"""
+        self.visible = value
+
+    def mostrar(self):
+        """Mostra este texto"""
+        self.show()
+
+    def ocultar(self):
+        """Oculta este texto"""
+        self.hide()
+
+    def renderizar(self, posición: tuple | list = (), mensaxe: str = ''):
+        self.render(posición, mensaxe)
+
+    def definir_fonte(self, fonte: str, tamaño: int):
+        """
+        Define unha nova fonte para este texto.
+
+        Parameters:
+            fonte: a nova fonte
+            tamaño: tamaño da nova fonte
+
+        Examples:
+            >>> t = Texto()
+            >>> t.definir_fonte('freefont', 20)
+        """
+        self.set_font(fonte, tamaño)
+
+    def definir_mensaxe(self, mensaxe: str):
+        """
+        Define unha nova mensaxe para este texto.
+
+        Parameters:
+            mensaxe: a nova mensaxe
+
+        Examples:
+            >>> t = Texto()
+            >>> t.definir_mensaxe('Teste')
+        """
+        self.set_message(mensaxe)
+
+    def definir_tamaño(self, tamaño: int):
+        """
+        Define o tamaño da fonte para este texto.
+
+        Parameters:
+            tamaño: o tamaño da fonte
+
+        Examples:
+            >>> t = Texto('Teste')
+            >>> t.definir_tamaño(20)
+        """
+        self.set_size(tamaño)
+
+    def definir_visíbel(self, visíbel: bool | None = None, tecla: str = ''):
+        """
+        Define se o texto é visíbel.
+
+        Parameters:
+            visíbel: True se o texto é visíbel, False caso contrario
+
+        Examples:
+            >>> t = Texto('Teste')
+            >>> t.definir_visíbel(False)
+        """
+        self.set_visible(visíbel)
+
+    def definir_seguimento(
+        self, seguimento: bool | None = None, tecla: str = ''
+    ):
+        """
+        Define se o texto vai ter seguimento.
+        É dicir, o texto vai manter a posición aínda que o protagonista avance.
+
+        Parameters:
+            seguimento: True se o texto vai ter seguimento, False caso contrario
+
+        Examples:
+            >>> t = Texto('Teste')
+            >>> t.definir_seguimento(False)
+        """
+        self.set_tracking(seguimento, tecla)
+
+    def definir_posición(self, posición: tuple | list, tecla: str = ''):
+        """
+        Define a posición do texto.
+
+        Parameters:
+            posición: unha tupla coas coordenadas
+            tecla: tecla opcional coa que realizar a acción
+
+        Examples:
+            >>> t = Texto('Teste')
+            >>> t.definir_posición((200, 200))
+        """
+        self.set_position(posición, tecla)
+
+
+class Cronómetro(Chronometer):
+    """Un cronómetro para o videoxogo."""
+
+    def __init__(
+        self,
+        inicial: int = settings.CHRONOMETERS_DEFAULT_INITIAL_VALUE,
+        en_milisegundos: bool = settings.CHRONOMETERS_DEFAULT_IN_MILLISECONDS,
+        **kwargs,
+    ):
+        super().__init__(inicial, en_milisegundos, **kwargs)
+
+    @property
+    def tempo(self) -> int:
+        """instante actual do temporizador deste cronómetro"""
+        return self.time
+
+    @property
+    def pausado(self) -> bool:
+        """verifica se este cronómetro está pausado ou correndo"""
+        return self.onpause
+
+    def formatar_reloxo(
+        self,
+        incluír_milisegundos: bool = False,
+        delimitador: str = settings.CHRONOMETERS_DEFAULT_DELIMITER,
+        delimitador_milisegundos: str = settings.CHRONOMETERS_DEFAULT_MILLIS_DELIMITER,
+    ) -> str:
+        """
+        Crea un texto coa información temporal do cronómetro, formatado como un reloxo dixital.
+
+        Parameters:
+            incluír_milisegundos: True se os milisegundos deben estar presentes. False, caso contrario
+            delimitador: delimitador textual entre horas, minutos e segundos
+            delimitador_milisegundos: delimitador textual entre segundos e milisegundos
+
+        Returns:
+            Unha cadea de texto coa información temporal formatada.
+        """
+        return self.format_clock(
+            incluír_milisegundos, delimitador, delimitador_milisegundos
+        )
+
+    def obter_reloxo(self) -> tuple:
+        """
+        Obtén a información actual do temporizador como valores separados.
+
+        Returns:
+            Unha tupla coa información temporal (horas, minutos, segundos, milisegundos)
+        """
+        return self.get_clock()
+
+    def obter_milisegundos(self) -> int:
+        """
+        Obtén a información actual do temporizador en milisegundos.
+
+        Returns:
+            Un enteiro co tempo actual en milisegundos.
+        """
+        return self.get_milliseconds()
+
+    def obter_segundos(self) -> int:
+        """
+        Obtén a información actual do temporizador en segundos.
+
+        Returns:
+            Un enteiro co tempo actual en segundos.
+        """
+        return self.get_seconds()
+
+    def definir_reloxo(self, segundos):
+        """
+        Define un novo tempo en segundos para o cronómetro actual.
+
+        Parameters:
+            segundos: cantidade de segundos
+        """
+        self.set_clock(segundos)
+
+    def iniciar(self):
+        """Inicia a conta do temporizador."""
+        self.start()
+
+    def pausar(self):
+        """Pausa a conta do temporizador."""
+        self.pause()
+
+    def reiniciar(self):
+        """Reinicia a conta do temporizador."""
+        self.reset()
+
+    def retomar(self):
+        """Retoma a conta do temporizador."""
+        self.resume()
+
+    def actualizar(self):
+        """Actualiza a mensaxe textual coa información temporal actual."""
+        self.actualizar()
+
+
+class Marcador(Scoreboard):
+    """Un marcador para o videoxogo"""
+
+    def __init__(self, prefixo, puntos=0, **kwargs):
+        super().__init__(prefixo, puntos, **kwargs)
+
+    @property
+    def puntos(self):
+        """un número enteiro cos puntos actuais"""
+        return self.score
+
+    @property
+    def mensaxe(self):
+        """texto coa información mostrada polo marcador"""
+        return self.message
+
+    def definir_puntos(self, puntos: int):
+        """
+        Define unha nova puntuación para o marcador.
+
+        Parameters:
+            puntos: a nova puntuación
+
+        Examples:
+            >>> marcador = Marcador('Puntos da protagonista: ')
+            >>> marcador.definir_puntos(100)
+        """
+        self.set_score(puntos)
+
+    def decrementar(self, decremento: int = 1):
+        """
+        Incrementa o marcador.
+
+        Parameters:
+            decremento: cantidade a decrementar no marcador
+
+        Examples:
+            >>> scoreboard = Scoreboard('My character: ')
+            >>> scoreboard.decrementar(5)
+        """
+        self.decrease(decremento)
+
+    def incrementar(self, incremento: int = 1):
+        """
+        Incrementa o marcador.
+
+        Parameters:
+            incremento: cantidade a incrementar no marcador
+
+        Examples:
+            >>> scoreboard = Scoreboard('My character: ')
+            >>> scoreboard.incrementar(5)
+        """
+        self.increase(incremento)
+
+
+class Diálogo(SpeechBubble):
+    """Un cadro de diálogo para o videoxogo"""
+
+    def __init__(
+        self,
+        mensaxe: str = '',
+        tempo: int = settings.SPEECH_BUBBLE_DEFAULT_TIME,
+        cor_fondo: tuple = settings.SPEECH_BUBBLE_DEFAULT_BACKGROUND_COLOR,
+        cor_bordo: tuple = settings.SPEECH_BUBBLE_DEFAULT_BORDER_COLOR,
+        groso_bordo: int = settings.SPEECH_BUBBLE_DEFAULT_BORDER_THICKNESS,
+        **kwargs,
+    ):
+        super().__init__(
+            mensaxe, tempo, cor_fondo, cor_bordo, groso_bordo, **kwargs
+        )
+
+    @property
+    def mensaxe(self):
+        """texto coa información mostrada polo cadro de diálogo"""
+        return self.message
+
+    def pintar(self):
+        self.draw()
+
+    def definir_cor_fondo(self, cor: tuple):
+        """
+        Define a cor de fondo para este cadro de diálogo.
+
+        Parameters:
+            cor: unha tupla coa cor (R, G, B)
+
+        Examples:
+            >>> diálogo = Diálogo('teste')
+            >>> diálogo.definir_cor_fondo((255, 0, 0))
+        """
+        self.set_background_color(cor)
+
+    def definir_cor_bordo(self, cor: tuple):
+        """
+        Define a cor do bordo para este cadro de diálogo.
+
+        Parameters:
+            cor: unha tupla coa cor (R, G, B)
+
+        Examples:
+            >>> diálogo = Diálogo('teste')
+            >>> diálogo.definir_cor_bordo((255, 0, 0))
+        """
+        self.set_border_color(cor)
+
+    def definir_límite_tempo(self, tempo: int):
+        """
+        Define o tempo que este cadro de diálogo estará visíbel.
+
+        Parameters:
+            tempo: un número enteiro coa cantidade de segundos ou None para non ter límite de tempo
+
+        Examples:
+            >>> diálogo = Diálogo('teste')
+            >>> diálogo.definir_límite_tempo(5)
+        """
+        self.set_timeout(tempo)
+
+    def actualizar(self):
+        ...

+ 5 - 0
jogai/locale/gl/utils.py

@@ -0,0 +1,5 @@
+from jogai.utils import eval_key
+
+
+def avaliar_tecla(tecla):
+    return eval_key(tecla)

+ 148 - 0
jogai/locale/gl/videoxogo.py

@@ -0,0 +1,148 @@
+from jogai import videogame
+from jogai.settings import settings
+
+__all__ = [
+    'simular_tecla_premida',
+    'tecla_premida',
+    'engadir_personaxe',
+    'engadir_cronómetro',
+    'engadir_grupo',
+    'engadir_barra_de_nivel',
+    'engadir_marcador',
+    'engadir_texto',
+    'iniciar',
+    'está_a_correr',
+    'ouvir_eventos',
+    'obter_cor',
+    'pintar',
+    'pintar_grupo',
+    'definir_bordos',
+    'definir_escenario',
+    'definir_gravidade',
+    'definir_marcas',
+    'definir_personaxes',
+    'definir_textos',
+    'definir_título',
+    'preparar',
+    'cargar_ambiente',
+    'parar',
+    'actualizar',
+    'saír',
+]
+
+
+def simular_tecla_premida(tecla, baixada):
+    videogame.simulate_key_press(tecla, baixada)
+
+
+def tecla_premida(tecla):
+    return videogame.key_pressed(tecla)
+
+
+def engadir_personaxe(novo_personaxe):
+    videogame.add_character(novo_personaxe)
+
+
+def engadir_cronómetro(novo_cronómetro):
+    videogame.add_chronometer(novo_cronómetro)
+
+
+def engadir_grupo(novo_grupo):
+    videogame.add_group(novo_grupo)
+
+
+def engadir_barra_de_nivel(nova_barra):
+    videogame.add_levelbar(nova_barra)
+
+
+def engadir_marcador(novo_marcador):
+    videogame.add_scoreboard(novo_marcador)
+
+
+def engadir_texto(novo_texto):
+    videogame.add_text(novo_texto)
+
+
+def iniciar():
+    videogame.init()
+
+
+def está_a_correr():
+    return videogame.is_running()
+
+
+def ouvir_eventos():
+    videogame.listen_events()
+
+
+def obter_cor(x, y):
+    return videogame.get_color(x, y)
+
+
+def pintar(elemento):
+    videogame.paint(elemento)
+
+
+def pintar_grupo(grupo):
+    videogame.paint_group(grupo)
+
+
+def definir_bordos(bordos):
+    videogame.set_boundaries(bordos)
+
+
+def definir_escenario(dados):
+    videogame.set_scene(dados)
+
+
+def definir_gravidade(gravidade):
+    videogame.set_gravity(gravidade)
+
+
+def definir_marcas(
+    oco_x: int = settings.MARKS_DEFAULT_X_GAP,
+    oco_y: int = settings.MARKS_DEFAULT_Y_GAP,
+    cor_x: tuple = settings.MARKS_DEFAULT_X_COLOR,
+    cor_y: tuple = settings.MARKS_DEFAULT_Y_COLOR,
+    largura: int = settings.MARKS_DEFAULT_WIDTH,
+    depurar: bool = False,
+):
+    videogame.set_marks(oco_x, oco_y, cor_x, cor_y, largura, depurar)
+
+
+def definir_personaxes(dados):
+    videogame.set_characters(dados)
+
+
+def definir_textos(dados):
+    videogame.set_texts(dados)
+
+
+def definir_título(título):
+    videogame.set_caption(título)
+
+
+def preparar(nome_ficheiro_dados):
+    return videogame.prepare(nome_ficheiro_dados)
+
+
+def cargar_ambiente(
+    novo_ambiente: str,
+    elementos_a_transferir: list | dict = [],
+    para_exportar: bool = False,
+):
+    videogame.load_environment(
+        novo_ambiente, elementos_a_transferir, para_exportar
+    )
+
+
+def parar():
+    videogame.stop()
+
+
+def actualizar():
+    videogame.update()
+
+
+def saír(espera: int = settings.DEFAULT_TIME_TO_QUIT):
+    videogame.quit(espera)

+ 167 - 50
jogai/scene.py

@@ -19,24 +19,49 @@
 # info@zero.gal
 
 import os
+from itertools import starmap
 from typing import Tuple
 
 import pygame
 
-from jogai import settings, utils, videogame
+from jogai import utils
 from jogai._globals import _window
+from jogai.settings import paths, settings
 from jogai.translations import gettext as _
 
 
-class Scene:
+def _load_scene(data: dict | str | list | tuple) -> 'Scene':
     """
-    A videogame scene with background color and an optional image.
+    Loads scene from data schema.
+
+    Parameters:
+        data: input schema
+
+    Returns:
+        An object containing the scene
     """
+    if isinstance(data, str):
+        data = {_('filename'): data}
+    if isinstance(data, list) or isinstance(data, tuple):
+        data = {_('background'): data}
+
+    if not isinstance(data, dict):
+        raise TypeError(
+            _('the scene data must be a dict, a string, a list or a tuple')
+        )
+
+    sc = eval(_('Scene'))()
+    if _('filename') in data:
+        sc.change_image(data[_('filename')])
+    if _('background') in data:
+        sc.change_background(data[_('background')])
+    if _('boundaries') in data:
+        sc.set_boundaries(data[_('boundaries')])
+    return sc
 
-    image = None
-    rect = None
-    filename = ''
-    background = settings.SCENE_DEFAULT_BACKGROUND
+
+class Scene:
+    """A videogame scene with background color and an optional image."""
 
     # TODO: Add background as html color
 
@@ -46,16 +71,46 @@ class Scene:
         background: Tuple[int, int, int] = settings.SCENE_DEFAULT_BACKGROUND,
     ):
         if not isinstance(image_filename, str):
-            raise TypeError(_('The image filename must be a string'))
+            raise TypeError(_('the image filename must be a string'))
 
+        self.image = None
+        self.rect = None
+        self._image_filename = ''
+        self.set_boundaries(settings.SCENE_DEFAULT_BOUNDARIES)
         self.change_background(background)
         if image_filename:
             self.change_image(image_filename)
 
+    @property
+    def background(self):
+        return self._background
+
+    @background.setter
+    def background(self, value):
+        self.change_background(value)
+
+    @property
+    def boundaries(self):
+        return self._boundaries
+
+    @boundaries.setter
+    def boundaries(self, value):
+        self.set_boundaries(value)
+
     @property
     def height(self):
         return self.rect.height
 
+    @property
+    def image_filename(self) -> str:
+        """scene's image filename"""
+        return self._image_filename
+
+    @property
+    def image_path(self) -> str:
+        """scene's image path"""
+        return os.path.join(paths.SCENES_DIR, self._image_filename)
+
     @property
     def width(self):
         return self.rect.width
@@ -64,53 +119,32 @@ class Scene:
     def x(self):
         return self.rect.x
 
-    def change_background(self, background: Tuple[int, int, int]):
+    def _process_raw_boundaries(self, raw_boundaries: list | tuple):
         """
-        Change the background color.
+        Translates from boundaries mixed data to pixels (True) or None (False)
 
         Parameters:
-            background: A tuple with the R,G,B components.
-
-        Examples:
-            >>> sc = Scene()
-            >>> sc.change_background((255, 255, 255))
+            raw_boundaries: a tuple or a list with raw desired boundaries
         """
-        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
+        def bool2pixel(raw_boundary, scene_boundary):
+            return (
+                scene_boundary
+                if isinstance(raw_boundary, bool)
+                else raw_boundary
+            )
+
+        raw_boundaries = [None if rb == False else rb for rb in raw_boundaries]
+        return list(
+            starmap(
+                bool2pixel,
+                zip(raw_boundaries, settings.SCENE_DEFAULT_BOUNDARIES),
+            )
         )
-        self.filename = filename
 
-    def set_position(self, x: int):
+    def _set_abscissa_position(self, x: int):
         """
-        Set the scene abscissa position.
+        Sets the scene abscissa position.
         The scene painted will never exceed the edges of the image.
         The x coordinate is a non-positive number (scene is moving to the left).
 
@@ -121,12 +155,15 @@ class Scene:
             >>> sc = Scene('test_large.png')
             >>> sc.set_position(300)
         """
-        if not isinstance(x, int):
-            raise TypeError(_('The x coordinate must be an integer.'))
+        if not type(x) == int:
+            raise TypeError(_('the x coordinate must be an integer'))
 
         # Set x in range to prevent the scene from being outside the window.
         # The minus sign is essential to correctly position the scene.
-        if x <= settings.WINDOW_WIDTH // 2:
+        if (
+            self.width <= settings.WINDOW_WIDTH
+            or x <= settings.WINDOW_WIDTH // 2
+        ):
             self.rect.x = 0
         elif (
             settings.WINDOW_WIDTH // 2
@@ -136,3 +173,83 @@ class Scene:
             self.rect.x = -x + settings.WINDOW_WIDTH // 2
         else:
             self.rect.x = -self.rect.width + settings.WINDOW_WIDTH
+
+    def change_background(self, background: Tuple[int, int, int]):
+        """
+        Changes the background color.
+
+        Parameters:
+            background: a tuple with the color components (R, G, B)
+
+        Examples:
+            >>> sc = Scene()
+            >>> sc.change_background((255, 255, 255))
+        """
+        if isinstance(background, list):
+            background = tuple(background)
+
+        if not isinstance(background, tuple):
+            raise TypeError(
+                _(
+                    'the background color must be a tuple with three integers (r,g,b) between 0 and 255'
+                )
+            )
+
+        if len(background) != 3 or not all(
+            map(lambda x: type(x) == int and 0 <= x <= 255, background)
+        ):
+            raise ValueError(
+                _(
+                    'the background color must be a tuple with three integers (r,g,b) between 0 and 255'
+                )
+            )
+
+        self._background = background
+        if self.rect is None:
+            self.rect = pygame.Rect(
+                0, 0, settings.WINDOW_WIDTH, settings.WINDOW_HEIGHT
+            )
+
+    def change_image(self, filename: str):
+        """
+        Changes 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(paths.SCENES_DIR, filename)
+        self._image_filename = filename
+        self._boundaries = [0, self.width, self.height, 0]
+
+    # TODO: get_background_color
+    """
+    def get_background_color(self, x: int, y: int) -> tuple:
+        """ """
+        return self.image.get_at((x, y))[:3]
+    """
+
+    def set_boundaries(self, boundaries: bool | list):
+        """
+        Sets the character's movement limits.
+
+        Parameters:
+            boundaries: a list with boundaries in pixels (integers) or booleans (top, right, bottom, left). Also, a unique boolean can be used for activating all scene boundaries
+
+        Examples:
+            >>> sc = Scene()
+            >>> sc.set_boundaries([False, True, 500, 100]) # ceiling without boundary, right side with scene boundary, floor at 500px, left side at 100px
+            >>> sc.set_boundaries(True)
+        """
+
+        if isinstance(boundaries, bool):
+            boundaries = [boundaries] * 4
+
+        if not isinstance(boundaries, list):
+            raise TypeError(
+                _('the scene boundaries must be a list or a boolean')
+            )
+        self._boundaries = self._process_raw_boundaries(boundaries)

+ 0 - 62
jogai/settings.py

@@ -1,62 +0,0 @@
-"""
-Videogame settings
-"""
-
-from os.path import abspath, dirname, join
-
-from jogai.translations import gettext as _
-
-LANGUAGE_CODE = 'gl'
-
-KEY_DELAY = 1
-KEY_INTERVAL = 25
-FPS = 40
-
-WINDOW_WIDTH = 800
-WINDOW_HEIGHT = 600
-SCENE_DEFAULT_BACKGROUND = (0, 0, 0)
-
-# PATHS
-BASE_DIR = dirname((abspath(__file__)))
-SCENES_DIR = join(_('images'), _('scenes'))
-CHARACTERS_DIR = join(_('images'), _('characters'))
-FONTS_DIR = _('fonts')
-
-DEFAULT_DATA_FILENAME = 'default_data.yaml'
-DATA_DIR = _('data')
-
-# CHARACTER FEATURES
-CHARACTER_DEFAULT_JUMP_FORCE = 10.0
-CHARACTER_DEFAULT_THUMBNAIL_SIZE = 40
-
-
-# ------- #
-# WIDGETS #
-# ------- #
-
-# CHRONOMETERS
-CHRONOMETERS_DEFAULT_HEIGHT = 60
-CHRONOMETERS_DEFAULT_WIDTH = 90
-CHRONOMETERS_DEFAULT_POSITION = (
-    WINDOW_WIDTH - CHRONOMETERS_DEFAULT_WIDTH - 20,
-    WINDOW_HEIGHT - CHRONOMETERS_DEFAULT_HEIGHT - 20,
-)
-
-# SCOREBOARDS
-SCOREBOARDS_DEFAULT_POSITION = (20, 20)
-
-# LEVELBARS
-LEVELBARS_MAX_PARTS = 25
-LEVELBARS_DEFAULT_WIDTH = 180
-LEVELBARS_DEFAULT_HEIGHT = 20
-LEVELBARS_DEFAULT_THICK = 3
-LEVELBARS_DEFAULT_BORDER_COLOR = (200, 0, 0)
-LEVELBARS_DEFAULT_FILL_COLOR = (241, 74, 6)
-LEVELBARS_DEFAULT_POSITION = (WINDOW_WIDTH - LEVELBARS_DEFAULT_WIDTH - 20, 25)
-
-# INVENTORY
-INVENTORY_DEFAULT_BACKGROUND_COLOR = (0, 0, 0, 180)
-INVENTORY_DEFAULT_TEXT_COLOR = (255, 255, 255)
-INVENTORY_DEFAULT_ITEMS_BY_ROW = 6
-INVENTORY_LEFT_MARGIN = 50
-INVENTORY_TOP_MARGIN = 50

+ 21 - 0
jogai/settings/paths.py

@@ -0,0 +1,21 @@
+from pathlib import Path
+
+from jogai.translations import gettext as _
+
+IMAGES_DIR = Path(_('images'))
+SCENES_DIR = IMAGES_DIR / _('scenes')
+CHARACTERS_DIR = IMAGES_DIR / _('characters')
+FONTS_DIR = Path(_('fonts'))
+DATA_DIR = Path(_('data'))
+
+# -------- #
+# EXPORTER #
+# -------- #
+
+
+EXPORT_CSS_SUBDIR = 'css'
+EXPORT_HTML_SUBDIR = 'html'
+EXPORT_JS_SUBDIR = 'js'
+EXPORT_MAIN_DIR = 'web'
+EXPORTER_PATH = Path('exporter')
+EXPORTER_TEMPLATES_PATH = EXPORTER_PATH / 'assets'

+ 99 - 0
jogai/settings/settings.py

@@ -0,0 +1,99 @@
+"""
+Videogame settings
+"""
+from os import getenv
+from os.path import abspath, dirname
+
+BASE_DIR = dirname(dirname((abspath(__file__))))
+
+"""
+if getenv('LANG'):
+    LANGUAGE_CODE = getenv('LANG')
+else:
+    LANGUAGE_CODE = 'en'
+"""
+LANGUAGE_CODE = 'en'
+
+KEY_DELAY = 1
+KEY_INTERVAL = 25
+FPS = 40
+
+WINDOW_WIDTH = 800
+WINDOW_HEIGHT = 600
+SCENE_DEFAULT_BACKGROUND = (0, 0, 0)
+SCENE_DEFAULT_BOUNDARIES = [0, WINDOW_WIDTH, WINDOW_HEIGHT, 0]
+
+DEFAULT_TIME_TO_QUIT = 0
+
+# CHARACTER FEATURES
+CHARACTER_DEFAULT_COLLIDABLE = False
+CHARACTER_DEFAULT_GRAVITATIVE = False
+CHARACTER_DEFAULT_JUMP_FORCE = 4
+CHARACTER_DEFAULT_JUMP_STEPS = 12
+CHARACTER_DEFAULT_JUMP_IN_THE_AIR = False
+CHARACTER_DEFAULT_SPEAK_TIME = 4
+CHARACTER_DEFAULT_THUMBNAIL_SIZE = 40
+CHARACTER_DEFAULT_TOUCHABLE = True
+CHARACTER_DEFAULT_VISIBLE = True
+
+# MARKS
+MARKS_DEFAULT_X_GAP = 50
+MARKS_DEFAULT_Y_GAP = 50
+MARKS_DEFAULT_X_COLOR = (255, 180, 30)
+MARKS_DEFAULT_Y_COLOR = (0, 0, 255)
+MARKS_DEFAULT_WIDTH = 1
+MARKS_FONT = 'FreeMono'
+MARKS_SIZE = 10
+
+# ------- #
+# WIDGETS #
+# ------- #
+
+# TEXT
+TEXT_DEFAULT_FONT = 'dejavusans'
+TEXT_DEFAULT_FONT_SIZE = 16
+TEXT_DEFAULT_COLOR = (255, 255, 255)
+TEXT_SHADOW_DEFAULT_COLOR = (0, 0, 0)
+TEXT_DEFAULT_POSITION = (0, 0)
+TEXT_SHADOW_DEFAULT_DISPLACEMENT = (2, 2)
+TEXT_DEFAULT_TRACKING = False
+
+# CHRONOMETERS
+CHRONOMETERS_DEFAULT_INITIAL_VALUE = 0
+CHRONOMETERS_DEFAULT_IN_MILLISECONDS = False
+CHRONOMETERS_DEFAULT_DELIMITER = ':'
+CHRONOMETERS_DEFAULT_MILLIS_DELIMITER = '.'
+CHRONOMETERS_DEFAULT_HEIGHT = 60
+CHRONOMETERS_DEFAULT_WIDTH = 90
+CHRONOMETERS_DEFAULT_POSITION = (
+    WINDOW_WIDTH - CHRONOMETERS_DEFAULT_WIDTH - 20,
+    WINDOW_HEIGHT - CHRONOMETERS_DEFAULT_HEIGHT - 20,
+)
+
+# SCOREBOARDS
+SCOREBOARDS_DEFAULT_POSITION = (20, 20)
+
+# LEVELBARS
+LEVELBARS_MAX_PARTS = 25
+LEVELBARS_DEFAULT_WIDTH = 180
+LEVELBARS_DEFAULT_HEIGHT = 20
+LEVELBARS_DEFAULT_THICK = 3
+LEVELBARS_DEFAULT_BORDER_COLOR = (200, 0, 0)
+LEVELBARS_DEFAULT_FILL_COLOR = (241, 74, 6)
+LEVELBARS_DEFAULT_POSITION = (WINDOW_WIDTH - LEVELBARS_DEFAULT_WIDTH - 20, 25)
+
+# INVENTORY
+INVENTORY_DEFAULT_BACKGROUND_COLOR = (0, 0, 0, 180)
+INVENTORY_DEFAULT_TEXT_COLOR = (255, 255, 255)
+INVENTORY_DEFAULT_FONT_SIZE = 30
+INVENTORY_DEFAULT_ITEMS_BY_ROW = 6
+INVENTORY_LEFT_MARGIN = 50
+INVENTORY_TOP_MARGIN = 50
+
+# SPEECH BUBBLE
+SPEECH_BUBBLE_DEFAULT_BACKGROUND_COLOR = (128, 128, 255)
+SPEECH_BUBBLE_DEFAULT_BORDER_COLOR = (0, 0, 0)
+SPEECH_BUBBLE_DEFAULT_BORDER_THICKNESS = 2
+SPEECH_BUBBLE_DEFAULT_BORDER_RADIUS = 15
+SPEECH_BUBBLE_DEFAULT_MARGIN = (10, 10, 10, 10)
+SPEECH_BUBBLE_DEFAULT_TIME = 4

+ 764 - 0
jogai/texts.py

@@ -0,0 +1,764 @@
+import re
+from os.path import join
+from time import sleep
+from typing import Tuple, Type
+
+import pygame
+
+from jogai import _globals
+from jogai.settings import paths, settings
+from jogai.translations import gettext as _
+from jogai.utils import _get_keys, eval_key
+
+
+def _load_texts(data_texts: dict) -> list:
+    """
+    Loads texts from data schema.
+
+    Parameters:
+        data: input schema
+
+    Returns:
+        A list containing all loaded texts
+    """
+
+    texts = []
+    for text_name, text_data in data_texts.items():
+        loaded_text = eval('{}({}=text_name)'.format(_('Text'), _('name')))
+        loaded_text._load_data(text_data)
+        texts.append(loaded_text)
+    return texts
+
+
+class Text:
+    """A text for the videogame."""
+
+    _predefined_attributes_allowed = (
+        _('font'),
+        _('message'),
+        _('position'),
+        _('size'),
+        _('visible'),
+        _('tracking'),
+    )
+
+    def __init__(
+        self,
+        message: str = '',
+        size: int = settings.TEXT_DEFAULT_FONT_SIZE,
+        color: tuple | list = settings.TEXT_DEFAULT_COLOR,
+        shadow_color: tuple | list = settings.TEXT_SHADOW_DEFAULT_COLOR,
+        position: tuple | list = settings.TEXT_DEFAULT_POSITION,
+        font: str = '',
+        bold: bool = False,
+        italic: bool = False,
+        name: str = '',
+    ):
+        """
+        Constructs a new text for the videogame.
+
+        Parameters:
+            message: a message for this text
+            size: the initial font size
+            color: the initial text color
+            shadow_color: the initial text shadow color
+            position: the initial position
+            font: the initial font
+            bold: True if the initial text will be in bold, False otherwise
+            italic: True if the initial text will be in italics, False otherwise
+            name: a label name for this text
+
+        Examples:
+            >>> warning_text = Text('Danger!', 20, (255, 0, 0), (0, 0))
+        """
+        # TODO: Implement shadow
+        if not type(size) == int:
+            raise TypeError(_('the font size must be an integer'))
+        if size < 1:
+            raise ValueError(_('the font size must be a positive integer'))
+        if not isinstance(font, str):
+            raise TypeError(_('the font must be a string'))
+        if not isinstance(bold, bool):
+            raise TypeError(_('the bold argument must be a boolean'))
+        if not isinstance(italic, bool):
+            raise TypeError(_('the italic argument must be a boolean'))
+
+        if not pygame.font.get_init():
+            pygame.font.init()
+        self._background_color = ()
+        self._color = color
+        self._name = name
+        self._message = message
+        self._shadow_color = shadow_color
+        self._tracking = settings.TEXT_DEFAULT_TRACKING
+        self._visible = False
+        self._x = 0
+        self._y = 0
+        self.rect = None
+        self.shadow_rect = None
+        self.set_font(font, size)
+        self._font.set_bold(bold)
+        self._font.set_italic(italic)
+        self.set_position(position)
+        self.set_message(message)
+        self.render()
+
+    @property
+    def x(self) -> int:
+        """text abscissa value"""
+        return self._x
+
+    @x.setter
+    def x(self, value):
+        """sets the text abscissa value"""
+        self.set_position((value, self.y))
+
+    @property
+    def y(self) -> int:
+        """text ordinate value"""
+        return self._y
+
+    @y.setter
+    def y(self, value):
+        """sets the text ordinate value"""
+        self.set_position((self.x, value))
+
+    @property
+    def background_color(self):
+        """background color"""
+        return self._background_color
+
+    @property
+    def color(self):
+        """text color"""
+        return self._color
+
+    @property
+    def font(self):
+        """text font name"""
+        return self._font_name
+
+    @property
+    def message(self):
+        """text message"""
+        return self._message
+
+    @property
+    def name(self) -> str:
+        """text's name"""
+        return self._name
+
+    @property
+    def shadow_color(self):
+        """text shadow color"""
+        return self._shadow_color
+
+    @property
+    def size(self):
+        """font size"""
+        return self._size
+
+    @property
+    def tracking(self):
+        """text tracking"""
+        return self._tracking
+
+    @tracking.setter
+    def tracking(self, value):
+        """set text tracking"""
+        self.set_tracking(value)
+
+    @property
+    def visible(self):
+        """text visibility"""
+        return self._visible
+
+    @visible.setter
+    def visible(self, value):
+        """sets the text visibility"""
+        self.set_visible(value)
+
+    def _load_data(self, data: dict):
+        """
+        Loads text's attributes from data schema.
+        Only declarated attributes in predefined_attributes_allowed are allowed.
+
+        Parameters:
+            data: input schema
+        """
+        if data is None:
+            data = {}
+
+        if not isinstance(data, dict):
+            raise TypeError(_('the data input must be a dict'))
+
+        for name, value in data.items():
+            attribute = re.sub(r'\s+', '_', name)
+            if name in self._predefined_attributes_allowed:
+                if isinstance(value, str):
+                    value = f'"{value}"'
+                eval(_('self.set_{}({})').format(name, str(value)))
+            else:
+                setattr(self, name, value)
+
+    def _update_rect_position(self) -> Type['pygame.Rect']:
+        """
+        Calculates and updates the position where the text must be painted.
+
+        Returns:
+            A rect placed in the videogame window.
+        """
+
+        if not _globals.vg_scene:
+            return None
+
+        if self.x + self.rect.width > _globals.vg_scene.width:
+            self.x = _globals.vg_scene.width - self.rect.width - 10
+        if not self.tracking:
+            self.rect.x = self.x + _globals.vg_scene.x
+        self.rect.y = self.y
+        self.shadow_rect.x = (
+            self.rect.x + settings.TEXT_SHADOW_DEFAULT_DISPLACEMENT[0]
+        )
+        self.shadow_rect.y = (
+            self.rect.y + settings.TEXT_SHADOW_DEFAULT_DISPLACEMENT[1]
+        )
+
+    def draw(self):
+        """
+        Paints this text on the main window.
+        """
+        if self._background_color:
+            pygame.draw.rect(
+                _globals._window, self._background_color, self.rect
+            )
+
+        _globals._window.blit(self.shadow_image, self.shadow_rect)
+        _globals._window.blit(self.image, self.rect)
+
+    def hide(self):
+        """Hides this text"""
+        self.set_visible(False)
+
+    def render(self):
+        self.image = self._font.render(
+            ' '.join(self.message), True, self._color
+        )
+        self.shadow_image = self._font.render(
+            ' '.join(self.message), True, self._shadow_color
+        )
+
+        if self.rect is None or self.message:
+            self.rect = self.image.get_rect()
+            self.rect.move_ip(self.x, self.y)
+
+        if self.shadow_rect is None or self.message:
+            self.shadow_rect = self.shadow_image.get_rect()
+            self.shadow_rect.move_ip(self.x + 1, self.y + 1)
+
+    def set_font(self, font: str, size: int):
+        """
+        Sets the font for this text.
+
+        Parameters:
+            font: the font to be loaded
+            size: the font size
+
+        Examples:
+            >>> t = Text('Test')
+            >>> t.set_font('freefont')
+        """
+        if not isinstance(font, str):
+            raise TypeError(_('the font must be a string'))
+        if not type(size) == int or size <= 0:
+            raise TypeError(_('the font size must be a positive integer'))
+        try:
+            if font:
+                self._font = pygame.font.Font(
+                    join(paths.FONTS_DIR, font), size
+                )
+            else:
+                self._font = pygame.font.SysFont(
+                    settings.TEXT_DEFAULT_FONT, size
+                )
+        except FileNotFoundError:
+            self._font = pygame.font.SysFont(None, size)
+        self._font_name = font
+        self._size = size
+        self.render()
+
+    def set_message(self, message: str | tuple | list | None):
+        """
+        Sets the message for this text.
+
+        Parameters:
+            message: the message to show
+
+        Examples:
+            >>> t = Text('Test')
+            >>> t.set_message('ola')
+        """
+        if message is None:
+            message = ''
+        if isinstance(message, str):
+            message = message.split('\n')
+        elif isinstance(message, tuple):
+            message = list(message)
+
+        if not isinstance(message, list):
+            raise TypeError(
+                _('the message must be a string, a list or a tuple')
+            )
+        self._message = message
+        self.render()
+
+    def set_position(self, position: tuple | list, key: str = ''):
+        """
+        Sets the text position as tuple (x, y).
+
+        Parameters:
+            position: a tuple with abscissa and ordinate coordinates
+            key: optional key to perform the action
+
+        Examples:
+            >>> t = Text('Test')
+            >>> t.set_position((200, 200))
+        """
+        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)'))
+
+        if not key or _get_keys()[eval_key(key)]:
+            self._x = position[0]
+            self._y = position[1]
+
+    def set_size(self, size: int):
+        """
+        Sets the font size for this text.
+
+        Parameters:
+            size: the font size
+
+        Examples:
+            >>> t = Text('Test')
+            >>> t.set_size(20)
+        """
+        self.set_font(self._font_name, size)
+
+    def set_tracking(self, tracking: bool, key: str = ''):
+        """
+        Sets whether the text is tracked
+
+        Parameters:
+            tracking: True if this text is tracked, False otherwise
+
+        Examples:
+            >>> t = Text('Test')
+            >>> t.set_tracking(True)
+        """
+        if tracking is None:
+            tracking = not self._tracking
+
+        if not isinstance(tracking, bool):
+            TypeError(_('the tracking attribute must be a boolean'))
+
+        if not key or _get_keys()[eval_key(key)]:
+            self._tracking = tracking
+
+    def set_visible(self, visible: bool | None = None, key: str = ''):
+        """
+        Sets whether the text is visible.
+
+        Parameters:
+            visible: True if this text is visible, False otherwise
+
+        Examples:
+            >>> t = Text('Test')
+            >>> t.set_visible(False)
+        """
+        if visible is None:
+            visible = not self._visible
+
+        if not isinstance(visible, bool):
+            TypeError(_('the visible attribute must be a boolean'))
+
+        if not key or _get_keys()[eval_key(key)]:
+            if key:
+                sleep(0.2)
+            self._visible = visible
+
+    def show(self):
+        """Shows this text"""
+        self.set_visible(True)
+
+
+class Chronometer(Text):
+    """A chronometer for the videogame"""
+
+    def __init__(
+        self,
+        initial: int = settings.CHRONOMETERS_DEFAULT_INITIAL_VALUE,
+        in_milliseconds: bool = settings.CHRONOMETERS_DEFAULT_IN_MILLISECONDS,
+        **kwargs,
+    ):
+        if not type(initial) == int:
+            raise TypeError(_('the initial value must be an integer'))
+        if initial < 0:
+            raise ValueError(_('the countdown cannot be negative'))
+        if not isinstance(in_milliseconds, bool):
+            raise TypeError(_('in_milliseconds argument must be a boolean'))
+
+        super().__init__(**kwargs)
+        self._initial = initial if in_milliseconds else initial * 1000
+        self._time = 0
+        self._timestamp = 0
+        self._onpause = True
+        self._message = self.format_clock()
+        self.set_tracking(True)
+
+    def __int__(self):
+        return self.get_milliseconds()
+
+    def __repr__(self):
+        return _('Chronometer <{}>').format(self.format_clock())
+
+    def __str__(self):
+        return self._message
+
+    @property
+    def time(self):
+        """current time on the chronometer"""
+        return self.get_milliseconds()
+
+    @property
+    def onpause(self) -> bool:
+        """check if this chronometer is on pause or running"""
+        return self._onpause
+
+    def format_clock(
+        self,
+        include_millis: bool = False,
+        delimiter: str = settings.CHRONOMETERS_DEFAULT_DELIMITER,
+        delimiter_millis: str = settings.CHRONOMETERS_DEFAULT_MILLIS_DELIMITER,
+    ) -> str:
+        """
+        Creates a string with the chronometer time information, formatted as a digital clock.
+
+        Parameters:
+            include_millis: True if milliseconds must be present. False, otherwise.
+            delimiter: string delimiter between hours, minutes and seconds.
+            delimiter_millis: string delimiter between seconds and milliseconds.
+
+        Returns:
+            A string with the formatted time information.
+        """
+        if not isinstance(include_millis, bool):
+            raise TypeError(_('include_millis must be a boolean'))
+        if not isinstance(delimiter, str):
+            raise TypeError(_('the delimiter must be a string'))
+        if not isinstance(delimiter_millis, str):
+            raise TypeError(
+                _('the delimiter for milliseconds must be a string')
+            )
+
+        formatted_time = tuple(
+            map(lambda x: str(x).zfill(2), self.get_clock())
+        )
+        formatted_clock = ':'.join(formatted_time[:-1])
+        if include_millis:
+            formatted_clock += f'.{formatted_time[-1]}'
+        return formatted_clock
+
+    def get_clock(self) -> tuple:
+        """
+        Gets the current chronometer time information as separated values.
+
+        Returns:
+            A tuple with the time information (hours, minutes, seconds, milliseconds)
+        """
+        millis = self.get_milliseconds()
+        if millis > 0:
+            quotient = millis // 1000
+            millis = millis % 1000
+            seconds = quotient % 60
+            quotient = quotient // 60
+            minutes = quotient % 60
+            hours = quotient // 60
+            return hours, minutes, seconds, millis
+        return 0, 0, 0, 0
+
+    def get_milliseconds(self) -> int:
+        """
+        Gets the current chronometer time information in milliseconds.
+
+        Returns:
+            An integer with the current time in milliseconds.
+        """
+        if self._onpause:
+            current = (
+                self._initial - self._time if self._initial else self._time
+            )
+        else:
+            if self._initial:
+                current = self._initial - (
+                    self._time + pygame.time.get_ticks() - self._timestamp
+                )
+            else:
+                current = (
+                    self._time + pygame.time.get_ticks() - self._timestamp
+                )
+
+        current = max(0, current)
+        return current
+
+    def get_seconds(self) -> int:
+        """
+        Gets the current chronometer time information in seconds.
+
+        Returns:
+            An integer with the current time in seconds.
+        """
+        return self.get_milliseconds() // 1000
+
+    def set_clock(self, seconds: int):
+        """
+        Sets a new time in seconds for the current chronometer.
+
+        Parameters:
+            seconds: amount of seconds
+        """
+        self._initial = seconds * 1000
+        self.reset()
+
+    def start(self):
+        """Start the chronometer count."""
+        self._time = 0
+        self._timestamp = pygame.time.get_ticks()
+        self._onpause = False
+
+    def pause(self):
+        """Pause the chronometer count."""
+        if not self._onpause:
+            self._onpause = True
+            self._time = self._time + pygame.time.get_ticks() - self._timestamp
+
+    def reset(self):
+        """
+        Resets the chronometer count.
+        """
+        self._time = 0
+        self._timestamp = pygame.time.get_ticks()
+
+    def resume(self):
+        """
+        Resumes the chronometer count.
+        """
+        if self._onpause:
+            self._onpause = False
+            self._timestamp = pygame.time.get_ticks()
+
+    def update(self):
+        """
+        Updates the text message with the current time information.
+        """
+        self._message = self.format_clock()
+        self.render()
+
+
+class Scoreboard(Text):
+    """A scoreboard for the videogame"""
+
+    def __init__(self, prefix: str, score: int = 0, **kwargs):
+        if not isinstance(prefix, str):
+            raise TypeError(_('the prefix text must be a string'))
+        super().__init__(**kwargs)
+        self._prefix = prefix
+        self.set_score(score)
+        self.set_tracking(True)
+        self.render()
+
+    def __int__(self):
+        return int(self._score)
+
+    def __repr__(self):
+        return f'Scoreboard <{self._message}>'
+
+    def __str__(self):
+        return self._message
+
+    @property
+    def score(self):
+        """an integer with the current score"""
+        return self._score
+
+    @property
+    def message(self):
+        """text with the information displayed by the scoreboard"""
+        return self._message
+
+    def set_score(self, score: int):
+        """
+        Sets the character score.
+
+        Parameters:
+            score: the new character score
+
+        Examples:
+            >>> scoreboard = Scoreboard('Protagonist's score: ')
+            >>> scoreboard.set_score(100)
+        """
+        if not type(score) == int:
+            raise TypeError(_('the score must be an integer'))
+        self._score = score
+        self._message = f'{self._prefix}{self._score}'
+        self.render()
+
+    def decrease(self, decrement: int = 1):
+        """
+        Decreases the character score.
+
+        Parameters:
+            decrement: amount to decrease to the scoreboard
+
+        Examples:
+            >>> scoreboard = Scoreboard('My character: ')
+            >>> scoreboard.decrement(5)
+        """
+        if not type(decrement) == int:
+            raise TypeError(_('the decrement must be an integer'))
+        self._score += decrement
+
+    def increase(self, increment: int = 1):
+        """
+        Increases the character score.
+
+        Parameters:
+            increment: amount to increase to the scoreboard
+
+        Examples:
+            >>> scoreboard = Scoreboard('My character: ')
+            >>> scoreboard.increment(5)
+        """
+        if not type(increment) == int:
+            raise TypeError(_('the increment must be an integer'))
+        self._score += increment
+
+
+class SpeechBubble(Text):
+    """A speech bubble for the videogame"""
+
+    def __init__(
+        self,
+        message: str = '',
+        time: int = settings.SPEECH_BUBBLE_DEFAULT_TIME,
+        background_color: tuple = settings.SPEECH_BUBBLE_DEFAULT_BACKGROUND_COLOR,
+        border_color: tuple = settings.SPEECH_BUBBLE_DEFAULT_BORDER_COLOR,
+        border_thickness: int = settings.SPEECH_BUBBLE_DEFAULT_BORDER_THICKNESS,
+        **kwargs,
+    ):
+        super().__init__(**kwargs)
+        self.set_timeout(time)
+        self.set_background_color(background_color)
+        self.set_border_color(border_color)
+        self.set_message(message)
+        if _('position') in kwargs:
+            self.render(position=kwargs[_('position')])
+        else:
+            self.render()
+
+    def __repr__(self):
+        return f'SpeechBubble <{self._message}>'
+
+    def __str__(self):
+        return self._message
+
+    @property
+    def message(self):
+        """text with the information displayed by the speech bubble"""
+        return '\n'.join(self._message)
+
+    def draw(self):
+        if self._background_color:
+            shape = pygame.Surface(self.rect.size, pygame.SRCALPHA)
+            rect_with_margin = self.rect.copy()
+            margin = settings.SPEECH_BUBBLE_DEFAULT_MARGIN
+            rect_with_margin.x -= margin[3]
+            rect_with_margin.y -= margin[0]
+            rect_with_margin.width += margin[3] + margin[1]
+            rect_with_margin.height += margin[0] + margin[2]
+            pygame.draw.rect(
+                _globals._window,
+                self._background_color,
+                rect_with_margin,
+                border_radius=settings.SPEECH_BUBBLE_DEFAULT_BORDER_RADIUS,
+            )
+            pygame.draw.rect(
+                _globals._window,
+                self._border_color,
+                rect_with_margin,
+                settings.SPEECH_BUBBLE_DEFAULT_BORDER_THICKNESS,
+                settings.SPEECH_BUBBLE_DEFAULT_BORDER_RADIUS,
+            )
+
+        _globals._window.blit(self.image, self.rect)
+
+    def set_background_color(self, color: tuple):
+        """
+        Sets the background color for this speech bubble.
+
+        Parameters:
+            color: a tuple with the color (R, G, B)
+
+        Examples:
+            >>> bubble = SpeechBubble('test')
+            >>> bubble.set_background_color((255, 0, 0))
+        """
+        if not isinstance(color, tuple):
+            raise TypeError(
+                _('the background color for the speech bubble must be a tuple')
+            )
+        self._background_color = color
+
+    def set_border_color(self, color: tuple):
+        """
+        Sets the border color for this speech bubble.
+
+        Parameters:
+            color: a tuple with the color (R, G, B)
+
+        Examples:
+            >>> bubble = SpeechBubble('test')
+            >>> bubble.set_border_color((255, 0, 0))
+        """
+        if not isinstance(color, tuple):
+            raise TypeError(
+                _('the border color for the speech bubble must be a tuple')
+            )
+        self._border_color = color
+
+    def set_timeout(self, time: int):
+        """
+        Sets the timeout for this speech bubble.
+
+        Parameters:
+            time: the timeout in seconds or None for no timeout
+
+        Examples:
+            >>> bubble = SpeechBubble('test')
+            >>> bubble.set_timeout(5)
+        """
+        if not type(time) == int:
+            TypeError(
+                _(
+                    'the timeout for the speech bubble must be a non-negative integer'
+                )
+            )
+        if time > 0:
+            self.timeout = Chronometer(time)
+        else:
+            self.timeout = None
+
+    def update(self):
+        ...

+ 23 - 83
jogai/translations.py

@@ -18,33 +18,15 @@
 # Zero! Factorial Studio
 # info@zero.gal
 
+import ast
 import gettext as gettext_module
 import os
 import sys
 import warnings
 
-from jogai import settings
+from jogai.settings 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:]
-    country, _, tail = country.partition('-')
-    country = country.title() if len(country) > 2 else country.upper()
-    if tail:
-        country += '-' + tail
-    return lang + '_' + country
+_language = None
 
 
 def gettext(message):
@@ -53,71 +35,29 @@ def gettext(message):
 
     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'
+    global _language
 
-    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
+    if not _language:
+        install()
+    return _language.gettext(message)
 
-        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()
+def install():
+    global _language
+    locale_path = os.path.join(settings.BASE_DIR, 'locale')
+    gettext_module.bindtextdomain('messages', locale_path)
+    gettext_module.textdomain('messages')
 
-        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}.'
-            )
+    _language = gettext_module.translation(
+        'messages',
+        locale_path,
+        languages=[to_language(settings.LANGUAGE_CODE)],
+        fallback=True,
+    )
+    _language.install()
 
-    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
+def to_language(locale):
+    """Extract a language (pt) from locale info (pt_PT.UTF-8)."""
+    lang, _, _ = locale.lower().partition('_')
+    return lang

+ 45 - 21
jogai/utils.py

@@ -19,42 +19,46 @@
 # info@zero.gal
 
 import gettext as gettext_module
-import os
 import sys
 import warnings
 from collections import OrderedDict
+from pathlib import Path
 from typing import Any
 
 import pygame
 import yaml
 from pygame.locals import *
 
-from jogai import _globals
-from jogai import exceptions as exc
-from jogai import logger, settings
+from jogai import _globals, exceptions, logger
+from jogai.settings import paths, settings
 from jogai.translations import gettext as _
 
 
 def _load_image(directory: str, filename: str) -> list:
+
+    directory = Path(directory)
+    filename = Path(filename)
+
+    """
     if not isinstance(directory, str) or not isinstance(filename, str):
         raise TypeError(
-            _('Both the directory and the filename must be strings.')
+            _('both the directory and the filename must be strings')
         )
-
-    path = os.path.join(directory, filename)
+    """
+    path = directory / filename
     try:
         image = pygame.image.load(path)
     except FileNotFoundError as fnfe:
-        raise FileNotFoundError(_('Image filename not found'))
+        raise FileNotFoundError(_('image filename not found: {}').format(path))
     except pygame.error as pe:
-        raise exc.UnsupportedFileFormatError(filename)
+        raise exceptions.UnsupportedFileFormatError(filename)
 
     rect = image.get_rect()
     return [image, rect]
 
 
 def _load_yaml(stream: str) -> OrderedDict:
-    """Create an OrderedDict from a yaml string.
+    """Creates an OrderedDict from a yaml string.
 
     Parameters:
         stream: data input stream
@@ -77,17 +81,17 @@ def _load_yaml(stream: str) -> OrderedDict:
 
 
 def _load_yaml_from_filename(
-    filename: str, directory=settings.DATA_DIR
+    filename: str, directory=paths.DATA_DIR
 ) -> OrderedDict:
-    path = os.path.join(directory, filename)
-    with open(path) as file:
+    path = Path(directory) / filename
+    with path.open() 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.
+    Converts function arguments from yaml data to string.
 
     Parameters:
         arguments: The function arguments from yaml item.
@@ -118,13 +122,28 @@ def _get_events() -> list:
     return pygame.event.get()
 
 
-def _get_keys() -> list:
-    return pygame.key.get_pressed()
+def _get_keys(key: str = '') -> list | int:
+    if not key:
+        return pygame.key.get_pressed()
+    try:
+        return _get_keys()[eval_key(key)]
+    except (IndexError, exceptions.KeyPressedNotFoundError):
+        return []
+
 
+def dict_to_kwargs_string(input: dict) -> Any:
+    if not isinstance(input, dict):
+        return input
 
-def eval_key(key: str) -> int:
+    return ', '.join(
+        f"{k}='{v}'" if isinstance(v, str) else f'{k}={v}'
+        for k, v in input.items()
+    )
+
+
+def eval_key(key: str) -> int | None:
     """
-    Evaluate a string as a pygame key.
+    Evaluates a string as a pygame key.
 
     Parameters:
         key: string representation of a key with or without 'K_' prefix
@@ -137,15 +156,20 @@ def eval_key(key: str) -> int:
         The integer representing the input key.
     """
     if not isinstance(key, str):
-        raise exc.KeyPressedNotFoundError
-
+        raise exceptions.KeyPressedNotFoundError
     try:
         if key.startswith('K_'):
             return eval(key)
         else:
             return eval(f'K_{key}')
     except NameError:
-        raise exc.KeyPressedNotFoundError
+        raise exceptions.KeyPressedNotFoundError
+
+
+def tuple_to_args_string(input: tuple) -> Any:
+    if not isinstance(input, tuple):
+        return input
+    return ', '.join(f"'{v}'" if isinstance(v, str) else str(v) for v in input)
 
 
 class Marks:

+ 269 - 148
jogai/videogame.py

@@ -21,103 +21,81 @@
 import builtins
 import os
 import time
-from typing import Tuple, Type
+from collections import OrderedDict
+from typing import Any, Tuple, Type
 
 import pygame
 
 import jogai
 import jogai._globals as _globals
-import jogai.settings as settings
-from jogai import character, group, logger, scene, utils, widgets
+from jogai import character, groups, logger, scene, texts, utils, widgets
+from jogai.settings import settings
 from jogai.translations import gettext as _
 from jogai.utils import _get_events, _get_keys
 
+_PREDEFINED_ATTRIBUTES_ALLOWED = (
+    _('caption'),
+    _('characters'),
+    _('gravity'),
+    _('marks'),
+    _('scene'),
+    _('texts'),
+)
+
 # --------------- #
 #    GLOBALS      #
 # --------------- #
 
 running = True
-_predefined_attributes_allowed = [
-    _('caption'),
-    _('gravity'),
-]
 
 # ------------------ #
 #    FUNCTIONS       #
 # ------------------ #
 
 
-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_attributes(data: dict):
-    """
-    Load attributes from data schema.
+def _calculate_initial_collisions():
+    for loaded_character in _globals.vg_characters:
+        loaded_character.current_collisions = (
+            loaded_character.calculate_collisions(
+                (loaded_character.x, loaded_character.y)
+            )
+        )
 
-    Parameters:
-        data: input schema
-    """
 
-    if not isinstance(data, dict):
-        raise TypeError(
-            _('The attributes in the input schema must be a dict.')
+def _calculate_initial_touch():
+    for loaded_character in _globals.vg_characters:
+        loaded_character.current_touch = loaded_character.calculate_touch(
+            (loaded_character.x, loaded_character.y)
         )
-    for attribute in data:
-        # utils._convert_function_arguments_to_string()
-        if attribute in _predefined_attributes_allowed:
-            eval(f'set_{attribute}(data["{attribute}"])')
 
 
-def _load_characters(data: dict) -> list:
+def _load_data(data: dict):
     """
-    Load characters from data schema.
+    Loads data from the schema.
 
     Parameters:
         data: input schema
-
-    Returns:
-        A list containing all loaded characters
     """
-    data_characters = {}
-    if not _('characters') in data:
-        return []
-    data_characters = data[_('characters')]
+    if not isinstance(data, dict):
+        raise TypeError(_('the attributes in the input schema must be a dict'))
 
-    characters = []
-    for character_name, character_data in data_characters.items():
-        if 'filename' in character_data:
-            character_filename = character_data['filename']
+    for name, value in data.items():
+        # utils._convert_function_arguments_to_string()
+        if name in _PREDEFINED_ATTRIBUTES_ALLOWED:
+            if isinstance(value, str):
+                value = f'"{value}"'
+            eval(_('set_{}({})').format(name, value))
         else:
-            character_filename = f'{character_name}.png'
-        loaded_character = character.Character(
-            character_filename,
-            character_name,
-        )
-        loaded_character._load_data(character_data)
-        characters.append(loaded_character)
-    return characters
+            add_attribute(name, value)
 
 
 def simulate_key_press(key: int | str, down: bool = True):
     """
-    Post a key press simulation to pygame event module.
+    Posts a key press simulation to pygame event module.
 
     Parameters:
-        key: Key to simulate
-        down: Keystroke direction. True for KEYDOWN, False for KEYUP
+        key: key to simulate
+        down: keystroke direction. True for KEYDOWN, False for KEYUP
 
     Examples:
         >>> simulate_key_press('a') # doctest: +SKIP
@@ -136,10 +114,10 @@ def simulate_key_press(key: int | str, down: bool = True):
 
 def key_pressed(key: int | str) -> bool:
     """
-    Check if a key has been pressed.
+    Checks if a key has been pressed.
 
     Parameters:
-        key: The key to be checked
+        key: the key to be checked
 
     Returns:
         True if the key is pressed, False otherwise.
@@ -152,42 +130,55 @@ def key_pressed(key: int | str) -> bool:
     return _get_keys()[key]
 
 
+def add_attribute(name: str, value: Any):
+    """
+    Adds a new attribute to the videogame.
+
+    Parameters:
+        name: a name for this attribute
+        value: a value for this attribute
+
+    Examples:
+        >>> add_attribute('test': 'foo') # doctest: +SKIP
+    """
+    _globals.vg_attributes.update({name: value})
+
+
 def add_character(new_character: 'character.Character'):
     """
-    Add a new character to the videogame.
+    Adds a new character to the videogame.
 
     Parameters:
         new_character: the character to add
 
     Examples:
-        >>> prepare()
         >>> p = character.Character('protagonist.png')
-        >>> add_character(p)
+        >>> add_character(p) # doctest: +SKIP
     """
     if not isinstance(new_character, character.Character):
-        raise TypeError(_('Only Character instances can be added.'))
+        raise TypeError(_('only Character instances can be added'))
     _globals.vg_characters.append(new_character)
 
 
-def add_chronometer(new_chronometer: 'widgets.Chronometer'):
+def add_chronometer(new_chronometer: 'texts.Chronometer'):
     """
-    Add a new chronometer to the videogame.
+    Adds a new chronometer to the videogame.
 
     Parameters:
         new_chronometer: the chronometer to be added
 
     Examples:
-        >>> chrono = widgets.Chronometer(30)
+        >>> chrono = texts.Chronometer(30)
         >>> add_chronometer(chrono) # doctest: +SKIP
     """
-    if not isinstance(new_chronometer, widgets.Chronometer):
-        raise TypeError(_('Only Chronometer instances can be added.'))
+    if not isinstance(new_chronometer, texts.Chronometer):
+        raise TypeError(_('only Chronometer instances can be added'))
     _globals.vg_chronometers.append(new_chronometer)
 
 
 def add_group(new_group: 'group.SquareGroup'):
     """
-    Add a new group to the videogame.
+    Adds a new group to the videogame.
 
     Parameters:
         new_group: the character to be added
@@ -197,14 +188,17 @@ def add_group(new_group: 'group.SquareGroup'):
         >>> rectangle = group.RectangleGroup(p, 4, 2)
         >>> add_group(rectangle) # doctest: +SKIP
     """
-    if not isinstance(new_group, group.Group):
-        raise TypeError(_('Only Group instances can be added.'))
+    if not isinstance(new_group, groups.Group):
+        raise TypeError(_('only Group instances can be added'))
     _globals.vg_groups.append(new_group)
+    for vg_character in new_group.sprites():
+        if vg_character in _globals.vg_characters:
+            _globals.vg_characters.remove(vg_character)
 
 
 def add_levelbar(new_levelbar: 'widgets.LevelBar'):
     """
-    Add a new level bar to the videogame.
+    Adds a new level bar to the videogame.
 
     Parameters:
         new_levelbar: the level bar to be added
@@ -214,45 +208,45 @@ def add_levelbar(new_levelbar: 'widgets.LevelBar'):
         >>> add_levelbar(lb) # doctest: +SKIP
     """
     if not isinstance(new_levelbar, widgets.LevelBar):
-        raise TypeError(_('Only LevelBar instances can be added.'))
+        raise TypeError(_('only LevelBar instances can be added'))
     _globals.vg_levelbars.append(new_levelbar)
 
 
-def add_scoreboard(new_text: 'widgets.Text'):
+def add_scoreboard(new_scoreboard: 'texts.Scoreboard'):
     """
-    Add a new scoreboard to the videogame.
+    Adds a new scoreboard to the videogame.
 
     Parameters:
         new_scoreboard: the scoreboard to be added
 
     Examples:
-        >>> sc = widgets.Scoreboard('Test')
+        >>> sc = texts.Scoreboard('Test')
         >>> add_scoreboard(sc) # doctest: +SKIP
     """
-    if not isinstance(new_scoreboard, widgets.Scoreboard):
-        raise TypeError(_('Only Scoreboard instances can be added.'))
-    _globals.vg_scobreboards.append(new_scoreboard)
+    if not isinstance(new_scoreboard, texts.Scoreboard):
+        raise TypeError(_('only Scoreboard instances can be added'))
+    _globals.vg_scoreboards.append(new_scoreboard)
 
 
-def add_text(new_text: 'widgets.Text'):
+def add_text(new_text: 'texts.Text'):
     """
-    Add a new text to the videogame.
+    Adds a new text to the videogame.
 
     Parameters:
         new_text: the text to be added
 
     Examples:
-        >>> text = widgets.Text('Test')
+        >>> text = texts.Text('Test')
         >>> add_text(text) # doctest: +SKIP
     """
-    if not isinstance(new_text, widgets.Text):
-        raise TypeError(_('Only Text instances can be added.'))
+    if not isinstance(new_text, texts.Text):
+        raise TypeError(_('only Text instances can be added'))
     _globals.vg_texts.append(new_text)
 
 
 def init():
     """
-    Start the pygame engine if it is not started.
+    Starts the pygame engine if it is not started.
 
     Examples:
         >>> init()
@@ -263,11 +257,12 @@ def init():
     _globals._window = pygame.display.set_mode(
         (settings.WINDOW_WIDTH, settings.WINDOW_HEIGHT)
     )
+    pygame.key.set_repeat(settings.KEY_DELAY, settings.KEY_INTERVAL)
 
 
 def is_running() -> bool:
     """
-    Check if the game is running. Useful in the main loop.
+    Checks if the game is running. Useful in the main loop.
 
     Returns:
         True if the game is running. False otherwise.
@@ -280,7 +275,7 @@ def is_running() -> bool:
 
 def listen_events():
     """
-    Listen for events coming from the keyboard and window clicks.
+    Listens for events coming from the keyboard and window clicks.
 
     Examples:
         >>> simulate_key_press('ESCAPE')
@@ -299,7 +294,7 @@ def listen_events():
 
 def get_color(x: int, y: int) -> Tuple[int, int, int]:
     """
-    Get the RGB components for a point in the game window.
+    Gets the RGB components for a point in the game window.
 
     Parameters:
         x: Position on the abscissa axis
@@ -311,65 +306,117 @@ def get_color(x: int, y: int) -> Tuple[int, int, int]:
     Examples:
         >>> color = get_color(100, 200)
     """
-    if not isinstance(x, int) or not isinstance(y, int):
+    if not type(x) == int or not type(y) == int:
         raise TypeError(
-            _('The coordinates of the point must be a non-negative integers.')
+            _('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.')
+            _('the coordinates of the point must be a non-negative integers')
         )
-    return tuple(_globals._window.get_at((x, y))[:-1])
+
+    if _globals._window:
+        return tuple(_globals._window.get_at((x, y))[:-1])
+    return None
 
 
 def paint(
     sprite: Type['scene.Scene']
     | Type['character.Character']
-    | Type['widgets.Text'],
+    | Type['texts.Text'],
 ):
     """
-    Paint multiple sprite types on the main window.
+    Paints multiple sprite types on the main window.
 
     Parameters:
-        sprite: The element to be painted.
+        sprite: the element to be painted
+    """
+    # TODO: check sprite type and show error if no image or no rect
+    if sprite.image and sprite.rect:
+        _globals._window.blit(sprite.image, sprite.rect)
+
+
+def paint_character(sprite: Type['character.Character']):
+    """
+    Paints a character on the main window.
+
+    Parameters:
+        sprite: the element to be painted
+    """
+    if sprite.visible:
+        paint(sprite)
+
+
+def paint_group(group: Type['groups.Group']):
+    """
+    Paints a group of sprites on the main window.
+
+    Parameters:
+        group: the group of sprites to be painted.
+    """
+    for sprite_item in group.characters:
+        sprite_item._update_rect_position()
+        paint(sprite_item)
+
+
+def paint_scene(sprite: Type['scene.Scene']):
+    """
+    Paints a scene on the main window.
+
+    Parameters:
+        sprite: the element to be painted
     """
     # TODO: check sprite type
     if hasattr(sprite, 'background'):
         _globals._window.fill(sprite.background)
-    if hasattr(sprite, 'image') and hasattr(sprite, 'rect'):
-        _globals._window.blit(sprite.image, sprite.rect)
+
+    paint(sprite)
 
 
-def paint_group(group: Type['group.SquareGroup']):
+def paint_text(lines: Type['texts.Text'] | list):
     """
-    Paint a group of sprites on the main window.
+    Paints a text on the main window.
 
     Parameters:
-        group: The group of sprites to be painted.
+        text: the element to be painted
     """
-    group.draw(_globals._window)
+
+    if isinstance(lines, jogai.texts.Text):
+        lines = [lines]
+
+    for line in lines:
+        if line.visible:
+            paint(line)
 
 
 def set_caption(caption: str):
     """
-    Set the caption for the videogame.
+    Sets the caption for the videogame.
 
     Parameters:
-        caption: Caption to be setted
+        caption: caption to be setted
 
     Examples:
         >>> set_caption('My videogame')
     """
     if not isinstance(caption, str):
-        raise TypeError(_('The caption for the videogame must be a string.'))
+        raise TypeError(_('the caption for the videogame must be a string'))
+    _globals.vg_title = caption
     pygame.display.set_caption(caption)
 
 
+def set_characters(data_characters: dict):
+    (
+        _globals.vg_characters,
+        _globals.vg_groups,
+    ) = character._load_characters_and_groups(data_characters)
+
+
 def set_gravity(gravity: int):
     """
-    Set the gravity in the videogame.
+    Sets the gravity in the videogame.
 
     Parameters:
         gravity: Gravity force to be setted.
@@ -377,21 +424,21 @@ def set_gravity(gravity: int):
     Examples:
         >>> set_gravity(5)
     """
-    if not isinstance(gravity, int):
-        raise TypeError(_('The gravity must be an integer.'))
+    if not type(gravity) == int:
+        raise TypeError(_('the gravity must be an integer'))
     _globals._gravity = gravity
 
 
 def set_marks(
-    x_gap: int = 0,
-    y_gap: int = 0,
-    x_color: tuple = (255, 180, 30),
-    y_color: tuple = (0, 0, 255),
-    width: int = 1,
+    x_gap: int = settings.MARKS_DEFAULT_X_GAP,
+    y_gap: int = settings.MARKS_DEFAULT_Y_GAP,
+    x_color: tuple = settings.MARKS_DEFAULT_X_COLOR,
+    y_color: tuple = settings.MARKS_DEFAULT_Y_COLOR,
+    width: int = settings.MARKS_DEFAULT_WIDTH,
     debug: bool = False,
 ):
     """
-    Set distance mark lines and position information along both axis.
+    Sets distance mark lines and position information along both axis.
 
     Parameters:
         x_gap: The gap distance on abscissa axis.
@@ -404,78 +451,149 @@ def set_marks(
     _globals.marks = utils.Marks(x_gap, y_gap, x_color, y_color, width, debug)
 
 
-def prepare(data_filename: str = ''):
+def set_scene(data_scene):
+    _globals.vg_scene = scene._load_scene(data_scene)
+
+
+def set_texts(data_texts):
+    _globals.vg_texts = texts._load_texts(data_texts)
+
+
+def prepare(environment_name: str):
     """
-    Prepare the game environment before entering the main loop.
+    Decorator function to prepare the game environments before entering the main loop.
 
     Examples:
         >>> init()
         >>> prepare()
     """
-    if not _globals._window:
-        init()
-    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 = _globals._default_data
 
-    _globals.vg_scene = _load_scene(vg_data)
-    _globals.vg_characters = _load_characters(vg_data)
-    _globals.vg_attributes = _load_attributes(vg_data)
+    def decorator(environment):
+        _globals.environments.update({environment_name: environment})
+
+    return decorator
+
+
+def load_environment(
+    new_environment: str,
+    items_to_transfer: list | dict = [],
+    to_export: bool = False,
+):
+    if isinstance(items_to_transfer, dict):
+        items_to_transfer = [
+            items_to_transfer,
+        ]
+
+    if not to_export:
+        if not new_environment in _globals.environments.keys():
+            raise KeyError(_('the environment name does not exist'))
+        if not _globals._window:
+            init()
+
+    _globals.vg_chronometers = []
+    _globals.vg_levelbars = []
+    _globals.vg_scoreboards = []
+
+    vg_data = utils._load_yaml_from_filename(f'{new_environment}.yaml')
+
+    _load_data(vg_data)
+
+    character_names = [c.name for c in _globals.vg_characters]
+    for item in items_to_transfer:
+        match item:
+            case character.Character():
+                if item.name in character_names:
+                    character_names.pop(character_names.index(item.name))
+                add_character(item)
+            case groups.Group():
+                add_group(item)
+            case texts.Chronometer():
+                add_chronometer(item)
+            case texts.Scoreboard():
+                add_scoreboard(item)
+            case texts.Text():
+                add_text(item)
+            case widgets.LevelBar():
+                add_levelbar(item)
+            case dict():
+                for name, value in item.items():
+                    add_attribute(name, value)
 
     if _globals.vg_characters:
+        _calculate_initial_collisions()
         _globals.character_focus = _globals.vg_characters[0]
 
-    # TODO: Check and filter variable names added to builtins
-    setattr(builtins, 'vgscene', _globals.vg_scene)
+    builtins.__dict__.update({_('current_scene'): _globals.vg_scene})
+
+    for vg_text in _globals.vg_texts:
+        # Check if character names exist in the predefined builtins
+        # if not vg_character.name in _globals.predefined_builtins:
+        # if not vg_text.name in builtins.__dict__.keys():
+        builtins.__dict__[vg_text.name] = vg_text
+
     for vg_character in _globals.vg_characters:
-        setattr(builtins, vg_character.name, vg_character)
+        # Check if character names exist in the predefined builtins
+        # if not vg_character.name in _globals.predefined_builtins:
+        # if not vg_character.name in builtins.__dict__.keys():
+        builtins.__dict__[vg_character.name] = vg_character
+
+    for vg_group in _globals.vg_groups:
+        # Check if group names exist in the predefined builtins
+        # if not vg_group.name in _globals.predefined_builtins:
+        # if not vg_group.name in builtins.__dict__.keys():
+        builtins.__dict__[vg_group.name] = vg_group
+
+    for name, value in _globals.vg_attributes.items():
+        builtins.__dict__[name] = value
+
+    _globals.current_environment = new_environment
 
 
 def stop():
     """
-    Finish the infinite videogame loop.
+    Finishes the infinite videogame loop.
     """
     global running
     running = False
 
 
-def update():
+def update(to_quit: bool = False):
     """
-    Refresh the display surface and listen events.
+    Updates the display surface and listen events.
     """
     if _globals.vg_scene:
         if _globals.vg_characters:
-            _globals.vg_scene.set_position(_globals.character_focus.x)
-        paint(_globals.vg_scene)
+            _globals.vg_scene._set_abscissa_position(
+                _globals.character_focus.x
+            )
+        paint_scene(_globals.vg_scene)
     if _globals.vg_characters:
         for pos, vg_character in enumerate(_globals.vg_characters):
             tracking = (
                 True if vg_character == _globals.character_focus else False
             )
             vg_character._update_rect_position(tracking=tracking)
-            paint(vg_character)
+            paint_character(vg_character)
             vg_character.update()
     if _globals.vg_groups:
-        for group_item in _globals.vg_groups:
-            for sprite_item in group_item.sprites():
-                sprite_item._update_rect_position()
-                paint(sprite_item)
-                sprite_item.update()
+        for vg_group in _globals.vg_groups:
+            paint_group(vg_group)
+            vg_group.update()
     if _globals.vg_levelbars:
         for levelbar_item in _globals.vg_levelbars:
             levelbar_item.draw()
     if _globals.vg_scoreboards:
         for scoreboard_item in _globals.vg_scoreboards:
-            paint(scoreboard_item)
+            scoreboard_item.draw()
     if _globals.vg_chronometers:
         for chronometers_item in _globals.vg_chronometers:
-            paint(chronometers_item)
+            chronometers_item.draw()
             chronometers_item.update()
     if _globals.vg_texts:
         for texts_item in _globals.vg_texts:
-            paint(texts_item)
+            if texts_item.visible:
+                texts_item._update_rect_position()
+                texts_item.draw()
     if _globals.visible_inventory:
         _globals.visible_inventory.draw()
     if _globals.marks:
@@ -483,11 +601,13 @@ def update():
     pygame.display.flip()
     _globals._clock.tick(settings.FPS)
     listen_events()
+    if not to_quit:
+        _globals.environments[_globals.current_environment]()
 
 
-def quit(wait: int = 0):
+def quit(wait: int = settings.DEFAULT_TIME_TO_QUIT):
     """
-    Exit the game.
+    Exits the game.
 
     Parameters:
         wait: Seconds to wait before exiting
@@ -495,7 +615,8 @@ def quit(wait: int = 0):
     Examples:
         >>> quit(1)
     """
-    if isinstance(wait, int) and wait > 0:
+    update(to_quit=True)
+    if type(wait) == int and wait > 0:
         time.sleep(wait)
     pygame.quit()
     stop()

+ 164 - 394
jogai/widgets.py

@@ -19,276 +19,28 @@
 # info@zero.gal
 
 from os.path import join
+from typing import Type
 
 import pygame
 
-from jogai import _globals, settings
+from jogai import _globals
+from jogai.settings import settings
 from jogai.translations import gettext as _
-
-
-class Text:
-    def __init__(
-        self,
-        message: str = '',
-        size: int = 20,
-        color: tuple = (0, 0, 0),
-        position: tuple = (0, 0),
-        font: str = '',
-        bold: bool = False,
-        italic: bool = False,
-        shadow: tuple = (),
-    ):
-        # TODO: Implement shadow
-        if not isinstance(size, int):
-            raise TypeError(_('The font size must be an integer'))
-        if size < 1:
-            raise ValueError(_('The font size must be a positive integer'))
-        if not isinstance(font, str):
-            raise TypeError(_('The font must be a string'))
-        if not isinstance(bold, bool):
-            raise TypeError(_('The bold argument must be a boolean'))
-        if not isinstance(italic, bool):
-            raise TypeError(_('The italic argument must be a boolean'))
-
-        if not pygame.font.get_init():
-            pygame.font.init()
-        self._message = message
-        self.color = color
-        try:
-            if not font:
-                raise FileNotFoundError
-            self.font = pygame.font.Font(join(settings.FONTS_DIR, font), size)
-        except FileNotFoundError:
-            self.font = pygame.font.Font(None, size)
-        self.font.set_bold(bold)
-        self.font.set_italic(italic)
-        self.render(position, message)
-
-    @property
-    def x(self):
-        return self.rect.x
-
-    @property
-    def y(self):
-        return self.rect.y
-
-    def render(self, position: tuple = (), message: str = ''):
-        if not isinstance(position, tuple) and not isinstance(position, list):
-            raise TypeError(_('The position must be a tuple or list (x, y).'))
-        if position and len(position) != 2:
-            raise ValueError(_('The position has two coordinates (x, y).'))
-        if not isinstance(message, str):
-            raise TypeError(_('The message must be a string.'))
-        if not position:
-            position = (self.rect.x, self.rect.y)
-        if message:
-            self._message = message
-        self.image = self.font.render(self._message, True, self.color)
-        self.rect = self.image.get_rect()
-        self.rect.x, self.rect.y = position
-
-    def set_message(self, message: str, key: str = ''):
-        """
-        Set a new message for the text.
-
-        Parameters:
-            message: The new message
-
-        Examples:
-            >>> t = Text()
-            >>> t.set_message('Test')
-        """
-        if not key or _get_keys()[utils.eval_key(key)]:
-            self.render(message=message)
-
-    def set_position(self, position: tuple | list, key: str = ''):
-        """
-        Set the text position as tuple.
-
-        Parameters:
-            position: A tuple with abscissa and ordinate coordinates
-
-        Examples:
-            >>> t = Text('Test')
-            >>> t.set_position((200, 200))
-        """
-        if not key or _get_keys()[utils.eval_key(key)]:
-            self.render(position=position)
-
-
-class Chronometer(Text):
-    def __init__(
-        self, initial: int = 0, in_milliseconds: bool = False, **kwargs
-    ):
-        if not isinstance(initial, int):
-            raise TypeError(_('The initial value must be an integer.'))
-        if initial < 0:
-            raise ValueError(_('The countdown cannot be negative.'))
-        if not isinstance(in_milliseconds, bool):
-            raise TypeError(_('in_milliseconds argument must be a boolean.'))
-
-        super().__init__(**kwargs)
-        self._initial = initial if in_milliseconds else initial * 1000
-        self._time = 0
-        self._timestamp = 0
-        self._onpause = True
-        self._message = self.format_clock()
-
-        if 'position' in kwargs:
-            self.render(position=kwargs['position'])
-        else:
-            self.render()
-
-    def __int__(self):
-        return self.get_milliseconds()
-
-    def __repr__(self):
-        return f'Chronometer <{self.format_clock()}>'
-
-    def __str__(self):
-        return self._message
-
-    @property
-    def time(self):
-        return self.get_milliseconds()
-
-    @property
-    def onpause(self) -> bool:
-        return self._onpause
-
-    def format_clock(
-        self,
-        include_millis: bool = False,
-        delimiter: str = ':',
-        delimiter_millis: str = '.',
-    ) -> str:
-        """
-        Create a string with the chronometer time information, formatted as a digital clock.
-
-        Parameters:
-            include_millis: True if milliseconds must be present. False, otherwise.
-            delimiter: The string delimiter between hours, minutes and seconds.
-            delimiter_millis: The string delimiter between seconds and milliseconds.
-
-        Returns:
-            A string with the formatted time information.
-        """
-        if not isinstance(include_millis, bool):
-            raise TypeError(_('include_millis must be a boolean.'))
-        if not isinstance(delimiter, str):
-            raise TypeError(_('The delimiter must be a string.'))
-        if not isinstance(delimiter_millis, str):
-            raise TypeError(
-                _('The delimiter for milliseconds must be a string.')
-            )
-
-        formatted_time = tuple(
-            map(lambda x: str(x).zfill(2), self.get_clock())
-        )
-        formatted_clock = ':'.join(formatted_time[:-1])
-        if include_millis:
-            formatted_clock += f'.{formatted_time[-1]}'
-        return formatted_clock
-
-    def get_clock(self) -> tuple:
-        """
-        Get the current chronometer time information.
-
-        Returns:
-            A tuple with the time information (hours, minutes, seconds, milliseconds)
-        """
-        millis = self.get_milliseconds()
-        if millis > 0:
-            quotient = millis // 1000
-            millis = millis % 1000
-            seconds = quotient % 60
-            quotient = quotient // 60
-            minutes = quotient % 60
-            hours = quotient // 60
-            return hours, minutes, seconds, millis
-        return 0, 0, 0, 0
-
-    def get_milliseconds(self) -> int:
-        """
-        Get the current chronometer time information in milliseconds.
-
-        Returns:
-            An integer with the current time in milliseconds.
-        """
-        if self._onpause:
-            current = (
-                self._initial - self._time if self._initial else self._time
-            )
-        else:
-            if self._initial:
-                current = self._initial - (
-                    self._time + pygame.time.get_ticks() - self._timestamp
-                )
-            else:
-                current = (
-                    self._time + pygame.time.get_ticks() - self._timestamp
-                )
-
-        current = max(0, current)
-        return current
-
-    def get_seconds(self) -> int:
-        """
-        Get the current chronometer time information in seconds.
-
-        Returns:
-            An integer with the current time in seconds.
-        """
-        return self.get_milliseconds() // 1000
-
-    def start(self):
-        """
-        Start the chronometer count.
-        """
-        self._time = 0
-        self._timestamp = pygame.time.get_ticks()
-        self._onpause = False
-
-    def pause(self):
-        """
-        Pause the chronometer count.
-        """
-        if not self._onpause:
-            self._onpause = True
-            self._time = self._time + pygame.time.get_ticks() - self._timestamp
-
-    def reset(self):
-        """
-        Reset the chronometer count.
-        """
-        self._time = 0
-
-    def resume(self):
-        """
-        Resume the chronometer count.
-        """
-        if self._onpause:
-            self._onpause = False
-            self._timestamp = pygame.time.get_ticks()
-
-    def update(self):
-        """
-        Update the text message with the current time information.
-        """
-        self._message = self.format_clock()
-        self.render()
+from jogai.utils import _get_keys, eval_key
 
 
 class Inventory:
+    """A inventory for the videogame"""
+
     def __init__(
         self,
         name: str,
         background_color: tuple = settings.INVENTORY_DEFAULT_BACKGROUND_COLOR,
         text_color: tuple = settings.INVENTORY_DEFAULT_TEXT_COLOR,
         font: str = '',
-        size: int = 30,
+        size: int = settings.INVENTORY_DEFAULT_FONT_SIZE,
     ):
-        self.name = name
+        self._name = name
         self.background_color = background_color
         self.text_color = text_color
         self._items = {}
@@ -298,23 +50,19 @@ class Inventory:
         try:
             if not font:
                 raise FileNotFoundError
-            self.font = pygame.font.Font(join(settings.FONTS_DIR, font), size)
+            self._font = pygame.font.Font(join(settings.FONTS_DIR, font), size)
         except FileNotFoundError:
-            self.font = pygame.font.Font(None, size)
+            self._font = pygame.font.Font(None, size)
 
-    def add(self, item):
-        if item in self._items.keys():
-            self._items[item] += 1
-        else:
-            self._items.update({item: 1})
-
-    def show(self):
-        if not key or _get_keys()[utils.eval_key(key)]:
-            _globals.visible_inventory = self
+    @property
+    def items(self):
+        """inventory items"""
+        return self._items
 
-    def hide(self):
-        if not key or _get_keys()[utils.eval_key(key)]:
-            _globals.visible_inventory = None
+    @property
+    def name(self):
+        """inventory name"""
+        return self._name
 
     def draw(self):
         shape = pygame.Surface(self.rect.size, pygame.SRCALPHA)
@@ -333,8 +81,8 @@ class Inventory:
         )
         _globals._window.blit(shape, self.rect)
         _globals._window.blit(
-            self.font.render(
-                f"{_('INVENTORY')} [{self.name}]", 1, self.text_color
+            self._font.render(
+                _('INVENTORY [{}]').format(self._name), 1, self.text_color
             ),
             (settings.INVENTORY_LEFT_MARGIN, settings.INVENTORY_TOP_MARGIN),
         )
@@ -356,12 +104,39 @@ class Inventory:
             )
             _globals._window.blit(item.thumbnail, (x, y))
             _globals._window.blit(
-                self.font.render(f'x{self._items[item]}', 1, self.text_color),
+                self._font.render(f'x{self._items[item]}', 1, self.text_color),
                 (x + width + 10, y + 5),
             )
 
+    def add(self, item):
+        """Adds a character to this inventory."""
+        if item in self._items.keys():
+            self._items[item] += 1
+        else:
+            self._items.update({item: 1})
+
+    def count(self, character: Type['character.Character'] | None = None):
+        """Counts the number of characters in this inventory"""
+        if character is None:
+            return sum(self.items.values())
+        if character in self.items:
+            return self.items[character]
+        return 0
+
+    def hide(self):
+        """Hides this inventory into the videogame window."""
+        if not key or _get_keys()[eval_key(key)]:
+            _globals.visible_inventory = None
+
+    def show(self):
+        """Shows this inventory into the videogame window."""
+        if not key or _get_keys()[eval_key(key)]:
+            _globals.visible_inventory = self
+
 
 class LevelBar(pygame.Surface):
+    """A level bar for the videogame"""
+
     def __init__(
         self,
         parts: int,
@@ -370,12 +145,12 @@ class LevelBar(pygame.Surface):
         border_color: tuple | list = settings.LEVELBARS_DEFAULT_BORDER_COLOR,
         fill_color: tuple | list = settings.LEVELBARS_DEFAULT_FILL_COLOR,
     ):
-        if not isinstance(parts, int):
-            raise TypeError(_('The amount of parts must be an integer.'))
+        if not type(parts) == int:
+            raise TypeError(_('the amount of parts must be an integer'))
         if not (0 < parts <= settings.LEVELBARS_MAX_PARTS):
             raise TypeError(
-                _(
-                    f'The amount of parts must be between 1 and {settings.LEVELBARS_MAX_PARTS}.'
+                _('the amount of parts must be between 1 and {}').format(
+                    settings.LEVELBARS_MAX_PARTS
                 )
             )
         super().__init__(
@@ -387,7 +162,7 @@ class LevelBar(pygame.Surface):
         self._parts = parts
         if initial_level is None:
             initial_level = parts
-        self.set(initial_level)
+        self.set_level(initial_level)
         self.border_color = border_color
         self.fill_color = fill_color
         self.x, self.y = position
@@ -395,93 +170,68 @@ class LevelBar(pygame.Surface):
         self._height = settings.LEVELBARS_DEFAULT_HEIGHT
         self._thick = settings.LEVELBARS_DEFAULT_THICK
 
-    def __int__(self):
-        return int(self._level)
-
-    def __repr(self):
-        return f'LevelBar <{self._level}/{self._parts}>'
-
-    def __str__(self):
-        return f'{self._level}/{self._parts}'
+    def __add__(self, other):
+        if type(other) == int:
+            return max(0, self._level + other)
+        return NotImplemented
 
-    def decrease(self, decrease: int):
-        """
-        Decrease the level for this bar.
+    def __eq__(self, other):
+        if type(other) == int:
+            return self._level == other
+        return False
 
-        Parameters:
-            decrease: amount to decrease
+    def __gt__(self, other):
+        if type(other) == int:
+            return self._level > other
+        return NotImplemented
 
-        Examples:
-            >>> lifes = LevelBar(5, 3)
-            >>> lifes.decrease(1)
-        """
-        if not isinstance(decrease, int):
-            raise TypeError(_('The decrease level must be an integer.'))
-        if self._level - decrease < 0:
-            raise ValueError(_('The decrease cannot be less than zero.'))
-        self.set(self._level - decrease)
+    def __int__(self):
+        return int(self._level)
 
-    def increase(self, increase: int):
-        """
-        Increase the level for this bar.
+    def __lt__(self, other):
+        if type(other) == int:
+            return self._level < other
+        return NotImplemented
 
-        Parameters:
-            increase: amount to increase
+    def __mul__(self, other):
+        if type(other) == int:
+            return self._level * other
+        return NotImplemented
 
-        Examples:
-            >>> lifes = LevelBar(5, 2)
-            >>> lifes.increase(1)
-        """
-        if not isinstance(increase, int):
-            raise TypeError(_('The increase level must be an integer.'))
-        if self._level + increase > self._parts:
-            raise ValueError(
-                _('The increase cannot exceed the maximum level.')
-            )
-        self.set(self._level + increase)
+    def __repr__(self):
+        return f'LevelBar <{self._level}/{self._parts}>'
 
-    def set(self, level):
-        """
-        Set the new level for this bar.
+    def __str__(self):
+        return f'{self._level}/{self._parts}'
 
-        Parameters:
-            level: the new level
+    def __sub__(self, other):
+        if type(other) == int:
+            return max(0, self._level - other)
+        return NotImplemented
 
-        Examples:
-            >>> lifes = LevelBar(5)
-            >>> lifes.set(3)
-        """
-        if not isinstance(level, int):
-            raise TypeError(_('The level must be an integer.'))
-        if not (0 <= level <= self._parts):
-            raise ValueError(
-                _(
-                    'The level range must be between zero and the number of parts.'
-                )
-            )
-        self._level = level
+    @property
+    def level(self) -> int:
+        """the current level of this bar"""
+        return self._level
 
-    def draw(self, position: tuple | list | None = None):
-        if position is None:
-            position = (self.x, self.y)
-        else:
-            self.x, self.y = position
+    def draw(self):
+        """Draw this level bar."""
 
         division = int(self._width / self._parts)
         # left border
         pygame.draw.line(
             _globals._window,
             self.border_color,
-            position,
-            (position[0], position[1] + self._height),
+            (self.x, self.y),
+            (self.x, self.y + self._height),
             self._thick,
         )
         # right border
         pygame.draw.line(
             _globals._window,
             self.border_color,
-            (position[0] + division * self._parts, position[1]),
-            (position[0] + division * self._parts, position[1] + self._height),
+            (self.x + division * self._parts, self.y),
+            (self.x + division * self._parts, self.y + self._height),
             self._thick,
         )
         for part in range(self._parts):
@@ -489,8 +239,8 @@ class LevelBar(pygame.Surface):
             pygame.draw.line(
                 _globals._window,
                 self.border_color,
-                (position[0] + division * part, position[1]),
-                (position[0] + division * (part + 1), position[1]),
+                (self.x + division * part, self.y),
+                (self.x + division * (part + 1), self.y),
                 self._thick,
             )
             # inside
@@ -499,12 +249,12 @@ class LevelBar(pygame.Surface):
                     _globals._window,
                     self.fill_color,
                     (
-                        position[0] + division * part + self._thick / 2,
-                        position[1] + int(self._height / 2),
+                        self.x + division * part + self._thick / 2,
+                        self.y + int(self._height / 2),
                     ),
                     (
-                        position[0] + division * (part + 1) - self._thick / 2,
-                        position[1] + int(self._height / 2),
+                        self.x + division * (part + 1) - self._thick / 2,
+                        self.y + int(self._height / 2),
                     ),
                     self._height,
                 )
@@ -512,73 +262,93 @@ class LevelBar(pygame.Surface):
             pygame.draw.line(
                 _globals._window,
                 self.border_color,
-                (position[0] + division * part, position[1] + self._height),
+                (self.x + division * part, self.y + self._height),
                 (
-                    position[0] + division * (part + 1),
-                    position[1] + self._height,
+                    self.x + division * (part + 1),
+                    self.y + self._height,
                 ),
                 self._thick,
             )
         self.rect = self.get_rect()
 
+    def decrease(self, decrease: int):
+        """
+        Decreases the level for this bar.
 
-class Scoreboard(Text):
-    def __init__(self, prefix: str, score: int = 0, **kwargs):
-        if not isinstance(prefix, str):
-            raise TypeError(_('The prefix text must be a string.'))
-        super().__init__(**kwargs)
-        self._prefix = prefix
-        self.set_score(score)
-        if 'position' in kwargs:
-            self.render(position=kwargs['position'])
-        else:
-            self.render()
-
-    def __int__(self):
-        return int(self._score)
+        Parameters:
+            decrease: amount to decrease
 
-    def __repr__(self):
-        return f'Scoreboard <{self._message}>'
+        Examples:
+            >>> tank = LevelBar(5, 3)
+            >>> tank.decrease(1)
+        """
+        if not type(decrease) == int:
+            raise TypeError(_('the decrease level must be an integer'))
+        if self._level - decrease < 0:
+            raise ValueError(_('the decrease cannot be less than zero'))
+        self.set(self._level - decrease)
 
-    def __str__(self):
-        return self._message
+    def increase(self, increase: int):
+        """
+        Increases the level for this bar.
 
-    @property
-    def score(self):
-        return self._score
+        Parameters:
+            increase: amount to increase
 
-    @property
-    def message(self):
-        return self._message
+        Examples:
+            >>> tank = LevelBar(5, 2)
+            >>> tank.increase(1)
+        """
+        if not type(increase) == int:
+            raise TypeError(_('the increase level must be an integer'))
+        if self._level + increase > self._parts:
+            raise ValueError(_('the increase cannot exceed the maximum level'))
+        self.set(self._level + increase)
 
-    def set_score(self, score: int):
+    def set_level(self, level: int):
         """
-        Set the character score.
+        Sets a new level for this bar.
 
         Parameters:
-            score: the new character score
+            level: the new level
 
         Examples:
-            >>> scoreboard = Scoreboard('My character: ')
-            >>> scoreboard.set_score(100)
+            >>> tank = LevelBar(5)
+            >>> tank.set_level(3)
         """
-        if not isinstance(score, int):
-            raise TypeError(_('Score must be an integer.'))
-        self._score = score
-        self._message = f'{self._prefix}{self._score}'
-        self.render()
+        if not type(level) == int:
+            raise TypeError(_('the level must be an integer'))
+        if not (0 <= level <= self._parts):
+            raise ValueError(
+                _(
+                    'the level range must be between zero and the number of parts'
+                )
+            )
+        self._level = level
 
-    def add_score(self, score: int):
+    def set_position(self, position: tuple | list):
         """
-        Increment the character score.
+        Sets a new position for this level bar.
 
         Parameters:
-            score: the character score to be added
+            position: a tuple with abscissa and ordinate coordinates
 
         Examples:
-            >>> scoreboard = Scoreboard('My character: ')
-            >>> scoreboard.add_score(5)
+            >>> tank = LevelBar(5)
+            >>> tank.set_position((200, 200))
         """
-        if not isinstance(score, int):
-            raise TypeError(_('Score must be an integer.'))
-        self._score += score
+        if not isinstance(position, tuple) and not isinstance(position, list):
+            raise TypeError(
+                _(
+                    'the position must be a tuple or a list with the coordinates (x, y)'
+                )
+            )
+        if len(position) != 2 or not all(
+            map(lambda p: type(p) == int, position)
+        ):
+            raise ValueError(
+                _(
+                    'the position must be a tuple or a list of integers with the coordinates (x, y)'
+                )
+            )
+        self.x, self.y = position

+ 1 - 1
mkdocs.yml

@@ -20,4 +20,4 @@ plugins:
   - mkdocstrings:
       handlers:
         python:
-          paths: [jogai]
+          paths: [jogai, jogai/locale/gl]

+ 51 - 72
poetry.lock

@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
 
 [[package]]
 name = "babel"
@@ -13,7 +13,7 @@ files = [
 ]
 
 [package.extras]
-dev = ["backports.zoneinfo", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata"]
+dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""]
 
 [[package]]
 name = "backrefs"
@@ -315,7 +315,7 @@ files = [
 ]
 
 [package.extras]
-toml = ["tomli"]
+toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
 
 [[package]]
 name = "flake8"
@@ -428,7 +428,7 @@ version = "3.1.6"
 description = "A very fast and expressive template engine."
 optional = false
 python-versions = ">=3.7"
-groups = ["doc"]
+groups = ["main", "doc"]
 files = [
     {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
     {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
@@ -462,7 +462,7 @@ version = "3.0.2"
 description = "Safely add untrusted strings to HTML/XML markup."
 optional = false
 python-versions = ">=3.9"
-groups = ["doc"]
+groups = ["main", "doc"]
 files = [
     {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"},
     {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"},
@@ -580,7 +580,7 @@ watchdog = ">=2.0"
 
 [package.extras]
 i18n = ["babel (>=2.9.0)"]
-min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.4)", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"]
+min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4) ; platform_system == \"Windows\"", "ghp-import (==1.0)", "importlib-metadata (==4.4) ; python_version < \"3.10\"", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"]
 
 [[package]]
 name = "mkdocs-autorefs"
@@ -866,73 +866,52 @@ files = [
 ]
 
 [[package]]
-name = "pygame"
-version = "2.6.1"
+name = "pygame-ce"
+version = "2.5.7"
 description = "Python Game Development"
 optional = false
-python-versions = ">=3.6"
-groups = ["dev"]
+python-versions = ">=3.10"
+groups = ["main"]
 files = [
-    {file = "pygame-2.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9beeb647e555afb5657111fa83acb74b99ad88761108eaea66472e8b8547b55b"},
-    {file = "pygame-2.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:10e3d2a55f001f6c0a6eb44aa79ea7607091c9352b946692acedb2ac1482f1c9"},
-    {file = "pygame-2.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:816e85000c5d8b02a42b9834f761a5925ef3377d2924e3a7c4c143d2990ce5b8"},
-    {file = "pygame-2.6.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a78fd030d98faab4a8e27878536fdff7518d3e062a72761c552f624ebba5a5f"},
-    {file = "pygame-2.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da3ad64d685f84a34ebe5daacb39fff14f1251acb34c098d760d63fee768f50c"},
-    {file = "pygame-2.6.1-cp310-cp310-win32.whl", hash = "sha256:9dd5c054d4bd875a8caf978b82672f02bec332f52a833a76899220c460bb4b58"},
-    {file = "pygame-2.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:00827aba089355925902d533f9c41e79a799641f03746c50a374dc5c3362e43d"},
-    {file = "pygame-2.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:20349195326a5e82a16e351ed93465a7845a7e2a9af55b7bc1b2110ea3e344e1"},
-    {file = "pygame-2.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f3935459109da4bb0b3901da9904f0a3e52028a3332a355d298b1673a334cf21"},
-    {file = "pygame-2.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c31dbdb5d0217f32764797d21c2752e258e5fb7e895326538d82b5f75a0cd856"},
-    {file = "pygame-2.6.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:173badf82fa198e6888017bea40f511cb28e69ecdd5a72b214e81e4dcd66c3b1"},
-    {file = "pygame-2.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce8cc108b92de9b149b344ad2e25eedbe773af0dc41dfb24d1f07f679b558c60"},
-    {file = "pygame-2.6.1-cp311-cp311-win32.whl", hash = "sha256:811e7b925146d8149d79193652cbb83e0eca0aae66476b1cb310f0f4226b8b5c"},
-    {file = "pygame-2.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:91476902426facd4bb0dad4dc3b2573bc82c95c71b135e0daaea072ed528d299"},
-    {file = "pygame-2.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4ee7f2771f588c966fa2fa8b829be26698c9b4836f82ede5e4edc1a68594942e"},
-    {file = "pygame-2.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c8040ea2ab18c6b255af706ec01355c8a6b08dc48d77fd4ee783f8fc46a843bf"},
-    {file = "pygame-2.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47a6938de93fa610accd4969e638c2aebcb29b2fca518a84c3a39d91ab47116"},
-    {file = "pygame-2.6.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33006f784e1c7d7e466fcb61d5489da59cc5f7eb098712f792a225df1d4e229d"},
-    {file = "pygame-2.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1206125f14cae22c44565c9d333607f1d9f59487b1f1432945dfc809aeaa3e88"},
-    {file = "pygame-2.6.1-cp312-cp312-win32.whl", hash = "sha256:84fc4054e25262140d09d39e094f6880d730199710829902f0d8ceae0213379e"},
-    {file = "pygame-2.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9e7396be0d9633831c3f8d5d82dd63ba373ad65599628294b7a4f8a5a01a65"},
-    {file = "pygame-2.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae6039f3a55d800db80e8010f387557b528d34d534435e0871326804df2a62f2"},
-    {file = "pygame-2.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a3a1288e2e9b1e5834e425bedd5ba01a3cd4902b5c2bff8ed4a740ccfe98171"},
-    {file = "pygame-2.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27eb17e3dc9640e4b4683074f1890e2e879827447770470c2aba9f125f74510b"},
-    {file = "pygame-2.6.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c1623180e70a03c4a734deb9bac50fc9c82942ae84a3a220779062128e75f3b"},
-    {file = "pygame-2.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef07c0103d79492c21fced9ad68c11c32efa6801ca1920ebfd0f15fb46c78b1c"},
-    {file = "pygame-2.6.1-cp313-cp313-win32.whl", hash = "sha256:3acd8c009317190c2bfd81db681ecef47d5eb108c2151d09596d9c7ea9df5c0e"},
-    {file = "pygame-2.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:813af4fba5d0b2cb8e58f5d95f7910295c34067dcc290d34f1be59c48bd1ea6a"},
-    {file = "pygame-2.6.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:56ffca6059b165bbf64f4b4be23b8068f6a0e220780e4f96ec0bb5ac3c63ec39"},
-    {file = "pygame-2.6.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3bede70ec708057e305815d6546012669226d1d80566785feca9b044216062e7"},
-    {file = "pygame-2.6.1-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f84f15d146d6aa93254008a626c56ef96fed276006202881a47b29757f0cd65a"},
-    {file = "pygame-2.6.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14f9dda45469b254c0f15edaaeaa85d2cc072ff6a83584a265f5d684c7f7efd8"},
-    {file = "pygame-2.6.1-cp36-cp36m-win32.whl", hash = "sha256:28b43190436037e428a5be28fc80cf6615304fd528009f2c688cc828f4ff104b"},
-    {file = "pygame-2.6.1-cp36-cp36m-win_amd64.whl", hash = "sha256:a4b8f04fceddd9a3ac30778d11f0254f59efcd1c382d5801271113cea8b4f2f3"},
-    {file = "pygame-2.6.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a620883d589926f157b8f1d1f543183ac52e5c30507dea445e3927ae0bee1c54"},
-    {file = "pygame-2.6.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b46e68cd168f44d0224c670bb72186688fc692d7079715f79d04096757d703d0"},
-    {file = "pygame-2.6.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b11356ac96261162d54a2c2b41a41978f00525631b01ec9c4fe26b01c66595"},
-    {file = "pygame-2.6.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:325a84d072d52e3c2921eff02f87c6a74b7e77d71db3bdf53801c6c975f1b6c4"},
-    {file = "pygame-2.6.1-cp37-cp37m-win32.whl", hash = "sha256:2a615d78b2364e86f541458ff41c2a46181b9a1e9eabd97b389282fdf04efbb3"},
-    {file = "pygame-2.6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:94afd1177680d92f9214c54966ad3517d18210c4fbc5d84a0192d218e93647e0"},
-    {file = "pygame-2.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97ac4e13847b6b293ecaffa5ffce9886c98d09c03309406931cc592f0cea6366"},
-    {file = "pygame-2.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d1a7f2b66ac2e4c9583b6d4c6d6f346fb10a3392c04163f537061f86a448ed5c"},
-    {file = "pygame-2.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac3f033d2be4a9e23660a96afe2986df3a6916227538a6a0061bc218c5088507"},
-    {file = "pygame-2.6.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1bf7ab5311bbced70320f1a56701650b4c18231343ae5af42111eea91e0949a"},
-    {file = "pygame-2.6.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21160d9093533eb831f1b708e630706e5ac16b30750571ec27bc3b8364814f38"},
-    {file = "pygame-2.6.1-cp38-cp38-win32.whl", hash = "sha256:7bffdd3eaf394d9645331d1c3a5df9d782ebcc3c5a78f3b657c7879a828dd111"},
-    {file = "pygame-2.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:818b4eaec9c4acb6ac64805d4ca8edd4062bebca77bd815c18739fe2842c97e9"},
-    {file = "pygame-2.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15efaa11a80a65dd589a95bebe812fa5bfc7e14946b638a424c5bd9ac6cca1a4"},
-    {file = "pygame-2.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:481cfe1bdbb7fe00acc5950c494c26f00240888619bdc396fc8c39a734797432"},
-    {file = "pygame-2.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d09fd950725d187aa5207c0cb8eb9ab0d2f8ce9ab8d189c30eeb470e71b617e"},
-    {file = "pygame-2.6.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:163e66de169bd5670c86e27d0b74aad0d2d745e3b63cf4e7eb5b2bff1231ca8d"},
-    {file = "pygame-2.6.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb6e8d0547f30ddc845f4fd1e33070ef548233ad0dbf21f7ecea768883d1bbdc"},
-    {file = "pygame-2.6.1-cp39-cp39-win32.whl", hash = "sha256:d29eb9a93f12aa3d997b6e3c447ac85b2a4b142ab2548441523a8fcf5e216042"},
-    {file = "pygame-2.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:6582aa71a681e02e55d43150a9ab41394e6bf4d783d2962a10aea58f424be060"},
-    {file = "pygame-2.6.1-pp36-pypy36_pp73-win32.whl", hash = "sha256:4a8ea113b1bf627322a025a1a5a87e3818a7f55ab3a4077ff1ae5c8c60576614"},
-    {file = "pygame-2.6.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b7f9f8e6f76de36f4725175d686601214af362a4f30614b4dae2240198e72e6f"},
-    {file = "pygame-2.6.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:bbb7167c92103a2091366e9af26d4914ba3776666e8677d3c93551353fffa626"},
-    {file = "pygame-2.6.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17498a2b043bc0e795faedef1b081199c688890200aef34991c1941caa2d2c89"},
-    {file = "pygame-2.6.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7103c60939bbc1e05cfc7ba3f1d2ad3bbf103b7828b82a7166a9ab6f51950146"},
-    {file = "pygame-2.6.1.tar.gz", hash = "sha256:56fb02ead529cee00d415c3e007f75e0780c655909aaa8e8bf616ee09c9feb1f"},
+    {file = "pygame_ce-2.5.7-cp310-cp310-macosx_10_11_universal2.whl", hash = "sha256:77d7d27cc46b846c814f1d85366d5af066483917f26433a10a949e74f471dfeb"},
+    {file = "pygame_ce-2.5.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9da68411fd4a1bb50375a40cc0ee5c0f83da51450acda8a87e7a83837670f657"},
+    {file = "pygame_ce-2.5.7-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1f9a60af653f367807dc50059122a5f6cbd1b5de68233306421b0c3218bcc92d"},
+    {file = "pygame_ce-2.5.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:322052db99d25f7faca0502e0b2a905eb68c10ec5f7a1fd59803c4175f1d1269"},
+    {file = "pygame_ce-2.5.7-cp310-cp310-win32.whl", hash = "sha256:275910c7f26baa7bf52b8e0ebd9301fe968806b9bd777506d6fc17e2bbcf3121"},
+    {file = "pygame_ce-2.5.7-cp310-cp310-win_amd64.whl", hash = "sha256:de75f752d6a090839ebdc69b3365478d0f8a98ea02d1eee417a4a1436c1b5168"},
+    {file = "pygame_ce-2.5.7-cp311-cp311-macosx_10_11_universal2.whl", hash = "sha256:903eab0a59563fd0d134e502a11c9f144d21dc93ee1f5b4b4eec31f8745142b4"},
+    {file = "pygame_ce-2.5.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ec2f2d8c95d1a5c9bfc9ffbae4c356fd71bcbc4fde70e59e3d050261bacfe908"},
+    {file = "pygame_ce-2.5.7-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:a980109b5aee984b78c884d607d4a406a92b63a8a7044180d0e2f8a26a7276a7"},
+    {file = "pygame_ce-2.5.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d35105bf5a453ffb64333bc3be685f8c55cbc4695c05733a68849a92345dcb45"},
+    {file = "pygame_ce-2.5.7-cp311-cp311-win32.whl", hash = "sha256:ecac266ccd459e354d693f2150a065fc695a6ffcdf15eaca06709620bb837fea"},
+    {file = "pygame_ce-2.5.7-cp311-cp311-win_amd64.whl", hash = "sha256:5ca51a671b5b6cfd747399f54fa91259d4b01980f517c9e8043f5c3e795f06ac"},
+    {file = "pygame_ce-2.5.7-cp311-cp311-win_arm64.whl", hash = "sha256:1f5a7e5d08f26dc8e4a899be502e6ef06ac5a2800a442750267141bca7703ba7"},
+    {file = "pygame_ce-2.5.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dca2f8ba56bf3b4b8c73c4863d0603773403f4f66bc2fe25784ea74aee3fffc3"},
+    {file = "pygame_ce-2.5.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4dc989462faf2c5947881f708fa406d7ac13c1bc987c1588d630ca62ad369989"},
+    {file = "pygame_ce-2.5.7-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:538689d77c44f5dbefc0abeab5b9806d6c90ebc6d64078736c8a4c731f08b37e"},
+    {file = "pygame_ce-2.5.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0d8fa8822c0f4b5ae6b44ada7be9f2193fd512da1381c37fa3c5bda8971d64cb"},
+    {file = "pygame_ce-2.5.7-cp312-cp312-win32.whl", hash = "sha256:604207c8813a594919bb7e6d3ed3834f79aa099d2d648e8a8fc3d786ad3fa1c8"},
+    {file = "pygame_ce-2.5.7-cp312-cp312-win_amd64.whl", hash = "sha256:b6b7eec2779fac11ed265a18ab926b3829654120a5c9a07c36eeedeb012b8c3c"},
+    {file = "pygame_ce-2.5.7-cp312-cp312-win_arm64.whl", hash = "sha256:eb99a8a7185057064163610b3ca3e1d3307f0eb3dfff6e19556fa5132c6bca87"},
+    {file = "pygame_ce-2.5.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8766985d802017e150fcfb9adc659967d8548a961d491b8974f0748412cc0427"},
+    {file = "pygame_ce-2.5.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eabfc184dd28c682e5e6ccfce01224bb12653c6648babcd0bb9d9bd662d938"},
+    {file = "pygame_ce-2.5.7-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:92d5732cd512c8ec0b47e9a3e6c683fb811bdf2090362580778ce698222a64ab"},
+    {file = "pygame_ce-2.5.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:48e9a4ece43b08adc8ffb0bbbe0fe183eef4bb501a7d31552ae7398592ec0a82"},
+    {file = "pygame_ce-2.5.7-cp313-cp313-win32.whl", hash = "sha256:f6439100bdee81da96f5acffb280f20511c93dd67178d8a8cf29d6cfd1aa46dd"},
+    {file = "pygame_ce-2.5.7-cp313-cp313-win_amd64.whl", hash = "sha256:25047c97760fc640a6a8bbfdf3398c5825adfd55986a7523a65c95a1fb61d759"},
+    {file = "pygame_ce-2.5.7-cp313-cp313-win_arm64.whl", hash = "sha256:f5a7197096ef82d588539f13b0f7ade767f800c7b0b015a94e386f488e1039f6"},
+    {file = "pygame_ce-2.5.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:addfbf7d119647eec157d611f463e5d572836fa74d0d222c59210294ed618b9d"},
+    {file = "pygame_ce-2.5.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1c92feb289e34ac323a7b62b91d230a4e32b44059c4d7fb95631e504b1d9b365"},
+    {file = "pygame_ce-2.5.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c1ed4100311015cc67d24fe4e4d7c2f4cb25c34e043545061271c1d018c02d4"},
+    {file = "pygame_ce-2.5.7-cp314-cp314-win32.whl", hash = "sha256:1b7e641af3aeac92bb7e4df1402d6802271f4bd09c747d2665f154ef9b07a4e6"},
+    {file = "pygame_ce-2.5.7-cp314-cp314-win_amd64.whl", hash = "sha256:595f4257c15fed11cd816ae0bfabad3d99f8f04a000d7d164a22c0f9afd6cb65"},
+    {file = "pygame_ce-2.5.7-cp314-cp314-win_arm64.whl", hash = "sha256:3e6ce74fd5a5f146f1bcf0224ae3e880c733917a9edbb53f790329d235520433"},
+    {file = "pygame_ce-2.5.7-pp311-pypy311_pp73-macosx_10_15_universal2.whl", hash = "sha256:8718bc75cd4ec4bd2b0b4862e5c615b119a45038e4572c9030731db3c6740cfe"},
+    {file = "pygame_ce-2.5.7-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6e4289438b0b530bbfa6e2a9088a5b4a9dc0524b6d60989c846e2c74cd149f77"},
+    {file = "pygame_ce-2.5.7-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:01c48daf8fccb66e8750d5d26bf20e3f0dc484a5f7748fa98fd7e2ac69a2f7f5"},
+    {file = "pygame_ce-2.5.7-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8ac7120a6c39c042abdc6bd2f5491f7121936c68c425235b52dc451ed9583dbf"},
+    {file = "pygame_ce-2.5.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d79c4f961ffa89183380c4284914ed2f693591baec7fb92e42f05857dd8664bd"},
+    {file = "pygame_ce-2.5.7.tar.gz", hash = "sha256:86beb797cd73c141299a29b56f7df2b0543fbdc81d428022458329ff694aaa51"},
 ]
 
 [[package]]
@@ -1210,7 +1189,7 @@ files = [
 ]
 
 [package.extras]
-brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
+brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
 h2 = ["h2 (>=4,<5)"]
 socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
 zstd = ["zstandard (>=0.18.0)"]
@@ -1261,4 +1240,4 @@ watchmedo = ["PyYAML (>=3.10)"]
 [metadata]
 lock-version = "2.1"
 python-versions = ">=3.11,<4.0"
-content-hash = "270d9aa0b331de80c37225620983d01731a9d87c7effa409e3e1065d7010402e"
+content-hash = "d79ef7ae6b87e28c25116e677403811bf89628ea0cc4646f24c60f95669afc51"

+ 7 - 6
pyproject.toml

@@ -3,12 +3,14 @@ name = "jogai"
 version = "0.1.0"
 description = ""
 authors = [
-    {name = "zerofatorial",email = "info@zero.gal"}
+    {name = "Zero! Studio",email = "info@zero.gal"}
 ]
 readme = "README.md"
 requires-python = ">=3.11,<4.0"
 dependencies = [
-    "ignr (>=2.2,<3.0)"
+    "ignr (>=2.2,<3.0)",
+    "jinja2 (>=3.1.6,<4.0.0)",
+    "pygame-ce (>=2.5.7,<3.0.0)"
 ]
 
 
@@ -22,7 +24,6 @@ pytest-cov = "^6.0.0"
 isort = "^6.0.1"
 blue = "^0.9.1"
 taskipy = "^1.14.1"
-pygame = "^2.6.1"
 polib = "^1.2.0"
 
 
@@ -34,7 +35,7 @@ pymdown-extensions = "^10.14.3"
 
 [tool.pytest.ini_options]
 pythonpath = "."
-addopts = "--doctest-modules"
+#addopts = "--doctest-modules"
 
 [tool.isort]
 profile = "black"
@@ -44,6 +45,6 @@ line_length = 79
 lint = "blue --check --diff . && isort --check --diff ."
 docs = "mkdocs serve"
 pre_test = "task lint"
-test = "pytest -x -s --cov=jogai -vv"
-script = "pytest -s scripts/basics.py"
+test = "pytest -x -s --pdb --cov=jogai -vv"
+scripts = "pytest -s scripts/${LANG}_basics.py"
 post_test = "coverage html"

+ 7 - 2
scripts/basics.py → scripts/en_basics.py

@@ -19,11 +19,16 @@
 # info@zero.gal
 
 from jogai import *
-from jogai.utils import _get_keys, eval_key
 
 
 def test_example01():
-    prepare('02.yaml')
+    @prepare('02')
+    def level_02():
+        protagonist.speak('Olá, como estás?', key='a')
+        secondary.speak('Todo ben', key='s')
+        print(protagonist.calculate_relative_position(secondary))
 
+    load_environment('02')
+    # breakpoint()
     while is_running():
         update()

+ 39 - 0
scripts/gl_basics.py

@@ -0,0 +1,39 @@
+# jogai - Python Game Library
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Library General Public
+# License as published by the Free Software Foundation; either
+# version 2 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Library General Public License for more details.
+#
+# You should have received a copy of the GNU Library General Public
+# License along with this library; if not, write to the Free
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+# Ramón Palleiro Rodríguez
+# Zero! Factorial Studio
+# info@zero.gal
+
+from jogai import *
+
+
+def test_gl01():
+    @preparar('01')
+    def nivel_01():
+        protagonista.avanzar()
+        if protagonista.tocar(secundario):
+            cargar_ambiente('02')
+
+    @preparar('02')
+    def nivel_02():
+        ...
+        # print(protagonista.x)
+
+    cargar_ambiente('02')
+
+    while está_a_correr():
+        actualizar()

+ 62 - 6
tests/conftest.py

@@ -18,14 +18,70 @@
 # Zero! Factorial Studio
 # info@zero.gal
 
-from pytest import fixture
+import pytest
+from pygame.display import get_init
 
 from jogai import videogame as vg
+from jogai.groups import Group, SquareGroup
 
 
-@fixture()
-def prepare_videogame():
+@vg.prepare('test')
+def test_code():
+    ...
+
+
+@pytest.fixture(scope='class')
+def videogame_test():
     vg.init()
-    vg.prepare()
-    yield
-    vg.quit()
+    vg.load_environment('test')
+    yield vg
+
+
+@pytest.fixture
+def scene_filename_test():
+    return 'scene.png'
+
+
+@pytest.fixture
+def character_filename_test():
+    return 'protagonist.png'
+
+
+@pytest.fixture(scope='class')
+def character_test():
+    return protagonist
+
+
+@pytest.fixture
+def character_name_test():
+    return 'protagonist'
+
+
+@pytest.fixture
+def secondary_character_test():
+    return secondary
+
+
+@pytest.fixture
+def group_name_test():
+    return 'protagonists'
+
+
+@pytest.fixture
+def group_character_test(character_test):
+    return character_test
+
+
+@pytest.fixture
+def simple_group_test(group_name_test, group_character_test):
+    return Group(group_name_test, [group_character_test])
+
+
+@pytest.fixture
+def square_group_test(group_name_test, group_character_test):
+    return SquareGroup(group_name_test, group_character_test, 2)
+
+
+@pytest.fixture
+def random_group_test():
+    return bricks

+ 413 - 154
tests/test_character.py

@@ -18,205 +18,464 @@
 # Zero! Factorial Studio
 # info@zero.gal
 
+from os.path import join
+
 import pygame
 import pytest
 
-from jogai import character, settings
+from jogai import paths, settings
 from jogai import videogame as vg
+from jogai.character import (
+    Character,
+    _check_and_format_args_for_group,
+    _load_characters_and_groups,
+)
+from jogai.texts import SpeechBubble
 from jogai.translations import gettext as _
 
 
-def test_load_data_with_none_argument(prepare_videogame):
-    protagonist._load_data(None)
-
-
-def test_load_data_with_wrong_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist._load_data('foo')
-
-
-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):
-        protagonist = character.Character('protagonist.png', name=1)
-
-
-def test_repr_character(prepare_videogame):
-    print(protagonist.__repr__)
-
-
-def test_wrong_predefined_keys_type():
-    with pytest.raises(TypeError):
-        protagonist = 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_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist.move(5, 3, key=-1)
-
-
-def test_rotate_with_wrong_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist.rotate('foo')
-
-
-def test_decrease_with_integer_argument(prepare_videogame):
-    protagonist.decrease(2)
+def test_check_and_format_args_for_a_random_group_with_a_dict():
+    assert (
+        _check_and_format_args_for_group('random', {'foo': 'bar'})
+        == '**arguments'
+    )
 
 
-def test_decrease_with_wrong_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist.decrease('foo')
+def test_check_and_format_args_for_a_rectangle_group_with_a_list():
+    assert (
+        _check_and_format_args_for_group('rectangle', [2, 3]) == '*arguments'
+    )
 
 
-def test_increase_with_integer_argument(prepare_videogame):
-    protagonist.increase(2)
+def test_check_and_format_args_for_a_square_group_with_a_integer():
+    assert _check_and_format_args_for_group('square', 3) == 'arguments'
 
 
-def test_increase_with_wrong_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist.increase('foo')
+def test_check_and_format_args_for_a_stairs_group_with_a_dict():
+    assert (
+        _check_and_format_args_for_group('stairs', {'foo': 'bar'})
+        == '**arguments'
+    )
 
 
-def test_scale_with_wrong_factor_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist.scale_to('foo')
+def test_check_and_format_args_for_group_with_wrong_args():
+    group_type = 'rectangle'
+    args_type = list.__name__
+    with pytest.raises(TypeError) as e:
+        _check_and_format_args_for_group(group_type, 5)
+    assert (
+        str(e.value)
+        == f'the arguments for the {group_type} group must be {args_type}'
+    )
 
 
-def test_scale_with_a_negative_factor(prepare_videogame):
-    with pytest.raises(ValueError):
-        protagonist.scale_to(-1)
+def test_check_and_format_args_for_unknown_group():
+    group_type = 'foo'
+    with pytest.raises(TypeError) as e:
+        format = _check_and_format_args_for_group(group_type, 3)
+    assert str(e.value) == f'the group type {group_type} is unknown'
 
 
-def test_flip_with_wrong_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist.flip('foo', 'bar')
+def test_load_characters_with_no_characters_key_in_data():
+    characters = _load_characters_and_groups({})
+    assert characters == ([], [])
 
 
-def test_set_angle_with_wrong_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist.set_angle('foo')
+def test_load_characters_with_a_correct_filename():
+    characters = _load_characters_and_groups(
+        {'foo': {'filename': 'protagonist.png'}}
+    )
+    assert characters[0][0].name == 'foo'
+    assert characters[0][0].image_filename == 'protagonist.png'
 
 
-def test_set_height_with_wrong_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist.set_height('foo')
+# def test_load_data_with_none_argument(videogame_test):
+# ...  # protagonist._load_data(None)
 
 
-def test_set_height_with_a_negative_amount(prepare_videogame):
-    with pytest.raises(ValueError):
-        protagonist.set_height(-1)
+class TestCharacter:
+    def test_load_data_with_wrong_type(self, videogame_test, character_test):
+        with pytest.raises(TypeError) as e:
+            character_test._load_data('foo')
+        assert str(e.value) == 'the data input must be a dict'
 
+    def test_height_is_a_non_negative_integer(
+        self, videogame_test, character_test
+    ):
+        assert character_test.height > -1
 
-def test_set_keys_with_wrong_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist.set_keys('foo')
+    def test_width_is_a_non_negative_integer(
+        self, videogame_test, character_test
+    ):
+        assert character_test.width > -1
 
+    def test_x_coordinate_is_an_integer(self, videogame_test, character_test):
+        assert isinstance(character_test.x, int)
 
-def test_set_position_with_wrong_types(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist.set_position('foo', 'bar')
+    def test_y_coordinate_is_an_integer(self, videogame_test, character_test):
+        assert isinstance(character_test.y, int)
 
+    def test_wrong_character_name(self):
+        with pytest.raises(TypeError) as e:
+            Character('protagonist.png', name=1)
+        assert str(e.value) == 'the character name must be a string'
 
-def test_set_position_with_wrong_number_of_parameters(prepare_videogame):
-    with pytest.raises(ValueError):
-        protagonist.set_position((2, 3, 4))
+    def test_repr_character(self, videogame_test, character_test):
+        assert repr(character_test) == f'Character: {character_test.name}'
 
+    def test_angle_property(self, videogame_test, character_test):
+        angle = 90
+        character_test.angle = angle
+        assert character_test.angle == angle
 
-def test_set_width_with_wrong_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist.set_width('a')
+    def test_bouncy_property(self, videogame_test, character_test):
+        bouncy = True
+        character_test.bouncy = bouncy
+        assert character_test.bouncy == bouncy
 
+    def test_center_property(self, videogame_test, character_test):
+        assert character_test.center == (
+            character_test.x + character_test.width // 2,
+            character_test.y + character_test.height // 2,
+        )
 
-def test_set_width_with_a_negative_amount(prepare_videogame):
-    with pytest.raises(ValueError):
-        protagonist.set_width(-1)
-
-
-def test_touch_with_wrong_characters_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist.touch('foo')
-
-
-def test_touch_with_list_not_all_characters(prepare_videogame):
-    with pytest.raises(ValueError):
-        protagonist.touch([secondary, 'foo'])
-
-
-def test_touch_with_wrong_key_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist.touch(secondary, key=-1)
-
-
-def test_touch_without_sprites_colliding(prepare_videogame):
-    protagonist.move_to(0, 0)
-    secondary.move_to(5000, 500)
-    vg.update()
-    assert protagonist.touch(secondary) == ''
-
-
-def test_jump_with_force_as_integer(prepare_videogame):
-    protagonist.jump(5)
-
+    def test_collidable_property(self, videogame_test, character_test):
+        collidable = True
+        character_test.collidable = collidable
+        assert character_test.collidable == collidable
+
+    def test_gravitative_property(self, videogame_test, character_test):
+        gravitative = True
+        character_test.gravitative = gravitative
+        assert character_test.gravitative == gravitative
+
+    def test_height_property(self, videogame_test, character_test):
+        height = 100
+        character_test.height = height
+        assert character_test.height == height
+
+    def test_image_filename_property(self, character_filename_test):
+        ch = Character(character_filename_test)
+        assert ch.image_filename == character_filename_test
+
+    def test_image_path_property(self, character_filename_test):
+        ch = Character(character_filename_test)
+        assert ch.image_path == join(
+            paths.CHARACTERS_DIR, character_filename_test
+        )
 
-def test_jump_with_wrong_force_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist.jump('foo')
+    def test_jump_force_property(self, videogame_test, character_test):
+        assert (
+            character_test.jump_force == settings.CHARACTER_DEFAULT_JUMP_FORCE
+        )
 
+    def test_jumping_property(self, videogame_test, character_test):
+        assert character_test.jumping == False
+
+    def test_lives_property(self, videogame_test, character_test):
+        lives = 3
+        character_test.lives = lives
+        assert character_test.lives == lives
+
+    def test_lives_no_property(self, videogame_test, secondary_character_test):
+        assert secondary_character_test.lives is None
+
+    def test_name_property(self, character_filename_test, character_name_test):
+        ch = Character(character_filename_test, character_name_test)
+        assert ch.name == character_name_test
+
+    def test_position_property(self, videogame_test, character_test):
+        x, y = 200, 300
+        character_test.x = x
+        character_test.y = y
+        assert character_test.position == (x, y)
+
+    def test_scale_property(self, videogame_test, character_test):
+        scale = 0.5
+        character_test.scale = scale
+        assert character_test.scale == scale
+
+    def test_score_property(self, videogame_test, character_test):
+        score = 3
+        character_test.score = score
+        assert character_test.score == score
+
+    def test_score_no_property(self, videogame_test, secondary_character_test):
+        assert secondary_character_test.score is None
+
+    def test_speaking_property(self, videogame_test, character_test):
+        character_test.speak('foo')
+        assert character_test.speaking == True
+
+    def test_speech_bubble_property(self, videogame_test, character_test):
+        assert isinstance(character_test.speech_bubble, SpeechBubble)
+
+    def test_speed_property(self, videogame_test, character_test):
+        speed = 20
+        character_test.speed = speed
+        assert character_test.speed == speed
+
+    def test_touchable_property(self, videogame_test, character_test):
+        touchable = True
+        character_test.touchable = touchable
+        assert character_test.touchable == touchable
+
+    def test_visible_property(self, videogame_test, character_test):
+        visible = False
+        character_test.visible = visible
+        assert character_test.visible == visible
+
+    def test_width_property(self, videogame_test, character_test):
+        width = 100
+        character_test.width = width
+        assert character_test.width == width
+
+    def test_calculate_relative_position_top(
+        self, videogame_test, character_test, secondary_character_test
+    ):
+        character_test.move_to(0, 0)
+        secondary_character_test.move_to(0, character_test.height + 1)
+        assert (
+            character_test.calculate_relative_position(
+                secondary_character_test
+            )
+            == character_test.TOP
+        )
 
-def test_jump_with_negative_force(prepare_videogame):
-    with pytest.raises(ValueError):
-        protagonist.jump(-5)
+    def test_calculate_relative_position_bottom(
+        self, videogame_test, character_test, secondary_character_test
+    ):
+        character_test.move_to(0, secondary_character_test.height + 1)
+        secondary_character_test.move_to(0, 0)
+        assert (
+            character_test.calculate_relative_position(
+                secondary_character_test
+            )
+            == character_test.BOTTOM
+        )
 
+    def test_calculate_relative_position_left(
+        self, videogame_test, character_test, secondary_character_test
+    ):
+        character_test.move_to(0, 0)
+        secondary_character_test.move_to(character_test.width + 1, 0)
+        assert (
+            character_test.calculate_relative_position(
+                secondary_character_test
+            )
+            == character_test.LEFT
+        )
 
-def test_jump_with_wrong_key_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist.jump(5, key=-1)
+    def test_calculate_relative_position_right(
+        self, videogame_test, character_test, secondary_character_test
+    ):
+        character_test.move_to(secondary_character_test.width + 1, 0)
+        secondary_character_test.move_to(0, 0)
+        assert (
+            character_test.calculate_relative_position(
+                secondary_character_test
+            )
+            == character_test.RIGHT
+        )
 
+    def test_move_with_wrong_coordinates(self, videogame_test, character_test):
+        with pytest.raises(TypeError) as e:
+            character_test.move('foo', 'bar')
+        assert str(e.value) == 'amount to move must be an integer'
+
+    def test_move_with_wrong_key_type(self, videogame_test, character_test):
+        with pytest.raises(TypeError) as e:
+            character_test.move(5, 3, key=-1)
+        assert str(e.value) == 'key pressed must be a string'
+
+    def test_rotate_with_wrong_type(self, videogame_test, character_test):
+        with pytest.raises(TypeError) as e:
+            character_test.rotate('foo')
+        assert str(e.value) == 'the rotation increment must be an integer'
+
+    def test_decrease_with_integer_argument(
+        self, videogame_test, character_test
+    ):
+        character_test.decrease(2)
+
+    def test_decrease_with_wrong_type(self, videogame_test, character_test):
+        with pytest.raises(TypeError) as e:
+            character_test.decrease('foo')
+        assert str(e.value) == 'the decrement must be a float number'
+
+    def test_increase_with_integer_argument(
+        self, videogame_test, character_test
+    ):
+        character_test.increase(2)
+
+    def test_increase_with_wrong_type(self, videogame_test, character_test):
+        with pytest.raises(TypeError) as e:
+            character_test.increase('foo')
+        assert str(e.value) == 'the increment must be a float number'
+
+    def test_scale_with_wrong_factor_type(
+        self, videogame_test, character_test
+    ):
+        with pytest.raises(TypeError) as e:
+            character_test.scale_to('foo')
+        assert (
+            str(e.value)
+            == 'the scale factor must be a positive number (float or int)'
+        )
 
-def test_jump_counter_when_updating_jump(prepare_videogame):
-    protagonist.jump()
-    previous_jump_counter = protagonist._jump_counter
-    protagonist._update_jump()
-    assert protagonist._jump_counter == previous_jump_counter - 1
+    def test_scale_with_a_negative_factor(
+        self, videogame_test, character_test
+    ):
+        with pytest.raises(ValueError) as e:
+            character_test.scale_to(-1)
+        assert (
+            str(e.value)
+            == 'the scale factor must be a non-negative number (float or int)'
+        )
 
+    def test_flip_with_wrong_type(self, videogame_test, character_test):
+        with pytest.raises(TypeError) as e:
+            character_test.flip('foo', 'bar')
+        assert (
+            str(e.value)
+            == 'both the horizontal and vertical axis must be booleans'
+        )
 
-def test_jump_counter_when_jump_finishes():
-    protagonist.jump()
-    while protagonist.is_jumping:
-        protagonist._update_jump()
-    assert protagonist._jump_counter == settings.DEFAULT_JUMP_FORCE
+    def test_set_angle_with_wrong_type(self, videogame_test, character_test):
+        with pytest.raises(TypeError) as e:
+            character_test.set_angle('foo')
+        assert str(e.value) == 'the rotation angle must be an integer'
+
+    @pytest.mark.parametrize('height', [True, False, 'foo', [1, 2, 3]])
+    def test_set_height_with_wrong_type(
+        self, videogame_test, character_test, height
+    ):
+        with pytest.raises(TypeError) as e:
+            character_test.set_height(height)
+        assert str(e.value) == 'height must be a non-negative integer'
+
+    def test_set_height_with_a_negative_amount(
+        self, videogame_test, character_test
+    ):
+        with pytest.raises(ValueError) as e:
+            character_test.set_height(-1)
+        assert str(e.value) == 'height must be a non-negative integer'
+
+    def test_set_keys_with_wrong_type(self, videogame_test, character_test):
+        with pytest.raises(TypeError) as e:
+            character_test.set_keys('foo')
+        assert (
+            str(e.value)
+            == 'the predefined keys must be a dict. No keys are loaded'
+        )
 
+    def test_set_position_with_wrong_types(
+        self, videogame_test, character_test
+    ):
+        with pytest.raises(TypeError) as e:
+            character_test.set_position('foo', 'bar')
+        assert str(e.value) == 'the position must be a tuple or list (x, y)'
+
+    def test_set_position_with_wrong_number_of_parameters(
+        self, videogame_test, character_test
+    ):
+        with pytest.raises(ValueError) as e:
+            character_test.set_position((2, 3, 4))
+        assert str(e.value) == 'the position has two coordinates (x, y)'
+
+    @pytest.mark.parametrize('width', [True, False, 'foo', [1, 2, 3]])
+    def test_set_width_with_wrong_type(
+        self, videogame_test, character_test, width
+    ):
+        with pytest.raises(TypeError) as e:
+            character_test.set_width(width)
+        assert str(e.value) == 'width must be a non-negative integer'
+
+    def test_set_width_with_a_negative_amount(
+        self, videogame_test, character_test
+    ):
+        with pytest.raises(ValueError) as e:
+            character_test.set_width(-1)
+        assert str(e.value) == 'width must be a non-negative integer'
+
+    def test_touch_with_wrong_characters_type(
+        self, videogame_test, character_test
+    ):
+        with pytest.raises(TypeError) as e:
+            character_test.touch('foo')
+        assert (
+            str(e.value)
+            == 'the instances to be touched must be a character, a group or a list of characters'
+        )
 
-def test_update_rect_position_with_wrong_traking_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        protagonist._update_rect_position(tracking='foo')
+    def test_touch_with_list_not_all_characters(
+        self, videogame_test, character_test, secondary_character_test
+    ):
+        with pytest.raises(TypeError) as e:
+            character_test.touch([secondary_character_test, 'foo'])
+        assert (
+            str(e.value)
+            == 'the instances to be touched must be a character, a group or a list of characters'
+        )
 
+    def test_touch_with_wrong_key_type(
+        self, videogame_test, character_test, secondary_character_test
+    ):
+        with pytest.raises(TypeError) as e:
+            character_test.touch(secondary_character_test, key=-1)
+        assert str(e.value) == 'key pressed must be a string'
+
+    def test_touch_without_sprites_colliding(
+        self, videogame_test, character_test, secondary_character_test
+    ):
+        character_test.move_to(0, 0)
+        secondary_character_test.move_to(5000, 500)
+        vg.update()
+        assert character_test.touch(secondary_character_test) == []
+
+    # TODO: assert ??
+    def test_jump_with_force_as_integer(self, videogame_test, character_test):
+        character_test.jump(5)
+
+    def test_jump_with_wrong_force_type(self, videogame_test, character_test):
+        with pytest.raises(TypeError) as e:
+            character_test.jump('foo')
+        assert (
+            str(e.value)
+            == 'the jump force must be a non-negative float number'
+        )
 
-def test_update_rect_position_when_character_moved(prepare_videogame):
-    protagonist.move_to(1000, 0)
-    protagonist._update_rect_position(tracking=True)
-    protagonist.move_to(3000, 0)
-    protagonist._update_rect_position(tracking=True)
+    def test_jump_with_negative_force(self, videogame_test, character_test):
+        with pytest.raises(ValueError) as e:
+            character_test.jump(-5)
+        assert str(e.value) == 'the jump force must be a non-negative float'
+
+    def test_jump_with_wrong_key_type(self, videogame_test, character_test):
+        with pytest.raises(TypeError) as e:
+            character_test.jump(5, key=-1)
+        assert str(e.value) == 'key pressed must be a string'
+
+    def test_jump_counter_when_updating_jump(
+        self, videogame_test, character_test
+    ):
+        character_test.jump()
+        previous_jump_counter = character_test._jump_counter
+        character_test._update_jump()
+        assert character_test._jump_counter == previous_jump_counter - 1
+
+    def test_update_rect_position_with_wrong_traking_type(
+        self, videogame_test, character_test
+    ):
+        with pytest.raises(TypeError) as e:
+            character_test._update_rect_position(tracking='foo')
+        assert str(e.value) == 'the tracking argument must be a boolean'
+
+    """
+    def test_update_rect_position_when_character_moved(
+        self, videogame_test, character_test
+    ):
+        character_test.move_to(1000, 0)
+        character_test._update_rect_position(tracking=True)
+        character_test.move_to(3000, 0)
+        character_test._update_rect_position(tracking=True)
+    """

+ 0 - 94
tests/test_group.py

@@ -1,94 +0,0 @@
-# jogai - Python Game Library
-#
-# This library is free software; you can redistribute it and/or
-# modify it under the terms of the GNU Library General Public
-# License as published by the Free Software Foundation; either
-# version 2 of the License, or (at your option) any later version.
-#
-# This library is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-# Library General Public License for more details.
-#
-# You should have received a copy of the GNU Library General Public
-# License along with this library; if not, write to the Free
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
-#
-# Ramón Palleiro Rodríguez
-# Zero! Factorial Studio
-# info@zero.gal
-
-import pytest
-
-from jogai import *
-
-
-def test_random_group_with_amount_wrong_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        RandomGroup(protagonist, 'foo', (0, 200), (0, 200))
-
-
-def test_random_group_with_ranges_wrong_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        RandomGroup(protagonist, 2, 'bar', 'foo')
-
-
-def test_random_group_with_amount_not_greater_than_1(prepare_videogame):
-    with pytest.raises(ValueError):
-        RandomGroup(protagonist, -1, (0, 200), (0, 200))
-
-
-def test_random_group_with_wrong_length_ranges(prepare_videogame):
-    with pytest.raises(ValueError):
-        RandomGroup(protagonist, 3, (1, 2, 3), (1, 2, 3))
-
-
-def test_random_group_with_min_greater_than_max_range(prepare_videogame):
-    with pytest.raises(ValueError):
-        RandomGroup(protagonist, 3, (100, 3), (100, 3))
-
-
-def test_random_group_with_lists(prepare_videogame):
-    RandomGroup(protagonist, 3, [0, 200], [0, 200])
-
-
-def test_rectangle_group_with_wrong_sides_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        RectangleGroup(protagonist, 'foo', 'bar')
-
-
-def test_rectangle_group_with_sides_out_of_range(prepare_videogame):
-    with pytest.raises(ValueError):
-        RectangleGroup(protagonist, -1, 3)
-
-
-def test_rectangle_group_with_both_sides_equal_to_1(prepare_videogame):
-    with pytest.raises(ValueError):
-        RectangleGroup(protagonist, 1, 1)
-
-
-def test_square_group_with_wrong_side_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        SquareGroup(protagonist, 'foo')
-
-
-def test_square_group_with_side_out_of_range(prepare_videogame):
-    with pytest.raises(ValueError):
-        SquareGroup(protagonist, -1)
-
-
-def test_stairs_group_with_wrong_steps_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        StairsGroup(protagonist, 'foo')
-
-
-def test_stairs_group_with_steps_out_of_range(prepare_videogame):
-    with pytest.raises(ValueError):
-        StairsGroup(protagonist, -1)
-
-
-def test_stairs_group_with_horizontal_and_vertical_flip_wrong_type(
-    prepare_videogame,
-):
-    with pytest.raises(TypeError):
-        StairsGroup(protagonist, 3, 'foo', 'bar')

+ 197 - 0
tests/test_groups.py

@@ -0,0 +1,197 @@
+# jogai - Python Game Library
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Library General Public
+# License as published by the Free Software Foundation; either
+# version 2 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Library General Public License for more details.
+#
+# You should have received a copy of the GNU Library General Public
+# License along with this library; if not, write to the Free
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+# Ramón Palleiro Rodríguez
+# Zero! Factorial Studio
+# info@zero.gal
+
+import pytest
+
+from jogai import *
+
+
+class TestGroups:
+    def test_wrong_type_group_name(self, videogame_test):
+        with pytest.raises(TypeError) as e:
+            Group(-1, [protagonist])
+        assert str(e.value) == 'group name must be a string'
+
+    def test_wrong_type_group_characters(videogame_test):
+        with pytest.raises(TypeError) as e:
+            Group('protagonists', 'foo')
+        assert str(e.value) == 'characters in group must be a list'
+
+    def test_group_backward(self, videogame_test, square_group_test):
+        initial = square_group_test[0].x
+        offset = 10
+        square_group_test.backward(offset)
+        assert square_group_test[0].x == initial - offset
+
+    def test_group_decrease(self, videogame_test, square_group_test):
+        initial = square_group_test[0].scale
+        offset = 0.1
+        square_group_test.decrease(offset)
+        assert square_group_test[0].scale == max(0, initial - offset)
+
+    def test_group_move_with_key(self, videogame_test, square_group_test):
+        initial_x = square_group_test[0].x
+        initial_y = square_group_test[0].y
+        offset = 5
+        square_group_test.move(offset, offset, key='s')
+        assert square_group_test[0].x == initial_x
+        assert square_group_test[0].y == initial_y
+
+    def test_group_flip(self, videogame_test, square_group_test):
+        initial = square_group_test[0].flipped
+        square_group_test.flip(True, False)
+        assert square_group_test[0].flipped == {
+            'horizontal': True,
+            'vertical': False,
+        }
+
+    def test_group_decrease(self, videogame_test, square_group_test):
+        initial = square_group_test[0].scale
+        offset = 0.1
+        square_group_test.decrease(offset)
+        assert square_group_test[0].scale == max(0, initial - offset)
+
+    def test_group_set_attribute(self, videogame_test, square_group_test):
+        new_height = 10
+        square_group_test.set_height(new_height)
+        assert square_group_test[0].height == new_height
+
+    def test_group_contains(
+        self, videogame_test, simple_group_test, group_character_test
+    ):
+        assert group_character_test in simple_group_test
+
+    def test_group_iteration(
+        self, videogame_test, simple_group_test, group_character_test
+    ):
+        group_iterator = iter(simple_group_test)
+        assert next(group_iterator) == group_character_test
+
+    def test_group_name_property(
+        self, videogame_test, simple_group_test, group_name_test
+    ):
+        assert simple_group_test.name == group_name_test
+
+    def test_group_characters_remove(
+        self, videogame_test, simple_group_test, group_character_test
+    ):
+        simple_group_test.remove(group_character_test)
+        assert simple_group_test.characters == []
+
+    def test_random_group_with_amount_wrong_type(self, videogame_test):
+        with pytest.raises(TypeError) as e:
+            RandomGroup('protagonists', protagonist, 'foo', (0, 200), (0, 200))
+        assert (
+            str(e.value)
+            == 'the amount of characters must be a integer greater than 1'
+        )
+
+    def test_random_group_with_amount_not_greater_than_1(self, videogame_test):
+        with pytest.raises(ValueError) as e:
+            RandomGroup('protagonists', protagonist, -1, (0, 200), (0, 200))
+        assert (
+            str(e.value)
+            == 'the amount of characters must be a integer greater than 1'
+        )
+
+    def test_random_group_with_ranges_wrong_type(self, videogame_test):
+        with pytest.raises(TypeError) as e:
+            RandomGroup('protagonists', protagonist, 2, 'bar', 'foo')
+        assert str(e.value) == 'both x and y ranges must be tuples'
+
+    def test_random_group_with_wrong_length_ranges(self, videogame_test):
+        with pytest.raises(ValueError) as e:
+            RandomGroup('protagonists', protagonist, 3, (1, 2, 3), (1, 2, 3))
+        assert (
+            str(e.value)
+            == 'both x and y ranges must have only two items (min, max)'
+        )
+
+    def test_random_group_with_min_greater_than_max_range(
+        self, videogame_test
+    ):
+        with pytest.raises(ValueError) as e:
+            RandomGroup('protagonists', protagonist, 3, (100, 3), (100, 3))
+        assert str(e.value) == 'both x and y ranges must be sorted (min, max)'
+
+    def test_random_group_empty_range(self, videogame_test):
+        rg = RandomGroup('protagonists', protagonist, 3, (), ())
+        assert rg.x_range == (0, current_scene.width - protagonist.width)
+        assert rg.y_range == (0, current_scene.height - protagonist.height)
+
+    def test_random_group_range_lists(self, videogame_test):
+        rg = RandomGroup('protagonists', protagonist, 3, [0, 200], [0, 200])
+        assert isinstance(rg.x_range, tuple)
+        assert rg.x_range == (0, 200)
+        assert isinstance(rg.y_range, tuple)
+        assert rg.y_range == (0, 200)
+
+    def test_rectangle_group_with_wrong_sides_type(self, videogame_test):
+        with pytest.raises(TypeError) as e:
+            RectangleGroup('protagonists', protagonist, 'foo', 'bar')
+        assert str(e.value) == 'both the base and the height must be integers'
+
+    def test_rectangle_group_with_sides_out_of_range(self, videogame_test):
+        with pytest.raises(ValueError) as e:
+            RectangleGroup('protagonists', protagonist, -1, 3)
+        assert str(e.value) == 'both the base and the height must be positive'
+
+    def test_rectangle_group_with_both_sides_equal_to_1(self, videogame_test):
+        with pytest.raises(ValueError) as e:
+            RectangleGroup('protagonists', protagonist, 1, 1)
+        assert (
+            str(e.value)
+            == 'at least one of the dimensions of the rectangle must be greater than 1'
+        )
+
+    def test_square_group_with_wrong_side_type(self, videogame_test):
+        with pytest.raises(TypeError) as e:
+            SquareGroup('protagonists', protagonist, 'foo')
+        assert str(e.value) == 'the side of the square must be an integer'
+
+    def test_square_group_with_side_out_of_range(self, videogame_test):
+        with pytest.raises(ValueError) as e:
+            SquareGroup('protagonists', protagonist, -1)
+        assert str(e.value) == 'the side of the square must be greater than 1'
+
+    def test_stairs_group(self, videogame_test):
+        steps = 3
+        sg = StairsGroup('protagonists', protagonist, steps)
+        assert sg.amount == steps * (steps + 1) // 2
+
+    def test_stairs_group_with_wrong_steps_type(self, videogame_test):
+        with pytest.raises(TypeError) as e:
+            StairsGroup('protagonists', protagonist, 'foo')
+        assert str(e.value) == 'the steps of the stairs must be an integer'
+
+    def test_stairs_group_with_steps_out_of_range(self, videogame_test):
+        with pytest.raises(ValueError) as e:
+            StairsGroup('protagonists', protagonist, -1)
+        assert str(e.value) == 'the steps of the stairs must be greater than 1'
+
+    def test_stairs_group_with_horizontal_and_vertical_flip_wrong_type(
+        self, videogame_test
+    ):
+        with pytest.raises(TypeError) as e:
+            StairsGroup('protagonists', protagonist, 3, 'foo', 'bar')
+        assert (
+            str(e.value)
+            == 'both horizontal and vertical flips must be a boolean'
+        )

+ 153 - 42
tests/test_scene.py

@@ -18,48 +18,159 @@
 # Zero! Factorial Studio
 # info@zero.gal
 
-import os
+from os.path import join
 
 import pytest
-
-from jogai import *
-
-
-def test_height_is_a_non_negative_integer(prepare_videogame):
-    assert vgscene.height > -1
-
-
-def test_width_is_a_non_negative_integer(prepare_videogame):
-    assert vgscene.width > -1
-
-
-def test_x_coordinate_is_an_integer(prepare_videogame):
-    assert isinstance(vgscene.x, int)
-
-
-def test_change_background_color_with_components_out_of_range():
-    with pytest.raises(ValueError):
+from pygame import Surface
+
+from jogai import paths, settings
+from jogai.scene import Scene, _load_scene
+
+
+class TestScene:
+    def test_load_scene_empty(self):
+        sc = _load_scene({})
+
+        assert sc.image_filename == ''
+        assert sc.image == None
+        assert sc.background == (0, 0, 0)
+
+    def test_load_scene_with_wrong_type(self):
+        with pytest.raises(TypeError) as e:
+            sc = _load_scene(-1)
+        assert (
+            str(e.value)
+            == 'the scene data must be a dict, a string, a list or a tuple'
+        )
+
+    def test_load_scene_with_background(self):
+        background = (255, 255, 255)
+        sc = _load_scene(background)
+        assert sc.image_filename == ''
+        assert sc.image == None
+        assert sc.background == background
+        assert sc.boundaries == [
+            None,
+            settings.WINDOW_WIDTH,
+            settings.WINDOW_HEIGHT,
+            None,
+        ]
+
+    def test_load_scene_with_background_and_boundaries(self):
+        background = (255, 255, 255)
+        sc = _load_scene({'background': background, 'boundaries': False})
+        assert sc.image_filename == ''
+        assert sc.image == None
+        assert sc.background == background
+        assert sc.boundaries == [None] * 4
+
+    def test_scene_object_with_existing_filename(self, scene_filename_test):
+        sc = Scene(scene_filename_test)
+        assert isinstance(sc.image, Surface) == True
+
+    def test_create_scene_with_wrong_type(self):
+        with pytest.raises(TypeError) as e:
+            sc = Scene(-1)
+        assert str(e.value) == 'the image filename must be a string'
+
+    def test_create_empty_scene(self):
         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)
-
-
-def test_set_position_with_wrong_type(prepare_videogame):
-    with pytest.raises(TypeError):
-        vgscene.set_position('foo')
+        assert sc.image_filename == ''
+        assert sc.background == settings.SCENE_DEFAULT_BACKGROUND
+
+    def test_scene_background_property(self, videogame_test):
+        background = (255, 255, 255)
+        current_scene.background = background
+        assert current_scene.background == background
+
+    def test_scene_boundaries_property(self, videogame_test):
+        boundaries = [50, 100, 500, 300]
+        current_scene.boundaries = boundaries
+        assert current_scene.boundaries == boundaries
+
+    def test_height_property(self, videogame_test):
+        assert current_scene.height == current_scene.rect.height
+
+    def test_image_filename_property(self, scene_filename_test):
+        sc = Scene(scene_filename_test)
+        sc.image_filename == scene_filename_test
+
+    def test_image_path_property(self, scene_filename_test):
+        sc = Scene(scene_filename_test)
+        assert sc.image_path == join(paths.SCENES_DIR, scene_filename_test)
+
+    def test_height_is_a_non_negative_integer(self, videogame_test):
+        assert current_scene.height > -1
+
+    def test_width_is_a_non_negative_integer(self, videogame_test):
+        assert current_scene.width > -1
+
+    def test_x_coordinate_is_an_integer(self, videogame_test):
+        assert isinstance(current_scene.x, int)
+
+    def test_change_background_color_with_a_list(self, videogame_test):
+        current_scene.change_background([255, 255, 255])
+        assert isinstance(current_scene.background, tuple) == True
+        assert current_scene.background == (255, 255, 255)
+
+    @pytest.mark.parametrize('color', [-1, 'foo'])
+    def test_change_background_color_wrong_type(self, videogame_test, color):
+        with pytest.raises(TypeError) as e:
+            current_scene.change_background(color)
+        assert (
+            str(e.value)
+            == 'the background color must be a tuple with three integers (r,g,b) between 0 and 255'
+        )
+
+    def test_change_background_color_with_components_out_of_range(
+        self, videogame_test
+    ):
+        with pytest.raises(ValueError) as e:
+            current_scene.change_background((-1, -1, 300))
+        assert (
+            str(e.value)
+            == 'the background color must be a tuple with three integers (r,g,b) between 0 and 255'
+        )
+
+    def test_change_background_color_with_different_args_length(
+        self, videogame_test
+    ):
+        with pytest.raises(ValueError) as e:
+            current_scene.change_background((0, 0))
+        assert (
+            str(e.value)
+            == 'the background color must be a tuple with three integers (r,g,b) between 0 and 255'
+        )
+
+    def test_set_abscissa_position_with_wrong_type(self, videogame_test):
+        with pytest.raises(TypeError) as e:
+            current_scene._set_abscissa_position('foo')
+        assert str(e.value) == 'the x coordinate must be an integer'
+
+    @pytest.mark.parametrize(
+        'x', [0, settings.WINDOW_WIDTH // 4, settings.WINDOW_WIDTH // 2]
+    )
+    def test_set_abscissa_position_less_or_equal_than_middle_of_window(
+        self, videogame_test, x
+    ):
+        current_scene._set_abscissa_position(x)
+        assert current_scene.x == 0
+
+    def test_set_abscissa_position_between_middle_of_window_and_scene_width_minus_window_width(
+        self, videogame_test
+    ):
+        x = current_scene.width - settings.WINDOW_WIDTH
+        current_scene._set_abscissa_position(x)
+        assert current_scene.x == -x + settings.WINDOW_WIDTH // 2
+
+    def test_set_abscissa_position_at_the_end(self, videogame_test):
+        current_scene._set_abscissa_position(current_scene.width)
+        assert current_scene.x == -current_scene.width + settings.WINDOW_WIDTH
+
+    @pytest.mark.parametrize('boundaries', [-1, 'foo'])
+    def test_set_boundaries_with_wrong_type(self, videogame_test, boundaries):
+        with pytest.raises(TypeError) as e:
+            current_scene.set_boundaries(boundaries)
+        assert (
+            str(e.value) == 'the scene boundaries must be a list or a boolean'
+        )

+ 13 - 4
tests/test_utils.py

@@ -24,10 +24,19 @@ 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_dict_to_kwargs_string_with_wrong_type():
+    input = 'foo'
+    assert utils.dict_to_kwargs_string(input) == input
+
+
+def test_eval_key():
+    key = 'a'
+    assert utils.eval_key(key) == ord(key)
+
+
+def test_eval_key_with_wrong_type():
+    with pytest.raises(exceptions.KeyPressedNotFoundError):
+        utils.eval_key({'foo': 'bar'})
 
 
 def test_load_image_with_wrong_types():

+ 187 - 66
tests/test_videogame.py

@@ -22,70 +22,191 @@ import pygame
 import pytest
 from pytest import fixture, mark
 
-from jogai import videogame as vg
-
-
-def test_load_empty_scene():
-    vg._load_scene({})
-
-
-@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_add_group_with_wrong_type():
-    with pytest.raises(TypeError):
-        vg.add_group('foo')
-
-
-def test_listen_quit_event():
-    quit_event = pygame.event.Event(
-        pygame.QUIT,
-        mod=pygame.locals.KMOD_NONE,
+from jogai import _globals, settings
+from jogai.character import Character
+from jogai.exceptions import KeyPressedNotFoundError
+from jogai.groups import RectangleGroup
+from jogai.texts import Chronometer, Scoreboard, Text
+from jogai.widgets import LevelBar
+
+
+class TestVideogame:
+    def test_load_data_with_a_predefined_attribute_allowed(
+        self, videogame_test
+    ):
+        videogame_test._load_data({'caption': 'foo'})
+        assert pygame.display.get_caption()[0] == 'foo'
+
+    def test_load_data_with_wrong_type(self, videogame_test):
+        with pytest.raises(TypeError) as e:
+            videogame_test._load_data('foo')
+        assert (
+            str(e.value) == 'the attributes in the input schema must be a dict'
+        )
+
+    @mark.parametrize(
+        ('key, down'),
+        [('a', False), ('ESCAPE', True)],
     )
-    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_load_yaml_with_no_characters():
-    vg.init()
-    vg.prepare('no_characters.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()
+    def test_simulate_key_press(self, videogame_test, key, down):
+        videogame_test.simulate_key_press(key, down)
+        events = pygame.event.get()
+        for event in events:
+            if event.type == pygame.KEYUP or event.type == pygame.KEYDOWN:
+                assert pygame.key.name(event.key) == key.lower()
+
+    @mark.parametrize(
+        ('key, down'),
+        [(-1, False), (None, True)],
+    )
+    def test_simulate_wrong_key_press(self, videogame_test, key, down):
+        with pytest.raises(KeyPressedNotFoundError) as e:
+            videogame_test.simulate_key_press(key, down)
+        assert str(e.value) == 'the key pressed was not found'
+
+    def test_add_character(self, videogame_test):
+        character = Character('protagonist.png')
+        videogame_test.add_character(character)
+        assert character in _globals.vg_characters
+
+    def test_add_character_with_wrong_type(self, videogame_test):
+        with pytest.raises(TypeError) as e:
+            videogame_test.add_character('foo')
+        assert str(e.value) == 'only Character instances can be added'
+
+    def test_add_chronometer(self, videogame_test):
+        chronometer = Chronometer(30)
+        videogame_test.add_chronometer(chronometer)
+        assert chronometer in _globals.vg_chronometers
+
+    def test_add_chronometer_with_wrong_type(self, videogame_test):
+        with pytest.raises(TypeError) as e:
+            videogame_test.add_chronometer('foo')
+        assert str(e.value) == 'only Chronometer instances can be added'
+
+    def test_add_group(self, videogame_test, square_group_test):
+        videogame_test.add_group(square_group_test)
+        assert square_group_test in _globals.vg_groups
+
+    def test_add_group_with_wrong_type(self, videogame_test):
+        with pytest.raises(TypeError) as e:
+            videogame_test.add_group('foo')
+        assert str(e.value) == 'only Group instances can be added'
+
+    def test_add_levelbar(self, videogame_test):
+        levelbar = LevelBar(3)
+        videogame_test.add_levelbar(levelbar)
+        assert levelbar in _globals.vg_levelbars
+
+    def test_add_levelbar_with_wrong_type(self, videogame_test):
+        with pytest.raises(TypeError) as e:
+            videogame_test.add_levelbar('foo')
+        assert str(e.value) == 'only LevelBar instances can be added'
+
+    def test_add_scoreboard(self, videogame_test):
+        scoreboard = Scoreboard('foo')
+        videogame_test.add_scoreboard(scoreboard)
+        assert scoreboard in _globals.vg_scoreboards
+
+    def test_add_scoreboard_with_wrong_type(self, videogame_test):
+        with pytest.raises(TypeError) as e:
+            videogame_test.add_scoreboard('foo')
+        assert str(e.value) == 'only Scoreboard instances can be added'
+
+    def test_add_text(self, videogame_test):
+        text = Text('foo')
+        videogame_test.add_text(text)
+        assert text in _globals.vg_texts
+
+    def test_add_text_with_wrong_type(self, videogame_test):
+        with pytest.raises(TypeError) as e:
+            videogame_test.add_text('foo')
+        assert str(e.value) == 'only Text instances can be added'
+
+    def test_listen_quit_event(self, videogame_test):
+        quit_event = pygame.event.Event(
+            pygame.QUIT,
+            mod=pygame.locals.KMOD_NONE,
+        )
+        pygame.event.post(quit_event)
+        videogame_test.listen_events()
+        assert pygame.display.get_init() == False
+        videogame_test.init()
+        videogame_test.load_environment('test')
+
+    def test_listen_escape_event(self, videogame_test):
+        videogame_test.simulate_key_press('ESCAPE', True)
+        videogame_test.listen_events()
+        assert pygame.display.get_init() == False
+        videogame_test.init()
+        videogame_test.load_environment('test')
+
+    def test_get_color(self, videogame_test):
+        color = videogame_test.get_color(
+            videogame_test.settings.WINDOW_WIDTH - 1,
+            videogame_test.settings.WINDOW_HEIGHT - 1,
+        )
+        assert len(color) == 3
+        assert all([True if 0 <= c <= 255 else False for c in color])
+
+    def test_get_color_with_wrong_type(self, videogame_test):
+        with pytest.raises(TypeError) as e:
+            videogame_test.get_color('foo', 'bar')
+
+        assert (
+            str(e.value)
+            == 'the coordinates of the point must be a non-negative integers'
+        )
+
+    def test_get_color_out_of_range_values(self, videogame_test):
+        with pytest.raises(ValueError) as e:
+            videogame_test.get_color(-3, 100000)
+        assert (
+            str(e.value)
+            == 'the coordinates of the point must be a non-negative integers'
+        )
+
+    @mark.parametrize('wait', [0, 1, -1, None, 'foo'])
+    def test_quit_including_wrong_wait(self, videogame_test, wait):
+        videogame_test.quit(wait)
+        assert pygame.display.get_init() == False
+        videogame_test.init()
+        videogame_test.load_environment('test')
+
+    def test_set_caption(self, videogame_test):
+        videogame_test.set_caption('foo')
+        assert pygame.display.get_caption()[0] == 'foo'
+
+    @mark.parametrize('caption', [1, -1, None])
+    def test_set_caption_with_wrong_type(self, videogame_test, caption):
+        with pytest.raises(TypeError) as e:
+            videogame_test.set_caption(caption)
+        assert str(e.value) == 'the caption for the videogame must be a string'
+
+    def test_set_gravity(self, videogame_test):
+        gravity = 5
+        videogame_test.set_gravity(gravity)
+        assert _globals._gravity == gravity
+
+    @mark.parametrize('gravity', ['foo', 1.2, None])
+    def test_set_gravity_with_wrong_type(self, videogame_test, gravity):
+        with pytest.raises(TypeError) as e:
+            videogame_test.set_gravity(gravity)
+        assert str(e.value) == 'the gravity must be an integer'
+
+    def test_set_marks(self, videogame_test):
+        videogame_test.set_marks()
+        assert _globals.marks.x_color == settings.MARKS_DEFAULT_X_COLOR
+        assert _globals.marks.y_color == settings.MARKS_DEFAULT_Y_COLOR
+        assert _globals.marks.width == settings.MARKS_DEFAULT_WIDTH
+
+    """
+    def test_prepare_02_yaml(self, videogame_test):
+        videogame_test.prepare('02.yaml')
+
+    def test_load_yaml_with_no_characters(self, videogame_test):
+        videogame_test.prepare('no_characters.yaml')
+
+    def test_update(self, videogame_test):
+        videogame_test.update()
+    """

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor