468 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			468 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| # -*- coding: utf-8 -*-
 | |
| 
 | |
| 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"]
 | |
|         self.levelName = levelData.get("name", "Unnamed Level")
 | |
|         self.levelScore = 0
 | |
| 
 | |
|         # 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'}),
 | |
|                     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)
 | |
|                 )
 | |
|                 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)
 | |
| 
 | |
|             # Only handle audio for non-vulnerability enemies
 | |
|             if not enemy.hasVulnerabilitySystem:
 | |
|                 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)
 | |
|                 self.levelScore += 1000  # All items collected points awarded
 | |
|                 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 == "bone_dust":
 | |
|                         self.player._coins += 1
 | |
|                         self.levelScore += 100
 | |
|                         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
 | |
|                                 self.levelScore += 1000
 | |
|                                 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 -= 5
 | |
|                             return False
 | |
|                 
 | |
|                         # Level complete
 | |
|                         pygame.mixer.stop()
 | |
|                         play_sound(self.sounds['end_of_level'])
 | |
|                         self.levelScore += 10000
 | |
|                         # Actually update the scoreboard with level completion
 | |
|                         self.player.scoreboard.increase_score(self.levelScore)
 | |
|                         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'])
 |