feat(actors): implement needed functions for the actors
This commit is contained in:
+230
-48
@@ -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])
|
||||||
|
|||||||
Reference in New Issue
Block a user