Files
silent-fh/src/monster.py
T

395 lines
13 KiB
Python

"""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,
)