Source code for escapy.pygame.pygame_ui

# Copyright (C) 2026 Matteo Zeccoli Marazzini
#
# This file is part of escapy.
#
# escapy is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# escapy is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for
# more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with escapy. If not, see <https://www.gnu.org/licenses/>.

"""Pygame-based UI implementation for escapy.

Provides :class:`PyGameUi`, a concrete implementation of
:class:`~escapy.protocols.GameUiProtocol` that renders the game using
*pygame*.  The UI is split into three screen regions: the main game
area, an inventory sidebar, and a message bar.
"""

from dataclasses import dataclass
from pathlib import Path

import pygame

from ..events import (
    AskedForCodeEvent,
    Event,
    GameEndedEvent,
    InspectedEvent,
)
from ..messages import MessageProvider
from ..protocols import GameProtocol, GameUiProtocol, InventoryInteractable, Placeable, Unlockable


@dataclass
class _NormalState:
    """Normal gameplay state."""

    pass


@dataclass
class _InsertCodeState:
    """State for inserting a code."""

    object_id: str
    prompt: str | None
    text: str = ""


@dataclass
class _InspectState:
    """State for inspecting an object."""

    object_id: str
    surface: pygame.Surface
    rect: pygame.Rect


type _UIState = _NormalState | _InsertCodeState | _InspectState


[docs] class PyGameUi(GameUiProtocol): """Pygame-based game UI. Manages the display window, input handling, and rendering. Three distinct internal states control how input is interpreted: * **Normal** – clicks on room objects or inventory items. * **InsertCode** – keyboard input for a code prompt. * **Inspect** – fullscreen view of an object image. Args: config: UI configuration dictionary (window size, asset paths, layout fractions, etc.). message_provider: Callable that maps events to display strings. """ def __init__(self, config: dict, message_provider: MessageProvider) -> None: pygame.init() pygame.display.set_caption(config["title"]) # Initialize display width = config["width"] height = config["height"] self.screen = pygame.display.set_mode((width, height)) self.clock = pygame.time.Clock() self.font = pygame.font.SysFont(None, 28) self.fps = config["fps"] # Layout configuration (fractions of screen) self.game_area_horizontal_fraction = config.get("game_area_horizontal_fraction", 0.85) self.game_area_vertical_fraction = config.get("game_area_vertical_fraction", 0.85) self.inventory_columns = config.get("inventory_columns", 2) self.inventory_spacing_fraction = config.get("inventory_spacing_fraction", 0.05) # Calculate initial layout self._calculate_layout() # Load assets assets_dir = Path(config["assets_dir"]) self.room_images = { room: pygame.image.load(assets_dir / image).convert() for room, image in config["rooms"].items() } self.object_images = { object: pygame.image.load(assets_dir / image).convert_alpha() for object, image in config["objects"].items() } self.is_running = False self._state: _UIState = _NormalState() self.messages: list[str] = [] self._get_event_message = message_provider def _calculate_layout(self) -> None: """Calculate all layout dimensions based on current screen size and fractions.""" screen_width, screen_height = self.screen.get_size() # Calculate split lines based on fractions horizontal_split = round(screen_height * self.game_area_vertical_fraction) vertical_split = round(screen_width * self.game_area_horizontal_fraction) # Create subsurfaces (subsurface constructor takes a Rect) self.game_area = self.screen.subsurface(pygame.Rect(0, 0, vertical_split, horizontal_split)) self.message_area = self.screen.subsurface( pygame.Rect(0, horizontal_split, vertical_split, screen_height - horizontal_split) ) self.inventory_area = self.screen.subsurface( pygame.Rect(vertical_split, 0, screen_width - vertical_split, screen_height) ) # Calculate inventory layout inventory_width = self.inventory_area.get_width() self.inventory_object_spacing = self.inventory_spacing_fraction * inventory_width available_width = inventory_width - (self.inventory_object_spacing * (self.inventory_columns + 1)) self.inventory_object_size = available_width / self.inventory_columns
[docs] def init(self, game: GameProtocol): """Bind the UI to a :class:`~escapy.protocols.GameProtocol` and start running.""" self.game = game self._update_objects() self.is_running = True
[docs] def tick(self): """Advance the clock to regulate the frame rate.""" self.clock.tick(self.fps)
[docs] def input(self) -> list[Event]: """Process all pending pygame events and return game events.""" events: list[Event] = [] for event in pygame.event.get(): if event.type == pygame.QUIT: events = self.game.quit() elif isinstance(self._state, _InspectState): events.extend(self._handle_inspect_input(event)) elif isinstance(self._state, _InsertCodeState): events.extend(self._handle_insert_code_input(event)) else: # NormalState events.extend(self._handle_normal_input(event)) return events
def _handle_normal_input(self, event: pygame.event.Event) -> list[Event]: """Handle input when in NORMAL state.""" events: list[Event] = [] if event.type == pygame.MOUSEBUTTONDOWN and not self.game.is_finished: click_pos = event.pos # Determine which area was clicked game_area_offset = self.game_area.get_abs_offset() game_area_abs_rect = self.game_area.get_rect(topleft=game_area_offset) inventory_offset = self.inventory_area.get_abs_offset() inventory_abs_rect = self.inventory_area.get_rect(topleft=inventory_offset) if game_area_abs_rect.collidepoint(click_pos): # Click in game area - check objects for object_id, object_rect in self.objects.items(): abs_rect = object_rect.move(game_area_offset) if abs_rect.collidepoint(click_pos): events = self.game.interact(object_id) elif inventory_abs_rect.collidepoint(click_pos): # Click in inventory area - check inventory objects for object_id, object_rect in self.inventory.items(): abs_rect = object_rect.move(inventory_offset) if abs_rect.collidepoint(click_pos): events = self.game.interact_inventory(object_id) break else: # Clicked in inventory area but not on any object events = self.game.interact_inventory(None) # Clicks in message area are ignored return events def _handle_insert_code_input(self, event: pygame.event.Event) -> list[Event]: """Handle input when in INSERT_CODE state.""" if not isinstance(self._state, _InsertCodeState): return [] events: list[Event] = [] if event.type == pygame.KEYDOWN: if event.key == pygame.K_RETURN: events = self.game.insert_code(self._state.object_id, self._state.text) self._state = _NormalState() elif event.key == pygame.K_ESCAPE: self._state = _NormalState() elif event.key == pygame.K_BACKSPACE: self._state.text = self._state.text[:-1] elif event.unicode and event.unicode.isprintable(): self._state.text += event.unicode return events def _handle_inspect_input(self, event: pygame.event.Event) -> list[Event]: """Handle input when in INSPECT state.""" if event.type in (pygame.KEYDOWN, pygame.MOUSEBUTTONDOWN): self._state = _NormalState() return []
[docs] def render(self): """Draw the current frame (room, objects, inventory, overlays).""" self._update_objects() # Draw room game_area_size = (self.game_area.get_width(), self.game_area.get_height()) room_image = pygame.transform.scale( self.room_images[self.game.current_room_id], game_area_size, ) # TODO: remove transform from game loop if too slow self.game_area.blit(room_image, (0, 0)) # Draw objects for object_id, rect in self.objects.items(): image = pygame.transform.scale( self.object_images[self._get_repr(object_id)], (rect.width, rect.height), ) # TODO: remove transform from game loop if too slow self.game_area.blit(image, rect) # Draw inventory self.inventory_area.fill(pygame.Color(0, 0, 0)) for object_id, rect in self.inventory.items(): image = pygame.transform.scale( self.object_images[self._get_repr(object_id)], (rect.width, rect.height), ) # TODO: remove transform from game loop if too slow self.inventory_area.blit(image, rect) if object_id == self.game.in_hand_object_id: pygame.draw.rect(self.inventory_area, pygame.Color(255, 255, 255), rect, 3) else: pygame.draw.rect(self.inventory_area, pygame.Color(0, 0, 0), rect, 3) # Draw message box self._render_messages() # Render state-specific overlays if isinstance(self._state, _InsertCodeState): self._render_insert_code_overlay() elif isinstance(self._state, _InspectState): self._render_inspect_overlay() pygame.display.flip()
def _render_messages(self) -> None: """Render the last messages in the message area.""" self.message_area.fill(pygame.Color(0, 0, 0)) if not self.messages: return # Calculate how many messages can fit line_height = self.font.get_height() padding = 5 available_height = self.message_area.get_height() - (padding * 2) max_lines = max(1, available_height // line_height) # Get the last N messages that fit messages_to_display = self.messages[-max_lines:] # Render each message y_offset = padding for message in messages_to_display: text_surface = self.font.render(message, True, pygame.Color(255, 255, 255)) self.message_area.blit(text_surface, (padding, y_offset)) y_offset += line_height def _render_insert_code_overlay(self) -> None: """Render the code insertion overlay.""" if not isinstance(self._state, _InsertCodeState): return overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA) overlay.fill((0, 0, 0, 180)) self.screen.blit(overlay, (0, 0)) label = self.font.render(self._state.prompt, True, (255, 255, 255)) box_width = int(self.screen.get_size()[0] * 0.6) box_height = 40 box_x = (self.screen.get_size()[0] - box_width) // 2 box_y = (self.screen.get_size()[1] - box_height) // 2 label_x = (self.screen.get_size()[0] - label.get_width()) // 2 label_y = box_y - 40 self.screen.blit(label, (label_x, label_y)) pygame.draw.rect( self.screen, pygame.Color(255, 255, 255), (box_x, box_y, box_width, box_height), ) pygame.draw.rect( self.screen, pygame.Color(0, 0, 0), (box_x, box_y, box_width, box_height), 2, ) text_surface = self.font.render(self._state.text, True, (0, 0, 0)) text_x = box_x + 10 text_y = box_y + (box_height - text_surface.get_height()) // 2 self.screen.blit(text_surface, (text_x, text_y)) def _render_inspect_overlay(self) -> None: """Render the inspect overlay.""" if not isinstance(self._state, _InspectState): return overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA) overlay.fill((0, 0, 0, 180)) self.screen.blit(overlay, (0, 0)) self.screen.blit(self._state.surface, self._state.rect)
[docs] def handle(self, events: list[Event]) -> None: """React to game events by updating messages and UI state.""" for event in events: # Get configured message for this event message = self._get_event_message(event) if message: self.add_message(message) # Handle state changes match event: case GameEndedEvent(): self.is_running = False case AskedForCodeEvent(object_id=object_id): prompt = message self._state = _InsertCodeState(object_id=object_id, prompt=prompt) case InspectedEvent(object_id=id): self._show_inspect(id) case _: pass
[docs] def quit(self) -> None: """Shut down the pygame display.""" pygame.quit()
[docs] def add_message(self, message: str) -> None: """Add a message to the message list.""" self.messages.append(message)
# TODO: add a Representable protocol to objects and use that to gt the representation key # instead of hardcoding the Unlockable logic here def _get_repr(self, object_id: str) -> str: """Return the image-lookup key for an object, accounting for lock state.""" object = self.game.objects[object_id] if isinstance(object, Unlockable): return f"{object_id}:{object.state}" return object_id def _update_objects(self): """Recalculate screen rects for room objects and inventory items.""" self.objects: dict[str, pygame.Rect] = {} game_area_width = self.game_area.get_width() game_area_height = self.game_area.get_height() for id, position in self.game.rooms[self.game.current_room_id].items(): object = self.game.objects[id] if not isinstance(object, Placeable): raise ValueError("object is not placeable") self.objects[id] = pygame.Rect( position.x * game_area_width, position.y * game_area_height, object.width * game_area_width, object.height * game_area_height, ) self.inventory: dict[str, pygame.Rect] = {} for i, id in enumerate(self.game.inventory): object = self.game.objects[id] if not isinstance(object, InventoryInteractable): raise ValueError("object is not inventory interactable") col = i % self.inventory_columns row = i // self.inventory_columns x = self.inventory_object_spacing + col * (self.inventory_object_size + self.inventory_object_spacing) y = row * (self.inventory_object_size + self.inventory_object_spacing) + self.inventory_object_spacing self.inventory[id] = pygame.Rect( x, y, self.inventory_object_size, self.inventory_object_size, ) def _show_inspect(self, object_id: str) -> None: """Switch to the inspect overlay for the given object.""" image = self.object_images[self._get_repr(object_id)] screen_w, screen_h = self.screen.get_size() max_w = int(screen_w * 0.8) max_h = int(screen_h * 0.8) img_w, img_h = image.get_size() if img_w == 0 or img_h == 0: return scale = min(max_w / img_w, max_h / img_h) target_w = max(1, int(img_w * scale)) target_h = max(1, int(img_h * scale)) surface = pygame.transform.smoothscale(image, (target_w, target_h)) rect = surface.get_rect(center=(screen_w // 2, screen_h // 2)) self._state = _InspectState(object_id=object_id, surface=surface, rect=rect)