feat: implement gameloop

Implement a basic game loop that draws the startscreen, and the map after you hit the right button
This commit is contained in:
Niklas Haiden
2026-05-08 15:32:58 +02:00
parent 872786c8e3
commit 20e106cb01
4 changed files with 172 additions and 9 deletions
+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)
+7 -1
View File
@@ -1,6 +1,8 @@
# --- DESK ---
from typing import Mapping, Literal, Tuple
from src.map import tile_size
DESK_WIDTH = 70
DESK_HEIGHT = 40
@@ -30,7 +32,7 @@ DIRECTION_FRAMES: Mapping[Direction, Tuple[int, int, int]] = {
# 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
@@ -91,3 +93,7 @@ COLOR_HP_BAR_YELLOW = (255, 255, 0)
HUD_BAR_WIDTH = 100
HUD_BAR_HEIGHT = 15
PLAYER_SPEED = 180.0
DEMO_ACTOR_HEIGHT = tile_size * 3
FPS = 60
+9 -7
View File
@@ -1,11 +1,13 @@
import pygame
from button import Button
from settings import (COLOR_BUTTON,
COLOR_BUTTON_HOVER,
COLOR_STARTSCREEN_BACKGROUND,
COLOR_STARTSCREEN_FONT_TITLE,
COLOR_STARTSCREEN_FONT_LABEL)
from text_input import TextInput
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):