diff --git a/assets/items/book.png b/assets/items/book.png new file mode 100644 index 0000000..a431d9a Binary files /dev/null and b/assets/items/book.png differ diff --git a/assets/sounds/bgm_creepy.wav b/assets/sounds/bgm_creepy.wav new file mode 100644 index 0000000..5de22a2 Binary files /dev/null and b/assets/sounds/bgm_creepy.wav differ diff --git a/assets/sounds/bgm_normal.wav b/assets/sounds/bgm_normal.wav new file mode 100644 index 0000000..cfebc4c Binary files /dev/null and b/assets/sounds/bgm_normal.wav differ diff --git a/assets/sounds/buy_fail.wav b/assets/sounds/buy_fail.wav new file mode 100644 index 0000000..db1f3a7 Binary files /dev/null and b/assets/sounds/buy_fail.wav differ diff --git a/assets/sounds/buy_success.wav b/assets/sounds/buy_success.wav new file mode 100644 index 0000000..8529e48 Binary files /dev/null and b/assets/sounds/buy_success.wav differ diff --git a/assets/sounds/monster_healed.wav b/assets/sounds/monster_healed.wav new file mode 100644 index 0000000..2d76c52 Binary files /dev/null and b/assets/sounds/monster_healed.wav differ diff --git a/assets/sounds/monster_hit.wav b/assets/sounds/monster_hit.wav new file mode 100644 index 0000000..523f838 Binary files /dev/null and b/assets/sounds/monster_hit.wav differ diff --git a/assets/sounds/monster_transform.wav b/assets/sounds/monster_transform.wav new file mode 100644 index 0000000..5c59770 Binary files /dev/null and b/assets/sounds/monster_transform.wav differ diff --git a/assets/sounds/tower_shoot.wav b/assets/sounds/tower_shoot.wav new file mode 100644 index 0000000..b9e2a8f Binary files /dev/null and b/assets/sounds/tower_shoot.wav differ diff --git a/src/create_game_sounds.py b/src/create_game_sounds.py new file mode 100644 index 0000000..62dd89f --- /dev/null +++ b/src/create_game_sounds.py @@ -0,0 +1,38 @@ +import wave, struct, math, os, random + +""" +run once to get sounds generated - KI helped here to do this! +""" + +SOUND_DIR = "../assets/sounds" + +def save_wav(filename, data): + if not os.path.exists(SOUND_DIR): + os.makedirs(SOUND_DIR) + + path = os.path.join(SOUND_DIR, filename) + + with wave.open(path, 'w') as f: + f.setnchannels(1) + f.setsampwidth(2) + f.setframerate(44100) + for s in data: + # Den Soundwert in das richtige Format (16-bit PCM) umwandeln + f.writeframesraw(struct.pack(' 0 else -1.0, 0.1)) +save_wav("monster_hit.wav", gen(0.1, lambda t, i: random.uniform(-1, 1), 0.3)) +save_wav("buy_success.wav", gen(0.3, lambda t, i: math.sin(2*math.pi*(660 if t<0.1 else 880)*t))) +save_wav("buy_fail.wav", gen(0.4, lambda t, i: math.sin(2*math.pi*120*t) * (1+math.sin(40*t)))) +save_wav("monster_healed.wav", gen(0.8, lambda t, i: math.sin(2*math.pi*(440+800*t**2)*t))) +save_wav("monster_transform.wav", gen(1.2, lambda t, i: math.sin(2*math.pi*(200-150*t)*t) * random.uniform(0.5, 1.0))) + +print("Fertig! Schau jetzt in deinem Hauptverzeichnis unter assets/sounds nach.") \ No newline at end of file diff --git a/src/ects.py b/src/ects.py index e746c12..ea9319d 100644 --- a/src/ects.py +++ b/src/ects.py @@ -2,6 +2,7 @@ import pygame from src.settings import START_ECTS_VALUE, ECTS_UPDATE_INTERVAL + class ECTS: def __init__(self): # total amount of ECTS the player has at the beginning @@ -24,3 +25,9 @@ class ECTS: # beer increases amount of how much ECTS are earned per second def upgrade_rate(self, extra_ects): self.ects_per_second += extra_ects + + def is_over_balance(self, amount): + return self.ects_balance >= amount + + def decrease_balance(self, amount): + self.ects_balance -= amount diff --git a/src/endscreen.py b/src/endscreen.py index 4a4571b..2eebf88 100644 --- a/src/endscreen.py +++ b/src/endscreen.py @@ -8,6 +8,8 @@ from settings import (COLOR_ENDSCREEN_BACKGROUND, COLOR_BUTTON, FONT_ENDSCREEN) +from src.highscore import Highscore + class Endscreen: def __init__(self, screen, player_name, score_ects, time_survived, won): self.screen = screen @@ -52,6 +54,8 @@ class Endscreen: self.button_hover_color ) + self.highscore = Highscore() + def run(self): # get events events = pygame.event.get() @@ -113,6 +117,9 @@ class Endscreen: restart_pressed = self.button_restart.draw(self.screen) menu_pressed = self.button_menu.draw(self.screen) + # draw highscore table + self.highscore.draw(self.screen, 800, 300) + # check button click events if restart_pressed: return "game" diff --git a/src/game.py b/src/game.py index 4425c6e..77228c1 100644 --- a/src/game.py +++ b/src/game.py @@ -4,8 +4,6 @@ Controls: - WASD / arrow keys: move Knoll around the map - Space: switch between good/normal and bad/angry sprite - Esc / Q: quit - -The demo also places a few bad/angry actors as monsters in the rooms. """ from __future__ import annotations @@ -19,15 +17,11 @@ from src.actors import Actor from src.map import Map, color_void, screen_h, screen_w, tile_size from src.settings import DEMO_ACTOR_HEIGHT, FPS, PLAYER_SPEED from src.startscreen import Startscreen - - -# Keep the actor close to the map scale. The map tiles are 30x30, so 2 tiles -# high makes the character readable without covering whole rooms/walls. +from src.sounds import SoundManager def tile_center(column: int, row: int) -> tuple[int, int]: """Return the pixel center of a map tile.""" - return column * tile_size + tile_size // 2, row * tile_size + tile_size // 2 @@ -39,25 +33,26 @@ class Game: self.font = pygame.font.SysFont("monospace", 22, bold=True) self.running = True + self.active_monster = None + + # --- SOUND & SYSTEMS --- + self.sound_manager = SoundManager() self.startscreen = Startscreen(self.screen) + self.state = "startscreen" self.player_name: str = "" self.map = Map() - # resolve the assets from the folder assets_dir = Path(__file__).resolve().parent.parent / "assets" actor_sides = Actor.load_sprite_sides(assets_dir, target_height=DEMO_ACTOR_HEIGHT) if not actor_sides: - raise RuntimeError("No normal_*/angry_* actor sprite pairs found in assets/") + raise RuntimeError(f"Keine Sprites in {assets_dir} gefunden!") - # Use the first pair for the player. With current assets this is Knoll. self.player = Actor.from_sides(actor_sides[0], self.find_start_position()) def run(self, max_frames: Optional[int] = None) -> None: - """Run the demo. ``max_frames`` is only used for automated smoke tests.""" - frame_count = 0 while self.running and (max_frames is None or frame_count < max_frames): dt = min(self.clock.tick(FPS) / 1000.0, 0.05) @@ -69,12 +64,13 @@ class Game: elif next_state == "game": self.player_name = self.startscreen.player_name self.state = "game" + self.sound_manager.play_bgm("normal") + elif self.state == "game": self.handle_events() self.update(dt) self.draw() - pygame.display.flip() frame_count += 1 @@ -89,8 +85,16 @@ class Game: self.running = False elif event.key == pygame.K_SPACE: self.player.toggle_side() + self.sound_manager.play_sfx("monster_transform") def update(self, dt: float) -> None: + if self.player.is_bad: + self.active_monster = self.player + self.sound_manager.play_bgm("creepy") + else: + self.active_monster = None + self.sound_manager.play_bgm("normal") + movement = self.get_player_movement(dt) self.player.move_by(movement) @@ -106,7 +110,7 @@ class Game: - (keys[pygame.K_LEFT] or keys[pygame.K_a]), (keys[pygame.K_DOWN] or keys[pygame.K_s]) - (keys[pygame.K_UP] or keys[pygame.K_w]), - ) + ) if movement.length_squared() == 0: return movement @@ -119,33 +123,23 @@ class Game: return movement.normalize() * PLAYER_SPEED * dt def is_position_walkable(self, position: pygame.Vector2) -> bool: - """Check if the actor's feet are standing on a walkable map tile.""" - column = int(position.x // tile_size) row = int(position.y // tile_size) return self.map.is_accessible(column, row) def find_start_position(self) -> tuple[int, int]: - """Find the first S tile and place the actor's feet near its center.""" - for row_index, row in enumerate(self.map.map): column_index = row.find("S") if column_index != -1: - # The S tile is at the very top of the current map, so place the - # actor one tile lower to keep the sprite visible on screen. return ( column_index * tile_size + tile_size // 2, (row_index + 1) * tile_size + tile_size // 2, ) - return tile_size, tile_size def draw(self) -> None: self.screen.fill(color_void) self.map.draw_map(self.screen, self.font) - - # Draw by Y position so actors lower on the map appear in front. for actor in sorted([self.player], key=lambda item: item.position.y): actor.draw(self.screen) - diff --git a/src/highscore.py b/src/highscore.py index 71f443b..625b8e9 100644 --- a/src/highscore.py +++ b/src/highscore.py @@ -1,9 +1,14 @@ import json +import pygame +from src.settings import HIGHSCORE_COLOR_FONT, HIGHSCORE_COLOR_TABLE class Highscore: def __init__(self): # fix path to json file with highscores - self.path = "./highscore.json" + self.path = "src/highscore.json" + + self.font_title = pygame.font.SysFont("Arial", 28, bold=True) + self.font_score = pygame.font.SysFont("Arial", 18, bold=True) # check if file already exists, otherwise create it try: @@ -89,4 +94,43 @@ class Highscore: formatted_scores.append(line) - return formatted_scores \ No newline at end of file + return formatted_scores + + def draw(self, screen, x, y): + # draw box background + box_rect = pygame.Rect(x, y, 500, 300) + pygame.draw.rect(screen, HIGHSCORE_COLOR_TABLE, box_rect, border_radius=12) + pygame.draw.rect(screen, HIGHSCORE_COLOR_TABLE, box_rect, border_radius=12) + + # title + title = self.font_title.render( + "ECTS Leaderboard", + True, + HIGHSCORE_COLOR_FONT + ) + screen.blit(title, (x + 20, y + 20)) + + # Get highscore lines + highscore_lines = self.get_top_scores_formatted() + + + if len(highscore_lines) == 0: + no_scores_text = self.font_score.render( + "No students ranked yet.", + True, + HIGHSCORE_COLOR_FONT + ) + screen.blit(no_scores_text, (x + 20, y + 70)) + return + + # Draw scores + score_y = y + 70 + + for line in highscore_lines: + score_text = self.font_score.render( + line, + True, + HIGHSCORE_COLOR_FONT + ) + screen.blit(score_text, (x + 20, score_y)) + score_y += 35 diff --git a/src/item.py b/src/item.py index e0cec9e..09be957 100644 --- a/src/item.py +++ b/src/item.py @@ -11,6 +11,7 @@ class Item: self.x = x self.y = y self.item_type = item_type + self.cost = random.randint(5, 10) # collision rect for player (2 times item size for width and height) self.rect = pygame.Rect(self.x, self.y, ITEM_SIZE, ITEM_SIZE) @@ -50,6 +51,9 @@ class Item: pygame.draw.rect(screen, color, draw_rect) pygame.draw.rect(screen, COLOR_HP_BAR_BG, draw_rect, 2) + def get_cost(self): + return self.cost + # try 50 times to find a free position to spawn item def _random_position(room_rect, blocked_rects): @@ -130,4 +134,4 @@ class ItemManager: # clear items when changing room def clear(self): self.items = [] - self.last_spawn_time = pygame.time.get_ticks() \ No newline at end of file + self.last_spawn_time = pygame.time.get_ticks() diff --git a/src/settings.py b/src/settings.py index 83378df..62b3e56 100644 --- a/src/settings.py +++ b/src/settings.py @@ -1,7 +1,7 @@ from typing import Mapping, Literal, Tuple -from src.map import tile_size -from tower import projectile_size +# Fenstergröße und Tile Größe (Felder) +tile_size = 30 # --- DESK --- DESK_WIDTH = 70 @@ -127,7 +127,6 @@ HUD_Y_DOOR = 20 HUD_Y_ZOMBIE = 50 HUD_Y_ECTS = 85 - # --- MAP --- # Fenstergröße und Tile Größe (Felder) tile_size = 30 @@ -142,4 +141,25 @@ color_door = (40, 90, 110) # dark blue color_void = (10, 10, 15) # a little more black color_text = (220, 180, 210) # rose color_border = (55, 35, 70) -color_start = (255, 0, 0)# dark violet \ No newline at end of file +color_start = (255, 0, 0)# dark violet +======= +# --- HIGHSCORE --- +HIGHSCORE_COLOR_FONT = (0, 0, 0) +HIGHSCORE_COLOR_TABLE = (150, 255, 0) + + +# --- SOUND --- +SOUND_DIR = "assets/sounds" + +SFX_TOWER_SHOOT = f"{SOUND_DIR}/tower_shoot.wav" +SFX_MONSTER_HIT = f"{SOUND_DIR}/monster_hit.wav" +SFX_BUY_SUCCESS = f"{SOUND_DIR}/buy_success.wav" +SFX_BUY_FAIL = f"{SOUND_DIR}/buy_fail.wav" +SFX_MONSTER_HEALED = f"{SOUND_DIR}/monster_healed.wav" +SFX_MONSTER_TRANSFORM = f"{SOUND_DIR}/monster_transform.wav" + +BGM_NORMAL = f"{SOUND_DIR}/bgm_normal.wav" +BGM_CREEPY = f"{SOUND_DIR}/bgm_creepy.wav" + +VOLUME_SFX = 0.5 +VOLUME_BGM = 0.3 diff --git a/src/shop.py b/src/shop.py new file mode 100644 index 0000000..87361ae --- /dev/null +++ b/src/shop.py @@ -0,0 +1,34 @@ +from src.item import Item + + +class Shop: + def __init__(self, ects, buyable_items, sound_manager): + self.ects = ects # give the ects object to the shop + self.buyable_items: list[Item] = buyable_items + # save soundManager to use it in buy() + self.sound_manager = sound_manager + + def buy(self, item_name): + item_to_buy = self.get_item(item_name) + if item_to_buy is None: + return False + + cost = item_to_buy.get_cost() + + # is_over_balance() means "has enough balance" + if not self.ects.is_over_balance(cost): + self.sound_manager.play_sfx("buy_fail") + return False + + self.ects.decrease_balance(cost) + self.sound_manager.play_sfx("buy_success") + return True + + def get_item(self, item_name): + for item in self.buyable_items: + if item.item_type == item_name: + return item + return None + + def get_items(self): + return self.buyable_items diff --git a/src/sounds.py b/src/sounds.py new file mode 100644 index 0000000..915473b --- /dev/null +++ b/src/sounds.py @@ -0,0 +1,51 @@ +import pygame +from src.settings import ( + SFX_TOWER_SHOOT, SFX_MONSTER_HIT, SFX_BUY_SUCCESS, + SFX_BUY_FAIL, SFX_MONSTER_HEALED, SFX_MONSTER_TRANSFORM, + BGM_NORMAL, BGM_CREEPY, VOLUME_SFX, VOLUME_BGM +) + +class SoundManager: + def __init__(self): + pygame.mixer.init() + self.effects = {} + self.current_bgm = None + + self._load_sfx("tower_shoot", SFX_TOWER_SHOOT) + self._load_sfx("monster_hit", SFX_MONSTER_HIT) + self._load_sfx("buy_success", SFX_BUY_SUCCESS) + self._load_sfx("buy_fail", SFX_BUY_FAIL) + self._load_sfx("monster_healed", SFX_MONSTER_HEALED) + self._load_sfx("monster_transform", SFX_MONSTER_TRANSFORM) + + # set volume via settings param + for sound in self.effects.values(): + if sound: + sound.set_volume(VOLUME_SFX) + + def _load_sfx(self, name, path): + try: + self.effects[name] = pygame.mixer.Sound(path) + except pygame.error: + print(f"Could not find sound: {path}") + self.effects[name] = None + + def play_sfx(self, name): + sound = self.effects.get(name) + if isinstance(sound, pygame.mixer.Sound): + sound.play() + + # set BackGroundMusic dependent if monster is active or tutor is active + def play_bgm(self, mode): + if self.current_bgm == mode: + return + + path = BGM_NORMAL if mode == "normal" else BGM_CREEPY + + try: + pygame.mixer.music.load(path) + pygame.mixer.music.set_volume(VOLUME_BGM) + pygame.mixer.music.play(-1) + self.current_bgm = mode + except pygame.error: + print(f"Could not find sound: {path}") diff --git a/src/startscreen.py b/src/startscreen.py index fddb731..6002407 100644 --- a/src/startscreen.py +++ b/src/startscreen.py @@ -8,6 +8,7 @@ from src.settings import ( COLOR_STARTSCREEN_FONT_LABEL, ) from src.text_input import TextInput +from src.highscore import Highscore class Startscreen: def __init__(self, screen): @@ -42,6 +43,8 @@ class Startscreen: 20, 310) + self.highscore = Highscore() + def run(self): # set background color self.screen.fill(COLOR_STARTSCREEN_BACKGROUND) @@ -70,6 +73,9 @@ class Startscreen: label_input_error = self.font_label.render("Student not found. Please enter valid name/id.", True, (255, 0, 0)) + # draw highscore table + self.highscore.draw(self.screen, 560, 90) + # get all events events = pygame.event.get() diff --git a/src/tower.py b/src/tower.py index ca79da4..dd1b114 100644 --- a/src/tower.py +++ b/src/tower.py @@ -1,5 +1,3 @@ -from turtledemo.lindenmayer import draw - import pygame import random import math @@ -13,7 +11,9 @@ respawn_delay = 10000 # ms bis neues Feld spawnt class Tower: - def __init__(self) -> None: + def __init__(self, sound_manager) -> None: + self.sound_manager = sound_manager + # Feld und Tower -> None wenn nicht aktiv # Position interagierbares Feld self.field_rect: pygame.Rect = None @@ -24,7 +24,7 @@ class Tower: # Zeitpunkt, wann der Tower gebaut wurde self.tower_start: int = None # Zeitpunkt, wann Tower despawnt ist - self.despawn_timer: int = None + self.despawn_time: int = None # Zeitpunkt des letzten Schusses self.last_shot: int = 0 @@ -65,9 +65,11 @@ class Tower: return False # prüft alle Bedingungen gleichzeitig if not (self.field_rect.colliderect(player_rect) and keys[pygame.K_e] and ects.balance >= TOWER_COST): + self.sound_manager.play_sfx("buy_fail") return False # ECTS abziehen und Tower bauen + self.sound_manager.play_sfx("buy_success") ects.balance -= TOWER_COST self.tower_rect = self.field_rect self.field_rect = None @@ -100,6 +102,7 @@ class Tower: "vel_y": (dy / distance) * PROJECTILE_SPEED, }) + self.sound_manager.play_sfx("tower_shoot") self.last_shot = now # Bewegung der Projektile und Kollision mit Monster prüfen @@ -110,6 +113,7 @@ class Tower: # Projektil trifft auf Monster -> Schaden wird addiert und Projektil entfernt if monster_rect and pygame.Rect(int(p["x"]), int(p["y"]), projectile_size, projectile_size).colliderect(monster_rect): total_damage += PROJECTILE_DMG + self.sound_manager.play_sfx("monster_hit") self.projectiles.remove(p) # wenn der Tower abgelaufen ist, soll er despawnen und ein Timer starten @@ -165,8 +169,7 @@ class Tower: else: # Fallback - gelbes Rechteck pygame.draw.rect(surface, COLOR_PROJECTILE, - pygame.Rect(int(p["x"]) - cam_x, int(p["y"]) - cam_y, projectile_size, projectile_size)) - + pygame.Rect(draw_x, draw_y, projectile_size, projectile_size)) @@ -175,22 +178,4 @@ class Tower: self.field_rect = None self.tower_rect = None self.projectiles = [] - self.despawn_timer = None - - - - - - - - - - - - - - - - - - + self.despawn_time = None \ No newline at end of file