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()