From 8515a843c3587ad04478b1a02b9cd9ef3608e59c Mon Sep 17 00:00:00 2001 From: Niklas Haiden Date: Fri, 8 May 2026 19:01:36 +0200 Subject: [PATCH] feat: implement game loop for monster interaction --- src/game.py | 218 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 207 insertions(+), 11 deletions(-) diff --git a/src/game.py b/src/game.py index 9821754..6452618 100644 --- a/src/game.py +++ b/src/game.py @@ -1,8 +1,8 @@ """Small playable demo that uses ``src.map.Map`` and the actor system. Controls: -- WASD / arrow keys: move Knoll around the map -- Space: switch between good/normal and bad/angry sprite +- WASD / arrow keys: move Niklas around the map +- Space: toggle player state - Esc / Q: quit The demo also places a few bad/angry actors as monsters in the rooms. @@ -16,9 +16,15 @@ from typing import Optional import pygame 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.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.tower import Tower # 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.startscreen = Startscreen(self.screen) + self.hud = HUD() + self.ects = ECTS() + self.tower = Tower() self.state = "startscreen" self.player_name: str = "" + self.game_start_time = 0 + self.endscreen: Optional[Endscreen] = None 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" - 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: - raise RuntimeError("No normal_*/angry_* actor sprite pairs found in assets/") - - # 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()) + monster_sides = Actor.load_sprite_sides(assets_dir, target_height=DEMO_ACTOR_HEIGHT) + self.monsters = MonsterManager(monster_sides, self.map) + self.monsters.spawn_initial(self.get_monster_spawn_blockers()) def run(self, max_frames: Optional[int] = None) -> None: """Run the demo. ``max_frames`` is only used for automated smoke tests.""" @@ -68,11 +88,20 @@ class Game: self.running = False elif next_state == "game": self.player_name = self.startscreen.player_name + self.game_start_time = pygame.time.get_ticks() self.state = "game" elif self.state == "game": self.handle_events() self.update(dt) 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() frame_count += 1 @@ -97,6 +126,16 @@ class Game: self.player.hit_wall() 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: keys = pygame.key.get_pressed() @@ -124,6 +163,143 @@ class Game: row = int(position.y // tile_size) 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]: """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 + 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: self.screen.fill(color_void) 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. - 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) + + 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(), + )