""" __________________________________________ | | | Project: Ninja Game | | Standard: 91883 (AS1.7) v1 | | School: Tauranga Boys' College | | Author: M Amirul | | Date: 5 May 2024 | | Python: 3.12.3 (venv) | |_________________________________________| """ import csv import json import math import os import random import sys import pygame from pygame.sprite import Sprite, Group # Base path for image assets BASE_IMG_PATH = "assets/images/" class Game(Sprite): # Converted to inherit from Sprite def __init__(self): """ Initializes the game object. This method sets up the game window, loads assets, initializes game objects, and sets various game states and variables. Args: None Returns: None """ # Initialize all imported pygame modules pygame.init() # Load background music and set its volume pygame.mixer.music.load("assets/audio/music.wav") pygame.mixer.music.set_volume(0.5) pygame.mixer.music.play(-1) # Play the music indefinitely # Set the window title pygame.display.set_caption("Ninja Game") # Create the main game window with a resolution of 640x480 self.screen = pygame.display.set_mode((640, 480)) # Create a smaller surface for rendering game elements self.display = pygame.Surface((320, 240)) # Create a clock object to manage the frame rate self.clock = pygame.time.Clock() # Initialize movement flags for the player self.movement = [False, False] # Load all game assets (images, animations, etc.) self.assets = { "decor": load_images("tiles/decor"), "grass": load_images("tiles/grass"), "large_decor": load_images("tiles/large_decor"), "stone": load_images("tiles/stone"), "player": load_image("entities/player.png"), "background": load_image("background.png"), "clouds": load_images("clouds"), "enemy/idle": Animation(load_images("entities/enemy/idle"), img_dur=6), "enemy/run": Animation(load_images("entities/enemy/run"), img_dur=4), "player/idle": Animation(load_images("entities/player/idle"), img_dur=6), "player/run": Animation(load_images("entities/player/run"), img_dur=4), "player/jump": Animation(load_images("entities/player/jump")), "player/slide": Animation(load_images("entities/player/slide")), "player/wall_slide": Animation(load_images("entities/player/wall_slide")), "particle/leaf": Animation( load_images("particles/leaf"), img_dur=20, loop=False ), "particle/particle": Animation( load_images("particles/particle"), img_dur=6, loop=False ), "gun": load_image("gun.png"), "projectile": load_image("projectile.png"), "key": load_image("entities/key.png"), "door": load_image("entities/door.png"), } # Load sound effects self.sfx = { "jump": pygame.mixer.Sound("assets/audio/jump.wav"), "dash": pygame.mixer.Sound("assets/audio/dash.wav"), "hit": pygame.mixer.Sound("assets/audio/hit.wav"), "shoot": pygame.mixer.Sound("assets/audio/shoot.wav"), # Shooting sound effect "ambience": pygame.mixer.Sound("assets/audio/ambience.wav"), } # Set sound effect volumes self.sfx["jump"].set_volume(0.9) self.sfx["dash"].set_volume(0.8) self.sfx["hit"].set_volume(0.8) self.sfx["shoot"].set_volume(0.8) self.sfx["ambience"].set_volume(0.6) # Create a Clouds object to manage cloud animations self.clouds = Clouds(self.assets["clouds"], count=16) # Create a Player object with initial position and size self.player = Player(self, (50, 50), (8, 15)) # Create a Tilemap object with a specified tile size self.tilemap = Tilemap(self, tile_size=16) # Load all level files from the levels directory self.levels = self.get_levels() self.level = 1 # Start at the first level self.screenshake = 0 # Initialize screen shake effect self.dead = 0 # Initialize player death state self.transition = -30 # Initialize level transition state self.score = 0 # Initialize player score self.high_score = self.get_high_score() # Load the high score self.main_menu = True # Set the game to start at the main menu self.menu_selection = 0 # Initialize menu selection index self.load_level(self.level) # Load the first level self.has_key = False # Track if the player has picked up a key self.show_key_message = True # Flag to show key pickup message self.message_timer = 0 # Timer for displaying the key pickup message self.exit_door = None # Track the position of the exit door def get_levels(self): """ Get a list of all available levels. Returns: A list of strings representing the names of all JSON files in the levels directory. """ # Path to the levels directory levels_path = "assets/levels/" # Return a list of all JSON files in the levels directory return [f for f in os.listdir(levels_path) if f.endswith(".json")] def load_level(self, map_id): """ Loads a level based on the specified map_id. """ self.tilemap.load(f"assets/levels/{map_id}.json") self.leaf_spawners = [] for tree in self.tilemap.extract([("large_decor", 2)], keep=True): self.leaf_spawners.append( pygame.Rect(4 + tree["pos"][0], 4 + tree["pos"][1], 23, 13) ) self.enemies = [] for spawner in self.tilemap.extract([("spawners", 0), ("spawners", 1)]): if spawner["variant"] == 0: self.player.pos = spawner["pos"] self.player.air_time = 0 else: self.enemies.append(Enemy(self, spawner["pos"], (8, 15))) self.projectiles = [] self.particles = [] self.sparks = [] self.scroll = [0, 0] self.dead = 0 self.transition = -30 self.score = 0 # Reset key status self.has_key = False self.show_key_message = False self.message_timer = 0 # Locate the exit door in the level self.exit_door = None for door in self.tilemap.extract([("door", 0)], keep=True): self.exit_door = door["pos"] print(f"Exit door located at: {self.exit_door}") # Debug statement def get_high_score(self): """ Retrieves the high score from a CSV file. Returns: int: The high score, or 0 if no high score is found or the file is not found. """ try: # Open the CSV file containing the scores in read mode with open("assets/levels/score.csv", newline="") as csvfile: # Create a CSV reader object to read the file reader = csv.reader(csvfile) # Skip the header row next(reader) # Read all the remaining rows into a list scores = list(reader) # If there are any scores in the list, return the first score as an integer if scores: return int(scores[0][1]) except FileNotFoundError: # If the file is not found, return 0 as the high score return 0 # If there are no scores, return 0 as the high score return 0 def save_score(self): """ Save the current level and score to a CSV file. This method opens a CSV file in write mode and writes the current level and score to it. The CSV file is located at "assets/levels/score.csv" and contains two columns: "Level" and "Score". The method first writes the header row with the column names, and then writes the current level and score as a new row. Parameters: - None Returns: - None """ # Open the CSV file containing the scores in write mode with open("assets/levels/score.csv", "w", newline="") as csvfile: # Create a CSV writer object to write to the file writer = csv.writer(csvfile) # Write the header row to the CSV file writer.writerow(["Level", "Score"]) # Write the current level and score to the CSV file writer.writerow([self.level, self.score]) def run(self): """ Runs the game. This method contains the main game loop. It checks if the main menu is active and shows the main menu if it is. Otherwise, it runs the game loop. """ while True: # If the main menu is active, show the main menu if self.main_menu: self.show_main_menu() else: # Otherwise, run the game loop self.game_loop() def show_main_menu(self): """ Displays the main menu of the game. This method creates and renders the title text, menu options, and handles user input for the main menu. The main menu loop continues until the user selects an option or quits the game. Args: None Returns: None """ # Create a large font for the title font_large = pygame.font.SysFont("Arial", 40) # Render the title text title_surf = font_large.render("Ninja Game", True, (255, 255, 255)) # Get the rectangle for the title text and center it on the screen title_rect = title_surf.get_rect( center=(self.display.get_width() / 2, self.display.get_height() / 2 - 100) ) # Define the menu options menu_options = ["Start Game", "Select Level", "Scoreboard", "Exit"] # Create a medium font for the menu options font_medium = pygame.font.SysFont("Arial", 36) # Render the menu options menu_surfs = [ font_medium.render(option, True, (255, 255, 255)) for option in menu_options ] # Get the rectangles for the menu options and center them on the screen menu_rects = [ surf.get_rect( center=( self.display.get_width() / 2, self.display.get_height() / 2 + i * 50, ) ) for i, surf in enumerate(menu_surfs) ] # Main menu loop while self.main_menu: # Draw the background self.display.blit(self.assets["background"], (0, 0)) # Draw the title self.display.blit(title_surf, title_rect) # Draw the menu options for i, rect in enumerate(menu_rects): # Highlight the selected menu option color = (255, 255, 0) if i == self.menu_selection else (255, 255, 255) surf = font_medium.render(menu_options[i], True, color) self.display.blit(surf, rect) # Scale the display to fit the screen and update the display self.screen.blit( pygame.transform.scale(self.display, self.screen.get_size()), (0, 0) ) pygame.display.update() # Handle events for event in pygame.event.get(): if event.type == pygame.QUIT: # Quit the game if the window is closed pygame.quit() sys.exit() if event.type == pygame.KEYDOWN: if event.key == pygame.K_DOWN: # Move the menu selection down self.menu_selection = (self.menu_selection + 1) % len( menu_options ) if event.key == pygame.K_UP: # Move the menu selection up self.menu_selection = (self.menu_selection - 1) % len( menu_options ) if event.key == pygame.K_RETURN: # Handle the selected menu option self.transition = -30 if self.menu_selection == 0: # Start Game self.main_menu = False elif self.menu_selection == 1: # Select Level self.show_level_selection() elif self.menu_selection == 2: # Scoreboard self.show_scoreboard() elif self.menu_selection == 3: # Exit pygame.quit() sys.exit() if event.key == pygame.K_ESCAPE: # Quit the game if the escape key is pressed pygame.quit() sys.exit() # Limit the frame rate to 15 frames per second self.clock.tick(15) def check_key_pickup(self): """Check if the player has picked up a key.""" player_rect = self.player.rect() for key in self.tilemap.extract([("key", 0)], keep=True): key_rect = pygame.Rect(key["pos"][0], key["pos"][1], 16, 16) if player_rect.colliderect(key_rect): self.has_key = True self.show_key_message = True self.message_timer = 60 # Display message for 60 frames # Remove key from tilemap key_pos_key = f"{key['pos'][0] // self.tilemap.tile_size};{key['pos'][1] // self.tilemap.tile_size}" if key_pos_key in self.tilemap.tilemap: del self.tilemap.tilemap[key_pos_key] print("Key added to inventory!") else: print("Error: Key not found in tilemap.") break # Exit the loop once the key is found and processed def check_door_interaction(self): """Check if the player is interacting with the door.""" player_rect = self.player.rect() if self.exit_door: door_rect = pygame.Rect(self.exit_door[0], self.exit_door[1], 16, 16) if player_rect.colliderect(door_rect): if self.has_key: print("Level complete! Moving to next level...") self.level += 1 self.save_score() # Save the current score before moving to the next level if self.level <= len(self.levels): self.load_level(self.level) self.has_key = False # Reset key status for the new level else: print("No more levels available.") self.main_menu = True else: print("You need a key to unlock this door!") def game_loop(self): """ The main game loop that runs until the main menu is activated. Handles updating and rendering game elements, handling events, and displaying information. """ # Stop any currently playing music pygame.mixer.music.stop() # Play the ambience sound in a loop self.sfx["ambience"].play(-1) # Main game loop, runs until the main menu is activated while not self.main_menu: self.check_key_pickup() if self.has_key: # Check if the player has the key self.check_door_interaction() # Display key pickup message if needed if self.show_key_message: font_small = pygame.font.SysFont("Arial", 18) message_surf = font_small.render("Key added to inventory!", True, (255, 255, 255)) self.display.blit(message_surf, (self.display.get_width() // 2 - 100, 10)) self.message_timer -= 1 if self.message_timer <= 0: self.show_key_message = False # Draw the background image onto the display self.display.blit(self.assets["background"], (0, 0)) # Reduce the screenshake effect over time self.screenshake = max(0, self.screenshake - 1) # Clear key status if the player is dead if self.dead: self.has_key = False self.show_key_message = False self.message_timer = 0 # Check if there are no enemies left and the player has the key if not len(self.enemies) and self.has_key and self.exit_door and not self.dead: # Increase the transition counter self.transition += 1 # If the transition counter exceeds 30, load the next level if self.transition > 30: if self.level < len(self.levels): self.level += 1 self.load_level(self.level) self.has_key = False # Reset key status for the new level else: print("No more levels available.") self.main_menu = True # If the transition counter is negative, increase it towards zero if self.transition < 0: self.transition += 1 # If the player is dead, handle the death transition if self.dead: self.dead += 1 if self.dead >= 10: self.transition = min(30, self.transition + 1) if self.dead > 40: self.show_game_over_screen() self.load_level(self.level) self.dead = 0 # Reset death state # Update the scroll position to follow the player self.scroll[0] += ( self.player.rect().centerx - self.display.get_width() / 2 - self.scroll[0] ) / 30 self.scroll[1] += ( self.player.rect().centery - self.display.get_height() / 2 - self.scroll[1] ) / 30 render_scroll = (int(self.scroll[0]), int(self.scroll[1])) # Generate leaf particles from leaf spawners for rect in self.leaf_spawners: if random.random() * 49999 < rect.width * rect.height: pos = ( rect.x + random.random() * rect.width, rect.y + random.random() * rect.height, ) self.particles.append( Particle( self, "leaf", pos, velocity=[-0.1, 0.3], frame=random.randint(0, 20), ) ) # Update and render clouds self.clouds.update() self.clouds.render(self.display, offset=render_scroll) # Render the tilemap self.tilemap.render(self.display, offset=render_scroll) # Update and render enemies for enemy in self.enemies.copy(): kill = enemy.update(self.tilemap, (0, 0)) enemy.render(self.display, offset=render_scroll) if kill: self.enemies.remove(enemy) self.score += 100 # If the player is not dead, update and render the player if not self.dead: self.player.update( self.tilemap, (self.movement[1] - self.movement[0], 0) ) self.player.render(self.display, offset=render_scroll) # Update and render projectiles for projectile in self.projectiles.copy(): projectile[0][0] += projectile[1] projectile[2] += 1 img = self.assets["projectile"] self.display.blit( img, ( projectile[0][0] - img.get_width() / 2 - render_scroll[0], projectile[0][1] - img.get_height() / 2 - render_scroll[1], ), ) if self.tilemap.solid_check(projectile[0]): if projectile in self.projectiles: self.projectiles.remove(projectile) for i in range(4): self.sparks.append( Spark( projectile[0], random.random() - 0.5 + (math.pi if projectile[1] > 0 else 0), 2 + random.random(), ) ) elif projectile[2] > 360: if projectile in self.projectiles: self.projectiles.remove(projectile) else: # Check if the projectile hits an enemy for enemy in self.enemies.copy(): if enemy.rect().collidepoint(projectile[0]): if projectile in self.projectiles: self.projectiles.remove(projectile) self.enemies.remove(enemy) self.score += 100 for i in range(30): angle = random.random() * math.pi * 2 speed = random.random() * 5 self.sparks.append( Spark( enemy.rect().center, angle, 2 + random.random(), ) ) self.particles.append( Particle( self, "particle", enemy.rect().center, velocity=[ math.cos(angle + math.pi) * speed * 0.5, math.sin(angle + math.pi) * speed * 0.5, ], frame=random.randint(0, 7), ) ) break elif abs(self.player.dashing) < 50: self.sfx["shoot"].play(0) if self.player.rect().collidepoint(projectile[0]): if projectile in self.projectiles: self.projectiles.remove(projectile) self.dead += 1 self.screenshake = max(16, self.screenshake) for i in range(30): angle = random.random() * math.pi * 2 speed = random.random() * 5 self.sparks.append( Spark( self.player.rect().center, angle, 2 + random.random(), ) ) self.particles.append( Particle( self, "particle", self.player.rect().center, velocity=[ math.cos(angle + math.pi) * speed * 0.5, math.sin(angle + math.pi) * speed * 0.5, ], frame=random.randint(0, 7), ) ) # Update and render sparks for spark in self.sparks.copy(): kill = spark.update() spark.render(self.display, offset=render_scroll) if kill: self.sparks.remove(spark) # Update and render particles for particle in self.particles.copy(): kill = particle.update() particle.render(self.display, offset=render_scroll) if particle.type == "leaf": particle.pos[0] += math.sin(particle.animation.frame * 0.035) * 0.3 if kill: self.particles.remove(particle) # Handle events for event in pygame.event.get(): if event.type == pygame.QUIT: self.sfx["ambience"].stop() # Stop ambience sound pygame.quit() sys.exit() if event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: self.main_menu = True self.sfx["ambience"].stop() # Stop ambience sound pygame.mixer.music.play(-1) # Play music sound return if event.key == pygame.K_LEFT: self.movement[0] = True if event.key == pygame.K_RIGHT: self.movement[1] = True if event.key == pygame.K_UP: self.player.jump() self.sfx["jump"].play() # Play jump sound effect if event.key == pygame.K_SPACE: self.player.dash() self.sfx["dash"].play() # Play dash sound effect if event.key == pygame.K_x: self.projectiles.append( [ [ self.player.rect().centerx + (-8 if self.player.flip else 8), self.player.rect().centery - 2, ], -4 if self.player.flip else 4, 0, ] ) self.sfx["shoot"].play() # Play shoot sound effect immediately if event.key == pygame.K_ESCAPE: self.main_menu = True return if event.type == pygame.KEYUP: if event.key == pygame.K_LEFT: self.movement[0] = False if event.key == pygame.K_RIGHT: self.movement[1] = False # Display FPS font_small = pygame.font.SysFont("Arial", 18) fps = self.clock.get_fps() fps_surf = font_small.render(f"FPS: {int(fps)}", True, (255, 255, 255)) self.display.blit(fps_surf, (10, 10)) # Display score score_surf = font_small.render( f"Score: {self.score}", True, (255, 255, 255) ) self.display.blit(score_surf, (10, 30)) # Display high score high_score_surf = font_small.render( f"High Score: {self.high_score}", True, (255, 255, 255) ) self.display.blit(high_score_surf, (10, 50)) # Display level level_surf = font_small.render( f"Level: {self.level}", True, (255, 255, 255) ) self.display.blit(level_surf, (self.display.get_width() - 120, 10)) # Apply screenshake effect screenshake_offset = ( random.random() * self.screenshake - self.screenshake / 2, random.random() * self.screenshake - self.screenshake / 2, ) # Handle transition effect if self.transition: transition_surf = pygame.Surface(self.display.get_size()) pygame.draw.circle( transition_surf, (255, 255, 255), (self.display.get_width() // 2, self.display.get_height() // 2), (30 - abs(self.transition)) * 8, ) transition_surf.set_colorkey((255, 255, 255)) self.display.blit(transition_surf, (0, 0)) # Scale the display to fit the screen and apply screenshake offset self.screen.blit( pygame.transform.scale(self.display, self.screen.get_size()), screenshake_offset, ) # Update the display pygame.display.update() # Cap the frame rate to 60 FPS self.clock.tick(60) # def show_game_over_screen(self): """ Displays the game over screen with the final score and options for restarting or returning to the main menu. This method checks if the current score is higher than the high score and updates it accordingly. It also saves the new high score to a file. The method renders and displays the "Game Over" text, restart instruction text, exit instruction text, and main menu instruction text on the screen. The method listens for keyboard events and handles the following key presses: - "R" key: Restarts the game. - "ESC" key: Returns to the main menu. - "M" key: Returns to the main menu. The method limits the frame rate to 15 frames per second. Parameters: - None Returns: - None """ # Method implementation goes here # Check if the current score is higher than the high score if self.score > self.high_score: # Update the high score with the current score self.high_score = self.score # Save the new high score to a file self.save_score() # Create a large font for the "Game Over" text font_large = pygame.font.SysFont("Arial", 65) # Render the "Game Over" text in red color game_over_surf = font_large.render("Game Over", True, (255, 0, 0)) # Get the rectangle for the "Game Over" text and center it on the screen game_over_rect = game_over_surf.get_rect( center=(self.display.get_width() / 2, self.display.get_height() / 2) ) # Create a small font for the restart instruction text font_small = pygame.font.SysFont("Arial", 18) # Render the restart instruction text in white color restart_surf = font_small.render("Press R to Restart", True, (255, 255, 255)) # Get the rectangle for the restart instruction text and position it below the "Game Over" text restart_rect = restart_surf.get_rect( center=(self.display.get_width() / 2, self.display.get_height() / 2 + 50) ) # Render the exit instruction text in white color exit_surf = font_small.render( "Press ESC to go back to Main Menu", True, (255, 255, 255) ) # Get the rectangle for the exit instruction text and position it below the restart instruction text exit_rect = exit_surf.get_rect( center=(self.display.get_width() / 2, self.display.get_height() / 2 + 100) ) # Render the main menu instruction text in white color menu_surf = font_small.render("Press M for Main Menu", True, (255, 255, 255)) # Get the rectangle for the main menu instruction text and position it below the exit instruction text menu_rect = menu_surf.get_rect( center=(self.display.get_width() / 2, self.display.get_height() / 2 + 150) ) # Main loop for the game over screen while True: # Draw the background image onto the display self.display.blit(self.assets["background"], (0, 0)) # Draw the "Game Over" text onto the display self.display.blit(game_over_surf, game_over_rect) # Draw the restart instruction text onto the display self.display.blit(restart_surf, restart_rect) # Draw the exit instruction text onto the display self.display.blit(exit_surf, exit_rect) # Draw the main menu instruction text onto the display self.display.blit(menu_surf, menu_rect) # Scale the display to fit the screen and update the display self.screen.blit( pygame.transform.scale(self.display, self.screen.get_size()), (0, 0) ) pygame.display.update() # Handle events for event in pygame.event.get(): if event.type == pygame.QUIT: # Quit the game if the window is closed pygame.quit() sys.exit() if event.type == pygame.KEYDOWN: if event.key == pygame.K_r: # Restart the game if the "R" key is pressed self.dead = 0 self.transition = -30 return if event.key == pygame.K_ESCAPE: # Go back to the main menu if the "ESC" key is pressed self.main_menu = True self.sfx["ambience"].stop() # Stop ambience sound pygame.mixer.music.play(-1) # Play music sound return if event.key == pygame.K_m: # Go back to the main menu if the "M" key is pressed self.transition = -30 self.main_menu = True self.sfx["ambience"].stop() # Stop ambience sound pygame.mixer.music.play(-1) # Play music sound return # Limit the frame rate to 15 frames per second self.clock.tick(15) def show_level_selection(self): """ Displays the level selection screen. This method renders the level selection screen, allowing the player to choose a level to play. If no levels are found, it displays a "No levels found" message. The player can navigate through the available levels using the arrow keys. Pressing the "RETURN" key loads the selected level and starts the game. Pressing the "ESC" key returns to the previous menu. Returns: None """ # Create a large font for the title font_large = pygame.font.SysFont("Arial", 40) # Render the title text "Select Level" in white color title_surf = font_large.render("Select Level", True, (255, 255, 255)) # Get the rectangle for the title text and center it on the screen title_rect = title_surf.get_rect( center=(self.display.get_width() / 2, self.display.get_height() / 2 - 100) ) # Create a small font for the level names font_small = pygame.font.SysFont("Arial", 36) # Get the list of available levels levels = self.get_levels() # Check if no levels are found if not levels: # Render the "No levels found" text in red color no_levels_surf = font_small.render("No levels found", True, (255, 0, 0)) # Get the rectangle for the "No levels found" text and center it on the screen no_levels_rect = no_levels_surf.get_rect( center=(self.display.get_width() / 2, self.display.get_height() / 2) ) # Main loop for the no levels found screen while True: # Draw the background image onto the display self.display.blit(self.assets["background"], (0, 0)) # Draw the title text onto the display self.display.blit(title_surf, title_rect) # Draw the "No levels found" text onto the display self.display.blit(no_levels_surf, no_levels_rect) # Scale the display to fit the screen and update the display self.screen.blit( pygame.transform.scale(self.display, self.screen.get_size()), (0, 0) ) pygame.display.update() # Handle events for event in pygame.event.get(): if event.type == pygame.QUIT: # Quit the game if the window is closed pygame.quit() sys.exit() if event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: # Return to the previous menu if the "ESC" key is pressed return # Limit the frame rate to 15 frames per second self.clock.tick(15) else: # Render the level names in white color level_surfs = [ font_small.render(f"Level {i + 1}", True, (255, 255, 255)) for i in range(len(levels)) ] # Get the rectangles for the level names and position them on the screen level_rects = [ surf.get_rect( center=( self.display.get_width() / 2, self.display.get_height() / 2 + i * 50, ) ) for i, surf in enumerate(level_surfs) ] # Main loop for the level selection screen while True: # Draw the background image onto the display self.display.blit(self.assets["background"], (0, 0)) # Draw the title text onto the display self.display.blit(title_surf, title_rect) # Draw the level names onto the display for i, rect in enumerate(level_rects): # Highlight the selected level name color = ( (255, 255, 0) if i == self.menu_selection else (255, 255, 255) ) surf = font_small.render(f"Level {i + 1}", True, color) self.display.blit(surf, rect) # Scale the display to fit the screen and update the display self.screen.blit( pygame.transform.scale(self.display, self.screen.get_size()), (0, 0) ) pygame.display.update() # Handle events for event in pygame.event.get(): if event.type == pygame.QUIT: # Quit the game if the window is closed pygame.quit() sys.exit() if event.type == pygame.KEYDOWN: if event.key == pygame.K_DOWN: # Move the menu selection down self.menu_selection = (self.menu_selection + 1) % len( levels ) if event.key == pygame.K_UP: # Move the menu selection up self.menu_selection = (self.menu_selection - 1) % len( levels ) if event.key == pygame.K_RETURN: # Load the selected level if the "RETURN" key is pressed self.level = self.menu_selection + 1 # Adjusting for 0-based index to 1-based level self.load_level(self.level) self.main_menu = False return if event.key == pygame.K_ESCAPE: # Return to the previous menu if the "ESC" key is pressed return # Limit the frame rate to 15 frames per second self.clock.tick(15) def show_scoreboard(self): """ Displays the scoreboard screen. This method renders and displays the scoreboard screen, showing the top scores achieved in the game. It uses Pygame to create and position the title text and the scores on the screen. The screen is updated continuously until the user closes the window or presses the "ESC" key. Args: None Returns: None """ # Create a large font for the title font_large = pygame.font.SysFont("Arial", 48) # Render the title text "Scoreboard" in white color title_surf = font_large.render("Scoreboard", True, (255, 255, 255)) # Get the rectangle for the title text and center it on the screen title_rect = title_surf.get_rect( center=(self.display.get_width() / 2, self.display.get_height() / 2 - 100) ) # Create a small font for the scores font_small = pygame.font.SysFont("Arial", 36) # Get the list of all scores scores = self.get_all_scores() # Render the scores in white color score_surfs = [ font_small.render( f"{i + 1}. Level {score[0]}: {score[1]}", True, (255, 255, 255) ) for i, score in enumerate(scores) ] # Get the rectangles for the scores and position them on the screen score_rects = [ surf.get_rect( center=( self.display.get_width() / 2, self.display.get_height() / 2 + i * 50, ) ) for i, surf in enumerate(score_surfs) ] # Main loop for the scoreboard screen while True: # Draw the background image onto the display self.display.blit(self.assets["background"], (0, 0)) # Draw the title text onto the display self.display.blit(title_surf, title_rect) # Draw the scores onto the display for surf, rect in zip(score_surfs, score_rects): self.display.blit(surf, rect) # Scale the display to fit the screen and update the display self.screen.blit( pygame.transform.scale(self.display, self.screen.get_size()), (0, 0) ) pygame.display.update() # Handle events for event in pygame.event.get(): if event.type == pygame.QUIT: # Quit the game if the window is closed pygame.quit() sys.exit() if event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: # Return to the previous menu if the "ESC" key is pressed return # Limit the frame rate to 15 frames per second self.clock.tick(15) def get_all_scores(self): """ Retrieves the top 10 scores from a CSV file. Returns: list: A list of the top 10 scores, sorted in descending order based on the score value. """ try: # Open the CSV file containing the scores in read mode with open("assets/levels/score.csv", newline="") as csvfile: # Create a CSV reader object to read the file reader = csv.reader(csvfile) # Skip the header row next(reader) # Read all the remaining rows into a list scores = list(reader) # Sort the scores in descending order based on the score value (second column) scores.sort(key=lambda x: int(x[1]), reverse=True) # Return the top 10 scores return scores[:10] except FileNotFoundError: # If the file is not found, return an empty list return [] def load_image(path): """ Load an image from the specified path and set its colorkey to (0, 0, 0). Args: path (str): The relative path to the image file. Returns: pygame.Surface: The loaded image with colorkey set, or None if loading fails. """ try: # Check if the path is for specific images that need transparency if "enemy" in path or "key" in path: # Load the image with alpha transparency img = pygame.image.load(BASE_IMG_PATH + path).convert_alpha() else: # Load the image and set colorkey for other images img = pygame.image.load(BASE_IMG_PATH + path).convert() img.set_colorkey((0, 0, 0)) return img except pygame.error as e: # If there is an error loading the image, print an error message print(f"Failed to load image {path}: {e}") # Return None to indicate failure return None def load_images(path): """ Load all images from a directory and return them as a list of pygame.Surface objects. Args: path (str): The relative path to the directory containing image files. Returns: list: A list of loaded images. Raises: FileNotFoundError: If the specified directory does not exist. """ # Initialize an empty list to store the loaded images images = [] # Iterate over all files in the specified directory, sorted alphabetically for img_name in sorted(os.listdir(BASE_IMG_PATH + path)): # Load each image using the load_image function and append it to the images list images.append(load_image(path + "/" + img_name)) # Return the list of loaded images return images class Animation(pygame.sprite.Sprite): """ Initializes the Animation object. Parameters: - images (list): A list of images for the animation. - img_dur (int): The duration each image should be displayed in frames. Default is 5. - loop (bool): Whether the animation should loop. Default is True. """ def __init__(self, images, img_dur=5, loop=True): super().__init__() # Initialize the parent Sprite class # Initialize the animation with a list of images self.images = images # Set whether the animation should loop self.loop = loop # Set the duration each image should be displayed (in frames) self.img_duration = img_dur # Flag to indicate if the animation is done self.done = False # Initialize the current frame of the animation self.frame = 0 def copy(self): """ Creates a copy of the Animation object. Returns: - Animation: A new instance of the Animation class with the same images, image duration, and loop settings. """ # Create a new instance of the Animation class with the same images, image duration, and loop settings return Animation(self.images, self.img_duration, self.loop) def update(self): # Check if the animation should loop """ Updates the animation by advancing the frame counter. If the animation is set to loop, the frame counter will wrap around when it exceeds the total number of frames. If the animation is not set to loop, the frame counter will increment but not exceed the total number of frames. If the frame counter reaches the last frame, the animation is marked as done. """ if self.loop: # Increment the frame counter and wrap around if it exceeds the total number of frames self.frame = (self.frame + 1) % (self.img_duration * len(self.images)) else: # Increment the frame counter but do not exceed the total number of frames self.frame = min(self.frame + 1, self.img_duration * len(self.images) - 1) # If the frame counter reaches the last frame, mark the animation as done if self.frame >= self.img_duration * len(self.images) - 1: self.done = True def img(self): return self.images[int(self.frame / self.img_duration)] AUTOTILE_MAP = { tuple(sorted([(1, 0), (0, 1)])): 0, tuple(sorted([(1, 0), (0, 1), (-1, 0)])): 1, tuple(sorted([(-1, 0), (0, 1)])): 2, tuple(sorted([(-1, 0), (0, -1), (0, 1)])): 3, tuple(sorted([(-1, 0), (0, -1)])): 4, tuple(sorted([(-1, 0), (0, -1), (1, 0)])): 5, tuple(sorted([(1, 0), (0, -1)])): 6, tuple(sorted([(1, 0), (0, -1), (0, 1)])): 7, tuple(sorted([(1, 0), (-1, 0), (0, 1), (0, -1)])): 8, } NEIGHBOR_OFFSETS = [ (-1, 0), (-1, -1), (0, -1), (1, -1), (1, 0), (0, 0), (-1, 1), (0, 1), (1, 1), ] PHYSICS_TILES = {"grass", "stone"} AUTOTILE_TYPES = {"grass", "stone"} class Tilemap(pygame.sprite.Sprite): def __init__(self, game, tile_size=16): """ Initialize a new instance of the Game class. Args: game (object): Reference to the main game object. tile_size (int, optional): Size of each tile in the tilemap. Defaults to 16. """ super().__init__() # Initialize the parent Sprite class # Reference to the main game object self.game = game # Size of each tile in the tilemap self.tile_size = tile_size # Dictionary to store the tilemap data self.tilemap = {} # List to store tiles that are not aligned to the grid self.offgrid_tiles = [] def extract(self, id_pairs, keep=False): """ Extracts tiles from the game's offgrid_tiles and tilemap dictionaries based on the provided id_pairs. Parameters: - id_pairs (list of tuples): A list of tuples representing the type and variant of tiles to extract. - keep (bool): A flag indicating whether to keep the extracted tiles in the dictionaries or remove them. Returns: - matches (list): A list of dictionaries representing the extracted tiles. """ # Initialize an empty list to store the matched tiles matches = [] # Iterate over a copy of the offgrid_tiles list for tile in self.offgrid_tiles.copy(): # Check if the tile's type and variant match any in the id_pairs if (tile["type"], tile["variant"]) in id_pairs: # Add a copy of the matching tile to the matches list matches.append(tile.copy()) # Remove the tile from offgrid_tiles if keep is False if not keep: self.offgrid_tiles.remove(tile) # Iterate over the tilemap dictionary for loc in self.tilemap: # Get the tile at the current location tile = self.tilemap[loc] # Check if the tile's type and variant match any in the id_pairs if (tile["type"], tile["variant"]) in id_pairs: # Add a copy of the matching tile to the matches list matches.append(tile.copy()) # Adjust the position of the tile to be in pixel coordinates matches[-1]["pos"] = matches[-1]["pos"].copy() matches[-1]["pos"][0] *= self.tile_size matches[-1]["pos"][1] *= self.tile_size # Remove the tile from tilemap if keep is False if not keep: del self.tilemap[loc] # Return the list of matched tiles return matches def tiles_around(self, pos): """ Returns a list of neighboring tiles around the given position. Args: pos (tuple): The position for which to find neighboring tiles. Returns: list: A list of neighboring tiles. """ # Initialize an empty list to store the neighboring tiles tiles = [] # Calculate the tile location based on the given position and tile size tile_loc = (int(pos[0] // self.tile_size), int(pos[1] // self.tile_size)) # Iterate over the predefined neighbor offsets for offset in NEIGHBOR_OFFSETS: # Calculate the location to check by adding the offset to the tile location check_loc = ( str(tile_loc[0] + offset[0]) + ";" + str(tile_loc[1] + offset[1]) ) # Check if the calculated location exists in the tilemap if check_loc in self.tilemap: # If it exists, add the tile at that location to the tiles list tiles.append(self.tilemap[check_loc]) # Return the list of neighboring tiles return tiles def save(self, path): """ Save the tilemap data, tile size, and offgrid tiles to a file in JSON format. Args: path (str): The path to the file where the data will be saved. Returns: None """ # Open the file at the specified path in write mode f = open(path, "w") # Dump the tilemap data, tile size, and offgrid tiles into the file as JSON json.dump( { "tilemap": self.tilemap, "tile_size": self.tile_size, "offgrid": self.offgrid_tiles, }, f, ) # Close the file f.close() def load(self, path): """ Loads the tilemap data from a JSON file. Args: path (str): The path to the JSON file. Returns: None """ # Open the file at the specified path in read mode f = open(path, "r") # Load the JSON data from the file into a dictionary map_data = json.load(f) # Close the file f.close() # Set the tilemap attribute to the loaded tilemap data self.tilemap = map_data["tilemap"] # Set the tile_size attribute to the loaded tile size self.tile_size = map_data["tile_size"] # Set the offgrid_tiles attribute to the loaded offgrid tiles self.offgrid_tiles = map_data["offgrid"] print("Loaded tilemap:", self.tilemap) # Debug statement print("Loaded offgrid_tiles:", self.offgrid_tiles) # Debug statement def solid_check(self, pos): """ Checks if the given position is on a solid tile in the tilemap. Args: pos (tuple): The position to check in the format (x, y). Returns: dict or None: The solid tile at the given position if it exists, None otherwise. """ # Calculate the tile location based on the given position and tile size tile_loc = ( str(int(pos[0] // self.tile_size)) + ";" + str(int(pos[1] // self.tile_size)) ) # Check if the calculated tile location exists in the tilemap if tile_loc in self.tilemap: # Check if the tile at the location is a physics tile (solid) if self.tilemap[tile_loc]["type"] in PHYSICS_TILES: # Return the solid tile return self.tilemap[tile_loc] ############ def tiles_around(self, pos): """ Get tiles around a given position. Args: pos (tuple): The position to check around. Returns: list: List of tiles around the given position. """ # Initialize an empty list to store the neighboring tiles tiles = [] # Calculate the tile location based on the given position and tile size tile_loc = (int(pos[0] // self.tile_size), int(pos[1] // self.tile_size)) # Iterate over the predefined neighbor offsets for offset in NEIGHBOR_OFFSETS: # Calculate the location to check by adding the offset to the tile location check_loc = ( str(tile_loc[0] + offset[0]) + ";" + str(tile_loc[1] + offset[1]) ) # Check if the calculated location exists in the tilemap if check_loc in self.tilemap: # If it exists, add the tile at that location to the tiles list tiles.append(self.tilemap[check_loc]) # Return the list of neighboring tiles return tiles def save(self, path): """ Save the tilemap to a file. Args: path (str): The file path to save the tilemap. """ # Open the file at the specified path in write mode with open(path, "w") as f: # Dump the tilemap data, tile size, and offgrid tiles into the file as JSON json.dump( { "tilemap": self.tilemap, "tile_size": self.tile_size, "offgrid": self.offgrid_tiles, }, f, ) def solid_check(self, pos): """ Check if a position is solid (i.e., collides with a physics tile). Args: pos (tuple): The position to check. Returns: dict: The tile information if the position is solid, otherwise None. """ # Calculate the tile location based on the given position and tile size tile_loc = ( str(int(pos[0] // self.tile_size)) + ";" + str(int(pos[1] // self.tile_size)) ) # Check if the calculated tile location exists in the tilemap if tile_loc in self.tilemap: # Check if the tile at the location is a physics tile (solid) if self.tilemap[tile_loc]["type"] in PHYSICS_TILES: # Return the solid tile return self.tilemap[tile_loc] def physics_rects_around(self, pos): """ Get the physics rectangles around a given position. Args: pos (tuple): The position to check around. Returns: list: List of pygame.Rect objects representing the physics rectangles. """ # Initialize an empty list to store the physics rectangles rects = [] # Iterate over the tiles around the given position for tile in self.tiles_around(pos): # Check if the tile is a physics tile (solid) if tile["type"] in PHYSICS_TILES: # Create a pygame.Rect object for the solid tile and add it to the rects list rects.append( pygame.Rect( tile["pos"][0] * self.tile_size, tile["pos"][1] * self.tile_size, self.tile_size, self.tile_size, ) ) # Return the list of physics rectangles return rects def autotile(self): """ Automatically set the tile variant based on neighboring tiles. This function updates the 'variant' attribute of each tile in the tilemap based on the presence of neighboring tiles of the same type. """ # Iterate over the tilemap dictionary for loc in self.tilemap: # Get the tile at the current location tile = self.tilemap[loc] # Initialize a set to store the neighboring tile offsets neighbors = set() # Iterate over the predefined shifts for neighboring tiles for shift in [(1, 0), (-1, 0), (0, -1), (0, 1)]: # Calculate the location to check by adding the shift to the tile location check_loc = ( str(tile["pos"][0] + shift[0]) + ";" + str(tile["pos"][1] + shift[1]) ) # Check if the calculated location exists in the tilemap if check_loc in self.tilemap: # Check if the neighboring tile is of the same type if self.tilemap[check_loc]["type"] == tile["type"]: # Add the shift to the neighbors set neighbors.add(shift) # Sort and convert the neighbors set to a tuple neighbors = tuple(sorted(neighbors)) # Check if the tile type is in the autotile types and if the neighbors match an autotile map if (tile["type"] in AUTOTILE_TYPES) and (neighbors in AUTOTILE_MAP): # Set the tile variant based on the autotile map tile["variant"] = AUTOTILE_MAP[neighbors] def render(self, surf, offset=(0, 0)): for tile in self.offgrid_tiles: surf.blit( self.game.assets[tile["type"]][tile["variant"]], (tile["pos"][0] - offset[0], tile["pos"][1] - offset[1]), ) for x in range( offset[0] // self.tile_size, (offset[0] + surf.get_width()) // self.tile_size + 1, ): for y in range( offset[1] // self.tile_size, (offset[1] + surf.get_height()) // self.tile_size + 1, ): loc = str(x) + ";" + str(y) if loc in self.tilemap: tile = self.tilemap[loc] if "variant" not in tile: tile["variant"] = 0 if isinstance(self.game.assets[tile["type"]], list): image = self.game.assets[tile["type"]][tile["variant"]] else: image = self.game.assets[tile["type"]] surf.blit( image, ( tile["pos"][0] * self.tile_size - offset[0], tile["pos"][1] * self.tile_size - offset[1], ), ) class Spark(pygame.sprite.Sprite): """ Represents a spark object that moves and renders on a surface. Attributes: pos (list): The position of the spark as a list of x and y coordinates. angle (float): The angle at which the spark is moving. speed (float): The speed at which the spark is moving. Methods: update(): Updates the position and speed of the spark. render(surf, offset=(0, 0)): Renders the spark on the given surface with an optional offset. """ def __init__(self, pos, angle, speed): super().__init__() # Initialize the parent Sprite class # Initialize the position, angle, and speed of the spark self.pos = list(pos) self.angle = angle self.speed = speed def update(self): # Update the position of the spark based on its angle and speed self.pos[0] += math.cos(self.angle) * self.speed self.pos[1] += math.sin(self.angle) * self.speed # Reduce the speed of the spark, ensuring it doesn't go below 0 self.speed = max(0, self.speed - 0.1) # Return True if the spark's speed is 0, indicating it should be removed return not self.speed def render(self, surf, offset=(0, 0)): # Calculate the points to render the spark as a polygon render_points = [ ( self.pos[0] + math.cos(self.angle) * self.speed * 3 - offset[0], self.pos[1] + math.sin(self.angle) * self.speed * 3 - offset[1], ), ( self.pos[0] + math.cos(self.angle + math.pi * 0.5) * self.speed * 0.5 - offset[0], self.pos[1] + math.sin(self.angle + math.pi * 0.5) * self.speed * 0.5 - offset[1], ), ( self.pos[0] + math.cos(self.angle + math.pi) * self.speed * 3 - offset[0], self.pos[1] + math.sin(self.angle + math.pi) * self.speed * 3 - offset[1], ), ( self.pos[0] + math.cos(self.angle - math.pi * 0.5) * self.speed * 0.5 - offset[0], self.pos[1] + math.sin(self.angle - math.pi * 0.5) * self.speed * 0.5 - offset[1], ), ] # Draw the spark as a white polygon on the given surface pygame.draw.polygon(surf, (255, 255, 255), render_points) class PhysicsEntity(pygame.sprite.Sprite): """ Represents a physics-based entity in the game. Attributes: game (Game): The game instance. type (str): The entity type. pos (list): The position of the entity. size (tuple): The size of the entity. velocity (list): The velocity of the entity. collisions (dict): The collision status of the entity. action (str): The current action of the entity. anim_offset (tuple): The animation offset of the entity. flip (bool): The flip status of the entity. last_movement (list): The last movement of the entity. """ def __init__(self, game, e_type, pos, size): super().__init__() # Initialize the parent Sprite class # Initialize the game, entity type, position, and size self.game = game self.type = e_type self.pos = list(pos) self.size = size # Initialize the velocity and collisions dictionary self.velocity = [0, 0] self.collisions = {"up": False, "down": False, "right": False, "left": False} # Initialize other attributes self.action = "" self.anim_offset = (-3, -3) self.flip = False self.set_action("idle") # Set initial action to "idle" self.last_movement = [0, 0] def rect(self): """ Return a pygame.Rect object representing the entity's position and size. Returns: pygame.Rect: The rectangle representing the entity. """ return pygame.Rect(self.pos[0], self.pos[1], self.size[0], self.size[1]) def set_action(self, action): """ Change the entity's action and load the corresponding animation. Args: action (str): The new action of the entity. """ if action != self.action: self.action = action self.animation = self.game.assets[self.type + "/" + self.action].copy() def update(self, tilemap, movement=(0, 0)): """ Update the entity's position, collisions, and animation. Args: tilemap (Tilemap): The tilemap instance. movement (tuple, optional): The movement of the entity. Defaults to (0, 0). """ # Reset collisions dictionary for the current frame self.collisions = {"up": False, "down": False, "right": False, "left": False} # Calculate the frame movement by adding movement and velocity frame_movement = ( movement[0] + self.velocity[0], movement[1] + self.velocity[1], ) # Update the entity's horizontal position and handle collisions self.pos[0] += frame_movement[0] entity_rect = self.rect() for rect in tilemap.physics_rects_around(self.pos): if entity_rect.colliderect(rect): if frame_movement[0] > 0: entity_rect.right = rect.left self.collisions["right"] = True if frame_movement[0] < 0: entity_rect.left = rect.right self.collisions["left"] = True self.pos[0] = entity_rect.x # Update the entity's vertical position and handle collisions self.pos[1] += frame_movement[1] entity_rect = self.rect() for rect in tilemap.physics_rects_around(self.pos): if entity_rect.colliderect(rect): if frame_movement[1] > 0: entity_rect.bottom = rect.top self.collisions["down"] = True if frame_movement[1] < 0: entity_rect.top = rect.bottom self.collisions["up"] = True self.pos[1] = entity_rect.y # Update the entity's flip attribute based on horizontal movement if movement[0] > 0: self.flip = False if movement[0] < 0: self.flip = True # Update the last movement attribute self.last_movement = movement # Apply gravity to the entity's vertical velocity, limiting it to 5 self.velocity[1] = min(5, self.velocity[1] + 0.1) # Reset vertical velocity if the entity is colliding with the ground or ceiling if self.collisions["down"] or self.collisions["up"]: self.velocity[1] = 0 # Update the entity's animation self.animation.update() def render(self, surf, offset=(0, 0)): """ Render the entity on the given surface with an optional offset. Args: surf (pygame.Surface): The surface to render the entity on. offset (tuple, optional): The offset of the entity. Defaults to (0, 0). """ surf.blit( # Flip the entity's image horizontally if self.flip is True pygame.transform.flip(self.animation.img(), self.flip, False), ( # Adjust the position by the offset and animation offset self.pos[0] - offset[0] + self.anim_offset[0], self.pos[1] - offset[1] + self.anim_offset[1], ), ) class Enemy(PhysicsEntity): """ Represents an enemy in the game. Inherits from the `PhysicsEntity` class. Attributes: game (Game): The game instance. pos (tuple): The position of the enemy. size (tuple): The size of the enemy. Methods: __init__(self, game, pos, size): Initializes the enemy object. update(self, tilemap, movement=(0, 0)): Updates the enemy's state. render(self, surf, offset=(0, 0)): Renders the enemy on the screen. """ def __init__(self, game, pos, size): # Initialize the enemy as a PhysicsEntity with the type "enemy" super().__init__(game, "enemy", pos, size) # Initialize the walking timer self.walking = 0 def update(self, tilemap, movement=(0, 0)): """ Updates the enemy's state. Args: tilemap (Tilemap): The tilemap object. movement (tuple, optional): The movement of the enemy. Defaults to (0, 0). Returns: bool: True if the enemy should be removed, False otherwise. """ # If the enemy is walking if self.walking: # Check if there is a solid tile in front of the enemy if tilemap.solid_check( (self.rect().centerx + (-7 if self.flip else 7), self.pos[1] + 23) ): # If the enemy collides with a wall, flip its direction if self.collisions["right"] or self.collisions["left"]: self.flip = not self.flip else: # Move the enemy in the current direction movement = (movement[0] - 0.5 if self.flip else 0.5, movement[1]) else: # If there is no solid tile, flip the enemy's direction self.flip = not self.flip # Decrease the walking timer self.walking = max(0, self.walking - 1) # If the walking timer reaches zero if not self.walking: # Calculate the distance to the player dis = ( self.game.player.pos[0] - self.pos[0], self.game.player.pos[1] - self.pos[1], ) # If the player is within a certain vertical range if abs(dis[1]) < 16: # If the enemy is facing the player, shoot a projectile if self.flip and dis[0] < 0: self.game.projectiles.append( [[self.rect().centerx - 7, self.rect().centery], -1.5, 0] ) for i in range(4): self.game.sparks.append( Spark( self.game.projectiles[-1][0], random.random() - 0.5 + math.pi, 2 + random.random(), ) ) self.game.sfx["shoot"].play() # Play shoot sound for enemy immediately if not self.flip and dis[0] > 0: self.game.projectiles.append( [[self.rect().centerx + 7, self.rect().centery], 1.5, 0] ) # Create sparks when shooting for i in range(4): self.game.sparks.append( Spark( self.game.projectiles[-1][0], random.random() - 0.5, 2 + random.random(), ) ) self.game.sfx["shoot"].play() # Play shoot sound for enemy immediately # Randomly start walking elif random.random() < 0.01: self.walking = random.randint(30, 120) # Update the enemy's position and handle collisions super().update(tilemap, movement=movement) # Set the enemy's action based on movement if movement[0] != 0: self.set_action("run") else: self.set_action("idle") # Check for collision with the player during a dash if abs(self.game.player.dashing) >= 50: if self.rect().colliderect(self.game.player.rect()): # Apply screenshake effect self.game.screenshake = max(16, self.game.screenshake) self.game.sfx['hit'].play() # Play hit sound for i in range(30): angle = random.random() * math.pi * 2 speed = random.random() * 5 self.game.sparks.append( Spark(self.rect().center, angle, 2 + random.random()) ) self.game.particles.append( Particle( self.game, "particle", self.rect().center, velocity=[ math.cos(angle + math.pi) * speed * 0.5, math.sin(angle + math.pi) * speed * 0.5, ], frame=random.randint(0, 7), ) ) self.game.sparks.append( Spark(self.rect().center, 0, 5 + random.random()) ) self.game.sparks.append( Spark(self.rect().center, math.pi, 5 + random.random()) ) # Return True to indicate the enemy should be removed return True def render(self, surf, offset=(0, 0)): """ Renders the enemy on the screen. Args: surf (pygame.Surface): The surface to render on. offset (tuple, optional): The offset position. Defaults to (0, 0). """ # Render the enemy using the parent class's render method super().render(surf, offset=offset) # Render the enemy's gun based on its direction if self.flip: surf.blit( pygame.transform.flip(self.game.assets["gun"], True, False), ( self.rect().centerx - 4 - self.game.assets["gun"].get_width() - offset[0], self.rect().centery - offset[1], ), ) else: surf.blit( self.game.assets["gun"], (self.rect().centerx + 4 - offset[0], self.rect().centery - offset[1]), ) class Player(PhysicsEntity): """ Represents a player in the game. Attributes: game (Game): The game instance. pos (tuple): The position of the player. size (tuple): The size of the player. air_time (int): The time the player has been in the air. jumps (int): The number of jumps the player has remaining. wall_slide (bool): Indicates if the player is currently wall sliding. dashing (int): The dashing status of the player. """ def __init__(self, game, pos, size): """ Initializes a new instance of the Player class. Args: game (Game): The game instance. pos (tuple): The position of the player. size (tuple): The size of the player. """ super().__init__(game, "player", pos, size) # Initialize air time, jumps, wall slide status, and dashing status self.air_time = 0 self.jumps = 1 self.wall_slide = False self.dashing = 0 def update(self, tilemap, movement=(0, 0)): """ Updates the player's position and handles collisions. Args: tilemap (Tilemap): The tilemap of the game. movement (tuple, optional): The movement of the player. Defaults to (0, 0). """ super().update(tilemap, movement=movement) # Increment air time self.air_time += 1 # If the player has been in the air for too long, trigger death if self.air_time > 120: if not self.game.dead: self.game.screenshake = max(16, self.game.screenshake) self.game.dead += 1 # Reset air time and jumps if the player is on the ground if self.collisions["down"]: self.air_time = 0 self.jumps = 1 # Handle wall sliding self.wall_slide = False if (self.collisions["right"] or self.collisions["left"]) and self.air_time > 4: self.wall_slide = True self.velocity[1] = min(self.velocity[1], 0.5) if self.collisions["right"]: self.flip = False else: self.flip = True self.set_action("wall_slide") # Set the player's action based on movement and air time if not self.wall_slide: if self.air_time > 4: self.set_action("jump") elif movement[0] != 0: self.set_action("run") else: self.set_action("idle") # Create particles during dashing if abs(self.dashing) in {60, 50}: for i in range(20): angle = random.random() * math.pi * 2 speed = random.random() * 0.5 + 0.5 pvelocity = [math.cos(angle) * speed, math.sin(angle) * speed] self.game.particles.append( Particle( self.game, "particle", self.rect().center, velocity=pvelocity, frame=random.randint(0, 7), ) ) # Update dashing status if self.dashing > 0: self.dashing = max(0, self.dashing - 1) if self.dashing < 0: self.dashing = min(0, self.dashing + 1) if abs(self.dashing) > 50: self.velocity[0] = abs(self.dashing) / self.dashing * 8 if abs(self.dashing) == 51: self.velocity[0] *= 0.1 pvelocity = [abs(self.dashing) / self.dashing * random.random() * 3, 0] self.game.particles.append( Particle( self.game, "particle", self.rect().center, velocity=pvelocity, frame=random.randint(0, 7), ) ) # Apply friction to horizontal velocity if self.velocity[0] > 0: self.velocity[0] = max(self.velocity[0] - 0.1, 0) else: self.velocity[0] = min(self.velocity[0] + 0.1, 0) def render(self, surf, offset=(0, 0)): # Render the player if not dashing """ Renders the player on the screen. Args: surf (Surface): The surface to render on. offset (tuple, optional): The offset of the player. Defaults to (0, 0). """ if abs(self.dashing) <= 50: super().render(surf, offset=offset) def jump(self): """ Handles the player's jump action. Returns: bool: True if the player successfully jumps, False otherwise. """ if self.wall_slide: if self.flip and self.last_movement[0] < 0: self.velocity[0] = 3.5 self.velocity[1] = -2.5 self.air_time = 5 self.jumps = max(0, self.jumps - 1) return True elif not self.flip and self.last_movement[0] > 0: self.velocity[0] = -3.5 self.velocity[1] = -2.5 self.air_time = 5 self.jumps = max(0, self.jumps - 1) return True # Handle regular jump elif self.jumps: self.velocity[1] = -3 self.jumps -= 1 self.air_time = 5 return True def dash(self): """ Initiates the player's dash action. """ if not self.dashing: if self.flip: self.dashing = -60 else: self.dashing = 60 class Particle(pygame.sprite.Sprite): """ Represents a particle in the game. Attributes: game (Game): The game instance. p_type (str): The type of the particle. pos (list): The position of the particle. velocity (list): The velocity of the particle. frame (int): The starting frame of the particle's animation. animation (Animation): The animation for the particle type. Methods: update(): Updates the particle's position and animation. render(surf, offset): Renders the particle on the given surface with the given offset. """ def __init__(self, game, p_type, pos, velocity=[0, 0], frame=0): """ Initializes a new Particle object. Args: game (Game): The game instance. p_type (str): The type of the particle. pos (list): The position of the particle. velocity (list, optional): The velocity of the particle. Defaults to [0, 0]. frame (int, optional): The starting frame of the particle's animation. Defaults to 0. """ super().__init__() # Initialize the parent Sprite class self.game = game self.type = p_type self.pos = list(pos) self.velocity = list(velocity) # Load the animation for the particle type and set the starting frame self.animation = self.game.assets["particle/" + p_type].copy() self.animation.frame = frame def update(self): """ Updates the particle's position and animation. Returns: bool: True if the particle should be removed, False otherwise. """ kill = False # If the animation is done, set the kill flag to True if self.animation.done: kill = True # Update the particle's position based on its velocity self.pos[0] += self.velocity[0] self.pos[1] += self.velocity[1] # Update the animation self.animation.update() # Return the kill flag to indicate if the particle should be removed return kill def render(self, surf, offset=(0, 0)): """ Renders the particle on the given surface with the given offset. Args: surf (Surface): The surface to render the particle on. offset (tuple, optional): The offset of the particle's position. Defaults to (0, 0). """ img = self.animation.img() # Use img.get_rect() to correctly position the image rect = img.get_rect(center=(self.pos[0] - offset[0], self.pos[1] - offset[1])) surf.blit(img, rect.topleft) class Cloud(pygame.sprite.Sprite): def __init__(self, pos, img, speed, depth): """ Initialize a Cloud object. Args: pos (tuple): The initial position of the cloud as a tuple (x, y). img (Surface): The image representing the cloud. speed (float): The speed at which the cloud moves horizontally. depth (float): The depth of the cloud, affecting the parallax effect. Returns: None """ super().__init__() # Initialize the parent Sprite class self.pos = list(pos) self.img = img self.speed = speed self.depth = depth def update(self): """ Update the position of the cloud based on its speed. Args: None Returns: None """ self.pos[0] += self.speed def render(self, surf, offset=(0, 0)): """ Render the cloud on the given surface with a parallax effect. Args: surf (Surface): The surface on which to render the cloud. offset (tuple, optional): The offset to apply to the cloud's position for parallax effect. Defaults to (0, 0). Returns: None """ render_pos = ( self.pos[0] - offset[0] * self.depth, self.pos[1] - offset[1] * self.depth, ) # Draw the cloud image on the surface, wrapping around the screen edges surf.blit( self.img, ( render_pos[0] % (surf.get_width() + self.img.get_width()) - self.img.get_width(), render_pos[1] % (surf.get_height() + self.img.get_height()) - self.img.get_height(), ), ) class Clouds(pygame.sprite.Sprite): """ Represents a collection of clouds in the game. Attributes: - cloud_images (list): A list of cloud images. - count (int): The number of clouds to create (default is 16). - clouds (list): A list of Cloud objects representing the clouds. Methods: - __init__(self, cloud_images, count=16): Initializes the Clouds object. - update(self): Updates the position of each cloud. - render(self, surf, offset=(0, 0)): Renders each cloud on the given surface with the specified offset. - fade_out(self): Creates a fade-out transition effect. """ def __init__(self, cloud_images, count=16): super().__init__() # Initialize the parent Sprite class # Initialize the list of clouds self.clouds = [] # Create the specified number of clouds with random properties for i in range(count): self.clouds.append( Cloud( (random.random() * 99999, random.random() * 99999), random.choice(cloud_images), random.random() * 0.05 + 0.05, random.random() * 0.6 + 0.2, ) ) # Sort the clouds by their depth for proper rendering order self.clouds.sort(key=lambda x: x.depth) def update(self): # Update each cloud's position for cloud in self.clouds: cloud.update() def render(self, surf, offset=(0, 0)): # Render each cloud with the given offset for cloud in self.clouds: cloud.render(surf, offset=offset) def fade_out(self): # Create a fade-out transition effect for i in range(30): transition_surf = pygame.Surface(self.display.get_size()) pygame.draw.circle( transition_surf, (255, 255, 255), (self.display.get_width() // 2, self.display.get_height() // 2), i * 8, ) transition_surf.set_colorkey((255, 255, 255)) self.display.blit(transition_surf, (0, 0)) self.screen.blit( pygame.transform.scale(self.display, self.screen.get_size()), (0, 0) ) pygame.display.update() self.clock.tick(30) if __name__ == "__main__": Game().run()