From 10aa0cc70f32229d5f92d8e046a0b662558a8850 Mon Sep 17 00:00:00 2001 From: Niklas Haiden Date: Fri, 8 May 2026 19:01:23 +0200 Subject: [PATCH] feat: implement monster class --- src/monster.py | 386 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 src/monster.py 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, + )