Compare commits

...

18 Commits

Author SHA1 Message Date
Storm Dragon 60ab8cd868 Changes merged from testing. 2025-09-11 11:47:43 -04:00
Storm Dragon d83e7db248 A few adjustments to documentation and reworked how monsters are unlocked in survival mode. Added bonus for unlocking everything. 2025-09-11 11:44:46 -04:00
Storm Dragon cc0f0437f5 game cover picture added. 2025-09-10 01:03:26 -04:00
Storm Dragon 2bfb146ef5 game cover picture added. 2025-09-10 01:02:41 -04:00
Storm Dragon 21d3cd7788 lint and spacing fixes. Also fixed problem with levels not showing up in compiled version (hopefully). 2025-09-09 22:02:33 -04:00
Storm Dragon 949c12f193 A few minor cleanups. Added game over sound to survival mode. Not sure how I missed that for this long lol. 2025-09-09 18:12:51 -04:00
Storm Dragon dc1557e71d Oops, forgot to add some files in the last push. Rest of the sound changes. 2025-09-09 03:03:22 -04:00
Storm Dragon 494de84ba3 Improve sound playing code to check for specific level pack sounds. 2025-09-09 03:02:40 -04:00
Storm Dragon 8766290ccd Hopefully fixed a race condition where you could be killed while getting a cauldron and come back to life. Added sound for fill in grave and for survivor bonus. 2025-09-09 00:01:53 -04:00
Storm Dragon 1d37a16f9e Minor cosmetic updates. 2025-09-08 18:03:05 -04:00
Storm Dragon 2437e13604 Updated libstormgames. Fixed death sounds following player. Fixed volume keys in survival mode. 2025-09-08 16:00:13 -04:00
Storm Dragon 04067a4bb3 Updated credits and instructions. 2025-09-07 20:27:16 -04:00
Storm Dragon 8a60c8e454 Fixed traceback in menu. 2025-09-07 18:21:13 -04:00
Storm Dragon 3034712c95 If wielding witch broom instantly land when jumping by pressing down. 2025-09-07 14:50:09 -04:00
Storm Dragon 56a78aa4ff Weapons now do different things. Shovels fill in graves, broom gives a speed and jump bonus and nunchucks just thwack things real hard and have best reach. 2025-09-07 13:20:49 -04:00
Storm Dragon cf3f27d9b8 Add ability to switch weapons with number keys. 2025-09-07 12:11:52 -04:00
Storm Dragon 2bc27c0e28 More work on survival mode. 2025-09-07 11:13:01 -04:00
Storm Dragon ce353d0ed9 Updates to libstormgames submodule. Updated the game to use the menu improvements. Started work on survival mode. 2025-09-07 03:13:25 -04:00
28 changed files with 1269 additions and 543 deletions
+1
View File
@@ -1 +1,2 @@
*.ogg filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
-3
View File
@@ -25,9 +25,6 @@ https://hubert-humphrey.com
Steven Robinson:
Ko-fi Supporters:
https://ko-fi.com/stormux/leaderboard
Music:
Choir of Doom:
+23 -3
View File
@@ -19,6 +19,9 @@ tab or capslock: Toggle run lock.
Control: Attack.
Space or shift: Hold to run if runlock is disabled. If run lock is enabled, hold to walk.
f, z, or /: Throw jack O'lantern.
1: Switch to rusty shovel (if available).
2: Switch to witch's broom (if available).
3: Switch to nunchucks (if available).
c: Check bone dust.
e: Check currently wielded weapon.
h: Check health.
@@ -28,7 +31,7 @@ l: Check lives remaining.
Alt+PageDown: Master volume decrease.
Alt+PageUp: Master volume increase.
Alt+End: Ambience volume decrease.
Alt+Home: Game volume increase.
Alt+Home: Ambience volume increase.
Alt+Delete: Game sounds volume decrease.
Alt+Insert: Game sounds volume increase.
Backspace: Pause or resume the game.
@@ -41,7 +44,24 @@ Spider webs can be passed by ducking as you move by them.
If you hit a spiderweb, a spider spawns, and you are slowed for 15 seconds. If you are invincible, you are not slowed, but the spider still appears.
Running and jumping both move you at 1.5 your normal speed.
Items bounce away from you when they are freed from the coffin. You must jump to catch them when they are in range.
The game automatically saves your progress when you have collected 200 bone dust. You will hear a message saying the game has been saved, and a wolf will howl. To load a game, use the load option in the main menu. Load only appears if you have saved games. The first save should happen around level 6.
You can switch between weapons you've collected using the number keys 1, 2, and 3. This allows strategic combat use different weapons for different situations based on their damage, range, and speed.
The shovel is a weak weapon, but it can fill in graves. While wielding the shovel hold down and walk (do not run) over the grave. If you run you will trip and fall in.
The witch's broom isn't in good enough condition from bashing enemies to be used for true flight, but it does grant a speed bonus and also allows you to land at any point while jumping, just press down.
The game automatically saves your progress when you have collected 200 bone dust. You will hear a message saying the game has been saved, and a wolf will howl. To load a game, use the load option in the main menu. Load only appears if you have saved games.
Game Modes
Story Mode: Traditional level-by-level progression through predefined stages. Your progress is saved, and you can collect extra lives by gathering bone dust.
Survival Mode: An endless challenge where you face wave after wave of increasingly difficult enemies. Each wave gets progressively harder with stronger enemies, faster spawn rates, and longer levels. Level length starts at 300 units and increases by 20 units per wave (capped at 500 units). In survival mode:
No saving or loading - each run is a fresh start
No extra lives are awarded
Each wave must be completed by defeating all enemies before you can advance
Bone dust still provides health bonuses but collecting 100 bone dust gives 2000 bonus score instead of extra lives
Monsters unlock per wave that matches the level in which they are found
Once all monsters are unlocked your score is doubled
All coffin contents are randomized
A detailed statistics report shows your performance at the end of each run
Enemies
@@ -61,7 +81,7 @@ Bonuses and items
Bone dust: Currency of the game. Collect it to gain health and extra lives.
Cauldron: Restores you to full health.
Witch's broom: A weapon gained from witches. Stronger than the grave digger's rusty shovel.
Witch's broom: A weapon gained from witches. Stronger than the grave digger's rusty shovel and grants extra speed and jump time.
Coffin: Contains items. Be quick, they try to escape.
Guts: Adds 2 health to your maximum unlife if your unlife is below 20 points otherwise it restores your health.
Hand of Glory: Grants invincibility for a short time.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11 -13
View File
@@ -14,7 +14,7 @@ class Pumpkin:
self.isActive = True
self.damage = playerMaxHealth // 2 # Half of player's max health
self.soundChannel = None
self.soundName = 'pumpkin_low' if isHigh else 'pumpkin_high' # Inverted mapping
self.soundName = "pumpkin_low" if isHigh else "pumpkin_high" # Inverted mapping
def update(self, sounds, playerX):
"""Update pumpkin position and sound"""
@@ -39,7 +39,7 @@ class Pumpkin:
# Calculate volume and pan for splat sound based on final position
volume, left, right = calculate_volume_and_pan(playerX, self.x)
if volume > 0: # Only play if within audible range
obj_play(sounds, 'pumpkin_splat', playerX, self.x, loop=False)
obj_play(sounds, "pumpkin_splat", playerX, self.x, loop=False)
def check_collision(self, player):
"""Check if pumpkin hits player"""
@@ -58,7 +58,9 @@ class Pumpkin:
class Catapult(Object):
def __init__(self, x, y, sounds, direction=1, fireInterval=5000, firingRange=20):
super().__init__(
x, y, "catapult",
x,
y,
"catapult",
isStatic=True,
isCollectible=False,
)
@@ -77,18 +79,14 @@ class Catapult(Object):
self.lastFireTime = currentTime
# Play launch sound using directional audio
play_directional_sound(self.sounds, 'catapult_launch', player.xPos, self.xPos)
play_directional_sound(self.sounds, "catapult_launch", player.xPos, self.xPos)
# Set up pending pumpkin
isHigh = random.choice([True, False])
fireDirection = 1 if player.xPos > self.xPos else -1
# Store pumpkin data for later creation
self.pendingPumpkin = {
'isHigh': isHigh,
'direction': fireDirection,
'playerMaxHealth': player.get_max_health()
}
self.pendingPumpkin = {"isHigh": isHigh, "direction": fireDirection, "playerMaxHealth": player.get_max_health()}
# Set when to actually launch the pumpkin
self.pumpkinLaunchTime = currentTime + self.launchDelay
@@ -116,9 +114,9 @@ class Catapult(Object):
# Create and fire the pending pumpkin
pumpkin = Pumpkin(
self.xPos,
self.pendingPumpkin['isHigh'],
self.pendingPumpkin['direction'],
self.pendingPumpkin['playerMaxHealth']
self.pendingPumpkin["isHigh"],
self.pendingPumpkin["direction"],
self.pendingPumpkin["playerMaxHealth"],
)
self.activePumpkins.append(pumpkin)
self.pendingPumpkin = None
@@ -140,4 +138,4 @@ class Catapult(Object):
pumpkin.isActive = False
self.activePumpkins.remove(pumpkin)
if not player.isInvincible:
self.sounds['player_takes_damage'].play()
self.sounds["player_takes_damage"].play()
+4 -15
View File
@@ -9,12 +9,7 @@ from src.powerup import PowerUp
class CoffinObject(Object):
def __init__(self, x, y, sounds, level, item="random"):
super().__init__(
x, y, "coffin",
isStatic=True,
isCollectible=False,
isHazard=False
)
super().__init__(x, y, "coffin", isStatic=True, isCollectible=False, isHazard=False)
self.sounds = sounds
self.level = level
self.isBroken = False
@@ -25,9 +20,9 @@ class CoffinObject(Object):
"""Handle being hit by the player's weapon"""
if not self.isBroken:
self.isBroken = True
play_sound(self.sounds['coffin_shatter'])
play_sound(self.sounds["coffin_shatter"])
self.level.levelScore += 500
self.level.player.stats.update_stat('Coffins broken', 1)
self.level.player.stats.update_stat("Coffins broken", 1)
# Stop the ongoing coffin sound
if self.channel:
@@ -54,13 +49,7 @@ class CoffinObject(Object):
drop_x = self.xPos + (direction * drop_distance)
self.dropped_item = PowerUp(
drop_x,
self.yPos,
item_type,
self.sounds,
direction,
self.level.leftBoundary,
self.level.rightBoundary
drop_x, self.yPos, item_type, self.sounds, direction, self.level.leftBoundary, self.level.rightBoundary
)
return True
+40
View File
@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Woohoo! Diemonsterdie!
# The moon is full
# The time is right to shed my evil demon's light on you
# Navigate by pumpkin's light
# This cornfield hides a shallow grave for you
import pygame
from libstormgames import *
from src.object import Object
class DeathSound(Object):
"""Special object that plays enemy death sounds at fixed positions and self-removes."""
def __init__(self, x, y, enemyType, sounds):
# Initialize as a static object with the death sound name
deathSoundName = f"{enemyType}_dies"
super().__init__(x, y, deathSoundName, isStatic=True, isCollectible=False, isHazard=False)
self.sounds = sounds
self.enemyType = enemyType
self.startTime = pygame.time.get_ticks()
# Get the duration of the death sound if it exists
if deathSoundName in sounds:
self.soundDuration = sounds[deathSoundName].get_length() * 1000 # Convert to milliseconds
else:
self.soundDuration = 1000 # Default 1 second if sound doesn't exist
def update(self, currentTime):
"""Check if sound has finished playing and mark for removal."""
if currentTime - self.startTime >= self.soundDuration:
# Sound has finished, deactivate and stop audio
self.isActive = False
if self.channel:
obj_stop(self.channel)
self.channel = None
+38 -42
View File
@@ -6,25 +6,20 @@ from libstormgames import *
from src.object import Object
from src.powerup import PowerUp
class Enemy(Object):
def __init__(self, xRange, y, enemyType, sounds, level, **kwargs):
# Track when critters should start hunting
self.hunting = False
# Initialize base object properties
super().__init__(
xRange,
y,
f"{enemyType}", # Base sound
isStatic=False,
isHazard=True
)
super().__init__(xRange, y, f"{enemyType}", isStatic=False, isHazard=True) # Base sound
# 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
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
self.sounds = sounds # Store reference to game sounds
# Movement and behavior properties
@@ -37,25 +32,25 @@ class Enemy(Object):
self._currentX = self.xRange[0] # Initialize current position
# Add spawn configuration
self.canSpawn = kwargs.get('can_spawn', False)
self.canSpawn = kwargs.get("can_spawn", False)
if self.canSpawn:
self.spawnCooldown = kwargs.get('spawn_cooldown', 2000)
self.spawnChance = kwargs.get('spawn_chance', 25)
self.spawnType = kwargs.get('spawn_type', 'zombie') # Default to zombie for backward compatibility
self.spawnDistance = kwargs.get('spawn_distance', 5)
self.spawnCooldown = kwargs.get("spawn_cooldown", 2000)
self.spawnChance = kwargs.get("spawn_chance", 25)
self.spawnType = kwargs.get("spawn_type", "zombie") # Default to zombie for backward compatibility
self.spawnDistance = kwargs.get("spawn_distance", 5)
self.lastSpawnTime = 0
# Attack pattern configuration
self.attackPattern = kwargs.get('attack_pattern', {'type': 'patrol'})
self.turnThreshold = self.attackPattern.get('turn_threshold', 5)
self.attackPattern = kwargs.get("attack_pattern", {"type": "patrol"})
self.turnThreshold = self.attackPattern.get("turn_threshold", 5)
# Initialize vulnerability system
self.hasVulnerabilitySystem = kwargs.get('has_vulnerability', False)
self.hasVulnerabilitySystem = kwargs.get("has_vulnerability", False)
if self.hasVulnerabilitySystem:
self.isVulnerable = False # Start invulnerable
self.vulnerabilityTimer = pygame.time.get_ticks()
self.vulnerabilityDuration = kwargs.get('vulnerability_duration', 2000)
self.invulnerabilityDuration = kwargs.get('invulnerability_duration', 5000)
self.vulnerabilityDuration = kwargs.get("vulnerability_duration", 2000)
self.invulnerabilityDuration = kwargs.get("invulnerability_duration", 5000)
soundName = f"{self.enemyType}_is_vulnerable" if self.isVulnerable else self.enemyType
self.channel = obj_play(self.sounds, soundName, self.level.player.xPos, self.xPos)
else:
@@ -68,19 +63,17 @@ class Enemy(Object):
self.health = 1 # Easy to kill
self.attackCooldown = 1500 # Slower attack rate
elif enemyType == "spider":
speedMultiplier = kwargs.get('speed_multiplier', 2.0)
speedMultiplier = kwargs.get("speed_multiplier", 2.0)
self.movementSpeed *= speedMultiplier # Spiders are faster
self.attackPattern = {'type': 'hunter'} # Spiders actively hunt the player
self.attackPattern = {"type": "hunter"} # Spiders actively hunt the player
self.turnThreshold = 3 # Spiders turn around quickly to chase player
@property
def xPos(self):
"""Current x position"""
return self._currentX
@xPos.setter
def xPos(self, value):
"""Set current x position"""
self._currentX = value
@@ -111,7 +104,9 @@ class Enemy(Object):
self.channel = obj_update(self.channel, player.xPos, self.xPos)
# Check for vulnerability state change
if currentTime - self.vulnerabilityTimer >= (self.vulnerabilityDuration if self.isVulnerable else self.invulnerabilityDuration):
if currentTime - self.vulnerabilityTimer >= (
self.vulnerabilityDuration if self.isVulnerable else self.invulnerabilityDuration
):
self.isVulnerable = not self.isVulnerable
self.vulnerabilityTimer = currentTime
@@ -126,8 +121,7 @@ class Enemy(Object):
self.hunting = True
# Handle movement based on enemy type and pattern
if (self.enemyType == "zombie" or
(self.attackPattern['type'] == 'hunter' and self.hunting)):
if self.enemyType == "zombie" or (self.attackPattern["type"] == "hunter" and self.hunting):
distanceToPlayer = player.xPos - self.xPos
@@ -166,6 +160,10 @@ class Enemy(Object):
# Ensure spawn point is within level boundaries
spawnX = max(self.level.leftBoundary, min(spawnX, self.level.rightBoundary))
# Set behavior based on game mode
behavior = "hunter" if self.level.levelId == 999 else "patrol"
turn_rate = 2 if self.level.levelId == 999 else 8 # Faster turn rate for survival
# Create new enemy of specified type
spawned = Enemy(
[spawnX, spawnX], # Single point range for spawn
@@ -175,7 +173,9 @@ class Enemy(Object):
self.level,
health=4, # Default health for spawned enemies
damage=2, # Default damage for spawned enemies
attack_range=1 # Default range for spawned enemies
attack_range=1, # Default range for spawned enemies
attack_pattern={"type": behavior},
turn_rate=turn_rate,
)
# Add to level's enemies
@@ -215,7 +215,8 @@ class Enemy(Object):
def attack(self, currentTime, player):
"""Perform attack on player"""
if player.isInvincible: return
if player.isInvincible:
return
self.lastAttackTime = currentTime
# Play attack sound
attackSound = f"{self.enemyType}_attack"
@@ -223,7 +224,7 @@ class Enemy(Object):
self.sounds[attackSound].play()
# Deal damage to player
player.set_health(player.get_health() - self.damage)
self.sounds['player_takes_damage'].play()
self.sounds["player_takes_damage"].play()
def take_damage(self, amount):
"""Handle enemy taking damage"""
@@ -251,10 +252,11 @@ class Enemy(Object):
# Award points
self.level.levelScore += totalPoints
# Play death sound if available using positional audio
deathSound = f"{self.enemyType}_dies"
if deathSound in self.sounds:
self.channel = obj_play(self.sounds, deathSound, self.level.player.xPos, self.xPos, loop=False)
# Create a DeathSound object to play death sound at fixed position
from src.die_monster_die import DeathSound
deathSoundObj = DeathSound(self.xPos, self.yPos, self.enemyType, self.sounds)
self.level.objects.append(deathSoundObj)
# Handle witch-specific drops
if self.enemyType == "witch":
@@ -270,15 +272,9 @@ class Enemy(Object):
dropX = self.xPos + (direction * dropDistance)
droppedItem = PowerUp(
dropX,
self.yPos,
itemType,
self.sounds,
direction,
self.level.leftBoundary,
self.level.rightBoundary
dropX, self.yPos, itemType, self.sounds, direction, self.level.leftBoundary, self.level.rightBoundary
)
self.level.bouncing_items.append(droppedItem)
# Update stats
self.level.player.stats.update_stat('Enemies killed', 1)
self.level.player.stats.update_stat("Enemies killed", 1)
+42 -82
View File
@@ -1,10 +1,11 @@
# -*- coding: utf-8 -*-
import os
import time
import pygame
import sys
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,22 @@ 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)) and not d.endswith(".md")]
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 +38,8 @@ def selection_menu(sounds, *options):
Returns:
str: Selected option or None if cancelled
"""
loop = True
pygame.mixer.stop()
i = 0
j = -1
return instruction_menu(sounds, "Select an adventure", *options)
# 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)
def select_game(sounds):
"""Display game selection menu and return chosen game.
@@ -101,37 +50,48 @@ def select_game(sounds):
Returns:
str: Selected game directory name or None if cancelled
"""
availableGames = get_available_games()
available_games = get_available_games()
if not availableGames:
if not available_games:
speak("No games found in levels directory!")
return None
# Convert directory names to display names (replace underscores with spaces)
menuOptions = [game.replace("_", " ") for game in availableGames]
menu_options = [game.replace("_", " ") for game in available_games]
choice = selection_menu(sounds, *menuOptions)
choice = selection_menu(sounds, *menu_options)
if choice is None:
return None
# Convert display name back to directory name if needed
gameDir = choice.replace(" ", "_")
if gameDir not in availableGames:
gameDir = choice # Use original if conversion doesn't match
game_dir = choice.replace(" ", "_")
if game_dir not in available_games:
game_dir = choice # Use original if conversion doesn't match
return gameDir
return game_dir
def get_level_path(gameDir, levelNum):
def get_level_path(game_dir, level_num):
"""Get full path to level JSON file.
Args:
gameDir (str): Game directory name
levelNum (int): Level number
game_dir (str): Game directory name
level_num (int): Level number
Returns:
str: Full path to level JSON file
"""
if gameDir is None:
raise ValueError("gameDir cannot be None")
return os.path.join("levels", gameDir, f"{levelNum}.json")
if game_dir is None:
raise ValueError("game_dir cannot be None")
# 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", game_dir, f"{level_num}.json")
return level_path
+9 -7
View File
@@ -4,6 +4,7 @@ import pygame
from libstormgames import *
from src.object import Object
class GraspingHands(Object):
"""A hazard where the ground crumbles beneath the player as undead hands reach up."""
@@ -14,7 +15,7 @@ class GraspingHands(Object):
"", # Empty string so no regular object sound plays
isStatic=True,
isCollectible=False,
isHazard=True
isHazard=True,
)
self.sounds = sounds
self.delay = delay # Delay in milliseconds before ground starts crumbling
@@ -51,7 +52,7 @@ class GraspingHands(Object):
self.isReset = False
# Play initial warning sound
play_sound(self.sounds['grasping_hands_start'])
play_sound(self.sounds["grasping_hands_start"])
speak("The ground crumbles as the dead reach for you.")
def reset(self):
@@ -67,7 +68,7 @@ class GraspingHands(Object):
self.crumbleChannel = None
# Play the end sound
play_sound(self.sounds['grasping_hands_end'])
play_sound(self.sounds["grasping_hands_end"])
def update(self, currentTime, player):
"""Update the grasping hands trap state"""
@@ -92,7 +93,7 @@ class GraspingHands(Object):
# Manage the looping positional audio for the crumbling ground
if self.crumbleChannel is None or not self.crumbleChannel.get_busy():
# Start the sound if it's not playing
self.crumbleChannel = obj_play(self.sounds, 'grasping_hands', player.xPos, self.crumblePosition)
self.crumbleChannel = obj_play(self.sounds, "grasping_hands", player.xPos, self.crumblePosition)
else:
# Update the sound position
self.crumbleChannel = obj_update(self.crumbleChannel, player.xPos, self.crumblePosition)
@@ -100,8 +101,9 @@ class GraspingHands(Object):
# Check if player is caught by crumbling
playerCaught = False
if not player.isJumping:
if (self.crumbleDirection > 0 and player.xPos <= self.crumblePosition) or \
(self.crumbleDirection < 0 and player.xPos >= self.crumblePosition):
if (self.crumbleDirection > 0 and player.xPos <= self.crumblePosition) or (
self.crumbleDirection < 0 and player.xPos >= self.crumblePosition
):
playerCaught = True
if playerCaught:
@@ -122,6 +124,6 @@ class GraspingHands(Object):
def __del__(self):
"""Cleanup when object is destroyed"""
# Ensure sound is stopped when object is destroyed
if hasattr(self, 'crumbleChannel') and self.crumbleChannel:
if hasattr(self, "crumbleChannel") and self.crumbleChannel:
obj_stop(self.crumbleChannel)
self.crumbleChannel = None
+40 -8
View File
@@ -8,14 +8,11 @@ from src.powerup import PowerUp
class GraveObject(Object):
def __init__(self, x, y, sounds, item=None, zombieSpawnChance=0):
super().__init__(
x, y, "grave",
isStatic=True,
isCollectible=False,
isHazard=True,
zombieSpawnChance=zombieSpawnChance
x, y, "grave", isStatic=True, isCollectible=False, isHazard=True, zombieSpawnChance=zombieSpawnChance
)
self.graveItem = item
self.graveItem = item
self.isCollected = False # Renamed to match style of isHazard, isStatic etc
self.isFilled = False # Track if grave has been filled with shovel
self.sounds = sounds
def collect_grave_item(self, player):
@@ -28,9 +25,44 @@ class GraveObject(Object):
if not self.graveItem or self.isCollected:
return False
# Collect the item if player is ducking
if player.isDucking:
# Collect the item if player is ducking, walking (not running), and wielding shovel
if (
player.isDucking
and not player.isRunning
and player.currentWeapon
and player.currentWeapon.name == "rusty_shovel"
):
self.isCollected = True # Mark as collected when collection succeeds
return True
return False
def can_fill_grave(self, player):
"""Check if grave can be filled with shovel.
Returns:
bool: True if grave can be filled, False otherwise
"""
# Can only fill empty graves (no item) that haven't been filled yet
if self.graveItem or self.isFilled:
return False
# Must be ducking, walking (not running), and wielding shovel
return (
player.isDucking
and not player.isRunning
and player.currentWeapon
and player.currentWeapon.name == "rusty_shovel"
)
def fill_grave(self, player):
"""Fill the grave with dirt using shovel.
Returns:
bool: True if grave was filled successfully
"""
if self.can_fill_grave(player):
self.isFilled = True
self.isHazard = False # No longer a hazard once filled
return True
return False
+4 -5
View File
@@ -6,6 +6,7 @@ from enum import Enum, auto
class ItemType(Enum):
"""Defines available item types and their properties"""
GUTS = auto()
HAND_OF_GLORY = auto()
JACK_O_LANTERN = auto()
@@ -13,14 +14,12 @@ class ItemType(Enum):
CAULDRON = auto()
WITCH_BROOM = auto()
class ItemProperties:
"""Manages item properties and availability"""
# Items that can appear in random drops
RANDOM_ELIGIBLE = {
ItemType.HAND_OF_GLORY: "hand_of_glory",
ItemType.JACK_O_LANTERN: "jack_o_lantern"
}
RANDOM_ELIGIBLE = {ItemType.HAND_OF_GLORY: "hand_of_glory", ItemType.JACK_O_LANTERN: "jack_o_lantern"}
# All possible items (including special ones)
ALL_ITEMS = {
@@ -29,7 +28,7 @@ class ItemProperties:
ItemType.JACK_O_LANTERN: "jack_o_lantern",
ItemType.EXTRA_LIFE: "extra_life",
ItemType.CAULDRON: "cauldron",
ItemType.WITCH_BROOM: "witch_broom"
ItemType.WITCH_BROOM: "witch_broom",
}
@staticmethod
+130 -82
View File
@@ -16,15 +16,16 @@ from src.skull_storm import SkullStorm
class Level:
def __init__(self, levelData, sounds, player):
def __init__(self, levelData, sounds, player, levelPackName=None):
self.sounds = sounds
self.levelPackName = levelPackName
self.objects = []
self.enemies = []
self.bouncing_items = []
self.projectiles = [] # Track active projectiles
self.player = player
self.lastWarningTime = 0
self.warningInterval = int(self.sounds['edge'].get_length() * 1000) # Convert seconds to milliseconds
self.warningInterval = int(self.sounds["edge"].get_length() * 1000) # Convert seconds to milliseconds
self.weapon_hit_channel = None
self.leftBoundary = levelData["boundaries"]["left"]
@@ -40,23 +41,35 @@ 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:
pygame.mixer.music.stop()
if "ambience" in levelData:
try:
pygame.mixer.music.load(f"sounds/ambience/{levelData['ambience']}")
pygame.mixer.music.play(-1) # Loop indefinitely
except:
pass
except:
ambientFile = levelData["ambience"]
# Build list of paths to try (pack-specific first, then generic)
ambiencePaths = []
if self.levelPackName:
ambiencePaths.append(f"sounds/{self.levelPackName}/ambience/{ambientFile}")
ambiencePaths.append(f"sounds/ambience/{ambientFile}")
# Try each path until one works
for ambiencePath in ambiencePaths:
try:
pygame.mixer.music.load(ambiencePath)
pygame.mixer.music.play(-1) # Loop indefinitely
break
except Exception:
continue
except Exception:
pass
# Create end of level object at right boundary
@@ -66,7 +79,7 @@ class Level:
"end_of_level",
isStatic=True,
isCollectible=False,
isHazard=False
isHazard=False,
)
self.objects.append(endLevel)
@@ -85,7 +98,7 @@ class Level:
obj["y"],
self.sounds,
fireInterval=obj.get("fireInterval", 5000),
firingRange=obj.get("range", 20)
firingRange=obj.get("range", 20),
)
self.objects.append(catapult)
# Check if this is grasping hands
@@ -95,7 +108,7 @@ class Level:
obj["y"],
self.sounds,
delay=obj.get("delay", 1000),
crumble_speed=obj.get("crumble_speed", 0.03)
crumble_speed=obj.get("crumble_speed", 0.03),
)
self.objects.append(graspingHands)
# Check if this is a grave
@@ -105,7 +118,7 @@ class Level:
obj["y"],
self.sounds,
item=obj.get("item", None),
zombieSpawnChance=obj.get("zombie_spawn_chance", 0)
zombieSpawnChance=obj.get("zombie_spawn_chance", 0),
)
self.objects.append(grave)
# Check if this is a skull storm
@@ -117,7 +130,7 @@ class Level:
obj.get("damage", 5),
obj.get("maximum_skulls", 3),
obj.get("frequency", {}).get("min", 2),
obj.get("frequency", {}).get("max", 5)
obj.get("frequency", {}).get("max", 5),
)
self.objects.append(skullStorm)
# Check if this is a coffin
@@ -127,7 +140,7 @@ class Level:
obj["y"],
self.sounds,
self, # Pass level reference
item=obj.get("item", "random") # Get item type or default to random
item=obj.get("item", "random"), # Get item type or default to random
)
self.objects.append(coffin)
# Check if this is a spider web
@@ -135,8 +148,7 @@ class Level:
# Check distance from graves
isValidPosition = True
for existingObj in self.objects:
if (existingObj.soundName == "grave" and
not hasattr(existingObj, 'graveItem')):
if existingObj.soundName == "grave" and not hasattr(existingObj, "graveItem"):
distance = abs(obj["x"] - existingObj.xPos)
if distance < 3:
isValidPosition = False
@@ -163,7 +175,7 @@ class Level:
damage=obj.get("damage", 1),
attack_range=obj.get("attack_range", 1),
movement_range=obj.get("movement_range", 5),
attack_pattern=obj.get("attack_pattern", {'type': 'patrol'}),
attack_pattern=obj.get("attack_pattern", {"type": "patrol"}),
can_spawn=obj.get("can_spawn", False),
spawn_type=obj.get("spawn_type", "zombie"),
spawn_cooldown=obj.get("spawn_cooldown", 2000),
@@ -172,7 +184,7 @@ class Level:
has_vulnerability=obj.get("has_vulnerability", False),
is_vulnerable=obj.get("is_vulnerable", False),
vulnerability_duration=obj.get("vulnerability_duration", 1000),
invulnerability_duration=obj.get("invulnerability_duration", 5000)
invulnerability_duration=obj.get("invulnerability_duration", 5000),
)
self.enemies.append(enemy)
else:
@@ -183,13 +195,13 @@ class Level:
isStatic=obj.get("static", True),
isCollectible=obj.get("collectible", False),
isHazard=obj.get("hazard", False),
zombieSpawnChance=obj.get("zombieSpawnChance", 0)
zombieSpawnChance=obj.get("zombieSpawnChance", 0),
)
self.objects.append(gameObject)
enemyCount = len(self.enemies)
coffinCount = sum(1 for obj in self.objects if hasattr(obj, 'isBroken'))
player.stats.update_stat('Enemies remaining', enemyCount)
player.stats.update_stat('Coffins remaining', coffinCount)
coffinCount = sum(1 for obj in self.objects if hasattr(obj, "isBroken"))
player.stats.update_stat("Enemies remaining", enemyCount)
player.stats.update_stat("Coffins remaining", coffinCount)
def update_audio(self):
"""Update all audio and entity state."""
@@ -201,9 +213,7 @@ class Level:
continue
# Check for potential zombie spawn from graves
if (obj.soundName == "grave" and
obj.zombieSpawnChance > 0 and
not obj.hasSpawned):
if obj.soundName == "grave" and obj.zombieSpawnChance > 0 and not obj.hasSpawned:
distance = abs(self.player.xPos - obj.xPos)
if distance < 6: # Within 6 tiles
@@ -212,6 +222,10 @@ class Level:
roll = random.randint(1, 100)
if roll <= obj.zombieSpawnChance:
# Set behavior based on game mode
behavior = "hunter" if self.levelId == 999 else "patrol"
turn_rate = 2 if self.levelId == 999 else 8 # Faster turn rate for survival
zombie = Enemy(
[obj.xPos, obj.xPos],
obj.yPos,
@@ -220,7 +234,9 @@ class Level:
self, # Pass the level reference
health=3,
damage=10,
attack_range=1
attack_range=1,
attack_pattern={"type": behavior},
turn_rate=turn_rate,
)
self.enemies.append(zombie)
speak("A zombie emerges from the grave!")
@@ -265,6 +281,16 @@ class Level:
if caught:
return # Stop if player is dead
# Update death sound objects
from src.die_monster_die import DeathSound
for obj in self.objects:
if isinstance(obj, DeathSound):
obj.update(currentTime)
# Clean up inactive objects (including finished death sounds)
self.objects = [obj for obj in self.objects if obj.isActive]
# Update bouncing items
for item in self.bouncing_items[:]: # Copy list to allow removal
if not item.update(currentTime, self.player.xPos):
@@ -275,8 +301,8 @@ class Level:
# Check for item collection
if abs(item._currentX - self.player.xPos) < 1 and self.player.isJumping:
play_sound(self.sounds[f'get_{item.soundName}'])
item.apply_effect(self.player)
play_sound(self.sounds[f"get_{item.soundName}"])
item.apply_effect(self.player, self)
self.levelScore += 1000 # All items collected points awarded
item.isActive = False
self.bouncing_items.remove(item)
@@ -297,11 +323,13 @@ class Level:
# Check for coffin hits
for obj in self.objects:
if hasattr(obj, 'isBroken'): # Check if it's a coffin without using isinstance
if (not obj.isBroken and
obj.xPos >= attackRange[0] and
obj.xPos <= attackRange[1] and
self.player.isJumping): # Must be jumping to hit floating coffins
if hasattr(obj, "isBroken"): # Check if it's a coffin without using isinstance
if (
not obj.isBroken
and obj.xPos >= attackRange[0]
and obj.xPos <= attackRange[1]
and self.player.isJumping
): # Must be jumping to hit floating coffins
if obj.hit(self.player.xPos):
self.bouncing_items.append(obj.dropped_item)
@@ -317,7 +345,7 @@ class Level:
health=8,
damage=8,
attack_range=1,
speed_multiplier=2.0
speed_multiplier=2.0,
)
self.enemies.append(spider)
@@ -336,62 +364,71 @@ class Level:
continue
# Handle grave edge warnings
if obj.isHazard and obj.soundName != "spiderweb" and not isinstance(obj, GraspingHands): # Exclude spiderwebs and grasping hands
if (
obj.isHazard and obj.soundName != "spiderweb" and not isinstance(obj, GraspingHands)
): # Exclude spiderwebs and grasping hands
distance = abs(self.player.xPos - obj.xPos)
currentTime = pygame.time.get_ticks()
if (distance <= 2 and not self.player.isJumping and not self.player.isInvincible
and currentTime - self.lastWarningTime >= self.warningInterval):
if (
distance <= 2
and not self.player.isJumping
and not self.player.isInvincible
and currentTime - self.lastWarningTime >= self.warningInterval
):
if isinstance(obj, GraveObject) and obj.graveItem and not obj.isCollected:
play_sound(self.sounds['_edge'])
play_sound(self.sounds["_edge"])
else:
play_sound(self.sounds['edge'])
play_sound(self.sounds["edge"])
self.lastWarningTime = currentTime
if not obj.is_in_range(self.player.xPos):
continue
# Handle collectibles
if obj.isCollectible and self.player.isJumping:
# Handle collectibles (but not if player died this frame)
if obj.isCollectible and self.player.isJumping and not self.player.diedThisFrame:
currentPos = round(self.player.xPos)
if currentPos not in obj.collectedPositions:
play_sound(self.sounds[f'get_{obj.soundName}'])
play_sound(self.sounds[f"get_{obj.soundName}"])
obj.collect_at_position(currentPos)
self.player.collectedItems.append(obj.soundName)
self.player.stats.update_stat('Items collected', 1)
self.player.stats.update_stat("Items collected", 1)
if obj.soundName == "bone_dust":
self.player._coins += 1
self.player.add_save_bone_dust(1) # Add to save bone dust counter too
self.levelScore += 100
self.player.stats.update_stat('Bone dust', 1)
self.player.stats.update_stat("Bone dust", 1)
if self.player._coins % 5 == 0:
# Only heal if below max health
if self.player.get_health() < self.player.get_max_health():
self.player.set_health(min(
self.player.get_health() + 1,
self.player.get_max_health()
))
self.player.set_health(min(self.player.get_health() + 1, self.player.get_max_health()))
if self.player._coins % 100 == 0:
# Extra life
self.player._coins = 0
self.player._lives += 1
self.levelScore += 1000
play_sound(self.sounds['get_extra_life'])
# Only give extra lives in story mode, not survival mode (level_id 999)
if self.levelId != 999:
# Extra life
self.player._coins = 0
self.player._lives += 1
self.levelScore += 1000
play_sound(self.sounds["get_extra_life"])
else:
# In survival mode, reset coin counter but give bonus score instead
self.player._coins = 0
self.levelScore += 2000 # Double score bonus instead of extra life
speak("100 bone dust collected! Bonus score!")
play_sound(
self.sounds.get("survivor_bonus", "bone_dust")
) # Use survivor_bonus sound if available, fallback to bone_dust
continue
# Handle spiderweb - this should trigger for both walking and jumping if not ducking
if obj.soundName == "spiderweb" and not self.player.isDucking:
# Create and apply web effect
webEffect = PowerUp(
obj.xPos,
obj.yPos,
'spiderweb',
self.sounds,
0 # No direction needed since it's just for effect
obj.xPos, obj.yPos, "spiderweb", self.sounds, 0 # No direction needed since it's just for effect
)
webEffect.level = self # Pass level reference for spider spawning
play_sound(self.sounds['hit_spiderweb'])
webEffect.apply_effect(self.player)
play_sound(self.sounds["hit_spiderweb"])
webEffect.apply_effect(self.player, self)
# Deactivate web
obj.isActive = False
@@ -401,16 +438,19 @@ class Level:
# Handle graves and other hazards
if obj.isHazard and not self.player.isJumping:
if isinstance(obj, GraveObject):
can_collect = obj.collect_grave_item(self.player)
can_collect = obj.collect_grave_item(self.player) and not self.player.diedThisFrame
can_fill = obj.can_fill_grave(self.player)
if can_collect:
# Successfully collected item while ducking
play_sound(self.sounds[f'get_{obj.graveItem}'])
self.player.stats.update_stat('Items collected', 1)
# Successfully collected item while ducking with shovel
play_sound(self.sounds[f"get_{obj.graveItem}"])
play_sound(self.sounds.get("fill_in_grave", "shovel_dig")) # Also play fill sound
self.player.stats.update_stat("Items collected", 1)
# Create PowerUp to handle the item effect
item = PowerUp(obj.xPos, obj.yPos, obj.graveItem, self.sounds, 1,
self.leftBoundary, self.rightBoundary)
item.apply_effect(self.player)
item = PowerUp(
obj.xPos, obj.yPos, obj.graveItem, self.sounds, 1, self.leftBoundary, self.rightBoundary
)
item.apply_effect(self.player, self)
# Stop grave's current audio channel
if obj.channel:
obj_stop(obj.channel)
@@ -419,6 +459,18 @@ class Level:
obj.channel = None
obj.isActive = False # Mark the grave as inactive after collection
continue
elif can_fill and obj.fill_grave(self.player):
# Successfully filled empty grave with shovel
play_sound(
self.sounds.get("fill_in_grave", "shovel_dig")
) # Use fill_in_grave sound if available, fallback to shovel_dig
self.player.stats.update_stat("Graves filled", 1)
# Stop grave's current audio channel
if obj.channel:
obj_stop(obj.channel)
obj.channel = None
obj.isActive = False # Mark grave as inactive after filling
continue
elif not self.player.isInvincible:
# Kill player for normal graves or non-ducking collision
play_sound(self.sounds[obj.soundName])
@@ -440,14 +492,14 @@ class Level:
# If level is locked, check for remaining enemies
if self.isLocked and any(enemy.isActive for enemy in self.enemies):
speak("You must defeat all enemies before proceeding!")
play_sound(self.sounds['locked'])
play_sound(self.sounds["locked"])
# Push player back a bit
self.player.xPos -= 5
return False
# Level complete
pygame.mixer.stop()
play_sound(self.sounds['end_of_level'])
play_sound(self.sounds["end_of_level"])
self.levelScore += 10000
# Actually update the scoreboard with level completion
self.player.scoreboard.increase_score(self.levelScore)
@@ -470,7 +522,7 @@ class Level:
# Calculate volume and pan for splat sound based on final position
volume, left, right = calculate_volume_and_pan(self.player.xPos, proj.x)
if volume > 0: # Only play if within audible range
obj_play(self.sounds, 'pumpkin_splat', self.player.xPos, proj.x, loop=False)
obj_play(self.sounds, "pumpkin_splat", self.player.xPos, proj.x, loop=False)
break
def throw_projectile(self):
@@ -480,10 +532,6 @@ class Level:
speak("No jack o'lanterns to throw!")
return
self.projectiles.append(Projectile(
proj_info['type'],
proj_info['start_x'],
proj_info['direction']
))
self.projectiles.append(Projectile(proj_info["type"], proj_info["start_x"], proj_info["direction"]))
# Play throw sound
play_sound(self.sounds['throw_jack_o_lantern'])
play_sound(self.sounds["throw_jack_o_lantern"])
+1
View File
@@ -2,6 +2,7 @@
from libstormgames import *
class Object:
def __init__(self, x, yPos, soundName, isStatic=True, isCollectible=False, isHazard=False, zombieSpawnChance=0):
# x can be either a single position or a range [start, end]
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Pack-specific sound system for Wicked Quest.
Provides hierarchical sound loading that checks pack-specific sounds first,
then falls back to generic sounds, without modifying libstormgames.
"""
import os
import pygame
from libstormgames.sound import Sound
class PackSoundSystem(dict):
"""Sound system with hierarchical pack-specific loading."""
def __init__(self, originalSounds, soundDir="sounds/", levelPackName=None):
"""Initialize pack-specific sound system.
Args:
originalSounds (dict): Original sound dictionary from initialize_gui
soundDir (str): Base sound directory
levelPackName (str): Name of level pack for pack-specific sounds
"""
# Initialize dict with original sounds
super().__init__(originalSounds)
self.soundDir = soundDir
self.levelPackName = levelPackName
# Load pack-specific sounds if pack name provided
if levelPackName:
self._load_pack_sounds()
def _load_pack_sounds(self):
"""Load pack-specific sounds from sounds/[pack_name]/ directory."""
packSoundDir = os.path.join(self.soundDir, self.levelPackName)
if not os.path.exists(packSoundDir):
return
try:
for dirPath, _, fileNames in os.walk(packSoundDir):
relPath = os.path.relpath(dirPath, packSoundDir)
for fileName in fileNames:
if fileName.lower().endswith((".ogg", ".wav")):
fullPath = os.path.join(dirPath, fileName)
baseName = os.path.splitext(fileName)[0]
# Create sound key same as base system
soundKey = baseName if relPath == "." else os.path.join(relPath, baseName).replace("\\", "/")
# Add/override sound in the main dictionary
self[soundKey] = pygame.mixer.Sound(fullPath)
except Exception as e:
print(f"Error loading pack sounds: {e}")
+76 -32
View File
@@ -59,28 +59,35 @@ class Player:
self.invincibilityStartTime = 0
self.invincibilityDuration = 10000 # 10 seconds of invincibility
# Death state tracking (to prevent revival after death in same frame)
self.diedThisFrame = False
# Initialize starting weapon (rusty shovel)
self.add_weapon(Weapon(
name="rusty_shovel",
damage=2,
range=2,
attackSound="player_shovel_attack",
hitSound="player_shovel_hit",
attackDuration=200 # 200ms attack duration
))
self.add_weapon(
Weapon(
name="rusty_shovel",
damage=2,
range=2,
attackSound="player_shovel_attack",
hitSound="player_shovel_hit",
attackDuration=200, # 200ms attack duration
)
)
self.scoreboard = Scoreboard()
def should_play_footstep(self, currentTime):
"""Check if it's time to play a footstep sound"""
return (self.distanceSinceLastStep >= self.get_step_distance() and
currentTime - self.lastStepTime >= self.get_step_interval())
return (
self.distanceSinceLastStep >= self.get_step_distance()
and currentTime - self.lastStepTime >= self.get_step_interval()
)
def duck(self):
"""Start ducking"""
if not self.isDucking and not self.isJumping: # Can't duck while jumping
self.isDucking = True
play_sound(self.sounds['duck'])
play_sound(self.sounds["duck"])
return True
return False
@@ -88,11 +95,14 @@ class Player:
"""Stop ducking state and play sound"""
if self.isDucking:
self.isDucking = False
play_sound(self.sounds['stand'])
play_sound(self.sounds["stand"])
def update(self, currentTime):
"""Update player state"""
if hasattr(self, 'webPenaltyEndTime'):
# Reset death flag at start of each frame
self.diedThisFrame = False
if hasattr(self, "webPenaltyEndTime"):
if currentTime >= self.webPenaltyEndTime:
self.moveSpeed *= 2 # Restore speed
if self.currentWeapon:
@@ -101,15 +111,17 @@ class Player:
# Check invincibility status
if self.isInvincible:
remaining_time = (self.invincibilityStartTime + self.invincibilityDuration - currentTime) / 1000 # Convert to seconds
remaining_time = (
self.invincibilityStartTime + self.invincibilityDuration - currentTime
) / 1000 # Convert to seconds
# Handle countdown sounds
if not hasattr(self, '_last_countdown'):
if not hasattr(self, "_last_countdown"):
self._last_countdown = 4 # Start counting from 4 to catch 3,2,1
current_second = int(remaining_time)
if current_second < self._last_countdown and current_second <= 3 and current_second > 0:
play_sound(self.sounds['end_of_invincibility_warning'])
play_sound(self.sounds["end_of_invincibility_warning"])
self._last_countdown = current_second
# Check if invincibility has expired
@@ -122,7 +134,7 @@ class Player:
"""Activate invincibility from Hand of Glory"""
self.isInvincible = True
self.invincibilityStartTime = pygame.time.get_ticks()
if hasattr(self, '_last_countdown'):
if hasattr(self, "_last_countdown"):
del self._last_countdown # Reset countdown if it exists
def extra_life(self):
@@ -150,23 +162,27 @@ class Player:
return None
self._jack_o_lantern_count -= 1
return {
'type': 'jack_o_lantern',
'start_x': self.xPos,
'direction': 1 if self.facingRight else -1
}
return {"type": "jack_o_lantern", "start_x": self.xPos, "direction": 1 if self.facingRight else -1}
def get_step_distance(self):
"""Get step distance based on current speed"""
weaponBonus = self.currentWeapon.speedBonus if self.currentWeapon else 1.0
totalMultiplier = weaponBonus
if self.isRunning or self.isJumping:
return self.baseStepDistance / self.runMultiplier
return self.baseStepDistance
totalMultiplier *= self.runMultiplier
return self.baseStepDistance / totalMultiplier
def get_step_interval(self):
"""Get minimum time between steps based on current speed"""
weaponBonus = self.currentWeapon.speedBonus if self.currentWeapon else 1.0
totalMultiplier = weaponBonus
if self.isRunning or self.isJumping:
return self.baseStepInterval / self.runMultiplier
return self.baseStepInterval
totalMultiplier *= self.runMultiplier
return self.baseStepInterval / totalMultiplier
def get_health(self):
"""Get current health"""
@@ -181,10 +197,18 @@ class Player:
self._health = self._maxHealth
def get_current_speed(self):
"""Calculate current speed based on state"""
"""Calculate current speed based on state and weapon"""
baseSpeed = self.moveSpeed
if self.isJumping or self.isRunning: return baseSpeed * self.runMultiplier
return baseSpeed
weaponBonus = self.currentWeapon.speedBonus if self.currentWeapon else 1.0
if self.isJumping or self.isRunning:
return baseSpeed * self.runMultiplier * weaponBonus
return baseSpeed * weaponBonus
def get_current_jump_duration(self):
"""Calculate current jump duration based on weapon bonus"""
weaponBonus = self.currentWeapon.jumpDurationBonus if self.currentWeapon else 1.0
return int(self.jumpDuration * weaponBonus)
def set_footstep_sound(self, soundName):
"""Set the current footstep sound"""
@@ -196,20 +220,22 @@ class Player:
# Oops, allow healing while invincible.
if self.isInvincible and value < old_health:
return
return
self._health = max(0, value) # Health can't go below 0
if self._health == 0 and old_health > 0:
self._lives -= 1
# Mark that player died this frame to prevent revival
self.diedThisFrame = True
# Stop all current sounds before playing death sound
pygame.mixer.stop()
try:
pygame.mixer.music.stop()
except:
except Exception:
pass
cut_scene(self.sounds, 'lose_a_life')
cut_scene(self.sounds, "lose_a_life")
def set_max_health(self, value):
"""Set max health"""
@@ -253,6 +279,24 @@ class Player:
if weapon in self.weapons:
self.currentWeapon = weapon
def switch_to_weapon(self, weaponIndex):
"""Switch to weapon by index (1=shovel, 2=broom, 3=nunchucks)"""
weaponMap = {1: "rusty_shovel", 2: "witch_broom", 3: "nunchucks"}
targetWeaponName = weaponMap.get(weaponIndex)
if not targetWeaponName:
return False
# Find the weapon in player's inventory
for weapon in self.weapons:
if weapon.name == targetWeaponName:
self.equip_weapon(weapon)
speak(weapon.name.replace("_", " "))
return True
# Weapon not found in inventory
return False
def add_item(self, item):
"""Add an item to inventory"""
self.inventory.append(item)
+49 -28
View File
@@ -5,14 +5,10 @@ from libstormgames import *
from src.object import Object
from src.weapon import Weapon
class PowerUp(Object):
def __init__(self, x, y, item_type, sounds, direction, left_boundary=1, right_boundary=100):
super().__init__(
x, y, item_type,
isStatic=False,
isCollectible=True,
isHazard=False
)
super().__init__(x, y, item_type, isStatic=False, isCollectible=True, isHazard=False)
self.sounds = sounds
self.direction = direction
self.speed = 0.049 # Base movement speed
@@ -56,35 +52,58 @@ class PowerUp(Object):
return True
def apply_effect(self, player):
def apply_effect(self, player, level=None):
"""Apply the item's effect when collected"""
if self.item_type == 'hand_of_glory':
if self.item_type == "hand_of_glory":
player.start_invincibility()
elif self.item_type == 'cauldron':
elif self.item_type == "cauldron":
player.restore_health()
elif self.item_type == 'guts':
elif self.item_type == "guts":
player.add_guts()
player.collectedItems.append('guts')
player.collectedItems.append("guts")
self.check_for_nunchucks(player)
elif self.item_type == 'jack_o_lantern':
elif self.item_type == "jack_o_lantern":
player.add_jack_o_lantern()
elif self.item_type == 'extra_life':
player.extra_life()
elif self.item_type == 'shin_bone': # Add shin bone handling
elif self.item_type == "extra_life":
# Don't give extra lives in survival mode
if level and level.levelId == 999:
# In survival mode, give bonus score instead
level.levelScore += 2000
speak("Extra life found! Bonus score in survival mode!")
play_sound(self.sounds.get("survivor_bonus", "get_extra_life")) # Use survivor_bonus sound if available
else:
player.extra_life()
elif self.item_type == "shin_bone": # Add shin bone handling
player.shinBoneCount += 1
player._coins += 5
player.add_save_bone_dust(5) # Add to save bone dust counter too
if player.get_health() < player.get_max_health():
player.set_health(min(
player.get_health() + 1,
player.get_max_health()
))
player.set_health(min(player.get_health() + 1, player.get_max_health()))
# Check for 100 coin bonus after adding shin bone coins
if player._coins >= 100:
# Only give extra lives in story mode, not survival mode (level_id 999)
if level.levelId != 999:
# Extra life
player._coins = 0
player._lives += 1
level.levelScore += 1000
play_sound(self.sounds["get_extra_life"])
else:
# In survival mode, reset coin counter but give bonus score instead
player._coins = 0
level.levelScore += 2000 # Double score bonus instead of extra life
speak("100 bone dust collected! Bonus score!")
play_sound(
self.sounds.get("survivor_bonus", "bone_dust")
) # Use survivor_bonus sound if available, fallback to bone_dust
self.check_for_nunchucks(player)
elif self.item_type == 'witch_broom':
elif self.item_type == "witch_broom":
broomWeapon = Weapon.create_witch_broom()
player.add_weapon(broomWeapon)
player.equip_weapon(broomWeapon)
elif self.item_type == 'spiderweb':
elif self.item_type == "spiderweb":
# Bounce player back (happens even if invincible)
player.xPos -= 3 if player.xPos > self.xPos else -3
@@ -98,7 +117,7 @@ class PowerUp(Object):
player.webPenaltyEndTime = pygame.time.get_ticks() + 15000
# Tell level to spawn a spider
if hasattr(self, 'level'):
if hasattr(self, "level"):
self.level.spawn_spider(self.xPos, self.yPos)
# Stop movement sound when collected
@@ -107,18 +126,20 @@ class PowerUp(Object):
self.channel = None
# Item tracking
player.stats.update_stat('Items collected', 1)
player.stats.update_stat("Items collected", 1)
def check_for_nunchucks(self, player):
"""Check if player has materials for nunchucks and create if conditions are met"""
if (player.shinBoneCount >= 2 and
'guts' in player.collectedItems and
not any(weapon.name == "nunchucks" for weapon in player.weapons)):
if (
player.shinBoneCount >= 2
and "guts" in player.collectedItems
and not any(weapon.name == "nunchucks" for weapon in player.weapons)
):
nunchucksWeapon = Weapon.create_nunchucks()
player.add_weapon(nunchucksWeapon)
player.equip_weapon(nunchucksWeapon)
basePoints = nunchucksWeapon.damage * 1000
rangeModifier = nunchucksWeapon.range * 500
player.scoreboard.increase_score(basePoints + rangeModifier)
play_sound(self.sounds['get_nunchucks'])
player.stats.update_stat('Items collected', 1)
play_sound(self.sounds["get_nunchucks"])
player.stats.update_stat("Items collected", 1)
+1
View File
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
class Projectile:
def __init__(self, projectile_type, start_x, direction):
self.type = projectile_type
+135 -116
View File
@@ -11,8 +11,8 @@ class SaveManager:
def __init__(self):
"""Initialize save manager with XDG-compliant save directory"""
# Use XDG_CONFIG_HOME or default to ~/.config
config_home = os.environ.get('XDG_CONFIG_HOME', os.path.expanduser('~/.config'))
self.save_dir = Path(config_home) / 'storm-games' / 'wicked-quest'
config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
self.save_dir = Path(config_home) / "storm-games" / "wicked-quest"
self.save_dir.mkdir(parents=True, exist_ok=True)
self.max_saves = 10
@@ -24,7 +24,7 @@ class SaveManager:
# Validate required parameters
if current_game is None:
return False, "No game selected to save"
if current_level is None:
return False, "No current level to save"
@@ -34,30 +34,30 @@ class SaveManager:
# Create save data
save_data = {
'player_state': {
'xPos': player.xPos,
'yPos': player.yPos,
'health': player._health,
'maxHealth': player._maxHealth,
'lives': player._lives,
'coins': player._coins,
'saveBoneDust': player._saveBoneDust,
'jackOLanternCount': player._jack_o_lantern_count,
'shinBoneCount': player.shinBoneCount,
'inventory': player.inventory,
'collectedItems': player.collectedItems,
'weapons': self._serialize_weapons(player.weapons),
'currentWeaponName': player.currentWeapon.name if player.currentWeapon else None,
'stats': self._serialize_stats(player.stats),
'scoreboard': self._serialize_scoreboard(player.scoreboard)
"player_state": {
"xPos": player.xPos,
"yPos": player.yPos,
"health": player._health,
"maxHealth": player._maxHealth,
"lives": player._lives,
"coins": player._coins,
"saveBoneDust": player._saveBoneDust,
"jackOLanternCount": player._jack_o_lantern_count,
"shinBoneCount": player.shinBoneCount,
"inventory": player.inventory,
"collectedItems": player.collectedItems,
"weapons": self._serialize_weapons(player.weapons),
"currentWeaponName": player.currentWeapon.name if player.currentWeapon else None,
"stats": self._serialize_stats(player.stats),
"scoreboard": self._serialize_scoreboard(player.scoreboard),
},
'game_state': {
'currentLevel': current_level,
'currentGame': current_game,
'gameStartTime': game_start_time,
'saveTime': datetime.now()
"game_state": {
"currentLevel": current_level,
"currentGame": current_game,
"gameStartTime": game_start_time,
"saveTime": datetime.now(),
},
'version': '1.0'
"version": "1.0",
}
# Generate filename with timestamp
@@ -67,26 +67,26 @@ class SaveManager:
try:
# Write to temporary file first, then rename for atomic operation
temp_filepath = filepath.with_suffix('.tmp')
with open(temp_filepath, 'wb') as f:
temp_filepath = filepath.with_suffix(".tmp")
with open(temp_filepath, "wb") as f:
pickle.dump(save_data, f)
f.flush() # Ensure data is written to disk
os.fsync(f.fileno()) # Force write to disk
# Atomic rename (replaces old file if it exists)
temp_filepath.rename(filepath)
# Clean up old saves if we exceed max_saves
self._cleanup_old_saves()
return True, f"Game saved to {filename}"
except Exception as e:
# Clean up temp file if it exists
if temp_filepath.exists():
try:
temp_filepath.unlink()
except:
except Exception:
pass
return False, f"Failed to save game: {str(e)}"
@@ -94,119 +94,136 @@ class SaveManager:
"""Serialize weapons for saving"""
serialized = []
for weapon in weapons:
serialized.append({
'name': weapon.name,
'damage': weapon.damage,
'range': weapon.range,
'attackSound': weapon.attackSound,
'hitSound': weapon.hitSound,
'attackDuration': weapon.attackDuration
})
serialized.append(
{
"name": weapon.name,
"damage": weapon.damage,
"range": weapon.range,
"attackSound": weapon.attackSound,
"hitSound": weapon.hitSound,
"attackDuration": weapon.attackDuration,
"speedBonus": getattr(weapon, "speedBonus", 1.0),
"jumpDurationBonus": getattr(weapon, "jumpDurationBonus", 1.0),
}
)
return serialized
def _deserialize_weapons(self, weapon_data):
"""Deserialize weapons from save data"""
from src.weapon import Weapon
weapons = []
for data in weapon_data:
# Handle backward compatibility for old saves
speedBonus = data.get("speedBonus", 1.0)
jumpDurationBonus = data.get("jumpDurationBonus", 1.0)
# For old saves, restore proper bonuses for specific weapons
if data["name"] == "witch_broom" and speedBonus == 1.0:
speedBonus = 1.17
jumpDurationBonus = 1.25
weapon = Weapon(
name=data['name'],
damage=data['damage'],
range=data['range'],
attackSound=data['attackSound'],
hitSound=data['hitSound'],
attackDuration=data['attackDuration']
name=data["name"],
damage=data["damage"],
range=data["range"],
attackSound=data["attackSound"],
hitSound=data["hitSound"],
attackDuration=data["attackDuration"],
speedBonus=speedBonus,
jumpDurationBonus=jumpDurationBonus,
)
weapons.append(weapon)
return weapons
def _serialize_stats(self, stats):
"""Serialize stats for saving"""
return {
'total': stats.total.copy(),
'level': stats.level.copy()
}
return {"total": stats.total.copy(), "level": stats.level.copy()}
def _deserialize_stats(self, stats_data):
"""Deserialize stats from save data"""
from src.stat_tracker import StatTracker
stats = StatTracker()
if 'total' in stats_data:
stats.total.update(stats_data['total'])
if 'level' in stats_data:
stats.level.update(stats_data['level'])
if "total" in stats_data:
stats.total.update(stats_data["total"])
if "level" in stats_data:
stats.level.update(stats_data["level"])
return stats
def _serialize_scoreboard(self, scoreboard):
"""Serialize scoreboard for saving"""
return {
'currentScore': getattr(scoreboard, 'currentScore', 0),
'highScores': getattr(scoreboard, 'highScores', [])
"currentScore": getattr(scoreboard, "currentScore", 0),
"highScores": getattr(scoreboard, "highScores", []),
}
def _deserialize_scoreboard(self, scoreboard_data):
"""Deserialize scoreboard from save data"""
from libstormgames import Scoreboard
scoreboard = Scoreboard()
if 'currentScore' in scoreboard_data:
scoreboard.currentScore = scoreboard_data['currentScore']
if 'highScores' in scoreboard_data:
scoreboard.highScores = scoreboard_data['highScores']
if "currentScore" in scoreboard_data:
scoreboard.currentScore = scoreboard_data["currentScore"]
if "highScores" in scoreboard_data:
scoreboard.highScores = scoreboard_data["highScores"]
return scoreboard
def get_save_files(self):
"""Get list of save files with metadata"""
save_files = []
pattern = str(self.save_dir / "save_*.pickle")
for filepath in glob.glob(pattern):
try:
with open(filepath, 'rb') as f:
with open(filepath, "rb") as f:
save_data = pickle.load(f)
# Validate save data structure
if not self._validate_save_data(save_data):
print(f"Invalid save file structure: {filepath}")
continue
# Extract save info
save_time = save_data['game_state']['saveTime']
level = save_data['game_state']['currentLevel']
game_name = save_data['game_state']['currentGame']
save_time = save_data["game_state"]["saveTime"]
level = save_data["game_state"]["currentLevel"]
game_name = save_data["game_state"]["currentGame"]
# Format display name
formatted_time = save_time.strftime("%B %d %I:%M%p")
display_name = f"{formatted_time} {game_name} Level {level}"
save_files.append({
'filepath': filepath,
'display_name': display_name,
'save_time': save_time,
'level': level,
'game_name': game_name,
'save_data': save_data
})
save_files.append(
{
"filepath": filepath,
"display_name": display_name,
"save_time": save_time,
"level": level,
"game_name": game_name,
"save_data": save_data,
}
)
except (pickle.PickleError, EOFError, OSError) as e:
print(f"Corrupted save file {filepath}: {e}")
# Try to remove corrupted save file
try:
os.remove(filepath)
print(f"Removed corrupted save file: {filepath}")
except:
except Exception:
pass
continue
except Exception as e:
print(f"Error reading save file {filepath}: {e}")
continue
# Sort by save time (newest first)
save_files.sort(key=lambda x: x['save_time'], reverse=True)
save_files.sort(key=lambda x: x["save_time"], reverse=True)
return save_files
def load_save(self, filepath):
"""Load game state from save file"""
try:
with open(filepath, 'rb') as f:
with open(filepath, "rb") as f:
save_data = pickle.load(f)
return True, save_data
except Exception as e:
@@ -214,55 +231,57 @@ class SaveManager:
def restore_player_state(self, player, save_data):
"""Restore player state from save data"""
player_state = save_data['player_state']
player_state = save_data["player_state"]
# Restore basic attributes
player.xPos = player_state['xPos']
player.yPos = player_state['yPos']
player._health = player_state['health']
player._maxHealth = player_state['maxHealth']
player._lives = player_state['lives']
player._coins = player_state['coins']
player._saveBoneDust = player_state['saveBoneDust']
player._jack_o_lantern_count = player_state['jackOLanternCount']
player.shinBoneCount = player_state['shinBoneCount']
player.inventory = player_state['inventory']
player.collectedItems = player_state['collectedItems']
player.xPos = player_state["xPos"]
player.yPos = player_state["yPos"]
player._health = player_state["health"]
player._maxHealth = player_state["maxHealth"]
player._lives = player_state["lives"]
player._coins = player_state["coins"]
player._saveBoneDust = player_state["saveBoneDust"]
player._jack_o_lantern_count = player_state["jackOLanternCount"]
player.shinBoneCount = player_state["shinBoneCount"]
player.inventory = player_state["inventory"]
player.collectedItems = player_state["collectedItems"]
# Restore weapons
player.weapons = self._deserialize_weapons(player_state['weapons'])
player.weapons = self._deserialize_weapons(player_state["weapons"])
# Restore current weapon
current_weapon_name = player_state.get('currentWeaponName')
current_weapon_name = player_state.get("currentWeaponName")
if current_weapon_name:
for weapon in player.weapons:
if weapon.name == current_weapon_name:
player.currentWeapon = weapon
break
# Restore stats
if 'stats' in player_state:
player.stats = self._deserialize_stats(player_state['stats'])
if "stats" in player_state:
player.stats = self._deserialize_stats(player_state["stats"])
else:
from src.stat_tracker import StatTracker
player.stats = StatTracker()
# Restore scoreboard
if 'scoreboard' in player_state:
player.scoreboard = self._deserialize_scoreboard(player_state['scoreboard'])
if "scoreboard" in player_state:
player.scoreboard = self._deserialize_scoreboard(player_state["scoreboard"])
else:
from libstormgames import Scoreboard
player.scoreboard = Scoreboard()
def _cleanup_old_saves(self):
"""Remove old save files if we exceed max_saves"""
save_files = self.get_save_files()
if len(save_files) > self.max_saves:
# Remove oldest saves
for save_file in save_files[self.max_saves:]:
try:
os.remove(save_file['filepath'])
os.remove(save_file["filepath"])
except Exception as e:
print(f"Error removing old save {save_file['filepath']}: {e}")
@@ -270,24 +289,24 @@ class SaveManager:
"""Validate that save data has required structure"""
try:
# Check for required top-level keys
required_keys = ['player_state', 'game_state', 'version']
required_keys = ["player_state", "game_state", "version"]
if not all(key in save_data for key in required_keys):
return False
# Check player_state structure
player_required = ['xPos', 'yPos', 'health', 'maxHealth', 'lives', 'coins', 'saveBoneDust']
if not all(key in save_data['player_state'] for key in player_required):
player_required = ["xPos", "yPos", "health", "maxHealth", "lives", "coins", "saveBoneDust"]
if not all(key in save_data["player_state"] for key in player_required):
return False
# Check game_state structure
game_required = ['currentLevel', 'currentGame', 'gameStartTime', 'saveTime']
if not all(key in save_data['game_state'] for key in game_required):
game_required = ["currentLevel", "currentGame", "gameStartTime", "saveTime"]
if not all(key in save_data["game_state"] for key in game_required):
return False
return True
except:
except Exception:
return False
def has_saves(self):
"""Check if any save files exist"""
return len(self.get_save_files()) > 0
return len(self.get_save_files()) > 0
+24 -29
View File
@@ -5,17 +5,13 @@ import random
from libstormgames import *
from src.object import Object
class SkullStorm(Object):
"""Handles falling skulls within a specified range."""
def __init__(self, xRange, y, sounds, damage, maxSkulls=3, minFreq=2, maxFreq=5):
super().__init__(
xRange,
y,
"", # No ambient sound for the skull storm
isStatic=True,
isCollectible=False,
isHazard=False
xRange, y, "", isStatic=True, isCollectible=False, isHazard=False # No ambient sound for the skull storm
)
self.sounds = sounds
self.damage = damage
@@ -37,7 +33,7 @@ class SkullStorm(Object):
inRange = self.xRange[0] <= player.xPos <= self.xRange[1]
if inRange and not self.playerInRange:
# Player just entered range - play the warning sound
play_sound(self.sounds['skull_storm'])
play_sound(self.sounds["skull_storm"])
self.playerInRange = True
elif not inRange and self.playerInRange: # Only speak when actually leaving range
# Player just left range
@@ -46,8 +42,8 @@ class SkullStorm(Object):
# Clear any active skulls when player leaves the range
for skull in self.activeSkulls[:]:
if skull['channel']:
obj_stop(skull['channel'])
if skull["channel"]:
obj_stop(skull["channel"])
self.activeSkulls = [] # Reset the list of active skulls
if not inRange:
@@ -55,29 +51,28 @@ class SkullStorm(Object):
# Update existing skulls
for skull in self.activeSkulls[:]: # Copy list to allow removal
if currentTime >= skull['land_time']:
if currentTime >= skull["land_time"]:
# Skull has landed
self.handle_landing(skull, player)
self.activeSkulls.remove(skull)
else:
# Update falling sound
timeElapsed = currentTime - skull['start_time']
fallProgress = timeElapsed / skull['fall_duration']
timeElapsed = currentTime - skull["start_time"]
fallProgress = timeElapsed / skull["fall_duration"]
currentY = self.yPos * (1 - fallProgress)
skull['channel'] = play_random_falling(
skull["channel"] = play_random_falling(
self.sounds,
'falling_skull',
"falling_skull",
player.xPos,
skull['x'],
skull["x"],
self.yPos,
currentY,
existingChannel=skull['channel']
existingChannel=skull["channel"],
)
# Check if we should spawn a new skull
if (len(self.activeSkulls) < self.maxSkulls and
currentTime - self.lastSkullTime >= self.nextSkullDelay):
if len(self.activeSkulls) < self.maxSkulls and currentTime - self.lastSkullTime >= self.nextSkullDelay:
self.spawn_skull(currentTime)
def spawn_skull(self, currentTime):
@@ -91,11 +86,11 @@ class SkullStorm(Object):
# Create new skull
skull = {
'x': random.uniform(self.xRange[0], self.xRange[1]),
'start_time': currentTime,
'fall_duration': fallDuration,
'land_time': currentTime + fallDuration,
'channel': None
"x": random.uniform(self.xRange[0], self.xRange[1]),
"start_time": currentTime,
"fall_duration": fallDuration,
"land_time": currentTime + fallDuration,
"channel": None,
}
self.activeSkulls.append(skull)
@@ -103,18 +98,18 @@ class SkullStorm(Object):
def handle_landing(self, skull, player):
"""Handle a skull landing."""
# Stop falling sound
if skull['channel']:
obj_stop(skull['channel'])
if skull["channel"]:
obj_stop(skull["channel"])
# Play landing sound with positional audio once
channel = pygame.mixer.find_channel(True) # Find an available channel
if channel:
soundObj = self.sounds['skull_lands']
obj_play(self.sounds, 'skull_lands', player.xPos, skull['x'], loop=False)
soundObj = self.sounds["skull_lands"]
obj_play(self.sounds, "skull_lands", player.xPos, skull["x"], loop=False)
# Check if player was hit
if abs(player.xPos - skull['x']) < 1: # Within 1 tile
if abs(player.xPos - skull["x"]) < 1: # Within 1 tile
if not player.isInvincible:
player.set_health(player.get_health() - self.damage)
self.sounds['player_takes_damage'].play()
self.sounds["player_takes_damage"].play()
speak("Hit by falling skull!")
+3 -8
View File
@@ -1,20 +1,15 @@
# -*- coding: utf-8 -*-
class StatTracker:
def __init__(self):
# Base dictionary for tracking stats
self.total = {
'Bone dust': 0,
'Enemies killed': 0,
'Coffins broken': 0,
'Items collected': 0,
'Total time': 0
}
self.total = {"Bone dust": 0, "Enemies killed": 0, "Coffins broken": 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
self.total["levelsCompleted"] = 0
def reset_level(self):
"""Reset level stats based on variable type"""
+245
View File
@@ -0,0 +1,245 @@
# -*- 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 (remove duplicates)
if "ambience" in data and data["ambience"] not in self.ambientSounds:
self.ambientSounds.append(data["ambience"])
if "footstep_sound" in data and data["footstep_sound"] not in self.footstepSounds:
self.footstepSounds.append(data["footstep_sound"])
# Parse objects
for obj in data.get("objects", []):
objCopy = copy.deepcopy(obj)
# Add source level information to track difficulty progression
objCopy["source_level"] = levelNum
# 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},
"locked": True, # Enable lock system for survival mode
"ambience": "Escaping the Grave.ogg", # Will be overridden below
"footstep_sound": "footstep_stone", # Will be overridden below
}
# Choose random music and footstep from collected unique tracks
if self.ambientSounds:
levelData["ambience"] = random.choice(self.ambientSounds)
if self.footstepSounds:
levelData["footstep_sound"] = random.choice(self.footstepSounds)
# 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 with buffer zones
startBufferZone = 25 # Safe zone at start of each wave
endBufferZone = 30 # Safe zone at end of each wave
currentX = startBufferZone # Start placing objects after start buffer zone
while currentX < segmentLength - endBufferZone:
# 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)
# Add end-of-level marker at the end, within the end buffer zone
endMarker = {
"x": segmentLength - (endBufferZone // 2), # Place marker in middle of end buffer
"y": 0,
"sound": "end_of_level",
}
levelData["objects"].append(endMarker)
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."""
# Level-based filtering: allow enemies from levels 1 through current wave
maxLevel = max(self.levelData.keys()) if self.levelData else 1
allowedLevels = list(range(1, min(difficultyLevel, maxLevel) + 1))
# If we're past the max level, allow all enemies
if difficultyLevel > maxLevel:
availableEnemies = self.enemyTemplates
else:
availableEnemies = [e for e in self.enemyTemplates if e.get("source_level", 1) in allowedLevels]
# Fallback to all enemies if filtering removed everything
if not availableEnemies:
availableEnemies = self.enemyTemplates
template = random.choice(availableEnemies)
obj = copy.deepcopy(template)
# Dynamic health scaling: random between wave/2 and wave
minHealth = max(1, difficultyLevel // 2)
maxHealth = max(1, difficultyLevel)
obj["health"] = random.randint(minHealth, maxHealth)
# Damage scaling (keep existing logic)
damageMultiplier = 1 + (difficultyLevel * 0.1)
obj["damage"] = max(1, int(obj.get("damage", 1) * damageMultiplier))
# Set all enemies to hunter mode for survival
obj["behavior"] = "hunter"
# Progressive turn rate reduction: start at 6, decrease to 1
obj["turn_rate"] = max(1, 7 - difficultyLevel)
# 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 coffins - make all items random in survival mode
if obj.get("type") == "coffin":
obj["item"] = "random" # Override any specified item
# 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
+19 -3
View File
@@ -1,7 +1,19 @@
# -*- coding: utf-8 -*-
class Weapon:
def __init__(self, name, damage, range, attackSound, hitSound, cooldown=500, attackDuration=200):
def __init__(
self,
name,
damage,
range,
attackSound,
hitSound,
cooldown=500,
attackDuration=200,
speedBonus=1.0,
jumpDurationBonus=1.0,
):
self.name = name
self.damage = damage
self.range = range # Range in tiles
@@ -9,6 +21,8 @@ class Weapon:
self.hitSound = hitSound
self.cooldown = cooldown # Milliseconds between attacks
self.attackDuration = attackDuration # Milliseconds the attack is active
self.speedBonus = speedBonus # Speed multiplier when wielding this weapon
self.jumpDurationBonus = jumpDurationBonus # Jump duration multiplier when wielding this weapon
self.lastAttackTime = 0
self.hitEnemies = set()
@@ -22,7 +36,7 @@ class Weapon:
attackSound="player_nunchuck_attack",
hitSound="player_nunchuck_hit",
cooldown=250,
attackDuration=100
attackDuration=100,
)
@classmethod
@@ -35,7 +49,9 @@ class Weapon:
attackSound="player_broom_attack",
hitSound="player_broom_hit",
cooldown=500,
attackDuration=200
attackDuration=200,
speedBonus=1.17, # 17% speed bonus when wielding the broom
jumpDurationBonus=1.25, # 25% longer jump duration for better traversal
)
def can_attack(self, currentTime):
+294 -65
View File
@@ -5,25 +5,48 @@ 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
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.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 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 load_level(self, levelNumber):
"""Load a level from its JSON file."""
@@ -36,7 +59,7 @@ class WickedQuest:
if self.player is None:
self.player = Player(levelData["player_start"]["x"],
levelData["player_start"]["y"],
self.sounds)
self.get_sounds())
else:
# Reset player for new level.
self.player.isDucking = False
@@ -56,7 +79,7 @@ class WickedQuest:
# Pass existing player to new level
pygame.event.clear()
self.currentLevel = Level(levelData, self.sounds, self.player)
self.currentLevel = Level(levelData, self.get_sounds(), self.player, self.currentGame)
return True
except FileNotFoundError:
@@ -87,7 +110,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,48 +124,24 @@ class WickedQuest:
options.append("Cancel")
# Show menu
currentIndex = 0
lastSpoken = -1
# Use instruction_menu for consistent behavior
choice = instruction_menu(self.get_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"""
# 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
@@ -157,8 +156,8 @@ class WickedQuest:
if success:
try:
if 'save' in self.sounds:
play_sound(self.sounds['save'])
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:
@@ -179,10 +178,17 @@ class WickedQuest:
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()
# 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])
@@ -209,13 +215,17 @@ class WickedQuest:
if movementDistance > 0 and not player.isJumping:
player.distanceSinceLastStep += movementDistance
if player.should_play_footstep(currentTime):
play_sound(self.sounds[player.footstepSound])
play_sound(self.get_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")
# 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]:
@@ -232,20 +242,44 @@ class WickedQuest:
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])
play_sound(self.get_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'])
play_sound(self.get_sounds()['jump'])
# Check if jump should end
if player.isJumping and currentTime - player.jumpStartTime >= player.jumpDuration:
# 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
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
@@ -272,7 +306,7 @@ class WickedQuest:
try:
speak(f"Level {self.currentLevel.levelId}, {self.currentLevel.levelName}, complete!")
pygame.mixer.music.stop()
cut_scene(self.sounds, '_finish_level')
cut_scene(self.get_sounds(), '_finish_level')
except:
pass
@@ -298,7 +332,31 @@ class WickedQuest:
pygame.event.clear()
self.player.scoreboard.add_high_score()
cut_scene(self.sounds, "game_over")
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):
@@ -389,8 +447,8 @@ class WickedQuest:
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')
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!")
@@ -414,7 +472,7 @@ class WickedQuest:
if self.saveManager.has_saves():
custom_options.append("load_game")
choice = game_menu(self.sounds, None, *custom_options)
choice = game_menu(self.get_sounds(), None, *custom_options)
if choice == "exit":
exit_game()
@@ -427,6 +485,8 @@ class WickedQuest:
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):
@@ -438,7 +498,11 @@ class WickedQuest:
else:
messagebox(f"Failed to load save: {save_data}")
elif choice == "play":
self.currentGame = select_game(self.sounds)
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:
@@ -448,10 +512,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.get_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()
@@ -463,9 +532,169 @@ class WickedQuest:
pygame.event.clear()
display_text(lines)
elif choice == "learn_sounds":
choice = learn_sounds(self.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()
# 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):
"""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()
+14 -1
View File
@@ -1,11 +1,24 @@
# -*- mode: python ; coding: utf-8 -*-
import os
# Collect level directories dynamically
level_dirs = []
levels_path = 'levels'
if os.path.exists(levels_path):
for item in os.listdir(levels_path):
item_path = os.path.join(levels_path, item)
if os.path.isdir(item_path):
level_dirs.append((item_path, item_path))
a = Analysis(
['wicked_quest.py'],
pathex=[],
binaries=[],
datas=[],
datas=level_dirs + [
('sounds', 'sounds'),
('libstormgames', 'libstormgames'),
],
hiddenimports=[],
hookspath=[],
hooksconfig={},