From 74b0af92fd2d988ff1344d6a12a7eed07f38dc18 Mon Sep 17 00:00:00 2001 From: Niklas Haiden Date: Thu, 7 May 2026 21:19:26 +0200 Subject: [PATCH 1/7] add some settings for the sprite assets --- src/settings.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/settings.py b/src/settings.py index 4055ea3..2cd5b08 100644 --- a/src/settings.py +++ b/src/settings.py @@ -1,4 +1,24 @@ # --- DESK --- +from typing import Mapping, Literal, Tuple + DESK_WIDTH = 70 DESK_HEIGHT = 40 + + +# 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 From 74139aa63d9383efe37726a3068dac4a05858af7 Mon Sep 17 00:00:00 2001 From: Niklas Haiden Date: Thu, 7 May 2026 22:07:15 +0200 Subject: [PATCH 2/7] feat(actor): implement player / actor class --- src/actors.py | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 src/actors.py diff --git a/src/actors.py b/src/actors.py new file mode 100644 index 0000000..5a725a9 --- /dev/null +++ b/src/actors.py @@ -0,0 +1,164 @@ +"""Actor state with switchable good/bad sprites. + +The drawing itself still happens through ``draw_actor``. This class only stores +which side of the actor is active and exposes functions to switch it. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Mapping, Tuple + +import pygame + +from src.actor_sprites import Position, draw_actor +from src.settings import DEFAULT_FRAME_TIME, Direction + + +@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. + + Example: ``normal_knoll`` is the good side and ``angry_knoll`` is the bad side. + """ + + name: str + good: ActorSpriteAsset + bad: ActorSpriteAsset + + +@dataclass(frozen=True) +class ActorDraw: + """Small data object for drawing one actor with ``draw_actors``.""" + + asset: ActorSpriteAsset + position: Position + direction: Direction = "down" + moving: bool = False + animation_time: float = 0.0 + + +@dataclass +class Actor: + """One game actor that can switch between good and bad visuals.""" + + 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() + + @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), + ) + + @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 + + def move_by(self, movement: Position) -> None: + """Move the actor and remember the old position for wall collisions. + + Call this before collision checks. If the new position is inside a wall, + call ``actor.hit_wall()`` to move the actor back and stop the walking + animation. + """ + + 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: + """Call when this actor collides with a wall. + + This moves the actor back to the last safe position and stops the walking + animation, so usage can look like: ``knoll.hit_wall()``. + """ + + 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 + + def draw(self, surface: pygame.Surface) -> pygame.Rect: + """Draw this actor onto any Pygame surface.""" + + return draw_actor( + surface, + self.current_asset, + self.position, + direction=self.direction, + moving=self.moving, + animation_time=self.animation_time, + ) From a96dfefa0737b6afd765e76f0e34c1005f7faed3 Mon Sep 17 00:00:00 2001 From: Niklas Haiden Date: Fri, 8 May 2026 14:33:05 +0200 Subject: [PATCH 3/7] feat(actors): implement needed functions for the actors --- src/actors.py | 278 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 230 insertions(+), 48 deletions(-) diff --git a/src/actors.py b/src/actors.py index 5a725a9..b6a44c1 100644 --- a/src/actors.py +++ b/src/actors.py @@ -1,19 +1,29 @@ -"""Actor state with switchable good/bad sprites. +"""Actor sprites, loading, drawing, and actor state in one place. -The drawing itself still happens through ``draw_actor``. This class only stores -which side of the actor is active and exposes functions to switch it. +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 Mapping, Tuple +from typing import Dict, Iterable, Mapping, Optional, Tuple, Union import pygame -from src.actor_sprites import Position, draw_actor -from src.settings import DEFAULT_FRAME_TIME, Direction +from src.settings import ( + DEFAULT_FRAME_TIME, + DEFAULT_SPRITE_HEIGHT, + DIRECTION_FRAMES, + Direction, +) + +Position = Union[Tuple[float, float], pygame.Vector2] @dataclass(frozen=True) @@ -27,30 +37,16 @@ class ActorSpriteAsset: @dataclass(frozen=True) class ActorSpriteSides: - """The good and bad sprite assets for the same actor. - - Example: ``normal_knoll`` is the good side and ``angry_knoll`` is the bad side. - """ + """The good and bad sprite assets for the same actor.""" name: str good: ActorSpriteAsset bad: ActorSpriteAsset -@dataclass(frozen=True) -class ActorDraw: - """Small data object for drawing one actor with ``draw_actors``.""" - - asset: ActorSpriteAsset - position: Position - direction: Direction = "down" - moving: bool = False - animation_time: float = 0.0 - - @dataclass class Actor: - """One game actor that can switch between good and bad visuals.""" + """One game actor with movement, drawing, and good/bad sprite switching.""" name: str good_asset: ActorSpriteAsset @@ -67,12 +63,11 @@ class Actor: 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": + def from_sides(cls, sides: ActorSpriteSides, position: Position) -> "Actor": """Create an actor from a loaded good/bad sprite pair.""" return cls( @@ -82,6 +77,168 @@ class Actor: 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.""" @@ -103,13 +260,11 @@ class Actor: 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. - - Call this before collision checks. If the new position is inside a wall, - call ``actor.hit_wall()`` to move the actor back and stop the walking - animation. - """ + """Move the actor and remember the old position for wall collisions.""" movement_vector = pygame.Vector2(movement) self.previous_position = self.position.copy() @@ -134,11 +289,7 @@ class Actor: self.animation_time = DEFAULT_FRAME_TIME def hit_wall(self) -> None: - """Call when this actor collides with a wall. - - This moves the actor back to the last safe position and stops the walking - animation, so usage can look like: ``knoll.hit_wall()``. - """ + """Move back to the last safe position and stop walking.""" self.position = self.previous_position.copy() self.stop_walking() @@ -151,14 +302,45 @@ class Actor: else: self.animation_time = DEFAULT_FRAME_TIME - def draw(self, surface: pygame.Surface) -> pygame.Rect: - """Draw this actor onto any Pygame surface.""" + # --------------------------------------------------------------------- + # Drawing + # --------------------------------------------------------------------- + def get_frame(self, *, frame_time: float = DEFAULT_FRAME_TIME) -> pygame.Surface: + """Return the current animation frame for this actor.""" - return draw_actor( - surface, - self.current_asset, - self.position, - direction=self.direction, - moving=self.moving, - animation_time=self.animation_time, - ) + 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]) From 96571641a27e2b109f2f8151e892389024eef584 Mon Sep 17 00:00:00 2001 From: Berger Lena Date: Fri, 8 May 2026 14:34:44 +0200 Subject: [PATCH 4/7] feat: HUD - add tower constants including cost, projectile stats, and duration - add item buying timer for shop items - add _draw_status_bar to handle dynamic HP rendering - add _format_time for MM:SS display --- src/hud.py | 23 +++++++++++++++++++++++ src/settings.py | 8 +++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/hud.py b/src/hud.py index e69de29..09e9d74 100644 --- a/src/hud.py +++ b/src/hud.py @@ -0,0 +1,23 @@ +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}" diff --git a/src/settings.py b/src/settings.py index e19f541..7cf9232 100644 --- a/src/settings.py +++ b/src/settings.py @@ -30,7 +30,6 @@ DIRECTION_FRAMES: Mapping[Direction, Tuple[int, int, int]] = { # 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 @@ -91,3 +90,10 @@ COLOR_HP_BAR_YELLOW = (255, 255, 0) HUD_BAR_WIDTH = 100 HUD_BAR_HEIGHT = 15 + +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 From f6beb533d6a4609d3c1904cb5dcf19b11906a418 Mon Sep 17 00:00:00 2001 From: Berger Lena Date: Fri, 8 May 2026 14:48:44 +0200 Subject: [PATCH 5/7] feat: HUD - add HUD class with font initialization - add status bars for doors and zombie hp - add ECTS balance and generation rate display - add game timer --- src/hud.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/hud.py b/src/hud.py index 09e9d74..eba708c 100644 --- a/src/hud.py +++ b/src/hud.py @@ -21,3 +21,27 @@ 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)) \ No newline at end of file From bd2152ac3be62ed67211fe00a06cb0e1cdfed074 Mon Sep 17 00:00:00 2001 From: Berger Lena Date: Fri, 8 May 2026 14:34:44 +0200 Subject: [PATCH 6/7] feat: HUD - add tower constants including cost, projectile stats, and duration - add item buying timer for shop items - add _draw_status_bar to handle dynamic HP rendering - add _format_time for MM:SS display --- src/hud.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hud.py b/src/hud.py index eba708c..e9c4e11 100644 --- a/src/hud.py +++ b/src/hud.py @@ -44,4 +44,4 @@ class HUD: # --- 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)) \ No newline at end of file + screen.blit(self.font.render(time_str, True, COLOR_HUD_TEXT), (screen.get_width() - 150, HUD_Y_DOOR - 5)) From 20e106cb010d0b2c1c557f23993f48a7f9f6ee79 Mon Sep 17 00:00:00 2001 From: Niklas Haiden Date: Fri, 8 May 2026 15:32:58 +0200 Subject: [PATCH 7/7] feat: implement gameloop Implement a basic game loop that draws the startscreen, and the map after you hit the right button --- main.py | 6 +- src/game.py | 151 +++++++++++++++++++++++++++++++++++++++++++++ src/settings.py | 8 ++- src/startscreen.py | 16 ++--- 4 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 src/game.py 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/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/settings.py b/src/settings.py index e19f541..8eea444 100644 --- a/src/settings.py +++ b/src/settings.py @@ -1,6 +1,8 @@ # --- DESK --- from typing import Mapping, Literal, Tuple +from src.map import tile_size + DESK_WIDTH = 70 DESK_HEIGHT = 40 @@ -30,7 +32,7 @@ DIRECTION_FRAMES: Mapping[Direction, Tuple[int, int, int]] = { # 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 @@ -91,3 +93,7 @@ COLOR_HP_BAR_YELLOW = (255, 255, 0) HUD_BAR_WIDTH = 100 HUD_BAR_HEIGHT = 15 + +PLAYER_SPEED = 180.0 +DEMO_ACTOR_HEIGHT = tile_size * 3 +FPS = 60 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):