""" --------------------------------------------- Project: Dungeon Run Standard: School: Tauranga Boys College Author: Aidan Plummer Date: May 2025 Python: 3.11.9 --------------------------------------------- """ # 1:54:10 import pygame import sys from time import sleep from pytmx.util_pygame import load_pygame class Tile(pygame.sprite.Sprite): # Sprite class to handle tiles def __init__(self,pos,surf,groups, fov, alpha=None): super().__init__(groups) new_size = (int(surf.get_width() * fov), int(surf.get_height() * fov)) self.image = pygame.transform.scale(surf, new_size) new_pos = (int(pos[0] * fov), int(pos[1] * fov)) self.rect = self.image.get_rect(topleft = new_pos) if alpha is not None: self.image.set_alpha(alpha) class Player(pygame.sprite.Sprite): def __init__(self, pos, groups, speed, collision_group, debug=False): super().__init__(groups) self.debug = debug raw_image = pygame.image.load("assets/BlueWizard/2BlueWizardIdle/Chara - BlueIdle00000.png") self.original_image = pygame.transform.scale(raw_image, (200, 200)) # Save original image self.image = self.original_image self.rect = self.image.get_rect(center=pos) # set image rect self.rect = pygame.Rect(0, 0, 60, 50) # Changes the hitbox size self.rect.center = pos self.image_rect = self.image.get_rect(midbottom=self.rect.midbottom) self.image_offset_y = 65 # Changes the y offset of the image (not the collision hitbox) self.image_rect.y += self.image_offset_y self.speed = speed self.velocity_y = 0 self.gravity = 2 self.jump_strength = 30 self.on_ground = False self.attacking = False self.facing_right = True self.state = 'idle' self.lives = 3 self.collision_group = collision_group # Animations # Idle Animation self.idle_frames = [pygame.transform.scale( pygame.image.load(f"assets/BlueWizard/2BlueWizardIdle/Chara - BlueIdle0000{i}.png"), (200, 200) ) for i in range(20)] # range = num of images in animation self.idle_frame_index = 0 self.idle_animation_timer = 0 self.idle_animation_speed = 0.1 # larger = faster animation self.walking_frames = [pygame.transform.scale( pygame.image.load(f"assets/BlueWizard/2BlueWizardWalk/Chara_BlueWalk0000{i}.png"), (200, 200) ) for i in range(20)] # range = num of images in animation self.walking_frame_index = 0 self.walking_animation_timer = 0 self.walking_animation_speed = 0.3 # larger = faster animation def update_image(self): if self.facing_right: self.image = self.original_image else: self.image = pygame.transform.flip(self.original_image, True, False) keys = pygame.key.get_pressed() if not (keys[pygame.K_a] or keys[pygame.K_LEFT] or keys[pygame.K_d] or keys[pygame.K_RIGHT]): self.play_idle_animation() if (keys[pygame.K_a] or keys[pygame.K_LEFT] or keys[pygame.K_d] or keys[pygame.K_RIGHT]): self.play_walking_animation() def play_idle_animation(self): self.state = 'idle' # Timer so we dont load animations per frame (was killing my bad laptop) self.idle_animation_timer += self.idle_animation_speed if self.idle_animation_timer >= 1: self.idle_animation_timer = 0 self.idle_frame_index = (self.idle_frame_index + 1) % len(self.idle_frames) frame = self.idle_frames[self.idle_frame_index] # Gets next image if self.facing_right: self.image = frame else: self.image = pygame.transform.flip(frame, True, False) # Positioning onto player self.image_rect = self.image.get_rect(midbottom=self.rect.midbottom) self.image_rect.y += self.image_offset_y def play_walking_animation(self): self.state = 'walking' #Animation timer self.walking_animation_timer += self.walking_animation_speed if self.walking_animation_timer >= 1: self.walking_animation_timer = 0 self.walking_frame_index = (self.walking_frame_index + 1) % len(self.walking_frames) frame = self.walking_frames[self.walking_frame_index] # Gets next image if self.facing_right: self.image = frame else: self.image = pygame.transform.flip(frame, True, False) # Positioning onto player self.image_rect = self.image.get_rect(midbottom=self.rect.midbottom) self.image_rect.y += self.image_offset_y def update(self): global stage # Scroll camera if player is past screen boundaries if self.rect.centerx - CAMERA_OFFSET.x < LEFT_SCROLL_BOUNDARY and CAMERA_OFFSET.x >= tmx_data.width: CAMERA_OFFSET.x = self.rect.centerx - LEFT_SCROLL_BOUNDARY elif self.rect.centerx - CAMERA_OFFSET.x > RIGHT_SCROLL_BOUNDARY and CAMERA_OFFSET.x+screen_width <= tmx_data.width*(tmx_data.tilewidth*fov)-10: CAMERA_OFFSET.x = self.rect.centerx - RIGHT_SCROLL_BOUNDARY # If player leaves screen bounds (left or right), they move to the corresponding map if self.rect.right < 0: print("Left Switch") right_spawn = GetRightSpawn(tmx_data) if stage == "stage.cavern_1": SwitchMap("Cavern_-1.tmx", (right_spawn, self.rect.centery), stage_name="stage.cavern_-1") elif stage == "stage.cavern_2": SwitchMap('Cavern_1.tmx', (right_spawn, self.rect.centery), stage_name="stage.cavern_1") elif self.rect.left > tmx_data.width*(tmx_data.tilewidth*fov)-10: print("Right Switch") if stage == "stage.cavern_1": SwitchMap("Cavern_2.tmx", (30, self.rect.centery), stage_name="stage.cavern_2") elif stage == "stage.cavern_-1": SwitchMap("Cavern_1.tmx", (30, self.rect.centery), stage_name="stage.cavern_1") keys = pygame.key.get_pressed() if keys[pygame.K_a] or keys[pygame.K_LEFT]: self.rect.x -= self.speed # Checks for collision on the left side collided_tiles = pygame.sprite.spritecollide(self, self.collision_group, False) for tile in collided_tiles: if self.rect.colliderect(tile.rect): self.rect.left = tile.rect.right break self.facing_right = False if keys[pygame.K_d] or keys[pygame.K_RIGHT]: self.rect.x += self.speed # Checks for collision on the right side collided_tiles = pygame.sprite.spritecollide(self, self.collision_group, False) for tile in collided_tiles: if self.rect.colliderect(tile.rect): self.rect.right = tile.rect.left break self.facing_right = True # Detects when E or F is played and fires a spell :) if (keys[pygame.K_e] or keys[pygame.K_f]) and not self.attacking: self.attacking = True spell = Spell(pos=(self.rect.centerx, self.rect.centery), facing_right=self.facing_right, groups=(spell_group,)) # Handles jumping if self.on_ground and (keys[pygame.K_SPACE] or keys[pygame.K_w] or keys[pygame.K_UP]): self.velocity_y = -self.jump_strength self.on_ground = False # Gravity self.velocity_y += self.gravity self.rect.y += self.velocity_y collided_tiles = pygame.sprite.spritecollide(self, self.collision_group, False) if self.debug: print(f"Player Y: {self.rect.y}, Player X: {self.rect.x}, VelY: {self.velocity_y}, Collisions: {len(collided_tiles)}") if collided_tiles: # Detect when player touches layers that are set in collided_tiles sprite group for tile in collided_tiles: if self.velocity_y > 0: self.rect.bottom = tile.rect.top self.velocity_y = 0 self.on_ground = True break elif self.velocity_y < 0: self.rect.top = tile.rect.bottom self.velocity_y = 0 else: self.on_ground = False if self.lives <= 0: GameOver() self.image_rect.midbottom = self.rect.midbottom self.image_rect.y += self.image_offset_y self.update_image() class Spell(pygame.sprite.Sprite): def __init__(self, pos, facing_right, groups): super().__init__(groups) print("Spell firing!") raw_image = pygame.image.load("assets/DarkVFX Frames/Dark VFX 1 (40x32)1.png") self.original_image = pygame.transform.scale(raw_image, (80, 64)) self.image = self.original_image if facing_right else pygame.transform.flip(self.original_image, True, False) self.rect = self.image.get_rect(center=pos) self.rect = pygame.Rect(0, 0, 15, 32) # Changes the hitbox size self.rect.center = pos self.facing_right = facing_right self.speed = 15 if facing_right else -15 self.collision_group = collision_group offset = 30 if facing_right else -30 self.rect.x += offset self.anim_frames = [pygame.transform.scale( pygame.image.load(f"assets\DarkVFX Frames\Dark VFX 1 (40x32){i+1}.png"), (80, 64) ) for i in range(17)] # range = num of images in animation self.frame_index = 0 self.animation_timer = 0 self.animation_speed = .4 # larger = faster animation def update(self): self.rect.x += self.speed # Removes spell if it leaves screen boundary (map) if self.rect.left < 0 or self.rect.right > tmx_data.width*(tmx_data.tilewidth*fov)-10: self.kill() player.attacking = False collided_tiles = pygame.sprite.spritecollide(self, self.collision_group, False) for tile in collided_tiles: if self.rect.colliderect(tile.rect): self.kill() player.attacking = False self.animation_timer += self.animation_speed if self.animation_timer >= 1: self.animation_timer = 0 self.frame_index += 1 if self.frame_index >= len(self.anim_frames): self.kill() player.attacking = False return frame = self.anim_frames[self.frame_index] # Gets next image if self.facing_right: self.image = frame else: self.image = pygame.transform.flip(frame, True, False) # Positioning onto spell self.image_rect = self.image.get_rect(midbottom=self.rect.midbottom) # Check collision with slimes hit_slimes = pygame.sprite.spritecollide(self, slime_group, False) for slime in hit_slimes: if self.rect.colliderect(slime.rect): print("Hit Slime") slime.kill() self.kill() player.attacking = False return class GreenSlime(pygame.sprite.Sprite): def __init__(self, pos, groups, speed, debug=False): super().__init__(groups) self.debug = debug raw_image = pygame.image.load("assets\SlimeGreen\SlimeBasic_00000.png") self.original_image = pygame.transform.scale(raw_image, (100, 60)) # Save original image self.image = self.original_image self.rect = self.image.get_rect(center=pos) # set image rect self.rect = pygame.Rect(0, 0, 100, 60) # Changes the hitbox size self.rect.center = pos self.velocity_y = 0 self.gravity = 2 self.speed = speed self.facing_right = True self.on_ground = False self.knockback_frames = 0 self.knockback_speed = 10 self.knockback_direction = 1 self.collision_group = collision_group self.image_rect = self.image.get_rect(midbottom=self.rect.midbottom) self.image_offset_y = 100 # Changes the y offset of the image (not the collision hitbox) self.image_rect.y += self.image_offset_y def update(self): # Knockback happens before any movement if self.knockback_frames > 0: self.rect.x += self.knockback_direction * self.knockback_speed self.knockback_frames -= 1 # Prevent sliding through tiles during knockback collided_tiles = pygame.sprite.spritecollide(self, self.collision_group, False) for tile in collided_tiles: if self.knockback_direction > 0: self.rect.right = tile.rect.left else: self.rect.left = tile.rect.right return # Skip rest of update during knockback #Gravity self.velocity_y += self.gravity self.rect.y += self.velocity_y # Vertical collision collided_tiles = pygame.sprite.spritecollide(self, self.collision_group, False) self.on_ground = False for tile in collided_tiles: if self.rect.colliderect(tile.rect): if self.velocity_y > 0: # falling self.rect.bottom = tile.rect.top self.velocity_y = 0 self.on_ground = True elif self.velocity_y < 0: # jumping self.rect.top = tile.rect.bottom self.velocity_y = 0 # Horizontal Movement if self.facing_right: self.rect.x += self.speed else: self.rect.x -= self.speed # Horizontal collision collided_tiles = pygame.sprite.spritecollide(self, self.collision_group, False) for tile in collided_tiles: if self.rect.colliderect(tile.rect): if self.facing_right: self.rect.right = tile.rect.left self.facing_right = False else: self.rect.left = tile.rect.right self.facing_right = True hit_slimes = pygame.sprite.spritecollide(self, slime_group, False) for slime in hit_slimes: if slime == self: continue # Doesnt collide itself if self.rect.colliderect(slime.rect): if self.facing_right: self.rect.right = slime.rect.left self.facing_right = False else: self.rect.left = slime.rect.right self.facing_right = True break if self.rect.colliderect(player.rect) and self.knockback_frames == 0: player.lives -= 1 self.knockback_frames = 10 self.knockback_direction = -1 if self.facing_right else 1 # Flip image if needed if self.facing_right: self.image = self.original_image else: self.image = pygame.transform.flip(self.original_image, True, False) self.image_rect.midbottom = self.rect.midbottom self.image_rect.y += self.image_offset_y def MainMenu(): # Sets all the things needed for main menu, mainly the visual stuff global tmx_data, fov, text, text_rect, textbg, textbg_rect, play_text, play_text_rect, playbg, playbg_rect # Local Variables white = (255,255,255) black = (0,0,0) # Game title and play button texts font = pygame.font.Font('Trajan Pro Bold.ttf', 64) text = font.render("CaveQuest", True, white) text_rect = text.get_rect() text_rect.center = (screen_width / 2, screen_height / 3) font = pygame.font.Font('Trajan Pro Bold.ttf', 64) # Drop shadow for text textbg = font.render("CaveQuest", True, black) textbg_rect = textbg.get_rect() textbg_rect.center = (screen_width / 2-4, screen_height / 3+4) raw_image = pygame.image.load("assets\play_background.png") original_image = pygame.transform.scale(raw_image, (150, 70)) # Save original image playbg = original_image playbg_rect = playbg.get_rect() playbg_rect.center = (screen_width / 2, screen_height / 2) font = pygame.font.Font('Trajan Pro Bold.ttf', 32) play_text = font.render("Play", True, white) play_text_rect = play_text.get_rect() play_text_rect.center = (screen_width / 2, screen_height / 2) fov = .25 tmx_data = load_pygame('mainmenu.tmx') LoadMap(tmx_data=tmx_data, stage="stage.menu") # Loads the main menu background def GameOver(): global game_over, game_over_text, game_over_text_rect game_over = True pygame.mixer.music.stop() # Local Variables white = (255,255,255) font = pygame.font.Font('Trajan Pro Bold.ttf', 32) game_over_text = font.render("GAME OVER", True, white) game_over_text_rect = game_over_text.get_rect() game_over_text_rect.center = (screen_width / 2, screen_height / 2) print("Game Over") def LoadMap(tmx_data, stage): for obj in tmx_data.objects: # Finds objects (a special type of tile in tiled) and renders them. pos = obj.x, obj.y if obj.image: Tile(pos = pos, surf = obj.image, groups = sprite_group, fov=fov) # Cycle through all layers for layer in tmx_data.visible_layers: if hasattr(layer,'data'): # Only layers with tiles for x,y,surf in layer.tiles(): pos = x * 256, y * 256 # !!! IMPORTANT !!! - Tile size, 256 # Alpha = transparancy if stage == "stage.cavern_1" or stage == "stage.menu": if layer.name == "MainLayer" or layer.name == "MenuLayer": # Normal Render alpha=None Tile(pos = pos, surf = surf, groups = (collision_group, main_group), fov=fov, alpha=alpha) if layer.name == "NonCollision": # Render without collision alpha=None Tile(pos = pos, surf = surf, groups = (main_group), fov=fov, alpha=alpha) if layer.name == "BackgroundLevel": # Checks if tile is part of this layer and passes arguments based on that, alpha = transparency alpha=128 Tile(pos = pos, surf = surf, groups = background_group, fov=fov, alpha=alpha) if stage == "stage.cavern_-1" or stage == "stage.cavern_2": if layer.name == "MainLayer": # Normal Render alpha=None Tile(pos = pos, surf = surf, groups = (collision_group, main_group), fov=fov, alpha=alpha) if layer.name == "NonCollision": # Render without collision alpha=None Tile(pos = pos, surf = surf, groups = (main_group), fov=fov, alpha=alpha) if layer.name == "BackgroundLevel": # Checks if tile is part of this layer and passes arguments based on that, alpha = transparency alpha=128 Tile(pos = pos, surf = surf, groups = background_group, fov=fov, alpha=alpha) # Spawn slime enemies in cavern_2 stage if stage == "stage.cavern_2": GreenSlime(pos=(800,400), groups=(slime_group, sprite_group), speed=2, debug=False) GreenSlime(pos=(1600,400), groups=(slime_group, sprite_group), speed=2, debug=False) GreenSlime(pos=(1900,400), groups=(slime_group, sprite_group), speed=2, debug=False) def SwitchMap(new_map_file, new_player_pos, stage_name): global tmx_data, stage ClearGroups() try: # Load new tiled map data tmx_data = load_pygame(new_map_file) except Exception as e: print(f"Failed to load map '{new_map_file}': {e}") return stage = stage_name LoadMap(tmx_data=tmx_data, stage=stage) sprite_group.add(player) # Set player position precisely on the new map player.rect.center = new_player_pos player.image_rect.midbottom = player.rect.midbottom # Calculate max camera offset based on map and screen size, considering zoom max_offset_x = max(0, tmx_data.width * tmx_data.tilewidth * fov - screen_width) max_offset_y = max(0, tmx_data.height * tmx_data.tileheight * fov - screen_height) # Center camera on player, but snap to screen edges CAMERA_OFFSET.x = player.rect.centerx - screen_width // 2 CAMERA_OFFSET.y = player.rect.centery - screen_height // 2 if CAMERA_OFFSET.x < 0: CAMERA_OFFSET.x = 0 elif CAMERA_OFFSET.x > max_offset_x: CAMERA_OFFSET.x = max_offset_x if CAMERA_OFFSET.y < 0: CAMERA_OFFSET.y = 0 elif CAMERA_OFFSET.y > max_offset_y: CAMERA_OFFSET.y = max_offset_y def ClearGroups(): # Clear all sprite groups to reset the game state or start fresh sprite_group.empty() foreground_group.empty() main_group.empty() background_group.empty() collision_group.empty() slime_group.empty() def Play(): global player, tmx_data, fov, stage # Load the tiled map for stage.cavern_1 tmx_data = load_pygame('Cavern_1.tmx') LoadMap(tmx_data=tmx_data, stage="stage.cavern_1") stage="stage.cavern_1" #Stops any playing music pygame.mixer.music.fadeout = 1 pygame.mixer.music.stop() fov=0.25 # !!! IMPORTANT !!! - Creates the player at the starting position with relevant groups and settings player = Player(pos=(550, 50), groups=sprite_group, speed=8, collision_group=collision_group, debug=False) # Player starting position and arguments def GetRightSpawn(tmx_data): # Calculate the right-side spawn position based on map width and field of view return int(tmx_data.width * tmx_data.tilewidth * fov - 100) # START OF RUNNING CODE pygame.init() clock = pygame.time.Clock() # Essential Variables screen_width = 350*3 screen_height = 250*3 LEFT_SCROLL_BOUNDARY = screen_width // 3 RIGHT_SCROLL_BOUNDARY = screen_width * 2 // 3 CAMERA_OFFSET = pygame.Vector2(0, 0) game_over = False main_menu = True stage = "stage.menu" player = None tmx_data = None fov = 0.25 # How much the camera is zoomed out, smaller = zoom in, larger = zoom out, 0.25 default fps = 60 display = pygame.display.set_mode((screen_width,screen_height)) pygame.display.set_caption("CaveQuest") sprite_group = pygame.sprite.Group() foreground_group = pygame.sprite.Group() main_group = pygame.sprite.Group() background_group = pygame.sprite.Group() sprite_group = pygame.sprite.Group() visible_sprites = pygame.sprite.Group() collision_group = pygame.sprite.Group() slime_group = pygame.sprite.Group() spell_group = pygame.sprite.Group() # Audio Variables try: pygame.mixer.music.load('assets/Audio/MainMenu.mp3') pygame.mixer.music.play(loops=-1, fade_ms=100) except pygame.error as e: print(f"Error loading music: {e}") # Background Images try: mushroomcavebg1 = pygame.image.load("assets/background/Mushroom_Cave_L1.png") mushroomcavebg1 = pygame.transform.scale(mushroomcavebg1, (screen_width, screen_height)) mushroomcavebg2 = pygame.image.load("assets/background/Mushroom_Cave_L2.png") mushroomcavebg2 = pygame.transform.scale(mushroomcavebg2, (screen_width, screen_height)) mushroomcavebg3 = pygame.image.load("assets/background/Mushroom_Cave_L3.png") mushroomcavebg3 = pygame.transform.scale(mushroomcavebg3, (screen_width, screen_height)) mushroomcavebg4 = pygame.image.load("assets/background/Mushroom_Cave_L4.png") mushroomcavebg4 = pygame.transform.scale(mushroomcavebg4, (screen_width, screen_height)) except pygame.error as e: print(f"Error loading background images: {e}") MainMenu() while main_menu == True: # Main Menu Loop for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() sys.exit() if event.type == pygame.MOUSEBUTTONDOWN: if event.button == 1: # Left mouse button if play_text_rect.collidepoint(event.pos) or playbg_rect.collidepoint(event.pos): main_menu = False ClearGroups() Play() print("Starting Game") # Display all relevant visual effects for menu display.blit(mushroomcavebg1, (0, 0)) display.blit(mushroomcavebg2, (0, 0)) display.blit(mushroomcavebg3, (0, 0)) display.blit(mushroomcavebg4, (0, 0)) display.blit(textbg, textbg_rect) display.blit(text, text_rect) display.blit(playbg, (screen_width / 2 - 150 / 2, screen_height / 2 - 70 / 2)) display.blit(play_text, play_text_rect) # Draw background, main, and foreground sprites (menu scene) for sprite in background_group: display.blit(sprite.image, sprite.rect.topleft) for sprite in main_group: display.blit(sprite.image, sprite.rect.topleft) for sprite in foreground_group: display.blit(sprite.image, sprite.rect.topleft) pygame.display.update() clock.tick(fps) while True and not main_menu == True: # Gameplay Loop for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() sys.exit() if game_over == False: # Update all relevant sprite groups sprite_group.update() slime_group.update() spell_group.update() display.blit(mushroomcavebg1, (0, 0)) background_group.update() foreground_group.update() # Draw all background sprites with camera offset applied for sprite in background_group: display.blit(sprite.image, sprite.rect.topleft - CAMERA_OFFSET) # Draw player (with camera offset) display.blit(player.image, player.image_rect.topleft - CAMERA_OFFSET) # Draws player on screen # Draw main sprites and spells for sprite in main_group: display.blit(sprite.image, sprite.rect.topleft - CAMERA_OFFSET) for spell in spell_group: display.blit(spell.image, spell.rect.topleft - CAMERA_OFFSET) # Draw foreground sprites for sprite in foreground_group: display.blit(sprite.image, sprite.rect.topleft - CAMERA_OFFSET) # Check if player fell below screen (death) if player.rect.y >= screen_height: GameOver() # Draw slimes with debug rectangles if debug mode is on for slime in slime_group: display.blit(slime.image, slime.rect.topleft - CAMERA_OFFSET) if slime.debug: debug_rect = slime.rect.copy() debug_rect.topleft -= CAMERA_OFFSET pygame.draw.rect(display, (255, 255, 255), debug_rect, 2) else: # Display Game Over text if game is over display.blit(game_over_text, game_over_text_rect) pygame.display.update() clock.tick(fps)