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/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/settings.py b/src/settings.py index 0ef6b21..36b11aa 100644 --- a/src/settings.py +++ b/src/settings.py @@ -1,8 +1,11 @@ # --- DESK --- +from typing import Mapping, Literal, Tuple + +from src.map import tile_size + DESK_WIDTH = 70 DESK_HEIGHT = 40 - # --- DOOR --- DOOR_WIDTH = 20 DOOR_HEIGHT = 100 @@ -13,6 +16,22 @@ COLOR_DOOR_BORDER = (100, 90, 80) COLOR_HP_BAR_GREEN = (0, 255, 0) COLOR_HP_BAR_BG = (50, 50, 50) +# Players Constants / Settings +# Valid movement/animation directions. +Direction = Literal["down", "left", "right", "up"] + +# 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 @@ -78,4 +97,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 index 1e9c34a..fddb731 100644 --- a/src/startscreen.py +++ b/src/startscreen.py @@ -1,11 +1,13 @@ import pygame -from button import Button -from settings import (COLOR_BUTTON, - COLOR_BUTTON_HOVER, - COLOR_STARTSCREEN_BACKGROUND, - COLOR_STARTSCREEN_FONT_TITLE, - COLOR_STARTSCREEN_FONT_LABEL) -from text_input import TextInput +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):