"""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 reset_encounter(self) -> None: """Stop the room attack and put all active monsters back into normal state.""" self.active_target_tile = None self.active_door = None for monster in self.monsters: monster.sleep() 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, )