feat: implement monster class
This commit is contained in:
+386
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user