Implement a basic game loop that draws the startscreen, and the map after you hit the right button
152 lines
5.2 KiB
Python
152 lines
5.2 KiB
Python
"""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)
|
|
|