diff --git a/02-particle-system/bydariogamer/objects.py b/02-particle-system/bydariogamer/objects.py index f09ae63..8c5ecb3 100644 --- a/02-particle-system/bydariogamer/objects.py +++ b/02-particle-system/bydariogamer/objects.py @@ -14,7 +14,7 @@ from functools import lru_cache from random import gauss, choices, uniform, randint from operator import attrgetter - +from typing import Optional import pygame import pygame.gfxdraw import numpy @@ -80,7 +80,7 @@ class Object: 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.state: Optional["State"] = None self.center = pygame.Vector2(pos) self.vel = pygame.Vector2(vel) self.sprite = sprite @@ -403,8 +403,8 @@ class ParticleManager: __slots__ = ("particles",) - LIMITER = 5000 - MULTIPLIER = 50 + LIMITER = 0 + MULTIPLIER = 2 RANDOMNESS = 1 TEN_THOUSAND_RANDOMNESS = 10000 * RANDOMNESS NORMAL_VALUES: list = numpy.random.default_rng().normal(size=TEN_THOUSAND_RANDOMNESS).tolist() @@ -453,6 +453,27 @@ def random_color(): r, g, b = hsv_to_rgb(uniform(0, 1), 0.8, 0.8) return pygame.Color(int(r * 255), int(g * 255), int(b * 255)) + @staticmethod + def break_surface(surface): + points = [ + [ + randint(0, surface.get_width()), + randint(0, surface.get_height()), + ] + for _ in range(3) + ] + surf = pygame.Surface(surface.get_size(), pygame.SRCALPHA) + pygame.draw.polygon(surf, (255, 255, 255), points) + surface.blit(surf, (0, 0), special_flags=pygame.BLEND_RGBA_MULT) + # extracting only the non-transparent portion + all_x = [i[0] for i in points] + all_y = [i[1] for i in points] + x1 = min(all_x) + x2 = max(all_x) + y1 = min(all_y) + y2 = max(all_y) + return surface.subsurface(pygame.Rect(x1, y1, x2 - x1, y2 - y1)).copy() + def __init__(self): self.particles = set() @@ -496,7 +517,7 @@ def burst(self, pos, vel, color=None, level=1): BouncingParticle( pos, self.randomize_vel(2 * vel.rotate(i * 360 / n)), - 60, + 30, self.random_color(), 4, ) @@ -522,13 +543,15 @@ def burst(self, pos, vel, color=None, level=1): ) ) - def explode(self, asteroid: Object): - return - i = 0 - for surface in break_surface(asteroid.rotated_sprite, 4): - surface.set_colorkey((0, 0, 0)) + def explode(self, asteroid: Asteroid): + for i in range(asteroid.level): self.particles.add( - SurfaceParticle(asteroid.center, 2 * asteroid.vel.rotate(i * 16 / 30), 100, surface) + BrokenSurfaceParticle( + pygame.Vector2(asteroid.center), + pygame.Vector2(2 * ParticleManager.gauss()).rotate(i * 180 / asteroid.level), + 200, + asteroid.sprite + ) ) def firetrail(self, pos, vel, color=pygame.Color(255, 165, 0)): @@ -598,6 +621,10 @@ def __init__(self, pos, vel, life, color, size): super().__init__(pos, vel, life, color, size) def logic(self): + self.age += 1 + self.pos += self.vel + self.vel *= 0.9 + self.color -= self.decay if not (0 < self.pos.x < SIZE[0]): self.vel.x *= -1 if 0 > self.pos.x: @@ -611,9 +638,6 @@ def logic(self): if SIZE[0] < self.pos.y: self.pos.y = SIZE[1] - self.age += 1 - self.pos += self.vel - class ShootParticle(Particle): def __init__(self, pos, vel, life, color, size): @@ -631,15 +655,16 @@ def draw(self, display): class SurfaceParticle: - __slots__ = ("pos", "vel", "life", "age", "surface", "rotation") + __slots__ = ("pos", "vel", "life", "age", "surface", "rotation", "bounce") - def __init__(self, pos, vel, life, surface): + def __init__(self, pos, vel, life, surface, bounce=False): self.pos = pos self.vel = vel self.life = life self.age = 0 self.surface: pygame.Surface = surface self.rotation = 0 + self.bounce = bounce @property def rotated_sprite(self): @@ -648,11 +673,27 @@ def rotated_sprite(self): def logic(self): self.age += 1 self.pos += self.vel - if 0 < self.pos.x < SIZE[0]: - self.vel.x *= -1 - if 0 < self.pos.y < SIZE[1]: - self.vel.y *= -1 - self.surface.set_alpha(255 * self.age // self.life) + self.rotation += 360 / self.life + self.surface.set_alpha(255 * (1 - self.age / self.life)) + if self.bounce: + if not (0 < self.pos.x < SIZE[0]): + self.vel.x *= -1 + if 0 > self.pos.x: + self.pos.x = 0 + if SIZE[0] < self.pos.x: + self.pos.x = SIZE[0] + if not (0 < self.pos.y < SIZE[1]): + self.vel.y *= -1 + if 0 > self.pos.y: + self.pos.y = 0 + if SIZE[0] < self.pos.y: + self.pos.y = SIZE[1] def draw(self, screen): - screen.blit(self.rotated_sprite, self.pos) + screen.blit(self.rotated_sprite, self.rotated_sprite.get_rect(center=self.pos)) + + +class BrokenSurfaceParticle(SurfaceParticle): + + def __init__(self, pos, vel, life, surface, bounce=False): + super().__init__(pos, vel, life, ParticleManager.break_surface(surface), bounce) diff --git a/02-particle-system/bydariogamer/utils.py b/02-particle-system/bydariogamer/utils.py index e32034a..32bc36e 100644 --- a/02-particle-system/bydariogamer/utils.py +++ b/02-particle-system/bydariogamer/utils.py @@ -22,7 +22,6 @@ "text", "randomize_color", "randomize_vel", - "break_surface", ] SUBMISSION_DIR = Path(__file__).parent @@ -115,18 +114,3 @@ def randomize_color(color): def randomize_vel(vel): return random.gauss(1, 0.5) * vel.rotate(random.gauss(0, 10)) - - -@lru_cache() -def break_surface(surf, divs): - # return [ - # pygame.surfarray.make_surface(array) - surfaces = [] - for nested_list in ( - numpy.hsplit(vertical, divs) - for vertical in numpy.vsplit(pygame.surfarray.array2d(surf), divs) - ): - for array in nested_list: - surfaces.append(pygame.surfarray.make_surface(array)) - - return surfaces diff --git a/04-bouncing-bubbles/bydariogamer/main.py b/04-bouncing-bubbles/bydariogamer/main.py new file mode 100644 index 0000000..9ae2ae1 --- /dev/null +++ b/04-bouncing-bubbles/bydariogamer/main.py @@ -0,0 +1,398 @@ +import sys +from pathlib import Path +from random import gauss, uniform, randint +from typing import List, Optional + +import math + +import pygame +import pygame.gfxdraw + +# 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 * + +BACKGROUND = 0x0F1012 +NB_BUBBLES = 20 + + +class Bubble: + AVERAGE_RECURSIVITY = 0 + RECURSIVITY_RANDOMNESS = 0 + MAX_RECURSIVITY = 0 + AVERAGE_RADIUS = 50 + RADIUS_RANDOMNESS = 5 + BOUNCE_VELOCITY = 0.4 + + def __init__( + self, + position: Optional[pygame.Vector2] = None, + parent: "Bubble" = None, + ): + self.depth = (parent.depth if parent else 0) + 1 + self.parent = parent + self.radius = abs(int(gauss(self.AVERAGE_RADIUS, self.RADIUS_RANDOMNESS))) + 1 + self.bounce = 0 + if parent: + self.radius = abs( + int( + parent.radius // 3 + randint( + -self.RADIUS_RANDOMNESS//self.depth, self.RADIUS_RANDOMNESS//self.depth + ) + ) + ) + 1 + self.inner = ( + World( + abs(int(gauss(self.AVERAGE_RECURSIVITY, self.RECURSIVITY_RANDOMNESS))), + self, + ) + if self.depth <= self.MAX_RECURSIVITY + else None + ) + self.size = 2 * (parent.radius * 2,) if parent else SIZE + + if position is None: + # Default position is random. + self.position = pygame.Vector2( + randint( + self.radius, + (self.size[0] if not parent else 2 * parent.radius) - self.radius, + ), + randint(self.radius, (self.size[1]) - self.radius), + ) + else: + self.position = position + + # Set a random direction and a speed of around World.HEAT. + self.velocity = pygame.Vector2() + self.velocity.from_polar((gauss(World.HEAT, 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, 100, 100 + + self.to_resolve: Optional[Collision] = None + + @property + def mass(self) -> float: + return 1 if not self.radius else self.radius ** 2 + + @property + def temperature(self): + return self.velocity.length() + + @property + def absolute_position(self): + if self.parent: + return ( + self.position + + self.parent.absolute_position + - pygame.Vector2(self.parent.radius) + ) + else: + return self.position + + def draw(self, screen: pygame.Surface): + pygame.gfxdraw.circle( + screen, + int(self.absolute_position.x), + int(self.absolute_position.y), + int(self.radius + 3*math.sin(self.bounce)) if self.radius + self.bounce > 1 else 1, + self.color, + ) + if self.inner: + for bubble in self.inner: + bubble.draw(screen) + + 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), (World.HEAT, 0), power=2) + self.velocity -= bubble_to_mouse.normalize() * strength + + def move(self, paused=False): + """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() > ( + World.HEAT - self.depth if self.velocity.length() > 0 else 1 + ): + self.velocity.scale_to_length(World.HEAT) + + if paused: + return + + self.bounce += self.BOUNCE_VELOCITY + self.position += self.velocity + debug.vector(self.velocity, self.absolute_position, scale=10) + + if self.inner: + for bubble in self.inner: + bubble.move(paused) + + def collide_borders(self): + if self.radius > self.position.x and self.velocity.x < 0: + self.velocity.x *= -1 + self.velocity.x += 1 + World.HEAT + self.velocity.scale_to_length(self.velocity.length() - World.FRICTION) + elif self.position.x > self.size[0] - self.radius and self.velocity.x > 0: + self.velocity.x *= -1 + self.velocity.x -= 1 + World.HEAT + self.velocity.scale_to_length(self.velocity.length() - World.FRICTION) + if self.radius > self.position.y and self.velocity.y < 0: + self.velocity.y *= -1 + self.velocity.y += 1 + World.HEAT + self.velocity.scale_to_length(self.velocity.length() - World.FRICTION) + elif self.position.y > self.size[1] - self.radius and self.velocity.y > 0: + self.velocity.y *= -1 + self.velocity.y -= 1 + World.HEAT + self.velocity.scale_to_length(self.velocity.length() - World.FRICTION) + if self.depth > 1: + if ( + self.position.distance_to(pygame.Vector2(self.size[0] / 2)) + + self.radius + > self.size[0] / 2 + ): + self.velocity += pygame.Vector2(self.size)/2 - self.position + self.velocity.scale_to_length(self.velocity.length() + World.HEAT - World.FRICTION) + if self.inner: + for bubble in self.inner: + bubble.collide_borders() + + def collide(self, other: "Bubble"): + """Get the collision data if there is a collision with the other Bubble""" + if self.radius + other.radius > (self.position - other.position).length(): + self.to_resolve = Collision(self, other) + if self.inner: + self.inner.collide_bubbles() + + def resolve_collision(self): + if self.to_resolve: + self.to_resolve.resolve() + self.to_resolve = None + if self.inner: + for bubble in self.inner: + bubble.resolve_collision() + + +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. + """ + + def __init__( + self, + first: "Bubble", + second: "Bubble", + ): + self.first = first + self.second = second + + def resolve(self): + """Apply a force on both colliding object to help them move out of collision.""" + + self.first.velocity += ( + -2 + * self.second.mass + / (self.first.mass + self.second.mass) + * (self.first.velocity - self.second.velocity).dot( + (self.first.position - self.second.position) + ) + / (self.first.position - self.second.position).length_squared() + * (self.first.position - self.second.position) + ) + # self.first.velocity = self.first.velocity - 2 * self.second.mass / ( + # self.first.mass + self.second.mass + # ) * (self.first.velocity - self.second.velocity).dot( + # (self.first.position - self.second.position) + # ) / ( + # self.first.position - self.second.position + # ).length_squared() * ( + # self.first.position - self.second.position + # ) + + self.first.velocity = ( + self.first.velocity + + (self.second.position - self.first.position).normalize() + * ( + (self.second.position - self.first.position).length() + - (self.first.radius + self.second.radius) + ) + ).normalize() * (self.first.velocity.length()) + + self.second.velocity += ( + -2 + * self.first.mass + / (self.second.mass + self.first.mass) + * (self.second.velocity - self.first.velocity).dot( + (self.second.position - self.first.position) + ) + / (self.second.position - self.first.position).length_squared() + * (self.second.position - self.first.position) + ) + # self.second.velocity = self.second.velocity - 2 * self.first.mass / ( + # self.second.mass + self.first.mass + # ) * (self.second.velocity - self.first.velocity).dot( + # (self.second.position - self.first.position) + # ) / ( + # self.second.position - self.first.position + # ).length_squared() * ( + # self.second.position - self.first.position + # ) + + self.second.velocity = ( + self.second.velocity + + (self.first.position - self.second.position).normalize() + * ( + (self.first.position - self.second.position).length() + - (self.second.radius + self.first.radius) + ) + ).normalize() * (self.second.velocity.length()) + + +# The world is a list of bubbles. +class World(List[Bubble]): + FRICTION = 1 + HEAT = 3 + + def __init__(self, nb, parent=None): + super().__init__(Bubble(parent=parent) for _ in range(nb)) + + def logic(self, mouse_position: pygame.Vector2, paused=False): + """Handles the collision and evolution of all the objects.""" + for bubble in self: + bubble.move(paused) + bubble.collide_borders() + bubble.move_away_from_mouse(mouse_position) + + self.collide_bubbles() + for bubble in self: + bubble.resolve_collision() + + def collide_bubbles(self): + for i, b1 in enumerate(self): + for b2 in self[i + 1 :]: + b1.collide(b2) + + def draw(self, screen): + for bubble in self: + bubble.draw(screen) + + @property + def temperature(self): + return int(sum((bubble.temperature for bubble in self))) + + @property + def interaction(self): + return len(self) * (len(self) + 1) // 2 + sum( + bubble.inner.interaction for bubble in self if bubble.inner + ) + + +def mainloop(): + """ + Controls: + [→]: increase FRICTION + [←]: decrease FRICTION + [↑]: increase HEAT + [↓]: decrease HEAT + [P]: pause + """ + pygame.init() + + world = World(NB_BUBBLES) + mouse_position = pygame.Vector2() + fps_counter = FpsCounter(60, Bubbles=world) + + show_parameters = True + paused = False + + 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, parent=None)) + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_h: + show_parameters = not show_parameters + elif event.key == pygame.K_UP: + World.HEAT += 1 + if World.HEAT > 100: + World.HEAT = 100 + elif event.key == pygame.K_DOWN: + World.HEAT -= 1 + if World.HEAT <= 1: + World.HEAT = 1 + if World.FRICTION > World.HEAT: + World.FRICTION = World.HEAT + elif event.key == pygame.K_RIGHT: + World.FRICTION += 1 + if World.FRICTION > World.HEAT: + World.FRICTION = World.HEAT + elif event.key == pygame.K_LEFT: + World.FRICTION -= 1 + if World.FRICTION < 0: + World.FRICTION = 0 + elif event.key == pygame.K_p: + paused = not paused + + debug.handle_event(event) + fps_counter.handle_event(event) + + # Handle the collisions + world.logic(mouse_position, paused) + fps_counter.logic() + + # Drawing the screen + screen.fill(BACKGROUND) + pygame.draw.circle( + screen, (50 + 2 * World.HEAT, 10, 10), pygame.mouse.get_pos(), 50 + ) + world.draw(screen) + fps_counter.draw(screen) + if show_parameters: + color = "#89C4F4" + t = text(f"WALL HEAT: {World.HEAT}", color) + r = screen.blit(t, t.get_rect(topright=(SIZE[0] - 15, 15))) + t = text(f"FRICTION: {World.FRICTION}", color) + r = screen.blit(t, t.get_rect(topright=(SIZE[0] - 15, r.bottom))) + t = text(f"TEMPERATURE: {world.temperature}", color) + r = screen.blit(t, t.get_rect(topright=(SIZE[0] - 15, r.bottom))) + t = text(f"INTERACTIONS: {world.interaction}", color) + screen.blit(t, t.get_rect(topright=(SIZE[0] - 15, r.bottom))) + 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/bydariogamer/metadata.py b/04-bouncing-bubbles/bydariogamer/metadata.py new file mode 100644 index 0000000..013388c --- /dev/null +++ b/04-bouncing-bubbles/bydariogamer/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 = "bydariogamer#7949" + + # The name that should be diplayed below your entry in the menu. + display_name = "bydariogamer" + + # 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/bydariogamer/utils.py b/04-bouncing-bubbles/bydariogamer/utils.py new file mode 100644 index 0000000..dc8a15c --- /dev/null +++ b/04-bouncing-bubbles/bydariogamer/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()