From a96dfefa0737b6afd765e76f0e34c1005f7faed3 Mon Sep 17 00:00:00 2001 From: Niklas Haiden Date: Fri, 8 May 2026 14:33:05 +0200 Subject: [PATCH] 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])