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'])