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

View File

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

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.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()

View File

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