Files
wicked-quest/src/level.py

447 lines
19 KiB
Python

import pygame
import random
from libstormgames import *
from src.catapult import Catapult
from src.coffin import CoffinObject
from src.enemy import Enemy
from src.grave import GraveObject
from src.object import Object
from src.player import Player
from src.projectile import Projectile
from src.powerup import PowerUp
from src.skull_storm import SkullStorm
class Level:
def __init__(self, levelData, sounds, player):
self.sounds = sounds
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.weapon_hit_channel = None
self.leftBoundary = levelData["boundaries"]["left"]
self.rightBoundary = levelData["boundaries"]["right"]
self.isLocked = levelData.get("locked", False) # Default to False if not specified
self.levelId = levelData["level_id"]
# Get footstep sound for this level, default to 'footstep' if not specified
self.footstepSound = levelData.get("footstep_sound", "footstep")
# Pass footstep sound to player
self.player.set_footstep_sound(self.footstepSound)
# Level intro message
levelIntro = f"Level {levelData['level_id']}, {levelData['name']}. {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:
pass
# Create end of level object at right boundary
endLevel = Object(
self.rightBoundary,
0, # Same y-level as player start
"end_of_level",
isStatic=True,
isCollectible=False,
isHazard=False
)
self.objects.append(endLevel)
# Load objects and enemies from level data
for obj in levelData["objects"]:
# Handle x position or range
if "x_range" in obj:
xPos = obj["x_range"]
else:
xPos = [obj["x"], obj["x"]] # Single position as range
# Check if this is a catapult
if obj.get("type") == "catapult":
catapult = Catapult(
xPos[0],
obj["y"],
self.sounds,
fireInterval=obj.get("fireInterval", 5000),
firingRange=obj.get("range", 20)
)
self.objects.append(catapult)
# Check if this is a grave
elif obj.get("type") == "grave":
grave = GraveObject(
xPos[0],
obj["y"],
self.sounds,
item=obj.get("item", None),
zombieSpawnChance=obj.get("zombie_spawn_chance", 0)
)
self.objects.append(grave)
# Check if this is a skull storm
elif obj.get("type") == "skull_storm":
skullStorm = SkullStorm(
xPos,
obj["y"],
self.sounds,
obj.get("damage", 5),
obj.get("maximum_skulls", 3),
obj.get("frequency", {}).get("min", 2),
obj.get("frequency", {}).get("max", 5)
)
self.objects.append(skullStorm)
# Check if this is a coffin
elif obj.get("type") == "coffin":
coffin = CoffinObject(
xPos[0],
obj["y"],
self.sounds,
self, # Pass level reference
item=obj.get("item", "random") # Get item type or default to random
)
self.objects.append(coffin)
# Check if this is a spider web
elif obj.get("type") == "spider_web":
# Check distance from graves
isValidPosition = True
for existingObj in self.objects:
if (existingObj.soundName == "grave" and
not hasattr(existingObj, 'graveItem')):
distance = abs(obj["x"] - existingObj.xPos)
if distance < 3:
isValidPosition = False
break
if isValidPosition:
web = Object(
obj["x"], # Just pass the single x value
obj["y"],
"spiderweb",
isStatic=True,
isCollectible=False,
)
self.objects.append(web)
# Check if this is an enemy
elif "enemy_type" in obj:
enemy = Enemy(
xPos,
obj["y"],
obj["enemy_type"],
self.sounds,
self, # Pass level reference
health=obj.get("health", 5),
damage=obj.get("damage", 1),
attack_range=obj.get("attack_range", 1),
movement_range=obj.get("movement_range", 5),
attack_pattern=obj.get("attack_pattern", {'type': 'patrol'}) # Add this line
)
self.enemies.append(enemy)
else:
gameObject = Object(
xPos,
obj["y"],
obj["sound"],
isStatic=obj.get("static", True),
isCollectible=obj.get("collectible", False),
isHazard=obj.get("hazard", False),
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)
def update_audio(self):
"""Update all audio and entity state."""
currentTime = pygame.time.get_ticks()
# Update regular objects and check for zombie spawning
for obj in self.objects:
if not obj.isActive:
continue
# Check for potential zombie spawn from graves
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
# Mark as checked before doing anything else to prevent multiple checks
obj.hasSpawned = True
roll = random.randint(1, 100)
if roll <= obj.zombieSpawnChance:
zombie = Enemy(
[obj.xPos, obj.xPos],
obj.yPos,
"zombie",
self.sounds,
self, # Pass the level reference
health=3,
damage=10,
attack_range=1
)
self.enemies.append(zombie)
speak("A zombie emerges from the grave!")
# Handle object audio
if obj.channel is not None:
obj.channel = obj_update(obj.channel, self.player.xPos, obj.xPos)
elif obj.soundName: # Only try to play sound if soundName is not empty
if not obj.isStatic:
obj.channel = obj_play(self.sounds, obj.soundName, self.player.xPos, obj.xPos)
else:
obj.channel = obj_play(self.sounds, obj.soundName, self.player.xPos, obj.xPos)
# Update enemies
for enemy in self.enemies:
if not enemy.isActive:
continue
enemy.update(currentTime, self.player)
if enemy.channel is None or not enemy.channel.get_busy():
enemy.channel = obj_play(self.sounds, enemy.enemyType, self.player.xPos, enemy.xPos)
if enemy.channel is not None:
enemy.channel = obj_update(enemy.channel, self.player.xPos, enemy.xPos)
# Update catapults
for obj in self.objects:
if isinstance(obj, Catapult):
obj.update(currentTime, self.player)
# Update skull storms
for obj in self.objects:
if isinstance(obj, SkullStorm):
obj.update(currentTime, self.player)
# Update bouncing items
for item in self.bouncing_items[:]: # Copy list to allow removal
if not item.update(currentTime, self.player.xPos):
self.bouncing_items.remove(item)
if not item.isActive:
speak(f"{item.soundName} got away!")
continue
# 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)
item.isActive = False
self.bouncing_items.remove(item)
def handle_combat(self, currentTime):
"""Handle combat interactions between player and enemies"""
# Only get attack range if attack is active
if self.player.currentWeapon and self.player.currentWeapon.is_attack_active(currentTime):
attackRange = self.player.currentWeapon.get_attack_range(self.player.xPos, self.player.facingRight)
# Check for enemy hits
for enemy in self.enemies:
if enemy.isActive and enemy.xPos >= attackRange[0] and enemy.xPos <= attackRange[1]:
# Only damage and play sound if this is a new hit for this attack
if self.player.currentWeapon.register_hit(enemy, currentTime):
play_sound(self.sounds[self.player.currentWeapon.hitSound])
enemy.take_damage(self.player.currentWeapon.damage)
# 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 obj.hit(self.player.xPos):
self.bouncing_items.append(obj.dropped_item)
def spawn_spider(self, xPos, yPos):
"""Spawn a spider at the given position"""
spider = Enemy(
[xPos - 5, xPos + 5], # Give spider a patrol range
yPos,
"spider",
self.sounds,
self,
health=8,
damage=8,
attack_range=1,
speed_multiplier=2.0
)
self.enemies.append(spider)
def handle_collisions(self):
"""Handle all collision checks and return True if level is complete."""
# Add a pump here so it gets called reasonably often.
pygame.event.pump()
# First check if player is dead
if self.player.get_health() <= 0:
return False
# Process object collisions for hazards and collectibles
for obj in self.objects:
if not obj.isActive:
continue
# Handle grave edge warnings
if obj.isHazard and obj.soundName != "spiderweb": # Explicitly exclude spiderwebs
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 isinstance(obj, GraveObject) and obj.graveItem and not obj.isCollected:
play_sound(self.sounds['_edge'])
else:
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:
currentPos = round(self.player.xPos)
if currentPos not in obj.collectedPositions:
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)
if obj.soundName == "coin":
self.player._coins += 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()
))
if self.player._coins % 100 == 0:
# Extra life
self.player._coins = 0
self.player._lives += 1
play_sound(self.sounds['get_extra_life'])
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
)
webEffect.level = self # Pass level reference for spider spawning
play_sound(self.sounds['hit_spiderweb'])
webEffect.apply_effect(self.player)
# Deactivate web
obj.isActive = False
obj.channel = obj_stop(obj.channel)
continue
# 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)
if can_collect:
# Successfully collected item while ducking
play_sound(self.sounds[f'get_{obj.graveItem}'])
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)
# Stop grave's current audio channel
if obj.channel:
obj_stop(obj.channel)
# Remove the grave
obj.graveItem = None
obj.channel = None
obj.isActive = False # Mark the grave as inactive after collection
continue
elif not self.player.isInvincible:
# Kill player for normal graves or non-ducking collision
play_sound(self.sounds[obj.soundName])
speak("You fell in an open grave! Now, it's yours!")
self.player.set_health(0)
return False
# Handle boundaries
if self.player.xPos < self.leftBoundary:
self.player.xPos = self.leftBoundary
speak("Start of level!")
# Check for level completion - takes precedence over everything except death
if self.player.get_health() > 0:
for obj in self.objects:
if obj.soundName == "end_of_level":
# Check if player has reached or passed the end marker
if self.player.xPos >= obj.xPos:
# 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'])
# Push player back a bit
self.player.xPos -= 1
return False
# Level complete
pygame.mixer.stop()
play_sound(self.sounds['end_of_level'])
return True
return False
def handle_projectiles(self, currentTime):
"""Update projectiles and check for collisions"""
for proj in self.projectiles[:]: # Copy list to allow removal
if not proj.update():
self.projectiles.remove(proj)
continue
# Check for enemy hits
for enemy in self.enemies:
if enemy.isActive and abs(proj.x - enemy.xPos) < 1:
proj.hit_enemy(enemy)
self.projectiles.remove(proj)
# 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)
break
def throw_projectile(self):
"""Have player throw a projectile"""
proj_info = self.player.throw_projectile()
if proj_info is None:
speak("No jack o'lanterns to throw!")
return
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'])