diff --git a/Boids.7z b/Boids.7z new file mode 100644 index 0000000..4fc5fcc Binary files /dev/null and b/Boids.7z differ diff --git a/__pycache__/lpgf.cpython-311.pyc b/__pycache__/lpgf.cpython-311.pyc new file mode 100644 index 0000000..ad0f41c Binary files /dev/null and b/__pycache__/lpgf.cpython-311.pyc differ diff --git a/boid.py b/boid.py new file mode 100644 index 0000000..a69af19 --- /dev/null +++ b/boid.py @@ -0,0 +1,244 @@ +import pygame as pg +from math import sqrt, ceil, floor +from random import random, randint +from lpgf import Pane, DynamicPane, isoceles_triangle + +SCREEN_DIMENSIONS = (1500, 1000) +PADDING_CONSTANT = 0.75 +FRAME_RATE = 144 +N_BOIDS = 250 + +def clamp(x, min, max) -> int: + if x < min: + return min + if x > max: + return max + return x + +def surface_color(position: pg.Vector2, surf: pg.Surface) -> tuple[int,int,int]: + rect = surf.get_rect() + x = clamp(position.x, 0, rect.width) + y = clamp(position.y, 0, rect.height) + return ( + ceil(255 * (x / rect.width)), + ceil(255 * (y / rect.height)), + 100 + ) + +class Boid: + + def __init__( + self, + search_radius: float, + separation_radius: float, + max_speed: float, + cohesion: float, + alignment: float, + separation: float, + bounds: pg.Rect, + image: pg.Surface = isoceles_triangle((15,10)) #this is the same triangle instance on purpose + ): + self.position = pg.Vector2(randint(bounds.left, bounds.right), randint(bounds.top, bounds.bottom)) + self.velocity = pg.Vector2(2*(random()-0.5)*max_speed, 2*(random()-0.5)*max_speed) #Know this can technically produce velicities higher than max, dont care + self._acceleration = pg.Vector2(0,0) + self._max_speed = max_speed + self.cohesion = cohesion + self.alignment = alignment + self.separation = separation + self.bounds = bounds + self._radius_squared = search_radius**2 + self._separation_radius_squared = separation_radius**2 + self._image = pg.transform.scale_by(image, random() * 0.5 + 0.5) + + @property + def search_radius(self): + return sqrt(self._radius_squared) + + @search_radius.setter + def search_radius(self, rad: float): + self._radius_squared = rad**2 + + @property + def separation_radius(self): + return sqrt(self._separation_radius_squared) + + @separation_radius.setter + def separation_radius(self, speed: float): + self._max_speed_squared = speed**2 + + def calculate_next_timestep(self, boids: list['Boid']): + centroid_target = pg.Vector2(0,0) + velocity_target = pg.Vector2(0,0) + nearby = 0 + + for b in boids: + if (self.position-b.position).length_squared() > self._radius_squared or b is self: + continue + centroid_target += b.position + velocity_target += b.velocity + nearby += 1 + if (self.position - b.position).length_squared() < self._separation_radius_squared: + self._acceleration += self.separation * (self.position - b.position).normalize() + + if nearby > 0: + centroid_target /= nearby + velocity_target /= nearby + self._acceleration += self.cohesion * (centroid_target - self.position).normalize() + self._acceleration += self.alignment * velocity_target.normalize() + + if self.position.y < self.bounds.top: + self._acceleration += pg.Vector2(0,150) + elif self.position.y > self.bounds.bottom: + self._acceleration -= pg.Vector2(0,150) + + if self.position.x < self.bounds.left: + self._acceleration += pg.Vector2(150,0) + elif self.position.x > self.bounds.right: + self._acceleration -= pg.Vector2(150,0) + + def attract(self, p: pg.Vector2, radius: float, strength: float) -> None: + diff = p - self.position + if diff.length_squared() < radius**2: + self._acceleration += strength * diff.normalize() + + def update(self, dt: float) -> None: + self.velocity += dt * self._acceleration + if self.velocity.length_squared() > self._max_speed**2: + self.velocity.scale_to_length(self._max_speed) + self.position += self.velocity * dt + self._acceleration = pg.Vector2(0,0) + + def draw(self, surface: pg.Surface): + image = self._image.copy() #as per pygame docs, do not continuously rotate an image. Make a copy of a prefab image + image.fill(surface_color(self.position, surface), special_flags=pg.BLEND_ADD) + image = pg.transform.rotate(image, self.velocity.angle_to(pg.Vector2(1,0))) + surface.blit(image, self.position) + +class BoidSim: + + def set_attractor(self, m_pos): + self._attractor = m_pos + + def clear_attractor(self, _): + self._attractor = None + + def set_repulsor(self, m_pos): + self._repulsor = m_pos + + def clear_repulsor(self, _): + self._repulsor = None + + def clear_all(self, m_pos): + self.clear_repulsor(m_pos) + self.clear_attractor(m_pos) + + def spawn(self, m_pos): + boid = Boid( + 150, + 20, + 100, + 80, + 80, + 100, + self._pane.get_rect().scale_by(0.75), + ) + boid.position = pg.Vector2(m_pos) + self._boids.append(boid) + + def __init__(self, n_boids: int, pane: Pane): + self._pane: Pane = pane + self._pane.on_click_held = self.set_attractor + self._pane.on_click_release = self.clear_attractor + self._pane.on_right_click = self.set_repulsor + self._pane.on_right_click_release = self.clear_repulsor + self._pane.on_middle_click = self.spawn + self._pane.on_mouse_exit = self.clear_all + self._attractor: tuple[int,int] | None = None + self._repulsor: tuple[int,int] | None = None + self._boids: list[Boid] = [] + bounds = pane.get_rect_position_zero().scale_by(0.75) + for _ in range(n_boids): + self._boids.append( + Boid( + 150, + 20, + 100, + 80, + 80, + 100, + bounds, + ) + ) + + def update(self, dt: float): + for boid in self._boids: + boid.calculate_next_timestep(self._boids) + if self._attractor: + boid.attract(self._attractor, 200, 1000) + if self._repulsor: + boid.attract(self._repulsor, 200, -1000) + boid.update(dt) + boid.draw(self._pane) + +def main(): + running = True + pg.init() + clock = pg.time.Clock() + + cat = pg.image.load("cat.png") + + screen: Pane = Pane.master_pane(SCREEN_DIMENSIONS, (0,0,255)) + boid_pane1 = DynamicPane( + (cat.get_rect().width, cat.get_rect().height), + (10,10), + screen, + 10, + blanking_color=(150,0,150), + background_image=cat + ) + + boid_pane2 = DynamicPane( + (200,200), + (250,250), + boid_pane1.content_pane, + 10, + blanking_color=(0,0,0,100), + ribbon_color=(0,50,100,180), + content_flags=pg.SRCALPHA, + ribbon_flags=pg.SRCALPHA + ) + + DynamicPane( + (100,100), + (0,0), + screen, + 10, + blanking_color=(255,255,255,0), + background_image=isoceles_triangle((100,90)), + content_flags=pg.SRCALPHA + ) + + sim1 = BoidSim(20, screen) + sim2 = BoidSim(20, boid_pane2.content_pane) + + while running: + screen.blank() + screen.update() + screen.poll_mouse() + dt = clock.tick(FRAME_RATE) / 1000 + print(screen._subpanes) + + events = pg.event.get() + + for event in events: + if event.type == pg.QUIT: + running = False + + sim1.update(dt) + sim2.update(dt) + + screen.draw() + pg.display.flip() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/cat.png b/cat.png new file mode 100644 index 0000000..49c4899 Binary files /dev/null and b/cat.png differ diff --git a/lpgf.py b/lpgf.py new file mode 100644 index 0000000..2f85ccc --- /dev/null +++ b/lpgf.py @@ -0,0 +1,269 @@ +from typing import Any +import pygame as pg +from math import ceil + +def isoceles_triangle(dimensions: tuple[int, int], color = (0,0,0,255)) -> pg.Surface: + surf = pg.Surface(dimensions, pg.SRCALPHA) + surf.fill((255,255,255,0)) + pg.draw.polygon( + surf, + color, + ( + (dimensions[0], dimensions[1] / 2), + (0,0), + (0, dimensions[1]) + ) + ) + return surf + +def circle(radius, color: tuple[int,int,int,int] = (0,0,0,255)) -> pg.Surface: + surf = pg.Surface((radius, radius), pg.SRCALPHA) + surf.fill((255,255,255,0)) + pg.draw.circle(surf, color, (radius / 2, radius / 2), radius) + return surf + +class Pane(pg.Surface): + + on_click = lambda self, m_pos: None + on_click_release = lambda self, m_pos: None + on_click_held = lambda self, m_pos: None + on_middle_click = lambda self, m_pos: None + on_middle_click_release = lambda self, m_pos: None + on_middle_click_held = lambda self, m_pos: None + on_right_click = lambda self, m_pos: None + on_right_click_release = lambda self, m_pos: None + on_right_click_held = lambda self, m_pos: None + on_mouse_move = lambda self, l_m_pos, m_pos: None + on_mouse_enter = lambda self, m_pos: None + on_mouse_exit = lambda self, m_pos: None + + def get_screen_position(self): + if isinstance(self._parent, Pane): + screen_position = self._parent.get_screen_position() + return ( + self.position[0] + screen_position[0], + self.position[1] + screen_position[1] + ) + return self.position + + + def get_screen_rect(self): + return self.get_rect().move(self.get_screen_position()) + + def get_rect(self) -> pg.Rect: + return super().get_rect().move(*self.position) + + def get_rect_position_zero(self) -> pg.Rect: + return super().get_rect() + + def master_pane(dimensions: tuple[int,int], blanking_color: tuple[int,int,int] = (0,0,0), **kwargs) -> 'Pane': + return Pane( + dimensions, + (0,0), + pg.display.set_mode(dimensions, **kwargs), + blanking_color=blanking_color, + **kwargs + ) + + def __init__( + self, + dimensions: tuple[int,int], + position: tuple[int, int], + parent: pg.Surface, + blanking_color: tuple[int,int,int] = (0,0,0), + background_image: pg.Surface | None = None, + flags=0 + ): + self._m_last_state = [False,False,False] + self._m_last_inframe = False + self._m_last_pos = pg.mouse.get_pos() + self.position = position + self.blanking_color = blanking_color + self._background_image = background_image + self._subpanes: list['Pane'] = [] + if isinstance(parent, Pane): + parent.add_subpane(self) + else: + self._parent = parent + super().__init__(dimensions, flags) + + def add_subpane(self, subpane: 'Pane') -> None: + subpane._parent = self + self._subpanes.append(subpane) + + def blank(self) -> None: + self.fill(self.blanking_color) + if self._background_image: + self.blit(self._background_image, (0,0)) + for subpane in self._subpanes: + subpane.blank() + + def poll_mouse(self, _m_upper_pane_position: tuple[int,int] | None = None, _occluded: bool = False) -> None: + if not _m_upper_pane_position: + _m_upper_pane_position = pg.mouse.get_pos() + m_pos = ( + _m_upper_pane_position[0] - self.position[0], + _m_upper_pane_position[1] - self.position[1] + ) + + for subpane in self._subpanes[::-1]: + subpane.poll_mouse(m_pos, _occluded) + if not _occluded and subpane.get_rect().collidepoint(m_pos): _occluded = True + + m_state = pg.mouse.get_pressed() + m_inframe = not _occluded and pg.mouse.get_focused() and self.get_rect_position_zero().collidepoint(m_pos) + + if not m_state[0] and self._m_last_state[0]: + self.on_click_release(m_pos) + self._m_last_state[0] = False + if not m_state[1] and self._m_last_state[1]: + self.on_middle_click_release(m_pos) + self._m_last_state[1] = False + if not m_state[2] and self._m_last_state[2]: + self.on_right_click_release(m_pos) + self._m_last_state[2] = False + + if m_inframe: + if m_state[0]: + self.on_click_held(m_pos) + if not self._m_last_state[0]: self.on_click(m_pos) + if m_state[1]: + self.on_middle_click_held(m_pos) + if not self._m_last_state[1]: self.on_middle_click(m_pos) + if m_state[2]: + self.on_right_click_held(m_pos) + if not self._m_last_state[2]: self.on_right_click(m_pos) + if m_pos != self._m_last_pos: + self.on_mouse_move(self._m_last_pos, m_pos) + + for index, state in enumerate(m_state): + if state: self._m_last_state[index] = True + + #this is done so the on_click_release callback is still called if mouse is out of frame + + if m_inframe and not self._m_last_inframe: + self.on_mouse_enter(m_pos) + elif not m_inframe and self._m_last_inframe: + self.on_mouse_exit(m_pos) + + self._m_last_inframe = m_inframe + self._m_last_pos = m_pos + + def update(self, **draw_options): + for subpane in self._subpanes: + subpane.update() + + def move_to_front(self, item): + if item not in self._subpanes: + return + self._subpanes.remove(item) + self._subpanes.append(item) + + def draw(self, special_flags: int = 0): + for subpane in self._subpanes: + subpane.draw() + self._parent.blit(self, self.position, special_flags=special_flags) + +class DynamicPane(Pane): + + @property + def content_pane(self): + return self._content_pane + + def _stick(self, _): + self._stuck = True + self._parent.move_to_front(self) + + def _unstick(self, _): + self._stuck = False + parent_rect = self._parent.get_rect() + self_rect = self.get_rect() + if not parent_rect.collidepoint(self_rect.center): + self.position = ( + parent_rect.center[0] - ceil(self_rect.width / 2), + parent_rect.center[1] - ceil(self_rect.height / 2) + ) + + def __init__( + self, + dimensions: tuple[int,int], + position: tuple[int,int,int], + parent: Pane, + ribbon_height: int, + ribbon_color: tuple[int,int,int] = (255,255,255), + blanking_color: tuple[int,int,int] = (0,0,0), + background_image: pg.Surface | None = None, + content_flags=0, + ribbon_flags=0 + ): + super().__init__( + dimensions, + position, + parent, + (255,255,255,0), + flags=pg.SRCALPHA + ) + self._ribbon: Pane = Pane( + (dimensions[0], ribbon_height), + (0,0), + self, + ribbon_color, + flags=ribbon_flags + ) + self._content_pane: Pane = Pane( + (dimensions[0], dimensions[1] - ribbon_height), + (0, ribbon_height), + self, + blanking_color=blanking_color, + background_image=background_image, + flags=content_flags + ) + self._stuck = False + self._ribbon.on_click = self._stick + self._ribbon.on_click_release = self._unstick + + def update(self): + m_pos = pg.mouse.get_pos() + s_pos = self.get_screen_position() + m_pane_pos = ( + m_pos[0] - s_pos[0], + m_pos[1] - s_pos[1] + ) + if self._stuck: + delta = ( + m_pane_pos[0] - self._m_last_pos[0], + m_pane_pos[1] - self._m_last_pos[1] + ) + self.position = ( + self.position[0] + delta[0], + self.position[1] + delta[1] + ) + + super().update() + + +class Slider(Pane): + + def __init__( + self, + dimensions: tuple[int,int], + parent: Pane, + knob_size: int, + bar_thickness: int, + knob_color: tuple[int,int,int] = (255,255,255), + bar_color: tuple[int,int,int] = (150,150,150) + ): + self._bar = Pane( + (dimensions[0], bar_thickness), + (0, ceil(dimensions[1] / 2) + ceil(bar_thickness / 2)), + self, + bar_color + ) + self._knob = Pane( + (knob_size, knob_size), + (0, ceil(dimensions[1] / 2) + ceil(knob_size / 2)), + self, + (0,0,0,0), + background_image=circle(knob_size, knob_color) + ) + \ No newline at end of file