Created
December 21, 2025 21:52
-
-
Save JGalego/0c21009d61a3317e679b873d28ab9b31 to your computer and use it in GitHub Desktop.
Control a ๐ญ with your cursor to collect ๐ง while avoiding a rising herd of ๐ด enemies that hunt with flocking behavior! ๐ฎ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # /// script | |
| # requires-python = ">=3.10" | |
| # dependencies = [ | |
| # "pygame >= 2.6.1", | |
| # ] | |
| # /// | |
| """ | |
| An escape game where the player avoids enemies with flocking behavior and collects items. | |
| Enemies exhibit herd behavior (separation, alignment, cohesion). | |
| """ | |
| # Standard imports | |
| import random | |
| import math | |
| import sys | |
| # Library imports | |
| import pygame | |
| # Initialize Pygame | |
| pygame.init() | |
| # Constants | |
| SCREEN_WIDTH = 800 | |
| SCREEN_HEIGHT = 600 | |
| FPS = 60 | |
| # Colors | |
| WHITE = (255, 255, 255) | |
| BLACK = (0, 0, 0) | |
| RED = (255, 0, 0) | |
| GREEN = (0, 255, 0) | |
| BLUE = (0, 100, 255) | |
| YELLOW = (255, 255, 0) | |
| DARK_GRAY = (50, 50, 50) | |
| # Game settings | |
| STARTING_LIVES = 3 | |
| BASELINE_ENEMIES = 5 # User can modify this | |
| ENEMY_SPAWN_INTERVAL = 3000 # milliseconds | |
| ITEM_SPAWN_INTERVAL = 2000 # milliseconds | |
| ENEMY_DEATH_INTERVAL = 1000 # milliseconds when mouse is off-screen | |
| class Vector2D: | |
| """Simple 2D vector class for physics calculations""" | |
| def __init__(self, x=0, y=0): | |
| self.x = x | |
| self.y = y | |
| def __add__(self, other): | |
| """Add two vectors""" | |
| return Vector2D(self.x + other.x, self.y + other.y) | |
| def __sub__(self, other): | |
| """Subtract two vectors""" | |
| return Vector2D(self.x - other.x, self.y - other.y) | |
| def __mul__(self, scalar): | |
| """Multiply vector by scalar""" | |
| return Vector2D(self.x * scalar, self.y * scalar) | |
| def __truediv__(self, scalar): | |
| """Divide vector by scalar""" | |
| if scalar != 0: | |
| return Vector2D(self.x / scalar, self.y / scalar) | |
| return Vector2D(0, 0) | |
| def magnitude(self): | |
| """Calculate vector magnitude""" | |
| return math.sqrt(self.x**2 + self.y**2) | |
| def normalize(self): | |
| """Return normalized vector""" | |
| mag = self.magnitude() | |
| if mag > 0: | |
| return self / mag | |
| return Vector2D(0, 0) | |
| def limit(self, max_value): | |
| """Limit vector magnitude to max_value""" | |
| if self.magnitude() > max_value: | |
| return self.normalize() * max_value | |
| return self | |
| def distance_to(self, other): | |
| """Calculate distance to another vector""" | |
| return math.sqrt((self.x - other.x)**2 + (self.y - other.y)**2) | |
| class Enemy: | |
| """Enemy with flocking behavior""" | |
| def __init__(self, x, y): | |
| self.pos = Vector2D(x, y) | |
| self.vel = Vector2D(random.uniform(-1, 1), random.uniform(-1, 1)) | |
| self.acc = Vector2D(0, 0) | |
| self.max_speed = 2.5 | |
| self.max_force = 0.1 | |
| self.size = 8 | |
| self.perception_radius = 50 | |
| def flock(self, enemies, player_pos, player_visible): | |
| """Apply flocking rules: separation, alignment, cohesion""" | |
| separation = self.separate(enemies) | |
| alignment = self.align(enemies) | |
| cohesion = self.cohere(enemies) | |
| # Only seek player if they're visible (on screen) | |
| if player_visible: | |
| seek = self.seek_target(player_pos) | |
| # Weight the forces | |
| separation = separation * 1.5 | |
| alignment = alignment * 1.0 | |
| cohesion = cohesion * 1.0 | |
| seek = seek * 0.8 | |
| self.acc = self.acc + separation + alignment + cohesion + seek | |
| else: | |
| # Random wandering when player is not visible | |
| wander = Vector2D(random.uniform(-1, 1), random.uniform(-1, 1)) | |
| wander = wander.normalize() * 0.5 | |
| # Weight the forces differently for wandering | |
| separation = separation * 1.0 | |
| alignment = alignment * 0.5 | |
| cohesion = cohesion * 0.5 | |
| self.acc = self.acc + separation + alignment + cohesion + wander | |
| def separate(self, enemies): | |
| """Separation: steer to avoid crowding""" | |
| steering = Vector2D(0, 0) | |
| total = 0 | |
| for other in enemies: | |
| distance = self.pos.distance_to(other.pos) | |
| if other != self and distance < self.perception_radius / 2: | |
| diff = self.pos - other.pos | |
| diff = diff / (distance + 0.001) # Weight by distance | |
| steering = steering + diff | |
| total += 1 | |
| if total > 0: | |
| steering = steering / total | |
| steering = steering.normalize() * self.max_speed | |
| steering = steering - self.vel | |
| steering = steering.limit(self.max_force) | |
| return steering | |
| def align(self, enemies): | |
| """Alignment: steer towards average heading""" | |
| steering = Vector2D(0, 0) | |
| total = 0 | |
| for other in enemies: | |
| distance = self.pos.distance_to(other.pos) | |
| if other != self and distance < self.perception_radius: | |
| steering = steering + other.vel | |
| total += 1 | |
| if total > 0: | |
| steering = steering / total | |
| steering = steering.normalize() * self.max_speed | |
| steering = steering - self.vel | |
| steering = steering.limit(self.max_force) | |
| return steering | |
| def cohere(self, enemies): | |
| """Cohesion: steer towards average position""" | |
| steering = Vector2D(0, 0) | |
| total = 0 | |
| for other in enemies: | |
| distance = self.pos.distance_to(other.pos) | |
| if other != self and distance < self.perception_radius: | |
| steering = steering + other.pos | |
| total += 1 | |
| if total > 0: | |
| steering = steering / total | |
| return self.seek_position(steering) | |
| return steering | |
| def seek_target(self, target_pos): | |
| """Seek towards the player""" | |
| desired = target_pos - self.pos | |
| desired = desired.normalize() * self.max_speed | |
| steering = desired - self.vel | |
| steering = steering.limit(self.max_force) | |
| return steering | |
| def seek_position(self, target_pos): | |
| """Seek towards a position""" | |
| desired = target_pos - self.pos | |
| desired = desired.normalize() * self.max_speed | |
| steering = desired - self.vel | |
| steering = steering.limit(self.max_force) | |
| return steering | |
| def update(self): | |
| """Update position and velocity""" | |
| self.vel = self.vel + self.acc | |
| self.vel = self.vel.limit(self.max_speed) | |
| self.pos = self.pos + self.vel | |
| self.acc = self.acc * 0 # Reset acceleration | |
| # Wrap around screen edges | |
| if self.pos.x < 0: | |
| self.pos.x = SCREEN_WIDTH | |
| elif self.pos.x > SCREEN_WIDTH: | |
| self.pos.x = 0 | |
| if self.pos.y < 0: | |
| self.pos.y = SCREEN_HEIGHT | |
| elif self.pos.y > SCREEN_HEIGHT: | |
| self.pos.y = 0 | |
| def draw(self, screen): | |
| """Draw the enemy""" | |
| pygame.draw.circle(screen, RED, (int(self.pos.x), int(self.pos.y)), self.size) | |
| # Draw direction indicator | |
| end_x = int(self.pos.x + self.vel.normalize().x * self.size) | |
| end_y = int(self.pos.y + self.vel.normalize().y * self.size) | |
| pygame.draw.line(screen, DARK_GRAY, | |
| (int(self.pos.x), int(self.pos.y)), | |
| (end_x, end_y), 2) | |
| def collides_with(self, x, y, radius): | |
| """Check collision with a point""" | |
| return self.pos.distance_to(Vector2D(x, y)) < (self.size + radius) | |
| class Item: | |
| """Collectible item""" | |
| def __init__(self): | |
| self.x = random.randint(50, SCREEN_WIDTH - 50) | |
| self.y = random.randint(50, SCREEN_HEIGHT - 50) | |
| self.size = 10 | |
| self.pulse = 0 | |
| def update(self): | |
| """Update pulse animation""" | |
| self.pulse = (self.pulse + 0.1) % (2 * math.pi) | |
| def draw(self, screen): | |
| """Draw the item as cheese""" | |
| pulse_size = int(math.sin(self.pulse) * 2) | |
| # Cheese wedge (triangle) | |
| cheese_points = [ | |
| (self.x - self.size, self.y + self.size), # Bottom left | |
| (self.x + self.size, self.y + self.size), # Bottom right | |
| (self.x, self.y - self.size) # Top | |
| ] | |
| # Yellow cheese with pulse | |
| offset = pulse_size | |
| pulsed_points = [ | |
| (self.x - self.size - offset, self.y + self.size + offset), | |
| (self.x + self.size + offset, self.y + self.size + offset), | |
| (self.x, self.y - self.size - offset) | |
| ] | |
| pygame.draw.polygon(screen, YELLOW, pulsed_points) | |
| pygame.draw.polygon(screen, (255, 200, 0), cheese_points) | |
| # Cheese holes | |
| hole_positions = [ | |
| (self.x - 3, self.y + 2), | |
| (self.x + 3, self.y + 2), | |
| (self.x, self.y - 3) | |
| ] | |
| for hole_x, hole_y in hole_positions: | |
| pygame.draw.circle(screen, (200, 150, 0), (hole_x, hole_y), 2) | |
| def collides_with(self, x, y): | |
| """Check if mouse collects the item""" | |
| distance = math.sqrt((self.x - x)**2 + (self.y - y)**2) | |
| return distance < self.size + 5 | |
| class Game: | |
| """Main game class""" | |
| # pylint: disable=too-many-instance-attributes | |
| def __init__(self): | |
| self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) | |
| pygame.display.set_caption("Escape the Herd") | |
| self.clock = pygame.time.Clock() | |
| self.font = pygame.font.Font(None, 36) | |
| self.small_font = pygame.font.Font(None, 24) | |
| # Initialize all instance attributes | |
| self.enemies = [] | |
| self.items = [] | |
| self.score = 0 | |
| self.lives = STARTING_LIVES | |
| self.game_over = False | |
| self.mouse_on_screen = False | |
| self.baseline_enemies = BASELINE_ENEMIES | |
| self.last_enemy_spawn = 0 | |
| self.last_item_spawn = 0 | |
| self.last_enemy_death = 0 | |
| self.last_score_penalty = 0 | |
| self.time_on_screen = 0 | |
| self.invincibility_timer = 0 | |
| self.reset_game() | |
| def reset_game(self): | |
| """Reset game state""" | |
| self.enemies = [] | |
| self.items = [] | |
| self.score = 0 | |
| self.lives = STARTING_LIVES | |
| self.game_over = False | |
| self.mouse_on_screen = False | |
| self.baseline_enemies = BASELINE_ENEMIES | |
| # Spawn initial enemies | |
| for _ in range(self.baseline_enemies): | |
| x = random.randint(0, SCREEN_WIDTH) | |
| y = random.randint(0, SCREEN_HEIGHT) | |
| self.enemies.append(Enemy(x, y)) | |
| # Spawn initial item | |
| self.items.append(Item()) | |
| # Timers | |
| self.last_enemy_spawn = pygame.time.get_ticks() | |
| self.last_item_spawn = pygame.time.get_ticks() | |
| self.last_enemy_death = pygame.time.get_ticks() | |
| self.last_score_penalty = pygame.time.get_ticks() | |
| self.time_on_screen = 0 | |
| self.invincibility_timer = 0 | |
| def spawn_enemy(self): | |
| """Spawn a new enemy at screen edge""" | |
| side = random.choice(['top', 'bottom', 'left', 'right']) | |
| if side == 'top': | |
| x, y = random.randint(0, SCREEN_WIDTH), 0 | |
| elif side == 'bottom': | |
| x, y = random.randint(0, SCREEN_WIDTH), SCREEN_HEIGHT | |
| elif side == 'left': | |
| x, y = 0, random.randint(0, SCREEN_HEIGHT) | |
| else: | |
| x, y = SCREEN_WIDTH, random.randint(0, SCREEN_HEIGHT) | |
| self.enemies.append(Enemy(x, y)) | |
| def handle_events(self): | |
| """Handle pygame events""" | |
| for event in pygame.event.get(): | |
| if event.type == pygame.QUIT: | |
| return False | |
| if event.type == pygame.KEYDOWN: | |
| if event.key == pygame.K_ESCAPE: | |
| return False | |
| if event.key == pygame.K_r and self.game_over: | |
| self.reset_game() | |
| if event.key == pygame.K_UP: | |
| # Allow user to increase baseline | |
| self.baseline_enemies += 1 | |
| if event.key == pygame.K_DOWN: | |
| # Allow user to decrease baseline | |
| self.baseline_enemies = max(1, self.baseline_enemies - 1) | |
| return True | |
| def update(self): | |
| """Update game state""" | |
| # pylint: disable=too-many-branches | |
| if self.game_over: | |
| return | |
| # Get mouse position and check if on screen | |
| mouse_x, mouse_y = pygame.mouse.get_pos() | |
| window_rect = self.screen.get_rect() | |
| mouse_in_bounds = window_rect.collidepoint(mouse_x, mouse_y) | |
| window_focused = pygame.mouse.get_focused() | |
| self.mouse_on_screen = mouse_in_bounds and window_focused | |
| current_time = pygame.time.get_ticks() | |
| # Update invincibility timer | |
| if self.invincibility_timer > 0: | |
| self.invincibility_timer -= 1 | |
| # Mouse on screen: spawn enemies and items | |
| if self.mouse_on_screen: | |
| self.time_on_screen += 1 | |
| # Spawn enemies based on time and items collected | |
| score_factor = self.score * 100 | |
| time_factor = self.time_on_screen // 100 | |
| spawn_rate = max(1000, ENEMY_SPAWN_INTERVAL - score_factor - time_factor) | |
| if current_time - self.last_enemy_spawn > spawn_rate: | |
| self.spawn_enemy() | |
| self.last_enemy_spawn = current_time | |
| # Spawn items | |
| item_spawn_time = current_time - self.last_item_spawn | |
| if len(self.items) == 0 or item_spawn_time > ITEM_SPAWN_INTERVAL: | |
| self.items.append(Item()) | |
| self.last_item_spawn = current_time | |
| else: | |
| # Mouse off screen: enemies die out | |
| if len(self.enemies) > self.baseline_enemies: | |
| if current_time - self.last_enemy_death > ENEMY_DEATH_INTERVAL: | |
| self.enemies.pop() | |
| self.last_enemy_death = current_time | |
| # Deduct 1 point per second when off screen | |
| if current_time - self.last_score_penalty >= 1000: | |
| self.score = max(0, self.score - 1) | |
| self.last_score_penalty = current_time | |
| # Update enemies with flocking behavior | |
| player_pos = Vector2D(mouse_x, mouse_y) | |
| for enemy in self.enemies: | |
| enemy.flock(self.enemies, player_pos, self.mouse_on_screen) | |
| enemy.update() | |
| # Check collision with player (only if not invincible AND mouse is on screen) | |
| is_vulnerable = self.mouse_on_screen and self.invincibility_timer == 0 | |
| if is_vulnerable and enemy.collides_with(mouse_x, mouse_y, 5): | |
| self.lives -= 1 | |
| self.invincibility_timer = 60 # 1 second at 60 FPS | |
| if self.lives <= 0: | |
| self.game_over = True | |
| # Update and check items | |
| for item in self.items[:]: | |
| item.update() | |
| if item.collides_with(mouse_x, mouse_y): | |
| self.score += 10 | |
| self.items.remove(item) | |
| def draw(self): | |
| """Draw everything""" | |
| # pylint: disable=too-many-locals,too-many-statements | |
| self.screen.fill(BLACK) | |
| # Draw items | |
| for item in self.items: | |
| item.draw(self.screen) | |
| # Draw enemies | |
| for enemy in self.enemies: | |
| enemy.draw(self.screen) | |
| # Draw mouse cursor (custom) - only if mouse is on screen | |
| if self.mouse_on_screen: | |
| mouse_x, mouse_y = pygame.mouse.get_pos() | |
| # Draw mouse (the animal) | |
| if self.invincibility_timer > 0: | |
| # Flashing colors when invincible | |
| body_color = BLUE if (self.invincibility_timer // 10) % 2 == 0 else WHITE | |
| else: | |
| body_color = (150, 150, 150) # Gray mouse | |
| # Mouse body (oval) | |
| pygame.draw.ellipse(self.screen, body_color, (mouse_x - 8, mouse_y - 6, 16, 12)) | |
| # Mouse head (circle) | |
| pygame.draw.circle(self.screen, body_color, (mouse_x, mouse_y - 3), 6) | |
| # Mouse ears (circles) | |
| pygame.draw.circle( | |
| self.screen, body_color, (mouse_x - 5, mouse_y - 8), 3) | |
| # Pink inner ear | |
| pink = (255, 192, 203) | |
| pygame.draw.circle( | |
| self.screen, pink, (mouse_x - 5, mouse_y - 8), 2) | |
| pygame.draw.circle( | |
| self.screen, body_color, (mouse_x + 5, mouse_y - 8), 3) | |
| # Pink inner ear | |
| pygame.draw.circle( | |
| self.screen, pink, (mouse_x + 5, mouse_y - 8), 2) | |
| # Mouse eyes | |
| pygame.draw.circle(self.screen, BLACK, (mouse_x - 2, mouse_y - 4), 1) | |
| pygame.draw.circle(self.screen, BLACK, (mouse_x + 2, mouse_y - 4), 1) | |
| # Mouse nose | |
| pygame.draw.circle(self.screen, (255, 192, 203), (mouse_x, mouse_y - 1), 1) | |
| # Mouse tail (curved line) | |
| tail_points = [ | |
| (mouse_x + 8, mouse_y), | |
| (mouse_x + 12, mouse_y + 2), | |
| (mouse_x + 15, mouse_y + 1) | |
| ] | |
| pygame.draw.lines(self.screen, body_color, False, tail_points, 2) | |
| # Draw HUD | |
| score_text = self.font.render(f"Score: {self.score}", True, WHITE) | |
| lives_text = self.font.render(f"Lives: {self.lives}", True, WHITE) | |
| enemy_count = len(self.enemies) | |
| enemies_text = self.small_font.render( | |
| f"Enemies: {enemy_count}", True, WHITE) | |
| baseline_info = f"Baseline: {self.baseline_enemies} (Up/Down)" | |
| baseline_text = self.small_font.render(baseline_info, True, WHITE) | |
| self.screen.blit(score_text, (10, 10)) | |
| self.screen.blit(lives_text, (10, 50)) | |
| self.screen.blit(enemies_text, (SCREEN_WIDTH - 150, 10)) | |
| self.screen.blit(baseline_text, (SCREEN_WIDTH - 200, 40)) | |
| # Draw status message | |
| if not self.mouse_on_screen: | |
| status_msg = "Move mouse over screen to activate!" | |
| status_text = self.small_font.render(status_msg, True, YELLOW) | |
| center_x = SCREEN_WIDTH // 2 | |
| center_y = SCREEN_HEIGHT - 30 | |
| text_rect = status_text.get_rect(center=(center_x, center_y)) | |
| self.screen.blit(status_text, text_rect) | |
| # Draw game over screen | |
| if self.game_over: | |
| overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT)) | |
| overlay.set_alpha(180) | |
| overlay.fill(BLACK) | |
| self.screen.blit(overlay, (0, 0)) | |
| game_over_text = self.font.render("GAME OVER", True, RED) | |
| final_score_text = self.font.render( | |
| f"Final Score: {self.score}", True, WHITE) | |
| restart_msg = "Press R to Restart or ESC to Quit" | |
| restart_text = self.small_font.render(restart_msg, True, WHITE) | |
| center_x = SCREEN_WIDTH // 2 | |
| center_y = SCREEN_HEIGHT // 2 | |
| self.screen.blit( | |
| game_over_text, | |
| (center_x - game_over_text.get_width() // 2, center_y - 60)) | |
| self.screen.blit( | |
| final_score_text, | |
| (center_x - final_score_text.get_width() // 2, center_y)) | |
| self.screen.blit( | |
| restart_text, | |
| (center_x - restart_text.get_width() // 2, center_y + 60)) | |
| pygame.display.flip() | |
| def run(self): | |
| """Main game loop""" | |
| running = True | |
| while running: | |
| running = self.handle_events() | |
| self.update() | |
| self.draw() | |
| self.clock.tick(FPS) | |
| pygame.quit() | |
| sys.exit() | |
| if __name__ == "__main__": | |
| game = Game() | |
| game.run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment