feat(actors): implement needed functions for the actors

This commit is contained in:
Niklas Haiden
2026-05-08 14:33:05 +02:00
parent 74139aa63d
commit a96dfefa07
+230 -48
View File
@@ -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 Use this module for everything actor-related:
which side of the actor is active and exposes functions to switch it.
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 __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Mapping, Tuple from typing import Dict, Iterable, Mapping, Optional, Tuple, Union
import pygame import pygame
from src.actor_sprites import Position, draw_actor from src.settings import (
from src.settings import DEFAULT_FRAME_TIME, Direction DEFAULT_FRAME_TIME,
DEFAULT_SPRITE_HEIGHT,
DIRECTION_FRAMES,
Direction,
)
Position = Union[Tuple[float, float], pygame.Vector2]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -27,30 +37,16 @@ class ActorSpriteAsset:
@dataclass(frozen=True) @dataclass(frozen=True)
class ActorSpriteSides: class ActorSpriteSides:
"""The good and bad sprite assets for the same actor. """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 name: str
good: ActorSpriteAsset good: ActorSpriteAsset
bad: 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 @dataclass
class Actor: 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 name: str
good_asset: ActorSpriteAsset good_asset: ActorSpriteAsset
@@ -67,12 +63,11 @@ class Actor:
self.position = pygame.Vector2(self.position) self.position = pygame.Vector2(self.position)
self.previous_position = self.position.copy() self.previous_position = self.position.copy()
# ---------------------------------------------------------------------
# Constructors / asset loading
# ---------------------------------------------------------------------
@classmethod @classmethod
def from_sides( def from_sides(cls, sides: ActorSpriteSides, position: Position) -> "Actor":
cls,
sides: ActorSpriteSides,
position: Position,
) -> "Actor":
"""Create an actor from a loaded good/bad sprite pair.""" """Create an actor from a loaded good/bad sprite pair."""
return cls( return cls(
@@ -82,6 +77,168 @@ class Actor:
position=pygame.Vector2(position), 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 @property
def current_asset(self) -> ActorSpriteAsset: def current_asset(self) -> ActorSpriteAsset:
"""The sprite asset that should currently be drawn.""" """The sprite asset that should currently be drawn."""
@@ -103,13 +260,11 @@ class Actor:
self.is_bad = not self.is_bad self.is_bad = not self.is_bad
# ---------------------------------------------------------------------
# Movement / collision helpers
# ---------------------------------------------------------------------
def move_by(self, movement: Position) -> None: def move_by(self, movement: Position) -> None:
"""Move the actor and remember the old position for wall collisions. """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) movement_vector = pygame.Vector2(movement)
self.previous_position = self.position.copy() self.previous_position = self.position.copy()
@@ -134,11 +289,7 @@ class Actor:
self.animation_time = DEFAULT_FRAME_TIME self.animation_time = DEFAULT_FRAME_TIME
def hit_wall(self) -> None: def hit_wall(self) -> None:
"""Call when this actor collides with a wall. """Move back to the last safe position and stop walking."""
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.position = self.previous_position.copy()
self.stop_walking() self.stop_walking()
@@ -151,14 +302,45 @@ class Actor:
else: else:
self.animation_time = DEFAULT_FRAME_TIME 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( frames = self.current_asset.frames[self.direction]
surface, if not self.moving:
self.current_asset, # Frame 2 is the nicest standing/idle frame for this 3-frame cycle.
self.position, return frames[1]
direction=self.direction,
moving=self.moving, frame_index = int(self.animation_time / frame_time) % len(frames)
animation_time=self.animation_time, 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])