feat: implement game loop for monster interaction

This commit is contained in:
Niklas Haiden
2026-05-08 19:01:36 +02:00
parent 10aa0cc70f
commit 8515a843c3
+207 -11
View File
@@ -1,8 +1,8 @@
"""Small playable demo that uses ``src.map.Map`` and the actor system. """Small playable demo that uses ``src.map.Map`` and the actor system.
Controls: Controls:
- WASD / arrow keys: move Knoll around the map - WASD / arrow keys: move Niklas around the map
- Space: switch between good/normal and bad/angry sprite - Space: toggle player state
- Esc / Q: quit - Esc / Q: quit
The demo also places a few bad/angry actors as monsters in the rooms. The demo also places a few bad/angry actors as monsters in the rooms.
@@ -16,9 +16,15 @@ from typing import Optional
import pygame import pygame
from src.actors import Actor from src.actors import Actor
from src.door import Door
from src.ects import ECTS
from src.endscreen import Endscreen
from src.hud import HUD
from src.map import Map, color_void, screen_h, screen_w, tile_size from src.map import Map, color_void, screen_h, screen_w, tile_size
from src.settings import DEMO_ACTOR_HEIGHT, FPS, PLAYER_SPEED from src.monster import MonsterManager
from src.settings import DEMO_ACTOR_HEIGHT, DOOR_HEIGHT, DOOR_HP, DOOR_WIDTH, FPS, PLAYER_SPEED
from src.startscreen import Startscreen from src.startscreen import Startscreen
from src.tower import Tower
# Keep the actor close to the map scale. The map tiles are 30x30, so 2 tiles # Keep the actor close to the map scale. The map tiles are 30x30, so 2 tiles
@@ -40,20 +46,34 @@ class Game:
self.running = True self.running = True
self.startscreen = Startscreen(self.screen) self.startscreen = Startscreen(self.screen)
self.hud = HUD()
self.ects = ECTS()
self.tower = Tower()
self.state = "startscreen" self.state = "startscreen"
self.player_name: str = "" self.player_name: str = ""
self.game_start_time = 0
self.endscreen: Optional[Endscreen] = None
self.map = Map() self.map = Map()
self.active_door: Optional[Door] = None
self.doors_by_room: dict[tuple[int, int, int, int], Door] = {}
# resolve the assets from the folder # Use Niklas as the player sprite.
assets_dir = Path(__file__).resolve().parent.parent / "assets" assets_dir = Path(__file__).resolve().parent.parent / "assets"
actor_sides = Actor.load_sprite_sides(assets_dir, target_height=DEMO_ACTOR_HEIGHT) player_asset = Actor.load_sprite_asset(
assets_dir / "niklas_sprites",
target_height=DEMO_ACTOR_HEIGHT,
)
self.player = Actor(
name="Niklas",
good_asset=player_asset,
bad_asset=player_asset,
position=self.find_start_position(),
)
if not actor_sides: monster_sides = Actor.load_sprite_sides(assets_dir, target_height=DEMO_ACTOR_HEIGHT)
raise RuntimeError("No normal_*/angry_* actor sprite pairs found in assets/") self.monsters = MonsterManager(monster_sides, self.map)
self.monsters.spawn_initial(self.get_monster_spawn_blockers())
# Use the first pair for the player. With current assets this is Knoll.
self.player = Actor.from_sides(actor_sides[0], self.find_start_position())
def run(self, max_frames: Optional[int] = None) -> None: def run(self, max_frames: Optional[int] = None) -> None:
"""Run the demo. ``max_frames`` is only used for automated smoke tests.""" """Run the demo. ``max_frames`` is only used for automated smoke tests."""
@@ -68,11 +88,20 @@ class Game:
self.running = False self.running = False
elif next_state == "game": elif next_state == "game":
self.player_name = self.startscreen.player_name self.player_name = self.startscreen.player_name
self.game_start_time = pygame.time.get_ticks()
self.state = "game" self.state = "game"
elif self.state == "game": elif self.state == "game":
self.handle_events() self.handle_events()
self.update(dt) self.update(dt)
self.draw() self.draw()
elif self.state == "endscreen":
next_state = self.endscreen.run() if self.endscreen is not None else "startscreen"
if next_state == "quit":
self.running = False
elif next_state == "startscreen":
self.state = "startscreen"
elif next_state == "game":
self.restart_game()
pygame.display.flip() pygame.display.flip()
frame_count += 1 frame_count += 1
@@ -97,6 +126,16 @@ class Game:
self.player.hit_wall() self.player.hit_wall()
self.player.update_animation(dt) self.player.update_animation(dt)
self.ects.update(self.is_player_at_desk())
self.handle_room_table_collision()
self.monsters.update(
dt,
self.player.position,
self.is_position_walkable,
self.get_monster_spawn_blockers(),
)
self.update_tower()
self.check_end_conditions()
def get_player_movement(self, dt: float) -> pygame.Vector2: def get_player_movement(self, dt: float) -> pygame.Vector2:
keys = pygame.key.get_pressed() keys = pygame.key.get_pressed()
@@ -124,6 +163,143 @@ class Game:
row = int(position.y // tile_size) row = int(position.y // tile_size)
return self.map.is_accessible(column, row) return self.map.is_accessible(column, row)
def is_player_at_desk(self) -> bool:
"""Return True while the player's hitbox touches a desk in the current room."""
player_rect = self.get_actor_rect(self.player)
room_rect = self.map.get_current_room(player_rect)
if room_rect is None:
return False
return any(desk.check_collision(player_rect) for desk in self.map.get_desks_for_room(room_rect))
def update_tower(self) -> None:
"""Spawn a tower when enough ECTS are available and let it attack the zombie."""
player_rect = self.get_actor_rect(self.player)
room_rect = self.map.get_current_room(player_rect)
monster = self.monsters.monsters[0] if self.monsters.monsters else None
if monster is None or not monster.is_alive:
self.tower.clear()
return
damage = self.tower.update(
room_rect,
self.get_tower_spawn_blockers(),
self.ects,
monster.rect,
)
if monster is not None and damage > 0:
monster.take_damage(damage)
if not monster.is_alive:
self.tower.clear()
def check_end_conditions(self) -> None:
if self.active_door is not None and self.active_door.current_hp <= 0:
self.show_endscreen(won=False)
def show_endscreen(self, won: bool) -> None:
self.endscreen = Endscreen(
self.screen,
self.player_name,
self.ects.ects_balance,
self.get_elapsed_seconds(),
won,
)
self.state = "endscreen"
def restart_game(self) -> None:
self.ects = ECTS()
self.tower.clear()
self.active_door = None
self.doors_by_room = {}
self.player.set_position(self.find_start_position())
self.monsters.clear()
self.monsters.spawn_initial(self.get_monster_spawn_blockers())
self.game_start_time = pygame.time.get_ticks()
self.endscreen = None
self.state = "game"
def handle_room_table_collision(self) -> None:
"""Close/draw the room door and wake monsters when player hits a desk."""
player_rect = self.get_actor_rect(self.player)
room_rect = self.map.get_current_room(player_rect)
if room_rect is None:
return
for desk in self.map.get_desks_for_room(room_rect):
if desk.check_collision(player_rect):
door = self.get_or_create_door_for_room(room_rect)
if door.current_hp > 0:
door.is_closed = True
self.active_door = door
self.monsters.activate_for_room(room_rect, door)
return
def get_or_create_door_for_room(self, room_rect: pygame.Rect) -> Door:
key = (room_rect.x, room_rect.y, room_rect.width, room_rect.height)
if key not in self.doors_by_room:
self.doors_by_room[key] = self.create_door_for_room(room_rect)
return self.doors_by_room[key]
def create_door_for_room(self, room_rect: pygame.Rect) -> Door:
"""Create a Door object centered on the map's D tiles for this room."""
door_tiles: list[pygame.Rect] = []
for row_index, row in enumerate(self.map.map):
for column_index, tile in enumerate(row):
if tile != "D":
continue
tile_rect = pygame.Rect(
column_index * tile_size,
row_index * tile_size,
tile_size,
tile_size,
)
if (
room_rect.left <= tile_rect.centerx <= room_rect.right
and room_rect.bottom <= tile_rect.centery <= room_rect.bottom + 2 * tile_size
):
door_tiles.append(tile_rect)
if not door_tiles:
return Door(room_rect.centerx - DOOR_WIDTH // 2, room_rect.bottom, DOOR_HP)
door_area = door_tiles[0].copy()
for tile_rect in door_tiles[1:]:
door_area.union_ip(tile_rect)
return Door(
door_area.centerx - DOOR_WIDTH // 2,
door_area.centery - DOOR_HEIGHT // 2,
DOOR_HP,
)
def get_monster_spawn_blockers(self) -> list[pygame.Rect]:
"""Rects that monsters should not spawn on."""
blockers = [self.get_actor_rect(self.player)]
for desk_list in self.map.desks.values():
blockers.extend(desk.rect for desk in desk_list)
return blockers
def get_tower_spawn_blockers(self) -> list[pygame.Rect]:
"""Rects that towers should not spawn on."""
blockers = self.get_monster_spawn_blockers()
if self.active_door is not None:
blockers.append(pygame.Rect(self.active_door.x, self.active_door.y, DOOR_WIDTH, DOOR_HEIGHT))
for monster in self.monsters.monsters:
blockers.append(monster.rect)
return blockers
@staticmethod
def get_actor_rect(actor: Actor) -> pygame.Rect:
frame = actor.get_frame()
return frame.get_rect(midbottom=(round(actor.position.x), round(actor.position.y)))
def find_start_position(self) -> tuple[int, int]: def find_start_position(self) -> tuple[int, int]:
"""Find the first S tile and place the actor's feet near its center.""" """Find the first S tile and place the actor's feet near its center."""
@@ -139,10 +315,30 @@ class Game:
return tile_size, tile_size return tile_size, tile_size
def get_elapsed_seconds(self) -> int:
if self.game_start_time == 0:
return 0
return (pygame.time.get_ticks() - self.game_start_time) // 1000
def draw(self) -> None: def draw(self) -> None:
self.screen.fill(color_void) self.screen.fill(color_void)
self.map.draw_map(self.screen, self.font) self.map.draw_map(self.screen, self.font)
if self.active_door is not None and self.active_door.is_closed:
self.active_door.draw(self.screen)
self.tower.draw(self.screen, self.font)
# Draw by Y position so actors lower on the map appear in front. # Draw by Y position so actors lower on the map appear in front.
for actor in sorted([self.player], key=lambda item: item.position.y): actors = [self.player, *self.monsters.actors]
for actor in sorted(actors, key=lambda item: item.position.y):
actor.draw(self.screen) actor.draw(self.screen)
self.hud.draw(
self.screen,
self.player_name,
self.active_door,
self.monsters.monsters[0] if self.monsters.monsters else None,
self.ects,
self.get_elapsed_seconds(),
)