From 74b0af92fd2d988ff1344d6a12a7eed07f38dc18 Mon Sep 17 00:00:00 2001 From: Niklas Haiden Date: Thu, 7 May 2026 21:19:26 +0200 Subject: [PATCH 1/3] 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/3] 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/3] 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])