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/settings.py b/src/settings.py index d245ac8..e19f541 100644 --- a/src/settings.py +++ b/src/settings.py @@ -1,8 +1,9 @@ # --- DESK --- +from typing import Mapping, Literal, Tuple + DESK_WIDTH = 70 DESK_HEIGHT = 40 - # --- DOOR --- DOOR_WIDTH = 20 DOOR_HEIGHT = 100 @@ -13,6 +14,23 @@ 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 @@ -72,4 +90,4 @@ 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