"""Small playable demo that uses ``src.map.Map`` and the actor system. Controls: - WASD / arrow keys: move Niklas around the map - Space: toggle player state - Esc / Q: quit """ from __future__ import annotations from pathlib import Path 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.item import Item, ItemManager from src.map import Map, color_void, screen_h, screen_w, tile_size from src.monster import MonsterManager from src.settings import ( BEER_BOTTLE_ECTS_VALUE, COFFEE_HEAL_VALUE, DEMO_ACTOR_HEIGHT, DOOR_HEIGHT, DOOR_HP, DOOR_WIDTH, FPS, PLAYER_SPEED, ) from src.shop import Shop from src.startscreen import Startscreen from src.sounds import SoundManager from src.tower import Tower def tile_center(column: int, row: int) -> tuple[int, int]: """Return the pixel center of a map tile.""" return column * tile_size + tile_size // 2, row * tile_size + tile_size // 2 class Game: def __init__(self) -> None: pygame.init() self.screen = pygame.display.set_mode((screen_w, screen_h)) self.clock = pygame.time.Clock() self.font = pygame.font.SysFont("monospace", 22, bold=True) self.item_font = pygame.font.SysFont("Arial", 16, bold=True) self.running = True self.active_monster = None self.buy_requested = False # --- SOUND & SYSTEMS --- self.sound_manager = SoundManager() self.startscreen = Startscreen(self.screen) self.hud = HUD() self.ects = ECTS() self.item_manager = ItemManager() self.shop = Shop(self.ects, self.item_manager.items, self.sound_manager) self.tower = Tower(self.sound_manager) self.state = "startscreen" self.player_name: str = "" self.game_start_time = 0 self.endscreen: Optional[Endscreen] = None self.current_item_room_key: Optional[tuple[int, int, int, int]] = None self.map = Map() self.active_door: Optional[Door] = None self.doors_by_room: dict[tuple[int, int, int, int], Door] = {} assets_dir = Path(__file__).resolve().parent.parent / "assets" 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(), ) 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: frame_count = 0 while self.running and (max_frames is None or frame_count < max_frames): dt = min(self.clock.tick(FPS) / 1000.0, 0.05) if self.state == "startscreen": next_state = self.startscreen.run() if next_state == "quit": 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" self.sound_manager.play_bgm("normal") 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 pygame.quit() def handle_events(self) -> None: for event in pygame.event.get(): if event.type == pygame.QUIT: self.running = False elif event.type == pygame.KEYDOWN: if event.key in (pygame.K_ESCAPE, pygame.K_q): self.running = False elif event.key == pygame.K_SPACE: self.player.toggle_side() self.sound_manager.play_sfx("monster_transform") elif event.key == pygame.K_e: self.buy_requested = True def update(self, dt: float) -> None: if self.player.is_bad: self.active_monster = self.player self.sound_manager.play_bgm("creepy") else: self.active_monster = None self.sound_manager.play_bgm("normal") movement = self.get_player_movement(dt) self.player.move_by(movement) if not self.is_position_walkable(self.player.position): self.player.hit_wall() self.player.update_animation(dt) self.ects.update(self.is_player_at_desk()) self.handle_room_table_collision() self.update_items() 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() movement = pygame.Vector2( (keys[pygame.K_RIGHT] or keys[pygame.K_d]) - (keys[pygame.K_LEFT] or keys[pygame.K_a]), (keys[pygame.K_DOWN] or keys[pygame.K_s]) - (keys[pygame.K_UP] or keys[pygame.K_w]), ) if movement.length_squared() == 0: return movement if abs(movement.x) > abs(movement.y): self.player.direction = "right" if movement.x > 0 else "left" else: self.player.direction = "down" if movement.y > 0 else "up" return movement.normalize() * PLAYER_SPEED * dt def is_position_walkable(self, position: pygame.Vector2) -> bool: column = int(position.x // tile_size) 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 update_items(self) -> None: """Spawn room items and process one player purchase request.""" player_rect = self.get_actor_rect(self.player) room_rect = self.map.get_current_room(player_rect) room_key = self.get_room_key(room_rect) if room_rect is not None else None if room_key != self.current_item_room_key: self.item_manager.clear() self.current_item_room_key = room_key self.item_manager.update(room_rect, self.get_item_spawn_blockers()) if self.buy_requested: self.try_buy_touching_item(player_rect) self.buy_requested = False def try_buy_touching_item(self, player_rect: pygame.Rect) -> None: item = self.item_manager.get_touching_item(player_rect) if item is None: return if self.shop.buy_item(item): self.apply_item_effect(item) self.item_manager.remove_item(item) def apply_item_effect(self, item: Item) -> None: if item.item_type == "beer": self.ects.upgrade_rate(BEER_BOTTLE_ECTS_VALUE) elif item.item_type == "coffee" and self.active_door is not None: self.active_door.current_hp = min( self.active_door.max_hp, self.active_door.current_hp + COFFEE_HEAL_VALUE, ) 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.shop = Shop(self.ects, self.item_manager.items, self.sound_manager) self.item_manager.clear() self.current_item_room_key = None 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 = self.get_room_key(room_rect) 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) blockers.extend(item.rect for item in self.item_manager.items) return blockers def get_item_spawn_blockers(self) -> list[pygame.Rect]: """Rects that room items should not spawn on.""" blockers = self.get_tower_spawn_blockers() if self.tower.tower_rect is not None: blockers.append(self.tower.tower_rect) blockers.extend(item.rect for item in self.item_manager.items) return blockers @staticmethod def get_room_key(room_rect: pygame.Rect) -> tuple[int, int, int, int]: return room_rect.x, room_rect.y, room_rect.width, room_rect.height @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]: for row_index, row in enumerate(self.map.map): column_index = row.find("S") if column_index != -1: return ( column_index * tile_size + tile_size // 2, (row_index + 1) * tile_size + tile_size // 2, ) 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.item_manager.draw(self.screen, 0, 0, self.item_font) self.tower.draw(self.screen, self.font) # Draw by Y position so actors lower on the map appear in front. 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(), )