diff --git a/src/endscreen.py b/src/endscreen.py index 2eebf88..075cda4 100644 --- a/src/endscreen.py +++ b/src/endscreen.py @@ -1,6 +1,6 @@ import pygame -from button import Button -from settings import (COLOR_ENDSCREEN_BACKGROUND, +from src.button import Button +from src.settings import (COLOR_ENDSCREEN_BACKGROUND, COLOR_ENDSCREEN_TEXT, COLOR_ENDSCREEN_WIN, COLOR_ENDSCREEN_LOSE, diff --git a/src/game.py b/src/game.py index 77228c1..0c1d52b 100644 --- a/src/game.py +++ b/src/game.py @@ -1,8 +1,8 @@ """Small playable demo that uses ``src.map.Map`` and the actor system. Controls: -- WASD / arrow keys: move Knoll around the map -- Space: switch between good/normal and bad/angry sprite +- WASD / arrow keys: move Niklas around the map +- Space: toggle player state - Esc / Q: quit """ @@ -14,10 +14,27 @@ from typing import Optional import pygame from src.actors import Actor +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.settings import DEMO_ACTOR_HEIGHT, FPS, PLAYER_SPEED +from src.monster import MonsterManager +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 def tile_center(column: int, row: int) -> tuple[int, int]: @@ -31,26 +48,45 @@ 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.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] = {} assets_dir = Path(__file__).resolve().parent.parent / "assets" - actor_sides = Actor.load_sprite_sides(assets_dir, target_height=DEMO_ACTOR_HEIGHT) + player_asset = Actor.load_sprite_asset( + assets_dir / "niklas_sprites", + target_height=DEMO_ACTOR_HEIGHT, + ) + self.player = Actor( + name="Niklas", + good_asset=player_asset, + bad_asset=player_asset, + position=self.find_start_position(), + ) - if not actor_sides: - raise RuntimeError(f"Keine Sprites in {assets_dir} gefunden!") - - self.player = Actor.from_sides(actor_sides[0], self.find_start_position()) + monster_sides = Actor.load_sprite_sides(assets_dir, target_height=DEMO_ACTOR_HEIGHT) + self.monsters = MonsterManager(monster_sides, self.map) + self.monsters.spawn_initial(self.get_monster_spawn_blockers()) def run(self, max_frames: Optional[int] = None) -> None: frame_count = 0 @@ -63,6 +99,7 @@ class Game: self.running = False elif next_state == "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") @@ -70,6 +107,14 @@ class Game: self.handle_events() self.update(dt) self.draw() + elif self.state == "endscreen": + next_state = self.endscreen.run() if self.endscreen is not None else "startscreen" + if next_state == "quit": + self.running = False + elif next_state == "startscreen": + self.state = "startscreen" + elif next_state == "game": + self.restart_game() pygame.display.flip() frame_count += 1 @@ -86,6 +131,8 @@ class Game: 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: @@ -102,6 +149,17 @@ class Game: self.player.hit_wall() 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, + self.is_position_walkable, + self.get_monster_spawn_blockers(), + ) + self.update_tower() + self.check_end_conditions() def get_player_movement(self, dt: float) -> pygame.Vector2: keys = pygame.key.get_pressed() @@ -127,6 +185,195 @@ class Game: row = int(position.y // tile_size) return self.map.is_accessible(column, row) + def is_player_at_desk(self) -> bool: + """Return True while the player's hitbox touches a desk in the current room.""" + + player_rect = self.get_actor_rect(self.player) + room_rect = self.map.get_current_room(player_rect) + if room_rect is None: + return False + + return any(desk.check_collision(player_rect) for desk in self.map.get_desks_for_room(room_rect)) + + def update_tower(self) -> None: + """Spawn a tower when enough ECTS are available and let it attack the zombie.""" + + player_rect = self.get_actor_rect(self.player) + room_rect = self.map.get_current_room(player_rect) + monster = self.monsters.monsters[0] if self.monsters.monsters else None + if monster is None or not monster.is_alive: + self.tower.clear() + return + + damage = self.tower.update( + room_rect, + self.get_tower_spawn_blockers(), + self.ects, + monster.rect, + ) + if monster is not None and damage > 0: + monster.take_damage(damage) + 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) + + def show_endscreen(self, won: bool) -> None: + self.endscreen = Endscreen( + self.screen, + self.player_name, + self.ects.ects_balance, + self.get_elapsed_seconds(), + won, + ) + self.state = "endscreen" + + 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 = {} + self.player.set_position(self.find_start_position()) + self.monsters.clear() + self.monsters.spawn_initial(self.get_monster_spawn_blockers()) + self.game_start_time = pygame.time.get_ticks() + self.endscreen = None + self.state = "game" + + def handle_room_table_collision(self) -> None: + """Close/draw the room door and wake monsters when player hits a desk.""" + + player_rect = self.get_actor_rect(self.player) + room_rect = self.map.get_current_room(player_rect) + if room_rect is None: + return + + for desk in self.map.get_desks_for_room(room_rect): + if desk.check_collision(player_rect): + door = self.get_or_create_door_for_room(room_rect) + if door.current_hp > 0: + door.is_closed = True + self.active_door = door + self.monsters.activate_for_room(room_rect, door) + return + + def get_or_create_door_for_room(self, room_rect: pygame.Rect) -> Door: + 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] + + def create_door_for_room(self, room_rect: pygame.Rect) -> Door: + """Create a Door object centered on the map's D tiles for this room.""" + + door_tiles: list[pygame.Rect] = [] + for row_index, row in enumerate(self.map.map): + for column_index, tile in enumerate(row): + if tile != "D": + continue + + tile_rect = pygame.Rect( + column_index * tile_size, + row_index * tile_size, + tile_size, + tile_size, + ) + if ( + room_rect.left <= tile_rect.centerx <= room_rect.right + and room_rect.bottom <= tile_rect.centery <= room_rect.bottom + 2 * tile_size + ): + door_tiles.append(tile_rect) + + if not door_tiles: + return Door(room_rect.centerx - DOOR_WIDTH // 2, room_rect.bottom, DOOR_HP) + + door_area = door_tiles[0].copy() + for tile_rect in door_tiles[1:]: + door_area.union_ip(tile_rect) + + return Door( + door_area.centerx - DOOR_WIDTH // 2, + door_area.centery - DOOR_HEIGHT // 2, + DOOR_HP, + ) + + def get_monster_spawn_blockers(self) -> list[pygame.Rect]: + """Rects that monsters should not spawn on.""" + + blockers = [self.get_actor_rect(self.player)] + for desk_list in self.map.desks.values(): + blockers.extend(desk.rect for desk in desk_list) + return blockers + + def get_tower_spawn_blockers(self) -> list[pygame.Rect]: + """Rects that towers should not spawn on.""" + + blockers = self.get_monster_spawn_blockers() + if self.active_door is not None: + 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]: for row_index, row in enumerate(self.map.map): column_index = row.find("S") @@ -137,9 +384,31 @@ class Game: ) return tile_size, tile_size + def get_elapsed_seconds(self) -> int: + if self.game_start_time == 0: + return 0 + return (pygame.time.get_ticks() - self.game_start_time) // 1000 + def draw(self) -> None: self.screen.fill(color_void) self.map.draw_map(self.screen, self.font) - for actor in sorted([self.player], key=lambda item: item.position.y): + 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. + actors = [self.player, *self.monsters.actors] + for actor in sorted(actors, key=lambda item: item.position.y): actor.draw(self.screen) + + self.hud.draw( + self.screen, + self.player_name, + self.active_door, + self.monsters.monsters[0] if self.monsters.monsters else None, + self.ects, + self.get_elapsed_seconds(), + ) diff --git a/src/hud.py b/src/hud.py index e9c4e11..a252501 100644 --- a/src/hud.py +++ b/src/hud.py @@ -3,20 +3,20 @@ from src.settings import ( HUD_FONT, HUD_FONT_SIZE, HUD_FONT_BOLD, COLOR_HUD_TEXT, COLOR_HP_BAR_GREEN, COLOR_HP_BAR_RED, COLOR_HP_BAR_BG, HUD_BAR_WIDTH, HUD_BAR_HEIGHT, - HUD_X_OFFSET, HUD_BAR_X, HUD_Y_DOOR, HUD_Y_ZOMBIE, HUD_Y_ECTS ) -# draw status bar (background bar and the colored hp bar) + def _draw_status_bar(screen, x, y, current, max_val, color): # bg bar is always the same pygame.draw.rect(screen, COLOR_HP_BAR_BG, (x, y, HUD_BAR_WIDTH, HUD_BAR_HEIGHT)) # hp bar if max_val > 0: - hp_width = (current / max_val) * HUD_BAR_WIDTH - pygame.draw.rect((screen, color), (x, y, hp_width, HUD_BAR_HEIGHT)) + current = max(0, min(current, max_val)) + hp_width = int((current / max_val) * HUD_BAR_WIDTH) + pygame.draw.rect(screen, color, (x, y, hp_width, HUD_BAR_HEIGHT)) + -# time formatter for MM:SS def _format_time(total_seconds): mins = total_seconds // 60 secs = total_seconds % 60 @@ -26,22 +26,52 @@ def _format_time(total_seconds): class HUD: def __init__(self): self.font = pygame.font.SysFont(HUD_FONT, HUD_FONT_SIZE, bold=HUD_FONT_BOLD) + self.small_font = pygame.font.SysFont(HUD_FONT, max(16, HUD_FONT_SIZE - 4), bold=False) + + def draw(self, screen, player_name, door, zombie, ects, total_time): + """Draw the game HUD at the bottom of the screen.""" + + panel_height = 86 + panel_rect = pygame.Rect(0, screen.get_height() - panel_height, screen.get_width(), panel_height) + pygame.draw.rect(screen, (10, 10, 15), panel_rect) + pygame.draw.rect(screen, (70, 70, 80), panel_rect, 2) + + top = panel_rect.top + 12 + label_y = top + bar_y = top + 34 + + # --- PLAYER --- + player_text = f"PLAYER: {player_name or 'Student'}" + screen.blit(self.font.render(player_text, True, COLOR_HUD_TEXT), (20, label_y)) - def draw(self, screen, door, zombie, ects, total_time): # --- DOOR HP --- - _draw_status_bar(screen, HUD_BAR_X, HUD_Y_DOOR, door.current_hp, COLOR_HP_BAR_GREEN) - # -5 px, small off-set to align the text better with hp bar - screen.blit(self.font.render("DOOR HP ", True, COLOR_HUD_TEXT), (HUD_X_OFFSET, HUD_Y_DOOR - 5)) + door_x = 330 + screen.blit(self.font.render("DOOR HP", True, COLOR_HUD_TEXT), (door_x, label_y)) + if door is not None: + _draw_status_bar(screen, door_x, bar_y, door.current_hp, door.max_hp, COLOR_HP_BAR_GREEN) + hp_text = f"{door.current_hp}/{door.max_hp}" + else: + _draw_status_bar(screen, door_x, bar_y, 0, 1, COLOR_HP_BAR_GREEN) + hp_text = "--/--" + screen.blit(self.small_font.render(hp_text, True, COLOR_HUD_TEXT), (door_x + HUD_BAR_WIDTH + 10, bar_y - 2)) # --- ZOMBIE HP --- - if zombie: - _draw_status_bar(screen, HUD_BAR_X, HUD_Y_ZOMBIE, zombie.current_hp, zombie.max_hp, COLOR_HP_BAR_RED) - screen.blit(self.font.render("Zombie HP:", True, COLOR_HUD_TEXT), (HUD_X_OFFSET, HUD_Y_ZOMBIE - 5)) + zombie_x = 620 + screen.blit(self.font.render("ZOMBIE HP", True, COLOR_HUD_TEXT), (zombie_x, label_y)) + if zombie is not None: + _draw_status_bar(screen, zombie_x, bar_y, zombie.current_hp, zombie.max_hp, COLOR_HP_BAR_RED) + zombie_text = f"{zombie.current_hp}/{zombie.max_hp}" + else: + _draw_status_bar(screen, zombie_x, bar_y, 0, 1, COLOR_HP_BAR_RED) + zombie_text = "--/--" + screen.blit(self.small_font.render(zombie_text, True, COLOR_HUD_TEXT), (zombie_x + HUD_BAR_WIDTH + 10, bar_y - 2)) # --- ECTS --- - ects_info = f"ECTS: {ects.ects_balance} ({ects.ects_per_second}/s)" - screen.blit(self.font.render(ects_info, True, COLOR_HUD_TEXT), (HUD_X_OFFSET, HUD_Y_ECTS)) + ects_x = 910 + ects_text = f"ECTS: {ects.ects_balance} ({ects.ects_per_second}/s)" + screen.blit(self.font.render(ects_text, True, COLOR_HUD_TEXT), (ects_x, label_y)) # --- TIME --- time_str = _format_time(total_time) - screen.blit(self.font.render(time_str, True, COLOR_HUD_TEXT), (screen.get_width() - 150, HUD_Y_DOOR - 5)) + time_surface = self.font.render(time_str, True, COLOR_HUD_TEXT) + screen.blit(time_surface, (screen.get_width() - time_surface.get_width() - 20, label_y)) 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/monster.py b/src/monster.py new file mode 100644 index 0000000..66779b0 --- /dev/null +++ b/src/monster.py @@ -0,0 +1,386 @@ +"""Monster spawning and hallway movement behaviour.""" + +from __future__ import annotations + +import random +from collections import deque +from typing import Callable, Iterable, Optional + +import pygame + +from src.actors import Actor, ActorSpriteSides +from src.door import Door +from src.settings import ( + MONSTER_ATTACK_INTERVAL, + MONSTER_DOOR_DAMAGE, + MONSTER_HP, + MONSTER_MAX_COUNT, + MONSTER_SPAWN_INTERVAL, + MONSTER_SPEED, + tile_size, +) + +WalkableCheck = Callable[[pygame.Vector2], bool] +GridPosition = tuple[int, int] +Position = tuple[int, int] + + +class Monster: + """A spawned monster backed by a good/bad actor sprite pair.""" + + def __init__( + self, + actor: Actor, + *, + max_hp: int = MONSTER_HP, + speed: float = MONSTER_SPEED, + ) -> None: + self.actor = actor + self.actor.become_good() + self.max_hp = max_hp + self.current_hp = max_hp + self.speed = speed + self.last_attack_time = -MONSTER_ATTACK_INTERVAL + + @property + def is_alive(self) -> bool: + return self.current_hp > 0 + + @property + def position(self) -> pygame.Vector2: + return self.actor.position + + @property + def rect(self) -> pygame.Rect: + frame = self.actor.get_frame() + return frame.get_rect(midbottom=self._int_position(self.actor.position)) + + def take_damage(self, amount: int) -> None: + self.current_hp = max(0, self.current_hp - amount) + + def sleep(self) -> None: + """Stay friendly/idling until the player triggers the room.""" + + self.actor.become_good() + self.actor.stop_walking() + + def wake_up(self) -> None: + """Switch to the angry sprite when the player triggers a room.""" + + self.actor.become_bad() + + def update( + self, + dt: float, + target_position: pygame.Vector2, + can_move_to: WalkableCheck, + ) -> None: + """Move toward the target, but only where ``can_move_to`` allows.""" + + if not self.is_alive: + self.actor.stop_walking() + return + + movement = pygame.Vector2(target_position) - self.actor.position + if movement.length_squared() == 0: + self.actor.stop_walking() + self.actor.update_animation(dt) + return + + self.face_position(target_position) + + distance = movement.length() + max_distance = self.speed * dt + if distance > max_distance: + movement = movement.normalize() * max_distance + + self.actor.move_by(movement) + if not can_move_to(self.actor.position): + self.actor.hit_wall() + + self.actor.update_animation(dt) + + def attack_door(self, door: Door) -> None: + """Damage the active door while standing outside it.""" + + self.wake_up() + self.actor.stop_walking() + + now = pygame.time.get_ticks() + if now - self.last_attack_time >= MONSTER_ATTACK_INTERVAL: + door.take_damage(MONSTER_DOOR_DAMAGE) + self.last_attack_time = now + + def face_position(self, target_position: pygame.Vector2) -> None: + movement = pygame.Vector2(target_position) - self.actor.position + if movement.length_squared() == 0: + return + + if abs(movement.x) > abs(movement.y): + self.actor.direction = "right" if movement.x > 0 else "left" + else: + self.actor.direction = "down" if movement.y > 0 else "up" + + def draw(self, surface: pygame.Surface) -> pygame.Rect: + return self.actor.draw(surface) + + @staticmethod + def _int_position(position: pygame.Vector2) -> tuple[int, int]: + return round(position.x), round(position.y) + + +class MonsterManager: + """Loads monster sprites, spawns monsters on H hallway tiles, and updates them.""" + + def __init__( + self, + sprite_sides: Iterable[ActorSpriteSides], + game_map, + *, + max_monsters: int = MONSTER_MAX_COUNT, + spawn_interval_ms: int = MONSTER_SPAWN_INTERVAL, + ) -> None: + self.sprite_sides = list(sprite_sides) + self.map = game_map + self.max_monsters = max_monsters + self.spawn_interval_ms = spawn_interval_ms + self.monsters: list[Monster] = [] + self.last_spawn_time = pygame.time.get_ticks() + self.active_target_tile: Optional[GridPosition] = None + self.active_door: Optional[Door] = None + + if not self.sprite_sides: + raise RuntimeError("No normal_*/angry_* monster sprite pairs found in assets/") + + @property + def actors(self) -> list[Actor]: + return [monster.actor for monster in self.monsters] + + def spawn_initial(self, blocked_rects: Optional[list[pygame.Rect]] = None) -> None: + """Fill the game with the starting monsters on H hallway tiles.""" + + blockers = list(blocked_rects or []) + while len(self.monsters) < self.max_monsters: + monster = self.spawn_monster(blockers) + if monster is None: + break + blockers.append(monster.rect) + + self.last_spawn_time = pygame.time.get_ticks() + + def activate_for_room(self, room_rect: pygame.Rect, door: Door) -> None: + """Wake monsters and send them to the hallway side of this room's door.""" + + target_tile = self.get_hall_tile_at_room_door(room_rect) + if target_tile is None: + return + + self.active_target_tile = target_tile + self.active_door = door + for monster in self.monsters: + monster.wake_up() + + def update( + self, + dt: float, + player_position: pygame.Vector2, + is_position_walkable: WalkableCheck, + blocked_rects: Optional[list[pygame.Rect]] = None, + ) -> None: + self.monsters = [monster for monster in self.monsters if monster.is_alive] + + if self.active_target_tile is None: + for monster in self.monsters: + monster.sleep() + else: + for monster in self.monsters: + waypoint = self.get_next_waypoint(monster, self.active_target_tile) + if self.position_to_tile(monster.position) == self.active_target_tile: + if self.active_door is not None and self.active_door.is_closed: + monster.attack_door(self.active_door) + else: + monster.actor.stop_walking() + else: + monster.wake_up() + monster.update(dt, waypoint, self.is_hall_position) + + now = pygame.time.get_ticks() + if len(self.monsters) < self.max_monsters and now - self.last_spawn_time >= self.spawn_interval_ms: + monster = self.spawn_monster(blocked_rects) + if monster is not None and self.active_target_tile is not None: + monster.wake_up() + self.last_spawn_time = now + + def get_hall_tile_at_room_door(self, room_rect: pygame.Rect) -> Optional[GridPosition]: + """Find the H tile directly outside the D-door of ``room_rect``.""" + + door_tiles: list[GridPosition] = [] + for row_index, row in enumerate(self.map.map): + for column_index, tile in enumerate(row): + if tile != "D": + continue + + tile_center_x = column_index * tile_size + tile_size // 2 + tile_center_y = row_index * tile_size + tile_size // 2 + if ( + room_rect.left <= tile_center_x <= room_rect.right + and room_rect.bottom <= tile_center_y <= room_rect.bottom + 2 * tile_size + ): + door_tiles.append((column_index, row_index)) + + hall_tiles: list[GridPosition] = [] + for door_tile in door_tiles: + for neighbor in self.get_neighbors(door_tile, allowed_tiles={"H"}): + hall_tiles.append(neighbor) + + if not hall_tiles: + return None + + room_center_tile = (room_rect.centerx // tile_size, room_rect.centery // tile_size) + return min( + hall_tiles, + key=lambda tile: abs(tile[0] - room_center_tile[0]) + abs(tile[1] - room_center_tile[1]), + ) + + def get_next_waypoint(self, monster: Monster, target_tile: GridPosition) -> pygame.Vector2: + """Return the next H-tile center on a BFS path to the target tile.""" + + start_tile = self.position_to_tile(monster.position) + if start_tile == target_tile: + return self.tile_center(target_tile) + + path = self.find_hall_path(start_tile, target_tile) + if len(path) >= 2: + return self.tile_center(path[1]) + if len(path) == 1: + return self.tile_center(path[0]) + + return monster.position.copy() + + def find_hall_path(self, start: GridPosition, target: GridPosition) -> list[GridPosition]: + """Find a path using only H hallway tiles. D doors are never traversed.""" + + if self.map.get_tile(*start) != "H" or self.map.get_tile(*target) != "H": + return [] + + queue: deque[GridPosition] = deque([start]) + came_from: dict[GridPosition, Optional[GridPosition]] = {start: None} + + while queue: + current = queue.popleft() + if current == target: + break + + for neighbor in self.get_neighbors(current, allowed_tiles={"H"}): + if neighbor in came_from: + continue + came_from[neighbor] = current + queue.append(neighbor) + + if target not in came_from: + return [] + + path = [target] + while path[-1] != start: + previous = came_from[path[-1]] + if previous is None: + break + path.append(previous) + + path.reverse() + return path + + def get_neighbors( + self, + tile: GridPosition, + *, + allowed_tiles: set[str], + ) -> list[GridPosition]: + column, row = tile + neighbors: list[GridPosition] = [] + for next_column, next_row in ( + (column + 1, row), + (column - 1, row), + (column, row + 1), + (column, row - 1), + ): + if self.map.get_tile(next_column, next_row) in allowed_tiles: + neighbors.append((next_column, next_row)) + return neighbors + + def is_hall_position(self, position: pygame.Vector2) -> bool: + """Monsters may move only on H hallway tiles, never through D doors.""" + + return self.map.get_tile(*self.position_to_tile(position)) == "H" + + def spawn_monster(self, blocked_rects: Optional[list[pygame.Rect]] = None) -> Optional[Monster]: + """Spawn one monster on a random H hallway tile if a free tile exists.""" + + if len(self.monsters) >= self.max_monsters: + return None + + blockers = list(blocked_rects or []) + blockers.extend(monster.rect for monster in self.monsters) + + sprite_sides = list(self.sprite_sides) + random.shuffle(sprite_sides) + + for sides in sprite_sides: + position = self._random_position_on_hall_tile(sides.bad.frames["down"][1], blockers) + if position is None: + continue + + actor = Actor.from_sides(sides, position) + monster = Monster(actor) + self.monsters.append(monster) + return monster + + return None + + def draw(self, surface: pygame.Surface) -> list[pygame.Rect]: + return [monster.draw(surface) for monster in sorted(self.monsters, key=lambda item: item.position.y)] + + def clear(self) -> None: + self.monsters = [] + self.last_spawn_time = pygame.time.get_ticks() + self.active_target_tile = None + self.active_door = None + + def _random_position_on_hall_tile( + self, + frame: pygame.Surface, + blocked_rects: list[pygame.Rect], + ) -> Optional[Position]: + hall_tiles = self.get_all_hall_tiles() + random.shuffle(hall_tiles) + + for column, row in hall_tiles: + position = self.tile_center((column, row)) + rect = frame.get_rect(midbottom=(round(position.x), round(position.y))) + + if any(rect.colliderect(blocker) for blocker in blocked_rects): + continue + + return round(position.x), round(position.y) + + return None + + def get_all_hall_tiles(self) -> list[GridPosition]: + hall_tiles: list[GridPosition] = [] + for row_index, row in enumerate(self.map.map): + for column_index, tile in enumerate(row): + if tile == "H": + hall_tiles.append((column_index, row_index)) + return hall_tiles + + @staticmethod + def position_to_tile(position: pygame.Vector2) -> GridPosition: + return int(position.x // tile_size), int(position.y // tile_size) + + @staticmethod + def tile_center(tile: GridPosition) -> pygame.Vector2: + column, row = tile + return pygame.Vector2( + column * tile_size + tile_size // 2, + row * tile_size + tile_size // 2, + ) diff --git a/src/settings.py b/src/settings.py index 2c3cac2..ba20b10 100644 --- a/src/settings.py +++ b/src/settings.py @@ -11,17 +11,16 @@ DESK_HEIGHT = 40 DOOR_WIDTH = 20 DOOR_HEIGHT = 100 DOOR_BORDER_WIDTH = 3 +DOOR_HP = 100 COLOR_DOOR_BEIGE = (245, 245, 220) COLOR_DOOR_BORDER = (100, 90, 80) - # --- PLAYER --- # Players Constants / Settings # Valid movement/animation directions. Direction = Literal["down", "left", "right", "up"] - # --- SPRITE --- # Sprite files are grouped by direction. # Example: "down" uses sprite_01.png, sprite_02.png, sprite_03.png. @@ -36,26 +35,22 @@ DIRECTION_FRAMES: Mapping[Direction, Tuple[int, int, int]] = { DEFAULT_SPRITE_HEIGHT: int = 138 DEFAULT_FRAME_TIME: float = 0.16 - # --- ECTS --- -START_ECTS_VALUE = 1 # how many ECTS are generated at the beginning -ECTS_UPDATE_INTERVAL = 1000 # time in ms (1000 = 1 sec) +START_ECTS_VALUE = 1 # how many ECTS are generated at the beginning +ECTS_UPDATE_INTERVAL = 1000 # time in ms (1000 = 1 sec) BEER_BOTTLE_ECTS_VALUE = 2 BEER_COST = 50 - # --- BUTTONS --- COLOR_BUTTON = (0, 255, 0) COLOR_BUTTON_HOVER = (200, 255, 0) - # --- STARTSCREEN --- COLOR_STARTSCREEN_BACKGROUND = (0, 0, 0) COLOR_STARTSCREEN_FONT_TITLE = (0, 255, 0) COLOR_STARTSCREEN_FONT_LABEL = (0, 255, 255) - # --- ENDSCREEN --- COLOR_ENDSCREEN_BACKGROUND = (0, 0, 0) COLOR_ENDSCREEN_TEXT = (255, 255, 255) @@ -63,30 +58,22 @@ COLOR_ENDSCREEN_WIN = (0, 255, 0) COLOR_ENDSCREEN_LOSE = (255, 0, 0) FONT_ENDSCREEN = "Arial" - # --- COFFEE --- COFFEE_COST = 30 COFFEE_HEAL_VALUE = 30 - # --- TOWER --- -TOWER_COST = 150 -PROJECTILE_DMG = 15 +TOWER_COST = 10 +PROJECTILE_DMG = 7 PROJECTILE_SPEED = 7 COLOR_PROJECTILE = (200, 200, 0) -TOWER_TIMER = 30000 # how long the tower stays and attack before it disappears -tower_size = 30 -projectile_size = 8 -fire_interval = 1000 # ms -respawn_delay = 10000 # ms - +TOWER_TIMER = 30000 # how long the tower stays and attack before it disappears # --- ITEM SPAWN TIMER --- -ITEM_SPAWN_INTERVAL = 5000 # ms until a new item spawns -ITEM_SHOPPING_TIMER = 10000 # how long you have time to buy items before they despawn +ITEM_SPAWN_INTERVAL = 5000 # ms until a new item spawns +ITEM_SHOPPING_TIMER = 10000 # how long you have time to buy items before they despawn ITEM_SIZE = 30 - # --- ZOMBIE --- ZOMBIE_EASY_HP = 30 ZOMBIE_EASY_DMG = 3 @@ -97,6 +84,13 @@ ZOMBIE_MEDIUM_DMG = 7 ZOMBIE_HARD_HP = 120 ZOMBIE_HARD_DMG = 12 +# --- MONSTERS --- +MONSTER_HP = ZOMBIE_MEDIUM_HP +MONSTER_MAX_COUNT = 1 +MONSTER_SPAWN_INTERVAL = 8000 # ms until a replacement monster spawns +MONSTER_SPEED = 90.0 +MONSTER_DOOR_DAMAGE = ZOMBIE_MEDIUM_DMG +MONSTER_ATTACK_INTERVAL = 1000 # ms between door attacks # --- HUD --- HUD_FONT = "Arial" @@ -118,8 +112,8 @@ PLAYER_SPEED = 180.0 DEMO_ACTOR_HEIGHT = tile_size * 3 FPS = 60 -HUD_X_OFFSET = 20 # left startpoint for texts -HUD_BAR_X = 150 # left startpoint for hp bars +HUD_X_OFFSET = 20 # left startpoint for texts +HUD_BAR_X = 150 # left startpoint for hp bars HUD_Y_DOOR = 20 HUD_Y_ZOMBIE = 50 diff --git a/src/shop.py b/src/shop.py index 87361ae..b9df79f 100644 --- a/src/shop.py +++ b/src/shop.py @@ -10,6 +10,9 @@ class Shop: 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 diff --git a/src/tower.py b/src/tower.py index dd1b114..1e2f9b3 100644 --- a/src/tower.py +++ b/src/tower.py @@ -1,181 +1,174 @@ -import pygame -import random import math -from src.settings import TOWER_COST, PROJECTILE_DMG, PROJECTILE_SPEED, COLOR_PROJECTILE, TOWER_TIMER, projectile_size, tower_size, fire_interval, respawn_delay +import random +from typing import Optional -tower_size = 30 # Größe Tower Feld in Pixel -projectile_size = 8 # Größe Projektil in Pixel -fire_interval = 1000 # ms zwischen Schüssen -respawn_delay = 10000 # ms bis neues Feld spawnt +import pygame +from src.settings import ( + COLOR_PROJECTILE, + PROJECTILE_DMG, + PROJECTILE_SPEED, + TOWER_COST, + TOWER_TIMER, +) + + +tower_size = 30 +projectile_size = 20 +fire_interval = 1000 # ms zwischen Schüssen class Tower: - def __init__(self, sound_manager) -> None: + def __init__(self, sound_manager=None) -> None: self.sound_manager = sound_manager - - # Feld und Tower -> None wenn nicht aktiv - # Position interagierbares Feld - self.field_rect: pygame.Rect = None - #Position des gebauten Tower - self.tower_rect: pygame.Rect = None - # Lister aller aktiven Projektile - dict mit Position und Geschwindigkeit - self.projectiles: list[dict] = [] - # Zeitpunkt, wann der Tower gebaut wurde - self.tower_start: int = None - # Zeitpunkt, wann Tower despawnt ist - self.despawn_time: int = None - # Zeitpunkt des letzten Schusses + self.tower_rect: Optional[pygame.Rect] = None + 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 - # Sprite laden für Projektil 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 as e: - print(f"Error loading projectile sprite: {e}") + 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 - # soll interaktives Tower Feld an zufälliger freier Position im Raum spawnen - def field_spawn(self, room_rect: pygame.Rect, blocked_rects: list[pygame.Rect]): - # versucht bis zu 50 mal, eine nicht belegte Stelle im Raum zu finden - for i in range(50): - x = random.randint(room_rect.left + tower_size, room_rect.right - tower_size * 2) - y = random.randint(room_rect.top + tower_size, room_rect.bottom - tower_size * 2) - rect = pygame.Rect(x, y, tower_size, tower_size) + def try_spawn(self, room_rect: Optional[pygame.Rect], blocked_rects: list[pygame.Rect], ects) -> bool: + """Spawn and pay for one tower when the player has enough ECTS.""" - # freie Position finden - nicht auf dem Tisch oder blockierten Rects - if not any(rect.colliderect(b) for b in blocked_rects): - self.field_rect = rect - return + if room_rect is None or self.tower_rect is not None: + return False + if ects.ects_balance < TOWER_COST: + return False - # Baut Tower wenn Spieler E drückt, auf Feld steht und genug ects hat - def try_build(self, player_rect: pygame.Rect, keys: pygame.key.ScancodeWrapper, ects): + rect = self._random_free_rect(room_rect, blocked_rects) + if rect is None: + self._play_sfx("buy_fail") + return False - # bricht ab wenn kein Feld vorhanden ist oder ein Tower bereits aktiv ist - if not self.field_rect or self.tower_rect: - 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.decrease_balance(TOWER_COST) + self.tower_rect = rect + self.tower_start = pygame.time.get_ticks() + self.projectiles = [] + self._play_sfx("buy_success") + return True - # 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 - self.tower_start = pygame.time.get_ticks() - return True - - # ruft jeden Frame auf und gibt den Schaden auf Monster zurück - # verwaltet Schüsse, Projektile, Tower-Leben und Respawn - def update(self, player_rect: pygame.Rect, keys: pygame.key.ScancodeWrapper, ects, monster_rect: pygame.Rect, room_rect: pygame.Rect, blocked_rects: list[pygame.Rect]): + def update( + self, + room_rect: Optional[pygame.Rect], + blocked_rects: list[pygame.Rect], + ects, + monster_rect: Optional[pygame.Rect], + ) -> int: + """Update tower spawning/shooting and return damage dealt to the monster.""" now = pygame.time.get_ticks() total_damage = 0 - # versucht, den Tower zu bauen wenn der Spieler auf dem Feld steht - self.try_build(player_rect, keys, ects) + self.try_spawn(room_rect, blocked_rects, ects) - # schießen dann, wenn Tower aktiv und Monster vorhanden sind - 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: - #berechnet Richtungsvektor vom Tower zum Monster - 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 - - # fügt neues Projektil mit Startposition und Geschwindigkeit hinzu - 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.sound_manager.play_sfx("tower_shoot") + self._shoot_at(monster_rect) self.last_shot = now - # Bewegung der Projektile und Kollision mit Monster prüfen - for p in self.projectiles[:]: # [:] = Kopie der Liste, dass man sicher löschen kann - p["x"] += p["vel_x"] - p["y"] += p["vel_y"] + for projectile in self.projectiles[:]: + projectile["x"] += projectile["vel_x"] + projectile["y"] += projectile["vel_y"] - # 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): + projectile_rect = pygame.Rect( + int(projectile["x"]), + int(projectile["y"]), + projectile_size, + projectile_size, + ) + if monster_rect is not None and projectile_rect.colliderect(monster_rect): total_damage += PROJECTILE_DMG - self.sound_manager.play_sfx("monster_hit") - self.projectiles.remove(p) + self.projectiles.remove(projectile) + self._play_sfx("monster_hit") - # wenn der Tower abgelaufen ist, soll er despawnen und ein Timer starten - if self.tower_rect and self.tower_start and now - self.tower_start >= TOWER_TIMER: - self.tower_rect = None - self.despawn_time = now - self.projectiles = [] - - # ein neues Feld soll spawnen nach dem Respawn Delay - if not self.tower_rect and not self.field_rect and self.despawn_time: - if now - self.despawn_time >= respawn_delay: - self.field_spawn(room_rect, blocked_rects) - self.despawn_time = None + 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) -> 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, + tower_size, + tower_size, + ) - # Zeichnet Feld, Tower und Projektile auf dem Bildschirm - def draw(self, surface: pygame.Surface, font: pygame.font.Font, cam_x: int = 0, cam_y: int = 0): - # Feld zeichnen - goldenes Rechteck mit "T" als Hinweis - if self.field_rect: - r = pygame.Rect(self.field_rect.x - cam_x, self.field_rect.y - cam_y, tower_size, tower_size) - pygame.draw.rect(surface, (160,120,30), r) - pygame.draw.rect(surface, (220,180,60), r, 2) - surface.blit(font.render("T", True, (255, 255, 255)), (r.x + 12, r.y + 10)) + seconds_left = max(0, TOWER_TIMER - (pygame.time.get_ticks() - self.tower_start)) // 1000 - # Tower Zeichnen mit Sprite - als Fallback grünes Rechteck - if self.tower_rect and self.tower_start: - r = pygame.Rect(self.tower_rect.x - cam_x, self.tower_rect.y - cam_y, tower_size, tower_size) - - # berechnet die verbleibende Zeit in Sekunden - sec = max(0, TOWER_TIMER - (pygame.time.get_ticks() - self.tower_start)) // 1000 - - if self.tower_image: - # Sprite verfügbar -> zeichnen - surface.blit(self.tower_image, r) + if self.tower_image is not None: + surface.blit(self.tower_image, draw_rect) else: - # kein Sprite -> Fallback grünes Rechteck - pygame.draw.rect(surface, (40, 160, 80), r) - pygame.draw.rect(surface, (80, 220, 120), r, 2) + pygame.draw.rect(surface, (40, 160, 80), draw_rect) + pygame.draw.rect(surface, (80, 220, 120), draw_rect, 2) - # Restzeit der Tower über dem Sprite anzeigen (in Sekunden) - surface.blit(font.render(str(sec), True, (255, 255, 255)), (r.x + 8, r.y - 18)) + surface.blit(font.render(str(seconds_left), True, (255, 255, 255)), (draw_rect.x + 8, draw_rect.y - 18)) - # alle aktiven Projektile zeichnen als Prite oder Fallback als kleine Rechtecke - for p in self.projectiles: - draw_x = int(p["x"]) - cam_x - draw_y = int(p["y"]) - cam_y - - if self.projectile_image: - # zeichnet Buch Sprite - surface.blit(self.projectile_image, (draw_x, draw_y)) + for projectile in self.projectiles: + projectile_rect = pygame.Rect( + int(projectile["x"]) - cam_x, + int(projectile["y"]) - cam_y, + projectile_size, + projectile_size, + ) + if self.projectile_image is not None: + surface.blit(self.projectile_image, projectile_rect) else: - # Fallback - gelbes Rechteck - pygame.draw.rect(surface, COLOR_PROJECTILE, - pygame.Rect(draw_x, draw_y, projectile_size, projectile_size)) + pygame.draw.rect(surface, COLOR_PROJECTILE, projectile_rect) - - - # alles zurücksetzen, wenn der Raum verlassen wird def clear(self) -> None: - self.field_rect = None self.tower_rect = None self.projectiles = [] - self.despawn_time = None \ No newline at end of file + 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]: + for _ in range(50): + x = random.randint(room_rect.left + tower_size, room_rect.right - tower_size * 2) + y = random.randint(room_rect.top + tower_size, room_rect.bottom - tower_size * 2) + rect = pygame.Rect(x, y, tower_size, tower_size) + + if not any(rect.colliderect(blocked) for blocked in blocked_rects): + return rect + + return None