diff --git a/levels/2.json b/levels/2.json new file mode 100644 index 0000000..8bf925f --- /dev/null +++ b/levels/2.json @@ -0,0 +1,118 @@ +{ + "level_id": 2, + "name": "The Graveyard", + "description": "The mausoleum led to an ancient graveyard. Watch out for falling skulls!", + "player_start": { + "x": 0, + "y": 0 + }, + "objects": [ + { + "x": 5, + "y": 3, + "sound": "coffin", + "type": "coffin" + }, + { + "x_range": [15, 17], + "y": 3, + "sound": "coin", + "collectible": true, + "static": true + }, + { + "x": 25, + "y": 0, + "enemy_type": "goblin", + "health": 4, + "damage": 2, + "attack_range": 1, + "movement_range": 5 + }, + { + "x": 35, + "y": 0, + "hazard": true, + "sound": "grave", + "static": true, + "zombie_spawn_chance": 15 + }, + { + "x_range": [45, 60], + "y": 12, + "type": "skull_storm", + "damage": 3, + "maximum_skulls": 2, + "frequency": { + "min": 3, + "max": 6 + } + }, + { + "x": 55, + "y": 3, + "sound": "coffin", + "type": "coffin" + }, + { + "x_range": [65, 67], + "y": 3, + "sound": "coin", + "collectible": true, + "static": true + }, + { + "x": 75, + "y": 0, + "enemy_type": "goblin", + "health": 5, + "damage": 2, + "attack_range": 1, + "movement_range": 6 + }, + { + "x": 85, + "y": 3, + "sound": "coffin", + "type": "coffin" + }, + { + "x": 95, + "y": 0, + "hazard": true, + "sound": "grave", + "static": true, + "zombie_spawn_chance": 20 + }, + { + "x_range": [105, 107], + "y": 3, + "sound": "coin", + "collectible": true, + "static": true + }, + { + "x": 120, + "y": 0, + "type": "catapult", + "direction": -1, + "fire_interval": 5000, + "range": 15 + }, + { + "x_range": [130, 160], + "y": 15, + "type": "skull_storm", + "damage": 4, + "maximum_skulls": 3, + "frequency": { + "min": 2, + "max": 5 + } + } + ], + "boundaries": { + "left": 0, + "right": 170 + } +} diff --git a/libstormgames b/libstormgames index 658709e..80fe2ca 160000 --- a/libstormgames +++ b/libstormgames @@ -1 +1 @@ -Subproject commit 658709ebcec4a66745cfe8b32047ea20d0ef185d +Subproject commit 80fe2caff3921ef2073e9f145e51a29cbdf80f1b diff --git a/sounds/falling_skull2.ogg b/sounds/falling_skull2.ogg new file mode 100644 index 0000000..4f73472 --- /dev/null +++ b/sounds/falling_skull2.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:223db0b37ba92ffe9cff653d5df1258de7600dc81fc2780520c7e80c13bde813 +size 16880 diff --git a/sounds/goblin_dies.ogg b/sounds/goblin_dies.ogg new file mode 100644 index 0000000..f4cefa8 --- /dev/null +++ b/sounds/goblin_dies.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca074e382576b7d6b9be2b6006bcd6dd35138935f550dd6cc8fc9aeb6126dccc +size 14891 diff --git a/sounds/player_takes_damage.ogg b/sounds/player_takes_damage.ogg new file mode 100644 index 0000000..12f1e62 --- /dev/null +++ b/sounds/player_takes_damage.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea06c20a79b5d2bb4dcf71bbb2db0e1a3ef15ec450493d2a211f1054fc46ae8b +size 10453 diff --git a/src/coffin.py b/src/coffin.py index 4aeffdd..928c4ea 100644 --- a/src/coffin.py +++ b/src/coffin.py @@ -3,8 +3,9 @@ from src.object import Object from src.powerup import PowerUp import random + class CoffinObject(Object): - def __init__(self, x, y, sounds): + def __init__(self, x, y, sounds, level): super().__init__( x, y, "coffin", isStatic=True, @@ -12,6 +13,7 @@ class CoffinObject(Object): isHazard=False ) self.sounds = sounds + self.level = level # Store level reference self.is_broken = False self.dropped_item = None @@ -20,6 +22,8 @@ class CoffinObject(Object): if not self.is_broken: self.is_broken = True self.sounds['coffin_shatter'].play() + self.level.player.stats.update_stat('Coffins broken', 1) + self.level.player.stats.update_stat('Coffins remaining', -1) # Stop the ongoing coffin sound if self.channel: diff --git a/src/enemy.py b/src/enemy.py index c79ed7a..d5ef087 100644 --- a/src/enemy.py +++ b/src/enemy.py @@ -4,18 +4,19 @@ import pygame class Enemy(Object): - def __init__(self, xRange, y, enemyType, sounds, **kwargs): + def __init__(self, xRange, y, enemyType, sounds, level, **kwargs): # Initialize base object properties super().__init__( xRange, y, - f"{enemyType}", # Base sound for ambient noise + f"{enemyType}", # Base sound isStatic=False, isHazard=True ) # Enemy specific properties self.enemyType = enemyType + self.level = level self.health = kwargs.get('health', 5) # Default 5 HP self.damage = kwargs.get('damage', 1) # Default 1 damage self.attackRange = kwargs.get('attack_range', 1) # Default 1 tile range @@ -106,7 +107,7 @@ class Enemy(Object): self.sounds[attackSound].play() # Deal damage to player player.set_health(player.get_health() - self.damage) - speak(f"The {self.enemyType} hits you!") + self.sounds['player_takes_damage'].play() def take_damage(self, amount): """Handle enemy taking damage""" @@ -120,7 +121,11 @@ class Enemy(Object): if self.channel: obj_stop(self.channel) self.channel = None - # Play death sound if available - deathSound = f"{self.enemyType}_death" + # Play death sound if available using positional audio + deathSound = f"{self.enemyType}_dies" if deathSound in self.sounds: - self.sounds[deathSound].play() + self.channel = obj_play(self.sounds, deathSound, self.level.player.xPos, self.xPos, loop=False) + + # Update stats + self.level.player.stats.update_stat('Enemies killed', 1) + self.level.player.stats.update_stat('Enemies remaining', -1) diff --git a/src/level.py b/src/level.py index 375ca14..a78fc3f 100644 --- a/src/level.py +++ b/src/level.py @@ -72,7 +72,8 @@ class Level: coffin = CoffinObject( xPos[0], obj["y"], - self.sounds + self.sounds, + self # Pass level reference ) self.objects.append(coffin) # Check if this is an enemy @@ -82,6 +83,7 @@ class Level: obj["y"], obj["enemy_type"], self.sounds, + self, # Pass level reference health=obj.get("health", 5), damage=obj.get("damage", 1), attack_range=obj.get("attack_range", 1), @@ -99,6 +101,10 @@ class Level: zombie_spawn_chance=obj.get("zombie_spawn_chance", 0) ) self.objects.append(gameObject) + enemyCount = len(self.enemies) + coffinCount = sum(1 for obj in self.objects if hasattr(obj, 'is_broken')) + player.stats.update_stat('Enemies remaining', enemyCount) + player.stats.update_stat('Coffins remaining', coffinCount) def update_audio(self): """Update all audio and entity state.""" @@ -126,6 +132,7 @@ class Level: obj.yPos, "zombie", self.sounds, + self, # Pass the level reference health=3, damage=10, attack_range=1 @@ -235,13 +242,19 @@ class Level: self.sounds[f'get_{obj.soundName}'].play() obj.collect_at_position(currentPos) self.player.collectedItems.append(obj.soundName) + self.player.stats.update_stat('Items collected', 1) if obj.soundName == "coin": self.player._coins += 1 + self.player.stats.update_stat('Bone dust', 1) elif obj.isHazard and not self.player.isJumping: - self.sounds[obj.soundName].play() - speak("You fell in an open grave!") - self.player.set_health(0) - return False + if not self.player.isInvincible: + self.sounds[obj.soundName].play() + speak("You fell in an open grave!") + self.player.set_health(0) + return False + else: + # When invincible, treat it like a successful jump over the grave + pass # Handle boundaries if self.player.xPos < self.leftBoundary: diff --git a/src/player.py b/src/player.py index b647120..1ec4327 100644 --- a/src/player.py +++ b/src/player.py @@ -1,5 +1,6 @@ import pygame from libstormgames import * +from src.stat_tracker import StatTracker from src.weapon import Weapon @@ -21,6 +22,7 @@ class Player: self._lives = 1 self.distanceSinceLastStep = 0 self.stepDistance = 0.5 + self.stats = StatTracker() # Inventory system self.inventory = [] diff --git a/src/skull_storm.py b/src/skull_storm.py index 99b2090..f133553 100644 --- a/src/skull_storm.py +++ b/src/skull_storm.py @@ -55,15 +55,15 @@ class SkullStorm(Object): fallProgress = timeElapsed / skull['fall_duration'] currentY = self.yPos * (1 - fallProgress) - if skull['channel'] is None or not skull['channel'].get_busy(): - skull['channel'] = play_random_falling( - self.sounds, - 'falling_skull', - player.xPos, - skull['x'], - self.yPos, - currentY - ) + skull['channel'] = play_random_falling( + self.sounds, + 'falling_skull', + player.xPos, + skull['x'], + self.yPos, + currentY, + existingChannel=skull['channel'] + ) # Check if we should spawn a new skull if (len(self.activeSkulls) < self.maxSkulls and diff --git a/src/stat_tracker.py b/src/stat_tracker.py new file mode 100644 index 0000000..50dacbc --- /dev/null +++ b/src/stat_tracker.py @@ -0,0 +1,44 @@ +class StatTracker: + def __init__(self): + # Base dictionary for tracking stats + self.total = { + 'Bone dust': 0, + 'Enemies killed': 0, + 'Enemies remaining': 0, + 'Coffins broken': 0, + 'Coffins remaining': 0, + 'Items collected': 0, + 'Total time': 0 + } + + # Create level stats from total (shallow copy is fine here) + self.level = self.total.copy() + + self.total['levelsCompleted'] = 0 + + def reset_level(self): + """Reset level stats based on variable type""" + for key in self.level: + if isinstance(self.level[key], (int, float)): + self.level[key] = 0 + elif isinstance(self.level[key], str): + self.level[key] = "" + elif isinstance(self.level[key], list): + self.level[key] = [] + elif self.level[key] is None: + self.level[key] = None + + def update_stat(self, statName, value=1, levelOnly=False): + """Update a stat in both level and total (unless levelOnly is True)""" + if statName in self.level: + self.level[statName] += value + if not levelOnly and statName in self.total: + self.total[statName] += value + + def get_level_stat(self, statName): + """Get a level stat""" + return self.level.get(statName, 0) + + def get_total_stat(self, statName): + """Get a total stat""" + return self.total.get(statName, 0) diff --git a/wicked_quest.py b/wicked_quest.py index b987606..2bcdf84 100644 --- a/wicked_quest.py +++ b/wicked_quest.py @@ -9,22 +9,42 @@ from src.player import Player class WickedQuest: def __init__(self): + """Initialize game and load sounds.""" self.sounds = initialize_gui("Wicked Quest") self.currentLevel = None + self.lastThrowTime = 0 + self.throwDelay = 250 + self.player = None # Will be initialized when first level loads def load_level(self, levelNumber): + """Load a level from its JSON file.""" levelFile = f"levels/{levelNumber}.json" try: with open(levelFile, 'r') as f: levelData = json.load(f) - self.currentLevel = Level(levelData, self.sounds) - speak(f"Level {levelNumber} loaded") + + # 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: + # Just update player position for new level + self.player.xPos = levelData["player_start"]["x"] + self.player.yPos = levelData["player_start"]["y"] + + # Pass existing player to new level + self.currentLevel = Level(levelData, self.sounds, self.player) + + # Announce level details + levelIntro = f"Level {levelData['level_id']}, {levelData['name']}. {levelData['description']}" + messagebox(levelIntro) + + return True except FileNotFoundError: speak("Level not found") return False - return True def handle_input(self): + """Process keyboard input for player actions.""" keys = pygame.key.get_pressed() player = self.currentLevel.player currentTime = pygame.time.get_ticks() @@ -45,17 +65,20 @@ class WickedQuest: player.xPos += currentSpeed player.facingRight = True + # Status queries if keys[pygame.K_c]: speak(f"{player.get_coins()} coins") - if keys[pygame.K_h]: speak(f"{player.get_health()} HP") - if keys[pygame.K_l]: speak(f"{player.get_lives()} lives") - - if keys[pygame.K_f]: # Throw projectile - self.currentLevel.throw_projectile() + if keys[pygame.K_j]: # Check jack o'lanterns + speak(f"{player.get_jack_o_lanterns()} jack o'lanterns") + if keys[pygame.K_f]: + currentTime = pygame.time.get_ticks() + if currentTime - self.lastThrowTime >= self.throwDelay: + self.currentLevel.throw_projectile() + self.lastThrowTime = currentTime # Handle attack with either CTRL key if (keys[pygame.K_LCTRL] or keys[pygame.K_RCTRL]) and player.start_attack(currentTime): @@ -81,45 +104,103 @@ class WickedQuest: # Reset step distance tracking after landing player.distanceSinceLastStep = 0 + 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"Level {self.currentLevel.levelId} Complete!"] + report.append(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)}") + + pygame.mixer.stop() + 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)}") + + display_text(report) + def game_loop(self): + """Main game loop handling updates and state changes.""" clock = pygame.time.Clock() - - while self.currentLevel.player.get_health() > 0 and self.currentLevel.player.get_lives() > 0: + startTime = pygame.time.get_ticks() + currentLevelNum = 1 + + while True: currentTime = pygame.time.get_ticks() - + if check_for_exit(): return - - # Update player state (including power-ups) + + # Update game state self.currentLevel.player.update(currentTime) - self.handle_input() - - # Update audio positioning and handle collisions self.currentLevel.update_audio() - self.currentLevel.handle_collisions() - - # Handle combat interactions + + # Handle combat and projectiles self.currentLevel.handle_combat(currentTime) - - # Update projectiles 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 + pygame.mixer.stop() + self.display_game_over(pygame.time.get_ticks() - startTime) + return + + # Handle collisions and check level completion + if self.currentLevel.handle_collisions(): # Changed from elif to if + # Level completed + self.display_level_stats(pygame.time.get_ticks() - startTime) + + # Try to load next level + currentLevelNum += 1 + if self.load_level(currentLevelNum): + # Reset timer for new level + startTime = pygame.time.get_ticks() + continue + else: + # No more levels - game complete! + messagebox("Congratulations! You've completed all available levels!") + self.display_game_over(pygame.time.get_ticks() - startTime) + return + clock.tick(60) # 60 FPS - - # Player died or ran out of lives - speak("Game Over") def run(self): + """Main game loop with menu system.""" while True: choice = game_menu(self.sounds, "play", "instructions", "learn_sounds", "credits", "donate", "exit") if choice == "exit": exit_game() elif choice == "play": + self.player = None # Reset player for new game if self.load_level(1): self.game_loop() + if __name__ == "__main__": game = WickedQuest() game.run()