Compare commits

...

11 Commits

Author SHA1 Message Date
Storm Dragon 04b9ac5a51 Fixed a bug where random items found in coffins didn't correctly trigger events. 2025-10-12 01:03:34 -04:00
Storm Dragon 275ead1e80 Bug fixes from the massive code cleanup. 2025-10-04 02:17:54 -04:00
Storm Dragon dd67eb5e1d Fix up some inconsistancies in the code levels should only be needed in one directory now, so update the hook to move levels into place on first run if needed. Warning, this version breaks existing save files. 2025-10-03 01:46:53 -04:00
Storm Dragon 95fc94a507 Improvements and lots of bug fixes to new weapon systems. 2025-09-30 16:12:42 -04:00
Storm Dragon 6d49ea25c3 Simplified the spec file and it will hopefully include all files in levels, not just folders. 2025-09-27 21:36:47 -04:00
Storm Dragon 870ef051ba Alternative key ' for console. 2025-09-27 16:25:34 -04:00
Storm Dragon fbbb970303 Fixed some console command bugs. 2025-09-27 02:20:31 -04:00
Storm Dragon a16b6f0b1f Well, unifying the way levels are called didn't seem to fix the requirement, so now using copy for levels in the hook file. 2025-09-26 17:49:38 -04:00
Storm Dragon 4681dc5a2e Found some differences in how levels are called that required the levels directory be in both _internal and in source directory. This fix hopefully makes them work from a single location. 2025-09-26 17:41:32 -04:00
Storm Dragon 28856d2662 Hopefully improve packaging. This is experimental and may have to be removed. 2025-09-26 17:05:20 -04:00
Storm Dragon 525c9d7563 Hopefully improve packaging. This is experimental and may have to be removed. 2025-09-26 17:04:13 -04:00
18 changed files with 1192 additions and 438 deletions
+3
View File
@@ -6,6 +6,9 @@ https://git.stormux.org/storm/wicked-quest
Angus Kola: Beta testing.
https://angus.kola.casa
Chris Wright: Beta testing.
https://www.youtube.com/channel/UCDWOwfwJ18lQiXRubVBEFkw
Deedra Waters: Beta testing.
Ember Wolfe: Sound effects, beta testing, and visual verification of messages.
+163
View File
@@ -819,5 +819,168 @@ This example demonstrates:
- **Custom audio** (themed ambience and footsteps)
- **Strategic design** (safe zones, risk/reward placement)
## Custom Weapons
Level packs can define custom weapons that players can craft and use alongside the standard weapons (1=shovel, 2=broom, 3=nunchucks). Custom weapons are bound to keys 4-0, giving you 7 additional weapon slots.
### Basic Structure
Add a `custom_weapons` array to your level JSON (typically level 1.json):
```json
{
"custom_weapons": [
{
"key": 4,
"name": "weapon_internal_name",
"display_name": "Player Visible Name",
"damage": 6,
"range": 3,
"attack_sound": "sound_name",
"hit_sound": "sound_name"
}
]
}
```
### Required Fields
- **`key`** - Key binding (4-9 or 0 for key 10)
- **`name`** - Internal weapon name (for game logic)
- **`damage`** - Weapon damage (integer)
- **`range`** - Attack range in tiles (integer)
- **`attack_sound`** - Sound when attacking
- **`hit_sound`** - Sound when hitting enemies
### Optional Fields
- **`display_name`** - Name shown to player (defaults to `name`)
- **`weapon_type`** - "melee" or "projectile" (defaults to "melee")
- **`cooldown`** - Milliseconds between attacks (defaults to 500)
- **`attack_duration`** - Milliseconds attack is active (defaults to 200)
- **`speed_bonus`** - Movement speed multiplier (defaults to 1.0)
- **`jump_bonus`** - Jump duration multiplier (defaults to 1.0)
- **`requires`** - Crafting requirements (defaults to none)
- **`craft_sound`** - Sound when weapon is crafted
- **`ammo_type`** - Ammo type for projectile weapons
- **`ammo_cost`** - Ammo consumed per shot (defaults to 1)
- **`projectile_speed`** - Speed of projectiles (defaults to 0.2)
### Melee Weapon Example
```json
{
"custom_weapons": [
{
"key": 4,
"name": "bone_hatchet",
"display_name": "blood-stained hatchet",
"damage": 6,
"range": 2,
"attack_sound": "player_hatchet_chop",
"hit_sound": "player_hatchet_hit",
"cooldown": 400,
"speed_bonus": 1.1,
"requires": {
"shin_bone": 3,
"hand_of_glory": 1
},
"craft_sound": "get_hatchet"
}
]
}
```
### Projectile Weapon Example
```json
{
"custom_weapons": [
{
"key": 6,
"name": "bone_crossbow",
"display_name": "cursed crossbow",
"weapon_type": "projectile",
"damage": 12,
"range": 8,
"attack_sound": "crossbow_fire",
"hit_sound": "crossbow_impact",
"cooldown": 1500,
"attack_duration": 100,
"ammo_type": "shin_bone",
"ammo_cost": 3,
"projectile_speed": 0.3,
"requires": {
"shin_bone": 5,
"guts": 2
},
"craft_sound": "get_crossbow"
}
]
}
```
### Crafting System
Weapons with `requires` are automatically crafted when the player has the necessary materials:
**Supported Ammo/Material Types:**
- `shin_bone` - Shin bones collected
- `bone_dust` - Bone dust for extra lives
- `guts` - Health/crafting items
- `hand_of_glory` - Collectible items
- `jack_o_lantern` - Throwable projectiles
- Any other item type in `collectedItems`
**Crafting Behavior:**
- Materials are NOT consumed (items keep their original functions)
- Weapons auto-craft when requirements are met
- Each weapon can only be crafted once per playthrough
- Crafting plays the `craft_sound` if specified
### Projectile Weapons
Projectile weapons (`weapon_type: "projectile"`) fire projectiles instead of melee attacks:
- **Ammo Consumption** - Each shot consumes `ammo_cost` of `ammo_type`
- **Out of Ammo** - Shows themed message: "Not enough candy canes"
- **Inventory Display** - Press 'c' to see current ammo counts
- **High Damage** - Usually more powerful than melee weapons
- **Strategic Cost** - Must balance ammo usage vs. other needs
### Weapon Override Integration
Custom weapons work with the weapon override system. You can theme custom weapons just like standard ones:
```json
{
"weapon_sound_overrides": {
"bone_hatchet": {
"name": "ice hatchet",
"attack_sound": "player_ice_hatchet_chop",
"hit_sound": "player_ice_hatchet_hit"
}
}
}
```
### Key Binding Reference
- **1-3**: Reserved for standard weapons (shovel, broom, nunchucks)
- **4-9**: Custom weapon slots (6 weapons)
- **0**: Custom weapon slot (key 10, for 7th weapon)
### Design Tips
**Balanced Progression:**
- Early weapons: Low requirements, moderate power
- Mid weapons: Medium requirements, good utility
- Late weapons: High requirements, devastating power
**Ammo Economics:**
- Cheap ammo: bone_dust (common)
- Expensive ammo: shin_bone (valuable for lives)
- Special ammo: guts, hand_of_glory (limited)
Check out the existing Wicked Quest levels for more examples and inspiration!
+59
View File
@@ -0,0 +1,59 @@
import os
import sys
import shutil
# Runtime hook to move directories and files from _internal to parent directory
if hasattr(sys, '_MEIPASS'):
# We're running from a PyInstaller bundle
bundleDir = os.path.dirname(sys.executable)
internalDir = sys._MEIPASS
# Directories to move from _internal to parent
# All these use the parent directory for easier user customization
dirsToMove = ['sounds', 'libstormgames', 'levels']
# Directories to copy (keep in both locations)
# None needed - everything now uses parent directory
dirsToCopy = []
# Files to move from _internal to parent
filesToMove = ['files', 'logo.png']
# Move directories
for dir_name in dirsToMove:
internalPath = os.path.join(internalDir, dir_name)
targetPath = os.path.join(bundleDir, dir_name)
# Only move if source exists and target doesn't exist
if os.path.exists(internalPath) and not os.path.exists(targetPath):
try:
shutil.move(internalPath, targetPath)
except Exception as e:
# Silently fail if we can't move - game will still work from _internal
pass
# Copy directories (keep in both locations)
for dir_name in dirsToCopy:
internalPath = os.path.join(internalDir, dir_name)
targetPath = os.path.join(bundleDir, dir_name)
# Only copy if source exists and target doesn't exist
if os.path.exists(internalPath) and not os.path.exists(targetPath):
try:
shutil.copytree(internalPath, targetPath)
except Exception as e:
# Silently fail if we can't copy - game will still work from _internal
pass
# Move files
for file_name in filesToMove:
internalPath = os.path.join(internalDir, file_name)
targetPath = os.path.join(bundleDir, file_name)
# Only move if source exists and target doesn't exist
if os.path.exists(internalPath) and not os.path.exists(targetPath):
try:
shutil.move(internalPath, targetPath)
except Exception as e:
# Silently fail if we can't move - game will still work from _internal at least enough to exit.
pass
BIN
View File
Binary file not shown.
+18 -17
View File
@@ -18,8 +18,8 @@ class CoffinObject(Object):
self.sounds = sounds
self.level = level
self.isBroken = False
self.dropped_item = None
self.specified_item = item
self.droppedItem = None
self.specifiedItem = item
def hit(self, player_pos):
"""Handle being hit by the player's weapon"""
@@ -38,32 +38,33 @@ class CoffinObject(Object):
self.isActive = False
# Determine item to drop
if self.specified_item == "random":
if self.specifiedItem == "random":
# Check if we're in survival mode (level_id 999)
is_survival_mode = hasattr(self.level, 'levelData') and self.level.levelData.get('level_id') == 999
item_type = ItemProperties.get_random_item(survival_mode=is_survival_mode)
isSurvivalMode = hasattr(self.level, 'levelData') and self.level.levelData.get('level_id') == 999
itemType = ItemProperties.get_random_item(survivalMode=isSurvivalMode)
else:
# Validate specified item
if ItemProperties.is_valid_item(self.specified_item):
item_type = self.specified_item
if ItemProperties.is_valid_item(self.specifiedItem):
itemType = self.specifiedItem
else:
# Fall back to random if invalid item specified
# Check if we're in survival mode (level_id 999)
is_survival_mode = hasattr(self.level, 'levelData') and self.level.levelData.get('level_id') == 999
item_type = ItemProperties.get_random_item(survival_mode=is_survival_mode)
isSurvivalMode = hasattr(self.level, 'levelData') and self.level.levelData.get('level_id') == 999
itemType = ItemProperties.get_random_item(survivalMode=isSurvivalMode)
# Create item 1-2 tiles away in random direction
direction = random.choice([-1, 1])
drop_distance = random.randint(1, 2)
drop_x = self.xPos + (direction * drop_distance)
dropDistance = random.randint(1, 2)
dropX = self.xPos + (direction * dropDistance)
self.dropped_item = PowerUp(
drop_x, self.yPos, item_type, self.sounds, direction, self.level.leftBoundary, self.level.rightBoundary
)
# Apply sound override after creation (similar to how graves handle it)
# Use themed item name if override exists (for proper themed item handling)
actualItemType = itemType
if hasattr(self, 'itemSoundOverride'):
self.dropped_item.soundName = self.itemSoundOverride
actualItemType = self.itemSoundOverride
self.droppedItem = PowerUp(
dropX, self.yPos, actualItemType, self.sounds, direction, self.level.leftBoundary, self.level.rightBoundary
)
return True
return False
+16 -16
View File
@@ -19,7 +19,7 @@ class Enemy(Object):
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.attackRange = kwargs.get("attackRange", 1) # Default 1 tile range
self.sounds = sounds # Store reference to game sounds
# Movement and behavior properties
@@ -32,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("canSpawn", 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("spawnCooldown", 2000)
self.spawnChance = kwargs.get("spawnChance", 25)
self.spawnType = kwargs.get("spawnType", "zombie") # Default to zombie for backward compatibility
self.spawnDistance = kwargs.get("spawnDistance", 5)
self.lastSpawnTime = 0
# Attack pattern configuration
self.attackPattern = kwargs.get("attack_pattern", {"type": "patrol"})
self.attackPattern = kwargs.get("attackPattern", {"type": "patrol"})
self.turnThreshold = self.attackPattern.get("turn_threshold", 5)
# Initialize vulnerability system
self.hasVulnerabilitySystem = kwargs.get("has_vulnerability", False)
self.hasVulnerabilitySystem = kwargs.get("hasVulnerability", 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("vulnerabilityDuration", 2000)
self.invulnerabilityDuration = kwargs.get("invulnerabilityDuration", 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:
@@ -63,7 +63,7 @@ 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("speedMultiplier", 2.0)
self.movementSpeed *= speedMultiplier # Spiders are faster
self.attackPattern = {"type": "hunter"} # Spiders actively hunt the player
self.turnThreshold = 3 # Spiders turn around quickly to chase player
@@ -162,7 +162,7 @@ class Enemy(Object):
# 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
turnRate = 2 if self.level.levelId == 999 else 8 # Faster turn rate for survival
# Create new enemy of specified type
spawned = Enemy(
@@ -173,9 +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_pattern={"type": behavior},
turn_rate=turn_rate,
attackRange=1, # Default range for spawned enemies
attackPattern={"type": behavior},
turnRate=turnRate,
)
# Add to level's enemies
@@ -273,7 +273,7 @@ class Enemy(Object):
droppedItem = PowerUp(
dropX, self.yPos, itemType, self.sounds, direction, self.level.leftBoundary, self.level.rightBoundary
)
self.level.bouncing_items.append(droppedItem)
self.level.bouncingItems.append(droppedItem)
# Update stats
self.level.player.stats.update_stat("Enemies killed", 1)
+44 -49
View File
@@ -7,6 +7,22 @@ from os.path import isdir, join
from libstormgames import speak, instruction_menu
def get_levels_base_path():
"""Get base path for levels directory, handling PyInstaller paths.
Returns:
str: Base path where levels directory is located
"""
if hasattr(sys, "_MEIPASS"):
# Running as PyInstaller executable
# Use parent directory (where executable is) instead of _internal
# This allows users to add new level packs without recompiling
return os.path.dirname(sys.executable)
else:
# Running as script
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def get_available_games():
"""Get list of available game directories in levels folder.
@@ -14,16 +30,9 @@ def get_available_games():
list: List of game directory names
"""
try:
# 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")]
basePath = get_levels_base_path()
levelsPath = os.path.join(basePath, "levels")
return [d for d in os.listdir(levelsPath) if isdir(join(levelsPath, d)) and not d.endswith(".md")]
except FileNotFoundError:
return []
@@ -50,71 +59,57 @@ def select_game(sounds):
Returns:
str: Selected game directory name or None if cancelled
"""
available_games = get_available_games()
availableGames = get_available_games()
if not available_games:
if not availableGames:
speak("No games found in levels directory!")
return None
# Convert directory names to display names (replace underscores with spaces)
menu_options = [game.replace("_", " ") for game in available_games]
menuOptions = [game.replace("_", " ") for game in availableGames]
choice = selection_menu(sounds, *menu_options)
choice = selection_menu(sounds, *menuOptions)
if choice is None:
return None
# Convert display name back to directory name if needed
game_dir = choice.replace(" ", "_")
if game_dir not in available_games:
game_dir = choice # Use original if conversion doesn't match
gameDir = choice.replace(" ", "_")
if gameDir not in availableGames:
gameDir = choice # Use original if conversion doesn't match
return game_dir
return gameDir
def get_level_path(game_dir, level_num):
def get_level_path(gameDir, levelNum):
"""Get full path to level JSON file.
Args:
game_dir (str): Game directory name
level_num (int): Level number
gameDir (str): Game directory name
levelNum (int): Level number
Returns:
str: Full path to level JSON file
"""
if game_dir is None:
raise ValueError("game_dir cannot be None")
if gameDir is None:
raise ValueError("gameDir 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
basePath = get_levels_base_path()
levelPath = os.path.join(basePath, "levels", gameDir, f"{levelNum}.json")
return levelPath
def get_game_dir_path(game_dir):
def get_game_dir_path(gameDir):
"""Get full path to game directory for end.ogg and other game files.
Args:
game_dir (str): Game directory name
gameDir (str): Game directory name
Returns:
str: Full path to game directory
"""
if game_dir is None:
raise ValueError("game_dir cannot be None")
# Handle PyInstaller path issues - same logic as get_level_path
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__)))
return os.path.join(base_path, "levels", game_dir)
if gameDir is None:
raise ValueError("gameDir cannot be None")
basePath = get_levels_base_path()
return os.path.join(basePath, "levels", gameDir)
+3 -3
View File
@@ -8,7 +8,7 @@ from src.object import Object
class GraspingHands(Object):
"""A hazard where the ground crumbles beneath the player as undead hands reach up."""
def __init__(self, xRange, y, sounds, delay=1000, crumble_speed=0.065):
def __init__(self, xRange, y, sounds, delay=1000, crumbleSpeed=0.065):
super().__init__(
xRange,
y,
@@ -19,7 +19,7 @@ class GraspingHands(Object):
)
self.sounds = sounds
self.delay = delay # Delay in milliseconds before ground starts crumbling
self.crumble_speed = crumble_speed # How fast the crumbling catches up (tiles per frame)
self.crumbleSpeed = crumbleSpeed # How fast the crumbling catches up (tiles per frame)
# Sound prefix for different sound effects (can be overridden)
self.soundPrefix = "grasping_hands"
@@ -95,7 +95,7 @@ class GraspingHands(Object):
# If triggered and delay has passed, start crumbling
if self.isTriggered and currentTime - self.triggerTime >= self.delay:
# Update crumble position based on direction
self.crumblePosition += self.crumble_speed * self.crumbleDirection
self.crumblePosition += self.crumbleSpeed * self.crumbleDirection
# Manage the looping positional audio for the crumbling ground
if self.crumbleChannel is None or not self.crumbleChannel.get_busy():
+13 -13
View File
@@ -45,28 +45,28 @@ class ItemProperties:
}
@staticmethod
def get_sound_name(item_type):
def get_sound_name(itemType):
"""Convert enum to sound/asset name"""
return ItemProperties.ALL_ITEMS.get(item_type)
return ItemProperties.ALL_ITEMS.get(itemType)
@staticmethod
def get_random_item(survival_mode=False):
def get_random_item(survivalMode=False):
"""Get a random item from eligible items"""
if survival_mode:
item_type = random.choice(list(ItemProperties.SURVIVAL_MODE_ELIGIBLE.keys()))
if survivalMode:
itemType = random.choice(list(ItemProperties.SURVIVAL_MODE_ELIGIBLE.keys()))
else:
item_type = random.choice(list(ItemProperties.STORY_MODE_ELIGIBLE.keys()))
return ItemProperties.get_sound_name(item_type)
itemType = random.choice(list(ItemProperties.STORY_MODE_ELIGIBLE.keys()))
return ItemProperties.get_sound_name(itemType)
@staticmethod
def is_valid_item(item_name):
def is_valid_item(itemName):
"""Check if an item name is valid"""
return item_name in [v for v in ItemProperties.ALL_ITEMS.values()]
return itemName in [v for v in ItemProperties.ALL_ITEMS.values()]
@staticmethod
def get_item_type(item_name):
def get_item_type(itemName):
"""Get ItemType enum from string name"""
for item_type, name in ItemProperties.ALL_ITEMS.items():
if name == item_name:
return item_type
for itemType, name in ItemProperties.ALL_ITEMS.items():
if name == itemName:
return itemType
return None
+171 -73
View File
@@ -22,12 +22,12 @@ class Level:
self.levelPackName = levelPackName
self.objects = []
self.enemies = []
self.bouncing_items = []
self.bouncingItems = []
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.weapon_hit_channel = None
self.weaponHitChannel = None
self.leftBoundary = levelData["boundaries"]["left"]
self.rightBoundary = levelData["boundaries"]["right"]
@@ -49,6 +49,11 @@ class Level:
# Give player access to overrides for newly added weapons
self.player.set_weapon_overrides(self.weaponOverrides)
# Load custom weapons if specified
self.customWeapons = levelData.get("custom_weapons", [])
if self.customWeapons:
self._load_custom_weapons(self.customWeapons)
# Level intro message (skip for survival mode)
if levelData["level_id"] != 999: # 999 is survival mode
# Check if level uses new dialog format or old description format
@@ -132,7 +137,7 @@ class Level:
obj["y"],
self.sounds,
delay=obj.get("delay", 1000),
crumble_speed=obj.get("crumble_speed", 0.03),
crumbleSpeed=obj.get("crumble_speed", 0.03),
)
# Apply sound overrides if specified
if "sound_overrides" in obj:
@@ -141,25 +146,25 @@ class Level:
# Check if this is a grave
elif obj.get("type") == "grave":
# Handle item spawning with chance (for survival mode)
grave_item = None
graveItem = None
if "item_spawn_chance" in obj:
# Survival mode: spawn item based on chance (random items already resolved)
spawn_chance = obj.get("item_spawn_chance", 0)
if random.randint(1, 100) <= spawn_chance:
grave_item = obj.get("item")
spawnChance = obj.get("item_spawn_chance", 0)
if random.randint(1, 100) <= spawnChance:
graveItem = obj.get("item")
else:
# Story mode: use item as specified
grave_item = obj.get("item", None)
if grave_item == "random":
graveItem = obj.get("item", None)
if graveItem == "random":
# Check if we're in survival mode (level_id 999)
is_survival_mode = self.levelData.get('level_id') == 999
grave_item = ItemProperties.get_random_item(survival_mode=is_survival_mode)
isSurvivalMode = self.levelData.get('level_id') == 999
graveItem = ItemProperties.get_random_item(survivalMode=isSurvivalMode)
grave = GraveObject(
xPos[0],
obj["y"],
self.sounds,
item=grave_item,
item=graveItem,
zombieSpawnChance=obj.get("zombie_spawn_chance", 0),
)
# Apply sound overrides if specified
@@ -227,18 +232,18 @@ class Level:
self, # Pass level reference
health=obj.get("health", 5),
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"}),
can_spawn=obj.get("can_spawn", False),
spawn_type=obj.get("spawn_type", "zombie"),
spawn_cooldown=obj.get("spawn_cooldown", 2000),
spawn_chance=obj.get("spawn_chance", 25),
spawn_distance=obj.get("spawn_distance", 5),
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),
attackRange=obj.get("attack_range", 1),
movementRange=obj.get("movement_range", 5),
attackPattern=obj.get("attack_pattern", {"type": "patrol"}),
canSpawn=obj.get("can_spawn", False),
spawnType=obj.get("spawn_type", "zombie"),
spawnCooldown=obj.get("spawn_cooldown", 2000),
spawnChance=obj.get("spawn_chance", 25),
spawnDistance=obj.get("spawn_distance", 5),
hasVulnerability=obj.get("has_vulnerability", False),
isVulnerable=obj.get("is_vulnerable", False),
vulnerabilityDuration=obj.get("vulnerability_duration", 1000),
invulnerabilityDuration=obj.get("invulnerability_duration", 5000),
)
self.enemies.append(enemy)
else:
@@ -281,7 +286,7 @@ class Level:
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
turnRate = 2 if self.levelId == 999 else 8 # Faster turn rate for survival
zombie = Enemy(
[obj.xPos, obj.xPos],
@@ -291,9 +296,9 @@ class Level:
self, # Pass the level reference
health=3,
damage=10,
attack_range=1,
attack_pattern={"type": behavior},
turn_rate=turn_rate,
attackRange=1,
attackPattern={"type": behavior},
turnRate=turnRate,
)
self.enemies.append(zombie)
speak("A zombie emerges from the grave!")
@@ -349,9 +354,9 @@ class Level:
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
for item in self.bouncingItems[:]: # Copy list to allow removal
if not item.update(currentTime, self.player.xPos):
self.bouncing_items.remove(item)
self.bouncingItems.remove(item)
if not item.isActive:
speak(f"{item.soundName} got away!")
continue
@@ -362,7 +367,7 @@ class Level:
item.apply_effect(self.player, self)
self.levelScore += 1000 # All items collected points awarded
item.isActive = False
self.bouncing_items.remove(item)
self.bouncingItems.remove(item)
def handle_combat(self, currentTime):
"""Handle combat interactions between player and enemies"""
@@ -389,7 +394,7 @@ class Level:
): # Must be jumping to hit floating coffins
if obj.hit(self.player.xPos):
self.bouncing_items.append(obj.dropped_item)
self.bouncingItems.append(obj.droppedItem)
def spawn_spider(self, xPos, yPos):
"""Spawn a spider at the given position"""
@@ -401,8 +406,8 @@ class Level:
self,
health=8,
damage=8,
attack_range=1,
speed_multiplier=2.0,
attackRange=1,
speedMultiplier=2.0,
)
self.enemies.append(spider)
@@ -412,20 +417,20 @@ class Level:
if enemyStats:
health = enemyStats.get("health", 8)
damage = enemyStats.get("damage", 2)
speed_multiplier = enemyStats.get("speed_multiplier", 1.0)
attack_range = enemyStats.get("attack_range", 1)
speedMultiplier = enemyStats.get("speed_multiplier", 1.0)
attackRange = enemyStats.get("attack_range", 1)
else:
# Default stats for common enemy types (backward compatibility)
if enemyType == "spider":
health, damage, speed_multiplier = 8, 8, 2.0
health, damage, speedMultiplier = 8, 8, 2.0
elif enemyType == "elf":
health, damage, speed_multiplier = 2, 1, 1.0
health, damage, speedMultiplier = 2, 1, 1.0
elif enemyType == "witch":
health, damage, speed_multiplier = 6, 2, 1.0
health, damage, speedMultiplier = 6, 2, 1.0
else:
# For any other enemy type, use reasonable defaults
health, damage, speed_multiplier = 6, 2, 1.0
attack_range = 1
health, damage, speedMultiplier = 6, 2, 1.0
attackRange = 1
enemy = Enemy(
[xPos - 5, xPos + 5], # Give enemy a patrol range
@@ -435,9 +440,9 @@ class Level:
self,
health=health,
damage=damage,
attack_range=attack_range,
speed_multiplier=speed_multiplier,
attack_pattern={"type": "hunter"}, # Hunt like original spiders
attackRange=attackRange,
speedMultiplier=speedMultiplier,
attackPattern={"type": "hunter"}, # Hunt like original spiders
)
self.enemies.append(enemy)
@@ -495,22 +500,22 @@ class Level:
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()))
if self.player._coins % 100 == 0:
# 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
if self.player._coins >= 100:
# Only give extra lives in story mode, not survival mode (level_id 999)
if self.levelId != 999:
# Extra life
self.player._coins -= 100
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 - collision depends on Y position
@@ -545,21 +550,21 @@ 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) and not self.player.diedThisFrame
can_fill = obj.can_fill_grave(self.player)
canCollect = obj.collect_grave_item(self.player) and not self.player.diedThisFrame
canFill = obj.can_fill_grave(self.player)
if can_collect:
if canCollect:
# Successfully collected item while ducking with shovel
# Check for item sound override
item_sound = obj.graveItem
itemSound = obj.graveItem
if hasattr(obj, 'itemSoundOverride'):
item_sound = obj.itemSoundOverride
play_sound(self.sounds[f"get_{item_sound}"])
itemSound = obj.itemSoundOverride
play_sound(self.sounds[f"get_{itemSound}"])
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
# Create PowerUp to handle the item effect (use themed name if override exists)
item = PowerUp(
obj.xPos, obj.yPos, obj.graveItem, self.sounds, 1, self.leftBoundary, self.rightBoundary
obj.xPos, obj.yPos, itemSound, self.sounds, 1, self.leftBoundary, self.rightBoundary
)
item.apply_effect(self.player, self)
# Stop grave's current audio channel
@@ -570,7 +575,7 @@ 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):
elif canFill and obj.fill_grave(self.player):
# Successfully filled empty grave with shovel
play_sound(
self.sounds.get("fill_in_grave", "shovel_dig")
@@ -638,15 +643,40 @@ class Level:
def throw_projectile(self):
"""Have player throw a projectile"""
proj_info = self.player.throw_projectile()
if proj_info is None:
projInfo = self.player.throw_projectile()
if projInfo is None:
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(projInfo["type"], projInfo["start_x"], projInfo["direction"]))
# Play throw sound
play_sound(self.sounds["throw_jack_o_lantern"])
def create_weapon_projectile(self, player):
"""Create a projectile from a projectile weapon attack"""
weapon = player.currentWeapon
if not weapon or not hasattr(weapon, 'weaponType') or weapon.weaponType != "projectile":
return
# Determine projectile direction
direction = 1 if player.facingRight else -1
# Create projectile with weapon-specific properties
# Use a generic projectile type for weapon projectiles
projectileType = f"weapon_{weapon.name}"
startX = player.xPos + (direction * 1) # Start 1 tile away from player
# Create projectile with custom speed and damage from weapon
projectile = Projectile(projectileType, startX, direction)
# Override projectile properties with weapon stats
projectile.damage = weapon.damage
projectile.speed = getattr(weapon, 'projectileSpeed', 0.2)
projectile.weaponProjectile = True # Mark as weapon projectile
projectile.hitSound = weapon.hitSound
self.projectiles.append(projectile)
def _apply_weapon_overrides(self, weaponOverrides):
"""Apply sound and name overrides to player weapons based on level pack theme."""
for weapon in self.player.weapons:
@@ -672,6 +702,74 @@ class Level:
if "hit_sound" in overrides and hasattr(weapon, 'hitSound') and weapon.hitSound != overrides["hit_sound"]:
weapon.hitSound = overrides["hit_sound"]
def _load_custom_weapons(self, customWeapons):
"""Load custom weapons from level data and make them available for crafting."""
from src.weapon import Weapon
# Store custom weapons in player for crafting checks
self.player.set_custom_weapons(customWeapons)
# Apply weapon overrides to custom weapons if they exist
for weaponData in customWeapons:
weaponName = weaponData["name"]
if weaponName in self.weaponOverrides:
overrides = self.weaponOverrides[weaponName]
# Apply overrides to the weapon data before any weapons are created
if "name" in overrides:
weaponData["display_name"] = overrides["name"]
if "attack_sound" in overrides:
weaponData["attack_sound"] = overrides["attack_sound"]
if "hit_sound" in overrides:
weaponData["hit_sound"] = overrides["hit_sound"]
# Check for custom weapons with no requirements and add them immediately
self._check_initial_custom_weapons()
def _check_initial_custom_weapons(self):
"""Check for custom weapons with no requirements and add them immediately at level start."""
if not hasattr(self.player, 'customWeapons') or not self.player.customWeapons:
return
from src.weapon import Weapon
for weaponData in self.player.customWeapons:
weaponName = weaponData["name"]
# Skip if weapon already crafted
if hasattr(self.player, 'craftedCustomWeapons') and weaponName in self.player.craftedCustomWeapons:
continue
# Skip if player already has this weapon
if any(weapon.originalName == weaponName for weapon in self.player.weapons):
continue
# Check if weapon has no requirements or empty requirements
requirements = weaponData.get("requires", {})
if not requirements: # No requirements or empty dict
# Create and add the weapon immediately
customWeapon = Weapon.create_from_json(weaponData)
self.player.add_weapon(customWeapon)
# Mark as crafted to prevent duplicate creation
if not hasattr(self.player, 'craftedCustomWeapons'):
self.player.craftedCustomWeapons = set()
self.player.craftedCustomWeapons.add(weaponName)
# Calculate score bonus
basePoints = customWeapon.damage * 1000
rangeModifier = customWeapon.range * 500
self.player.scoreboard.increase_score(basePoints + rangeModifier)
# Play craft sound if specified
craftSound = weaponData.get("craft_sound")
if craftSound and craftSound in self.sounds:
play_sound(self.sounds[craftSound])
else:
# Fallback to generic weapon craft sound
play_sound(self.sounds.get("get_weapon", "get_nunchucks"))
self.player.stats.update_stat("Items collected", 1)
def _apply_object_sound_overrides(self, obj, soundOverrides):
"""Apply sound overrides to an object based on level pack theme."""
# Override base sound name if specified (originalSoundName remains unchanged for game logic)
@@ -724,7 +822,7 @@ class Level:
obj.itemSoundOverride = soundOverrides["item"]
# Handle item sound overrides for coffins
if hasattr(obj, 'specified_item') and obj.specified_item and obj.specified_item != "random" and "item" in soundOverrides:
if hasattr(obj, 'specifiedItem') and obj.specifiedItem and obj.specifiedItem != "random" and "item" in soundOverrides:
# Store the override for later use when coffin is broken
if not hasattr(obj, 'itemSoundOverride'):
obj.itemSoundOverride = soundOverrides["item"]
+127 -18
View File
@@ -45,7 +45,7 @@ class Player:
self.collectedItems = []
self._coins = 0 # Regular bone dust for extra lives
self._saveBoneDust = 0 # Separate bone dust counter for saves
self._jack_o_lantern_count = 0
self._jackOLanternCount = 0
self.shinBoneCount = 0
# Combat related attributes
@@ -53,6 +53,7 @@ class Player:
self.currentWeapon = None
self.isAttacking = False
self.lastAttackTime = 0
self.isProjectileAttack = False # Flag for projectile weapon attacks
# Power-up states
self.isInvincible = False
@@ -114,7 +115,7 @@ class Player:
# Check invincibility status
if self.isInvincible:
remaining_time = (
remainingTime = (
self.invincibilityStartTime + self.invincibilityDuration - currentTime
) / 1000 # Convert to seconds
@@ -122,10 +123,10 @@ class Player:
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:
currentSecond = int(remainingTime)
if currentSecond < self._last_countdown and currentSecond <= 3 and currentSecond > 0:
play_sound(self.sounds["end_of_invincibility_warning"])
self._last_countdown = current_second
self._last_countdown = currentSecond
# Check if invincibility has expired
if currentTime - self.invincibilityStartTime >= self.invincibilityDuration:
@@ -146,11 +147,11 @@ class Player:
def get_jack_o_lanterns(self):
"""Get number of jack o'lanterns"""
return self._jack_o_lantern_count
return self._jackOLanternCount
def add_jack_o_lantern(self):
"""Add a jack o'lantern"""
self._jack_o_lantern_count += 1
self._jackOLanternCount += 1
def add_guts(self):
"""Apply guts, increase max_health by 2 if less than 20 else restore health"""
@@ -164,7 +165,7 @@ class Player:
if self.get_jack_o_lanterns() <= 0:
return None
self._jack_o_lantern_count -= 1
self._jackOLanternCount -= 1
return {"type": "jack_o_lantern", "start_x": self.xPos, "direction": 1 if self.facingRight else -1}
def get_step_distance(self):
@@ -219,19 +220,19 @@ class Player:
def set_health(self, value):
"""Set health and handle death if needed."""
old_health = self._health
oldHealth = self._health
# Oops, allow healing while invincible.
if self.isInvincible and value < old_health:
if self.isInvincible and value < oldHealth:
return
# Check for god mode (prevents all damage)
if hasattr(self, '_godMode') and self._godMode and value < old_health:
if hasattr(self, '_godMode') and self._godMode and value < oldHealth:
return
self._health = max(0, value) # Health can't go below 0
if self._health == 0 and old_health > 0:
if self._health == 0 and oldHealth > 0:
self._lives -= 1
# Mark that player died this frame to prevent revival
self.diedThisFrame = True
@@ -290,10 +291,29 @@ class Player:
self.currentWeapon = weapon
def switch_to_weapon(self, weaponIndex):
"""Switch to weapon by index (1=shovel, 2=broom, 3=nunchucks)"""
"""Switch to weapon by index (1=shovel, 2=broom, 3=nunchucks, 4-0=custom weapons)"""
# Standard weapons (1-3)
weaponMap = {1: "rusty_shovel", 2: "witch_broom", 3: "nunchucks"}
targetWeaponName = weaponMap.get(weaponIndex)
# For keys 4-0, check for custom weapons
if targetWeaponName is None and 4 <= weaponIndex <= 10:
# Convert 0 key to index 10 for easier handling
keyIndex = 10 if weaponIndex == 0 else weaponIndex
# Look for custom weapon with this key binding
for weapon in self.weapons:
if hasattr(weapon, 'keyBinding') and weapon.keyBinding == keyIndex:
self.equip_weapon(weapon)
# Use display name if available, otherwise format weapon name
displayName = getattr(weapon, 'displayName', weapon.name.replace("_", " "))
speak(displayName)
return True
# No weapon found for this key
speak("Nothing here")
return False
if not targetWeaponName:
return False
@@ -307,12 +327,20 @@ class Player:
return True
# Weapon not found in inventory
speak("Nothing here")
return False
def set_weapon_overrides(self, weaponOverrides):
"""Store weapon overrides for applying to newly added weapons"""
self.weaponOverrides = weaponOverrides
def set_custom_weapons(self, customWeapons):
"""Store custom weapon definitions for crafting checks"""
self.customWeapons = customWeapons
# Initialize set to track which custom weapons have been crafted
if not hasattr(self, 'craftedCustomWeapons'):
self.craftedCustomWeapons = set()
def _apply_weapon_override(self, weapon):
"""Apply weapon overrides to a single weapon"""
if not hasattr(self, 'weaponOverrides') or not self.weaponOverrides:
@@ -340,6 +368,63 @@ class Player:
if "hit_sound" in overrides and hasattr(weapon, 'hitSound') and weapon.hitSound != overrides["hit_sound"]:
weapon.hitSound = overrides["hit_sound"]
def has_ammo(self, ammoType, cost):
"""Check if player has enough ammo for a projectile weapon"""
if ammoType == "shin_bone":
return self.shinBoneCount >= cost
elif ammoType == "bone_dust":
return self._coins >= cost
elif ammoType == "guts":
return self.collectedItems.count("guts") >= cost
elif ammoType == "hand_of_glory":
return self.collectedItems.count("hand_of_glory") >= cost
elif ammoType == "jack_o_lantern":
return self._jackOLanternCount >= cost
else:
# Check for any other item type in collectedItems
return self.collectedItems.count(ammoType) >= cost
def consume_ammo(self, ammoType, cost):
"""Consume ammo for a projectile weapon shot"""
if ammoType == "shin_bone":
self.shinBoneCount = max(0, self.shinBoneCount - cost)
elif ammoType == "bone_dust":
self._coins = max(0, self._coins - cost)
elif ammoType == "guts":
# Remove items from collectedItems list
for _ in range(min(cost, self.collectedItems.count("guts"))):
self.collectedItems.remove("guts")
elif ammoType == "hand_of_glory":
for _ in range(min(cost, self.collectedItems.count("hand_of_glory"))):
self.collectedItems.remove("hand_of_glory")
elif ammoType == "jack_o_lantern":
self._jackOLanternCount = max(0, self._jackOLanternCount - cost)
else:
# Handle any other item type
for _ in range(min(cost, self.collectedItems.count(ammoType))):
self.collectedItems.remove(ammoType)
def get_ammo_display_name(self, ammoType):
"""Get themed display name for ammo type"""
# Check for themed mappings in weapon overrides
if hasattr(self, 'weaponOverrides') and self.weaponOverrides:
# Define themed mappings (this could be extended or made configurable)
themedMappings = {
"shin_bone": "candy_cane", # Christmas theme example
"guts": "reindeer_guts"
}
# Check if there's a themed equivalent and if the override exists
if ammoType in themedMappings:
themedName = themedMappings[ammoType]
# If the themed item exists in overrides, use the themed name
for overrideKey, overrideData in self.weaponOverrides.items():
if isinstance(overrideData, dict) and themedName in str(overrideData):
return themedName.replace("_", " ")
# Return default name
return ammoType.replace("_", " ")
def add_item(self, item):
"""Add an item to inventory"""
self.inventory.append(item)
@@ -347,11 +432,35 @@ class Player:
def start_attack(self, currentTime):
"""Attempt to start an attack with the current weapon"""
if self.currentWeapon and self.currentWeapon.start_attack(currentTime):
self.isAttacking = True
self.lastAttackTime = currentTime
return True
return False
if not self.currentWeapon:
return False
# Check if this is a projectile weapon
if hasattr(self.currentWeapon, 'weaponType') and self.currentWeapon.weaponType == "projectile":
# Check ammo availability
if not self.has_ammo(self.currentWeapon.ammoType, self.currentWeapon.ammoCost):
from libstormgames import speak
ammoName = self.get_ammo_display_name(self.currentWeapon.ammoType)
speak(f"Not enough {ammoName}")
return False
# Consume ammo and mark as projectile attack
if self.currentWeapon.start_attack(currentTime):
self.consume_ammo(self.currentWeapon.ammoType, self.currentWeapon.ammoCost)
self.isAttacking = True
self.lastAttackTime = currentTime
# Mark this as a projectile attack for the level to handle
self.isProjectileAttack = True
return True
return False
else:
# Standard melee weapon
if self.currentWeapon.start_attack(currentTime):
self.isAttacking = True
self.lastAttackTime = currentTime
self.isProjectileAttack = False
return True
return False
def get_attack_range(self, currentTime):
"""Get the current attack's range based on position and facing direction"""
+92 -28
View File
@@ -7,43 +7,43 @@ 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)
def __init__(self, x, y, itemType, sounds, direction, leftBoundary=1, rightBoundary=100):
super().__init__(x, y, itemType, isStatic=False, isCollectible=True, isHazard=False)
self.sounds = sounds
self.direction = direction
self.speed = 0.049 # Base movement speed
self.item_type = item_type
self.itemType = itemType
self.channel = None
self._currentX = x # Initialize the current x position
self.left_boundary = left_boundary
self.right_boundary = right_boundary
self.leftBoundary = leftBoundary
self.rightBoundary = rightBoundary
def update(self, current_time, player_pos):
def update(self, currentTime, playerPos):
"""Update item position"""
if not self.isActive:
return False
# Update position
new_x = self._currentX + self.direction * self.speed
newX = self._currentX + self.direction * self.speed
# Check boundaries and bounce if needed
if new_x < self.left_boundary:
self._currentX = self.left_boundary
if newX < self.leftBoundary:
self._currentX = self.leftBoundary
self.direction = 1 # Start moving right
elif new_x > self.right_boundary:
self._currentX = self.right_boundary
elif newX > self.rightBoundary:
self._currentX = self.rightBoundary
self.direction = -1 # Start moving left
else:
self._currentX = new_x
self._currentX = newX
# Update positional audio
if self.channel is None or not self.channel.get_busy():
self.channel = obj_play(self.sounds, "item_bounce", player_pos, self._currentX)
self.channel = obj_play(self.sounds, "item_bounce", playerPos, self._currentX)
else:
self.channel = obj_update(self.channel, player_pos, self._currentX)
self.channel = obj_update(self.channel, playerPos, self._currentX)
# Check if item is too far from player (12 tiles)
if abs(self._currentX - player_pos) > 12:
if abs(self._currentX - playerPos) > 12:
self.isActive = False
if self.channel:
self.channel.stop()
@@ -55,19 +55,22 @@ class PowerUp(Object):
def apply_effect(self, player, level=None):
"""Apply the item's effect when collected"""
# Map themed items to their original equivalents for game logic
original_item_type = self._get_original_item_type(self.item_type)
originalItemType = self._get_original_item_type(self.itemType)
if original_item_type == "hand_of_glory":
if originalItemType == "hand_of_glory":
player.start_invincibility()
elif original_item_type == "cauldron":
self.check_for_custom_weapons(player)
elif originalItemType == "cauldron":
player.restore_health()
elif original_item_type == "guts":
elif originalItemType == "guts":
player.add_guts()
player.collectedItems.append(original_item_type)
player.collectedItems.append(originalItemType)
self.check_for_nunchucks(player)
elif original_item_type == "jack_o_lantern":
self.check_for_custom_weapons(player)
elif originalItemType == "jack_o_lantern":
player.add_jack_o_lantern()
elif original_item_type == "extra_life":
self.check_for_custom_weapons(player)
elif originalItemType == "extra_life":
# Don't give extra lives in survival mode
if level and level.levelId == 999:
# In survival mode, give bonus score instead
@@ -76,7 +79,7 @@ class PowerUp(Object):
play_sound(self.sounds.get("survivor_bonus", "get_extra_life")) # Use survivor_bonus sound if available
else:
player.extra_life()
elif original_item_type == "shin_bone": # Add shin bone handling
elif originalItemType == "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
@@ -102,11 +105,12 @@ class PowerUp(Object):
) # Use survivor_bonus sound if available, fallback to bone_dust
self.check_for_nunchucks(player)
elif original_item_type == "witch_broom":
self.check_for_custom_weapons(player)
elif originalItemType == "witch_broom":
broomWeapon = Weapon.create_witch_broom()
player.add_weapon(broomWeapon)
player.equip_weapon(broomWeapon)
elif original_item_type == "spiderweb":
elif originalItemType == "spiderweb":
# Bounce player back (happens even if invincible)
player.xPos -= 3 if player.xPos > self.xPos else -3
@@ -161,10 +165,70 @@ class PowerUp(Object):
play_sound(self.sounds["get_nunchucks"])
player.stats.update_stat("Items collected", 1)
def _get_original_item_type(self, item_type):
def check_for_custom_weapons(self, player):
"""Check if player has materials for any custom weapons and create if conditions are met"""
if not hasattr(player, 'customWeapons') or not player.customWeapons:
return
from src.weapon import Weapon
for weaponData in player.customWeapons:
weaponName = weaponData["name"]
# Skip if weapon already crafted
if weaponName in player.craftedCustomWeapons:
continue
# Skip if player already has this weapon
if any(weapon.originalName == weaponName for weapon in player.weapons):
continue
# Check if all crafting requirements are met
requirements = weaponData.get("requires", {})
canCraft = True
for itemType, neededCount in requirements.items():
if itemType == "shin_bone":
if player.shinBoneCount < neededCount:
canCraft = False
break
elif itemType == "jack_o_lantern":
if player._jackOLanternCount < neededCount:
canCraft = False
break
else:
# Count items in collectedItems list
playerCount = player.collectedItems.count(itemType)
if playerCount < neededCount:
canCraft = False
break
if canCraft:
# Create and add the weapon
customWeapon = Weapon.create_from_json(weaponData)
player.add_weapon(customWeapon)
player.equip_weapon(customWeapon)
player.craftedCustomWeapons.add(weaponName)
# Calculate score bonus
basePoints = customWeapon.damage * 1000
rangeModifier = customWeapon.range * 500
player.scoreboard.increase_score(basePoints + rangeModifier)
# Play craft sound if specified
craftSound = weaponData.get("craft_sound")
if craftSound and craftSound in self.sounds:
play_sound(self.sounds[craftSound])
else:
# Fallback to generic weapon craft sound
play_sound(self.sounds.get("get_weapon", "get_nunchucks"))
player.stats.update_stat("Items collected", 1)
def _get_original_item_type(self, itemType):
"""Map themed item names to their original equivalents for game logic."""
# Define themed equivalents that should behave like original items
themed_mappings = {
themedMappings = {
# Christmas theme
"candy_cane": "shin_bone",
"reindeer_guts": "guts",
@@ -173,4 +237,4 @@ class PowerUp(Object):
# "frozen_heart": "guts",
}
return themed_mappings.get(item_type, item_type)
return themedMappings.get(itemType, itemType)
+5 -5
View File
@@ -2,15 +2,15 @@
class Projectile:
def __init__(self, projectile_type, start_x, direction):
self.type = projectile_type
self.x = start_x
def __init__(self, projectileType, startX, direction):
self.type = projectileType
self.x = startX
self.direction = direction
self.speed = 0.2 # Projectiles move faster than player
self.isActive = True
self.damage = 5 # All projectiles do same damage for now
self.range = 12 # Maximum travel distance in tiles
self.start_x = start_x
self.startX = startX
def update(self):
"""Update projectile position and check if it should still exist"""
@@ -20,7 +20,7 @@ class Projectile:
self.x += self.direction * self.speed
# Check if projectile has gone too far
if abs(self.x - self.start_x) > self.range:
if abs(self.x - self.startX) > self.range:
self.isActive = False
return False
+104 -75
View File
@@ -11,30 +11,32 @@ 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"
self.save_dir.mkdir(parents=True, exist_ok=True)
self.max_saves = 10
configHome = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
self.saveDir = Path(configHome) / "storm-games" / "wicked-quest"
self.saveDir.mkdir(parents=True, exist_ok=True)
self.maxSaves = 10
def create_save(self, player, current_level, game_start_time, current_game):
def create_save(self, player, currentLevel, gameStartTime, currentGame, bypassCost=False):
"""Create a save file with current game state"""
if not player.can_save():
return False, "Not enough bone dust to save (need 200)"
if not bypassCost:
if not player.can_save():
return False, "Not enough bone dust to save (need 200)"
# Validate required parameters
if current_game is None:
if currentGame is None:
return False, "No game selected to save"
if current_level is None:
if currentLevel is None:
return False, "No current level to save"
# Spend the bone dust
if not player.spend_save_bone_dust(200):
return False, "Failed to spend bone dust"
# Spend the bone dust (only if not bypassing cost)
if not bypassCost:
if not player.spend_save_bone_dust(200):
return False, "Failed to spend bone dust"
# Create save data
save_data = {
"player_state": {
saveData = {
"playerState": {
"xPos": player.xPos,
"yPos": player.yPos,
"health": player._health,
@@ -42,19 +44,21 @@ class SaveManager:
"lives": player._lives,
"coins": player._coins,
"saveBoneDust": player._saveBoneDust,
"jackOLanternCount": player._jack_o_lantern_count,
"jackOLanternCount": player._jackOLanternCount,
"shinBoneCount": player.shinBoneCount,
"inventory": player.inventory,
"collectedItems": player.collectedItems,
"weapons": self._serialize_weapons(player.weapons),
"currentWeaponName": player.currentWeapon.name if player.currentWeapon else None,
"craftedCustomWeapons": list(getattr(player, 'craftedCustomWeapons', set())),
"customWeapons": getattr(player, 'customWeapons', []),
"stats": self._serialize_stats(player.stats),
"scoreboard": self._serialize_scoreboard(player.scoreboard),
},
"game_state": {
"currentLevel": current_level,
"currentGame": current_game,
"gameStartTime": game_start_time,
"currentLevel": currentLevel,
"currentGame": currentGame,
"gameStartTime": gameStartTime,
"saveTime": datetime.now(),
},
"version": "1.0",
@@ -63,21 +67,21 @@ class SaveManager:
# Generate filename with timestamp
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
filename = f"save_{timestamp}.pickle"
filepath = self.save_dir / filename
filepath = self.saveDir / filename
try:
# Write to temporary file first, then rename for atomic operation
temp_filepath = filepath.with_suffix(".tmp")
tempFilepath = filepath.with_suffix(".tmp")
with open(temp_filepath, "wb") as f:
pickle.dump(save_data, f)
with open(tempFilepath, "wb") as f:
pickle.dump(saveData, 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)
tempFilepath.rename(filepath)
# Clean up old saves if we exceed max_saves
# Clean up old saves if we exceed maxSaves
self._cleanup_old_saves()
return True, f"Game saved to {filename}"
@@ -106,6 +110,15 @@ class SaveManager:
"attackDuration": weapon.attackDuration,
"speedBonus": getattr(weapon, "speedBonus", 1.0),
"jumpDurationBonus": getattr(weapon, "jumpDurationBonus", 1.0),
# Custom weapon attributes
"keyBinding": getattr(weapon, "keyBinding", None),
"displayName": getattr(weapon, "displayName", None),
"weaponType": getattr(weapon, "weaponType", "melee"),
"ammoType": getattr(weapon, "ammoType", None),
"ammoCost": getattr(weapon, "ammoCost", 1),
"projectileSpeed": getattr(weapon, "projectileSpeed", 0.2),
"craftRequirements": getattr(weapon, "craftRequirements", {}),
"craftSound": getattr(weapon, "craftSound", None),
}
)
return serialized
@@ -146,6 +159,18 @@ class SaveManager:
)
# Set originalName after creation for backward compatibility
weapon.originalName = originalName
# Restore custom weapon attributes (for backward compatibility, use get with defaults)
weapon.keyBinding = data.get("keyBinding", None)
# Only set displayName if it exists and is not None
if "displayName" in data and data["displayName"] is not None:
weapon.displayName = data["displayName"]
weapon.weaponType = data.get("weaponType", "melee")
weapon.ammoType = data.get("ammoType", None)
weapon.ammoCost = data.get("ammoCost", 1)
weapon.projectileSpeed = data.get("projectileSpeed", 0.2)
weapon.craftRequirements = data.get("craftRequirements", {})
weapon.craftSound = data.get("craftSound", None)
weapons.append(weapon)
return weapons
@@ -184,36 +209,36 @@ class SaveManager:
def get_save_files(self):
"""Get list of save files with metadata"""
save_files = []
pattern = str(self.save_dir / "save_*.pickle")
saveFiles = []
pattern = str(self.saveDir / "save_*.pickle")
for filepath in glob.glob(pattern):
try:
with open(filepath, "rb") as f:
save_data = pickle.load(f)
saveData = pickle.load(f)
# Validate save data structure
if not self._validate_save_data(save_data):
if not self._validate_saveData(saveData):
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"]
saveTime = saveData["game_state"]["saveTime"]
level = saveData["game_state"]["currentLevel"]
gameName = saveData["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}"
formattedTime = saveTime.strftime("%B %d %I:%M%p")
displayName = f"{formattedTime} {gameName} Level {level}"
save_files.append(
saveFiles.append(
{
"filepath": filepath,
"display_name": display_name,
"save_time": save_time,
"displayName": displayName,
"saveTime": saveTime,
"level": level,
"game_name": game_name,
"save_data": save_data,
"gameName": gameName,
"saveData": saveData,
}
)
except (pickle.PickleError, EOFError, OSError) as e:
@@ -230,90 +255,94 @@ class SaveManager:
continue
# Sort by save time (newest first)
save_files.sort(key=lambda x: x["save_time"], reverse=True)
return save_files
saveFiles.sort(key=lambda x: x["saveTime"], reverse=True)
return saveFiles
def load_save(self, filepath):
"""Load game state from save file"""
try:
with open(filepath, "rb") as f:
save_data = pickle.load(f)
return True, save_data
saveData = pickle.load(f)
return True, saveData
except Exception as e:
return False, f"Failed to load save: {str(e)}"
def restore_player_state(self, player, save_data):
def restore_player_state(self, player, saveData):
"""Restore player state from save data"""
player_state = save_data["player_state"]
playerState = saveData["playerState"]
# 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 = playerState["xPos"]
player.yPos = playerState["yPos"]
player._health = playerState["health"]
player._maxHealth = playerState["maxHealth"]
player._lives = playerState["lives"]
player._coins = playerState["coins"]
player._saveBoneDust = playerState["saveBoneDust"]
player._jackOLanternCount = playerState["jackOLanternCount"]
player.shinBoneCount = playerState["shinBoneCount"]
player.inventory = playerState["inventory"]
player.collectedItems = playerState["collectedItems"]
# Restore weapons
player.weapons = self._deserialize_weapons(player_state["weapons"])
player.weapons = self._deserialize_weapons(playerState["weapons"])
# Restore custom weapon tracking data (for backward compatibility, use get with defaults)
player.craftedCustomWeapons = set(playerState.get("craftedCustomWeapons", []))
player.customWeapons = playerState.get("customWeapons", [])
# Restore current weapon
current_weapon_name = player_state.get("currentWeaponName")
if current_weapon_name:
currentWeaponName = playerState.get("currentWeaponName")
if currentWeaponName:
for weapon in player.weapons:
if weapon.name == current_weapon_name:
if weapon.name == currentWeaponName:
player.currentWeapon = weapon
break
# Restore stats
if "stats" in player_state:
player.stats = self._deserialize_stats(player_state["stats"])
if "stats" in playerState:
player.stats = self._deserialize_stats(playerState["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 playerState:
player.scoreboard = self._deserialize_scoreboard(playerState["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()
"""Remove old save files if we exceed maxSaves"""
saveFiles = self.get_save_files()
if len(save_files) > self.max_saves:
if len(saveFiles) > self.maxSaves:
# Remove oldest saves
for save_file in save_files[self.max_saves:]:
for save_file in saveFiles[self.maxSaves:]:
try:
os.remove(save_file["filepath"])
except Exception as e:
print(f"Error removing old save {save_file['filepath']}: {e}")
def _validate_save_data(self, save_data):
def _validate_saveData(self, saveData):
"""Validate that save data has required structure"""
try:
# Check for required top-level keys
required_keys = ["player_state", "game_state", "version"]
if not all(key in save_data for key in required_keys):
requiredKeys = ["playerState", "game_state", "version"]
if not all(key in saveData for key in requiredKeys):
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):
# Check playerState structure
playerRequired = ["xPos", "yPos", "health", "maxHealth", "lives", "coins", "saveBoneDust"]
if not all(key in saveData["playerState"] for key in playerRequired):
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):
gameRequired = ["currentLevel", "currentGame", "gameStartTime", "saveTime"]
if not all(key in saveData["game_state"] for key in gameRequired):
return False
return True
+27 -18
View File
@@ -4,7 +4,7 @@ import json
import os
import random
import copy
from src.game_selection import get_level_path
from src.game_selection import get_level_path, get_levels_base_path
class SurvivalGenerator:
@@ -24,14 +24,15 @@ class SurvivalGenerator:
self.ambientSounds = []
self.footstepSounds = []
self.weaponOverrides = {}
self.customWeapons = [] # Custom weapons from level pack
self.availableItems = set() # Dynamically discovered items from containers
self.loadLevelData()
self.parseTemplates()
self.load_level_data()
self.parse_templates()
def loadLevelData(self):
def load_level_data(self):
"""Load all level JSON files from the game pack."""
levelFiles = []
packPath = os.path.join("levels", self.gamePack)
packPath = os.path.join(get_levels_base_path(), "levels", self.gamePack)
if not os.path.exists(packPath):
raise FileNotFoundError(f"Game pack '{self.gamePack}' not found")
@@ -48,7 +49,7 @@ class SurvivalGenerator:
levelNum = int(levelFile.split(".")[0])
self.levelData[levelNum] = json.load(f)
def parseTemplates(self):
def parse_templates(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)
@@ -61,6 +62,10 @@ class SurvivalGenerator:
if "weapon_sound_overrides" in data:
self.weaponOverrides.update(data["weapon_sound_overrides"])
# Collect custom weapons (typically from level 1, but merge from all levels)
if "custom_weapons" in data:
self.customWeapons.extend(data["custom_weapons"])
# Parse objects
for obj in data.get("objects", []):
objCopy = copy.deepcopy(obj)
@@ -69,14 +74,14 @@ class SurvivalGenerator:
# Discover items from containers (graves and coffins)
if obj.get("type") in ["grave", "coffin"] and "item" in obj:
item_name = obj["item"]
itemName = obj["item"]
# Check for sound override (themed equivalent)
if "sound_overrides" in obj and "item" in obj["sound_overrides"]:
item_name = obj["sound_overrides"]["item"]
itemName = obj["sound_overrides"]["item"]
# Exclude special items that should remain rare/exclusive
if item_name and item_name != "random" and item_name not in ["extra_life"]:
self.availableItems.add(item_name)
if itemName and itemName != "random" and itemName not in ["extra_life"]:
self.availableItems.add(itemName)
# Categorize objects
if "enemy_type" in obj:
@@ -130,6 +135,10 @@ class SurvivalGenerator:
if self.weaponOverrides:
levelData["weapon_sound_overrides"] = self.weaponOverrides
# Include custom weapons if any were found
if self.customWeapons:
levelData["custom_weapons"] = self.customWeapons
# Calculate spawn rates based on difficulty
# Ensure total probabilities stay under 1.0 to prevent graves being squeezed out
collectibleDensity = max(0.05, 0.15 - (difficultyLevel * 0.01)) # Fewer collectibles over time
@@ -145,16 +154,16 @@ class SurvivalGenerator:
# Guarantee at least one coffin per wave if coffins exist in templates
coffinTemplates = [obj for obj in self.objectTemplates if obj.get("type") == "coffin"]
coffin_placed = False
coffinPlaced = False
if coffinTemplates:
# Place guaranteed coffin in the first quarter of the level
coffin_x = random.randint(startBufferZone + 10, (segmentLength - endBufferZone) // 4)
coffin_template = random.choice(coffinTemplates)
coffin_obj = copy.deepcopy(coffin_template)
coffin_obj["item"] = "random" # Override any specified item
coffin_obj["x"] = coffin_x
levelData["objects"].append(coffin_obj)
coffin_placed = True
coffinX = random.randint(startBufferZone + 10, (segmentLength - endBufferZone) // 4)
coffinTemplate = random.choice(coffinTemplates)
coffinObj = copy.deepcopy(coffinTemplate)
coffinObj["item"] = "random" # Override any specified item
coffinObj["x"] = coffinX
levelData["objects"].append(coffinObj)
coffinPlaced = True
while currentX < segmentLength - endBufferZone:
# Determine what to place based on probability
+42
View File
@@ -55,6 +55,48 @@ class Weapon:
jumpDurationBonus=1.25, # 25% longer jump duration for better traversal
)
@classmethod
def create_from_json(cls, weaponData):
"""Create a custom weapon from JSON data"""
# Extract required fields
name = weaponData["name"]
damage = weaponData["damage"]
range = weaponData["range"]
attackSound = weaponData["attack_sound"]
hitSound = weaponData["hit_sound"]
# Extract optional fields with defaults
cooldown = weaponData.get("cooldown", 500)
attackDuration = weaponData.get("attack_duration", 200)
speedBonus = weaponData.get("speed_bonus", 1.0)
jumpDurationBonus = weaponData.get("jump_bonus", 1.0)
weapon = cls(
name=name,
damage=damage,
range=range,
attackSound=attackSound,
hitSound=hitSound,
cooldown=cooldown,
attackDuration=attackDuration,
speedBonus=speedBonus,
jumpDurationBonus=jumpDurationBonus,
)
# Store additional custom weapon data
weapon.keyBinding = weaponData.get("key", None)
weapon.displayName = weaponData.get("display_name", name)
weapon.craftRequirements = weaponData.get("requires", {})
weapon.craftSound = weaponData.get("craft_sound", None)
# Projectile weapon support
weapon.weaponType = weaponData.get("weapon_type", "melee") # "melee" or "projectile"
weapon.ammoType = weaponData.get("ammo_type", None)
weapon.ammoCost = weaponData.get("ammo_cost", 1)
weapon.projectileSpeed = weaponData.get("projectile_speed", 0.2)
return weapon
def can_attack(self, currentTime):
"""Check if enough time has passed since last attack"""
return currentTime - self.lastAttackTime >= self.cooldown
+297 -109
View File
@@ -25,6 +25,8 @@ class WickedQuest:
self.throwDelay = 250
self.lastWeaponSwitchTime = 0
self.weaponSwitchDelay = 200
self.lastStatusTime = 0
self.statusDelay = 300 # 300ms between status checks
self.player = None
self.currentGame = None
self.runLock = False # Toggle behavior of the run keys
@@ -39,6 +41,9 @@ class WickedQuest:
# Level tracking for proper progression
self.currentLevelNum = 1
# Cheat save flag - allows one save per level/session
self.canSave = True
def initialize_pack_sounds(self):
"""Initialize pack-specific sound system after game selection."""
if self.currentGame:
@@ -53,6 +58,42 @@ class WickedQuest:
self.initialize_pack_sounds()
return self.soundSystem if self.soundSystem else self.sounds
def get_closest_enemy_info(self):
"""Get information about the closest enemy, returns None if no enemies."""
if not self.currentLevel or not self.currentLevel.enemies:
return None
# Find active enemies
activeEnemies = []
for enemy in self.currentLevel.enemies:
if enemy.isActive:
distance = abs(enemy.xPos - self.player.xPos)
direction = "right" if enemy.xPos > self.player.xPos else "left"
activeEnemies.append((enemy, distance, direction))
if not activeEnemies:
return None
# Sort by distance and get closest
activeEnemies.sort(key=lambda x: x[1])
enemy, distance, direction = activeEnemies[0]
# Convert distance to natural language
if distance == 0:
return f"{enemy.enemyType} right on top of you"
elif distance <= 10:
distanceDesc = "very close"
elif distance <= 30:
distanceDesc = "close"
elif distance <= 60:
distanceDesc = "far"
elif distance <= 100:
distanceDesc = "very far"
else:
distanceDesc = "extremely far"
return f"{enemy.enemyType} {distanceDesc} to the {direction}"
def process_console_command(self, command):
"""Process console commands and execute their effects."""
command = command.lower().strip()
@@ -78,32 +119,32 @@ class WickedQuest:
if 'get_extra_life' in self.get_sounds():
play_sound(self.get_sounds()['get_extra_life'])
elif command == "nekromantix":
# Give 100 bone dust
# Give 100 bone dust (both types)
if self.player:
self.player._boneDust += 100
speak(f"100 bone dust granted. You now have {self.player.get_coins()} bone dust")
self.player._coins += 100
self.player.add_save_bone_dust(100)
speak(f"100 bone dust granted. You now have {self.player.get_coins()} bone dust and {self.player.get_save_bone_dust()} save bone dust")
if 'coin' in self.get_sounds():
play_sound(self.get_sounds()['coin'])
elif command == "calabrese":
# Reveal all enemies on current level
# Reveal closest enemy on current level
if self.currentLevel and self.currentLevel.enemies:
enemyCount = len([enemy for enemy in self.currentLevel.enemies if enemy.isActive])
if enemyCount > 0:
speak(f"{enemyCount} enemies remaining on this level")
for enemy in self.currentLevel.enemies:
if enemy.isActive:
distance = abs(enemy.xPos - self.player.xPos)
direction = "right" if enemy.xPos > self.player.xPos else "left"
speak(f"{enemy.enemyType} {distance} units to the {direction}")
closestEnemyInfo = self.get_closest_enemy_info()
if closestEnemyInfo:
speak(f"Closest enemy: {closestEnemyInfo}")
else:
speak("No active enemies on this level")
else:
speak("No enemies found")
elif command == "balzac":
# Give 200 bone dust
# Set bone dust to 200 (both types)
if self.player:
self.player._boneDust = 200
speak(f"Bone dust set to 200")
self.player._coins = 200
self.player._saveBoneDust = 200
speak(f"Bone dust set to 200. You now have {self.player.get_coins()} bone dust and {self.player.get_save_bone_dust()} save bone dust")
if 'coin' in self.get_sounds():
play_sound(self.get_sounds()['coin'])
elif command == "blitzkid":
@@ -135,7 +176,7 @@ class WickedQuest:
if grantedWeapons:
speak(f"Granted {', '.join(grantedWeapons)}")
else:
speak("You already have both weapons")
speak("You already have all weapons")
elif command == "creepshow":
# Toggle god mode (invincibility)
if self.player:
@@ -149,14 +190,14 @@ class WickedQuest:
elif command == "diemonsterdie":
# Give 100 jack o'lanterns
if self.player:
self.player._jack_o_lantern_count += 100
self.player._jackOLanternCount += 100
speak(f"100 jack o'lanterns granted. You now have {self.player.get_jack_o_lanterns()} jack o'lanterns")
if 'get_jack_o_lantern' in self.get_sounds():
play_sound(self.get_sounds()['get_jack_o_lantern'])
elif command == "murderland":
# Set jack o'lanterns to exactly 13 (the special number)
if self.player:
self.player._jack_o_lantern_count = 13
self.player._jackOLanternCount = 13
speak("13 jack o'lanterns, this power courses through my soul.")
if 'get_jack_o_lantern' in self.get_sounds():
play_sound(self.get_sounds()['get_jack_o_lantern'])
@@ -180,6 +221,91 @@ class WickedQuest:
speak("Coffin spawned at your location")
if 'coffin' in self.get_sounds():
play_sound(self.get_sounds()['coffin'])
elif command == "theother": # Save game on current level
if not self.canSave:
speak("Save already used for this level")
return
# Don't save in survival mode
if hasattr(self, 'currentLevel') and self.currentLevel and self.currentLevel.levelId == 999:
speak("Cannot save in survival mode")
return
# Create save without bone dust requirement
try:
success, message = self.saveManager.create_save(
self.player,
self.currentLevel.levelId,
self.gameStartTime,
self.currentGame,
bypassCost=True # Skip bone dust requirement
)
if success:
self.canSave = False # Disable further saves for this level
speak("Game saved")
try:
if 'save' in self.get_sounds():
play_sound(self.get_sounds()['save'])
except Exception as e:
print(f"Error playing save sound: {e}")
pass
else:
speak(f"Save failed: {message}")
except Exception as e:
speak("Save failed due to error")
print(f"Error during cheat save: {e}")
elif command == "wednesday13":
# Max out all stats - the ultimate power-up
if self.player:
from src.weapon import Weapon
# Max health and restore to full
self.player._maxHealth = 20
self.player._health = 20
# Max lives (set to 13 for the theme)
self.player._lives = 13
# Max bone dust (both types)
self.player._coins = 999
self.player._saveBoneDust = 999
# Max jack o'lanterns (set to 99 for practicality)
self.player._jackOLanternCount = 99
# Grant all weapons with level pack override support
weaponOverrides = getattr(self.currentLevel, 'weaponSoundOverrides', {}) if self.currentLevel else {}
# Check if player already has broom
hasBroom = any(w.originalName == "witch_broom" for w in self.player.weapons)
if not hasBroom:
broom = Weapon.create_witch_broom()
if weaponOverrides and "witch_broom" in weaponOverrides:
override = weaponOverrides["witch_broom"]
broom.name = override.get("name", broom.name)
broom.attackSound = override.get("attack_sound", broom.attackSound)
broom.hitSound = override.get("hit_sound", broom.hitSound)
self.player.add_weapon(broom)
# Check if player already has nunchucks
hasNunchucks = any(w.originalName == "nunchucks" for w in self.player.weapons)
if not hasNunchucks:
nunchucks = Weapon.create_nunchucks()
if weaponOverrides and "nunchucks" in weaponOverrides:
override = weaponOverrides["nunchucks"]
nunchucks.name = override.get("name", nunchucks.name)
nunchucks.attackSound = override.get("attack_sound", nunchucks.attackSound)
nunchucks.hitSound = override.get("hit_sound", nunchucks.hitSound)
self.player.add_weapon(nunchucks)
# Enable god mode for good measure
self.player._godMode = True
speak("MAXIMUM POWER! All stats maxed, all weapons granted, god mode enabled")
if 'get_extra_life' in self.get_sounds():
play_sound(self.get_sounds()['get_extra_life'])
else:
speak("Unknown command")
@@ -229,6 +355,8 @@ class WickedQuest:
if self.load_level(levelNum):
# Update the current level counter for proper progression
self.currentLevelNum = levelNum
# Reset cheat save flag for warped level
self.canSave = True
speak(f"Warped to level {levelNum}")
else:
speak(f"Failed to load level {levelNum}")
@@ -310,29 +438,29 @@ class WickedQuest:
def load_game_menu(self):
"""Display load game menu with available saves using instruction_menu"""
save_files = self.saveManager.get_save_files()
if not save_files:
saveFiles = self.saveManager.get_save_files()
if not saveFiles:
messagebox("No save files found.")
return None
# Create menu options
options = []
for save_file in save_files:
options.append(save_file['display_name'])
for saveFile in saveFiles:
options.append(saveFile['displayName'])
options.append("Cancel")
# Use instruction_menu for consistent behavior
choice = instruction_menu(self.get_sounds(), "Select a save file to load:", *options)
if choice == "Cancel" or choice is None:
return None
else:
# Find the corresponding save file
for save_file in save_files:
if save_file['display_name'] == choice:
return save_file
for saveFile in saveFiles:
if saveFile['displayName'] == choice:
return saveFile
return None
def auto_save(self):
@@ -418,20 +546,69 @@ class WickedQuest:
player.distanceSinceLastStep = 0
player.lastStepTime = currentTime
# Status queries
if keys[pygame.K_c]:
# Different status message for survival vs story mode
if hasattr(self, 'currentLevel') and self.currentLevel and self.currentLevel.levelId == 999:
speak(f"{player.get_coins()} bone dust collected")
else:
speak(f"{player.get_coins()} bone dust for extra lives, {player.get_save_bone_dust()} bone dust for saves")
if keys[pygame.K_h]:
speak(f"{player.get_health()} health of {player.get_max_health()}")
if keys[pygame.K_i]:
if self.currentLevel.levelId == 999:
speak(f"Wave {self.survivalWave}. {player.get_health()} health of {player.get_max_health()}. {int(self.currentLevel.levelScore)} points on this wave so far. {player.get_lives()} lives remaining.")
else:
speak(f"Level {self.currentLevel.levelId}, {self.currentLevel.levelName}. {player.get_health()} health of {player.get_max_health()}. {int(self.currentLevel.levelScore)} points on this level so far. {player.get_lives()} lives remaining.")
# Status queries with debouncing
if (keys[pygame.K_c] or keys[pygame.K_e] or keys[pygame.K_h] or keys[pygame.K_i]) and currentTime - self.lastStatusTime >= self.statusDelay:
self.lastStatusTime = currentTime
if keys[pygame.K_c]:
# Simplified bone dust status only
if hasattr(self, 'currentLevel') and self.currentLevel and self.currentLevel.levelId == 999:
speak(f"{player.get_coins()} bone dust collected")
else:
speak(f"{player.get_coins()} bone dust for extra lives, {player.get_save_bone_dust()} bone dust for saves")
elif keys[pygame.K_e]:
# Weapon and ammo status
if player.currentWeapon:
weaponName = getattr(player.currentWeapon, 'displayName', player.currentWeapon.name.replace("_", " "))
statusMessage = f"Wielding {weaponName}"
# Check if it's a projectile weapon - always show ammo for projectile weapons
weaponType = getattr(player.currentWeapon, 'weaponType', 'melee')
if weaponType == "projectile":
ammoType = getattr(player.currentWeapon, 'ammoType', None)
if ammoType:
ammoCount = 0
ammoDisplayName = ammoType.replace("_", " ") # Default fallback
# Get current ammo count based on ammo type
if ammoType == "bone_dust":
ammoCount = player.get_coins()
ammoDisplayName = "bone dust"
elif ammoType == "shin_bone":
ammoCount = player.shinBoneCount
ammoDisplayName = "shin bones"
elif ammoType == "jack_o_lantern":
ammoCount = player._jackOLanternCount
ammoDisplayName = "jack o'lanterns"
elif ammoType == "guts":
ammoCount = player.collectedItems.count("guts")
ammoDisplayName = "guts"
elif ammoType == "hand_of_glory":
ammoCount = player.collectedItems.count("hand_of_glory")
ammoDisplayName = "hands of glory"
else:
# Check for any other item type in collectedItems
ammoCount = player.collectedItems.count(ammoType)
statusMessage += f". {ammoCount} {ammoDisplayName}"
speak(statusMessage)
else:
speak("No weapon equipped")
elif keys[pygame.K_h]:
speak(f"{player.get_health()} health of {player.get_max_health()}")
elif keys[pygame.K_i]:
if self.currentLevel.levelId == 999:
baseInfo = f"Wave {self.survivalWave}. {player.get_health()} health of {player.get_max_health()}. {int(self.currentLevel.levelScore)} points on this wave so far. {player.get_lives()} lives remaining."
else:
baseInfo = f"Level {self.currentLevel.levelId}, {self.currentLevel.levelName}. {player.get_health()} health of {player.get_max_health()}. {int(self.currentLevel.levelScore)} points on this level so far. {player.get_lives()} lives remaining."
# Add closest enemy info
closestEnemyInfo = self.get_closest_enemy_info()
if closestEnemyInfo:
speak(f"{baseInfo} {closestEnemyInfo}")
else:
speak(baseInfo)
if keys[pygame.K_l]:
speak(f"{player.get_lives()} lives")
if keys[pygame.K_j]: # Check jack o'lanterns
@@ -446,12 +623,11 @@ class WickedQuest:
if currentTime - self.lastThrowTime >= self.throwDelay:
self.currentLevel.throw_projectile()
self.lastThrowTime = currentTime
if keys[pygame.K_e]:
speak(f"Wielding {self.currentLevel.player.currentWeapon.name.replace('_', ' ')}")
# Weapon switching (1=shovel, 2=broom, 3=nunchucks)
# Weapon switching (1=shovel, 2=broom, 3=nunchucks, 4-0=custom weapons)
currentTime = pygame.time.get_ticks()
if currentTime - self.lastWeaponSwitchTime >= self.weaponSwitchDelay:
# Check standard weapon keys (1-3)
if keys[pygame.K_1]:
if player.switch_to_weapon(1):
self.lastWeaponSwitchTime = currentTime
@@ -461,11 +637,37 @@ class WickedQuest:
elif keys[pygame.K_3]:
if player.switch_to_weapon(3):
self.lastWeaponSwitchTime = currentTime
# Check custom weapon keys (4-0)
elif keys[pygame.K_4]:
if player.switch_to_weapon(4):
self.lastWeaponSwitchTime = currentTime
elif keys[pygame.K_5]:
if player.switch_to_weapon(5):
self.lastWeaponSwitchTime = currentTime
elif keys[pygame.K_6]:
if player.switch_to_weapon(6):
self.lastWeaponSwitchTime = currentTime
elif keys[pygame.K_7]:
if player.switch_to_weapon(7):
self.lastWeaponSwitchTime = currentTime
elif keys[pygame.K_8]:
if player.switch_to_weapon(8):
self.lastWeaponSwitchTime = currentTime
elif keys[pygame.K_9]:
if player.switch_to_weapon(9):
self.lastWeaponSwitchTime = currentTime
elif keys[pygame.K_0]:
if player.switch_to_weapon(10): # 0 key maps to index 10
self.lastWeaponSwitchTime = currentTime
# Handle attack with either CTRL key
if (keys[pygame.K_LCTRL] or keys[pygame.K_RCTRL]) and player.start_attack(currentTime):
play_sound(self.get_sounds()[player.currentWeapon.attackSound])
# If this was a projectile weapon attack, create a projectile
if hasattr(player, 'isProjectileAttack') and player.isProjectileAttack:
self.currentLevel.create_weapon_projectile(player)
# Handle jumping
if (keys[pygame.K_w] or keys[pygame.K_UP]) and not player.isJumping and not player.isDucking:
player.isJumping = True
@@ -584,7 +786,7 @@ class WickedQuest:
altPressed = mods & pygame.KMOD_ALT
# Console activation with grave accent key - only in story mode
if event.key == pygame.K_BACKQUOTE: # Grave accent key (`)
if event.key in (pygame.K_BACKQUOTE, pygame.K_QUOTE): # Grave accent key (`)
# Open console (only in story mode, not survival)
if hasattr(self, 'currentLevel') and self.currentLevel and self.currentLevel.levelId != 999:
self.open_console()
@@ -661,6 +863,8 @@ class WickedQuest:
self.currentLevelNum += 1
if self.load_level(self.currentLevelNum):
# Reset cheat save flag for new level
self.canSave = True
# Auto save at the beginning of new level if conditions are met
self.auto_save()
levelStartTime = pygame.time.get_ticks() # Reset level timer for new level
@@ -669,9 +873,9 @@ class WickedQuest:
# Game complete - use gameStartTime for total
totalTime = pygame.time.get_ticks() - self.gameStartTime
if self.player.xPos >= self.currentLevel.rightBoundary:
# Check for end of game scene using relative path like other sounds
# Check for end of game scene using unified path resolution
for ext in ['.wav', '.ogg', '.mp3']:
endFile = os.path.join("levels", self.currentGame, f'end{ext}')
endFile = os.path.join(get_game_dir_path(self.currentGame), f'end{ext}')
if os.path.exists(endFile):
self.get_sounds()['end_scene'] = pygame.mixer.Sound(endFile)
cut_scene(self.get_sounds(), 'end_scene')
@@ -694,39 +898,39 @@ class WickedQuest:
while True:
# Add load game option if saves exist
custom_options = []
customOptions = []
if self.saveManager.has_saves():
custom_options.append("load_game")
choice = game_menu(self.get_sounds(), None, *custom_options)
customOptions.append("load_game")
choice = game_menu(self.get_sounds(), None, *customOptions)
if choice == "exit":
exit_game()
elif choice == "load_game":
selected_save = self.load_game_menu()
if selected_save:
success, save_data = self.saveManager.load_save(selected_save['filepath'])
selectedSave = self.load_game_menu()
if selectedSave:
success, saveData = self.saveManager.load_save(selectedSave['filepath'])
if success:
# Load the saved game
self.currentGame = save_data['game_state']['currentGame']
self.gameStartTime = save_data['game_state']['gameStartTime']
current_level = save_data['game_state']['currentLevel']
self.currentGame = saveData['game_state']['currentGame']
self.gameStartTime = saveData['game_state']['gameStartTime']
currentLevel = saveData['game_state']['currentLevel']
# Initialize pack-specific sound system
self.initialize_pack_sounds()
# Load the level
if self.load_level(current_level):
if self.load_level(currentLevel):
# Restore player state
self.saveManager.restore_player_state(self.player, save_data)
self.saveManager.restore_player_state(self.player, saveData)
# Re-apply weapon overrides after restoring player state to ensure
# sound/name overrides work with restored weapon properties
if hasattr(self.currentLevel, 'weaponOverrides') and self.currentLevel.weaponOverrides:
self.currentLevel._apply_weapon_overrides(self.currentLevel.weaponOverrides)
self.game_loop(current_level)
self.game_loop(currentLevel)
else:
messagebox("Failed to load saved level.")
else:
messagebox(f"Failed to load save: {save_data}")
messagebox(f"Failed to load save: {saveData}")
elif choice == "play":
self.currentGame = select_game(self.get_sounds())
if self.currentGame is None:
@@ -743,13 +947,13 @@ class WickedQuest:
continue
if self.currentGame:
# Ask player to choose game mode
mode_choice = game_mode_menu(self.get_sounds(), self.currentGame)
if mode_choice == "story":
modeChoice = game_mode_menu(self.get_sounds(), self.currentGame)
if modeChoice == "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":
elif modeChoice == "survival":
self.start_survival_mode()
elif choice == "high_scores":
board = Scoreboard()
@@ -912,12 +1116,12 @@ class WickedQuest:
self.currentLevel = Level(levelData, self.get_sounds(), self.player, self.currentGame)
def game_mode_menu(sounds, game_dir=None):
def game_mode_menu(sounds, gameDir=None):
"""Display game mode selection menu using instruction_menu.
Args:
sounds (dict): Dictionary of loaded sound effects
game_dir (str): Current game directory to check for instructions/credits
gameDir (str): Current game directory to check for instructions/credits
Returns:
str: Selected game mode or None if cancelled
@@ -926,79 +1130,63 @@ def game_mode_menu(sounds, game_dir=None):
import os
# Build base menu options
menu_options = ["Story", "Survival Mode"]
menuOptions = ["Story", "Survival Mode"]
# Check for level pack specific files if game directory is provided
if game_dir:
if gameDir:
try:
game_path = get_game_dir_path(game_dir)
gamePath = get_game_dir_path(gameDir)
# Check for instructions.txt
instructions_path = os.path.join(game_path, "instructions.txt")
if os.path.exists(instructions_path):
menu_options.append("Instructions")
instructionsPath = os.path.join(gamePath, "instructions.txt")
if os.path.exists(instructionsPath):
menuOptions.append("Instructions")
# Check for credits.txt
credits_path = os.path.join(game_path, "credits.txt")
if os.path.exists(credits_path):
menu_options.append("Credits")
creditsPath = os.path.join(gamePath, "credits.txt")
if os.path.exists(creditsPath):
menuOptions.append("Credits")
except Exception:
# If there's any error checking files, just continue with basic menu
pass
while True:
choice = instruction_menu(sounds, "Select game mode:", *menu_options)
choice = instruction_menu(sounds, "Select game mode:", *menuOptions)
if choice == "Story":
return "story"
elif choice == "Survival Mode":
return "survival"
elif choice == "Instructions" and game_dir:
elif choice == "Instructions" and gameDir:
# Display instructions file
try:
game_path = get_game_dir_path(game_dir)
instructions_path = os.path.join(game_path, "instructions.txt")
print(f"DEBUG: Looking for instructions at: {instructions_path}")
if os.path.exists(instructions_path):
print("DEBUG: Instructions file found, loading content...")
with open(instructions_path, 'r', encoding='utf-8') as f:
instructions_content = f.read()
print(f"DEBUG: Content length: {len(instructions_content)} characters")
print("DEBUG: Calling display_text...")
gamePath = get_game_dir_path(gameDir)
instructionsPath = os.path.join(gamePath, "instructions.txt")
if os.path.exists(instructionsPath):
with open(instructionsPath, 'r', encoding='utf-8') as f:
instructionsContent = f.read()
# Convert string to list of lines for display_text
content_lines = instructions_content.split('\n')
print(f"DEBUG: Split into {len(content_lines)} lines")
display_text(content_lines)
print("DEBUG: display_text returned")
contentLines = instructionsContent.split('\n')
display_text(contentLines)
else:
print("DEBUG: Instructions file not found at expected path")
speak("Instructions file not found")
except Exception as e:
print(f"DEBUG: Error loading instructions: {str(e)}")
speak(f"Error loading instructions: {str(e)}")
elif choice == "Credits" and game_dir:
elif choice == "Credits" and gameDir:
# Display credits file
try:
game_path = get_game_dir_path(game_dir)
credits_path = os.path.join(game_path, "credits.txt")
print(f"DEBUG: Looking for credits at: {credits_path}")
if os.path.exists(credits_path):
print("DEBUG: Credits file found, loading content...")
with open(credits_path, 'r', encoding='utf-8') as f:
credits_content = f.read()
print(f"DEBUG: Content length: {len(credits_content)} characters")
print("DEBUG: Calling display_text...")
gamePath = get_game_dir_path(gameDir)
creditsPath = os.path.join(gamePath, "credits.txt")
if os.path.exists(creditsPath):
with open(creditsPath, 'r', encoding='utf-8') as f:
creditsContent = f.read()
# Convert string to list of lines for display_text
content_lines = credits_content.split('\n')
print(f"DEBUG: Split into {len(content_lines)} lines")
display_text(content_lines)
print("DEBUG: display_text returned")
contentLines = creditsContent.split('\n')
display_text(contentLines)
else:
print("DEBUG: Credits file not found at expected path")
speak("Credits file not found")
except Exception as e:
print(f"DEBUG: Error loading credits: {str(e)}")
speak(f"Error loading credits: {str(e)}")
else:
return None
+6 -12
View File
@@ -1,15 +1,7 @@
# -*- 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))
# Include entire levels directory (simpler and more reliable)
level_dirs = [('levels', 'levels')]
a = Analysis(
['wicked_quest.py'],
@@ -18,11 +10,13 @@ a = Analysis(
datas=level_dirs + [
('sounds', 'sounds'),
('libstormgames', 'libstormgames'),
('files', 'files'),
('logo.png', '.'),
],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
runtime_hooks=['move_dirs_hook.py'],
excludes=[],
noarchive=False,
optimize=0,
@@ -39,7 +33,7 @@ exe = EXE(
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,