From 53009373c279cff0363f2953d23a7c5a78d89b14 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 30 Jan 2025 19:11:33 -0500 Subject: [PATCH] More content added, weapons, coin collecting, basic combat. --- .gitignore | 1 + levels/1.json | 12 ++-- sounds/player_shovel_attack.ogg | 3 + sounds/player_shovel_hit.ogg | 3 + src/enemy.py | 105 ++++++++++++++++++++++++++++++ src/level.py | 109 ++++++++++++++++++++------------ src/object.py | 29 ++++++++- src/player.py | 78 ++++++++++++++++++++++- src/weapon.py | 33 ++++++++++ wicked_quest.py | 34 ++++++---- 10 files changed, 344 insertions(+), 63 deletions(-) create mode 100644 sounds/player_shovel_attack.ogg create mode 100644 sounds/player_shovel_hit.ogg create mode 100644 src/enemy.py create mode 100644 src/weapon.py diff --git a/.gitignore b/.gitignore index 8c24d07..5025efb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +filestructure.txt __pycache__/ *\.pyc sounds/rec.sh diff --git a/levels/1.json b/levels/1.json index a1ca11c..1e40bda 100644 --- a/levels/1.json +++ b/levels/1.json @@ -1,14 +1,14 @@ { "level_id": 1, "name": "The Mausoleum", - "description": "After years of existing as a pile of bones, someone was crazy enough to assemble your skeleton. Time to reak some havoc!", + "description": "After years of existing as a pile of bones, someone was crazy enough to assemble your skeleton. Time to wreak some havoc!", "player_start": { "x": 0, "y": 0 }, "objects": [ { - "x": 5, + "x_range": [5, 7], "y": 3, "sound": "coin", "collectible": true, @@ -17,9 +17,11 @@ { "x": 25, "y": 0, - "sound": "goblin", - "hazard": true, - "static": false + "enemy_type": "goblin", + "health": 5, + "damage": 1, + "attack_range": 1, + "movement_range": 5 }, { "x": 50, diff --git a/sounds/player_shovel_attack.ogg b/sounds/player_shovel_attack.ogg new file mode 100644 index 0000000..c07bebb --- /dev/null +++ b/sounds/player_shovel_attack.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c7e119901adbe06e4b69b8aeaf7fc49cfc0f8c6d4c74170ed8437a98e95b02ed +size 6134 diff --git a/sounds/player_shovel_hit.ogg b/sounds/player_shovel_hit.ogg new file mode 100644 index 0000000..946693d --- /dev/null +++ b/sounds/player_shovel_hit.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a2f51236a8feb5e81f21d26cb220583fa7f54b7e139a8984dc228ef6008a824 +size 6402 diff --git a/src/enemy.py b/src/enemy.py new file mode 100644 index 0000000..b67914a --- /dev/null +++ b/src/enemy.py @@ -0,0 +1,105 @@ +from libstormgames import * +from src.object import Object +import pygame + + +class Enemy(Object): + def __init__(self, xRange, y, enemyType, sounds, **kwargs): + # Initialize base object properties + super().__init__( + xRange, + y, + f"{enemyType}", # Base sound for ambient noise + isStatic=False, + isHazard=True + ) + + # Enemy specific properties + self.enemyType = enemyType + 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.movementRange = kwargs.get('movement_range', 5) # Default 5 tile patrol + self.sounds = sounds # Store reference to game sounds + + # Movement and behavior properties + self.movingRight = True # Initial direction + self.movementSpeed = 0.03 # Slightly slower than player + self.patrolStart = self.xRange[0] + self.patrolEnd = self.xRange[0] + self.movementRange + self.lastAttackTime = 0 + self.attackCooldown = 1000 # 1 second between attacks + + @property + def xPos(self): + """Current x position""" + return self._currentX if hasattr(self, '_currentX') else self.xRange[0] + + @xPos.setter + def xPos(self, value): + """Set current x position""" + self._currentX = value + + def update(self, currentTime, player): + """Update enemy position and handle attacks""" + if not self.isActive or self.health <= 0: + return + + # Update position based on patrol behavior + if self.movingRight: + self.xPos += self.movementSpeed + if self.xPos >= self.patrolEnd: + self.movingRight = False + else: + self.xPos -= self.movementSpeed + if self.xPos <= self.patrolStart: + self.movingRight = True + + # Check for attack opportunity + if self.can_attack(currentTime, player): + self.attack(currentTime, player) + + def can_attack(self, currentTime, player): + """Check if enemy can attack player""" + # Must have cooled down from last attack + if currentTime - self.lastAttackTime < self.attackCooldown: + return False + + # Don't attack if player is jumping + if player.isJumping: + return False + + # Check if player is in range and on same side we're facing + distance = abs(player.xPos - self.xPos) + tolerance = 0.5 # Same tolerance as we used for the grave + + if distance <= (self.attackRange + tolerance): + # Only attack if we're facing the right way + playerOnRight = player.xPos > self.xPos + return playerOnRight == self.movingRight + + return False + + def attack(self, currentTime, player): + """Perform attack on player""" + self.lastAttackTime = currentTime + # Play attack sound + attackSound = f"{self.enemyType}_attack" + if attackSound in self.sounds: + self.sounds[attackSound].play() + # Deal damage to player + player.set_health(player.get_health() - self.damage) + speak(f"The {self.enemyType} hits you!") + + def take_damage(self, amount): + """Handle enemy taking damage""" + self.health -= amount + if self.health <= 0: + self.die() + + def die(self): + """Handle enemy death""" + self.isActive = False + if self.channel: + obj_stop(self.channel) + self.channel = None diff --git a/src/level.py b/src/level.py index dcad680..2a3544f 100644 --- a/src/level.py +++ b/src/level.py @@ -1,76 +1,101 @@ +import pygame from libstormgames import * -from src.player import Player +from src.enemy import Enemy from src.object import Object +from src.player import Player class Level: def __init__(self, levelData, sounds): self.sounds = sounds self.objects = [] + self.enemies = [] self.player = Player(levelData["player_start"]["x"], levelData["player_start"]["y"]) - # Load objects from level data + # Load objects and enemies from level data for obj in levelData["objects"]: - gameObject = Object( - obj["x"], - obj["y"], - obj["sound"], - isStatic=obj.get("static", True), - isCollectible=obj.get("collectible", False), - isHazard=obj.get("hazard", False) - ) - self.objects.append(gameObject) + # 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 an enemy + if "enemy_type" in obj: + enemy = Enemy( + xPos, + obj["y"], + obj["enemy_type"], + self.sounds, + health=obj.get("health", 5), + damage=obj.get("damage", 1), + attack_range=obj.get("attack_range", 1), + movement_range=obj.get("movement_range", 5) + ) + 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) + ) + self.objects.append(gameObject) def update_audio(self): - # Update all object sounds based on player position + currentTime = pygame.time.get_ticks() + + # Update regular objects for obj in self.objects: if not obj.isActive: continue - # For non-static objects like goblins, ensure continuous sound if not obj.isStatic: - # If channel doesn't exist or sound stopped playing, restart it if obj.channel is None or not obj.channel.get_busy(): obj.channel = obj_play(self.sounds, obj.soundName, self.player.xPos, obj.xPos) else: - # Original logic for static objects if obj.channel is None: obj.channel = obj_play(self.sounds, obj.soundName, self.player.xPos, obj.xPos) - # Update position-based audio for all objects if obj.channel is not None: obj.channel = obj_update(obj.channel, 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) + + def handle_combat(self, currentTime): + """Handle combat interactions between player and enemies""" + attackRange = self.player.get_attack_range(currentTime) + if attackRange: + for enemy in self.enemies: + if enemy.isActive and enemy.xPos >= attackRange[0] and enemy.xPos <= attackRange[1]: + self.sounds[self.player.currentWeapon.hitSound].play() + enemy.take_damage(self.player.currentWeapon.damage) def handle_collisions(self): for obj in self.objects: if not obj.isActive: continue - # Single-tile collision detection (more precise) - collision_threshold = 0.5 # Half a tile width from center - distance = abs(self.player.xPos - obj.xPos) - - if distance <= collision_threshold: - if obj.isCollectible: - # Coins must be collected while jumping - if self.player.isJumping: + if obj.is_in_range(self.player.xPos): + if obj.isCollectible and self.player.isJumping: + currentPos = round(self.player.xPos) + if currentPos not in obj.collectedPositions: self.sounds[f'get_{obj.soundName}'].play() speak(f"Collected {obj.soundName}") - obj.isActive = False - obj_stop(obj.channel) + obj.collect_at_position(currentPos) self.player.collectedItems.append(obj.soundName) - elif obj.isHazard: - # Only affect player if they're not jumping (for ground-based hazards) - if not self.player.isJumping: - if obj.soundName == "grave": - self.sounds['grave'].play() - speak("You fell into a pit!") - elif obj.soundName == "goblin": - self.sounds['goblin'].play() - speak("A goblin got you!") - else: # Other hazards - self.sounds[obj.soundName].play() - speak(f"A {obj.soundName} got you!") - - # Apply knockback for any hazard hit - knockback = 2 - self.player.xPos = self.player.xPos - knockback if self.player.facingRight else self.player.xPos + knockback + elif obj.isHazard and not self.player.isJumping: + self.sounds[obj.soundName].play() + speak("You fell in an open grave!") + self.player.set_health(0) diff --git a/src/object.py b/src/object.py index 264be71..7a8e91e 100644 --- a/src/object.py +++ b/src/object.py @@ -1,6 +1,9 @@ +from libstormgames import * + class Object: - def __init__(self, xPos, yPos, soundName, isStatic=True, isCollectible=False, isHazard=False): - self.xPos = xPos + def __init__(self, x, yPos, soundName, isStatic=True, isCollectible=False, isHazard=False): + # x can be either a single position or a range [start, end] + self.xRange = [x, x] if isinstance(x, (int, float)) else x self.yPos = yPos self.soundName = soundName self.isStatic = isStatic @@ -8,3 +11,25 @@ class Object: self.isHazard = isHazard self.channel = None # For tracking the sound channel self.isActive = True + # For collectibles in a range, track which positions have been collected + self.collectedPositions = set() + + @property + def xPos(self): + """Return center of range for audio positioning""" + return (self.xRange[0] + self.xRange[1]) / 2 + + def is_in_range(self, x): + """Check if a given x position is within this object's range""" + tolerance = 0.5 # Half a unit tolerance + return (self.xRange[0] - tolerance) <= x <= (self.xRange[1] + tolerance) + + def collect_at_position(self, x): + """Mark a specific position in the range as collected""" + self.collectedPositions.add(x) + # If all positions in range are collected, deactivate the object and stop sound + if len(self.collectedPositions) == int(self.xRange[1] - self.xRange[0] + 1): + self.isActive = False + if self.channel: + obj_stop(self.channel) + self.channel = None diff --git a/src/player.py b/src/player.py index 115d391..4302ebe 100644 --- a/src/player.py +++ b/src/player.py @@ -1,5 +1,8 @@ +from src.weapon import Weapon + class Player: def __init__(self, xPos, yPos): + # Movement attributes self.xPos = xPos self.yPos = yPos self.moveSpeed = 0.05 @@ -7,7 +10,76 @@ class Player: self.jumpStartTime = 0 self.isJumping = False self.facingRight = True - self.collectedItems = [] - # Track distance for footsteps + + # Stats and tracking + self._health = 10 + self._lives = 1 self.distanceSinceLastStep = 0 - self.stepDistance = 0.5 # Play a step sound every 0.5 units moved + self.stepDistance = 0.5 + + # Inventory system + self.inventory = [] + self.collectedItems = [] + self.coins = 0 + + # Combat related attributes + self.weapons = [] + self.currentWeapon = None + self.isAttacking = False + self.lastAttackTime = 0 + + # Initialize starting weapon (rusty shovel) + self.add_weapon(Weapon( + name="rusty_shovel", + damage=2, + range=2, + attackSound="player_shovel_attack", + hitSound="player_shovel_hit", + attackDuration=200 # 200ms attack duration + )) + + def get_health(self): + """Get current health""" + return self._health + + def set_health(self, value): + """Set health and handle death if needed""" + self._health = max(0, value) # Health can't go below 0 + if self._health == 0: + self._lives -= 1 + if self._lives > 0: + self._health = 10 # Reset health if we still have lives + + def get_lives(self): + """Get remaining lives""" + return self._lives + + def add_weapon(self, weapon): + """Add a new weapon to inventory and equip if first weapon""" + self.weapons.append(weapon) + if len(self.weapons) == 1: # If this is our first weapon, equip it + self.equip_weapon(weapon) + + def equip_weapon(self, weapon): + """Equip a specific weapon""" + if weapon in self.weapons: + self.currentWeapon = weapon + + def add_item(self, item): + """Add an item to inventory""" + self.inventory.append(item) + self.collectedItems.append(item) + + 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 + + def get_attack_range(self, currentTime): + """Get the current attack's range based on position and facing direction""" + if not self.currentWeapon or not self.currentWeapon.is_attack_active(currentTime): + return None + return self.currentWeapon.get_attack_range(self.xPos, self.facingRight) diff --git a/src/weapon.py b/src/weapon.py new file mode 100644 index 0000000..1461d62 --- /dev/null +++ b/src/weapon.py @@ -0,0 +1,33 @@ +class Weapon: + def __init__(self, name, damage, range, attackSound, hitSound, cooldown=500, attackDuration=200): + self.name = name + self.damage = damage + self.range = range # Range in tiles + self.attackSound = attackSound + self.hitSound = hitSound + self.cooldown = cooldown # Milliseconds between attacks + self.attackDuration = attackDuration # Milliseconds the attack is active + self.lastAttackTime = 0 + + def can_attack(self, currentTime): + """Check if enough time has passed since last attack""" + return currentTime - self.lastAttackTime >= self.cooldown + + def get_attack_range(self, playerPos, facingRight): + """Calculate the area that this attack would hit""" + if facingRight: + return (playerPos, playerPos + self.range) + else: + return (playerPos - self.range, playerPos) + + def start_attack(self, currentTime): + """Begin an attack and return True if allowed""" + if self.can_attack(currentTime): + self.lastAttackTime = currentTime + return True + return False + + def is_attack_active(self, currentTime): + """Check if the attack is still in its active frames""" + timeSinceAttack = currentTime - self.lastAttackTime + return 0 <= timeSinceAttack <= self.attackDuration diff --git a/wicked_quest.py b/wicked_quest.py index ce59446..9902f83 100644 --- a/wicked_quest.py +++ b/wicked_quest.py @@ -27,34 +27,36 @@ class WickedQuest: def handle_input(self): keys = pygame.key.get_pressed() player = self.currentLevel.player + currentTime = pygame.time.get_ticks() # Calculate current speed based on jumping state currentSpeed = player.moveSpeed * 1.5 if player.isJumping else player.moveSpeed # Track movement distance for this frame - movement = 0 + movementDistance = 0 # Horizontal movement if keys[pygame.K_a]: # Left - movement = currentSpeed + movementDistance = currentSpeed player.xPos -= currentSpeed player.facingRight = False elif keys[pygame.K_d]: # Right - movement = currentSpeed + movementDistance = currentSpeed player.xPos += currentSpeed player.facingRight = True + # Handle attack with either CTRL key + if (keys[pygame.K_LCTRL] or keys[pygame.K_RCTRL]) and player.start_attack(currentTime): + self.sounds[player.currentWeapon.attackSound].play() + # Play footstep sounds if moving and not jumping - if movement > 0 and not player.isJumping: - player.distanceSinceLastStep += movement + if movementDistance > 0 and not player.isJumping: + player.distanceSinceLastStep += movementDistance if player.distanceSinceLastStep >= player.stepDistance: self.sounds['footstep'].play() player.distanceSinceLastStep = 0 # Handle jumping - currentTime = pygame.time.get_ticks() - - # Start jump if keys[pygame.K_w] and not player.isJumping: player.isJumping = True player.jumpStartTime = currentTime @@ -69,7 +71,9 @@ class WickedQuest: def game_loop(self): clock = pygame.time.Clock() - while True: + while self.currentLevel.player.get_health() > 0 and self.currentLevel.player.get_lives() > 0: + currentTime = pygame.time.get_ticks() + if check_for_exit(): return @@ -79,13 +83,21 @@ class WickedQuest: self.currentLevel.update_audio() self.currentLevel.handle_collisions() + # Handle combat interactions + self.currentLevel.handle_combat(currentTime) + clock.tick(60) # 60 FPS + + # Player died or ran out of lives + speak("Game Over") def run(self): while True: - choice = game_menu(self.sounds, "play", "instructions", "learn_sounds", "credits", "donate") + choice = game_menu(self.sounds, "play", "instructions", "learn_sounds", "credits", "donate", "exit") - if choice == "play": + if choice == "exit": + exit_game() + elif choice == "play": if self.load_level(1): self.game_loop()