Merge branch 'main' into feat/endscreen

This commit is contained in:
Kevin Baumgartner
2026-05-08 15:39:27 +02:00
6 changed files with 590 additions and 10 deletions
+5 -1
View File
@@ -1,5 +1,9 @@
from src.game import Game
def main(): def main():
print("Hello from silent-fh-squad-g!") game = Game()
game.run()
if __name__ == "__main__": if __name__ == "__main__":
+346
View File
@@ -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])
+151
View File
@@ -0,0 +1,151 @@
"""Small playable demo that uses ``src.map.Map`` and the actor system.
Controls:
- WASD / arrow keys: move Knoll around the map
- Space: switch between good/normal and bad/angry sprite
- Esc / Q: quit
The demo also places a few bad/angry actors as monsters in the rooms.
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
import pygame
from src.actors import Actor
from src.map import Map, color_void, screen_h, screen_w, tile_size
from src.settings import DEMO_ACTOR_HEIGHT, FPS, PLAYER_SPEED
from src.startscreen import Startscreen
# Keep the actor close to the map scale. The map tiles are 30x30, so 2 tiles
# high makes the character readable without covering whole rooms/walls.
def tile_center(column: int, row: int) -> tuple[int, int]:
"""Return the pixel center of a map tile."""
return column * tile_size + tile_size // 2, row * tile_size + tile_size // 2
class Game:
def __init__(self) -> None:
pygame.init()
self.screen = pygame.display.set_mode((screen_w, screen_h))
self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("monospace", 22, bold=True)
self.running = True
self.startscreen = Startscreen(self.screen)
self.state = "startscreen"
self.player_name: str = ""
self.map = Map()
# resolve the assets from the folder
assets_dir = Path(__file__).resolve().parent.parent / "assets"
actor_sides = Actor.load_sprite_sides(assets_dir, target_height=DEMO_ACTOR_HEIGHT)
if not actor_sides:
raise RuntimeError("No normal_*/angry_* actor sprite pairs found in assets/")
# Use the first pair for the player. With current assets this is Knoll.
self.player = Actor.from_sides(actor_sides[0], self.find_start_position())
def run(self, max_frames: Optional[int] = None) -> None:
"""Run the demo. ``max_frames`` is only used for automated smoke tests."""
frame_count = 0
while self.running and (max_frames is None or frame_count < max_frames):
dt = min(self.clock.tick(FPS) / 1000.0, 0.05)
if self.state == "startscreen":
next_state = self.startscreen.run()
if next_state == "quit":
self.running = False
elif next_state == "game":
self.player_name = self.startscreen.player_name
self.state = "game"
elif self.state == "game":
self.handle_events()
self.update(dt)
self.draw()
pygame.display.flip()
frame_count += 1
pygame.quit()
def handle_events(self) -> None:
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.running = False
elif event.type == pygame.KEYDOWN:
if event.key in (pygame.K_ESCAPE, pygame.K_q):
self.running = False
elif event.key == pygame.K_SPACE:
self.player.toggle_side()
def update(self, dt: float) -> None:
movement = self.get_player_movement(dt)
self.player.move_by(movement)
if not self.is_position_walkable(self.player.position):
self.player.hit_wall()
self.player.update_animation(dt)
def get_player_movement(self, dt: float) -> pygame.Vector2:
keys = pygame.key.get_pressed()
movement = pygame.Vector2(
(keys[pygame.K_RIGHT] or keys[pygame.K_d])
- (keys[pygame.K_LEFT] or keys[pygame.K_a]),
(keys[pygame.K_DOWN] or keys[pygame.K_s])
- (keys[pygame.K_UP] or keys[pygame.K_w]),
)
if movement.length_squared() == 0:
return movement
if abs(movement.x) > abs(movement.y):
self.player.direction = "right" if movement.x > 0 else "left"
else:
self.player.direction = "down" if movement.y > 0 else "up"
return movement.normalize() * PLAYER_SPEED * dt
def is_position_walkable(self, position: pygame.Vector2) -> bool:
"""Check if the actor's feet are standing on a walkable map tile."""
column = int(position.x // tile_size)
row = int(position.y // tile_size)
return self.map.is_accessible(column, row)
def find_start_position(self) -> tuple[int, int]:
"""Find the first S tile and place the actor's feet near its center."""
for row_index, row in enumerate(self.map.map):
column_index = row.find("S")
if column_index != -1:
# The S tile is at the very top of the current map, so place the
# actor one tile lower to keep the sprite visible on screen.
return (
column_index * tile_size + tile_size // 2,
(row_index + 1) * tile_size + tile_size // 2,
)
return tile_size, tile_size
def draw(self) -> None:
self.screen.fill(color_void)
self.map.draw_map(self.screen, self.font)
# Draw by Y position so actors lower on the map appear in front.
for actor in sorted([self.player], key=lambda item: item.position.y):
actor.draw(self.screen)
+47
View File
@@ -0,0 +1,47 @@
import pygame
from src.settings import (
HUD_FONT, HUD_FONT_SIZE, HUD_FONT_BOLD,
COLOR_HUD_TEXT, COLOR_HP_BAR_GREEN, COLOR_HP_BAR_RED, COLOR_HP_BAR_BG,
HUD_BAR_WIDTH, HUD_BAR_HEIGHT,
HUD_X_OFFSET, HUD_BAR_X, HUD_Y_DOOR, HUD_Y_ZOMBIE, HUD_Y_ECTS
)
# draw status bar (background bar and the colored hp bar)
def _draw_status_bar(screen, x, y, current, max_val, color):
# bg bar is always the same
pygame.draw.rect(screen, COLOR_HP_BAR_BG, (x, y, HUD_BAR_WIDTH, HUD_BAR_HEIGHT))
# hp bar
if max_val > 0:
hp_width = (current / max_val) * HUD_BAR_WIDTH
pygame.draw.rect((screen, color), (x, y, hp_width, HUD_BAR_HEIGHT))
# time formatter for MM:SS
def _format_time(total_seconds):
mins = total_seconds // 60
secs = total_seconds % 60
return f"TIME {mins:02}:{secs:02}"
class HUD:
def __init__(self):
self.font = pygame.font.SysFont(HUD_FONT, HUD_FONT_SIZE, bold=HUD_FONT_BOLD)
def draw(self, screen, door, zombie, ects, total_time):
# --- DOOR HP ---
_draw_status_bar(screen, HUD_BAR_X, HUD_Y_DOOR, door.current_hp, COLOR_HP_BAR_GREEN)
# -5 px, small off-set to align the text better with hp bar
screen.blit(self.font.render("DOOR HP ", True, COLOR_HUD_TEXT), (HUD_X_OFFSET, HUD_Y_DOOR - 5))
# --- ZOMBIE HP ---
if zombie:
_draw_status_bar(screen, HUD_BAR_X, HUD_Y_ZOMBIE, zombie.current_hp, zombie.max_hp, COLOR_HP_BAR_RED)
screen.blit(self.font.render("Zombie HP:", True, COLOR_HUD_TEXT), (HUD_X_OFFSET, HUD_Y_ZOMBIE - 5))
# --- ECTS ---
ects_info = f"ECTS: {ects.ects_balance} ({ects.ects_per_second}/s)"
screen.blit(self.font.render(ects_info, True, COLOR_HUD_TEXT), (HUD_X_OFFSET, HUD_Y_ECTS))
# --- TIME ---
time_str = _format_time(total_time)
screen.blit(self.font.render(time_str, True, COLOR_HUD_TEXT), (screen.get_width() - 150, HUD_Y_DOOR - 5))
+32 -2
View File
@@ -1,8 +1,11 @@
# --- DESK --- # --- DESK ---
from typing import Mapping, Literal, Tuple
from src.map import tile_size
DESK_WIDTH = 70 DESK_WIDTH = 70
DESK_HEIGHT = 40 DESK_HEIGHT = 40
# --- DOOR --- # --- DOOR ---
DOOR_WIDTH = 20 DOOR_WIDTH = 20
DOOR_HEIGHT = 100 DOOR_HEIGHT = 100
@@ -13,6 +16,22 @@ COLOR_DOOR_BORDER = (100, 90, 80)
COLOR_HP_BAR_GREEN = (0, 255, 0) COLOR_HP_BAR_GREEN = (0, 255, 0)
COLOR_HP_BAR_BG = (50, 50, 50) 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 --- # --- ECTS ---
START_ECTS_VALUE = 1 # how many ECTS are generated at the beginning START_ECTS_VALUE = 1 # how many ECTS are generated at the beginning
@@ -78,4 +97,15 @@ COLOR_HP_BAR_RED = (200, 0, 0)
COLOR_HP_BAR_YELLOW = (255, 255, 0) COLOR_HP_BAR_YELLOW = (255, 255, 0)
HUD_BAR_WIDTH = 100 HUD_BAR_WIDTH = 100
HUD_BAR_HEIGHT = 15 HUD_BAR_HEIGHT = 15
PLAYER_SPEED = 180.0
DEMO_ACTOR_HEIGHT = tile_size * 3
FPS = 60
HUD_X_OFFSET = 20 # left startpoint for texts
HUD_BAR_X = 150 # left startpoint for hp bars
HUD_Y_DOOR = 20
HUD_Y_ZOMBIE = 50
HUD_Y_ECTS = 85
+9 -7
View File
@@ -1,11 +1,13 @@
import pygame import pygame
from button import Button from src.button import Button
from settings import (COLOR_BUTTON, from src.settings import (
COLOR_BUTTON_HOVER, COLOR_BUTTON,
COLOR_STARTSCREEN_BACKGROUND, COLOR_BUTTON_HOVER,
COLOR_STARTSCREEN_FONT_TITLE, COLOR_STARTSCREEN_BACKGROUND,
COLOR_STARTSCREEN_FONT_LABEL) COLOR_STARTSCREEN_FONT_TITLE,
from text_input import TextInput COLOR_STARTSCREEN_FONT_LABEL,
)
from src.text_input import TextInput
class Startscreen: class Startscreen:
def __init__(self, screen): def __init__(self, screen):