#!/usr/bin/env python3 # -*- coding: utf-8 -*- import json import os import pygame from libstormgames import * from src.level import Level from src.object import Object from src.player import Player from src.game_selection import select_game, get_level_path from src.save_manager import SaveManager class WickedQuest: def __init__(self): """Initialize game and load sounds.""" self.sounds = initialize_gui("Wicked Quest") self.currentLevel = None self.gameStartTime = None self.lastThrowTime = 0 self.throwDelay = 250 self.player = None self.currentGame = None self.runLock = False # Toggle behavior of the run keys self.saveManager = SaveManager() def load_level(self, levelNumber): """Load a level from its JSON file.""" levelFile = get_level_path(self.currentGame, levelNumber) try: with open(levelFile, 'r') as f: levelData = json.load(f) # Create player if this is the first level if self.player is None: self.player = Player(levelData["player_start"]["x"], levelData["player_start"]["y"], self.sounds) else: # Reset player for new level. self.player.isDucking = False self.player.xPos = levelData["player_start"]["x"] self.player.yPos = levelData["player_start"]["y"] self.player.isInvincible = False if hasattr(self.player, '_last_countdown'): del self.player._last_countdown self.player.invincibilityStartTime = 0 # Clean up spider web effects if hasattr(self.player, 'webPenaltyEndTime'): del self.player.webPenaltyEndTime # Remove the penalty timer self.player.moveSpeed *= 2 # Restore normal speed if self.player.currentWeapon: self.player.currentWeapon.attackDuration *= 0.5 # Restore normal attack speed # Pass existing player to new level pygame.event.clear() self.currentLevel = Level(levelData, self.sounds, self.player) return True except FileNotFoundError: return False def validate_levels(self): """Check if level files have valid JSON.""" errors = [] # Check levels from 1 until no more files are found levelNumber = 1 while True: levelPath = get_level_path(self.currentGame, levelNumber) if not os.path.exists(levelPath): break try: with open(levelPath, 'r') as f: # This will raise an exception if JSON is invalid json.load(f) except json.JSONDecodeError as e: errors.append(f"Level {levelNumber}: Invalid JSON format - {str(e)}") except Exception as e: errors.append(f"Level {levelNumber}: Error reading file - {str(e)}") levelNumber += 1 return errors def load_game_menu(self): """Display load game menu with available saves""" save_files = self.saveManager.get_save_files() if not save_files: messagebox("No save files found.") return None # Create menu options options = [] for save_file in save_files: options.append(save_file['display_name']) options.append("Cancel") # Show menu currentIndex = 0 lastSpoken = -1 messagebox("Select a save file to load:") while True: if currentIndex != lastSpoken: speak(options[currentIndex]) lastSpoken = currentIndex event = pygame.event.wait() if event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: return None elif event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(options) - 1: currentIndex += 1 try: self.sounds['menu-move'].play() except: pass elif event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0: currentIndex -= 1 try: self.sounds['menu-move'].play() except: pass elif event.key == pygame.K_RETURN: try: self.sounds['menu-select'].play() except: pass if currentIndex == len(options) - 1: # Cancel return None else: return save_files[currentIndex] pygame.event.clear() def auto_save(self): """Automatically save the game if player has enough bone dust""" if not self.player.can_save(): return False # Automatically create save try: success, message = self.saveManager.create_save( self.player, self.currentLevel.levelId, self.gameStartTime, self.currentGame ) if success: try: if 'save' in self.sounds: play_sound(self.sounds['save']) else: print("Save sound not found in sounds dictionary") except Exception as e: print(f"Error playing save sound: {e}") pass # Continue if save sound fails to play else: print(f"Save failed: {message}") return success except Exception as e: print(f"Error during save: {e}") return False def handle_input(self): """Process keyboard input for player actions.""" keys = pygame.key.get_pressed() player = self.currentLevel.player currentTime = pygame.time.get_ticks() # Update running and ducking states if (keys[pygame.K_s] or keys[pygame.K_DOWN]) and not player.isDucking: player.duck() elif (not keys[pygame.K_s] and not keys[pygame.K_DOWN]) and player.isDucking: player.stand() if self.runLock: player.isRunning = not (keys[pygame.K_SPACE] or keys[pygame.K_LSHIFT] or keys[pygame.K_RSHIFT]) else: player.isRunning = keys[pygame.K_SPACE] or keys[pygame.K_LSHIFT] or keys[pygame.K_RSHIFT] # Get current speed (handles both running and jumping) currentSpeed = player.get_current_speed() # Track movement distance for this frame movementDistance = 0 # Horizontal movement if keys[pygame.K_a] or keys[pygame.K_LEFT]: # Left movementDistance = currentSpeed player.xPos -= currentSpeed player.facingRight = False elif keys[pygame.K_d] or keys[pygame.K_RIGHT]: # Right movementDistance = currentSpeed player.xPos += currentSpeed player.facingRight = True # Handle footsteps if movementDistance > 0 and not player.isJumping: player.distanceSinceLastStep += movementDistance if player.should_play_footstep(currentTime): play_sound(self.sounds[player.footstepSound]) player.distanceSinceLastStep = 0 player.lastStepTime = currentTime # Status queries if keys[pygame.K_c]: speak(f"{player.get_coins()} bone dust for extra lives, {player.get_save_bone_dust()} bone dust for saves") if keys[pygame.K_h]: speak(f"{player.get_health()} health of {player.get_max_health()}") if keys[pygame.K_i]: speak(f"Level {self.currentLevel.levelId}, {self.currentLevel.levelName}. {player.get_health()} health of {player.get_max_health()}. {int(self.currentLevel.levelScore)} points on this level so far. {player.get_lives()} lives remaining.") if keys[pygame.K_l]: speak(f"{player.get_lives()} lives") if keys[pygame.K_j]: # Check jack o'lanterns speak(f"{player.get_jack_o_lanterns()} jack o'lanterns") if keys[pygame.K_f] or keys[pygame.K_z] or keys[pygame.K_SLASH]: currentTime = pygame.time.get_ticks() if currentTime - self.lastThrowTime >= self.throwDelay: self.currentLevel.throw_projectile() self.lastThrowTime = currentTime if keys[pygame.K_e]: speak(f"Wielding {self.currentLevel.player.currentWeapon.name.replace('_', ' ')}") # Handle attack with either CTRL key if (keys[pygame.K_LCTRL] or keys[pygame.K_RCTRL]) and player.start_attack(currentTime): play_sound(self.sounds[player.currentWeapon.attackSound]) # Handle jumping if (keys[pygame.K_w] or keys[pygame.K_UP]) and not player.isJumping: player.isJumping = True player.jumpStartTime = currentTime play_sound(self.sounds['jump']) # Check if jump should end if player.isJumping and currentTime - player.jumpStartTime >= player.jumpDuration: player.isJumping = False play_sound(self.sounds[player.footstepSound]) # Landing sound # Reset step distance tracking after landing player.distanceSinceLastStep = 0 player.lastStepTime = currentTime def display_level_stats(self, timeTaken): """Display level completion statistics.""" # Convert time from milliseconds to minutes:seconds minutes = timeTaken // 60000 seconds = (timeTaken % 60000) // 1000 # Update time in stats self.currentLevel.player.stats.update_stat('Total time', timeTaken, levelOnly=True) report = [f"Time taken: {minutes} minutes and {seconds} seconds"] # Add all level stats for key in self.currentLevel.player.stats.level: if key != 'Total time': # Skip time since we already displayed it report.append(f"{key}: {self.currentLevel.player.stats.get_level_stat(key)}") report.append(f"Score: {int(self.currentLevel.levelScore)}") # Stop all sounds and music then play fanfare try: speak(f"Level {self.currentLevel.levelId}, {self.currentLevel.levelName}, complete!") pygame.mixer.music.stop() cut_scene(self.sounds, '_finish_level') except: pass display_text(report) self.currentLevel.player.stats.reset_level() def display_game_over(self, timeTaken): """Display game over screen with statistics.""" minutes = timeTaken // 60000 seconds = (timeTaken % 60000) // 1000 report = ["Game Over!"] report.append(f"Time taken: {minutes} minutes and {seconds} seconds") # Add all total stats for key in self.currentLevel.player.stats.total: if key not in ['Total time', 'levelsCompleted']: # Skip these report.append(f"Total {key}: {self.currentLevel.player.stats.get_total_stat(key)}") report.append(f"Final Score: {self.player.scoreboard.get_score()}") if self.player.scoreboard.check_high_score(): pygame.event.clear() self.player.scoreboard.add_high_score() cut_scene(self.sounds, "game_over") display_text(report) def game_loop(self, startingLevelNum=1): """Main game loop handling updates and state changes.""" clock = pygame.time.Clock() levelStartTime = pygame.time.get_ticks() currentLevelNum = startingLevelNum while True: currentTime = pygame.time.get_ticks() pygame.event.pump() # Game volume controls for event in pygame.event.get(): if event.type == pygame.KEYDOWN: # Check for Alt modifier mods = pygame.key.get_mods() altPressed = mods & pygame.KMOD_ALT if event.key == pygame.K_ESCAPE: try: pygame.mixer.music.stop() except: pass return elif event.key in [pygame.K_CAPSLOCK, pygame.K_TAB]: self.runLock = not self.runLock speak("Run lock " + ("enabled." if self.runLock else "disabled.")) elif event.key == pygame.K_BACKSPACE: pause_game() # Volume controls (require Alt) elif altPressed: if event.key == pygame.K_PAGEUP: adjust_master_volume(0.1) elif event.key == pygame.K_PAGEDOWN: adjust_master_volume(-0.1) elif event.key == pygame.K_HOME: adjust_bgm_volume(0.1) elif event.key == pygame.K_END: adjust_bgm_volume(-0.1) elif event.key == pygame.K_INSERT: adjust_sfx_volume(0.1) elif event.key == pygame.K_DELETE: adjust_sfx_volume(-0.1) # Update game state self.currentLevel.player.update(currentTime) self.handle_input() self.currentLevel.update_audio() # Handle combat and projectiles self.currentLevel.handle_combat(currentTime) self.currentLevel.handle_projectiles(currentTime) # Check for death first if self.currentLevel.player.get_health() <= 0: if self.currentLevel.player.get_lives() <= 0: # Game over - use gameStartTime for total time pygame.mixer.stop() totalTime = pygame.time.get_ticks() - self.gameStartTime self.display_game_over(totalTime) return else: pygame.mixer.stop() self.currentLevel.player._health = self.currentLevel.player._maxHealth self.load_level(currentLevelNum) levelStartTime = pygame.time.get_ticks() # Reset level timer continue # Handle collisions and check level completion if self.currentLevel.handle_collisions(): # Level time is from levelStartTime levelTime = pygame.time.get_ticks() - levelStartTime self.display_level_stats(levelTime) currentLevelNum += 1 if self.load_level(currentLevelNum): # Auto save at the beginning of new level if conditions are met self.auto_save() levelStartTime = pygame.time.get_ticks() # Reset level timer for new level continue else: # Game complete - use gameStartTime for total totalTime = pygame.time.get_ticks() - self.gameStartTime if self.player.xPos >= self.currentLevel.rightBoundary: # Check for end of game scene gamePath = os.path.dirname(get_level_path(self.currentGame, 1)) for ext in ['.wav', '.ogg', '.mp3']: endFile = os.path.join(gamePath, f'end{ext}') if os.path.exists(endFile): self.sounds['end_scene'] = pygame.mixer.Sound(endFile) cut_scene(self.sounds, 'end_scene') break else: messagebox("Congratulations! You've completed all available levels!") self.display_game_over(totalTime) return clock.tick(60) # 60 FPS def run(self): """Main game loop with menu system.""" # make sure no music is playing when the menu loads. try: pygame.mixer.music.stop() except: pass while True: # Add load game option if saves exist custom_options = [] if self.saveManager.has_saves(): custom_options.append("load_game") choice = game_menu(self.sounds, None, *custom_options) if choice == "exit": exit_game() elif choice == "load_game": selected_save = self.load_game_menu() if selected_save: success, save_data = self.saveManager.load_save(selected_save['filepath']) if success: # Load the saved game self.currentGame = save_data['game_state']['currentGame'] self.gameStartTime = save_data['game_state']['gameStartTime'] current_level = save_data['game_state']['currentLevel'] # Load the level if self.load_level(current_level): # Restore player state self.saveManager.restore_player_state(self.player, save_data) self.game_loop(current_level) else: messagebox("Failed to load saved level.") else: messagebox(f"Failed to load save: {save_data}") elif choice == "play": self.currentGame = select_game(self.sounds) # Validate level files before starting errors = self.validate_levels() if errors: errorLines = ["Level files contain errors:"] errorLines.extend(errors) errorLines.append("\nPlease fix these errors before playing.") display_text(errorLines) continue if self.currentGame: self.player = None # Reset player for new game self.gameStartTime = pygame.time.get_ticks() if self.load_level(1): self.game_loop() elif choice == "high_scores": board = Scoreboard() scores = board.get_high_scores() lines = ["High Scores:"] for i, entry in enumerate(scores, 1): scoreStr = f"{i}. {entry['name']}: {entry['score']}" lines.append(scoreStr) pygame.event.clear() display_text(lines) elif choice == "learn_sounds": choice = learn_sounds(self.sounds) if __name__ == "__main__": game = WickedQuest() game.run()