gtn/src/engine.py
2023-10-29 12:27:21 +01:00

808 lines
25 KiB
Python

"""
Un moteur de jeu inspiré de bevy.
"""
import glob
import json
import math
import os
from typing import Callable, Optional, Sequence, SupportsFloat, TypeVar, Union
from time import time
import pygame
class World:
"""
Un monde contenant toutes les données du jeu.
"""
__T = TypeVar("__T")
"""
Permet d'indiquer le type d'un composant ou d'une ressource.
"""
def __init__(self):
"""
Constructeur d'un nouveau monde vide.
"""
self._entities: dict[type[object], set[Entity]] = {}
self.__resources: dict[type[object], object] = {}
def create_entity(self, *components: object) -> "Entity":
"""
Crée une nouvelle entité avec les composants donnés et l'ajoute au monde.
Paramètres:
*components: les composants de l'entité.
Retourne:
L'entité.
"""
return Entity(self, *components)
def __setitem__(self, resource_type: type[__T], resource: __T):
if resource_type != type(resource):
raise TypeError()
self.__resources[resource_type] = resource
def __getitem__(self, resource_type: type[__T]) -> __T:
return self.__resources[resource_type] # type: ignore
def __contains__(self, resource_type: type[object]) -> bool:
return resource_type in self.__resources
def __delitem__(self, resource_type: type[object]):
del self.__resources[resource_type]
def query(
self, *component_types: type[object], without: Sequence[type[object]] = ()
) -> set["Entity"]:
"""
Renvoie les entités qui ont tous les composants de type *component_types
et qui n'ont aucun des composants de type *without.
Paramètres:
*component_types: les types de composants des entités a renvoyer.
without: les types de composants des entités a exclure.
Retourne:
Les entités qui correspondent aux critères.
"""
if len(component_types) == 0:
return set()
entities = set(self._entities.get(component_types[0], set()))
for component_type in component_types[1:]:
entities.intersection_update(self._entities.get(component_type, set()))
for component_type in without:
entities.difference_update(self._entities.get(component_type, set()))
return entities
class Entity:
"""
Une entité du monde. Elle contient des composants.
"""
__T = TypeVar("__T")
"""
Permet d'indiquer le type d'un composant.
"""
def __init__(self, world: World, *components: object):
"""
Crée une nouvelle entité avec les composants donnés et l'ajoute au monde.
Paramètres:
world: le monde dans lequel ajouter l'entité
*components: les composants à ajouter à l'entité.
"""
self.__world = world
self.__components: dict[type[object], object] = {}
for component in components:
self[type(component)] = component
def __setitem__(self, component_type: type[__T], component: __T):
if component_type != type(component):
raise TypeError()
self.__components[component_type] = component
self.__world._entities.setdefault(component_type, set()).add(self)
def __getitem__(self, component_type: type[__T]) -> __T:
return self.__components[component_type] # type: ignore
def __contains__(self, component_type: type[object]) -> bool:
return component_type in self.__components
def __delitem__(self, component_type: type[object]):
if self.__components.pop(component_type, None) is not None:
self.__world._entities[component_type].remove(self)
if len(self.__world._entities[component_type]) == 0:
del self.__world._entities[component_type]
def __repr__(self) -> str:
return f"Entity({id(self)})"
class Vec2:
"""
Un vecteur 2D
"""
def __init__(self, *args: Union[SupportsFloat, "Vec2"]):
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
else:
self.x = float(args[0])
self.y = float(args[0])
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, SupportsFloat):
return Vec2(self.x + float(other), self.y + float(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, SupportsFloat):
return Vec2(self.x - float(other), self.y - float(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, SupportsFloat):
return Vec2(self.x * float(other), self.y * float(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, SupportsFloat):
return Vec2(self.x / float(other), self.y / float(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})"
class Display:
"""
Une classe utilitaire pour la gestion de la fenêtre du jeu.
"""
WIDTH = 1440.0
HEIGHT = 1080.0
RATIO = WIDTH / HEIGHT
INVERT_RATIO = HEIGHT / WIDTH
@staticmethod
def _calculate_surface_rect() -> tuple[float, float, float, float]:
"""
Calcule et renvoit le rectangle de la surface dans la fenêtre du jeu.
"""
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
class Game:
"""
Un ressource qui représente le jeu actuel.
"""
def __init__(self):
self.stop_requested = False
self.next_scene: Optional[str] = None
def stop(self):
"""
Demande l'arrêt du jeu.
"""
self.stop_requested = True
def change_scene(self, scene_name: str):
"""
Demande un changement de scène.
Paramètres:
scene_name: Le nom de la scène dans laquelle aller.
"""
self.next_scene = scene_name
class Assets:
"""
Resource qui permet la gestion des assets.
"""
def __init__(self, surface: pygame.Surface):
# Création de la texture d'erreur
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(surface)
# Chargement des textures
self.__textures: dict[str, pygame.Surface] = {}
for file in glob.iglob("assets/textures/**/*.png", recursive=True):
self.__textures[file[16:].replace("\\", "/")] = pygame.image.load(
file
).convert_alpha(surface)
# Création du cache pour les polices
self.__fonts: dict[int, pygame.font.Font] = {}
def get_texture(self, name: str) -> pygame.Surface:
"""
Renvoie la texture qui correspond au nom *name*.
Paramètres:
name: Le nom de la texture.
Retourne:
La texture qui correspond au nom *name*.
"""
return self.__textures.get(name, self.__error_texture)
def get_texture_size(self, name: str) -> Vec2:
"""
Renvoie la taille de la texture qui correspond au nom *name*.
Paramètres:
name: Le nom de la texture.
Retourne:
La taille de la texture qui correspond au nom *name*.
"""
return Vec2(*self.get_texture(name).get_size())
def get_font(self, size: int) -> pygame.font.Font:
"""
Renvoie la police qui correspond à la taille *size*.
Paramètres:
size: La taille de la police.
Retourne:
La police qui correspond à la taille *size*.
"""
font = self.__fonts.get(size)
if font is None:
font = pygame.font.Font("assets/font.ttf", size)
self.__fonts[size] = font
return font
def get_text_size(self, text: str, size: int) -> Vec2:
"""
Renvoie la taille d'un texte avec une certaine taille de police.
Paramètres:
text: Le texte.
size: La taille de la police.
Retourne:
La taille d'un texte avec une certaine taille de police.
"""
return Vec2(*self.get_font(size).size(text))
class Time(float):
"""
Ressource qui represente le temps depuis 1900.
"""
class Delta(float):
"""
Ressource qui represente le temps depuis la dernière frame.
"""
class Keyboard:
"""
Ressource qui représente les entrées utilisateurs sur le clavier à la frame actuelle.
"""
def __init__(self):
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):
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
class Position(Vec2):
"""
Composant qui représente la position d'une entité.
"""
class Centered:
"""
Composant permettant de dire que l'affichage de l'entité doit être centré.
"""
class Offset(Vec2):
"""
Composant qui represente un décalage de la position d'une entité.
"""
class Order(int):
"""
Composant qui represente l'ordre d'affichage d'une entité.
"""
class Texture(str):
"""
Composant qui rerpésente la texture d'une entité.
"""
class HoveredTexture(Texture):
"""
Composant qui represente la texture lorsque l'entité est survolée.
"""
class Animation:
"""
Composant qui représente une animation.
"""
def __init__(
self,
name: str,
callback: Callable[[World, Entity], object] = lambda _w, _e: None,
):
self.name = name
self.callback = callback
with open(
f"assets/textures/animations/{name}/info.json",
"r",
encoding="utf-8",
) as f:
info = json.load(f)
self.end_image: str = info.get("end_image", "")
self.offset = Offset(info["offset"]["x"], info["offset"]["y"])
self.frame_count: int = info["frame_count"]
self.fps: int = info["fps"]
self.time = 0.0
class Text(str):
"""
Composant qui represente un texte.
"""
class TextSize(int):
"""
Composant qui represente la taille d'un texte.
"""
class Color(pygame.Color):
"""
Composant qui represente la couleur d'une entité.
"""
class HoverEnter:
"""
Composant qui marque un entité comme commencée à être survolée.
"""
class Hovered:
"""
Composant qui marque un entité comme survolée.
"""
class HoverExit:
"""
Composant qui marque un entité comme arreté d'être survolée.
"""
class Clickable:
"""
Composant qui marque un entité comme pouvant etre cliquer.
"""
def __init__(self, callback: Callable[[World, Entity], object]):
self.callback = callback
class Sound:
"""
Composant qui une entité emettrant un son.
"""
def __init__(self, name: str) -> None:
self.name = name
self._is_paying = False
class Scene:
"""
Une scène dans le jeu.
"""
def __init__(
self,
init_systems: list[Callable[[World], object]],
update_systems: list[Callable[[World], object]],
stop_systems: list[Callable[[World], object]],
):
self.init_systems = init_systems
self.update_systems = update_systems
self.stop_systems = stop_systems
def __add__(self, other: "Scene") -> "Scene":
return Scene(
self.init_systems + other.init_systems,
self.update_systems + other.update_systems,
self.stop_systems + other.stop_systems,
)
def start_game(
scenes: dict[str, Scene],
start_scene: str,
*,
title: str = "Game",
) -> None:
"""
Lance le moteur de jeu.
"""
# Initialisation de pygame
pygame.init()
if os.path.exists("icon.png"):
pygame.display.set_icon(pygame.image.load("icon.png"))
pygame.display.set_caption(title)
pygame.display.set_mode((800, 600), pygame.RESIZABLE)
surface = pygame.Surface((Display.WIDTH, Display.HEIGHT))
keyboard = Keyboard()
mouse = Mouse()
# Chargements des assets
assets = Assets(surface)
# On récupère la première scène
scene = scenes.get(start_scene)
# Tant qu'il y a des scènes a executer on les executent
while scene is not None:
# Initialisation du monde
world = World()
world[Game] = Game()
world[Assets] = assets
world[Keyboard] = keyboard
world[Mouse] = mouse
world[Time] = Time(time())
# Initialisation de la scène
for system in scene.init_systems:
system(world)
# Tant que la scène n'est pas terminé
while world[Game].next_scene is None and not world[Game].stop_requested:
# On gère les évenements pygame
keyboard.pressed.clear()
keyboard.released.clear()
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 = Display._calculate_surface_rect()
mouse.position = Vec2(
((event.pos[0] - rect[0]) / rect[2]) * Display.WIDTH,
((event.pos[1] - rect[1]) / rect[3]) * Display.HEIGHT,
)
# On vérifie le survol des textures et textes
for entity in world.query(Position, Texture):
# Récupération de la position et taille de l'entité
position: Vec2 = entity[Position]
if Offset in entity:
position = position + entity[Offset]
size = assets.get_texture_size(entity[Texture])
if Centered in entity:
position -= size / 2
# On détermine si la souris est sur l'entité
if (
mouse.position.x >= position.x
and mouse.position.x <= position.x + size.x
and mouse.position.y >= position.y
and mouse.position.y <= position.y + size.y
):
if Hovered not in entity:
entity[HoverEnter] = HoverEnter()
else:
del entity[HoverEnter]
entity[Hovered] = Hovered()
else:
if Hovered in entity:
entity[HoverExit] = HoverExit()
else:
del entity[HoverExit]
del entity[Hovered]
# On met à jour le temps et delta
now = time()
world[Delta] = Delta(now - world[Time])
world[Time] = Time(now)
# On execute les objets clickables
if mouse.is_button_pressed(1):
for entity in world.query(Clickable, Hovered):
entity[Clickable].callback(world, entity)
# On met à jour la scène
for system in scene.update_systems:
system(world)
# Mise à jour des animations
for entity in world.query(Animation):
animation = entity[Animation]
if animation.time == 0:
if animation.end_image == "" and Texture in entity:
animation.end_image = entity[Texture]
animation.time += world[Delta]
frame_index = int(animation.time * animation.fps)
if frame_index >= animation.frame_count:
entity[Texture] = Texture(animation.end_image)
del entity[Animation]
del entity[Offset]
animation.callback(world, entity)
else:
entity[Offset] = Offset(animation.offset.x, animation.offset.y)
entity[Texture] = Texture(
f"animations/{animation.name}/{frame_index:04}.png"
)
# Rendu du monde
for entity in sorted(world.query(Order, Position), key=lambda e: e[Order]):
# Récupération de la position de l'entité
position: Vec2 = entity[Position]
if Offset in entity:
position = position + entity[Offset]
centered = Centered in entity
# Affichage de la texture
if Texture in entity:
if HoveredTexture in entity and Hovered in entity:
texture = assets.get_texture(entity[HoveredTexture])
else:
texture = assets.get_texture(entity[Texture])
surface.blit(
texture,
(
position.x - texture.get_width() / 2
if centered
else position.x,
position.y - texture.get_height() / 2
if centered
else position.y,
),
)
# Affichage des textes
if Text in entity:
color = entity[Color] if Color in entity else Color(255, 255, 255)
size = entity[TextSize] if TextSize in entity else 50
font = assets.get_font(size)
font_surface = font.render(entity[Text], True, color)
surface.blit(
font_surface,
(
position.x - font_surface.get_width() / 2
if centered
else position.x,
position.y - font_surface.get_height() / 2
if centered
else position.y,
),
)
# On met le son
for entity in world.query(Sound):
# On verifie si le son est deja actif.
if not entity[Sound]._is_paying:
# On charge le son et on le joue.
if os.path.exists("assets/sounds/" + entity[Sound].name):
entity[Sound]._is_paying = True
pygame.mixer.Sound("assets/sounds/" + entity[Sound].name).play()
# Mise a jour de la fenêtre
rect = Display._calculate_surface_rect()
pygame.transform.set_smoothscale_backend("MMX")
pygame.transform.smoothscale(
surface,
(rect[2], rect[3]),
pygame.display.get_surface().subsurface(rect),
)
pygame.display.flip()
surface.fill((0, 0, 0))
# Arrêt de la scène
for system in scene.stop_systems:
system(world)
# Récupération de la scène suivante
next_scene = world[Game].next_scene
if next_scene is not None:
scene = scenes.get(next_scene)
else:
scene = None
# Arrêt du jeu
pygame.quit()