Files
wicked-quest/wicked_quest.py

650 lines
27 KiB
Python
Executable File

#!/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
from src.survival_generator import SurvivalGenerator
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.lastWeaponSwitchTime = 0
self.weaponSwitchDelay = 200
self.player = None
self.currentGame = None
self.runLock = False # Toggle behavior of the run keys
self.saveManager = SaveManager()
self.survivalGenerator = None
self.lastBroomLandingTime = 0 # Timestamp to prevent ducking after broom landing
self.survivalWave = 1
self.survivalScore = 0
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 using instruction_menu"""
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")
# Use instruction_menu for consistent behavior
choice = instruction_menu(self.sounds, "Select a save file to load:", *options)
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"""
# Don't save in survival mode
if hasattr(self, 'currentLevel') and self.currentLevel and self.currentLevel.levelId == 999:
return False
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
# Don't handle ducking if jumping (down key is used for instant landing with broom)
# Also prevent ducking for 250ms after broom landing to avoid conflict
broomLandingGracePeriod = 250 # milliseconds
recentBroomLanding = (hasattr(self, 'lastBroomLandingTime') and
currentTime - self.lastBroomLandingTime < broomLandingGracePeriod)
if not player.isJumping and not recentBroomLanding:
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]:
# Different status message for survival vs story mode
if hasattr(self, 'currentLevel') and self.currentLevel and self.currentLevel.levelId == 999:
speak(f"{player.get_coins()} bone dust collected")
else:
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('_', ' ')}")
# Weapon switching (1=shovel, 2=broom, 3=nunchucks)
currentTime = pygame.time.get_ticks()
if currentTime - self.lastWeaponSwitchTime >= self.weaponSwitchDelay:
if keys[pygame.K_1]:
if player.switch_to_weapon(1):
self.lastWeaponSwitchTime = currentTime
elif keys[pygame.K_2]:
if player.switch_to_weapon(2):
self.lastWeaponSwitchTime = currentTime
elif keys[pygame.K_3]:
if player.switch_to_weapon(3):
self.lastWeaponSwitchTime = currentTime
# 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'])
# Handle instant landing with broom (press down while jumping)
if (player.isJumping and (keys[pygame.K_s] or keys[pygame.K_DOWN]) and
player.currentWeapon and player.currentWeapon.name == "witch_broom"):
player.isJumping = False
play_sound(self.sounds[player.footstepSound]) # Landing sound
# Reset step distance tracking after landing
player.distanceSinceLastStep = 0
player.lastStepTime = currentTime
# Set timestamp to prevent immediate ducking after broom landing
self.lastBroomLandingTime = currentTime
# Check if jump should end naturally
if player.isJumping and currentTime - player.jumpStartTime >= player.get_current_jump_duration():
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 display_survival_stats(self, timeTaken):
"""Display survival mode completion statistics."""
# Convert time from milliseconds to minutes:seconds
minutes = timeTaken // 60000
seconds = (timeTaken % 60000) // 1000
report = [f"Survival Mode Complete!"]
report.append(f"Final Wave Reached: {self.survivalWave}")
report.append(f"Final Score: {self.survivalScore}")
report.append(f"Time Survived: {minutes} minutes and {seconds} seconds")
report.append("") # Blank line
# 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)}")
if self.currentLevel.player.scoreboard.check_high_score():
pygame.event.clear()
self.currentLevel.player.scoreboard.add_high_score()
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:
# Ask player to choose game mode
mode_choice = game_mode_menu(self.sounds)
if mode_choice == "story":
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()
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)
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()
# Show intro message before level starts
messagebox(f"Survival Mode - Wave {self.survivalWave}! Survive as long as you can!")
# Generate first survival segment
levelData = self.survivalGenerator.generate_survival_level(self.survivalWave, 300)
self.currentLevel = Level(levelData, self.sounds, self.player)
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:
# Stop all sounds before exiting
pygame.mixer.stop()
pygame.mixer.music.stop()
# Calculate survival time
survivalTime = pygame.time.get_ticks() - self.gameStartTime
self.display_survival_stats(survivalTime)
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:
# Check lock system - only advance if no active enemies remain
if self.currentLevel.isLocked and any(enemy.isActive for enemy in self.currentLevel.enemies):
speak("You must defeat all enemies before proceeding to the next wave!")
play_sound(self.sounds['locked'])
# Push player back a bit
self.player.xPos -= 5
else:
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()
# Calculate survival time
survivalTime = pygame.time.get_ticks() - self.gameStartTime
self.display_survival_stats(survivalTime)
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
# Announce new wave before starting level
speak(f"Wave {self.survivalWave}!")
# 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)
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:", "Story", "Survival Mode")
if choice == "Story":
return "story"
elif choice == "Survival Mode":
return "survival"
else:
return None
if __name__ == "__main__":
game = WickedQuest()
game.run()