diff --git a/02-particle-system/polastyn2030/main.py b/02-particle-system/polastyn2030/main.py new file mode 100644 index 0000000..f940422 --- /dev/null +++ b/02-particle-system/polastyn2030/main.py @@ -0,0 +1,80 @@ +import sys +from pathlib import Path + +try: + import wclib +except ImportError: + # wclib may not be in the path because of the architecture + # of all the challenges and the fact that there are many + # way to run them (through the showcase, or on their own) + + ROOT_FOLDER = Path(__file__).parent.parent.parent + sys.path.append(str(ROOT_FOLDER)) + import wclib + +# This line tells python how to handle the relative imports +# when you run this file directly. +__package__ = "02-particle-system." + Path(__file__).parent.name + +# ---- Recommended: don't modify anything above this line ---- # + +# Metadata about your submission +__author__ = "polastyn#7640" # Put yours! +__achievements__ = [ # Uncomment the ones you've done + # "Casual", + # "Ambitious", + "Adventurous", +] + +# To import the modules in yourname/, you need to use relative imports, +# otherwise your project will not be compatible with the showcase. +# noinspection PyPackages +from .objects import * +# noinspection PyPackages +from .particle_system import load_particle_spawner as load +# noinspection PyPackages +from .particle_system_template import ParticleTemplate, SpawnerTemplate +# noinspection PyPackages +from . import particle_system as par + +BACKGROUND = 0x0F1012 + + +def mainloop(): + pygame.init() + + player = Player((SIZE[0] / 2, SIZE[1] / 2), (0, 0)) + # The state is just a collection of all the objects in the game + state = State(player, FpsCounter(60), *Asteroid.generate_many()) + image = pygame.Surface((10, 10)) + image.fill((255, 255, 255)) + spawner = load( + ParticleTemplate(life_time=10, size=(5, 5), speed=(10, 20)), + image, SpawnerTemplate(spawn_pos=(30, 30, 30), spawn_delay=0, limit=10) + ) + spawner.info.spawn_pos = par.RemotePos(player, "center") + spawner_angle = par.RandomFloat(-10, 10) + spawner.info.object_info.angle = spawner_angle + + while True: + screen, events = yield + for event in events: + if event.type == pygame.QUIT: + return + else: + state.handle_event(event) + + # Note: the logic for collisions is in the Asteroids class. + # This may seem arbitrary, but the only collisions that we consider + # are with asteroids. + state.logic() + spawner_angle.move_center(-player.rotation+90) + spawner.update() + + screen.fill(BACKGROUND) + spawner.draw(screen) + state.draw(screen) + + +if __name__ == "__main__": + wclib.run(mainloop()) diff --git a/02-particle-system/polastyn2030/objects.py b/02-particle-system/polastyn2030/objects.py new file mode 100644 index 0000000..f18e8f2 --- /dev/null +++ b/02-particle-system/polastyn2030/objects.py @@ -0,0 +1,373 @@ +""" +This file provides objects that can be used to make up +a basic playground for the challenges. + +This code is provided so that you can focus on implementing +your particle system, without needing to implement a game that +goes with it too. +Feel free to modify everything in this file to your liking. +""" + +import time +from collections import deque +from colorsys import hsv_to_rgb +from functools import lru_cache +from operator import attrgetter +from random import gauss, choices, uniform + +import pygame + +# noinspection PyPackages +from .utils import * + + +class State: + def __init__(self, *initial_objects: "Object"): + self.objects = set() + self.objects_to_add = set() + + for obj in initial_objects: + self.add(obj) + + def add(self, obj: "Object"): + # We don't add objects immediately, + # as it could invalidate iterations. + self.objects_to_add.add(obj) + obj.state = self + return obj + + def logic(self): + to_remove = set() + for obj in self.objects: + obj.logic() + if not obj.alive: + to_remove.add(obj) + self.objects.difference_update(to_remove) + self.objects.update(self.objects_to_add) + self.objects_to_add.clear() + + def draw(self, screen): + for obj in sorted(self.objects, key=attrgetter("Z")): + obj.draw(screen) + + def handle_event(self, event): + for obj in self.objects: + if obj.handle_event(event): + break + + +class Object: + """ + The base class for all objects of the game. + + Controls: + - [d] Show the hitboxes for debugging. + """ + + # Controls the order of draw. + # Objects are sorted according to Z before drawing. + Z = 0 + + # All the objects are considered circles, + # Their hit-box is scaled by the given amount, to the advantage of the player. + HIT_BOX_SCALE = 1.2 + + def __init__(self, pos, vel, sprite: pygame.Surface): + # The state is set when the object is added to a state. + self.state: "State" = None + self.center = pygame.Vector2(pos) + self.vel = pygame.Vector2(vel) + self.sprite = sprite + self.rotation = 0.0 # for the sprite + self.alive = True + # Cache it every frame, as it was taking 10% (!!) of the processing power. + self.rect = self.get_rect() + + def __str__(self): + return f"<{self.__class__.__name__}(center={self.center}, vel={self.vel}, rotation={int(self.rotation)})>" + + @property + def radius(self): + """All objects are considered circles of this radius for collisions.""" + # The 1.2 is to be nicer to the player + return self.sprite.get_width() / 2 * self.HIT_BOX_SCALE + + def collide(self, other: "Object") -> bool: + """Whether two objects collide.""" + # The distance must be modified because everything wraps + dx = (self.center.x - other.center.x) % SIZE[0] + dx = min(dx, SIZE[0] - dx) + dy = (self.center.y - other.center.y) % SIZE[1] + dy = min(dy, SIZE[1] - dy) + return (dx ** 2 + dy ** 2) <= (self.radius + other.radius) ** 2 + + @property + def rotated_sprite(self): + # We round the rotation to the nearest integer so that + # the cache has a chance to work. Otherwise there would + # always be cache misses: it is very unlikely to have + # to floats that are equal. + return rotate_image(self.sprite, int(self.rotation)) + + def get_rect(self): + """Compute the rectangle containing the object.""" + return self.rotated_sprite.get_rect(center=self.center) + + def handle_event(self, event): + """Override this method to make an object react to events. + Returns True if the event was handled and should not be given to other objects.""" + return False + + def draw(self, screen): + screen.blit(self.rotated_sprite, self.rect) + + # Goal: wrap around the screen. + w, h = SIZE + tl = 0, 0 + tr = w, 0 + br = w, h + bl = 0, h + + shifts = [] + for a, b, offset in [ + (tl, tr, (0, h)), + (bl, br, (0, -h)), + (tl, bl, (w, 0)), + (tr, br, (-w, 0)), + ]: + # For each side [a,b] of the screen that it overlaps + if self.rect.clipline(a, b): + shifts.append(offset) + # Draw the spaceship at the other edge too. + screen.blit( + self.rotated_sprite, + self.rotated_sprite.get_rect(center=self.center + offset), + ) + + # Take care of the corners of the screen. + # Here I assume that no object can touch two sides of the screen + # at the same time. If so, the code wouldn't be correct, but still + # produce the expected result -.-' + assert len(shifts) <= 2 + if len(shifts) == 2: + screen.blit( + self.rotated_sprite, + self.rotated_sprite.get_rect(center=self.center + shifts[0] + shifts[1]), + ) + + # To see the exact size of the hitboxes + if pygame.key.get_pressed()[pygame.K_d]: + pygame.draw.circle(screen, "red", self.center, self.radius, width=1) + + def logic(self, **kwargs): + # self.vel = clamp_vector(self.vel, self.MAX_VEL) + self.center += self.vel + + self.center.x %= SIZE[0] + self.center.y %= SIZE[1] + + self.rect = self.get_rect() + + +class Player(Object): + Z = 10 + HIT_BOX_SCALE = 0.7 # harder to touch the player + ACCELERATION = 0.7 + FRICTION = 0.9 + ROTATION_ACCELERATION = 3 + INITIAL_ROTATION = 90 + FIRE_COOLDOWN = 15 # frames + + def __init__(self, pos, vel): + super().__init__(pos, vel, load_image("player", 3)) + + self.speed = 0 + self.fire_cooldown = -1 + + def handle_event(self, event): + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_SPACE: + self.fire() + + def logic(self): + self.fire_cooldown -= 1 + + # For continuous shooting: + # if pressed[pygame.K_SPACE]: + # self.fire(new_objects) + + # Motion + pressed = pygame.key.get_pressed() + rotation_acc = pressed[pygame.K_LEFT] - pressed[pygame.K_RIGHT] + raw_acceleration = 0.5 * pressed[pygame.K_DOWN] - pressed[pygame.K_UP] + + self.speed += raw_acceleration * self.ACCELERATION + self.speed *= self.FRICTION # friction + + # The min term makes it harder to turn at slow speed. + self.rotation += rotation_acc * self.ROTATION_ACCELERATION * min(1.0, 0.4 + abs(self.speed)) + + self.vel.from_polar((self.speed, self.INITIAL_ROTATION - self.rotation)) + + super().logic() + + def fire(self): + if self.fire_cooldown >= 0: + return + + self.fire_cooldown = self.FIRE_COOLDOWN + bullet = Bullet(self.center, 270 - self.rotation) + self.state.add(bullet) + + # You can add particles here too. + ... + + def on_asteroid_collision(self, asteroid: "Asteroid"): + # For simplicity I just explode the asteroid, but depending on what you aim for, + # it might be better to just loose some life or even reset the game... + asteroid.explode(Bullet(self.center, self.rotation)) + + # Add particles here (and maybe damage the ship or something...) + ... + + +class Bullet(Object): + Z = 1 + SPEED = 10 + TIME_TO_LIVE = 60 * 2 + + def __init__(self, pos, angle): + super().__init__(pos, from_polar(self.SPEED, angle), load_image("bullet", 2)) + self.rotation = 90 - angle + self.time_to_live = self.TIME_TO_LIVE + + def logic(self, **kwargs): + super().logic(**kwargs) + + self.time_to_live -= 1 + + if self.time_to_live <= 0: + self.alive = False + + # Maybe some trail particles here ? You can put particles EVERYWHERE. Really. + ... + + +class Asteroid(Object): + AVG_SPEED = 1 + EXPLOSION_SPEED_BOOST = 1.8 + + def __init__(self, pos, vel, size=4, color=None): + assert 1 <= size <= 4 + self.level = size + # We copy to change the color + self.color = color or self.random_color() + + super().__init__(pos, vel, self.colored_image(size, self.color)) + + @staticmethod + @lru_cache(100) + def colored_image(size, color): + sprite = load_image(f"asteroid-{16*2**size}").copy() + sprite.fill(color, special_flags=pygame.BLEND_RGB_MULT) + return sprite + + def logic(self): + super().logic() + + for obj in self.state.objects: + if not obj.alive: + continue + + if isinstance(obj, Bullet): + # Detect if the bullet and asteroid collide. + if self.collide(obj): + self.explode(obj) + break + elif isinstance(obj, Player): + if self.collide(obj): + obj.on_asteroid_collision(self) + + def explode(self, bullet): + bullet.alive = False + self.alive = False + if self.level > 1: + # We spawn two smaller asteroids in the direction perpendicular to the collision. + perp_velocity = pygame.Vector2(bullet.vel.y, -bullet.vel.x) + perp_velocity.scale_to_length(self.vel.length() * self.EXPLOSION_SPEED_BOOST) + for mult in (-1, 1): + self.state.add( + Asteroid(self.center, perp_velocity * mult, self.level - 1, self.color) + ) + + # You'll add particles here for sure ;) + ... + + def random_color(self): + r, g, b = hsv_to_rgb(uniform(0, 1), 0.8, 0.8) + return int(r * 255), int(g * 255), int(b * 255) + + @classmethod + def generate_many(cls, nb=10): + """Return a set of nb Asteroids randomly generated.""" + objects = set() + for _ in range(nb): + angle = uniform(0, 360) + distance_from_center = gauss(SIZE[1] / 2, SIZE[1] / 12) + pos = SCREEN.center + from_polar(distance_from_center, angle) + vel = from_polar(gauss(cls.AVG_SPEED, cls.AVG_SPEED / 6), gauss(180 + angle, 30)) + size = choices([1, 2, 3, 4], [4, 3, 2, 1])[0] + objects.add(cls(pos, vel, size)) + + return objects + + +class FpsCounter(Object): + """ + A wrapper around pygame.time.Clock that shows the FPS on screen. + + Controls: + - [F] Toggles the display of FPS + - [U] Toggles the capping of FPS + """ + + Z = 1000 + REMEMBER = 30 + + def __init__(self, fps): + self.hidden = False + self.cap_fps = True + self.target_fps = fps + self.clock = pygame.time.Clock() + self.frame_starts = deque([time.time()], maxlen=self.REMEMBER) + + dummy_surface = pygame.Surface((1, 1)) + super().__init__((4, 8), (0, 0), dummy_surface) + + def handle_event(self, event): + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_f: + self.hidden = not self.hidden + elif event.key == pygame.K_u: + self.cap_fps = not self.cap_fps + + def logic(self, **kwargs): + # Passing 0 to tick() removes the cap on FPS. + self.clock.tick(self.target_fps * self.cap_fps) + + self.frame_starts.append(time.time()) + + @property + def current_fps(self): + if len(self.frame_starts) <= 1: + return 0 + seconds = self.frame_starts[-1] - self.frame_starts[0] + return (len(self.frame_starts) - 1) / seconds + + def draw(self, screen): + if self.hidden: + return + + color = "#89C4F4" + t = text(f"FPS: {int(self.current_fps)}", color) + screen.blit(t, self.center) diff --git a/02-particle-system/polastyn2030/particle_system.py b/02-particle-system/polastyn2030/particle_system.py new file mode 100644 index 0000000..373b853 --- /dev/null +++ b/02-particle-system/polastyn2030/particle_system.py @@ -0,0 +1,462 @@ +""" +File for particle system +""" +from __future__ import annotations + +from builtins import super +from typing import Callable, TypeVar as _TypeVar, Type, cast, overload, Union, Tuple, Literal +import random as rd + +import pygame as pg + +try: + # noinspection PyPackages + from . import particle_system_template as pst +except ImportError: + import particle_system_template as pst + + +class _FixedGetAttr: + def __getattribute__(self, item: str): + data = super().__getattribute__(item) + + if item.startswith("__") and item.endswith("__"): + return data + + try: + data = data.__get__(self, self.__class__) + except AttributeError: + pass + return data + + +class BaseDynamicSurface(pg.Surface): + # noinspection PyMissingConstructor + def __init__(self, source_surface: pg.Surface, *arg_configs, **kwarg_configs): + self.source_surface = source_surface + self.arg_configs = arg_configs + self.kwarg_configs = kwarg_configs + self.__names__ = {"source_surface", "arg_configs", "kwarg_configs", "tick", "map_self_to_new"} + + def __getattribute__(self, item: str): + if item.startswith("__") and item.endswith("__"): + return object.__getattribute__(self, item) + elif item in self.__names__: + return object.__getattribute__(self, item) + else: + return getattr(self.source_surface, item) + + def tick(self): + pass + + def map_self_to_new(self, surface: pg.Surface | None = None): + return self.__cls__( + surface if surface else self.source_surface, *self.arg_configs, **self.kwarg_configs + ) + + +class BaseParticleObject(_FixedGetAttr, pg.sprite.Sprite): + def __init__(self, surface: pg.Surface | BaseDynamicSurface, size: pg.Vector2, position: pg.Vector2, + rotation: float, bounding_box: pg.Rect, life_time: int): + super().__init__() + self.source_surface = surface + self.size = size + self.position = position + self.rotation = rotation + self.source_rect = bounding_box + self.life_time = life_time + + self._smooth_scale = True + + @property + def alive(self): + return self.life_time > 0 + + @property + def surface(self): + copy = self.source_surface.copy() + + if self._smooth_scale: + copy = pg.transform.smoothscale(copy, self.tuple_size) + else: + copy = pg.transform.scale(copy, self.tuple_size) + + copy = pg.transform.rotate(copy, self.rotation) + + if isinstance(self.source_surface, BaseDynamicSurface): + copy = self.source_surface.map_self_to_new(copy) + + return copy + + @property + def image(self): + return self.surface + + @property + def rect(self): + copy = self.source_rect.copy() + copy.topleft = pg.Vector2(copy.topleft) + self.position + return copy + + @property + def tuple_size(self): + return int(self.size.x), int(self.size.y) + + def update(self, *args, **kwargs) -> None: + if not self.alive: + self.kill() + + self.tick() + + def tick(self): + pass + + +class DynamicParticle(BaseParticleObject): + def __init__( + self, + surface: pg.Surface | BaseDynamicSurface, size: pg.Vector2, + position: pg.Vector2, rotation: float, + bounding_box: pg.Rect, life_time: int, + speed: pg.Vector2, + rotation_speed: float, + scale_change: pg.Vector2 + ): + + super().__init__(surface, size, position, rotation, bounding_box, life_time) + + self.speed = speed + self.rotation_speed = rotation_speed + self.scale_change = scale_change + + def tick(self): + if not self.alive: + return + + self.life_time -= 1 + + self.__change("position", self.speed) + self.__change("rotation", self.rotation_speed) + self.__change("size", self.scale_change) + + if isinstance(self.surface, BaseDynamicSurface): + self.surface.tick() + + _T = _TypeVar("_T", bound=object) + + def __change(self, name: str, by: Callable[[_T], _T] | _T): + self.__change_value(name, getattr(self, name, None), by) + + def __change_value(self, name: str, data: _T, by: Callable[[_T], _T] | _T): + if getattr(by, "__call__", False): + setattr(self, name, by(data)) + else: + try: + setattr(self, name, data + by) + except TypeError: + pass + + +class RandomPos: + def __init__(self, center: pg.Vector2, pos_range: float | pg.Rect): + self.center = center + self.pos_range = pos_range + self.__names__ = {"center", "pos_range", "get"} + + def __get__(self, instance, owner): + return self.get() + + def __getattribute__(self, item: str): + if item.startswith("__") and item.endswith("__"): + try: + return super().__getattribute__(item) + except ValueError: + val = self.get() + return getattr(val, item) + elif item in self.__names__: + return super().__getattribute__(item) + else: + val = self.get() + return getattr(val, item) + + def get(self): + if isinstance(self.pos_range, (float, int)): + offset = pg.Vector2(1, 0) + offset.rotate(rd.randint(0, 360)) + offset *= rd.randint(0, int(self.pos_range)) + return self.center + offset + + x = rd.randint(0, self.pos_range.w) + y = rd.randint(0, self.pos_range.h) + + pos = pg.Vector2(x, y) + pos += pg.Vector2(self.pos_range.topleft) + + return pos + + +class RandomFloat: + # noinspection PyInitNewSignature + def __init__(self, smallest: float, biggest: float): + self.smallest = smallest + self.biggest = biggest + self.__names__ = {"smallest", "biggest", "get", "move_left", "move_right", "move_center"} + + def __get__(self, instance, owner): + return self.get() + + def __getattribute__(self, item: str): + if item.startswith("__") and item.endswith("__"): + try: + return super().__getattribute__(item) + except ValueError: + val = self.get() + return getattr(val, item) + elif item in self.__names__: + return super().__getattribute__(item) + else: + val = self.get() + return getattr(val, item) + + def get(self): + random = rd.random() + diff = self.biggest - self.smallest + random *= diff + random += self.smallest + return random + + def move_left(self, to: float): + diff = self.biggest - self.smallest + self.smallest = to + self.biggest = to + diff + + def move_right(self, to: float): + diff = self.biggest - self.smallest + self.biggest = to + self.smallest = to - diff + + def move_center(self, to: float): + diff = self.biggest - self.smallest + half = diff / 2 + self.smallest = to - half + self.biggest = to + half + + +class RandomInt(RandomFloat): + def get(self): + ret = super(RandomInt, self).get() + return int(ret) + + +class RemotePos(RandomPos): + # noinspection PyMissingConstructor + def __init__(self, obj: object, attr: str, op: str = "x"): + self.obj = obj + self.attr = attr + self.op = op + self.__names__ = {"obj", "attr", "op", "get"} + + def get(self): + return eval(self.op, {}, {"x": pg.Vector2(getattr(self.obj, self.attr))}) + + +class RemoteFloat(RandomFloat): + # noinspection PyMissingConstructor + def __init__(self, obj: object, attr: str, op: str = "x"): + self.obj = obj + self.attr = attr + self.op = op + self.__names__ = {"obj", "attr", "op", "get"} + + def get(self): + return eval(self.op, {}, {"x": float(getattr(self.obj, self.attr))}) + + +class RemoteInt(RemoteFloat): + def get(self): + return int(super(RemoteInt, self).get()) + + +class ParticleObjectInfo(_FixedGetAttr): + def __init__(self, + surface: pg.Surface | BaseDynamicSurface, + size: pg.Vector2 | RandomPos, + size_change: pg.Vector2 | RandomPos, + rotation: float | RandomFloat, + rotation_speed: float | RandomFloat, + life_time: int | RandomInt, + bounding_box: pg.Rect, + speed: float | RandomFloat, + moving_angle: float | RandomFloat, + particle_class: Type[DynamicParticle] | None = None + ): + self.surface = surface + self.size = size + self.size_change = size_change + self.rotation = rotation + self.rotation_speed = rotation_speed + self.life_time = life_time + self.bounding_box = bounding_box + self.speed = speed + self.angle = moving_angle + self.particle_class = particle_class if particle_class is not None else DynamicParticle + + +class ParticleSpawnerInfo(_FixedGetAttr): + def __init__( + self, + spawn_pos: pg.Vector2 | RandomPos, + spawn_delay: int | RandomInt, + object_info: ParticleObjectInfo, + limit: int = -1, + burst_function: Callable[[int], int] | None = None + ): + self.spawn_pos = spawn_pos + self.spawn_delay = spawn_delay + self.object_info = object_info + self.limit = limit + self.burst_function = burst_function + + def generate(self): + info = self.object_info + speed = pg.Vector2(1, 0).rotate(info.angle) * info.speed + obj = self.object_info.particle_class( + info.surface, info.size, self.spawn_pos, info.rotation, info.bounding_box, + info.life_time, speed, info.rotation_speed, info.size_change + ) + return obj + + +class BaseParticleSpawner(pg.sprite.Group): + def __init__(self, info: ParticleSpawnerInfo): + self.info = info + self.active = True + self.next_spawn = 0 + self.spawn_count = 0 + super().__init__() + + def add(self, *sprites: pg.Sprite) -> None: + if len(self) < self.info.limit: + super(BaseParticleSpawner, self).add(*sprites) + + def spawn(self): + if self.info.burst_function: + # noinspection PyBroadException + try: + value = self.info.burst_function(self.spawn_count) + + for _ in range(value): + self.add(self.info.generate()) + except Exception: + self.info.burst_function = None + self.spawn() + else: + self.add(self.info.generate()) + self.spawn_count += 1 + + def update(self, *args, **kwargs) -> None: + if self.active: + if self.next_spawn <= 0: + self.spawn() + self.next_spawn += self.info.spawn_delay + self.next_spawn -= 1 + super(BaseParticleSpawner, self).update(*args, **kwargs) + + +@overload +def _convert_to_valid( + data: Union[Tuple[float, float], Tuple[float, float, float], Tuple[float, float, float, float]], + mode: Literal["p", "pos", "v", "vector2", "Vector2"] +) -> pg.Vector2 | RandomPos: ... + + +@overload +def _convert_to_valid(data: Union[float, Tuple[float, float]], mode: Literal["f", "float"]) -> float | RandomFloat: ... + + +@overload +def _convert_to_valid(data: Union[int, Tuple[int, int]], mode: Literal["i", "int"]) -> int | RandomInt: ... + + +@overload +def _convert_to_valid(data: Tuple[float, float, float, float], mode: Literal["r", "rect", "Rect"]) -> pg.Rect: ... + + +def _convert_to_valid(data, mode): + if mode in {"p", "pos", "v", "vector2", "Vector2"}: + data = cast(Union[Tuple[float, float], Tuple[float, float, float], Tuple[float, float, float, float]], data) + if len(data) == 2: + return pg.Vector2(data) + elif len(data) == 3: + return RandomPos(pg.Vector2(data[:2]), data[2]) + else: + return RandomPos(pg.Vector2(data[:2]), pg.Rect(data)) + elif mode in {"f", "float"}: + data = cast(Union[float, Tuple[float, float]], data) + if isinstance(data, float): + return data + elif isinstance(data, int): + return float(data) + return RandomFloat(*data) + elif mode in {"i", "int"}: + data = cast(Union[int, Tuple[int, int]], data) + if isinstance(data, int): + return data + return RandomInt(*data) + elif mode in {"r", "rect", "Rect"}: + data = cast(Tuple[float, float, float, float], data) + return pg.Rect(data) + else: + raise TypeError("mode argument not in valid options") + + +def _fix_particle_template(template: pst.ParticleTemplate): + translating = { + "size": "v", "life_time": "i", "size_change": "p", "rotation": "f", "rotation_speed": "f", + "bounding_box": "r", "speed": "f", "moving_angle": "f" + } + new = {} + for el in template: + # noinspection PyTypeChecker,PyTypedDict + new[el] = _convert_to_valid(template[el], translating[el]) + return new + + +def load_particle_info( + surface: pg.Surface, template: pst.ParticleTemplate, + particle_class: Type[DynamicParticle] | None = None +): + particle_class = particle_class if particle_class is not None else DynamicParticle + full_template = pst.particle_template_filler.copy() + full_template.update(dict(template)) + full_template = cast(pst.ParticleTemplate, full_template) + fixed = _fix_particle_template(full_template) + + if "bounding_box" not in fixed: + fixed["bounding_box"] = surface.get_rect() + + return ParticleObjectInfo(surface=surface, particle_class=particle_class, **fixed) + + +def load_spawner_info( + template: pst.SpawnerTemplate, + object_info: ParticleObjectInfo, + burst_function: Callable[[int], int] | None = None +): + spawn_pos = _convert_to_valid(template['spawn_pos'], "p") + spawn_delay = _convert_to_valid(template['spawn_delay'], "i") + limit = _convert_to_valid(template['limit'], "i") + return ParticleSpawnerInfo(spawn_pos, spawn_delay, object_info, limit, burst_function) + + +def load_particle_spawner( + particle_template: pst.ParticleTemplate, + surface: pg.Surface, + spawner_template: pst.SpawnerTemplate, + particle_class: Type[DynamicParticle] | None = None, + burst_function: Callable[[int], int] | None = None, + spawner_class: Type[BaseParticleSpawner] | None = None +): + spawner_class = spawner_class if spawner_class is not None else BaseParticleSpawner + particle_info = load_particle_info(surface, particle_template, particle_class) + return spawner_class(load_spawner_info(spawner_template, particle_info, burst_function)) diff --git a/02-particle-system/polastyn2030/particle_system_template.py b/02-particle-system/polastyn2030/particle_system_template.py new file mode 100644 index 0000000..a3a8e20 --- /dev/null +++ b/02-particle-system/polastyn2030/particle_system_template.py @@ -0,0 +1,42 @@ +""" +Typing part pg particle_system - required to run particle_system.py +""" +from __future__ import annotations +from typing import TypedDict, Tuple, Union + + +# Union[Tuple[float, float], Tuple[float, float, float], Tuple[float, float, float, float]] +# Union[float, Tuple[float, float]] +# Union[int, Tuple[int, int]] +# Tuple[float, float, float, float] +class _NeededParticleTemplate(TypedDict): + size: Union[Tuple[float, float], Tuple[float, float, float], Tuple[float, float, float, float]] + life_time: Union[int, Tuple[int, int]] + + +class _NotImportantParticleTemplate(TypedDict, total=False): + size_change: Union[Tuple[float, float], Tuple[float, float, float], Tuple[float, float, float, float]] + rotation: Union[float, Tuple[float, float]] + rotation_speed: Union[float, Tuple[float, float]] + bounding_box: Tuple[float, float, float, float] + speed: Union[float, Tuple[float, float]] + moving_angle: Union[float, Tuple[float, float]] + + +class ParticleTemplate(_NeededParticleTemplate, _NotImportantParticleTemplate): + pass + + +particle_template_filler: _NotImportantParticleTemplate = _NotImportantParticleTemplate( + size_change=(0, 0), + rotation=0, + rotation_speed=0, + speed=0, + moving_angle=0 +) + + +class SpawnerTemplate(TypedDict): + spawn_pos: Union[Tuple[float, float], Tuple[float, float, float], Tuple[float, float, float, float]] + spawn_delay: Union[int, Tuple[int, int]] + limit: Union[int, Tuple[int, int]] diff --git a/02-particle-system/polastyn2030/requirements.txt b/02-particle-system/polastyn2030/requirements.txt new file mode 100644 index 0000000..2a1e6fe --- /dev/null +++ b/02-particle-system/polastyn2030/requirements.txt @@ -0,0 +1,8 @@ +# Add here the extra requirements for your project. +# You cannot specify version numbers (yet). +# Lines starting with a "#" are ignored. +# DO NOT list pygame here. + +# Please try to limit you dependencies as much as you can + +# numpy diff --git a/02-particle-system/polastyn2030/utils.py b/02-particle-system/polastyn2030/utils.py new file mode 100644 index 0000000..46d50b1 --- /dev/null +++ b/02-particle-system/polastyn2030/utils.py @@ -0,0 +1,97 @@ +from functools import lru_cache +from pathlib import Path +from random import uniform + +import pygame + +from wclib.constants import SIZE, ROOT_DIR + +__all__ = [ + "SIZE", + "SUBMISSION_DIR", + "ASSETS", + "SCREEN", + "load_image", + "rotate_image", + "clamp", + "random_in_rect", + "from_polar", + "clamp_vector", + "text", +] + +SUBMISSION_DIR = Path(__file__).parent +ASSETS = SUBMISSION_DIR.parent / "assets" +SCREEN = pygame.Rect(0, 0, *SIZE) + + +@lru_cache() +def load_image(name: str, scale=1, alpha=True): + """Load a image from the disk and caches the results.""" + image = pygame.image.load(ASSETS / f"{name}.png") + if scale != 1: + new_size = image.get_width() * scale, image.get_height() * scale + image = pygame.transform.scale(image, new_size) + if alpha: + return image.convert_alpha() + else: + return image.convert() + + +@lru_cache() +def rotate_image(surf, angle: int): + """Rotate function that caches its results for performance.""" + return pygame.transform.rotate(surf, angle) + + +def clamp(value, mini, maxi): + """Clamp value between mini and maxi""" + if value < mini: + return mini + elif maxi < value: + return maxi + else: + return value + + +def random_in_rect(rect): + """Return a random point uniformly in a rectangle.""" + rect = pygame.Rect(rect) + return pygame.Vector2(uniform(rect.left, rect.right), uniform(rect.top, rect.bottom)) + + +def from_polar(rho, theta): + """Create a Vector2 from its polar representation.""" + v = pygame.Vector2() + v.from_polar((rho, theta)) + return v + + +def clamp_vector(v: pygame.Vector2, max_length): + """Ensure that a vector has a magnitude less than max_length.""" + if v.length() > max_length: + return v.normalize() * max_length + return v + + +@lru_cache() +def font(size=20, name=None): + """ + Load a font from its name in the wclib/assets folder. + + If a Path object is given as the name, this path will be used instead. + Results are cached. + """ + + name = name or "regular" + if isinstance(name, Path): + path = name + else: + path = ROOT_DIR / "wclib" / "assets" / (name + ".ttf") + return pygame.font.Font(path, size) + + +@lru_cache(5000) +def text(txt, color, size=20, font_name=None): + """Render a text on a surface. Results are cached.""" + return font(size, font_name).render(str(txt), True, color) diff --git a/04-bouncing-bubbles/polastyn/line_intersection.py b/04-bouncing-bubbles/polastyn/line_intersection.py new file mode 100644 index 0000000..8c4bb8b --- /dev/null +++ b/04-bouncing-bubbles/polastyn/line_intersection.py @@ -0,0 +1,76 @@ +""" +This file is not mine! +The code was grabbed from: https://www.geeksforgeeks.org/check-if-two-given-line-segments-intersect/ +and reformatted. +Credits goes to the creator: Ansh Riyal +""" + + +# Given three collinear points p, q, r, the function checks if +# point q lies on line segment 'pr' +def on_segment(p, q, r): + if ((q.x <= max(p.x, r.x)) and (q.x >= min(p.x, r.x)) and + (q.y <= max(p.y, r.y)) and (q.y >= min(p.y, r.y))): + return True + return False + + +def orientation(p, q, r): + # to find the orientation of an ordered triplet (p,q,r) + # function returns the following values: + # 0 : Collinear points + # 1 : Clockwise points + # 2 : Counterclockwise + + # See https://www.geeksforgeeks.org/orientation-3-ordered-points/amp/ + # for details of below formula. + + val = (float(q.y - p.y) * (r.x - q.x)) - (float(q.x - p.x) * (r.y - q.y)) + if val > 0: + + # Clockwise orientation + return 1 + elif val < 0: + + # Counterclockwise orientation + return 2 + else: + + # Collinear orientation + return 0 + + +# The main function that returns true if +# the line segment 'p1q1' and 'p2q2' intersect. +def do_intersect(p1, q1, p2, q2): + # Find the 4 orientations required for + # the general and special cases + o1 = orientation(p1, q1, p2) + o2 = orientation(p1, q1, q2) + o3 = orientation(p2, q2, p1) + o4 = orientation(p2, q2, q1) + + # General case + if (o1 != o2) and (o3 != o4): + return True + + # Special Cases + + # p1 , q1 and p2 are collinear and p2 lies on segment p1q1 + if (o1 == 0) and on_segment(p1, p2, q1): + return True + + # p1 , q1 and q2 are collinear and q2 lies on segment p1q1 + if (o2 == 0) and on_segment(p1, q2, q1): + return True + + # p2 , q2 and p1 are collinear and p1 lies on segment p2q2 + if (o3 == 0) and on_segment(p2, p1, q2): + return True + + # p2 , q2 and q1 are collinear and q1 lies on segment p2q2 + if (o4 == 0) and on_segment(p2, q1, q2): + return True + + # If none of the cases + return False diff --git a/04-bouncing-bubbles/polastyn/main.py b/04-bouncing-bubbles/polastyn/main.py new file mode 100644 index 0000000..520bb64 --- /dev/null +++ b/04-bouncing-bubbles/polastyn/main.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import sys +from dataclasses import dataclass +from pathlib import Path +from random import gauss, uniform, randint, getrandbits +from typing import List, Optional + +import pygame + +# This line tells python how to handle the relative imports +# when you run this file directly. Don't modify this line. +__package__ = "04-bouncing-bubbles." + Path(__file__).absolute().parent.name + +# To import the modules in yourname/, you need to use relative imports, +# otherwise your project will not be compatible with the showcase. +from .utils import * +from .line_intersection import do_intersect as line_intersect + +BACKGROUND = 0x0F1012 +NB_BUBBLES = 42 + + +def mul_vectors(v1: pygame.Vector2, v2: pygame.Vector2): + return pygame.Vector2(v1.x * v2.x, v1.y * v2.y) + + +def perp_vec(vec: pygame.Vector2): + return pygame.Vector2(vec.y, -vec.x) + + +def counter_perp_vec(vec: pygame.Vector2): + return pygame.Vector2(-vec.y, vec.x) + + +class Bubble: + MAX_VELOCITY = 7 + + def __init__(self, position=None, is_rect: bool | None = None): + self.is_rect = is_rect if is_rect is not None else not getrandbits(1) + self.rotation = 0 + self.rotation_speed = 0 + self.radius = int(gauss(25, 5)) + + if position is None: + # Default position is random. + self.position = pygame.Vector2( + randint(self.radius, SIZE[0] - self.radius), + randint(self.radius, SIZE[1] - self.radius), + ) + else: + self.position: pygame.Vector2 = position + + # Set a random direction and a speed of around 3. + self.velocity = pygame.Vector2() + self.velocity.from_polar((gauss(3, 0.5), uniform(0, 360))) + + # Pick a random color with high saturation and value. + self.color = pygame.Color(0) + self.color.hsva = uniform(0, 360), 80, 80, 100 + + self._fix_force = 5 + + self.border_lines = [ + [pygame.Vector2(0, 0), pygame.Vector2(0, SIZE[1])], # left + [pygame.Vector2(0, 0), pygame.Vector2(SIZE[0], 0)], # up + [pygame.Vector2(SIZE[0], 0), pygame.Vector2(SIZE)], # right + [pygame.Vector2(0, SIZE[1]), pygame.Vector2(SIZE)] # down + ] + + @property + def mass(self): + return self.radius ** 2 + + def get_rect_points(self): + rec = pygame.Rect(0, 0, self.radius * 2, self.radius * 2) + rec.center = 0, 0 + tl = pygame.Vector2(rec.topleft).rotate(self.rotation) + self.position + tr = pygame.Vector2(rec.topright).rotate(self.rotation) + self.position + bl = pygame.Vector2(rec.bottomleft).rotate(self.rotation) + self.position + br = pygame.Vector2(rec.bottomright).rotate(self.rotation) + self.position + return tl, tr, br, bl + + def get_lines(self) -> list[list[pygame.Vector2, pygame.Vector2]]: + ret = [] + last = None + for el in self.get_rect_points(): + if last is not None: + ret.append([last, el]) + last = el + + if len(ret) > 1: + ret.append([last, ret[0][0]]) + + return ret + + def draw(self, screen: pygame.Surface): + if self.is_rect: + pygame.draw.polygon(screen, self.color, self.get_rect_points()) + else: + pygame.draw.circle(screen, self.color, self.position, self.radius) + + def move_away_from_mouse(self, mouse_pos: pygame.Vector2): + """Apply a force on the bubble to move away from the mouse.""" + bubble_to_mouse = mouse_pos - self.position + distance_to_mouse = bubble_to_mouse.length() + if 0 < distance_to_mouse < 200: + strength = chrange(distance_to_mouse, (0, 200), (1, 0), power=2) + self.velocity -= bubble_to_mouse.normalize() * strength + + def move(self): + """Move the bubble according to its velocity.""" + # We first limit the velocity to not get bubbles that go faster than what we can enjoy. + if self.velocity.length() > self.MAX_VELOCITY: + self.velocity.scale_to_length(self.MAX_VELOCITY) + + self.position += self.velocity + debug.vector(self.velocity, self.position, scale=10) + self.rotation += self.rotation_speed + debug.vector(pygame.Vector2(1, 0), self.position, "yellow", self.rotation_speed) + + def how_colliding_border(self, mode: int): + """ + :param mode: 0 or 1 (0 circle, 1 rect) + :return: + """ + if mode: + left = self.collide_rect_line(*self.border_lines[0]) + top = self.collide_rect_line(*self.border_lines[1]) + right = self.collide_rect_line(*self.border_lines[2]) + down = self.collide_rect_line(*self.border_lines[3]) + return left, right, top, down + else: + left = self.position.x - self.radius <= 0 + right = self.position.x + self.radius >= SIZE[0] + top = self.position.y - self.radius <= 0 + down = self.position.y + self.radius >= SIZE[1] + return left, right, top, down + + def collide_borders(self): + # The first challenge is to make the bubbles bounce against the border. + # Hover that doesn't mean that a bubble must always be completely inside of the screen: + # If for instance it spawned on the edge, we don't want it to teleport so that it fits the screen, + # we want everything to be *smooooth*. + # + # To be sure it is smooth, a good rule is to never modify self.position directly, + # but instead modify self.velocity when needed. + # + # The second golden principle is to be lazy and not do anything if the collision will + # resolve itself naturally in a few frames, that is, if the bubble is already moving + # away from the wall. + + collided = self.how_colliding_border(int(self.is_rect)) + if collided[0]: + change = min(self.velocity.x * 0.25, -self._fix_force) + self.velocity.x -= change + + if collided[1]: + change = max(self.velocity.x * 0.25, self._fix_force) + self.velocity.x -= change + + if collided[2]: + change = min(self.velocity.y * 0.25, -self._fix_force) + self.velocity.y -= change + + if collided[3]: + change = max(self.velocity.y * 0.25, self._fix_force) + self.velocity.y -= change + + def collide_rect_rect(self, other: "Bubble") -> Optional["Collision"]: + collisions = 0 + + for l1 in self.get_lines(): + for l2 in other.get_lines(): + if line_intersect(l1[0], l1[1], l2[0], l2[1]): + collisions += 1 + + if collisions == 0: + return None + + return self._generate_collision(other) + + def collide_circle_line(self, p1: pygame.Vector2, p2: pygame.Vector2): + angle = p2 - p1 + angle /= angle.length() + + cross1 = perp_vec(angle) * self.radius + cross2 = counter_perp_vec(angle) * self.radius + cross1 += self.position + cross2 += self.position + return line_intersect(p1, p2, cross1, cross2) + + def collide_rect_line(self, p1: pygame.Vector2, p2: pygame.Vector2): + for el in self.get_lines(): + if line_intersect(el[0], el[1], p1, p2): + return True + return False + + def collide_circle_rect(self, other: "Bubble") -> Optional["Collision"]: + collisions = 0 + + for line in other.get_lines(): + if self.collide_circle_line(line[0], line[1]): + collisions += 1 + + if collisions == 0: + return None + + return self._generate_collision(other) + + def collide_circle_circle(self, other: "Bubble") -> Optional["Collision"]: + diff = other.position - self.position + diff_len = diff.length() + if diff_len <= self.radius + other.radius: + return self._generate_collision(other) + return None + + def _generate_collision(self, other: "Bubble") -> Optional["Collision"]: + diff = other.position - self.position + diff_len = diff.length() + left_to_right_way = diff / diff_len + right_to_left_way = left_to_right_way * -1 + left_collided_point = self.position + left_to_right_way * self.radius + right_collided_point = other.position + right_to_left_way * other.radius + center_collision_point = (right_collided_point - left_collided_point) / 2 + left_collided_point + + rotated_way = perp_vec(diff) + rotated_way = rotated_way / rotated_way.length() + + debug.vector(rotated_way * 20, center_collision_point, "blue") + + return Collision(self, other, center_collision_point, rotated_way) + + def collide(self, other: "Bubble") -> Optional["Collision"]: + """Get the collision data if there is a collision with the other Bubble""" + if self.is_rect: + if other.is_rect: + return self.collide_rect_rect(other) + else: + return self.collide_circle_rect(other) + else: + if other.is_rect: + return other.collide_circle_rect(self) + else: + return self.collide_circle_circle(other) + + +# The second challenge contains two parts. +# The first one is to generate a list of all the collisions +# between bubbles. +# The data for a collision is stored into the Collision class below, +# and is generated by the Bubble.collide method above. +# The second part is then to process those collision data, and resolve them. + + +@dataclass +class Collision: + """ + The data of a collision consist of four attributes. + + [first] and [second] are the the two objects that collided. + [center] is the collision point, that is, the point from which you + would like to push both circles away from. It corresponds to the center + of the overlapping area of the two moving circles, which is also the + midpoint between the two centers. + [normal] is the axis along which the two circles should bounce. That is, + if two bubbles move horizontally they bounce against the vertical axis, + so normal would be a vertical vector. + """ + + first: "Bubble" + second: "Bubble" + center: pygame.Vector2 + normal: pygame.Vector2 + + def resolve(self): + """Apply a force on both colliding object to help them move out of collision.""" + + # The second part of the Ambitious challenge is to resolve the collisions that we have collected. + # (See below in World.logic for how all this is put together). + + # Resolving a collision, here, means to modify the velocity of the two bubbles + # so that they move out of collision. Not necessarly in one frame, but if + # they move away from each other for say 2-5 frames, the collision will be resolved. + + # To do so, add a force to the velocity of each bubble to help the two bubbles to separate. + # The separating force is perpendicular to the normal, similarly to how bubbles bounce + # against a wall: only the part of the velocity perpendicular to the wall is reflected. + # Keep in mind that one bubble an have multiple collisions at the same time. + # You may need to define extra methods. + # If you have troubles handling the mass of the particles, start by assuming they + # have a mass of 1, and then upgrade your code to take the mass into account. + + self.apply_rotation(0) + self.apply_rotation(1) + + v1 = perp_vec(self.normal * self.first.velocity.length()) * self.second.mass + v2 = counter_perp_vec(self.normal * self.second.velocity.length()) * self.first.mass + self.first.velocity += v1 + self.second.velocity += v2 + + def apply_rotation(self, who: int): + him = self.second if who else self.first + her = self.first if who else self.second + central = her.position - him.position + angle = her.velocity.angle_to(central) + him.rotation_speed += angle + + +# The world is a list of bubbles. +class World(List[Bubble]): + def __init__(self, nb): + super().__init__(Bubble() for _ in range(nb)) + + def logic(self, mouse_position: pygame.Vector2): + """Handles the collision and evolution of all the objects.""" + + # Second part of the ambitious challenge is to make the algorithm that solves the collisions. + # A part of it is already provided so that you can focus on the important part. + + # We start by moving the bubbles and do the collisions with the static objects, the walls. + for bubble in self: + bubble.move() + bubble.collide_borders() + bubble.move_away_from_mouse(mouse_position) + + # Then we check each pair of bubbles to collect all collisions. + collisions = [] + for i, b1 in enumerate(self): + for b2 in self[i + 1:]: + collision = b1.collide(b2) + if collision: + collisions.append(collision) + + # And finally we resolve them all at once, so that it doesn't impact the detection of collision. + for collision in collisions: + collision.resolve() + + def draw(self, screen): + for bubble in self: + bubble.draw(screen) + + +def mainloop(): + pygame.init() + + world = World(NB_BUBBLES) + + mouse_position = pygame.Vector2() + + fps_counter = FpsCounter(60, Bubbles=world) + while True: + screen, events = yield + for event in events: + if event.type == pygame.QUIT: + return + elif event.type == pygame.MOUSEMOTION: + mouse_position.xy = event.pos + elif event.type == pygame.MOUSEBUTTONDOWN: + world.append(Bubble(event.pos)) + debug.handle_event(event) + fps_counter.handle_event(event) + + if pygame.key.get_pressed()[pygame.K_SPACE]: + collisions = [] + for i, b1 in enumerate(world): + for b2 in world[i + 1:]: + collision = b1.collide(b2) + if collision: + collisions.append(collision) + else: + # Handle the collisions + world.logic(mouse_position) + fps_counter.logic() + + if pygame.key.get_pressed()[pygame.K_c]: + world.clear() + + # Drawing the screen + screen.fill(BACKGROUND) + world.draw(screen) + fps_counter.draw(screen) + debug.draw(screen) + + +# ---- Recommended: don't modify anything below this line ---- # +if __name__ == "__main__": + try: + # Note: your editor might say that this is an error, but it's not. + # Most editors can't understand that we are messing with the path. + import wclib + except ImportError: + # wclib may not be in the path because of the architecture + # of all the challenges and the fact that there are many + # way to run them (through the showcase, or on their own) + ROOT_FOLDER = Path(__file__).absolute().parent.parent.parent + sys.path.append(str(ROOT_FOLDER)) + import wclib + + wclib.run(mainloop()) diff --git a/04-bouncing-bubbles/polastyn/metadata.py b/04-bouncing-bubbles/polastyn/metadata.py new file mode 100644 index 0000000..aa5e1cf --- /dev/null +++ b/04-bouncing-bubbles/polastyn/metadata.py @@ -0,0 +1,36 @@ +# Do not change the class name. +class Metadata: + # Your discord name and tag, so that we can award you the points + # in the leaderboards. + discord_tag = "polastyn2030#7640" + + # The name that should be diplayed below your entry in the menu. + display_name = "polastyn" + + # All the challenges that you think you have achieved. + # Uncomment each one that you have done and not only the highest. + achievements = [ + "Casual", + "Ambitious", + "Adventurous", + ] + + # The lowest python version on which your code can run. + # It is specified as a tuple, so (3, 7) mean python 3.7 + # If you don't know how retro-compatible your code is, + # set this to your python version. + # In order to have the most people to run your entry, try to + # keep the minimum version as low as possible (ie. don't use + # match, := etc... + min_python_version = (3, 7) + + # A list of all the modules that one should install + # to run your entry. Each string is what you would pass to + # the import statement. + # Each line will be passed to pip install if needed, but you cannot + # (yet?) specify version constraints. Modules that have a different name + # on install and import are also not supported. If you need it, + # please open an issue on the GitHub. + dependencies = [ + # "numpy", + ] diff --git a/04-bouncing-bubbles/polastyn/utils.py b/04-bouncing-bubbles/polastyn/utils.py new file mode 100644 index 0000000..5ed757e --- /dev/null +++ b/04-bouncing-bubbles/polastyn/utils.py @@ -0,0 +1,266 @@ +import time +from collections import deque +from functools import lru_cache +from pathlib import Path +from typing import Tuple, Sized + +import pygame + +from wclib.constants import SIZE, ROOT_DIR + +__all__ = [ + "SIZE", + "SUBMISSION_DIR", + "ASSETS", + "SCREEN", + "load_image", + "text", + "chrange", + "FpsCounter", + "debug", +] + +SUBMISSION_DIR = Path(__file__).parent +ASSETS = SUBMISSION_DIR.parent / "assets" +SCREEN = pygame.Rect(0, 0, *SIZE) + + +@lru_cache() +def load_image(name: str, scale=1, alpha=True, base: Path = ASSETS): + """Load an image from the global assets folder given its name. + + If [base] is given, load a n image from this folder instead. + For instance you can pass SUBMISSION_DIR to load an image from your own directory. + + If [scale] is not one, scales the images in both directions by the given factor. + + The function automatically calls convert_alpha() but if transparency is not needed, + one can set [alpha] to False to .convert() the image instead. + + The results are cached, so this function returns the same surface every time it + is called with the same arguments. If you want to modify the returned surface, + .copy() it first. + """ + + image = pygame.image.load(base / f"{name}.png") + if scale != 1: + new_size = int(image.get_width() * scale), int(image.get_height() * scale) + image = pygame.transform.scale(image, new_size) + + if alpha: + return image.convert_alpha() + else: + return image.convert() + + +@lru_cache() +def font(size=20, name=None): + """ + Load a font from its name in the wclib/assets folder. + + If a Path object is given as the name, this path will be used instead. + This way, you can use custom fonts that are inside your own folder. + Results are cached. + """ + + name = name or "regular" + if isinstance(name, Path): + path = name + else: + path = ROOT_DIR / "wclib" / "assets" / (name + ".ttf") + return pygame.font.Font(path, size) + + +@lru_cache(5000) +def text(txt, color, size=20, font_name=None): + """Render a text on a surface. Results are cached.""" + return font(size, font_name).render(str(txt), True, color) + + +def chrange( + x: float, + initial_range: Tuple[float, float], + target_range: Tuple[float, float], + power=1, + flipped=False, +): + """Change the range of a number by mapping the initial_range to target_range using a linear transformation.""" + normalised = (x - initial_range[0]) / (initial_range[1] - initial_range[0]) + normalised **= power + if flipped: + normalised = 1 - normalised + return normalised * (target_range[1] - target_range[0]) + target_range[0] + + +class FpsCounter: + """ + A wrapper around pygame.time.Clock that shows the FPS on screen. + + It can also show the lengths of different collections (nb of objects/particles...). + + Controls: + - [F] Toggles the display of FPS + - [U] Toggles the capping of FPS + """ + + Z = 1000 + REMEMBER = 30 + + def __init__(self, fps, **counters: Sized): + """ + Show and manage the FPS of the game. + + Args: + fps: the desired number of frames per second. + **counters: pairs of labels and collections + whose size will be displayed. + """ + + self.hidden = False + self.cap_fps = True + self.target_fps = fps + self.clock = pygame.time.Clock() + self.frame_starts = deque([time.time()], maxlen=self.REMEMBER) + self.counters = counters + + def handle_event(self, event): + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_f: + self.hidden = not self.hidden + elif event.key == pygame.K_u: + self.cap_fps = not self.cap_fps + + def logic(self, **kwargs): + # Passing 0 to tick() removes the cap on FPS. + self.clock.tick(self.target_fps * self.cap_fps) + + self.frame_starts.append(time.time()) + + @property + def current_fps(self): + if len(self.frame_starts) <= 1: + return 0 + seconds = self.frame_starts[-1] - self.frame_starts[0] + return (len(self.frame_starts) - 1) / seconds + + def draw(self, screen): + if self.hidden: + return + + color = "#89C4F4" + t = text(f"FPS: {int(self.current_fps)}", color) + r = screen.blit(t, t.get_rect(topleft=(15, 15))) + + for label, collection in self.counters.items(): + t = text(f"{label}: {len(collection)}", color) + r = screen.blit(t, r.bottomleft) + + +class Debug: + """ + This class helps with graphical debuging. + It allows to draw points, vectors, rectangles and text + on top of the window at any moment of the execution. + + You can use this from any function to visualise vectors, + intermediates computations and anything that you would like to know + the value without printing it. + It is + + All debug drawing disapear after one frame, except the texts + for which the last [texts_to_keep] stay on the screen so that there + is sufficient time to read them. + + All debug methods return their arguments so that can be chained. + For instance, you can write: + + >>> debug = Debug() + >>> pos += debug.vector(velocity, pos) + + Which is equivalent to: + + >>> pos += velocity + But also draws the [velocity] vector centered at [pos] so that you see it. + """ + + def __init__(self, texts_to_keep=20): + self.texts_to_keep = texts_to_keep + + self.points = [] + self.vectors = [] + self.rects = [] + self.texts = [] + self.nb_txt_this_frame = 0 + + # Backup to restore if the game is paused, + # this way, anotations are not lost when objects + # are not updated anymore. + self.lasts = [[], [], [], []] + + self.enabled = False + self.paused = False + + def point(self, x, y, color="red"): + """Draw a point on the screen.""" + if self.enabled: + self.points.append((x, y, color)) + return x, y + + def vector(self, vec, anchor, color="red", scale=1): + """Draw a vector centered at [anchor] on the next frame. + It can be useful to [scale] if the expected length is too small or too large.""" + if self.enabled: + self.vectors.append((pygame.Vector2(anchor), pygame.Vector2(vec) * scale, color)) + return vec + + def rectangle(self, rect, color="red"): + """Draw a rectangle on the next frame.""" + if self.enabled: + self.rects.append((rect, color)) + return rect + + def text(self, *obj): + """Draw a text on the screen until there too many texts.""" + if self.enabled: + self.texts.append(obj) + self.nb_txt_this_frame += 1 + + def handle_event(self, event): + if event.type == pygame.KEYDOWN and event.key == pygame.K_d: + self.enabled = not self.enabled + + def draw(self, screen: pygame.Surface): + if not self.enabled: + return + + if self.paused: + self.points, self.vectors, self.rects, self.texts = self.lasts + + for (x, y, color) in self.points: + pygame.draw.circle(screen, color, (x, y), 1) + + for (anchor, vec, color) in self.vectors: + pygame.draw.line(screen, color, anchor, anchor + vec) + + for rect, color in self.rects: + pygame.draw.rect(screen, color, rect, 1) + + y = SIZE[1] - 15 + for i, obj in enumerate(self.texts): + color = "white" if len(self.texts) - i - 1 >= self.nb_txt_this_frame else "yellow" + s = text(" ".join(map(str, obj)), color) + r = screen.blit(s, s.get_rect(bottomleft=(15, y))) + y = r.top + + # Clear everything for the next frame. + self.lasts = [self.points, self.vectors, self.rects, self.texts] + self.points = [] + self.vectors = [] + self.rects = [] + self.texts = self.texts[-self.texts_to_keep:] + if not self.paused: + self.nb_txt_this_frame = 0 + + +# Global debug instance, accessible from everywhere. +debug = Debug()