395 lines
13 KiB
Python
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,
|
|
)
|