From 74139aa63d9383efe37726a3068dac4a05858af7 Mon Sep 17 00:00:00 2001 From: Niklas Haiden Date: Thu, 7 May 2026 22:07:15 +0200 Subject: [PATCH] feat(actor): implement player / actor class --- src/actors.py | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 src/actors.py diff --git a/src/actors.py b/src/actors.py new file mode 100644 index 0000000..5a725a9 --- /dev/null +++ b/src/actors.py @@ -0,0 +1,164 @@ +"""Actor state with switchable good/bad sprites. + +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. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Mapping, Tuple + +import pygame + +from src.actor_sprites import Position, draw_actor +from src.settings import DEFAULT_FRAME_TIME, Direction + + +@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. + + Example: ``normal_knoll`` is the good side and ``angry_knoll`` is the bad side. + """ + + 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.""" + + 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() + + @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), + ) + + @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 + + 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. + """ + + 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: + """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()``. + """ + + 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 + + def draw(self, surface: pygame.Surface) -> pygame.Rect: + """Draw this actor onto any Pygame surface.""" + + return draw_actor( + surface, + self.current_asset, + self.position, + direction=self.direction, + moving=self.moving, + animation_time=self.animation_time, + )