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/endscreen.py b/src/endscreen.py index e20f28e..075cda4 100644 --- a/src/endscreen.py +++ b/src/endscreen.py @@ -8,6 +8,8 @@ from src.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 6452618..0c1d52b 100644 --- a/src/game.py +++ b/src/game.py @@ -4,8 +4,6 @@ Controls: - WASD / arrow keys: move Niklas around the map - Space: toggle player state - Esc / Q: quit - -The demo also places a few bad/angry actors as monsters in the rooms. """ from __future__ import annotations @@ -20,20 +18,27 @@ from src.door import Door from src.ects import ECTS from src.endscreen import Endscreen from src.hud import HUD +from src.item import Item, ItemManager from src.map import Map, color_void, screen_h, screen_w, tile_size from src.monster import MonsterManager -from src.settings import DEMO_ACTOR_HEIGHT, DOOR_HEIGHT, DOOR_HP, DOOR_WIDTH, FPS, PLAYER_SPEED +from src.settings import ( + BEER_BOTTLE_ECTS_VALUE, + COFFEE_HEAL_VALUE, + DEMO_ACTOR_HEIGHT, + DOOR_HEIGHT, + DOOR_HP, + DOOR_WIDTH, + FPS, + PLAYER_SPEED, +) +from src.shop import Shop from src.startscreen import Startscreen +from src.sounds import SoundManager from src.tower import Tower -# 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. - - 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 @@ -43,22 +48,30 @@ class Game: self.screen = pygame.display.set_mode((screen_w, screen_h)) self.clock = pygame.time.Clock() self.font = pygame.font.SysFont("monospace", 22, bold=True) + self.item_font = pygame.font.SysFont("Arial", 16, bold=True) self.running = True + self.active_monster = None + self.buy_requested = False + + # --- SOUND & SYSTEMS --- + self.sound_manager = SoundManager() self.startscreen = Startscreen(self.screen) self.hud = HUD() self.ects = ECTS() - self.tower = Tower() + self.item_manager = ItemManager() + self.shop = Shop(self.ects, self.item_manager.items, self.sound_manager) + self.tower = Tower(self.sound_manager) self.state = "startscreen" self.player_name: str = "" self.game_start_time = 0 self.endscreen: Optional[Endscreen] = None + self.current_item_room_key: Optional[tuple[int, int, int, int]] = None self.map = Map() self.active_door: Optional[Door] = None self.doors_by_room: dict[tuple[int, int, int, int], Door] = {} - # Use Niklas as the player sprite. assets_dir = Path(__file__).resolve().parent.parent / "assets" player_asset = Actor.load_sprite_asset( assets_dir / "niklas_sprites", @@ -76,8 +89,6 @@ class Game: self.monsters.spawn_initial(self.get_monster_spawn_blockers()) 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) @@ -90,6 +101,8 @@ class Game: self.player_name = self.startscreen.player_name self.game_start_time = pygame.time.get_ticks() self.state = "game" + self.sound_manager.play_bgm("normal") + elif self.state == "game": self.handle_events() self.update(dt) @@ -117,8 +130,18 @@ class Game: self.running = False elif event.key == pygame.K_SPACE: self.player.toggle_side() + self.sound_manager.play_sfx("monster_transform") + elif event.key == pygame.K_e: + self.buy_requested = True 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) @@ -128,6 +151,7 @@ class Game: self.player.update_animation(dt) self.ects.update(self.is_player_at_desk()) self.handle_room_table_collision() + self.update_items() self.monsters.update( dt, self.player.position, @@ -144,7 +168,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 @@ -157,8 +181,6 @@ 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) @@ -194,6 +216,41 @@ class Game: if not monster.is_alive: self.tower.clear() + def update_items(self) -> None: + """Spawn room items and process one player purchase request.""" + + player_rect = self.get_actor_rect(self.player) + room_rect = self.map.get_current_room(player_rect) + room_key = self.get_room_key(room_rect) if room_rect is not None else None + + if room_key != self.current_item_room_key: + self.item_manager.clear() + self.current_item_room_key = room_key + + self.item_manager.update(room_rect, self.get_item_spawn_blockers()) + + if self.buy_requested: + self.try_buy_touching_item(player_rect) + self.buy_requested = False + + def try_buy_touching_item(self, player_rect: pygame.Rect) -> None: + item = self.item_manager.get_touching_item(player_rect) + if item is None: + return + + if self.shop.buy_item(item): + self.apply_item_effect(item) + self.item_manager.remove_item(item) + + def apply_item_effect(self, item: Item) -> None: + if item.item_type == "beer": + self.ects.upgrade_rate(BEER_BOTTLE_ECTS_VALUE) + elif item.item_type == "coffee" and self.active_door is not None: + self.active_door.current_hp = min( + self.active_door.max_hp, + self.active_door.current_hp + COFFEE_HEAL_VALUE, + ) + def check_end_conditions(self) -> None: if self.active_door is not None and self.active_door.current_hp <= 0: self.show_endscreen(won=False) @@ -210,6 +267,9 @@ class Game: def restart_game(self) -> None: self.ects = ECTS() + self.shop = Shop(self.ects, self.item_manager.items, self.sound_manager) + self.item_manager.clear() + self.current_item_room_key = None self.tower.clear() self.active_door = None self.doors_by_room = {} @@ -238,7 +298,7 @@ class Game: return def get_or_create_door_for_room(self, room_rect: pygame.Rect) -> Door: - key = (room_rect.x, room_rect.y, room_rect.width, room_rect.height) + key = self.get_room_key(room_rect) if key not in self.doors_by_room: self.doors_by_room[key] = self.create_door_for_room(room_rect) return self.doors_by_room[key] @@ -293,26 +353,35 @@ class Game: blockers.append(pygame.Rect(self.active_door.x, self.active_door.y, DOOR_WIDTH, DOOR_HEIGHT)) for monster in self.monsters.monsters: blockers.append(monster.rect) + blockers.extend(item.rect for item in self.item_manager.items) return blockers + def get_item_spawn_blockers(self) -> list[pygame.Rect]: + """Rects that room items should not spawn on.""" + + blockers = self.get_tower_spawn_blockers() + if self.tower.tower_rect is not None: + blockers.append(self.tower.tower_rect) + blockers.extend(item.rect for item in self.item_manager.items) + return blockers + + @staticmethod + def get_room_key(room_rect: pygame.Rect) -> tuple[int, int, int, int]: + return room_rect.x, room_rect.y, room_rect.width, room_rect.height + @staticmethod def get_actor_rect(actor: Actor) -> pygame.Rect: frame = actor.get_frame() return frame.get_rect(midbottom=(round(actor.position.x), round(actor.position.y))) 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 get_elapsed_seconds(self) -> int: @@ -327,6 +396,7 @@ class Game: if self.active_door is not None and self.active_door.is_closed: self.active_door.draw(self.screen) + self.item_manager.draw(self.screen, 0, 0, self.item_font) self.tower.draw(self.screen, self.font) # Draw by Y position so actors lower on the map appear in front. diff --git a/src/item.py b/src/item.py index 09be957..92965a7 100644 --- a/src/item.py +++ b/src/item.py @@ -1,17 +1,27 @@ import pygame import random from src.settings import ( - ITEM_SPAWN_INTERVAL, ITEM_SHOPPING_TIMER, ITEM_SIZE, - COLOR_HP_BAR_YELLOW, COLOR_HP_BAR_BG + BEER_COST, + COFFEE_COST, + ITEM_SPAWN_INTERVAL, + ITEM_SHOPPING_TIMER, + ITEM_SIZE, + COLOR_HP_BAR_YELLOW, + COLOR_HP_BAR_BG, ) +ITEM_COSTS = { + "beer": BEER_COST, + "coffee": COFFEE_COST, +} + class Item: def __init__(self, x, y, item_type): self.x = x self.y = y self.item_type = item_type - self.cost = random.randint(5, 10) + self.cost = ITEM_COSTS.get(item_type, 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) @@ -33,7 +43,7 @@ class Item: return pygame.time.get_ticks() - self.spawn_time >= ITEM_SHOPPING_TIMER # cam offset to convert world coordinates to screen coordinates - def draw(self, screen, cam_x, cam_y): + def draw(self, screen, cam_x, cam_y, font=None): draw_x = self.x - cam_x draw_y = self.y - cam_y @@ -51,6 +61,12 @@ class Item: pygame.draw.rect(screen, color, draw_rect) pygame.draw.rect(screen, COLOR_HP_BAR_BG, draw_rect, 2) + if font is not None: + cost_surface = font.render(f"{self.cost} ECTS", True, (255, 255, 255)) + prompt_surface = font.render("E", True, (255, 255, 255)) + screen.blit(cost_surface, (draw_x - 10, draw_y - cost_surface.get_height() - 2)) + screen.blit(prompt_surface, (draw_x + ITEM_SIZE // 2 - prompt_surface.get_width() // 2, draw_y + ITEM_SIZE + 2)) + def get_cost(self): return self.cost @@ -93,14 +109,7 @@ class ItemManager: def update(self, room_rect, blocked_rects): now = pygame.time.get_ticks() - remaining_items = [] - - for item in self.items: - if item.is_expired() == False: - remaining_items.append(item) - - # replace the old list with the new temp_list - self.items = remaining_items + self.items = [item for item in self.items if not item.is_expired()] # spawn logic for new items if room_rect is not None: @@ -116,9 +125,13 @@ class ItemManager: # check if player press "e" to buy def check_collect(self, player_rect, keys): if keys[pygame.K_e]: - for item in self.items: - if player_rect.colliderect(item.rect): - return item + return self.get_touching_item(player_rect) + return None + + def get_touching_item(self, player_rect): + for item in self.items: + if player_rect.colliderect(item.rect): + return item return None # delete item from list after buying it @@ -127,9 +140,9 @@ class ItemManager: self.items.remove(item) # draw all active items - def draw(self, screen, cam_x, cam_y): + def draw(self, screen, cam_x, cam_y, font=None): for item in self.items: - item.draw(screen, cam_x, cam_y) + item.draw(screen, cam_x, cam_y, font) # clear items when changing room def clear(self): diff --git a/src/map.py b/src/map.py index ac4ea39..eed245f 100644 --- a/src/map.py +++ b/src/map.py @@ -1,20 +1,6 @@ import pygame from src.desk import Desk -from src.settings import DESK_WIDTH, DESK_HEIGHT, tile_size - -# Fenstergröße -screen_w = 1900 -screen_h = 900 - -# Farben für die einzelnen Tiles -color_wall = (30, 20, 40) # black like -color_room = (75, 40, 55) # dark red-violet -color_hall = (35, 65, 45) # dark green -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 +from src.settings import DESK_WIDTH, DESK_HEIGHT, tile_size, screen_w, screen_h, color_wall, color_room, color_hall, color_door, color_void, color_text, color_border, color_start class Map: diff --git a/src/settings.py b/src/settings.py index 476b6d6..ba20b10 100644 --- a/src/settings.py +++ b/src/settings.py @@ -119,6 +119,40 @@ HUD_Y_DOOR = 20 HUD_Y_ZOMBIE = 50 HUD_Y_ECTS = 85 +# --- MAP --- +# Fenstergröße und Tile Größe (Felder) +tile_size = 30 +screen_w = 1900 +screen_h = 900 + +# Farben für die einzelnen Tiles +color_wall = (30, 20, 40) # black like +color_room = (75, 40, 55) # dark red-violet +color_hall = (35, 65, 45) # dark green +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 + + # --- 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 index f0a13eb..b9df79f 100644 --- a/src/shop.py +++ b/src/shop.py @@ -2,12 +2,17 @@ from src.item import Item class Shop: - def __init__(self, ects, buyable_items): + 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) + return self.buy_item(item_to_buy) + + def buy_item(self, item_to_buy): if item_to_buy is None: return False @@ -15,9 +20,11 @@ class Shop: # 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): 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/tower.py b/src/tower.py index d057819..1e2f9b3 100644 --- a/src/tower.py +++ b/src/tower.py @@ -4,7 +4,13 @@ from typing import Optional import pygame -from src.settings import TOWER_COST, PROJECTILE_DMG, PROJECTILE_SPEED, COLOR_PROJECTILE, TOWER_TIMER +from src.settings import ( + COLOR_PROJECTILE, + PROJECTILE_DMG, + PROJECTILE_SPEED, + TOWER_COST, + TOWER_TIMER, +) tower_size = 30 @@ -13,29 +19,32 @@ fire_interval = 1000 # ms zwischen Schüssen class Tower: - def __init__(self) -> None: + def __init__(self, sound_manager=None) -> None: + self.sound_manager = sound_manager self.tower_rect: Optional[pygame.Rect] = None - self.projectiles: list[dict] = [] + self.projectiles: list[dict[str, float]] = [] self.tower_start: Optional[int] = None self.last_shot: int = 0 - # Sprite laden für den Tower try: self.tower_image = pygame.image.load("assets/items/tower.png").convert_alpha() self.tower_image = pygame.transform.scale(self.tower_image, (tower_size, tower_size)) - except Exception as e: - print(f"Error loading tower sprite: {e}") + except Exception as error: + print(f"Error loading tower sprite: {error}") self.tower_image = None - # Projectile sprite: the tower throws books. try: self.projectile_image = pygame.image.load("assets/items/book.png").convert_alpha() - self.projectile_image = pygame.transform.scale(self.projectile_image, (projectile_size, projectile_size)) - except Exception: + self.projectile_image = pygame.transform.scale( + self.projectile_image, + (projectile_size, projectile_size), + ) + except Exception as error: + print(f"Error loading projectile sprite: {error}") self.projectile_image = None def try_spawn(self, room_rect: Optional[pygame.Rect], blocked_rects: list[pygame.Rect], ects) -> bool: - """Spawn and pay for one tower as soon as the player has enough ECTS.""" + """Spawn and pay for one tower when the player has enough ECTS.""" if room_rect is None or self.tower_rect is not None: return False @@ -44,12 +53,14 @@ class Tower: rect = self._random_free_rect(room_rect, blocked_rects) if rect is None: + self._play_sfx("buy_fail") return False ects.decrease_balance(TOWER_COST) self.tower_rect = rect self.tower_start = pygame.time.get_ticks() self.projectiles = [] + self._play_sfx("buy_success") return True def update( @@ -66,17 +77,9 @@ class Tower: self.try_spawn(room_rect, blocked_rects, ects) - if self.tower_rect and monster_rect: + if self.tower_rect is not None and monster_rect is not None: if now - self.last_shot >= fire_interval: - dx = monster_rect.centerx - self.tower_rect.centerx - dy = monster_rect.centery - self.tower_rect.centery - distance = math.sqrt(dx * dx + dy * dy) or 1 - self.projectiles.append({ - "x": float(self.tower_rect.centerx), - "y": float(self.tower_rect.centery), - "vel_x": (dx / distance) * PROJECTILE_SPEED, - "vel_y": (dy / distance) * PROJECTILE_SPEED, - }) + self._shoot_at(monster_rect) self.last_shot = now for projectile in self.projectiles[:]: @@ -89,17 +92,19 @@ class Tower: projectile_size, projectile_size, ) - if monster_rect and projectile_rect.colliderect(monster_rect): + if monster_rect is not None and projectile_rect.colliderect(monster_rect): total_damage += PROJECTILE_DMG self.projectiles.remove(projectile) + self._play_sfx("monster_hit") - if self.tower_rect and self.tower_start and now - self.tower_start >= TOWER_TIMER: - self.clear() + if self.tower_rect is not None and self.tower_start is not None: + if now - self.tower_start >= TOWER_TIMER: + self.clear() return total_damage - def draw(self, surface: pygame.Surface, font: pygame.font.Font, cam_x: int = 0, cam_y: int = 0): - if self.tower_rect and self.tower_start: + def draw(self, surface: pygame.Surface, font: pygame.font.Font, cam_x: int = 0, cam_y: int = 0) -> None: + if self.tower_rect is not None and self.tower_start is not None: draw_rect = pygame.Rect( self.tower_rect.x - cam_x, self.tower_rect.y - cam_y, @@ -107,15 +112,15 @@ class Tower: tower_size, ) - sec = max(0, TOWER_TIMER - (pygame.time.get_ticks() - self.tower_start)) // 1000 + seconds_left = max(0, TOWER_TIMER - (pygame.time.get_ticks() - self.tower_start)) // 1000 - if self.tower_image: + if self.tower_image is not None: surface.blit(self.tower_image, draw_rect) else: pygame.draw.rect(surface, (40, 160, 80), draw_rect) pygame.draw.rect(surface, (80, 220, 120), draw_rect, 2) - surface.blit(font.render(str(sec), True, (255, 255, 255)), (draw_rect.x + 8, draw_rect.y - 18)) + surface.blit(font.render(str(seconds_left), True, (255, 255, 255)), (draw_rect.x + 8, draw_rect.y - 18)) for projectile in self.projectiles: projectile_rect = pygame.Rect( @@ -124,7 +129,7 @@ class Tower: projectile_size, projectile_size, ) - if self.projectile_image: + if self.projectile_image is not None: surface.blit(self.projectile_image, projectile_rect) else: pygame.draw.rect(surface, COLOR_PROJECTILE, projectile_rect) @@ -133,6 +138,28 @@ class Tower: self.tower_rect = None self.projectiles = [] self.tower_start = None + self.last_shot = 0 + + def _shoot_at(self, monster_rect: pygame.Rect) -> None: + if self.tower_rect is None: + return + + dx = monster_rect.centerx - self.tower_rect.centerx + dy = monster_rect.centery - self.tower_rect.centery + distance = math.sqrt(dx * dx + dy * dy) or 1 + self.projectiles.append( + { + "x": float(self.tower_rect.centerx), + "y": float(self.tower_rect.centery), + "vel_x": (dx / distance) * PROJECTILE_SPEED, + "vel_y": (dy / distance) * PROJECTILE_SPEED, + } + ) + self._play_sfx("tower_shoot") + + def _play_sfx(self, name: str) -> None: + if self.sound_manager is not None: + self.sound_manager.play_sfx(name) @staticmethod def _random_free_rect(room_rect: pygame.Rect, blocked_rects: list[pygame.Rect]) -> Optional[pygame.Rect]: