Updates to libstormgames submodule. Updated the game to use the menu improvements. Started work on survival mode.

This commit is contained in:
Storm Dragon
2025-09-07 03:13:25 -04:00
parent 76a49baa15
commit ce353d0ed9
6 changed files with 399 additions and 119 deletions

View File

@@ -1,10 +1,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import os import os
import sys
import time import time
import pygame import pygame
from os.path import isdir, join from os.path import isdir, join
from libstormgames import speak from libstormgames import speak, instruction_menu
def get_available_games(): def get_available_games():
"""Get list of available game directories in levels folder. """Get list of available game directories in levels folder.
@@ -13,12 +14,21 @@ def get_available_games():
list: List of game directory names list: List of game directory names
""" """
try: 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: except FileNotFoundError:
return [] return []
def selection_menu(sounds, *options): def selection_menu(sounds, *options):
"""Display level selection menu. """Display level selection menu using instruction_menu.
Args: Args:
sounds (dict): Dictionary of loaded sound effects sounds (dict): Dictionary of loaded sound effects
@@ -27,70 +37,7 @@ def selection_menu(sounds, *options):
Returns: Returns:
str: Selected option or None if cancelled str: Selected option or None if cancelled
""" """
loop = True return instruction_menu(sounds, "Select an adventure", *options)
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)
def select_game(sounds): def select_game(sounds):
"""Display game selection menu and return chosen game. """Display game selection menu and return chosen game.
@@ -134,4 +81,14 @@ def get_level_path(gameDir, levelNum):
""" """
if gameDir is None: if gameDir is None:
raise ValueError("gameDir cannot be 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

View File

@@ -40,12 +40,13 @@ class Level:
# Pass footstep sound to player # Pass footstep sound to player
self.player.set_footstep_sound(self.footstepSound) self.player.set_footstep_sound(self.footstepSound)
# Level intro message # Level intro message (skip for survival mode)
levelIntro = f"Level {levelData['level_id']}, {levelData['name']}. " if levelData['level_id'] != 999: # 999 is survival mode
if self.isLocked: levelIntro = f"Level {levelData['level_id']}, {levelData['name']}. "
levelIntro += "This is a boss level. You must defeat all enemies before you can advance. " if self.isLocked:
levelIntro += levelData['description'] levelIntro += "This is a boss level. You must defeat all enemies before you can advance. "
messagebox(levelIntro) levelIntro += levelData['description']
messagebox(levelIntro)
# Handle level music # Handle level music
try: try:

224
src/survival_generator.py Normal file
View File

@@ -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

View File

@@ -10,6 +10,7 @@ from src.object import Object
from src.player import Player from src.player import Player
from src.game_selection import select_game, get_level_path from src.game_selection import select_game, get_level_path
from src.save_manager import SaveManager from src.save_manager import SaveManager
from src.survival_generator import SurvivalGenerator
class WickedQuest: class WickedQuest:
@@ -24,6 +25,9 @@ class WickedQuest:
self.currentGame = None self.currentGame = None
self.runLock = False # Toggle behavior of the run keys self.runLock = False # Toggle behavior of the run keys
self.saveManager = SaveManager() self.saveManager = SaveManager()
self.survivalGenerator = None
self.survivalWave = 1
self.survivalScore = 0
def load_level(self, levelNumber): def load_level(self, levelNumber):
"""Load a level from its JSON file.""" """Load a level from its JSON file."""
@@ -87,7 +91,7 @@ class WickedQuest:
return errors return errors
def load_game_menu(self): 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() save_files = self.saveManager.get_save_files()
if not save_files: if not save_files:
@@ -101,45 +105,17 @@ class WickedQuest:
options.append("Cancel") options.append("Cancel")
# Show menu # Use instruction_menu for consistent behavior
currentIndex = 0 choice = instruction_menu(self.sounds, "Select a save file to load:", *options)
lastSpoken = -1
messagebox("Select a save file to load:") if choice == "Cancel" or choice is None:
return None
while True: else:
if currentIndex != lastSpoken: # Find the corresponding save file
speak(options[currentIndex]) for save_file in save_files:
lastSpoken = currentIndex if save_file['display_name'] == choice:
return save_file
event = pygame.event.wait() return None
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): def auto_save(self):
"""Automatically save the game if player has enough bone dust""" """Automatically save the game if player has enough bone dust"""
@@ -448,10 +424,15 @@ class WickedQuest:
display_text(errorLines) display_text(errorLines)
continue continue
if self.currentGame: if self.currentGame:
self.player = None # Reset player for new game # Ask player to choose game mode
self.gameStartTime = pygame.time.get_ticks() mode_choice = game_mode_menu(self.sounds)
if self.load_level(1): if mode_choice == "campaign":
self.game_loop() 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": elif choice == "high_scores":
board = Scoreboard() board = Scoreboard()
scores = board.get_high_scores() scores = board.get_high_scores()
@@ -465,6 +446,119 @@ class WickedQuest:
elif choice == "learn_sounds": elif choice == "learn_sounds":
choice = learn_sounds(self.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__": if __name__ == "__main__":
game = WickedQuest() game = WickedQuest()

View File

@@ -5,7 +5,11 @@ a = Analysis(
['wicked_quest.py'], ['wicked_quest.py'],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[], datas=[
('levels', 'levels'),
('sounds', 'sounds'),
('libstormgames', 'libstormgames'),
],
hiddenimports=[], hiddenimports=[],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},