diff --git a/engine/__init__.py b/engine/__init__.py index f2384c1..05f7af2 100644 --- a/engine/__init__.py +++ b/engine/__init__.py @@ -147,6 +147,15 @@ class World: resource: _T = self._resources[resource_type] # type: ignore[assignment] return resource + def __delitem__(self, resource_type: Type[_T]) -> None: + """ + Supprime la ressource de type *resource_type. + + Paramètres: + resource_type: Le type de ressource. + """ + self.remove(resource_type) + def __contains__(self, *resource_types: Type[_T]) -> bool: """ Renvoie True si le monde contient toutes les ressources de *resource_types. @@ -219,6 +228,15 @@ class Entity: """ return self._components[component_type] + def __delitem__(self, component_type: Type[_T]) -> None: + """ + Supprime le composant de type *component_type. + + Paramètres: + component_type: Le type du composant. + """ + self.remove(component_type) + def __contains__(self, *component_types: Type[_T]) -> bool: """ Renvoie True si l'entité contient tous les composants de *component_types. @@ -264,15 +282,29 @@ class Game: *plugins: Les plugins a ajouter au jeu. """ self._running = False + self._pre_startup_tasks: list[Callable[[World], None]] = [] self._startup_tasks: list[Callable[[World], None]] = [] + self._post_startup_tasks: list[Callable[[World], None]] = [] self._pre_update_tasks: list[Callable[[World], None]] = [] self._update_tasks: list[Callable[[World], None]] = [] self._post_update_tasks: list[Callable[[World], None]] = [] + self._pre_render_tasks: list[Callable[[World], None]] = [] self._render_tasks: list[Callable[[World], None]] = [] + self._post_render_tasks: list[Callable[[World], None]] = [] + self._pre_shutdown_tasks: list[Callable[[World], None]] = [] self._shutdown_tasks: list[Callable[[World], None]] = [] + self._post_shutdown_tasks: list[Callable[[World], None]] = [] for plugin in plugins: plugin.apply(self) + def add_pre_startup_tasks(self, *tasks: Callable[[World], None]) -> None: + """ + Ajoute des taches qui s'executeront en premier avant le démarrage du jeu. + """ + if self._running: + raise RuntimeError("Cannot add a task while the loop is running") + self._pre_startup_tasks.extend(tasks) + def add_startup_tasks(self, *tasks: Callable[[World], None]) -> None: """ Ajoute des taches qui s'executeront au démarrage du jeu. @@ -284,6 +316,14 @@ class Game: raise RuntimeError("Cannot add a task while the loop is running") self._startup_tasks.extend(tasks) + def add_post_startup_tasks(self, *tasks: Callable[[World], None]) -> None: + """ + Ajoute des taches qui s'executeront en dernier après le démarrage du jeu. + """ + if self._running: + raise RuntimeError("Cannot add a task while the loop is running") + self._post_startup_tasks.extend(tasks) + def add_pre_update_tasks(self, *tasks: Callable[[World], None]) -> None: """ Ajoute des taches qui s'executeront au debut de chaque mise à jour du jeu. @@ -317,6 +357,17 @@ class Game: raise RuntimeError("Cannot add a task while the loop is running") self._post_update_tasks.extend(tasks) + def add_pre_render_tasks(self, *tasks: Callable[[World], None]) -> None: + """ + Ajoute des taches qui s'executeront au début de chaque mise à jour du jeu pour le rendu. + + Paramètres: + *tasks: Les taches à ajouter. + """ + if self._running: + raise RuntimeError("Cannot add a task while the loop is running") + self._pre_render_tasks.extend(tasks) + def add_render_tasks(self, *tasks: Callable[[World], None]) -> None: """ Ajoute des taches qui s'executeront après chaque mise à jour du jeu pour le rendu. @@ -328,6 +379,25 @@ class Game: raise RuntimeError("Cannot add a task while the loop is running") self._render_tasks.extend(tasks) + def add_post_render_tasks(self, *tasks: Callable[[World], None]) -> None: + """ + Ajoute des taches qui s'executeront après chaque mise à jour du jeu pour le rendu. + + Paramètres: + *tasks: Les taches à ajouter. + """ + if self._running: + raise RuntimeError("Cannot add a task while the loop is running") + self._post_render_tasks.extend(tasks) + + def add_pre_shutdown_tasks(self, *tasks: Callable[[World], None]) -> None: + """ + Ajoute des taches qui s'executeront au début de la fin de la boucle de jeu. + """ + if self._running: + raise RuntimeError("Cannot add a task while the loop is running") + self._pre_shutdown_tasks.extend(tasks) + def add_shutdown_tasks(self, *tasks: Callable[[World], None]) -> None: """ Ajoute des taches qui s'executeront à la fin de la boucle de jeu. @@ -339,6 +409,14 @@ class Game: raise RuntimeError("Cannot add a task while the loop is running") self._shutdown_tasks.extend(tasks) + def add_post_shutdown_tasks(self, *tasks: Callable[[World], None]) -> None: + """ + Ajoute des taches qui s'executeront à la fin de la fin de la boucle de jeu. + """ + if self._running: + raise RuntimeError("Cannot add a task while the loop is running") + self._post_shutdown_tasks.extend(tasks) + def run(self, world: World = World()) -> World: """ Lance la boucle de jeu. @@ -351,6 +429,14 @@ class Game: world.set(self) world.apply() + # On execute les taches de pré initialisation du monde + for task in self._pre_startup_tasks: + try: + task(world) + except Exception as e: + error(f"Error during pre-startup task: {e}") + world.apply() + # On execute les taches d'initialisation du monde for task in self._startup_tasks: try: @@ -359,6 +445,14 @@ class Game: error(f"Error during startup task: {e}") world.apply() + # On execute les taches de post initialisation du monde + for task in self._post_startup_tasks: + try: + task(world) + except Exception as e: + error(f"Error during post-startup task: {e}") + world.apply() + while self._running: # On execute les taches de pré mise à jour du monde for task in self._pre_update_tasks: @@ -392,6 +486,22 @@ class Game: error(f"Error during render task: {e}") world.apply() + # On execute les taches de fin de rendu du jeu + for task in self._post_render_tasks: + try: + task(world) + except Exception as e: + error(f"Error during post-render task: {e}") + world.apply() + + # On execute les taches de pré fin de boucle + for task in self._pre_shutdown_tasks: + try: + task(world) + except Exception as e: + error(f"Error during pre-shutdown task: {e}") + world.apply() + # On exécute les taches de fin du monde for task in self._shutdown_tasks: try: @@ -400,6 +510,14 @@ class Game: error(f"Error during shutdown task: {e}") world.apply() + # On execute les taches de post fin de boucle + for task in self._post_shutdown_tasks: + try: + task(world) + except Exception as e: + error(f"Error during post-shutdown task: {e}") + world.apply() + # On retourne le monde return world diff --git a/engine/math.py b/engine/math.py new file mode 100644 index 0000000..0675128 --- /dev/null +++ b/engine/math.py @@ -0,0 +1,101 @@ +""" +Définis des classes utiles. +""" + + +from typing import SupportsFloat, Union +import math + + +class Vec2: + """ + Un vecteur 2D + """ + + def __init__(self, *args: Union[SupportsFloat, "Vec2"]) -> None: + if ( + len(args) == 2 + and isinstance(args[0], SupportsFloat) + and isinstance(args[1], SupportsFloat) + ): + self.x = float(args[0]) + self.y = float(args[1]) + elif len(args) == 1: + if isinstance(args[0], Vec2): + self.x = args[0].x + self.y = args[0].y + elif isinstance(args[0], SupportsFloat): + self.x = float(args[0]) + self.y = float(args[0]) + else: + raise ValueError("Invalid argument") + elif len(args) == 0: + self.x = 0.0 + self.y = 0.0 + else: + raise ValueError("Invalid number of arguments") + + def __add__(self, other: object) -> "Vec2": + if isinstance(other, Vec2): + return Vec2(self.x + other.x, self.y + other.y) + elif isinstance(other, float): + return Vec2(self.x + other, self.y + other) + raise ValueError( + f"Unsupported operand type(s) for +: 'Vec2' and '{type(other)}'" + ) + + def __sub__(self, other: object) -> "Vec2": + if isinstance(other, Vec2): + return Vec2(self.x - other.x, self.y - other.y) + elif isinstance(other, float): + return Vec2(self.x - other, self.y - other) + raise ValueError( + f"Unsupported operand type(s) for -: 'Vec2' and '{type(other)}'" + ) + + def __mul__(self, other: object) -> "Vec2": + if isinstance(other, Vec2): + return Vec2(self.x * other.x, self.y * other.y) + elif isinstance(other, float): + return Vec2(self.x * other, self.y * other) + raise ValueError( + f"Unsupported operand type(s) for *: 'Vec2' and '{type(other)}'" + ) + + def __truediv__(self, other: object) -> "Vec2": + if isinstance(other, Vec2): + return Vec2(self.x / other.x, self.y / other.y) + elif isinstance(other, float): + return Vec2(self.x / other, self.y / other) + raise ValueError( + f"Unsupported operand type(s) for /: 'Vec2' and '{type(other)}'" + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Vec2): + return self.x == other.x and self.y == other.y + return False + + def __hash__(self) -> int: + return hash((self.x, self.y)) + + def __neg__(self) -> "Vec2": + return Vec2(-self.x, -self.y) + + @property + def length(self) -> float: + """ + Retourne la longueur du vecteur. + """ + return math.sqrt(self.x**2 + self.y**2) + + @property + def normalized(self) -> "Vec2": + """ + Retourne une version normalisé du vecteur. + """ + length = self.length + return Vec2(self.x / length, self.y / length) + + def __repr__(self) -> str: + return f"Vec2({self.x}, {self.y})" diff --git a/engine/plugins/pygame.py b/engine/plugins/pygame.py new file mode 100644 index 0000000..a0b916c --- /dev/null +++ b/engine/plugins/pygame.py @@ -0,0 +1,227 @@ +""" +Définit un plugin qui gère les évenements pygame. +""" + +from engine import * +from engine.math import Vec2 +import pygame + + +class PygamePlugin(Plugin): + """ + Plugin qui gère les évenements pygame. + """ + + @staticmethod + def _find_surface_rect() -> tuple[float, float, float, float]: + width, height = pygame.display.get_surface().get_size() + if width / height < Display.RATIO: + target_height = width * (Display.INVERT_RATIO) + offset = (height - target_height) / 2 + rect = (0.0, offset, float(width), target_height) + else: + target_width = height * (Display.RATIO) + offset = (width - target_width) / 2 + rect = (offset, 0.0, target_width, float(height)) + return rect + + @staticmethod + def _initialize(world: World) -> None: + """ + Initialize pygame et les ressources. + """ + pygame.init() + pygame.display.set_mode((800, 600), pygame.RESIZABLE) + + # Initialisation des ressources + world.set( + Display(), + Keyboard(), + Mouse(), + ) + + @staticmethod + def _check_events(world: World) -> None: + """ + Met a jour les ressources avec les evenements pygame. + """ + + keyboard = world[Keyboard] + keyboard.pressed.clear() + keyboard.released.clear() + + mouse = world[Mouse] + mouse.pressed.clear() + mouse.released.clear() + + for event in pygame.event.get(): + if event.type == pygame.QUIT: + world[Game].stop() + elif event.type == pygame.KEYDOWN: + key_name = pygame.key.name(event.key) + if key_name == "f11": + pygame.display.toggle_fullscreen() + keyboard.keys.add(key_name) + keyboard.pressed.add(key_name) + elif event.type == pygame.KEYUP: + key_name = pygame.key.name(event.key) + if key_name == "f11": + continue + keyboard.keys.remove(key_name) + keyboard.released.add(key_name) + elif event.type == pygame.MOUSEBUTTONDOWN: + mouse.buttons.add(event.button) + mouse.pressed.add(event.button) + elif event.type == pygame.MOUSEBUTTONUP: + mouse.buttons.remove(event.button) + mouse.released.add(event.button) + elif event.type == pygame.MOUSEMOTION: + rect = PygamePlugin._find_surface_rect() + mouse.position = Vec2( + ((event.pos[0] - rect[0]) / rect[2]) * Display.WIDTH, + ((event.pos[1] - rect[1]) / rect[3]) * Display.HEIGHT, + ) + + @staticmethod + def _update_display(world: World) -> None: + """ + Met a jour le rendu de l'écran. + """ + display = world[Display] + rect = PygamePlugin._find_surface_rect() + pygame.transform.set_smoothscale_backend("MMX") + pygame.transform.smoothscale( + display._surface, + (rect[2], rect[3]), + pygame.display.get_surface().subsurface(rect), + ) + pygame.display.flip() + display._surface.fill((0, 0, 0)) + + @staticmethod + def _terminate(world: World) -> None: + """ + Ferme pygame. + """ + pygame.quit() + + def apply(self, game: Game) -> None: + """ + Applique le plugin a un jeu. + + Paramètres: + game: Le jeu auquel appliquer le plugin. + """ + game.add_pre_startup_tasks(self._initialize) + game.add_pre_update_tasks(self._check_events) + game.add_post_render_tasks(self._update_display) + game.add_post_shutdown_tasks(self._terminate) + + +class Display: + """ + Ressource qui represente la fenetre du jeu. + """ + + WIDTH = 1080.0 + HEIGHT = 810.0 + RATIO = WIDTH / HEIGHT + INVERT_RATIO = HEIGHT / WIDTH + + def __init__(self) -> None: + self._surface = pygame.Surface((Display.WIDTH, Display.HEIGHT)) + + +class Keyboard: + """ + Ressource qui représente les entrées utilisateurs sur le clavier à la frame actuelle. + """ + + def __init__(self) -> None: + self.keys: set[str] = set() + self.pressed: set[str] = set() + self.released: set[str] = set() + + def is_key_pressed(self, key_name: str) -> bool: + """ + Renvoie True si la touche *key_name* a commencé a être appuyée pendant la frame actuelle. + + Paramètres: + key_name: Le nom de la touche à tester. + + Retourne: + True si la touche *key_name* a commencé a être appuyée pendant la frame actuelle. + """ + return key_name in self.pressed + + def is_key(self, key_name: str) -> bool: + """ + Renvoie True si la touche *key_name* est actuellement appuyée. + + Paramètres: + key_name: Le nom de la touche à tester. + + Retourne: + True si la touche *key_name* est actuellement appuyée. + """ + return key_name in self.keys + + def is_key_released(self, key_name: str) -> bool: + """ + Renvoie True si la touche *key_name* a été relachée pendant la frame actuelle. + + Paramètres: + key_name: Le nom de la touche à tester. + + Retourne: + True si la touche *key_name* a été relachée pendant la frame actuelle. + """ + return key_name in self.released + + +class Mouse: + """ + Ressource qui représente l'état de la souris à la frame actuelle. + """ + + def __init__(self) -> None: + self.buttons: set[int] = set() + self.pressed: set[int] = set() + self.released: set[int] = set() + self.position: Vec2 = Vec2(0.0, 0.0) + + def is_button_pressed(self, button: int) -> bool: + """ + Renvoie True si le bouton *button* a commencé a être appuyée pendant la frame actuelle. + + Paramètres: + button: Le numéro du bouton à tester. + + Retourne: + True si le bouton *button* a commencé a être appuyée pendant la frame actuelle. + """ + return button in self.pressed + + def is_button(self, button: int) -> bool: + """ + Renvoie True si le bouton *button* est actuellement appuyé. + + Paramètres: + button: Le numéro du bouton à tester. + + Retourne: + True si le bouton *button* est actuellement appuyé. + """ + return button in self.buttons + + def is_button_released(self, button: int) -> bool: + """ + Renvoie True si le bouton *button* a été relaché pendant la frame actuelle. + + Paramètres: + button: Le numéro du bouton à tester. + + Retourne: + True si le bouton *button* aLongrightarrow relaché pendant la frame actuelle. + """ + return button in self.released diff --git a/engine/plugins/render.py b/engine/plugins/render.py new file mode 100644 index 0000000..41ff061 --- /dev/null +++ b/engine/plugins/render.py @@ -0,0 +1,91 @@ +""" +Définis un plugin permettant d'afficher des choses a l'écran. +""" + +from engine import * +from engine.math import Vec2 +import pygame, os + +from engine.plugins.pygame import Display + + +class RenderPlugin(Plugin): + """ + Plugin permettant d'afficher des choses a l'écran. + """ + + @staticmethod + def _initialize(world: World) -> None: + """ + Initialize le système de rendu. + """ + world.set(TextureManager(world[Display])) + + @staticmethod + def _render(world: World) -> None: + """ + Fais le rendu des sprites. + """ + display = world[Display] + textures = world[TextureManager] + + # Rendu de toutes les objects de rendu + entities = sorted(world.query(Order, Position), key=lambda e: e[Order]) + for entity in entities: + # Récupération de la position des entitées + position = entity[Position] + + # Affichage de la texture + if Texture in entity: + display._surface.blit( + textures[entity[Texture]], (position.x, position.y) + ) + + def apply(self, game: Game) -> None: + """ + Applique le plugin a un jeu. + + Paramarters: + game: Le jeu auquel appliquer le plugin. + """ + game.add_post_startup_tasks(self._initialize) + game.add_render_tasks(self._render) + + +class Position(Vec2): + """ + Composant qui représente la position d'une entité. + """ + + +class Order(int): + """ + Composant qui represente l'ordre d'affichage d'un objet. + """ + + +class TextureManager: + """ + Ressource qui contient les textures du jeu. + """ + + def __init__(self, display: Display) -> None: + self._textures: dict[str, pygame.Surface] = {} + for file in os.listdir("textures"): + self._textures[file] = pygame.image.load(f"textures/{file}").convert( + display._surface + ) + error_texture = pygame.Surface((256, 256)) + error_texture.fill((0, 0, 0)) + pygame.draw.rect(error_texture, (255, 0, 255), (0, 0, 128, 128)) + pygame.draw.rect(error_texture, (255, 0, 255), (128, 128, 128, 128)) + self._error_texture = error_texture.convert(display._surface) + + def __getitem__(self, key: str) -> pygame.Surface: + return self._textures.get(key, self._error_texture) + + +class Texture(str): + """ + Composant qui represente la texture d'un sprite. + """ diff --git a/engine/plugins/timing.py b/engine/plugins/timing.py index 7b834d1..2e25cd1 100644 --- a/engine/plugins/timing.py +++ b/engine/plugins/timing.py @@ -27,7 +27,7 @@ class TimePlugin(Plugin): Paramètres: game: Le jeu auquel appliquer le plugin. """ - game.add_startup_tasks(self._initialize_time) + game.add_pre_startup_tasks(self._initialize_time) game.add_pre_update_tasks(self._update_time) diff --git a/main.py b/main.py index c9814a8..2910b1c 100644 --- a/main.py +++ b/main.py @@ -4,32 +4,69 @@ Ceci est un exemple de comment l'on peut utiliser le moteur du jeu. from engine import * -from engine.plugins.timing import TimePlugin, Time +from engine.math import Vec2 +from engine.plugins.render import Order, RenderPlugin, Position, Texture +from engine.plugins.timing import Delta, TimePlugin +from engine.plugins.pygame import Display, Keyboard, PygamePlugin +from random import random # Initialisation -game = Game(TimePlugin()) +game = Game(TimePlugin(), PygamePlugin(), RenderPlugin()) -# Ajout de tache au démarage (l'ordre d'ajout est important) -game.add_startup_tasks(lambda world: print("Hello first")) -game.add_startup_tasks(lambda world: print("Hello second")) -game.add_startup_tasks(lambda world: print("Hello third")) -game.add_startup_tasks(lambda world: print("Hello last")) -# Ajoute de tache au mise à jour (malgré le world[Game].stop(), la boucle termine les taches suivantes) -game.add_pre_update_tasks(lambda world: print("Pre Update")) -game.add_pre_update_tasks(lambda world: print(world[Time])) -game.add_update_tasks(lambda world: world[Game].stop()) -game.add_post_update_tasks(lambda world: print("Post Update")) +# On créer une tache pour afficher des sprites +def spawn_sprites(world: World) -> None: + """ + Ajoute des sprites au monde. + """ + for i in range(100): + red = random() < 0.1 + world.create_entity( + Position(random() * Display.WIDTH, random() * Display.HEIGHT), + Texture("test2.png") if red else Texture("test.png"), + Order(1 if red else 0), + ) -# Ajout de tache au rendu -game.add_render_tasks(lambda world: print("Render task 1")) -game.add_render_tasks(lambda world: print("Render task 2")) -game.add_render_tasks(lambda world: print("Render task 3")) -# Ajout de tache à la fin -game.add_shutdown_tasks(lambda world: print("Bye first")) -game.add_shutdown_tasks(lambda world: print("Bye second")) +# On ajoutant la tache +game.add_startup_tasks(spawn_sprites) + + +def move_sprites(world: World) -> None: + """ + Change la position des sprites. + """ + move = Vec2( + (-1.0 if world[Keyboard].is_key("q") else 0.0) + + (1.0 if world[Keyboard].is_key("d") else 0.0), + (-1.0 if world[Keyboard].is_key("z") else 0.0) + + (1.0 if world[Keyboard].is_key("s") else 0.0), + ) + for entity in world.query(Position): + if entity[Order] == 1: + continue + entity.set(Position(entity[Position] + (move * world[Delta] * 1000.0))) + + +# On ajoute la tache +game.add_update_tasks(move_sprites) + + +# On créer une tache pour tester si les plugins fonctionnent +def salutations(world: World) -> None: + """ + Affiche "Bonjour" si la touche B est pressé et "Au revoir" si la touche B est relachée. + """ + if world[Keyboard].is_key_pressed("b"): + print("Bonjour") + + if world[Keyboard].is_key_released("b"): + print("Au revoir") + + +# On ajoute la tache de test +game.add_update_tasks(salutations) # On lance la boucle game.run() diff --git a/requirements.txt b/requirements.txt index 8120074..969ddee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ mypy -pylint \ No newline at end of file +pylint +pygame \ No newline at end of file diff --git a/textures/test.png b/textures/test.png new file mode 100644 index 0000000..39b449a Binary files /dev/null and b/textures/test.png differ diff --git a/textures/test2.png b/textures/test2.png new file mode 100644 index 0000000..91b876e Binary files /dev/null and b/textures/test2.png differ