YAP/new_gui.py

307 lines
10 KiB
Python
Raw Normal View History

2026-10-04 19:17:16 +02:00
# libraries
from enum import Enum
from functools import lru_cache
import pygame
# project modules
from constants import *
# NOTE_1
# Refactored using AI (Claude), did a great job, uses some obscure syntax
# still much better than the original which was becoming unusable
# I will use some of the stuff introduced by AI in this file to refactor all the other files
# NOTE_2
# The .get() function takes 2 values, the first is the field of the dictionary, the second
# is a default value to return if the key is not found
# -------------------------------------------------------------
# Enum and tables
# -------------------------------------------------------------
class ButtonTypes(Enum):
# The value doubles as the asset prefix: assets/<value>_btn.png
# and assets/<value>_dark_btn.png
PLAY = "play"
RESUME = "resume"
MENU = "menu"
DELETE = "delete"
QUIT = "quit"
RETRY = "retry"
FW = "fw"
BW = "bw"
GO = "go"
class MenuType(Enum):
PAUSE = 0
COMPLETED = 1
START = 2
SELECT = 3
class EntryState(Enum):
UNLOCKED = 0
LOCKED = 1
# Unscaled hitbox sizes (width, height); anything not listed uses the default
DEFAULT_HITBOX = (232, 82) # Defaults to a normal button
HITBOX_SIZES = {
ButtonTypes.FW: (105, 82),
ButtonTypes.BW: (105, 82),
ButtonTypes.GO: (83, 63),
}
CARD_FILES = {
MenuType.PAUSE: "assets/paused_card.png",
MenuType.COMPLETED: "assets/completed_card.png",
MenuType.START: "assets/menu_card.png",
MenuType.SELECT: "assets/select_card.png",
}
ICON_FILES = {
EntryState.LOCKED: "assets/locked.png",
EntryState.UNLOCKED: "assets/unlocked.png",
}
# Layout constants for SelectEntry
ENTRY_WIDTH = 500
ENTRY_PADDING = 20
ENTRY_ICON_X = 800
# --------------------------------------------------------------------------
# Helpers
# --------------------------------------------------------------------------
@lru_cache(maxsize=None)
def load_image(path, scale=1):
image = pygame.image.load(path).convert_alpha()
if scale != 1:
image = pygame.transform.scale_by(image, scale)
return image
# --------------------------------------------------------------------------
# Widgets
# --------------------------------------------------------------------------
class Widget:
# Anything a MenuPage can stack vertically.
# Note: this notation is called type hinting
rect: pygame.Rect
def place(self, centerx, y):
self.rect.centerx = centerx
self.rect.y = y
# This are just the interface for the methods implemented in the various widgets
def height(self, text_font):
return self.rect.height
def draw(self, screen, mouse, text_font):
raise NotImplementedError
class Button(Widget):
def __init__(self, value, kind=ButtonTypes.PLAY):
# value is a free-form field returned when the button is clicked
self.value = value
self.kind = kind
# f-strings from python 3.6 to embed variables
prefix = f"assets/{kind.value}"
self.image = load_image(f"{prefix}_btn.png", SCALE_FACTOR)
self.dark_image = load_image(f"{prefix}_dark_btn.png", SCALE_FACTOR)
width, height = HITBOX_SIZES.get(kind, DEFAULT_HITBOX)
self.rect = pygame.Rect(0, 0, int(width * SCALE_FACTOR), int(height * SCALE_FACTOR))
# text font is none because buttons have text in the image but the interface is the same for all widgets
def height(self, text_font=None):
return self.image.get_height() / 2
def clicked(self, mouse):
return self.rect.collidepoint(mouse)
def draw(self, screen, mouse, text_font=None):
image = self.dark_image if self.clicked(mouse) else self.image
# The image is always centred on the hitbox, so nothing to keep in sync
screen.blit(image, image.get_rect(center=self.rect.center))
class TextArea(Widget):
def __init__(self, text, color):
self.text = text
self.color = color
self.rect = pygame.Rect(0, 0, 0, 0)
def height(self, text_font):
return text_font.size(self.text)[1]
def draw(self, screen, mouse, text_font, anchor="midtop"):
# The anchor can be any rect attribute like midtop, topleft etc
surface = text_font.render(self.text, True, self.color)
position = getattr(self.rect, anchor)
screen.blit(surface, surface.get_rect(**{anchor: position}))
class SelectEntry(Widget):
# One row of the level-select menu: [go button] [label] ........ [lock icon]
def __init__(self, text, value, state):
self.value = value
self.btn = Button(value, ButtonTypes.GO)
self.label = TextArea(text, DARK)
self.icon = load_image(ICON_FILES[state])
self.icon_rect = self.icon.get_rect()
self.rect = pygame.Rect(0, 0, ENTRY_WIDTH, self.btn.rect.height)
def place(self, centerx, y):
super().place(centerx, y)
x = self.rect.x + ENTRY_PADDING
self.btn.rect.topleft = (x, self.rect.y)
self.label.rect.midleft = (x + self.btn.rect.width * 3, self.rect.centery)
self.icon_rect.topleft = (ENTRY_ICON_X, self.rect.y)
def clicked(self, mouse):
return self.btn.clicked(mouse)
def draw(self, screen, mouse, text_font):
self.btn.draw(screen, mouse)
self.label.draw(screen, mouse, text_font, anchor="center")
screen.blit(self.icon, self.icon_rect)
# --------------------------------------------------------------------------
# Menu page
# --------------------------------------------------------------------------
class MenuPage:
"""One card with optional top text, buttons, entries and bottom text,
stacked vertically and centred on screen."""
def __init__(self, kind):
self.top_text_area = None
self.bottom_text_area = None
self.buttons = []
self.entries = []
self.total_height = 0 # set by draw(), read by Menu
self.bg_image = load_image(CARD_FILES[kind], SCALE_FACTOR)
@property
def is_full(self):
return len(self.buttons) + len(self.entries) >= MAX_BTN
def add_button(self, value, kind):
self.buttons.append(Button(value, kind))
def add_entry(self, text, value, state):
self.entries.append(SelectEntry(text, value, state))
def add_top_text(self, text):
self.top_text_area = TextArea(text, DARK)
def add_bottom_text(self, text):
self.bottom_text_area = TextArea(text, DARK)
def _elements(self):
elements = []
if self.top_text_area:
elements.append(self.top_text_area)
elements.extend(self.buttons)
elements.extend(self.entries)
if self.bottom_text_area:
elements.append(self.bottom_text_area)
return elements
def draw(self, screen, mouse, btn_font, text_font):
# btn_font is unused now; kept so existing calls don't break
center_x, center_y = screen.get_rect().center
screen.blit(self.bg_image, self.bg_image.get_rect(center=(center_x, center_y)))
elements = self._elements()
spacing = BTN_HEIGHT * 0.5
heights = [e.height(text_font) for e in elements]
self.total_height = sum(heights) + spacing * max(len(elements) - 1, 0)
y = center_y - self.total_height / 2
for element, height in zip(elements, heights):
element.place(center_x, y)
element.draw(screen, mouse, text_font)
y += height + spacing
def handle_event(self, event, mouse):
if event.type == pygame.QUIT:
return "quit_game"
if event.type == pygame.MOUSEBUTTONDOWN:
for clickable in (*self.buttons, *self.entries):
if clickable.clicked(mouse):
return clickable.value
return None
# --------------------------------------------------------------------------
# Multi-page menu
# --------------------------------------------------------------------------
class Menu:
"""Paged menu: new pages are created automatically when one is full.
For now pages hold only buttons or entries, no top/bottom text."""
def __init__(self, menu_type):
self.menu_type = menu_type
self.pages = [MenuPage(menu_type)]
self.current_page_i = 0
self.prev_btn = Button(None, ButtonTypes.BW)
self.menu_btn = Button("menu", ButtonTypes.MENU)
self.next_btn = Button(None, ButtonTypes.FW)
self.nav_buttons = (self.prev_btn, self.menu_btn, self.next_btn)
@property
def page(self):
return self.pages[self.current_page_i]
def _page_with_room(self):
if self.pages[-1].is_full:
self.pages.append(MenuPage(self.menu_type))
return self.pages[-1]
def add_button(self, value, kind):
self._page_with_room().add_button(value, kind)
def add_entry(self, text, value, state):
self._page_with_room().add_entry(text, value, state)
def draw(self, screen, mouse, btn_font, text_font):
center_x, center_y = screen.get_rect().center
self.page.draw(screen, mouse, btn_font, text_font)
spacing = BTN_HEIGHT * 0.5
bottom_y = center_y + self.page.total_height / 2 + spacing
group_width = sum(b.rect.width for b in self.nav_buttons)
step = group_width / 2.5
# Left / middle / right, spaced `step` apart around the centre
for i, button in enumerate(self.nav_buttons):
button.place(center_x + (i - 1) * step, bottom_y)
button.draw(screen, mouse)
def handle_event(self, event, mouse):
if event.type == pygame.MOUSEBUTTONDOWN:
if self.prev_btn.clicked(mouse):
self.current_page_i = max(self.current_page_i - 1, 0)
return None
if self.next_btn.clicked(mouse):
self.current_page_i = min(self.current_page_i + 1, len(self.pages) - 1)
return None
if self.menu_btn.clicked(mouse):
return self.menu_btn.value
# Also handles pygame.QUIT and clicks on the page's own buttons
return self.page.handle_event(event, mouse)