Files
wicked-quest/wicked_quest.py

1216 lines
54 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import os
import pygame
from libstormgames import *
from src.pack_sound_system import PackSoundSystem
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, get_game_dir_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.soundSystem = None # Will be created when game is selected
self.currentLevel = None
self.gameStartTime = None
self.lastThrowTime = 0
self.throwDelay = 250
self.lastWeaponSwitchTime = 0
self.weaponSwitchDelay = 200
self.lastStatusTime = 0
self.statusDelay = 300 # 300ms between status checks
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
# Console system - no longer needed since we use get_input directly
# Level tracking for proper progression
self.currentLevelNum = 1
# Cheat save flag - allows one save per level/session
self.canSave = True
def initialize_pack_sounds(self):
"""Initialize pack-specific sound system after game selection."""
if self.currentGame:
self.soundSystem = PackSoundSystem(self.sounds, "sounds/", levelPackName=self.currentGame)
else:
self.soundSystem = PackSoundSystem(self.sounds, "sounds/")
def get_sounds(self):
"""Get the current sound system (pack-specific if available, otherwise original)."""
if self.soundSystem is None and self.currentGame is not None:
# Initialize pack sounds if not done yet
self.initialize_pack_sounds()
return self.soundSystem if self.soundSystem else self.sounds
def get_closest_enemy_info(self):
"""Get information about the closest enemy, returns None if no enemies."""
if not self.currentLevel or not self.currentLevel.enemies:
return None
# Find active enemies
active_enemies = []
for enemy in self.currentLevel.enemies:
if enemy.isActive:
distance = abs(enemy.xPos - self.player.xPos)
direction = "right" if enemy.xPos > self.player.xPos else "left"
active_enemies.append((enemy, distance, direction))
if not active_enemies:
return None
# Sort by distance and get closest
active_enemies.sort(key=lambda x: x[1])
enemy, distance, direction = active_enemies[0]
# Convert distance to natural language
if distance == 0:
return f"{enemy.enemyType} right on top of you"
elif distance <= 10:
distance_desc = "very close"
elif distance <= 30:
distance_desc = "close"
elif distance <= 60:
distance_desc = "far"
elif distance <= 100:
distance_desc = "very far"
else:
distance_desc = "extremely far"
return f"{enemy.enemyType} {distance_desc} to the {direction}"
def process_console_command(self, command):
"""Process console commands and execute their effects."""
command = command.lower().strip()
if command == "samhain":
# Level selection menu
self.show_level_select_menu()
elif command == "misfits":
# Give 20 HP (but don't exceed max HP)
if self.player:
currentHealth = self.player.get_health()
maxHealth = self.player.get_max_health()
newHealth = min(currentHealth + 20, maxHealth)
self.player._health = newHealth
speak(f"Health restored to {newHealth}")
if 'heal' in self.get_sounds():
play_sound(self.get_sounds()['heal'])
elif command == "coffinshakers":
# Give extra life
if self.player:
self.player._lives += 1
speak(f"Extra life granted. You now have {self.player.get_lives()} lives")
if 'get_extra_life' in self.get_sounds():
play_sound(self.get_sounds()['get_extra_life'])
elif command == "nekromantix":
# Give 100 bone dust (both types)
if self.player:
self.player._coins += 100
self.player.add_save_bone_dust(100)
speak(f"100 bone dust granted. You now have {self.player.get_coins()} bone dust and {self.player.get_save_bone_dust()} save bone dust")
if 'coin' in self.get_sounds():
play_sound(self.get_sounds()['coin'])
elif command == "calabrese":
# Reveal closest enemy on current level
if self.currentLevel and self.currentLevel.enemies:
enemyCount = len([enemy for enemy in self.currentLevel.enemies if enemy.isActive])
if enemyCount > 0:
speak(f"{enemyCount} enemies remaining on this level")
closest_enemy_info = self.get_closest_enemy_info()
if closest_enemy_info:
speak(f"Closest enemy: {closest_enemy_info}")
else:
speak("No active enemies on this level")
else:
speak("No enemies found")
elif command == "balzac":
# Set bone dust to 200 (both types)
if self.player:
self.player._coins = 200
self.player._saveBoneDust = 200
speak(f"Bone dust set to 200. You now have {self.player.get_coins()} bone dust and {self.player.get_save_bone_dust()} save bone dust")
if 'coin' in self.get_sounds():
play_sound(self.get_sounds()['coin'])
elif command == "blitzkid":
# Grant broom and nunchucks weapons with level pack override support
if self.player:
from src.weapon import Weapon
# Check if player already has broom
hasBroom = any(weapon.originalName == "witch_broom" for weapon in self.player.weapons)
if not hasBroom:
broomWeapon = Weapon.create_witch_broom()
self.player.add_weapon(broomWeapon)
# Check if player already has nunchucks
hasNunchucks = any(weapon.originalName == "nunchucks" for weapon in self.player.weapons)
if not hasNunchucks:
nunchucksWeapon = Weapon.create_nunchucks()
self.player.add_weapon(nunchucksWeapon)
# Report what was granted (using display names with overrides)
grantedWeapons = []
if not hasBroom:
broomName = next((w.name for w in self.player.weapons if w.originalName == "witch_broom"), "witch broom")
grantedWeapons.append(broomName.replace('_', ' '))
if not hasNunchucks:
nunchucksName = next((w.name for w in self.player.weapons if w.originalName == "nunchucks"), "nunchucks")
grantedWeapons.append(nunchucksName.replace('_', ' '))
if grantedWeapons:
speak(f"Granted {', '.join(grantedWeapons)}")
else:
speak("You already have all weapons")
elif command == "creepshow":
# Toggle god mode (invincibility)
if self.player:
if hasattr(self.player, '_godMode'):
self.player._godMode = not self.player._godMode
else:
self.player._godMode = True
status = "enabled" if getattr(self.player, '_godMode', False) else "disabled"
speak(f"God mode {status}")
elif command == "diemonsterdie":
# Give 100 jack o'lanterns
if self.player:
self.player._jack_o_lantern_count += 100
speak(f"100 jack o'lanterns granted. You now have {self.player.get_jack_o_lanterns()} jack o'lanterns")
if 'get_jack_o_lantern' in self.get_sounds():
play_sound(self.get_sounds()['get_jack_o_lantern'])
elif command == "murderland":
# Set jack o'lanterns to exactly 13 (the special number)
if self.player:
self.player._jack_o_lantern_count = 13
speak("13 jack o'lanterns, this power courses through my soul.")
if 'get_jack_o_lantern' in self.get_sounds():
play_sound(self.get_sounds()['get_jack_o_lantern'])
elif command == "koffinkats":
# Spawn a coffin with random item at player's current position
if self.player and self.currentLevel:
from src.coffin import CoffinObject
# Create coffin at player's current position
coffin = CoffinObject(
int(self.player.xPos), # Use player's current x position
self.player.yPos, # Use player's current y position
self.get_sounds(),
self.currentLevel,
item="random" # Random item
)
# Add coffin to the level's object list
self.currentLevel.objects.append(coffin)
speak("Coffin spawned at your location")
if 'coffin' in self.get_sounds():
play_sound(self.get_sounds()['coffin'])
elif command == "theother": # Save game on current level
if not self.canSave:
speak("Save already used for this level")
return
# Don't save in survival mode
if hasattr(self, 'currentLevel') and self.currentLevel and self.currentLevel.levelId == 999:
speak("Cannot save in survival mode")
return
# Create save without bone dust requirement
try:
success, message = self.saveManager.create_save(
self.player,
self.currentLevel.levelId,
self.gameStartTime,
self.currentGame,
bypass_cost=True # Skip bone dust requirement
)
if success:
self.canSave = False # Disable further saves for this level
speak("Game saved")
try:
if 'save' in self.get_sounds():
play_sound(self.get_sounds()['save'])
except Exception as e:
print(f"Error playing save sound: {e}")
pass
else:
speak(f"Save failed: {message}")
except Exception as e:
speak("Save failed due to error")
print(f"Error during cheat save: {e}")
elif command == "wednesday13":
# Max out all stats - the ultimate power-up
if self.player:
from src.weapon import Weapon
# Max health and restore to full
self.player._maxHealth = 20
self.player._health = 20
# Max lives (set to 13 for the theme)
self.player._lives = 13
# Max bone dust (both types)
self.player._coins = 999
self.player._saveBoneDust = 999
# Max jack o'lanterns (set to 99 for practicality)
self.player._jack_o_lantern_count = 99
# Grant all weapons with level pack override support
weaponOverrides = getattr(self.currentLevel, 'weaponSoundOverrides', {}) if self.currentLevel else {}
# Check if player already has broom
hasBroom = any(w.weaponType == "broom" for w in self.player.weapons)
if not hasBroom:
broom = Weapon("broom", "witch broom", "broom_attack", "broom_hit", 7, 1.5, 2.0, 1.25)
if weaponOverrides and "witch_broom" in weaponOverrides:
override = weaponOverrides["witch_broom"]
broom.name = override.get("name", broom.name)
broom.attackSound = override.get("attack_sound", broom.attackSound)
broom.hitSound = override.get("hit_sound", broom.hitSound)
self.player.add_weapon(broom)
# Check if player already has nunchucks
hasNunchucks = any(w.weaponType == "nunchucks" for w in self.player.weapons)
if not hasNunchucks:
nunchucks = Weapon("nunchucks", "nunchucks", "nunchucks_attack", "nunchucks_hit", 10, 1.0, 1.0, 1.0)
if weaponOverrides and "nunchucks" in weaponOverrides:
override = weaponOverrides["nunchucks"]
nunchucks.name = override.get("name", nunchucks.name)
nunchucks.attackSound = override.get("attack_sound", nunchucks.attackSound)
nunchucks.hitSound = override.get("hit_sound", nunchucks.hitSound)
self.player.add_weapon(nunchucks)
# Enable god mode for good measure
self.player._godMode = True
speak("MAXIMUM POWER! All stats maxed, all weapons granted, god mode enabled")
if 'get_extra_life' in self.get_sounds():
play_sound(self.get_sounds()['get_extra_life'])
else:
speak("Unknown command")
def show_level_select_menu(self):
"""Show menu to select and warp to different levels."""
if not self.currentGame:
speak("No game loaded")
return
# Find available levels
availableLevels = []
levelNumber = 1
while True:
levelPath = get_level_path(self.currentGame, levelNumber)
if not os.path.exists(levelPath):
break
availableLevels.append(levelNumber)
levelNumber += 1
if not availableLevels:
speak("No levels found")
return
# Create menu options
options = []
for levelNum in availableLevels:
options.append(f"Level {levelNum}")
options.append("Cancel")
choice = instruction_menu(self.get_sounds(), "Warp to which level?", *options)
if choice and choice != "Cancel":
# Extract level number from choice
levelNum = int(choice.split()[-1])
# Stop all sounds and music before warping
pygame.mixer.stop()
try:
pygame.mixer.music.stop()
except:
pass
# Clear events for clean transition
pygame.event.clear()
# Load the selected level
if self.load_level(levelNum):
# Update the current level counter for proper progression
self.currentLevelNum = levelNum
# Reset cheat save flag for warped level
self.canSave = True
speak(f"Warped to level {levelNum}")
else:
speak(f"Failed to load level {levelNum}")
def open_console(self):
"""Open the console for input and process the command."""
from libstormgames import get_input
# Clear events before opening console for PyInstaller compatibility
pygame.event.clear()
# Get console input using libstormgames text input
command = get_input("Enter console command:")
# Process the command if user didn't cancel
if command is not None:
self.process_console_command(command)
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.get_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.get_sounds(), self.player, self.currentGame)
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.get_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.get_sounds():
play_sound(self.get_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.get_sounds()[player.footstepSound])
player.distanceSinceLastStep = 0
player.lastStepTime = currentTime
# Status queries with debouncing
if (keys[pygame.K_c] or keys[pygame.K_e] or keys[pygame.K_h] or keys[pygame.K_i]) and currentTime - self.lastStatusTime >= self.statusDelay:
self.lastStatusTime = currentTime
if keys[pygame.K_c]:
# Simplified bone dust status only
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")
elif keys[pygame.K_e]:
# Weapon and ammo status
if player.currentWeapon:
weapon_name = getattr(player.currentWeapon, 'displayName', player.currentWeapon.name.replace("_", " "))
status_message = f"Wielding {weapon_name}"
# Check if it's a projectile weapon - always show ammo for projectile weapons
weapon_type = getattr(player.currentWeapon, 'weaponType', 'melee')
if weapon_type == "projectile":
ammo_type = getattr(player.currentWeapon, 'ammoType', None)
if ammo_type:
ammo_count = 0
ammo_display_name = ammo_type.replace("_", " ") # Default fallback
# Get current ammo count based on ammo type
if ammo_type == "bone_dust":
ammo_count = player.get_coins()
ammo_display_name = "bone dust"
elif ammo_type == "shin_bone":
ammo_count = player.shinBoneCount
ammo_display_name = "shin bones"
elif ammo_type == "jack_o_lantern":
ammo_count = player._jack_o_lantern_count
ammo_display_name = "jack o'lanterns"
elif ammo_type == "guts":
ammo_count = player.collectedItems.count("guts")
ammo_display_name = "guts"
elif ammo_type == "hand_of_glory":
ammo_count = player.collectedItems.count("hand_of_glory")
ammo_display_name = "hands of glory"
else:
# Check for any other item type in collectedItems
ammo_count = player.collectedItems.count(ammo_type)
status_message += f". {ammo_count} {ammo_display_name}"
speak(status_message)
else:
speak("No weapon equipped")
elif keys[pygame.K_h]:
speak(f"{player.get_health()} health of {player.get_max_health()}")
elif keys[pygame.K_i]:
if self.currentLevel.levelId == 999:
base_info = f"Wave {self.survivalWave}. {player.get_health()} health of {player.get_max_health()}. {int(self.currentLevel.levelScore)} points on this wave so far. {player.get_lives()} lives remaining."
else:
base_info = 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."
# Add closest enemy info
closest_enemy_info = self.get_closest_enemy_info()
if closest_enemy_info:
speak(f"{base_info} {closest_enemy_info}")
else:
speak(base_info)
if keys[pygame.K_l]:
speak(f"{player.get_lives()} lives")
if keys[pygame.K_j]: # Check jack o'lanterns
jackOLanternCount = player.get_jack_o_lanterns()
if jackOLanternCount == 13:
# Murderland rocks!
speak(f"{jackOLanternCount} jack o'lanterns, this power courses through my soul.")
else:
speak(f"{jackOLanternCount} 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
# Weapon switching (1=shovel, 2=broom, 3=nunchucks, 4-0=custom weapons)
currentTime = pygame.time.get_ticks()
if currentTime - self.lastWeaponSwitchTime >= self.weaponSwitchDelay:
# Check standard weapon keys (1-3)
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
# Check custom weapon keys (4-0)
elif keys[pygame.K_4]:
if player.switch_to_weapon(4):
self.lastWeaponSwitchTime = currentTime
elif keys[pygame.K_5]:
if player.switch_to_weapon(5):
self.lastWeaponSwitchTime = currentTime
elif keys[pygame.K_6]:
if player.switch_to_weapon(6):
self.lastWeaponSwitchTime = currentTime
elif keys[pygame.K_7]:
if player.switch_to_weapon(7):
self.lastWeaponSwitchTime = currentTime
elif keys[pygame.K_8]:
if player.switch_to_weapon(8):
self.lastWeaponSwitchTime = currentTime
elif keys[pygame.K_9]:
if player.switch_to_weapon(9):
self.lastWeaponSwitchTime = currentTime
elif keys[pygame.K_0]:
if player.switch_to_weapon(10): # 0 key maps to index 10
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.get_sounds()[player.currentWeapon.attackSound])
# If this was a projectile weapon attack, create a projectile
if hasattr(player, 'isProjectileAttack') and player.isProjectileAttack:
self.currentLevel.create_weapon_projectile(player)
# Handle jumping
if (keys[pygame.K_w] or keys[pygame.K_UP]) and not player.isJumping and not player.isDucking:
player.isJumping = True
player.jumpStartTime = currentTime
play_sound(self.get_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 getattr(player.currentWeapon, 'originalName', player.currentWeapon.name) == "witch_broom"):
player.isJumping = False
play_sound(self.get_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.get_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.get_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.get_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()
cut_scene(self.get_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()
self.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
# Console activation with grave accent key - only in story mode
if event.key in (pygame.K_BACKQUOTE, pygame.K_QUOTE): # Grave accent key (`)
# Open console (only in story mode, not survival)
if hasattr(self, 'currentLevel') and self.currentLevel and self.currentLevel.levelId != 999:
self.open_console()
continue
if event.key == pygame.K_ESCAPE:
# Stop all sounds before exiting (consistent with survival mode)
pygame.mixer.stop()
try:
pygame.mixer.music.stop()
except:
pass
# Calculate total time and show game over sequence
totalTime = pygame.time.get_ticks() - self.gameStartTime
self.display_game_over(totalTime)
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()
# Speech history controls
elif event.key == pygame.K_F1:
speak_previous()
elif event.key == pygame.K_F2:
speak_current()
elif event.key == pygame.K_F3:
speak_next()
# 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(self.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)
self.currentLevelNum += 1
if self.load_level(self.currentLevelNum):
# Reset cheat save flag for new level
self.canSave = True
# 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 using unified path resolution
for ext in ['.wav', '.ogg', '.mp3']:
endFile = os.path.join(get_game_dir_path(self.currentGame), f'end{ext}')
if os.path.exists(endFile):
self.get_sounds()['end_scene'] = pygame.mixer.Sound(endFile)
cut_scene(self.get_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.get_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']
# Initialize pack-specific sound system
self.initialize_pack_sounds()
# Load the level
if self.load_level(current_level):
# Restore player state
self.saveManager.restore_player_state(self.player, save_data)
# Re-apply weapon overrides after restoring player state to ensure
# sound/name overrides work with restored weapon properties
if hasattr(self.currentLevel, 'weaponOverrides') and self.currentLevel.weaponOverrides:
self.currentLevel._apply_weapon_overrides(self.currentLevel.weaponOverrides)
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.get_sounds())
if self.currentGame is None:
continue # User cancelled game selection, return to main menu
# Initialize pack-specific sound system
self.initialize_pack_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.get_sounds(), self.currentGame)
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.get_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.get_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.get_sounds(), self.player, self.currentGame)
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:
# Check for Alt modifier
mods = pygame.key.get_mods()
altPressed = mods & pygame.KMOD_ALT
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()
# Speech history controls
elif event.key == pygame.K_F1:
speak_previous()
elif event.key == pygame.K_F2:
speak_current()
elif event.key == pygame.K_F3:
speak_next()
# 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)
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.get_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
# Check for all monsters unlocked bonus and prepare wave message
waveMessage = f"Wave {self.survivalWave}!"
if self.survivalGenerator:
maxLevel = max(self.survivalGenerator.levelData.keys()) if self.survivalGenerator.levelData else 1
if self.survivalWave == maxLevel + 1: # First wave after all levels unlocked
# Double the current score
self.survivalScore *= 2
play_sound(self.get_sounds().get("survivor_bonus", self.get_sounds()["get_extra_life"]))
waveMessage += " All monsters unlocked bonus! Score doubled!"
# Announce new wave (with bonus message if applicable)
speak(waveMessage)
# 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.get_sounds(), self.player, self.currentGame)
def game_mode_menu(sounds, game_dir=None):
"""Display game mode selection menu using instruction_menu.
Args:
sounds (dict): Dictionary of loaded sound effects
game_dir (str): Current game directory to check for instructions/credits
Returns:
str: Selected game mode or None if cancelled
"""
from src.game_selection import get_game_dir_path
import os
# Build base menu options
menu_options = ["Story", "Survival Mode"]
# Check for level pack specific files if game directory is provided
if game_dir:
try:
game_path = get_game_dir_path(game_dir)
# Check for instructions.txt
instructions_path = os.path.join(game_path, "instructions.txt")
if os.path.exists(instructions_path):
menu_options.append("Instructions")
# Check for credits.txt
credits_path = os.path.join(game_path, "credits.txt")
if os.path.exists(credits_path):
menu_options.append("Credits")
except Exception:
# If there's any error checking files, just continue with basic menu
pass
while True:
choice = instruction_menu(sounds, "Select game mode:", *menu_options)
if choice == "Story":
return "story"
elif choice == "Survival Mode":
return "survival"
elif choice == "Instructions" and game_dir:
# Display instructions file
try:
game_path = get_game_dir_path(game_dir)
instructions_path = os.path.join(game_path, "instructions.txt")
print(f"DEBUG: Looking for instructions at: {instructions_path}")
if os.path.exists(instructions_path):
print("DEBUG: Instructions file found, loading content...")
with open(instructions_path, 'r', encoding='utf-8') as f:
instructions_content = f.read()
print(f"DEBUG: Content length: {len(instructions_content)} characters")
print("DEBUG: Calling display_text...")
# Convert string to list of lines for display_text
content_lines = instructions_content.split('\n')
print(f"DEBUG: Split into {len(content_lines)} lines")
display_text(content_lines)
print("DEBUG: display_text returned")
else:
print("DEBUG: Instructions file not found at expected path")
speak("Instructions file not found")
except Exception as e:
print(f"DEBUG: Error loading instructions: {str(e)}")
speak(f"Error loading instructions: {str(e)}")
elif choice == "Credits" and game_dir:
# Display credits file
try:
game_path = get_game_dir_path(game_dir)
credits_path = os.path.join(game_path, "credits.txt")
print(f"DEBUG: Looking for credits at: {credits_path}")
if os.path.exists(credits_path):
print("DEBUG: Credits file found, loading content...")
with open(credits_path, 'r', encoding='utf-8') as f:
credits_content = f.read()
print(f"DEBUG: Content length: {len(credits_content)} characters")
print("DEBUG: Calling display_text...")
# Convert string to list of lines for display_text
content_lines = credits_content.split('\n')
print(f"DEBUG: Split into {len(content_lines)} lines")
display_text(content_lines)
print("DEBUG: display_text returned")
else:
print("DEBUG: Credits file not found at expected path")
speak("Credits file not found")
except Exception as e:
print(f"DEBUG: Error loading credits: {str(e)}")
speak(f"Error loading credits: {str(e)}")
else:
return None
if __name__ == "__main__":
game = WickedQuest()
game.run()