From ce353d0ed94ac82196c9d2d8196f5634c9e6f0d3 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 7 Sep 2025 03:13:25 -0400 Subject: [PATCH] Updates to libstormgames submodule. Updated the game to use the menu improvements. Started work on survival mode. --- libstormgames | 2 +- src/game_selection.py | 93 +++++----------- src/level.py | 13 ++- src/survival_generator.py | 224 ++++++++++++++++++++++++++++++++++++++ wicked_quest.py | 180 ++++++++++++++++++++++-------- wicked_quest.spec | 6 +- 6 files changed, 399 insertions(+), 119 deletions(-) create mode 100644 src/survival_generator.py diff --git a/libstormgames b/libstormgames index ca2d0d3..dcd204e 160000 --- a/libstormgames +++ b/libstormgames @@ -1 +1 @@ -Subproject commit ca2d0d34bde656ec20ffdfd88952853d701cf946 +Subproject commit dcd204e476aa06b0ed8d8ba8ba23b0d28f04e14c diff --git a/src/game_selection.py b/src/game_selection.py index 561d734..1bc300b 100644 --- a/src/game_selection.py +++ b/src/game_selection.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- import os +import sys import time import pygame from os.path import isdir, join -from libstormgames import speak +from libstormgames import speak, instruction_menu def get_available_games(): """Get list of available game directories in levels folder. @@ -13,12 +14,21 @@ def get_available_games(): list: List of game directory names """ try: - return [d for d in os.listdir("levels") if isdir(join("levels", d))] + # Handle PyInstaller path issues + if hasattr(sys, '_MEIPASS'): + # Running as PyInstaller executable + base_path = sys._MEIPASS + else: + # Running as script + base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + levels_path = os.path.join(base_path, "levels") + return [d for d in os.listdir(levels_path) if isdir(join(levels_path, d))] except FileNotFoundError: return [] def selection_menu(sounds, *options): - """Display level selection menu. + """Display level selection menu using instruction_menu. Args: sounds (dict): Dictionary of loaded sound effects @@ -27,70 +37,7 @@ def selection_menu(sounds, *options): Returns: str: Selected option or None if cancelled """ - loop = True - pygame.mixer.stop() - i = 0 - j = -1 - - # Clear any pending events - pygame.event.clear() - - speak("Select an adventure") - time.sleep(1.0) - - while loop: - if i != j: - speak(options[i]) - j = i - - pygame.event.pump() - event = pygame.event.wait() - - if event.type == pygame.KEYDOWN: - if event.key == pygame.K_ESCAPE: - return None - - if event.key == pygame.K_DOWN and i < len(options) - 1: - i = i + 1 - try: - sounds['menu-move'].play() - except: - pass - - if event.key == pygame.K_UP and i > 0: - i = i - 1 - try: - sounds['menu-move'].play() - except: - pass - - if event.key == pygame.K_HOME and i != 0: - i = 0 - try: - sounds['menu-move'].play() - except: - pass - - if event.key == pygame.K_END and i != len(options) - 1: - i = len(options) - 1 - try: - sounds['menu-move'].play() - except: - pass - - if event.key == pygame.K_RETURN: - try: - sounds['menu-select'].play() - time.sleep(sounds['menu-select'].get_length()) - except: - pass - return options[i] - elif event.type == pygame.QUIT: - return None - - pygame.event.pump() - event = pygame.event.clear() - time.sleep(0.001) + return instruction_menu(sounds, "Select an adventure", *options) def select_game(sounds): """Display game selection menu and return chosen game. @@ -134,4 +81,14 @@ def get_level_path(gameDir, levelNum): """ if gameDir is None: raise ValueError("gameDir cannot be None") - return os.path.join("levels", gameDir, f"{levelNum}.json") + + # Handle PyInstaller path issues + if hasattr(sys, '_MEIPASS'): + # Running as PyInstaller executable + base_path = sys._MEIPASS + else: + # Running as script + base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + level_path = os.path.join(base_path, "levels", gameDir, f"{levelNum}.json") + return level_path diff --git a/src/level.py b/src/level.py index b0071bc..e4b4b1d 100644 --- a/src/level.py +++ b/src/level.py @@ -40,12 +40,13 @@ class Level: # Pass footstep sound to player self.player.set_footstep_sound(self.footstepSound) - # Level intro message - levelIntro = f"Level {levelData['level_id']}, {levelData['name']}. " - if self.isLocked: - levelIntro += "This is a boss level. You must defeat all enemies before you can advance. " - levelIntro += levelData['description'] - messagebox(levelIntro) + # Level intro message (skip for survival mode) + if levelData['level_id'] != 999: # 999 is survival mode + levelIntro = f"Level {levelData['level_id']}, {levelData['name']}. " + if self.isLocked: + levelIntro += "This is a boss level. You must defeat all enemies before you can advance. " + levelIntro += levelData['description'] + messagebox(levelIntro) # Handle level music try: diff --git a/src/survival_generator.py b/src/survival_generator.py new file mode 100644 index 0000000..15c6342 --- /dev/null +++ b/src/survival_generator.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- + +import json +import os +import random +import copy +from src.game_selection import get_level_path + + +class SurvivalGenerator: + def __init__(self, gamePack): + """Initialize the survival generator for a specific game pack. + + Args: + gamePack (str): Name of the game pack directory + """ + self.gamePack = gamePack + self.levelData = {} + self.objectTemplates = [] + self.enemyTemplates = [] + self.collectibleTemplates = [] + self.hazardTemplates = [] + self.ambientSounds = [] + self.footstepSounds = [] + self.loadLevelData() + self.parseTemplates() + + def loadLevelData(self): + """Load all level JSON files from the game pack.""" + levelFiles = [] + packPath = os.path.join("levels", self.gamePack) + + if not os.path.exists(packPath): + raise FileNotFoundError(f"Game pack '{self.gamePack}' not found") + + # Get all JSON files in the pack directory + for file in os.listdir(packPath): + if file.endswith('.json') and file[0].isdigit(): + levelFiles.append(file) + + # Load each level file + for levelFile in levelFiles: + levelPath = os.path.join(packPath, levelFile) + with open(levelPath, 'r') as f: + levelNum = int(levelFile.split('.')[0]) + self.levelData[levelNum] = json.load(f) + + def parseTemplates(self): + """Parse all level data to extract object templates by type.""" + for levelNum, data in self.levelData.items(): + # Store ambience and footstep sounds + if 'ambience' in data: + self.ambientSounds.append(data['ambience']) + if 'footstep_sound' in data: + self.footstepSounds.append(data['footstep_sound']) + + # Parse objects + for obj in data.get('objects', []): + objCopy = copy.deepcopy(obj) + + # Categorize objects + if 'enemy_type' in obj: + self.enemyTemplates.append(objCopy) + elif obj.get('collectible', False) or obj.get('sound') == 'bone_dust': + self.collectibleTemplates.append(objCopy) + elif obj.get('type') in ['skull_storm', 'catapult', 'grasping_hands']: + self.hazardTemplates.append(objCopy) + else: + self.objectTemplates.append(objCopy) + + def generate_survival_level(self, difficultyLevel=1, segmentLength=100): + """Generate an endless survival level segment. + + Args: + difficultyLevel (int): Current difficulty level (increases over time) + segmentLength (int): Length of this level segment + + Returns: + dict: Generated level data + """ + # Base level structure + levelData = { + "level_id": 999, # Special ID for survival mode + "name": f"Wave {difficultyLevel}", + "description": "", # Empty description to avoid automatic messagebox + "player_start": {"x": 0, "y": 0}, + "objects": [], + "boundaries": {"left": 0, "right": segmentLength}, + "ambience": "Escaping the Grave.ogg", # Will be overridden below + "footstep_sound": "footstep_stone" # Will be overridden below + } + + # Choose random music and footstep from any level + randomLevel = random.choice(list(self.levelData.values())) + if 'ambience' in randomLevel and randomLevel['ambience']: + levelData["ambience"] = randomLevel['ambience'] + if 'footstep_sound' in randomLevel and randomLevel['footstep_sound']: + levelData["footstep_sound"] = randomLevel['footstep_sound'] + + # Calculate spawn rates based on difficulty + collectibleDensity = max(0.1, 0.3 - (difficultyLevel * 0.02)) # Fewer collectibles over time + enemyDensity = min(0.8, 0.2 + (difficultyLevel * 0.05)) # More enemies over time + hazardDensity = min(0.4, 0.1 + (difficultyLevel * 0.03)) # More hazards over time + objectDensity = max(0.1, 0.2 - (difficultyLevel * 0.01)) # Fewer misc objects over time + + # Generate objects across the segment + currentX = 10 # Start placing objects at x=10 + + while currentX < segmentLength - 10: + # Determine what to place based on probability + rand = random.random() + + if rand < collectibleDensity and self.collectibleTemplates: + obj = self.place_collectible(currentX, difficultyLevel) + currentX += random.randint(8, 15) + elif rand < collectibleDensity + enemyDensity and self.enemyTemplates: + obj = self.place_enemy(currentX, difficultyLevel) + currentX += random.randint(15, 25) + elif rand < collectibleDensity + enemyDensity + hazardDensity and self.hazardTemplates: + obj = self.place_hazard(currentX, difficultyLevel) + currentX += random.randint(20, 35) + elif rand < collectibleDensity + enemyDensity + hazardDensity + objectDensity and self.objectTemplates: + obj = self.place_object(currentX, difficultyLevel) + currentX += random.randint(12, 20) + else: + currentX += random.randint(5, 15) + continue + + if obj: + levelData["objects"].append(obj) + + return levelData + + def place_collectible(self, xPos, difficultyLevel): + """Place a collectible at the given position.""" + template = random.choice(self.collectibleTemplates) + obj = copy.deepcopy(template) + + # Handle x_range vs single x + if 'x_range' in obj: + rangeSize = obj['x_range'][1] - obj['x_range'][0] + obj['x_range'] = [xPos, xPos + rangeSize] + else: + obj['x'] = xPos + + return obj + + def place_enemy(self, xPos, difficultyLevel): + """Place an enemy at the given position with scaled difficulty.""" + # Filter out boss enemies for early waves + bossEnemies = ['witch', 'boogie_man', 'revenant', 'ghost', 'headless_horseman'] + + if difficultyLevel < 3: # Waves 1-2: no bosses + availableEnemies = [e for e in self.enemyTemplates + if e.get('enemy_type') not in bossEnemies] + elif difficultyLevel < 5: # Waves 3-4: exclude the hardest bosses + hardestBosses = ['revenant', 'ghost', 'headless_horseman'] + availableEnemies = [e for e in self.enemyTemplates + if e.get('enemy_type') not in hardestBosses] + else: # Wave 5+: all enemies allowed + availableEnemies = self.enemyTemplates + + # Fallback to all enemies if filtering removed everything + if not availableEnemies: + availableEnemies = self.enemyTemplates + + template = random.choice(availableEnemies) + obj = copy.deepcopy(template) + + # Scale enemy stats based on difficulty + healthMultiplier = 1 + (difficultyLevel * 0.15) + damageMultiplier = 1 + (difficultyLevel * 0.1) + + obj['health'] = int(obj.get('health', 1) * healthMultiplier) + obj['damage'] = max(1, int(obj.get('damage', 1) * damageMultiplier)) + + # Handle x_range vs single x + if 'x_range' in obj: + rangeSize = obj['x_range'][1] - obj['x_range'][0] + obj['x_range'] = [xPos, xPos + rangeSize] + else: + obj['x'] = xPos + + return obj + + def place_hazard(self, xPos, difficultyLevel): + """Place a hazard at the given position with scaled difficulty.""" + template = random.choice(self.hazardTemplates) + obj = copy.deepcopy(template) + + # Scale hazard difficulty + if obj.get('type') == 'skull_storm': + obj['damage'] = max(1, int(obj.get('damage', 1) * (1 + difficultyLevel * 0.1))) + obj['maximum_skulls'] = min(6, obj.get('maximum_skulls', 2) + (difficultyLevel // 3)) + elif obj.get('type') == 'catapult': + obj['fire_interval'] = max(1000, obj.get('fire_interval', 4000) - (difficultyLevel * 100)) + + # Handle x_range vs single x + if 'x_range' in obj: + rangeSize = obj['x_range'][1] - obj['x_range'][0] + obj['x_range'] = [xPos, xPos + rangeSize] + else: + obj['x'] = xPos + + return obj + + def place_object(self, xPos, difficultyLevel): + """Place a misc object at the given position.""" + template = random.choice(self.objectTemplates) + obj = copy.deepcopy(template) + + # Handle graves - increase zombie spawn chance with difficulty + if obj.get('type') == 'grave': + baseChance = obj.get('zombie_spawn_chance', 0) + obj['zombie_spawn_chance'] = min(50, baseChance + (difficultyLevel * 2)) + + # Handle x_range vs single x + if 'x_range' in obj: + rangeSize = obj['x_range'][1] - obj['x_range'][0] + obj['x_range'] = [xPos, xPos + rangeSize] + else: + obj['x'] = xPos + + return obj \ No newline at end of file diff --git a/wicked_quest.py b/wicked_quest.py index fa7a586..0d3f350 100755 --- a/wicked_quest.py +++ b/wicked_quest.py @@ -10,6 +10,7 @@ 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 +from src.survival_generator import SurvivalGenerator class WickedQuest: @@ -24,6 +25,9 @@ class WickedQuest: self.currentGame = None self.runLock = False # Toggle behavior of the run keys self.saveManager = SaveManager() + self.survivalGenerator = None + self.survivalWave = 1 + self.survivalScore = 0 def load_level(self, levelNumber): """Load a level from its JSON file.""" @@ -87,7 +91,7 @@ class WickedQuest: return errors def load_game_menu(self): - """Display load game menu with available saves""" + """Display load game menu with available saves using instruction_menu""" save_files = self.saveManager.get_save_files() if not save_files: @@ -101,45 +105,17 @@ class WickedQuest: options.append("Cancel") - # Show menu - currentIndex = 0 - lastSpoken = -1 + # Use instruction_menu for consistent behavior + choice = instruction_menu(self.sounds, "Select a save file to load:", *options) - 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() + if choice == "Cancel" or choice is None: + return None + else: + # Find the corresponding save file + for save_file in save_files: + if save_file['display_name'] == choice: + return save_file + return None def auto_save(self): """Automatically save the game if player has enough bone dust""" @@ -448,10 +424,15 @@ class WickedQuest: 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() + # Ask player to choose game mode + mode_choice = game_mode_menu(self.sounds) + if mode_choice == "campaign": + self.player = None # Reset player for new game + self.gameStartTime = pygame.time.get_ticks() + if self.load_level(1): + self.game_loop() + elif mode_choice == "survival": + self.start_survival_mode() elif choice == "high_scores": board = Scoreboard() scores = board.get_high_scores() @@ -465,6 +446,119 @@ class WickedQuest: elif choice == "learn_sounds": choice = learn_sounds(self.sounds) + def start_survival_mode(self): + """Initialize and start survival mode.""" + self.survivalGenerator = SurvivalGenerator(self.currentGame) + self.survivalWave = 1 + self.survivalScore = 0 + self.player = Player(0, 0, self.sounds) + self.gameStartTime = pygame.time.get_ticks() + + # Generate first survival segment + levelData = self.survivalGenerator.generate_survival_level(self.survivalWave, 300) + self.currentLevel = Level(levelData, self.sounds, self.player) + + messagebox(f"Survival Mode - Wave {self.survivalWave}! Survive as long as you can!") + self.survival_loop() + + def survival_loop(self): + """Main survival mode game loop with endless level generation.""" + clock = pygame.time.Clock() + + while True: + currentTime = pygame.time.get_ticks() + pygame.event.pump() + + # Handle events + for event in pygame.event.get(): + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + messagebox(f"Survival ended! Final score: {self.survivalScore}") + 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() + elif event.type == pygame.QUIT: + exit_game() + + # Update game state (following main game_loop pattern) + 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) + + # Handle collisions (including collecting items) + self.currentLevel.handle_collisions() + + # Check if player reached end of segment - generate new one + if self.player.xPos >= self.currentLevel.rightBoundary - 20: + self.advance_survival_wave() + + # Check for death first (following main game loop pattern) + if self.currentLevel.player.get_health() <= 0: + if self.currentLevel.player.get_lives() <= 0: + # Game over - stop all sounds + pygame.mixer.stop() + messagebox(f"Game Over! Final wave: {self.survivalWave}, Final score: {self.survivalScore}") + return + else: + # Player died but has lives left - respawn + pygame.mixer.stop() + self.currentLevel.player._health = self.currentLevel.player._maxHealth + # Reset player position to beginning of current segment + self.player.xPos = 10 + + # Update score based on survival time + self.survivalScore += 1 + + clock.tick(60) # 60 FPS + + def advance_survival_wave(self): + """Generate next wave/segment for survival mode.""" + self.survivalWave += 1 + + # Clear any lingering projectiles/sounds from previous wave + if hasattr(self, 'currentLevel') and self.currentLevel: + self.currentLevel.projectiles.clear() + pygame.mixer.stop() # Stop any ongoing catapult/enemy sounds + + # Generate new segment + segmentLength = min(500, 300 + (self.survivalWave * 20)) # Longer segments over time + levelData = self.survivalGenerator.generate_survival_level(self.survivalWave, segmentLength) + + # Preserve player position but shift to start of new segment + playerX = 10 + self.player.xPos = playerX + + # Create new level + self.currentLevel = Level(levelData, self.sounds, self.player) + + speak(f"Wave {self.survivalWave}! Difficulty increased!") + + +def game_mode_menu(sounds): + """Display game mode selection menu using instruction_menu. + + Args: + sounds (dict): Dictionary of loaded sound effects + + Returns: + str: Selected game mode or None if cancelled + """ + choice = instruction_menu(sounds, "Select game mode:", "Campaign", "Survival Mode") + + if choice == "Campaign": + return "campaign" + elif choice == "Survival Mode": + return "survival" + else: + return None + if __name__ == "__main__": game = WickedQuest() diff --git a/wicked_quest.spec b/wicked_quest.spec index 1a49587..1e1ff7f 100644 --- a/wicked_quest.spec +++ b/wicked_quest.spec @@ -5,7 +5,11 @@ a = Analysis( ['wicked_quest.py'], pathex=[], binaries=[], - datas=[], + datas=[ + ('levels', 'levels'), + ('sounds', 'sounds'), + ('libstormgames', 'libstormgames'), + ], hiddenimports=[], hookspath=[], hooksconfig={},