Merge remote-tracking branch 'refs/remotes/origin/main' into feat/map
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 766 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 710 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 710 KiB |
@@ -1,5 +1,9 @@
|
||||
from src.game import Game
|
||||
|
||||
|
||||
def main():
|
||||
print("Hello from silent-fh-squad-g!")
|
||||
game = Game()
|
||||
game.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+346
@@ -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])
|
||||
@@ -0,0 +1,67 @@
|
||||
import pygame
|
||||
|
||||
class Button:
|
||||
def __init__(self, text, width, height, pos_x, pos_y, color, hover):
|
||||
"""
|
||||
|
||||
:param text: Text inside the button.
|
||||
:param width: Width of the button.
|
||||
:param height: Height of the button.
|
||||
:param pos_x: X position of the button on the screen.
|
||||
:param pos_y: Y position of the button on the screen.
|
||||
:param color: Color of the button on the screen.
|
||||
:param hover: Color of the button on the screen when mouse is hover.
|
||||
"""
|
||||
self.text = text
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.pos_x = pos_x
|
||||
self.pos_y = pos_y
|
||||
self.color = color
|
||||
self.hover = hover
|
||||
|
||||
# create rectangle for button
|
||||
self.rect = pygame.Rect(self.pos_x, self.pos_y, self.width, self.height)
|
||||
|
||||
# set font for button text
|
||||
self.font = pygame.font.SysFont("Arial", 30)
|
||||
|
||||
# temp var to stor click event - so it will be executed just once
|
||||
self.clicked = False
|
||||
|
||||
def draw(self, screen):
|
||||
"""
|
||||
Draw the button on the screen.
|
||||
:param screen: Screen object where button will be drawn.
|
||||
:return: Return True when button was clicked.
|
||||
"""
|
||||
action = False
|
||||
|
||||
# get current mous position
|
||||
mouse_pos = pygame.mouse.get_pos()
|
||||
|
||||
# check if mouse is over button
|
||||
if self.rect.collidepoint(mouse_pos):
|
||||
current_color = self.hover
|
||||
|
||||
# check if left mouse button is pressed
|
||||
if pygame.mouse.get_pressed()[0] == 1:
|
||||
self.clicked = True
|
||||
if pygame.mouse.get_pressed()[0] == 0 and self.clicked:
|
||||
self.clicked = False
|
||||
action = True
|
||||
else:
|
||||
current_color = self.color
|
||||
self.clicked = False
|
||||
|
||||
# draw buttton
|
||||
pygame.draw.rect(screen, current_color, self.rect, border_radius = 12)
|
||||
|
||||
# render button text
|
||||
text_surface = self.font.render(self.text, True, (255, 255, 255))
|
||||
|
||||
# place text in the middle of the button
|
||||
text_rect = text_surface.get_rect(center=self.rect.center)
|
||||
screen.blit(text_surface, text_rect)
|
||||
|
||||
return action
|
||||
+1
-1
@@ -23,4 +23,4 @@ class ECTS:
|
||||
|
||||
# beer increases amount of how much ECTS are earned per second
|
||||
def upgrade_rate(self, extra_ects):
|
||||
self.ects_per_second += extra_ects
|
||||
self.ects_per_second += extra_ects
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import pygame
|
||||
from button import Button
|
||||
from settings import (COLOR_ENDSCREEN_BACKGROUND,
|
||||
COLOR_ENDSCREEN_TEXT,
|
||||
COLOR_ENDSCREEN_WIN,
|
||||
COLOR_ENDSCREEN_LOSE,
|
||||
COLOR_BUTTON_HOVER,
|
||||
COLOR_BUTTON,
|
||||
FONT_ENDSCREEN)
|
||||
|
||||
class Endscreen:
|
||||
def __init__(self, screen, player_name, score_ects, time_survived, won):
|
||||
self.screen = screen
|
||||
self.player_name = player_name
|
||||
self.score_ects = score_ects
|
||||
self.time_survived = time_survived
|
||||
self.won = won
|
||||
|
||||
# setup fonts
|
||||
self.font_title = pygame.font.SysFont(FONT_ENDSCREEN, 52, bold=True)
|
||||
self.font_label = pygame.font.SysFont(FONT_ENDSCREEN, 28)
|
||||
self.font_small = pygame.font.SysFont(FONT_ENDSCREEN, 22)
|
||||
|
||||
# setup colors
|
||||
self.background_color = COLOR_ENDSCREEN_BACKGROUND
|
||||
self.text_color = COLOR_ENDSCREEN_TEXT
|
||||
|
||||
self.win_color = COLOR_ENDSCREEN_WIN
|
||||
self.lose_color = COLOR_ENDSCREEN_LOSE
|
||||
|
||||
self.button_color = COLOR_BUTTON
|
||||
self. button_hover_color = COLOR_BUTTON_HOVER
|
||||
|
||||
# Buttons
|
||||
self.button_restart = Button(
|
||||
"[ENTER] Try Again",
|
||||
500,
|
||||
70,
|
||||
230,
|
||||
440,
|
||||
self.button_color,
|
||||
self.button_hover_color
|
||||
)
|
||||
|
||||
self.button_menu = Button(
|
||||
"[ESC] Back to Menu",
|
||||
500,
|
||||
70,
|
||||
230,
|
||||
530,
|
||||
self.button_color,
|
||||
self.button_hover_color
|
||||
)
|
||||
|
||||
def run(self):
|
||||
# get events
|
||||
events = pygame.event.get()
|
||||
|
||||
for event in events:
|
||||
if event.type == pygame.QUIT:
|
||||
return "quit"
|
||||
|
||||
if event.type == pygame.KEYDOWN:
|
||||
if event.key == pygame.K_RETURN:
|
||||
return "game"
|
||||
|
||||
if event.key == pygame.K_ESCAPE:
|
||||
return "startscreen"
|
||||
|
||||
# draw background
|
||||
self.screen.fill(self.background_color)
|
||||
|
||||
# create texts depending on win/lose
|
||||
if self.won:
|
||||
title_text = "EXAM PASSED!"
|
||||
title_color = self.win_color
|
||||
message_lines = [
|
||||
"Congratulations, student.",
|
||||
"You survived the night shift at FH Kapfenberg.",
|
||||
"Your ECTS have ben successfully submitted."
|
||||
]
|
||||
else:
|
||||
title_text = "EXMATRICULATED!"
|
||||
title_color = self.lose_color
|
||||
message_lines = [
|
||||
"The lecturers found you.",
|
||||
"Your assignment was not submitted in time.",
|
||||
"Please retake the semester."
|
||||
]
|
||||
|
||||
# draw title/texts
|
||||
title = self.font_title.render(title_text, True, title_color)
|
||||
title_rect = title.get_rect(center=(480, 90))
|
||||
self.screen.blit(title, title_rect)
|
||||
|
||||
y = 160
|
||||
for line in message_lines:
|
||||
message = self.font_small.render(line, True, self.text_color)
|
||||
message_rect = message.get_rect(center=(480, y))
|
||||
self.screen.blit(message, message_rect)
|
||||
y += 32
|
||||
|
||||
# draw results
|
||||
name_text = self.font_label.render(f"Student: {self.player_name}", True, self.text_color)
|
||||
score_text = self.font_label.render(f"ECTS collected: {self.score_ects}", True, self.text_color)
|
||||
time_text = self.font_label.render(f"Time survived: {self.time_survived}", True, self.text_color)
|
||||
|
||||
self.screen.blit(name_text, (280, 290))
|
||||
self.screen.blit(score_text, (280, 330))
|
||||
self.screen.blit(time_text, (280, 370))
|
||||
|
||||
# draw buttons
|
||||
restart_pressed = self.button_restart.draw(self.screen)
|
||||
menu_pressed = self.button_menu.draw(self.screen)
|
||||
|
||||
# check button click events
|
||||
if restart_pressed:
|
||||
return "game"
|
||||
if menu_pressed:
|
||||
return "startscreen"
|
||||
|
||||
return "endscreen"
|
||||
+151
@@ -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
@@ -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))
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import pygame
|
||||
import random
|
||||
from src.settings import (
|
||||
ITEM_SPAWN_INTERVAL, ITEM_SHOPPING_TIMER, ITEM_SIZE,
|
||||
COLOR_HP_BAR_YELLOW, COLOR_HP_BAR_BG
|
||||
)
|
||||
|
||||
|
||||
class Item:
|
||||
def __init__(self, x, y, item_type):
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.item_type = item_type
|
||||
|
||||
# collision rect for player (2 times item size for width and height)
|
||||
self.rect = pygame.Rect(self.x, self.y, ITEM_SIZE, ITEM_SIZE)
|
||||
|
||||
# timer to store time when item appeared
|
||||
self.spawn_time = pygame.time.get_ticks()
|
||||
|
||||
# try to load sprite with item type
|
||||
try:
|
||||
path = f"assets/items/{item_type}.png"
|
||||
self.image = pygame.image.load(path).convert_alpha()
|
||||
self.image = pygame.transform.scale(self.image, (ITEM_SIZE, ITEM_SIZE))
|
||||
except Exception as e:
|
||||
print(f"Error loading item sprite {item_type}: {e}")
|
||||
self.image = None
|
||||
|
||||
# to check if the time ran out to buy the item
|
||||
def is_expired(self):
|
||||
return pygame.time.get_ticks() - self.spawn_time >= ITEM_SHOPPING_TIMER
|
||||
|
||||
# cam offset to convert world coordinates to screen coordinates
|
||||
def draw(self, screen, cam_x, cam_y):
|
||||
draw_x = self.x - cam_x
|
||||
draw_y = self.y - cam_y
|
||||
|
||||
if self.image is not None:
|
||||
screen.blit(self.image, (draw_x, draw_y))
|
||||
else:
|
||||
# fallback using settings colors
|
||||
# coffee brown, beer yellow
|
||||
if self.item_type == "coffee":
|
||||
color = (120, 70, 30)
|
||||
else:
|
||||
color = COLOR_HP_BAR_YELLOW
|
||||
|
||||
draw_rect = (draw_x, draw_y, ITEM_SIZE, ITEM_SIZE)
|
||||
pygame.draw.rect(screen, color, draw_rect)
|
||||
pygame.draw.rect(screen, COLOR_HP_BAR_BG, draw_rect, 2)
|
||||
|
||||
|
||||
# try 50 times to find a free position to spawn item
|
||||
def _random_position(room_rect, blocked_rects):
|
||||
attempts = 0
|
||||
max_attempts = 50
|
||||
|
||||
while attempts < max_attempts:
|
||||
# generate random x/y coordinates with room boundaries
|
||||
# * 2 to have a margin to walls
|
||||
x = random.randint(room_rect.left + ITEM_SIZE, room_rect.right - ITEM_SIZE * 2)
|
||||
y = random.randint(room_rect.top + ITEM_SIZE, room_rect.bottom - ITEM_SIZE * 2)
|
||||
|
||||
# create a rect at this position for testing collision
|
||||
test_rect = pygame.Rect(x, y, ITEM_SIZE, ITEM_SIZE)
|
||||
blocked = False
|
||||
for b in blocked_rects:
|
||||
if test_rect.colliderect(b):
|
||||
blocked = True
|
||||
break
|
||||
|
||||
# if position is not blocked, return the position
|
||||
if not blocked:
|
||||
return x, y
|
||||
|
||||
attempts += 1
|
||||
|
||||
# no spot after 50 attempts -> return
|
||||
return None
|
||||
|
||||
|
||||
class ItemManager:
|
||||
def __init__(self):
|
||||
self.items = []
|
||||
self.last_spawn_time = pygame.time.get_ticks()
|
||||
|
||||
# remove expired items and spawn new ones
|
||||
def update(self, room_rect, blocked_rects):
|
||||
now = pygame.time.get_ticks()
|
||||
|
||||
remaining_items = []
|
||||
|
||||
for item in self.items:
|
||||
if item.is_expired() == False:
|
||||
remaining_items.append(item)
|
||||
|
||||
# replace the old list with the new temp_list
|
||||
self.items = remaining_items
|
||||
|
||||
# spawn logic for new items
|
||||
if room_rect is not None:
|
||||
if now - self.last_spawn_time >= ITEM_SPAWN_INTERVAL:
|
||||
pos = _random_position(room_rect, blocked_rects)
|
||||
if pos is not None:
|
||||
x, y = pos
|
||||
item_type = random.choice(["coffee", "beer"])
|
||||
self.items.append(Item(x, y, item_type))
|
||||
|
||||
self.last_spawn_time = now
|
||||
|
||||
# check if player press "e" to buy
|
||||
def check_collect(self, player_rect, keys):
|
||||
if keys[pygame.K_e]:
|
||||
for item in self.items:
|
||||
if player_rect.colliderect(item.rect):
|
||||
return item
|
||||
return None
|
||||
|
||||
# delete item from list after buying it
|
||||
def remove_item(self, item):
|
||||
if item in self.items:
|
||||
self.items.remove(item)
|
||||
|
||||
# draw all active items
|
||||
def draw(self, screen, cam_x, cam_y):
|
||||
for item in self.items:
|
||||
item.draw(screen, cam_x, cam_y)
|
||||
|
||||
# clear items when changing room
|
||||
def clear(self):
|
||||
self.items = []
|
||||
self.last_spawn_time = pygame.time.get_ticks()
|
||||
+64
-7
@@ -1,8 +1,11 @@
|
||||
from typing import Mapping, Literal, Tuple
|
||||
|
||||
from src.map import tile_size
|
||||
|
||||
# --- DESK ---
|
||||
DESK_WIDTH = 70
|
||||
DESK_HEIGHT = 40
|
||||
|
||||
|
||||
# --- DOOR ---
|
||||
DOOR_WIDTH = 20
|
||||
DOOR_HEIGHT = 100
|
||||
@@ -12,18 +15,59 @@ COLOR_DOOR_BEIGE = (245, 245, 220)
|
||||
COLOR_DOOR_BORDER = (100, 90, 80)
|
||||
|
||||
|
||||
# --- PLAYER ---
|
||||
# Players Constants / Settings
|
||||
# Valid movement/animation directions.
|
||||
Direction = Literal["down", "left", "right", "up"]
|
||||
|
||||
|
||||
# --- SPRITE ---
|
||||
# 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 ---
|
||||
START_ECTS_VALUE = 1 # how many ECTS are generated at the beginning
|
||||
ECTS_UPDATE_INTERVAL = 1000 # time in ms = 1 sec
|
||||
START_ECTS_VALUE = 1 # how many ECTS are generated at the beginning
|
||||
ECTS_UPDATE_INTERVAL = 1000 # time in ms (1000 = 1 sec)
|
||||
|
||||
BEER_BOTTLE_ECTS_VALUE = 2
|
||||
BEER_COST = 50
|
||||
|
||||
|
||||
# --- BUTTONS ---
|
||||
COLOR_BUTTON = (0, 255, 0)
|
||||
COLOR_BUTTON_HOVER = (200, 255, 0)
|
||||
|
||||
|
||||
# --- STARTSCREEN ---
|
||||
COLOR_STARTSCREEN_BACKGROUND = (0, 0, 0)
|
||||
COLOR_STARTSCREEN_FONT_TITLE = (0, 255, 0)
|
||||
COLOR_STARTSCREEN_FONT_LABEL = (0, 255, 255)
|
||||
|
||||
|
||||
# --- ENDSCREEN ---
|
||||
COLOR_ENDSCREEN_BACKGROUND = (0, 0, 0)
|
||||
COLOR_ENDSCREEN_TEXT = (255, 255, 255)
|
||||
COLOR_ENDSCREEN_WIN = (0, 255, 0)
|
||||
COLOR_ENDSCREEN_LOSE = (255, 0, 0)
|
||||
FONT_ENDSCREEN = "Arial"
|
||||
|
||||
|
||||
# --- COFFEE ---
|
||||
COFFEE_COST = 30
|
||||
COFFEE_HEAL_VALUE = 30
|
||||
|
||||
|
||||
# --- TOWER ---
|
||||
TOWER_COST = 150
|
||||
PROJECTILE_DMG = 15
|
||||
@@ -32,11 +76,13 @@ COLOR_PROJECTILE = (200, 200, 0)
|
||||
TOWER_TIMER = 30000 # how long the tower stays and attack before it disappears
|
||||
|
||||
|
||||
# --- SHOPPING TIMER ---
|
||||
ITEM_SHOPPING_TIMER = 10000 # for items like coffee, beer, tower - how long u have time to buy it
|
||||
# --- ITEM SPAWN TIMER ---
|
||||
ITEM_SPAWN_INTERVAL = 5000 # ms until a new item spawns
|
||||
ITEM_SHOPPING_TIMER = 10000 # how long you have time to buy items before they despawn
|
||||
ITEM_SIZE = 30
|
||||
|
||||
|
||||
# -- ZOMBIE ---
|
||||
# --- ZOMBIE ---
|
||||
ZOMBIE_EASY_HP = 30
|
||||
ZOMBIE_EASY_DMG = 3
|
||||
|
||||
@@ -61,4 +107,15 @@ COLOR_HP_BAR_RED = (200, 0, 0)
|
||||
COLOR_HP_BAR_YELLOW = (255, 255, 0)
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import pygame
|
||||
from src.button import Button
|
||||
from src.settings import (
|
||||
COLOR_BUTTON,
|
||||
COLOR_BUTTON_HOVER,
|
||||
COLOR_STARTSCREEN_BACKGROUND,
|
||||
COLOR_STARTSCREEN_FONT_TITLE,
|
||||
COLOR_STARTSCREEN_FONT_LABEL,
|
||||
)
|
||||
from src.text_input import TextInput
|
||||
|
||||
class Startscreen:
|
||||
def __init__(self, screen):
|
||||
self.screen = screen
|
||||
self.font_title = pygame.font.SysFont("Arial", 36)
|
||||
self.font_label = pygame.font.SysFont("Arial", 20)
|
||||
|
||||
self.button_color = COLOR_BUTTON
|
||||
self.button_hover_color = COLOR_BUTTON_HOVER
|
||||
|
||||
self.player_name = ""
|
||||
self.wrong_name = False
|
||||
|
||||
self.button_start = Button("[ENTER] Matriculate",
|
||||
500,
|
||||
70,
|
||||
20,
|
||||
100,
|
||||
self.button_color,
|
||||
self.button_hover_color)
|
||||
|
||||
self.button_quit = Button("[ESC] Exmatriculate",
|
||||
500,
|
||||
70,
|
||||
20,
|
||||
190,
|
||||
self.button_color,
|
||||
self.button_hover_color)
|
||||
|
||||
self.text_input_name = TextInput(400,
|
||||
40,
|
||||
20,
|
||||
310)
|
||||
|
||||
def run(self):
|
||||
# set background color
|
||||
self.screen.fill(COLOR_STARTSCREEN_BACKGROUND)
|
||||
|
||||
# set title and intro/welcome message
|
||||
title = self.font_title.render("SILENT FH", True, COLOR_STARTSCREEN_FONT_TITLE)
|
||||
self.screen.blit(title, (10, 10))
|
||||
message_1 = self.font_label.render("Welcome to Silent FH", True, COLOR_STARTSCREEN_FONT_LABEL)
|
||||
message_2 = self.font_label.render("Study with purpose. Survive with Coffee.", True,
|
||||
COLOR_STARTSCREEN_FONT_LABEL)
|
||||
|
||||
self.screen.blit(message_1, (10, 50))
|
||||
self.screen.blit(message_2, (10, 70))
|
||||
|
||||
# draw buttons and get click state
|
||||
button_start_pressed = self.button_start.draw(self.screen)
|
||||
button_quit_pressed = self.button_quit.draw(self.screen)
|
||||
|
||||
# draw text input box
|
||||
label_input = self.font_label.render("Enter your student name or ID:", True,
|
||||
COLOR_STARTSCREEN_FONT_LABEL)
|
||||
self.screen.blit(label_input, (10, 280))
|
||||
self.text_input_name.draw(self.screen)
|
||||
|
||||
# prepare error message when no input was submitted
|
||||
label_input_error = self.font_label.render("Student not found. Please enter valid name/id.", True,
|
||||
(255, 0, 0))
|
||||
|
||||
# get all events
|
||||
events = pygame.event.get()
|
||||
|
||||
# react on button clicks
|
||||
if button_start_pressed:
|
||||
self.player_name = self.text_input_name.get_text()
|
||||
|
||||
if self.player_name.strip() != "":
|
||||
print("Spielername:", self.player_name)
|
||||
return "game"
|
||||
else:
|
||||
self.wrong_name = True
|
||||
if button_quit_pressed:
|
||||
return "quit"
|
||||
|
||||
# check events
|
||||
for event in events:
|
||||
if event.type == pygame.QUIT:
|
||||
return "quit"
|
||||
|
||||
# forward event to text input box
|
||||
self.text_input_name.handle_event(event)
|
||||
|
||||
if event.type == pygame.KEYDOWN:
|
||||
if event.key == pygame.K_ESCAPE:
|
||||
return "quit"
|
||||
|
||||
if event.key == pygame.K_RETURN:
|
||||
self.player_name = self.text_input_name.get_text()
|
||||
|
||||
if self.player_name.strip() != "":
|
||||
print("Spielername:", self.player_name)
|
||||
return "game"
|
||||
else:
|
||||
self.wrong_name = True
|
||||
|
||||
if self.wrong_name:
|
||||
self.screen.blit(label_input_error, (10, 350))
|
||||
|
||||
return "startscreen"
|
||||
|
||||
|
||||
# test function fro temporary testing
|
||||
if __name__ == "__main__":
|
||||
pygame.init()
|
||||
|
||||
screen = pygame.display.set_mode((960, 640))
|
||||
pygame.display.set_caption("Silent FH - Startscreen Test")
|
||||
|
||||
clock = pygame.time.Clock()
|
||||
|
||||
startscreen = Startscreen(screen)
|
||||
state = "startscreen"
|
||||
|
||||
running = True
|
||||
|
||||
while running:
|
||||
clock.tick(60)
|
||||
|
||||
if state == "startscreen":
|
||||
state = startscreen.run()
|
||||
|
||||
elif state == "game":
|
||||
screen.fill((30, 30, 30))
|
||||
|
||||
font = pygame.font.SysFont("Arial", 36)
|
||||
text = font.render("GAME STARTED", True, (255, 255, 255))
|
||||
screen.blit(text, (300, 280))
|
||||
|
||||
info = font.render("Press ESC to quit", True, (150, 150, 150))
|
||||
screen.blit(info, (300, 330))
|
||||
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.QUIT:
|
||||
state = "quit"
|
||||
|
||||
if event.type == pygame.KEYDOWN:
|
||||
if event.key == pygame.K_ESCAPE:
|
||||
state = "quit"
|
||||
|
||||
elif state == "quit":
|
||||
running = False
|
||||
|
||||
pygame.display.flip()
|
||||
|
||||
pygame.quit()
|
||||
Reference in New Issue
Block a user