472 lines
19 KiB
Python
Executable File
472 lines
19 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
|
|
|
|
|
|
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.player = None
|
|
self.currentGame = None
|
|
self.runLock = False # Toggle behavior of the run keys
|
|
self.saveManager = SaveManager()
|
|
|
|
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"""
|
|
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")
|
|
|
|
# Show menu
|
|
currentIndex = 0
|
|
lastSpoken = -1
|
|
|
|
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()
|
|
|
|
def auto_save(self):
|
|
"""Automatically save the game if player has enough bone dust"""
|
|
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
|
|
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]:
|
|
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('_', ' ')}")
|
|
|
|
# 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'])
|
|
|
|
# Check if jump should end
|
|
if player.isJumping and currentTime - player.jumpStartTime >= player.jumpDuration:
|
|
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 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:
|
|
self.player = None # Reset player for new game
|
|
self.gameStartTime = pygame.time.get_ticks()
|
|
if self.load_level(1):
|
|
self.game_loop()
|
|
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)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
game = WickedQuest()
|
|
game.run()
|