diff --git a/assets/items/beer.png b/assets/items/beer.png new file mode 100644 index 0000000..42ec53a Binary files /dev/null and b/assets/items/beer.png differ diff --git a/assets/items/coffee.png b/assets/items/coffee.png new file mode 100644 index 0000000..0285eb4 Binary files /dev/null and b/assets/items/coffee.png differ diff --git a/assets/items/tower.png b/assets/items/tower.png new file mode 100644 index 0000000..8d91859 Binary files /dev/null and b/assets/items/tower.png differ diff --git a/main.py b/main.py index 808775c..82a129b 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,9 @@ +from src.game import Game + + def main(): - print("Hello from silent-fh-squad-g!") + game = Game() + game.run() if __name__ == "__main__": diff --git a/src/actors.py b/src/actors.py new file mode 100644 index 0000000..b6a44c1 --- /dev/null +++ b/src/actors.py @@ -0,0 +1,346 @@ +"""Actor sprites, loading, drawing, and actor state in one place. + +Use this module for everything actor-related: + + actor_sides = Actor.load_sprite_sides(Path("assets")) + knoll = Actor.from_sides(actor_sides[0], (480, 320)) + knoll.become_bad() + knoll.draw(screen) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, Iterable, Mapping, Optional, Tuple, Union + +import pygame + +from src.settings import ( + DEFAULT_FRAME_TIME, + DEFAULT_SPRITE_HEIGHT, + DIRECTION_FRAMES, + Direction, +) + +Position = Union[Tuple[float, float], pygame.Vector2] + + +@dataclass(frozen=True) +class ActorSpriteAsset: + """Loaded animation frames for one actor/skin.""" + + name: str + folder: Path + frames: Mapping[Direction, Tuple[pygame.Surface, ...]] + + +@dataclass(frozen=True) +class ActorSpriteSides: + """The good and bad sprite assets for the same actor.""" + + name: str + good: ActorSpriteAsset + bad: ActorSpriteAsset + + +@dataclass +class Actor: + """One game actor with movement, drawing, and good/bad sprite switching.""" + + name: str + good_asset: ActorSpriteAsset + bad_asset: ActorSpriteAsset + position: pygame.Vector2 + direction: Direction = "down" + moving: bool = False + animation_time: float = 0.0 + is_bad: bool = False + previous_position: pygame.Vector2 = field(init=False) + + def __post_init__(self) -> None: + # Always store positions as Vector2, even if a tuple was passed in. + self.position = pygame.Vector2(self.position) + self.previous_position = self.position.copy() + + # --------------------------------------------------------------------- + # Constructors / asset loading + # --------------------------------------------------------------------- + @classmethod + def from_sides(cls, sides: ActorSpriteSides, position: Position) -> "Actor": + """Create an actor from a loaded good/bad sprite pair.""" + + return cls( + name=sides.name, + good_asset=sides.good, + bad_asset=sides.bad, + position=pygame.Vector2(position), + ) + + @classmethod + def load_sprite_sides( + cls, + root: Path, + *, + target_height: int = DEFAULT_SPRITE_HEIGHT, + ) -> list[ActorSpriteSides]: + """Load actors that have both a ``normal_*`` and ``angry_*`` folder. + + Example: ``normal_knoll`` + ``angry_knoll`` becomes one actor named + ``Knoll`` with a good and a bad side. + """ + + folders_by_key = { + cls._actor_side_key(folder): folder + for folder in cls.discover_sprite_folders(root) + if cls._actor_side_key(folder) is not None + } + + sides: list[ActorSpriteSides] = [] + for side, actor_name in sorted(folders_by_key): + if side != "normal": + continue + + bad_folder = folders_by_key.get(("angry", actor_name)) + if bad_folder is None: + continue + + good_folder = folders_by_key[("normal", actor_name)] + sides.append( + ActorSpriteSides( + name=actor_name.replace("_", " ").title(), + good=cls.load_sprite_asset(good_folder, target_height=target_height), + bad=cls.load_sprite_asset(bad_folder, target_height=target_height), + ) + ) + + return sides + + @classmethod + def load_sprite_assets( + cls, + root: Path, + *, + target_height: int = DEFAULT_SPRITE_HEIGHT, + ) -> list[ActorSpriteAsset]: + """Load every complete actor sprite folder inside ``root``.""" + + return [ + cls.load_sprite_asset(folder, target_height=target_height) + for folder in cls.discover_sprite_folders(root) + ] + + @classmethod + def discover_sprite_folders(cls, root: Path) -> list[Path]: + """Find all complete actor sprite folders below ``root``.""" + + if not root.is_dir(): + raise FileNotFoundError(f"Missing assets folder: {root}") + + return sorted( + ( + folder + for folder in root.iterdir() + if folder.is_dir() and cls.has_complete_sprite_set(folder) + ), + key=lambda folder: cls.friendly_asset_name(folder).lower(), + ) + + @classmethod + def load_sprite_asset( + cls, + folder: Path, + *, + target_height: int = DEFAULT_SPRITE_HEIGHT, + ) -> ActorSpriteAsset: + """Load one actor folder into memory.""" + + if not folder.is_dir(): + raise FileNotFoundError(f"Missing sprite folder: {folder}") + if not cls.has_complete_sprite_set(folder): + raise FileNotFoundError( + f"Incomplete sprite folder: {folder} must contain sprite_01.png through sprite_12.png" + ) + + frames: Dict[Direction, Tuple[pygame.Surface, ...]] = {} + for direction, frame_numbers in DIRECTION_FRAMES.items(): + frames[direction] = tuple( + cls.load_surface( + folder / f"sprite_{frame_number:02d}.png", + target_height=target_height, + ) + for frame_number in frame_numbers + ) + + return ActorSpriteAsset( + name=cls.friendly_asset_name(folder), + folder=folder, + frames=frames, + ) + + @staticmethod + def load_surface(path: Path, *, target_height: Optional[int] = None) -> pygame.Surface: + """Load one PNG as a transparent ``pygame.Surface``.""" + + surface = pygame.image.load(path) + if pygame.display.get_surface() is not None: + surface = surface.convert_alpha() + + if target_height is not None: + surface = Actor.scale_to_height(surface, target_height) + return surface + + @staticmethod + def scale_to_height(surface: pygame.Surface, target_height: int) -> pygame.Surface: + """Scale a surface proportionally to ``target_height``.""" + + width, height = surface.get_size() + if height == target_height: + return surface + + scale = target_height / height + target_size = (max(1, round(width * scale)), max(1, target_height)) + return pygame.transform.smoothscale(surface, target_size) + + @classmethod + def has_complete_sprite_set(cls, folder: Path) -> bool: + """Check that the folder has all 12 required PNG files.""" + + return all( + (folder / f"sprite_{frame_number:02d}.png").is_file() + for frame_numbers in DIRECTION_FRAMES.values() + for frame_number in frame_numbers + ) + + @staticmethod + def friendly_asset_name(folder: Path) -> str: + """Turn ``normal_knoll_sprites`` into ``Normal Knoll``.""" + + name = folder.name + for suffix in ("_sprites_cleaned", "_sprites"): + name = name.removesuffix(suffix) + return name.replace("_", " ").title() + + @staticmethod + def _actor_side_key(folder: Path) -> Optional[Tuple[str, str]]: + """Return ``(side, actor_name)`` for folders like normal_knoll/angry_knoll.""" + + name = folder.name + for suffix in ("_sprites_cleaned", "_sprites"): + name = name.removesuffix(suffix) + + for side in ("normal", "angry"): + prefix = f"{side}_" + if name.startswith(prefix): + return side, name.removeprefix(prefix) + + return None + + # --------------------------------------------------------------------- + # Good/bad state + # --------------------------------------------------------------------- + @property + def current_asset(self) -> ActorSpriteAsset: + """The sprite asset that should currently be drawn.""" + + return self.bad_asset if self.is_bad else self.good_asset + + def become_bad(self) -> None: + """Switch this actor to its bad/angry sprite.""" + + self.is_bad = True + + def become_good(self) -> None: + """Switch this actor back to its good/normal sprite.""" + + self.is_bad = False + + def toggle_side(self) -> None: + """Switch good -> bad or bad -> good.""" + + self.is_bad = not self.is_bad + + # --------------------------------------------------------------------- + # Movement / collision helpers + # --------------------------------------------------------------------- + def move_by(self, movement: Position) -> None: + """Move the actor and remember the old position for wall collisions.""" + + movement_vector = pygame.Vector2(movement) + self.previous_position = self.position.copy() + + if movement_vector.length_squared() == 0: + self.stop_walking() + return + + self.position += movement_vector + self.moving = True + + def set_position(self, position: Position) -> None: + """Set actor position while keeping the last position for collision rollback.""" + + self.previous_position = self.position.copy() + self.position = pygame.Vector2(position) + + def stop_walking(self) -> None: + """Stop movement and show the idle standing frame.""" + + self.moving = False + self.animation_time = DEFAULT_FRAME_TIME + + def hit_wall(self) -> None: + """Move back to the last safe position and stop walking.""" + + self.position = self.previous_position.copy() + self.stop_walking() + + def update_animation(self, dt: float) -> None: + """Advance walking animation time.""" + + if self.moving: + self.animation_time += dt + else: + self.animation_time = DEFAULT_FRAME_TIME + + # --------------------------------------------------------------------- + # Drawing + # --------------------------------------------------------------------- + def get_frame(self, *, frame_time: float = DEFAULT_FRAME_TIME) -> pygame.Surface: + """Return the current animation frame for this actor.""" + + frames = self.current_asset.frames[self.direction] + if not self.moving: + # Frame 2 is the nicest standing/idle frame for this 3-frame cycle. + return frames[1] + + frame_index = int(self.animation_time / frame_time) % len(frames) + return frames[frame_index] + + def draw(self, surface: pygame.Surface, *, anchor: str = "midbottom") -> pygame.Rect: + """Draw this actor onto any Pygame surface and return the drawn rect.""" + + frame = self.get_frame() + rect = frame.get_rect(**{anchor: self._int_position(self.position)}) + surface.blit(frame, rect) + return rect + + @staticmethod + def draw_many( + surface: pygame.Surface, + actors: Iterable["Actor"], + *, + sort_by_y: bool = True, + ) -> list[pygame.Rect]: + """Draw multiple actors and return their drawn rects.""" + + actor_list = list(actors) + if sort_by_y: + actor_list.sort(key=lambda actor: actor.position.y) + + return [actor.draw(surface) for actor in actor_list] + + @staticmethod + def _int_position(position: Position) -> tuple[int, int]: + """Pygame rect anchors expect pixel coordinates.""" + + return round(position[0]), round(position[1]) diff --git a/src/button.py b/src/button.py new file mode 100644 index 0000000..e254574 --- /dev/null +++ b/src/button.py @@ -0,0 +1,67 @@ +import pygame + +class Button: + def __init__(self, text, width, height, pos_x, pos_y, color, hover): + """ + + :param text: Text inside the button. + :param width: Width of the button. + :param height: Height of the button. + :param pos_x: X position of the button on the screen. + :param pos_y: Y position of the button on the screen. + :param color: Color of the button on the screen. + :param hover: Color of the button on the screen when mouse is hover. + """ + self.text = text + self.width = width + self.height = height + self.pos_x = pos_x + self.pos_y = pos_y + self.color = color + self.hover = hover + + # create rectangle for button + self.rect = pygame.Rect(self.pos_x, self.pos_y, self.width, self.height) + + # set font for button text + self.font = pygame.font.SysFont("Arial", 30) + + # temp var to stor click event - so it will be executed just once + self.clicked = False + + def draw(self, screen): + """ + Draw the button on the screen. + :param screen: Screen object where button will be drawn. + :return: Return True when button was clicked. + """ + action = False + + # get current mous position + mouse_pos = pygame.mouse.get_pos() + + # check if mouse is over button + if self.rect.collidepoint(mouse_pos): + current_color = self.hover + + # check if left mouse button is pressed + if pygame.mouse.get_pressed()[0] == 1: + self.clicked = True + if pygame.mouse.get_pressed()[0] == 0 and self.clicked: + self.clicked = False + action = True + else: + current_color = self.color + self.clicked = False + + # draw buttton + pygame.draw.rect(screen, current_color, self.rect, border_radius = 12) + + # render button text + text_surface = self.font.render(self.text, True, (255, 255, 255)) + + # place text in the middle of the button + text_rect = text_surface.get_rect(center=self.rect.center) + screen.blit(text_surface, text_rect) + + return action \ No newline at end of file diff --git a/src/ects.py b/src/ects.py index 4ecb40b..e746c12 100644 --- a/src/ects.py +++ b/src/ects.py @@ -23,4 +23,4 @@ class ECTS: # beer increases amount of how much ECTS are earned per second def upgrade_rate(self, extra_ects): - self.ects_per_second += extra_ects \ No newline at end of file + self.ects_per_second += extra_ects diff --git a/src/endscreen.py b/src/endscreen.py new file mode 100644 index 0000000..4a4571b --- /dev/null +++ b/src/endscreen.py @@ -0,0 +1,122 @@ +import pygame +from button import Button +from settings import (COLOR_ENDSCREEN_BACKGROUND, + COLOR_ENDSCREEN_TEXT, + COLOR_ENDSCREEN_WIN, + COLOR_ENDSCREEN_LOSE, + COLOR_BUTTON_HOVER, + COLOR_BUTTON, + FONT_ENDSCREEN) + +class Endscreen: + def __init__(self, screen, player_name, score_ects, time_survived, won): + self.screen = screen + self.player_name = player_name + self.score_ects = score_ects + self.time_survived = time_survived + self.won = won + + # setup fonts + self.font_title = pygame.font.SysFont(FONT_ENDSCREEN, 52, bold=True) + self.font_label = pygame.font.SysFont(FONT_ENDSCREEN, 28) + self.font_small = pygame.font.SysFont(FONT_ENDSCREEN, 22) + + # setup colors + self.background_color = COLOR_ENDSCREEN_BACKGROUND + self.text_color = COLOR_ENDSCREEN_TEXT + + self.win_color = COLOR_ENDSCREEN_WIN + self.lose_color = COLOR_ENDSCREEN_LOSE + + self.button_color = COLOR_BUTTON + self. button_hover_color = COLOR_BUTTON_HOVER + + # Buttons + self.button_restart = Button( + "[ENTER] Try Again", + 500, + 70, + 230, + 440, + self.button_color, + self.button_hover_color + ) + + self.button_menu = Button( + "[ESC] Back to Menu", + 500, + 70, + 230, + 530, + self.button_color, + self.button_hover_color + ) + + def run(self): + # get events + events = pygame.event.get() + + for event in events: + if event.type == pygame.QUIT: + return "quit" + + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_RETURN: + return "game" + + if event.key == pygame.K_ESCAPE: + return "startscreen" + + # draw background + self.screen.fill(self.background_color) + + # create texts depending on win/lose + if self.won: + title_text = "EXAM PASSED!" + title_color = self.win_color + message_lines = [ + "Congratulations, student.", + "You survived the night shift at FH Kapfenberg.", + "Your ECTS have ben successfully submitted." + ] + else: + title_text = "EXMATRICULATED!" + title_color = self.lose_color + message_lines = [ + "The lecturers found you.", + "Your assignment was not submitted in time.", + "Please retake the semester." + ] + + # draw title/texts + title = self.font_title.render(title_text, True, title_color) + title_rect = title.get_rect(center=(480, 90)) + self.screen.blit(title, title_rect) + + y = 160 + for line in message_lines: + message = self.font_small.render(line, True, self.text_color) + message_rect = message.get_rect(center=(480, y)) + self.screen.blit(message, message_rect) + y += 32 + + # draw results + name_text = self.font_label.render(f"Student: {self.player_name}", True, self.text_color) + score_text = self.font_label.render(f"ECTS collected: {self.score_ects}", True, self.text_color) + time_text = self.font_label.render(f"Time survived: {self.time_survived}", True, self.text_color) + + self.screen.blit(name_text, (280, 290)) + self.screen.blit(score_text, (280, 330)) + self.screen.blit(time_text, (280, 370)) + + # draw buttons + restart_pressed = self.button_restart.draw(self.screen) + menu_pressed = self.button_menu.draw(self.screen) + + # check button click events + if restart_pressed: + return "game" + if menu_pressed: + return "startscreen" + + return "endscreen" diff --git a/src/game.py b/src/game.py new file mode 100644 index 0000000..4425c6e --- /dev/null +++ b/src/game.py @@ -0,0 +1,151 @@ +"""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 +- Esc / Q: quit + +The demo also places a few bad/angry actors as monsters in the rooms. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import pygame + +from src.actors import Actor +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.startscreen import Startscreen + + +# Keep the actor close to the map scale. The map tiles are 30x30, so 2 tiles +# high makes the character readable without covering whole rooms/walls. + + +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.running = True + + self.startscreen = Startscreen(self.screen) + self.state = "startscreen" + self.player_name: str = "" + + self.map = Map() + + # resolve the assets from the folder + assets_dir = Path(__file__).resolve().parent.parent / "assets" + actor_sides = Actor.load_sprite_sides(assets_dir, target_height=DEMO_ACTOR_HEIGHT) + + 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()) + + def run(self, max_frames: Optional[int] = None) -> None: + """Run the demo. ``max_frames`` is only used for automated smoke tests.""" + + 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.state = "game" + elif self.state == "game": + self.handle_events() + self.update(dt) + self.draw() + + + 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() + + def update(self, dt: float) -> None: + 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) + + 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: + """Check if the actor's feet are standing on a walkable map tile.""" + + column = int(position.x // tile_size) + row = int(position.y // tile_size) + return self.map.is_accessible(column, row) + + def find_start_position(self) -> tuple[int, int]: + """Find the first S tile and place the actor's feet near its center.""" + + for row_index, row in enumerate(self.map.map): + column_index = row.find("S") + if column_index != -1: + # The S tile is at the very top of the current map, so place the + # actor one tile lower to keep the sprite visible on screen. + return ( + column_index * tile_size + tile_size // 2, + (row_index + 1) * tile_size + tile_size // 2, + ) + + return tile_size, tile_size + + def draw(self) -> None: + self.screen.fill(color_void) + self.map.draw_map(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): + actor.draw(self.screen) + diff --git a/src/hud.py b/src/hud.py index e69de29..e9c4e11 100644 --- a/src/hud.py +++ b/src/hud.py @@ -0,0 +1,47 @@ +import pygame +from src.settings import ( + HUD_FONT, HUD_FONT_SIZE, HUD_FONT_BOLD, + COLOR_HUD_TEXT, COLOR_HP_BAR_GREEN, COLOR_HP_BAR_RED, COLOR_HP_BAR_BG, + HUD_BAR_WIDTH, HUD_BAR_HEIGHT, + HUD_X_OFFSET, HUD_BAR_X, HUD_Y_DOOR, HUD_Y_ZOMBIE, HUD_Y_ECTS +) + +# draw status bar (background bar and the colored hp bar) +def _draw_status_bar(screen, x, y, current, max_val, color): + # bg bar is always the same + pygame.draw.rect(screen, COLOR_HP_BAR_BG, (x, y, HUD_BAR_WIDTH, HUD_BAR_HEIGHT)) + + # hp bar + if max_val > 0: + hp_width = (current / max_val) * HUD_BAR_WIDTH + pygame.draw.rect((screen, color), (x, y, hp_width, HUD_BAR_HEIGHT)) + +# time formatter for MM:SS +def _format_time(total_seconds): + mins = total_seconds // 60 + secs = total_seconds % 60 + return f"TIME {mins:02}:{secs:02}" + + +class HUD: + def __init__(self): + self.font = pygame.font.SysFont(HUD_FONT, HUD_FONT_SIZE, bold=HUD_FONT_BOLD) + + def draw(self, screen, door, zombie, ects, total_time): + # --- DOOR HP --- + _draw_status_bar(screen, HUD_BAR_X, HUD_Y_DOOR, door.current_hp, COLOR_HP_BAR_GREEN) + # -5 px, small off-set to align the text better with hp bar + screen.blit(self.font.render("DOOR HP ", True, COLOR_HUD_TEXT), (HUD_X_OFFSET, HUD_Y_DOOR - 5)) + + # --- ZOMBIE HP --- + if zombie: + _draw_status_bar(screen, HUD_BAR_X, HUD_Y_ZOMBIE, zombie.current_hp, zombie.max_hp, COLOR_HP_BAR_RED) + screen.blit(self.font.render("Zombie HP:", True, COLOR_HUD_TEXT), (HUD_X_OFFSET, HUD_Y_ZOMBIE - 5)) + + # --- ECTS --- + ects_info = f"ECTS: {ects.ects_balance} ({ects.ects_per_second}/s)" + screen.blit(self.font.render(ects_info, True, COLOR_HUD_TEXT), (HUD_X_OFFSET, HUD_Y_ECTS)) + + # --- TIME --- + time_str = _format_time(total_time) + screen.blit(self.font.render(time_str, True, COLOR_HUD_TEXT), (screen.get_width() - 150, HUD_Y_DOOR - 5)) diff --git a/src/item.py b/src/item.py new file mode 100644 index 0000000..e0cec9e --- /dev/null +++ b/src/item.py @@ -0,0 +1,133 @@ +import pygame +import random +from src.settings import ( + ITEM_SPAWN_INTERVAL, ITEM_SHOPPING_TIMER, ITEM_SIZE, + COLOR_HP_BAR_YELLOW, COLOR_HP_BAR_BG +) + + +class Item: + def __init__(self, x, y, item_type): + self.x = x + self.y = y + self.item_type = item_type + + # collision rect for player (2 times item size for width and height) + self.rect = pygame.Rect(self.x, self.y, ITEM_SIZE, ITEM_SIZE) + + # timer to store time when item appeared + self.spawn_time = pygame.time.get_ticks() + + # try to load sprite with item type + try: + path = f"assets/items/{item_type}.png" + self.image = pygame.image.load(path).convert_alpha() + self.image = pygame.transform.scale(self.image, (ITEM_SIZE, ITEM_SIZE)) + except Exception as e: + print(f"Error loading item sprite {item_type}: {e}") + self.image = None + + # to check if the time ran out to buy the item + def is_expired(self): + return pygame.time.get_ticks() - self.spawn_time >= ITEM_SHOPPING_TIMER + + # cam offset to convert world coordinates to screen coordinates + def draw(self, screen, cam_x, cam_y): + draw_x = self.x - cam_x + draw_y = self.y - cam_y + + if self.image is not None: + screen.blit(self.image, (draw_x, draw_y)) + else: + # fallback using settings colors + # coffee brown, beer yellow + if self.item_type == "coffee": + color = (120, 70, 30) + else: + color = COLOR_HP_BAR_YELLOW + + draw_rect = (draw_x, draw_y, ITEM_SIZE, ITEM_SIZE) + pygame.draw.rect(screen, color, draw_rect) + pygame.draw.rect(screen, COLOR_HP_BAR_BG, draw_rect, 2) + + +# try 50 times to find a free position to spawn item +def _random_position(room_rect, blocked_rects): + attempts = 0 + max_attempts = 50 + + while attempts < max_attempts: + # generate random x/y coordinates with room boundaries + # * 2 to have a margin to walls + x = random.randint(room_rect.left + ITEM_SIZE, room_rect.right - ITEM_SIZE * 2) + y = random.randint(room_rect.top + ITEM_SIZE, room_rect.bottom - ITEM_SIZE * 2) + + # create a rect at this position for testing collision + test_rect = pygame.Rect(x, y, ITEM_SIZE, ITEM_SIZE) + blocked = False + for b in blocked_rects: + if test_rect.colliderect(b): + blocked = True + break + + # if position is not blocked, return the position + if not blocked: + return x, y + + attempts += 1 + + # no spot after 50 attempts -> return + return None + + +class ItemManager: + def __init__(self): + self.items = [] + self.last_spawn_time = pygame.time.get_ticks() + + # remove expired items and spawn new ones + def update(self, room_rect, blocked_rects): + now = pygame.time.get_ticks() + + remaining_items = [] + + for item in self.items: + if item.is_expired() == False: + remaining_items.append(item) + + # replace the old list with the new temp_list + self.items = remaining_items + + # spawn logic for new items + if room_rect is not None: + if now - self.last_spawn_time >= ITEM_SPAWN_INTERVAL: + pos = _random_position(room_rect, blocked_rects) + if pos is not None: + x, y = pos + item_type = random.choice(["coffee", "beer"]) + self.items.append(Item(x, y, item_type)) + + self.last_spawn_time = now + + # check if player press "e" to buy + def check_collect(self, player_rect, keys): + if keys[pygame.K_e]: + for item in self.items: + if player_rect.colliderect(item.rect): + return item + return None + + # delete item from list after buying it + def remove_item(self, item): + if item in self.items: + self.items.remove(item) + + # draw all active items + def draw(self, screen, cam_x, cam_y): + for item in self.items: + item.draw(screen, cam_x, cam_y) + + # clear items when changing room + def clear(self): + self.items = [] + self.last_spawn_time = pygame.time.get_ticks() \ No newline at end of file diff --git a/src/settings.py b/src/settings.py index 969155c..fb68e0a 100644 --- a/src/settings.py +++ b/src/settings.py @@ -1,8 +1,11 @@ +from typing import Mapping, Literal, Tuple + +from src.map import tile_size + # --- DESK --- DESK_WIDTH = 70 DESK_HEIGHT = 40 - # --- DOOR --- DOOR_WIDTH = 20 DOOR_HEIGHT = 100 @@ -12,18 +15,59 @@ COLOR_DOOR_BEIGE = (245, 245, 220) COLOR_DOOR_BORDER = (100, 90, 80) +# --- PLAYER --- +# Players Constants / Settings +# Valid movement/animation directions. +Direction = Literal["down", "left", "right", "up"] + + +# --- SPRITE --- +# Sprite files are grouped by direction. +# Example: "down" uses sprite_01.png, sprite_02.png, sprite_03.png. +DIRECTION_FRAMES: Mapping[Direction, Tuple[int, int, int]] = { + "down": (1, 2, 3), + "left": (4, 5, 6), + "right": (7, 8, 9), + "up": (10, 11, 12), +} + +# Default drawing size and animation speed. +DEFAULT_SPRITE_HEIGHT: int = 138 +DEFAULT_FRAME_TIME: float = 0.16 + + # --- ECTS --- -START_ECTS_VALUE = 1 # how many ECTS are generated at the beginning -ECTS_UPDATE_INTERVAL = 1000 # time in ms = 1 sec +START_ECTS_VALUE = 1 # how many ECTS are generated at the beginning +ECTS_UPDATE_INTERVAL = 1000 # time in ms (1000 = 1 sec) BEER_BOTTLE_ECTS_VALUE = 2 BEER_COST = 50 +# --- BUTTONS --- +COLOR_BUTTON = (0, 255, 0) +COLOR_BUTTON_HOVER = (200, 255, 0) + + +# --- STARTSCREEN --- +COLOR_STARTSCREEN_BACKGROUND = (0, 0, 0) +COLOR_STARTSCREEN_FONT_TITLE = (0, 255, 0) +COLOR_STARTSCREEN_FONT_LABEL = (0, 255, 255) + + +# --- ENDSCREEN --- +COLOR_ENDSCREEN_BACKGROUND = (0, 0, 0) +COLOR_ENDSCREEN_TEXT = (255, 255, 255) +COLOR_ENDSCREEN_WIN = (0, 255, 0) +COLOR_ENDSCREEN_LOSE = (255, 0, 0) +FONT_ENDSCREEN = "Arial" + + # --- COFFEE --- COFFEE_COST = 30 COFFEE_HEAL_VALUE = 30 + # --- TOWER --- TOWER_COST = 150 PROJECTILE_DMG = 15 @@ -32,11 +76,13 @@ COLOR_PROJECTILE = (200, 200, 0) TOWER_TIMER = 30000 # how long the tower stays and attack before it disappears -# --- SHOPPING TIMER --- -ITEM_SHOPPING_TIMER = 10000 # for items like coffee, beer, tower - how long u have time to buy it +# --- ITEM SPAWN TIMER --- +ITEM_SPAWN_INTERVAL = 5000 # ms until a new item spawns +ITEM_SHOPPING_TIMER = 10000 # how long you have time to buy items before they despawn +ITEM_SIZE = 30 -# -- ZOMBIE --- +# --- ZOMBIE --- ZOMBIE_EASY_HP = 30 ZOMBIE_EASY_DMG = 3 @@ -61,4 +107,15 @@ COLOR_HP_BAR_RED = (200, 0, 0) COLOR_HP_BAR_YELLOW = (255, 255, 0) HUD_BAR_WIDTH = 100 -HUD_BAR_HEIGHT = 15 \ No newline at end of file +HUD_BAR_HEIGHT = 15 + +PLAYER_SPEED = 180.0 +DEMO_ACTOR_HEIGHT = tile_size * 3 +FPS = 60 + +HUD_X_OFFSET = 20 # left startpoint for texts +HUD_BAR_X = 150 # left startpoint for hp bars + +HUD_Y_DOOR = 20 +HUD_Y_ZOMBIE = 50 +HUD_Y_ECTS = 85 diff --git a/src/startscreen.py b/src/startscreen.py new file mode 100644 index 0000000..fddb731 --- /dev/null +++ b/src/startscreen.py @@ -0,0 +1,158 @@ +import pygame +from src.button import Button +from src.settings import ( + COLOR_BUTTON, + COLOR_BUTTON_HOVER, + COLOR_STARTSCREEN_BACKGROUND, + COLOR_STARTSCREEN_FONT_TITLE, + COLOR_STARTSCREEN_FONT_LABEL, +) +from src.text_input import TextInput + +class Startscreen: + def __init__(self, screen): + self.screen = screen + self.font_title = pygame.font.SysFont("Arial", 36) + self.font_label = pygame.font.SysFont("Arial", 20) + + self.button_color = COLOR_BUTTON + self.button_hover_color = COLOR_BUTTON_HOVER + + self.player_name = "" + self.wrong_name = False + + self.button_start = Button("[ENTER] Matriculate", + 500, + 70, + 20, + 100, + self.button_color, + self.button_hover_color) + + self.button_quit = Button("[ESC] Exmatriculate", + 500, + 70, + 20, + 190, + self.button_color, + self.button_hover_color) + + self.text_input_name = TextInput(400, + 40, + 20, + 310) + + def run(self): + # set background color + self.screen.fill(COLOR_STARTSCREEN_BACKGROUND) + + # set title and intro/welcome message + title = self.font_title.render("SILENT FH", True, COLOR_STARTSCREEN_FONT_TITLE) + self.screen.blit(title, (10, 10)) + message_1 = self.font_label.render("Welcome to Silent FH", True, COLOR_STARTSCREEN_FONT_LABEL) + message_2 = self.font_label.render("Study with purpose. Survive with Coffee.", True, + COLOR_STARTSCREEN_FONT_LABEL) + + self.screen.blit(message_1, (10, 50)) + self.screen.blit(message_2, (10, 70)) + + # draw buttons and get click state + button_start_pressed = self.button_start.draw(self.screen) + button_quit_pressed = self.button_quit.draw(self.screen) + + # draw text input box + label_input = self.font_label.render("Enter your student name or ID:", True, + COLOR_STARTSCREEN_FONT_LABEL) + self.screen.blit(label_input, (10, 280)) + self.text_input_name.draw(self.screen) + + # prepare error message when no input was submitted + label_input_error = self.font_label.render("Student not found. Please enter valid name/id.", True, + (255, 0, 0)) + + # get all events + events = pygame.event.get() + + # react on button clicks + if button_start_pressed: + self.player_name = self.text_input_name.get_text() + + if self.player_name.strip() != "": + print("Spielername:", self.player_name) + return "game" + else: + self.wrong_name = True + if button_quit_pressed: + return "quit" + + # check events + for event in events: + if event.type == pygame.QUIT: + return "quit" + + # forward event to text input box + self.text_input_name.handle_event(event) + + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + return "quit" + + if event.key == pygame.K_RETURN: + self.player_name = self.text_input_name.get_text() + + if self.player_name.strip() != "": + print("Spielername:", self.player_name) + return "game" + else: + self.wrong_name = True + + if self.wrong_name: + self.screen.blit(label_input_error, (10, 350)) + + return "startscreen" + + +# test function fro temporary testing +if __name__ == "__main__": + pygame.init() + + screen = pygame.display.set_mode((960, 640)) + pygame.display.set_caption("Silent FH - Startscreen Test") + + clock = pygame.time.Clock() + + startscreen = Startscreen(screen) + state = "startscreen" + + running = True + + while running: + clock.tick(60) + + if state == "startscreen": + state = startscreen.run() + + elif state == "game": + screen.fill((30, 30, 30)) + + font = pygame.font.SysFont("Arial", 36) + text = font.render("GAME STARTED", True, (255, 255, 255)) + screen.blit(text, (300, 280)) + + info = font.render("Press ESC to quit", True, (150, 150, 150)) + screen.blit(info, (300, 330)) + + for event in pygame.event.get(): + if event.type == pygame.QUIT: + state = "quit" + + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + state = "quit" + + elif state == "quit": + running = False + + pygame.display.flip() + + pygame.quit() \ No newline at end of file