diff --git a/assets/error.mp3 b/assets/sounds/error.mp3 similarity index 100% rename from assets/error.mp3 rename to assets/sounds/error.mp3 diff --git a/assets/textures/background.png b/assets/textures/background.png index 7341a33..01be4e0 100644 Binary files a/assets/textures/background.png and b/assets/textures/background.png differ diff --git a/assets/textures/dada.png b/assets/textures/dada.png new file mode 100644 index 0000000..773c20d Binary files /dev/null and b/assets/textures/dada.png differ diff --git a/assets/textures/dodo.png b/assets/textures/dodo.png new file mode 100644 index 0000000..88f011c Binary files /dev/null and b/assets/textures/dodo.png differ diff --git a/src/engine/ecs.py b/src/engine/ecs.py index a94a1f0..717eba1 100644 --- a/src/engine/ecs.py +++ b/src/engine/ecs.py @@ -146,10 +146,17 @@ class World(Entity): - `entity`: l'entité dans lequelle ajouter le composant. - `component`: le composant à ajouter. """ + if isinstance(component, tuple): + for c in component: # type: ignore + if c is not None: + self.set_component(entity, c) # type: ignore + return self.__components.setdefault(entity.identifier, {})[type(component)] = component self.__entities.setdefault(type(component), set()).add(entity.identifier) - def get_component[T]( + def get_component[ + T + ]( self, entity: "Entity", component_type: type[T], default: Optional[T] = None ) -> T: """ diff --git a/src/main.py b/src/main.py index 06f1d8f..33f6a2d 100644 --- a/src/main.py +++ b/src/main.py @@ -3,9 +3,58 @@ Module d'exemple de l'utilisation du moteur de jeu. """ from engine import Scene, start_game -from engine.ecs import World -from plugins import defaults -from plugins.render import Origin, Position, Scale, Texture +from engine.ecs import Entity, World +from engine.math import Vec2 +from plugins import defaults, physics +from plugins import render +from plugins.inputs import Held +from plugins.render import ( + Origin, + Position, + Scale, + SpriteBundle, +) +from plugins.timing import Delta + + +def lol(a: Entity, b: Entity): + if Bounce in b: + speed = a[physics.Velocity].length + a[physics.Velocity] = a[physics.Velocity].normalized + a[physics.Velocity].y = (a[Position].y - b[Position].y) * 0.005 + a[physics.Velocity] = a[physics.Velocity].normalized * min( + (speed * 1.5), 1000.0 + ) + return True + + +class Bounce: + pass + + +def lol_simul(a: Entity, b: Entity): + lol(a, b) + return RightWall not in b + + +class Simulated: + pass + + +class Bar: + pass + + +class RightWall: + pass + + +class BallFollow(int): + pass + + +class Mine: + pass def __initialize(world: World): @@ -13,16 +62,154 @@ def __initialize(world: World): Initialise les ressources pour le moteur de jeu. """ world.new_entity().set( - Texture("background.png", 0), - # Scale(1000, 1000), - # Origin(0.5, 0.5), - # Position(600, 600), + SpriteBundle( + "background.png", + -1, + scale=Vec2(render.WIDTH, render.HEIGHT), + ), ) + # world.new_entity().set( + # SpriteBundle( + # "dodo.png", + # 0, + # position=Vec2(800, 500), + # origin=Vec2(0.5, 0.5), + # scale=Vec2(400, 300), + # ), + # physics.Solid(), + # ) + + world.new_entity().set( + SpriteBundle( + "dodo.png", + 0, + position=Vec2(100, 100), + origin=Vec2(0, 0), + scale=Vec2(render.WIDTH - 200, 10), + ), + physics.Solid(), + ) + + world.new_entity().set( + SpriteBundle( + "dodo.png", + 0, + position=Vec2(100, render.HEIGHT - 100), + origin=Vec2(0, 1), + scale=Vec2(render.WIDTH - 200, 10), + ), + physics.Solid(), + ) + + world.new_entity().set( + SpriteBundle( + "dodo.png", + 0, + position=Vec2(render.WIDTH - 100, 100), + origin=Vec2(1, 0), + scale=Vec2(10, render.HEIGHT - 200), + ), + physics.Solid(), + RightWall(), + ) + world.new_entity().set( + SpriteBundle( + "dodo.png", + 0, + position=Vec2(100, 100), + origin=Vec2(0, 0), + scale=Vec2(10, render.HEIGHT - 200), + ), + physics.Solid(), + ) + + world.new_entity().set( + SpriteBundle( + "dodo.png", + 0, + position=Vec2(render.WIDTH - 130, render.HEIGHT / 2), + origin=Vec2(0.5, 0.5), + scale=Vec2(10, 200), + ), + physics.Solid(), + Bar(), + Bounce(), + ) + + world.new_entity().set( + SpriteBundle( + "dodo.png", + 0, + position=Vec2(130, render.HEIGHT / 2), + origin=Vec2(0.5, 0.5), + scale=Vec2(10, 200), + ), + physics.Solid(), + Mine(), + Bounce(), + ) + + world.new_entity().set( + SpriteBundle( + "dada.png", + 1, + scale=Vec2(10), + position=Vec2(500, 500), + origin=Vec2(0.5, 0.5), + ), + physics.Velocity(Vec2(200, 100)), + physics.CollisionHandler(lol), + ) + + +def __update(world: World): + """ + Test. + """ + for entity in world.query(Mine, Position): + if "z" in world[Held]: + entity[Position].y -= 300 * world[Delta] + if "s" in world[Held]: + entity[Position].y += 300 * world[Delta] + + ball = max( + world.query(Position, physics.Velocity, physics.CollisionHandler), + key=lambda e: e[Position].x, + ) + for bar in world.query(Bar): + bar.remove(physics.Solid) + entity = world.new_entity() + entity.set( + Position(ball[Position]), + Scale(ball[Scale]), + physics.Velocity(ball[physics.Velocity]), + Origin(ball[Origin]), + physics.CollisionHandler(lol_simul), + ) + physics.move_entity(entity, entity[physics.Velocity] * 500) + target = entity[Position].y + for bar in world.query(Bar): + diff = target - bar[Position].y + bar[Position].y += (diff / abs(diff)) * 300 * world[Delta] + bar.set(physics.Solid()) + entity.destroy() + + # ball.set(Simulated()) + # for entity in world.query(Bar): + # entity.remove(physics.Solid) + # last_position = Vec2(ball[Position]) + # last_velocity = Vec2(ball[physics.Velocity]) + # physics.move_entity(ball, ball[physics.Velocity] * 5000) + # ball[Position] = last_position + # ball[physics.Velocity] = last_velocity + # for entity in world.query(Bar): + # entity.set(physics.Solid()) + # ball.remove(Simulated) MENU = Scene( [__initialize], - [], + [__update], [], ) diff --git a/src/plugins/assets.py b/src/plugins/assets.py new file mode 100644 index 0000000..852e6f4 --- /dev/null +++ b/src/plugins/assets.py @@ -0,0 +1,45 @@ +""" +Ce module contient des utilitaires pour le chargement des ressources du jeu. +""" + +import pygame + + +def load_texture(name: str, cache: dict[str, pygame.Surface] = {}) -> pygame.Surface: + """ + Charge une texture et la renvoi. + """ + surface = cache.get(name) + if surface is None: + surface = pygame.image.load(f"assets/textures/{name}").convert_alpha() + cache[name] = surface + return surface + + +def load_sound( + name: str, cache: dict[str, pygame.mixer.Sound] = {} +) -> pygame.mixer.Sound: + """ + Charge un son et le renvoi. + """ + sound = cache.get(name) + if sound is None: + sound = pygame.mixer.Sound(f"assets/sounds/{name}") + cache[name] = sound + return sound + + +def load_text( + text: str, + size: int, + color: pygame.Color, + cache: dict[tuple[str, int, tuple[int, int, int]], pygame.Surface] = {}, +) -> pygame.Surface: + """ + Charge un texte et le renvoi. + """ + surface = cache.get((text, size, (color.r, color.g, color.b))) + if surface is None: + surface = pygame.font.Font("assets/font.ttf", size).render(text, True, color) + cache[(text, size, (color.r, color.g, color.b))] = surface + return surface diff --git a/src/plugins/click.py b/src/plugins/click.py index eb58691..47db260 100644 --- a/src/plugins/click.py +++ b/src/plugins/click.py @@ -2,13 +2,12 @@ Un plugin permettant de savoir si l'on a cliqué sur une entité. """ -from tkinter import Scale from typing import Callable from engine import GlobalPlugin from engine.ecs import Entity, World from plugins.hover import Hovered from plugins.inputs import Pressed -from plugins.render import Position +from plugins.render import Origin, Position, Scale class Clicked: @@ -31,7 +30,7 @@ def __update_clicked(world: World): Met à jour les composants `Clicked`. """ mouse_click = "button_1" in world[Pressed] - sprite_entities = world.query(Position, Scale) + sprite_entities = world.query(Position, Scale, Origin) for entity in sprite_entities: if Hovered in entity and mouse_click: entity[Clicked] = Clicked() diff --git a/src/plugins/defaults.py b/src/plugins/defaults.py index fe5b434..2ad6ca4 100644 --- a/src/plugins/defaults.py +++ b/src/plugins/defaults.py @@ -2,14 +2,16 @@ Plugin qui rassemple tous les plugins globaux. """ -from plugins import display, inputs, sound, render, timing, hover +from plugins import click, display, inputs, physics, sound, render, timing, hover PLUGIN = ( display.PLUGIN + timing.PLUGIN + inputs.PLUGIN + + physics.PLUGIN + hover.PLUGIN + + click.PLUGIN + sound.PLUGIN + render.PLUGIN ) diff --git a/src/plugins/hover.py b/src/plugins/hover.py index d5c32e3..c458e70 100644 --- a/src/plugins/hover.py +++ b/src/plugins/hover.py @@ -44,10 +44,10 @@ def __update_hovered(world: World): """ # On met à jour les composants mouse_position = world[MousePosition] - for entity in world.query(Position, Scale): + for entity in world.query(Position, Scale, Origin): # Récupération de la position et taille de l'entité size = entity[Scale] - position = entity[Position] - (entity.get(Origin, Origin(0)) * size) + position = entity[Position] - (entity[Origin] * size) # On détermine si la souris est sur l'entité if ( diff --git a/src/plugins/physics.py b/src/plugins/physics.py new file mode 100644 index 0000000..8879d4f --- /dev/null +++ b/src/plugins/physics.py @@ -0,0 +1,194 @@ +""" +Plugin implémentant une physique exacte pour des collisions AABB. +""" + +from typing import Callable +from engine import GlobalPlugin +from engine.ecs import Entity, World +from engine.math import Vec2 +from plugins.render import Origin, Position, Scale +from plugins.timing import Delta + + +class Solid: + """ + Composant représentant un objet (de préférence imobille pour que la simulation soit prédictible) qui ne laisse pas passer les objets dynamiques. + """ + + +class Velocity(Vec2): + """ + Composant donnant la vélocité d'un objet. + """ + + +class CollisionHandler: + """ + Composant permettant de traiter les collisions. + """ + + def __init__(self, callback: Callable[[Entity, Entity], bool]): + self.callback = callback + + +class AABB: + """ + Définit une boite. + """ + + def __init__(self, min: Vec2, max: Vec2, entity: Entity): + self.min = min + self.max = max + self.entity = entity + + @staticmethod + def from_entity(entity: Entity): + min = entity[Position] - entity[Origin] * entity[Scale] + return AABB(min, min + entity[Scale], entity) + + def entity_position(self, entity: Entity): + scale = self.max - self.min + entity[Position] = self.min + entity[Origin] * scale + entity[Scale] = scale + + def __contains__(self, point: Vec2): + return ( + self.min.x <= point.x <= self.max.x and self.min.y <= point.y <= self.max.y + ) + + def move(self, movement: Vec2): + self.min += movement + self.max += movement + + +def line_to_line(sa: Vec2, ea: Vec2, sb: Vec2, eb: Vec2): + """ + Renvoie la collision entre deux lignes. + """ + if sa.x == ea.x: + sa.x += 0.0001 + if sb.x == eb.x: + sb.x += 0.0001 + if sa.y == ea.y: + sa.y += 0.0001 + if sb.y == eb.y: + sb.y += 0.0001 + + divisor = (eb.y - sb.y) * (ea.x - sa.x) - (eb.x - sb.x) * (ea.y - sa.y) + if divisor == 0: + uA = 0 + else: + uA = ((eb.x - sb.x) * (sa.y - sb.y) - (eb.y - sb.y) * (sa.x - sb.x)) / (divisor) + divisor = (eb.y - sb.y) * (ea.x - sa.x) - (eb.x - sb.x) * (ea.y - sa.y) + if divisor == 0: + uB = 0 + else: + uB = ((ea.x - sa.x) * (sa.y - sb.y) - (ea.y - sa.y) * (sa.x - sb.x)) / (divisor) + if uA >= 0 and uA <= 1 and uB >= 0 and uB <= 1: + return ( + Vec2((uA * (ea.x - sa.x)), (uA * (ea.y - sa.y))).length / (ea - sa).length + ) + return 1.0 + + +def line_to_aabb(start: Vec2, end: Vec2, aabb: AABB): + """ + Renvoie la collision entre une ligne et une AABB. + """ + left = line_to_line(start, end, aabb.min, Vec2(aabb.min.x, aabb.max.y)) + right = line_to_line(start, end, Vec2(aabb.max.x, aabb.min.y), aabb.max) + bottom = line_to_line(start, end, aabb.min, Vec2(aabb.max.x, aabb.min.y)) + top = line_to_line(start, end, Vec2(aabb.min.x, aabb.max.y), aabb.max) + t = min([left, right, bottom, top]) + if t == left: + normal = Vec2(-1, 0) + elif t == right: + normal = Vec2(1, 0) + elif t == bottom: + normal = Vec2(0, -1) + elif t == top: + normal = Vec2(0, 1) + else: + normal = Vec2(0, 0) + return t, normal + + +def aabb_to_aabb(moving: AABB, static: AABB, movement: Vec2): + """ + Renvoie la collision entre deux AABB. + """ + size = (moving.max - moving.min) / 2 + static = AABB(static.min - size, static.max + size, static.entity) + start_pos = moving.min + size + return line_to_aabb(start_pos, start_pos + movement, static) + + +def aabb_to_aabbs(moving: AABB, statics: list[AABB], movement: Vec2): + """ + Renvoie la collision entre deux AABB. + """ + t = 1.0 + normal = Vec2(0, 0) + entity = None + for static in statics: + if static.entity == moving.entity: + continue + result = aabb_to_aabb(moving, static, movement) + if result[0] < t: + t = result[0] + normal = result[1] + entity = static.entity + return t, normal, entity + + +def move_entity(entity: Entity, movement: Vec2, disable_callback: bool = False): + world = entity.world + aabb = AABB.from_entity(entity) + others = [ + AABB.from_entity(other) for other in world.query(Solid, Position, Scale, Origin) + ] + counter = 0 + while movement.length > 0.0001 and counter < 50: + t, normal, obstacle = aabb_to_aabbs(aabb, others, movement) + if t == 1.0: + step = movement + else: + step = movement * max(t - 0.000001, 0) + aabb.move(step) + aabb.entity_position(entity) + movement -= step + if normal.x != 0: + movement.x *= -1 + entity[Velocity].x *= -1 + if normal.y != 0: + movement.y *= -1 + entity[Velocity].y *= -1 + movement /= entity[Velocity] + if obstacle is not None and not disable_callback: + if not entity.get( + CollisionHandler, CollisionHandler(lambda e, o: True) + ).callback(entity, obstacle): + break + if not obstacle.get( + CollisionHandler, CollisionHandler(lambda e, o: True) + ).callback(obstacle, entity): + break + movement *= entity[Velocity] + counter += 1 + + +def __apply_velocity(world: World): + """ + Applique la vélocité a toutes les entitées. + """ + delta = world[Delta] + for entity in world.query(Velocity, Position, Scale, Origin): + move_entity(entity, entity[Velocity] * delta) + + +PLUGIN = GlobalPlugin( + [], + [__apply_velocity], + [], + [], +) diff --git a/src/plugins/render.py b/src/plugins/render.py index 9bf659b..299b450 100644 --- a/src/plugins/render.py +++ b/src/plugins/render.py @@ -3,9 +3,10 @@ Un plugin qui s'occupe de rendre des choses dans la fenetre. """ import pygame -from engine import GlobalPlugin, KeepAlive +from engine import GlobalPlugin from engine.ecs import World from engine.math import Vec2 +from plugins import assets WIDTH = 1440 @@ -28,6 +29,52 @@ def calculate_surface_rect() -> tuple[float, float, float, float]: return offset, 0.0, target_width, float(height) +class SpriteBundle: + """ + Un assemblage de composants permettant de faire une sprite. + """ + + def __new__( + cls, + texture: str, + order: float, + position: Vec2 = Vec2(0), + scale: Vec2 = Vec2(128), + origin: Vec2 = Vec2(0), + ): + return ( + Texture(texture), + Order(order), + Position(position), + Scale(scale), + Origin(origin), + ) + + +class TextBundle: + """ + Un assemblage de composants permettant de faire un texte. + """ + + def __new__( + cls, + text: str, + order: float, + size: int = 50, + color: pygame.Color = pygame.Color(255, 255, 255), + position: Vec2 = Vec2(0), + origin: Vec2 = Vec2(0), + ): + return ( + Text(text), + TextSize(size), + TextColor(color), + Position(position), + Order(order), + Origin(origin), + ) + + class Texture(str): """ Composant donnant le nom de la texture d'une entité. @@ -58,37 +105,44 @@ class Origin(Vec2): """ -class Surface(KeepAlive, pygame.Surface): +class Text(str): """ - Ressource qui stocke la surface de rendu du jeu. + Composant donnant le texte d'une entité. """ -def __initialize(world: World): +class TextSize(int): """ - Prépare le monde pour la gestion du rendu. + Composant donnant la taille du texte d'une entité. """ - world.set(Surface((WIDTH, HEIGHT))) -def __render(world: World, cache: dict[str, pygame.Surface] = {}): +class TextColor(pygame.Color): + """ + Composant donnant la couleur du texte d'une entité. + """ + + +def __render(world: World, surface: pygame.Surface = pygame.Surface((WIDTH, HEIGHT))): """ Rend le monde du jeu sur la surface puis l'affiche sur la fenetre. """ # On rend le monde sur la surface - surface: Surface = world[Surface] - entities = sorted(world.query(Texture), key=lambda entity: entity.get(Order, -1)) + entities = world.query(Texture, Position, Order, Scale, Origin) + entities.update(world.query(Text, Position, Order, Origin, TextSize, TextColor)) + entities = sorted(entities, key=lambda entity: entity[Order]) for entity in entities: - texture_name = entity[Texture] - texture = cache.get(texture_name) - if texture is None: - texture = pygame.image.load(f"assets/textures/{texture_name}") - cache[texture_name] = texture - scale = entity.get(Scale, Scale(128)) - texture = pygame.transform.scale(texture, (scale.x, scale.y)) - position = ( - entity.get(Position, Position(0)) - (entity.get(Origin, Origin(0))) * scale - ) + if Text in entity: + texture = assets.load_text( + entity[Text], entity[TextSize], entity[TextColor] + ) + scale = Scale(texture.get_width(), texture.get_height()) + else: + texture = entity[Texture] + texture = assets.load_texture(texture) + scale = entity[Scale] + texture = pygame.transform.scale(texture, (scale.x, scale.y)) + position = entity[Position] - entity[Origin] * scale surface.blit(texture, (position.x, position.y)) # On affiche la surface sur la fenetre @@ -103,7 +157,7 @@ def __render(world: World, cache: dict[str, pygame.Surface] = {}): PLUGIN = GlobalPlugin( - [__initialize], + [], [], [__render], [], diff --git a/src/plugins/sound.py b/src/plugins/sound.py index 5b358f3..f095244 100644 --- a/src/plugins/sound.py +++ b/src/plugins/sound.py @@ -5,43 +5,38 @@ Un plugin permettant de jouer des sons. from typing import Callable import pygame -from engine import GlobalPlugin, KeepAlive +from engine import GlobalPlugin from engine.ecs import Entity, World +from plugins import assets -class Channels(KeepAlive, dict[Entity, pygame.mixer.Channel]): - """ - Ressource qui stoque les sons actuellement joués dans le jeu. - """ - - -class Sound: +class Sound(str): """ Composant permettant de jouer un son. """ - def __init__( - self, - sound: str, - loop: bool = False, - volume: float = 1.0, - fade_ms: int = 0, - callback: Callable[[World, Entity], object] = lambda _w, _e: None, - ): - self.sound = sound - self.loop = loop - self.volume = volume - self.fade_ms = fade_ms + +class Volume(float): + """ + Composant donnant le volume d'un son. + """ + + +class Loop: + """ + Composant indiquant si le son joué par l'entité doit se relancer en boucle. + """ + + +class SoundCallback: + """ + Composant donnant une fonction qui sera appelée à la fin du son. + """ + + def __init__(self, callback: Callable[[World, Entity], object]): self.callback = callback -def __initialize(world: World): - """ - Ajoute les ressources utiles pour le plugin. - """ - world.set(Channels()) - - def __update_sounds( world: World, channels: dict[Entity, pygame.mixer.Channel] = {}, @@ -51,23 +46,23 @@ def __update_sounds( Met à jour les sons du jeu. """ # Ajout des sons non gérés - channels = world[Channels] sound_entities = world.query(Sound) for entity in sound_entities: if entity not in channels: - sound = entity[Sound] - channel = sound.sound.play(sound.loop, fade_ms=sound.fade_ms) + sound_name = entity[Sound] + sound = assets.load_sound(sound_name) + channel = sound.play(Loop in entity) if channel is not None: # type: ignore - channel.set_volume(sound.volume) + channel.set_volume(entity.get(Volume, 1.0)) channels[entity] = channel # On supprime les sons qui sont arrêtés ou qui n'ont plus d'entité channels_to_remove: list[Entity] = [] for entity, channel in channels.items(): if not channel.get_busy() and Sound in entity: - callback = entity[Sound].callback + callback = entity.get(SoundCallback, SoundCallback(lambda w, e: None)) del entity[Sound] - callback(world, entity) + callback.callback(world, entity) channels_to_remove.append(entity) elif entity not in sound_entities: channel.stop() @@ -77,7 +72,7 @@ def __update_sounds( PLUGIN = GlobalPlugin( - [__initialize], + [], [], [__update_sounds], [],