"""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])