447 lines
19 KiB
Python
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'])
|