from __future__ import annotations import math from pathlib import Path import asyncio from dataclasses import dataclass from typing import Callable, Dict, List, Optional import pygame from config.settings_schema import SETTINGS_SCHEMA, parse_resolution from src.assets import ASSETS, IconAtlas from src.backgrounds import LayerSpec, ParallaxBackground from src.bootstrap import init_pygame from src.game import Game from src.settings_io import load_settings, save_settings class Screen: def handle_event(self, event: pygame.event.Event) -> Optional[str]: return None def update(self, dt: float) -> Optional[str]: return None def draw(self) -> None: raise NotImplementedError @dataclass class MenuItem: label: str action: str enabled: bool = True class MenuScreen(Screen): def __init__(self, app: "App", title: str, items: List[MenuItem], subtitle: str | None = None): self.app = app self.title = title self.items = items self.subtitle = subtitle self.index = 0 self._ensure_valid_index() def _ensure_valid_index(self): if not self.items: self.index = 0 return self.index %= len(self.items) for _ in range(len(self.items)): if self.items[self.index].enabled: return self.index = (self.index + 1) % len(self.items) def handle_event(self, event: pygame.event.Event) -> Optional[str]: if event.type != pygame.KEYDOWN: return None if event.key in (pygame.K_UP, pygame.K_w): self.index = (self.index - 1) % len(self.items) self._ensure_valid_index() return None if event.key in (pygame.K_DOWN, pygame.K_s): self.index = (self.index + 1) % len(self.items) self._ensure_valid_index() return None if event.key in (pygame.K_RETURN, pygame.K_SPACE): item = self.items[self.index] if item.enabled: if item.action == "language": self.app.toggle_language() if hasattr(self.app, "_apply_main_menu_localization"): try: self.app._apply_main_menu_localization(self) except Exception: pass return None return item.action if event.key == pygame.K_ESCAPE: return "back" return None def draw(self) -> None: screen = self.app.screen screen.fill((5, 6, 12)) self.app.background_preview(screen) font = self.app.font big = self.app.big_font w, h = screen.get_size() title_s = big.render(self.title, True, (230, 230, 230)) screen.blit(title_s, (w // 2 - title_s.get_width() // 2, 90)) if self.subtitle: sub = font.render(self.subtitle, True, (180, 200, 255)) screen.blit(sub, (w // 2 - sub.get_width() // 2, 140)) y = 240 for i, item in enumerate(self.items): active = i == self.index col = (230, 230, 230) if item.enabled else (120, 120, 120) if active and item.enabled: col = (120, 200, 120) text = f"> {item.label}" if active else f" {item.label}" surf = font.render(text, True, col) screen.blit(surf, (w // 2 - 140, y)) y += 30 footer = self.app.draw_funding_footer(screen) hint_text = self.app.menu_hint_text() if hasattr(self.app, "menu_hint_text") else "↑/↓ Enter Esc Back" hint = font.render(hint_text, True, (140, 160, 200)) hint_y = h - 48 if footer: hint_y = min(hint_y, footer.y - hint.get_height() - 10) screen.blit(hint, (w // 2 - hint.get_width() // 2, hint_y)) class SettingsScreen(Screen): def __init__(self, app: "App"): self.app = app self.category_keys = list(SETTINGS_SCHEMA.keys()) self.cat_index = 0 self.items: list[tuple[str, str, dict]] = [] self.item_index = 0 self.working: Dict[str, object] = dict(app.settings) self._rebuild_items() self._toast: str | None = None self._toast_t = 0.0 def _rebuild_items(self): self.items = [] cat = self.category_keys[self.cat_index] for key, spec in SETTINGS_SCHEMA[cat].items(): self.items.append((cat, key, spec)) # type: ignore[arg-type] self.item_index = max(0, min(self.item_index, max(0, len(self.items) - 1))) def handle_event(self, event: pygame.event.Event) -> Optional[str]: if event.type != pygame.KEYDOWN: return None if event.key == pygame.K_ESCAPE: self.app.settings.update(self.working) save_settings(self.app.settings) return "back" if event.key in (pygame.K_TAB, pygame.K_RIGHTBRACKET): self.cat_index = (self.cat_index + 1) % len(self.category_keys) self._rebuild_items() return None if event.key in (pygame.K_LEFTBRACKET,): self.cat_index = (self.cat_index - 1) % len(self.category_keys) self._rebuild_items() return None if event.key in (pygame.K_UP, pygame.K_w): self.item_index = (self.item_index - 1) % len(self.items) return None if event.key in (pygame.K_DOWN, pygame.K_s): self.item_index = (self.item_index + 1) % len(self.items) return None if event.key in (pygame.K_LEFT, pygame.K_a): self._adjust(-1) return None if event.key in (pygame.K_RIGHT, pygame.K_d): self._adjust(+1) return None if event.key in (pygame.K_RETURN, pygame.K_SPACE): self._toggle() return None return None def _adjust(self, direction: int): if not self.items: return cat, key, spec = self.items[self.item_index] typ = spec.get("type") if typ == "slider": mn = int(spec.get("min", 0)) mx = int(spec.get("max", 100)) step = 5 cur = int(self.working.get(key, spec.get("default", 0))) cur = max(mn, min(mx, cur + direction * step)) self.working[key] = cur elif typ == "selector": opts = list(spec.get("options", [])) if not opts: return cur = self.working.get(key, spec.get("default")) try: idx = opts.index(cur) except ValueError: idx = 0 idx = (idx + direction) % len(opts) val = opts[idx] if key == "resolution" and isinstance(val, str): self.working[key] = parse_resolution(val) else: self.working[key] = val elif typ == "toggle": self._toggle() self._mark_toast_if_restart(spec) self._apply_live_settings() def _toggle(self): if not self.items: return _, key, spec = self.items[self.item_index] typ = spec.get("type") if typ != "toggle": return cur = bool(self.working.get(key, spec.get("default", False))) self.working[key] = not cur self._mark_toast_if_restart(spec) self._apply_live_settings() def _mark_toast_if_restart(self, spec: dict): if spec.get("requires_restart"): self._toast = "Requires restart to apply" self._toast_t = 2.0 def _apply_live_settings(self): # Apply audio/show_fps immediately for current gameplay if present. if self.app._last_gameplay is None: return game = self.app._last_gameplay.game try: from src.audio import AudioVolumes vols = AudioVolumes( master=float(self.working.get("master_volume", 80)) / 100.0, music=float(self.working.get("music_volume", 50)) / 100.0, sfx=float(self.working.get("sfx_volume", 80)) / 100.0, ) game.audio.set_volumes(vols) except Exception: return def update(self, dt: float) -> Optional[str]: if self._toast_t > 0: self._toast_t = max(0.0, self._toast_t - dt) if self._toast_t <= 0: self._toast = None return None def draw(self) -> None: screen = self.app.screen screen.fill((5, 6, 12)) self.app.background_preview(screen) font = self.app.font big = self.app.big_font w, h = screen.get_size() title_s = big.render("SETTINGS", True, (230, 230, 230)) screen.blit(title_s, (w // 2 - title_s.get_width() // 2, 60)) cats = " ".join([f"[{c.upper()}]" if i == self.cat_index else c.upper() for i, c in enumerate(self.category_keys)]) screen.blit(font.render(cats, True, (180, 200, 255)), (w // 2 - 260, 110)) y = 160 for i, (cat, key, spec) in enumerate(self.items): active = i == self.item_index label = str(spec.get("label", key)) typ = spec.get("type") val = self.working.get(key, spec.get("default")) if key == "resolution" and isinstance(val, tuple): val_str = f"{val[0]}x{val[1]}" else: val_str = str(val) if typ == "toggle": val_str = "ON" if bool(val) else "OFF" line = f"{label:22} {val_str}" col = (120, 200, 120) if active else (230, 230, 230) screen.blit(font.render(("> " if active else " ") + line, True, col), (w // 2 - 260, y)) y += 26 footer = self.app.draw_funding_footer(screen) hint = font.render("TAB change category ←/→ adjust ESC save&back", True, (140, 160, 200)) hint_y = h - 48 if footer: hint_y = min(hint_y, footer.y - hint.get_height() - 10) screen.blit(hint, (w // 2 - hint.get_width() // 2, hint_y)) if self._toast: toast = font.render(self._toast, True, (230, 230, 230)) toast_y = hint_y - toast.get_height() - 6 screen.blit(toast, (w // 2 - toast.get_width() // 2, toast_y)) class GameplayScreen(Screen): def __init__(self, app: "App"): self.app = app self.game = Game(app.screen, app.clock, app.settings) def handle_event(self, event: pygame.event.Event) -> Optional[str]: if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: # Pause only when the game isn't showing an internal modal. if not (self.game.crafting.open or self.game.level_complete_open or self.game.game_over_open): return "pause" self.game.handle_event(event) # If user chose "NO" on game-over, return to menu. if getattr(self.game, "quit_to_menu_requested", False): return "menu" return None def update(self, dt: float) -> Optional[str]: self.game.update(dt) if getattr(self.game, "quit_to_menu_requested", False): return "menu" return None def draw(self) -> None: self.game.draw(self.app.clock.get_fps()) class PauseScreen(MenuScreen): def __init__(self, app: "App", gameplay: GameplayScreen): super().__init__( app, "PAUSED", [ MenuItem("RESUME", "resume"), MenuItem("SETTINGS", "settings"), MenuItem("MAIN MENU", "menu"), ], subtitle="ESC to resume", ) self._gameplay = gameplay def handle_event(self, event: pygame.event.Event) -> Optional[str]: if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: return "resume" return super().handle_event(event) def draw(self) -> None: # Draw frozen gameplay behind the pause UI. self._gameplay.draw() dim = pygame.Surface(self.app.screen.get_size(), pygame.SRCALPHA) dim.fill((0, 0, 0, 140)) self.app.screen.blit(dim, (0, 0)) screen = self.app.screen font = self.app.font big = self.app.big_font w, h = screen.get_size() title_s = big.render(self.title, True, (230, 230, 230)) screen.blit(title_s, (w // 2 - title_s.get_width() // 2, 120)) if self.subtitle: sub = font.render(self.subtitle, True, (180, 200, 255)) screen.blit(sub, (w // 2 - sub.get_width() // 2, 170)) y = 260 for i, item in enumerate(self.items): active = i == self.index col = (230, 230, 230) if item.enabled else (120, 120, 120) if active and item.enabled: col = (120, 200, 120) text = f"> {item.label}" if active else f" {item.label}" surf = font.render(text, True, col) screen.blit(surf, (w // 2 - 140, y)) y += 30 hint = font.render("↑/↓ Enter Esc Resume", True, (140, 160, 200)) screen.blit(hint, (w // 2 - hint.get_width() // 2, h - 48)) @dataclass(frozen=True) class HowToPage: title_pl: str title_en: str bullets_pl: list[str] bullets_en: list[str] icons: tuple[str, ...] = () class HowToPlayScreen(Screen): def __init__(self, app: "App", *, next_action: str | None = None): self.app = app self.next_action = next_action self.page = 0 self._icons = self._load_icons() self._pages: list[HowToPage] = [ HowToPage( title_pl="Sterowanie", title_en="Controls", bullets_pl=[ "Ruch: WASD / Strzałki", "Strzał: LCTRL lub LPM", "Crafting: E (pauzuje świat)", "Super magnes: SPACE (cooldown)", "Pauza: ESC", ], bullets_en=[ "Move: WASD / Arrow Keys", "Fire: LCTRL or LMB", "Crafting: E (pauses the world)", "Super magnet: SPACE (cooldown)", "Pause: ESC", ], icons=("ship", "bullet_laser"), ), HowToPage( title_pl="Zbieranie i crafting", title_en="Loot & crafting", bullets_pl=[ "Asteroidy: metale (steel/aluminum/copper)", "Wrogowie: zaawansowane materiały i bonusy", "E: wybierz broń / ulepszenia / statki", "Broń z craftingu ma priorytet nad dropami", ], bullets_en=[ "Asteroids: metals (steel/aluminum/copper)", "Enemies: advanced materials and power-ups", "E: choose weapons / upgrades / ships", "Crafted weapons have priority over drops", ], icons=("steel_scrap", "aluminum_sheet", "copper_wire"), ), HowToPage( title_pl="Bonusy i efekty", title_en="Power-ups & effects", bullets_pl=[ "Magnes: przyciąga materiały do statku", "Regen: powolne leczenie przez chwilę", "Shield: blokuje obrażenia przez krótki czas", "Multishot: potrójny strzał (na Laser Cannon)", ], bullets_en=[ "Magnet: pulls loot towards your ship", "Regen: heals over time for a short while", "Shield: blocks damage for a short while", "Multishot: triple shot (Laser Cannon only)", ], icons=("status_regen", "status_shield", "status_multishot", "powerup_super_magnet"), ), HowToPage( title_pl="Misje i bossowie", title_en="Missions & bosses", bullets_pl=[ "Każdy poziom ma 1 misję", "Po ukończeniu misji pojawia się boss", "Pokonaj bossa, aby przejść dalej", "Po śmierci restartujesz bieżący poziom", ], bullets_en=[ "Each level has 1 mission", "After completing it, a boss appears", "Defeat the boss to proceed", "On death you restart the current level", ], icons=("boss", "bullet_missile"), ), ] def _load_icons(self) -> dict[str, pygame.Surface]: icons: dict[str, pygame.Surface] = {} def safe_scale(surf: pygame.Surface, size: int) -> pygame.Surface: w, h = surf.get_size() scale = size / max(1, max(w, h)) return pygame.transform.smoothscale(surf, (max(2, int(w * scale)), max(2, int(h * scale)))) # Materials/icons from All.png (same mapping as in-game). try: sheet = ASSETS.load_image(ASSETS.sprite_sheet_path()) atlas = IconAtlas(sheet, cell_size=16) mapping = { "steel_scrap": (1, 0), "aluminum_sheet": (2, 0), "copper_wire": (3, 0), } for key, (c, r) in mapping.items(): icons[key] = safe_scale(atlas.get(c, r), 22) except Exception: pass # Status icons from Bonuses-0001.png try: sheet = ASSETS.load_image(Path(__file__).resolve().parent.parent / "Bonuses-0001.png") cell = 32 def cut(col: int, row: int) -> pygame.Surface: r = pygame.Rect(col * cell, row * cell, cell, cell) s = pygame.Surface((cell, cell), pygame.SRCALPHA) s.blit(sheet, (0, 0), r) return safe_scale(s, 22) icons["status_regen"] = cut(0, 0) icons["status_shield"] = cut(1, 0) icons["status_multishot"] = cut(3, 0) except Exception: pass # Bullets from Bullets-0001.png (cells chosen to match current player weapon colors). try: sheet = ASSETS.load_image(Path(__file__).resolve().parent.parent / "Bullets-0001.png") cell = 32 def cut(col: int, row: int) -> pygame.Surface: r = pygame.Rect(col * cell, row * cell, cell, cell) s = pygame.Surface((cell, cell), pygame.SRCALPHA) s.blit(sheet, (0, 0), r) s = pygame.transform.rotate(s, -90) return safe_scale(s, 24) icons["bullet_laser"] = cut(4, 2) icons["bullet_missile"] = cut(4, 3) except Exception: pass # Ship preview from SpaceShips_Player-0001.png (cell used by Salvager One). try: sheet = ASSETS.load_image(Path(__file__).resolve().parent.parent / "SpaceShips_Player-0001.png") cell = 64 c, r = (2, 1) rect = pygame.Rect(c * cell, r * cell, cell, cell) s = pygame.Surface((cell, cell), pygame.SRCALPHA) s.blit(sheet, (0, 0), rect) s = pygame.transform.rotate(s, -90) icons["ship"] = safe_scale(s, 38) except Exception: pass # Boss preview from SpaceShip_Boss-0001.png try: sheet = ASSETS.load_image(Path(__file__).resolve().parent.parent / "SpaceShip_Boss-0001.png") cell = 96 rect = pygame.Rect(0, 0, cell, cell) s = pygame.Surface((cell, cell), pygame.SRCALPHA) s.blit(sheet, (0, 0), rect) s = pygame.transform.rotate(s, -90) icons["boss"] = safe_scale(s, 36) except Exception: pass # Use one of the existing in-game tinted power-up icons if possible. try: power_path = ASSETS.resolve("spaceship_gamekit", "spritesheets", "power-up.png") if power_path.exists(): base = ASSETS.load_image(power_path) base = safe_scale(base, 22) overlay = pygame.Surface(base.get_size()) overlay.fill((110, 220, 255)) out = base.copy() out.blit(overlay, (0, 0), special_flags=pygame.BLEND_RGB_ADD) icons["powerup_super_magnet"] = out except Exception: pass return icons def handle_event(self, event: pygame.event.Event) -> Optional[str]: if event.type != pygame.KEYDOWN: return None if event.key == pygame.K_ESCAPE: return "back" if event.key in (pygame.K_LEFT, pygame.K_a): self.page = (self.page - 1) % len(self._pages) return None if event.key in (pygame.K_RIGHT, pygame.K_d, pygame.K_TAB): self.page = (self.page + 1) % len(self._pages) return None if event.key in (pygame.K_RETURN, pygame.K_SPACE): if self.page >= len(self._pages) - 1 and self.next_action: return self.next_action self.page = min(len(self._pages) - 1, self.page + 1) return None return None def draw(self) -> None: screen = self.app.screen screen.fill((5, 6, 12)) self.app.background_preview(screen) w, h = screen.get_size() font = self.app.font big = self.app.big_font page = self._pages[self.page] title = big.render("HOW TO PLAY / INSTRUKCJA", True, (230, 230, 230)) screen.blit(title, (w // 2 - title.get_width() // 2, 44)) # Main panel panel_w = min(920, w - 120) panel_h = min(520, h - 200) panel = pygame.Rect((w - panel_w) // 2, 120, panel_w, panel_h) pygame.draw.rect(screen, (10, 12, 18), panel) pygame.draw.rect(screen, (80, 140, 200), panel, 2) # Columns: PL | EN mid = panel.x + panel.w // 2 pygame.draw.line(screen, (40, 60, 90), (mid, panel.y + 10), (mid, panel.bottom - 10)) # Headings screen.blit(font.render(f"PL: {page.title_pl}", True, (180, 200, 255)), (panel.x + 18, panel.y + 16)) screen.blit(font.render(f"EN: {page.title_en}", True, (180, 200, 255)), (mid + 18, panel.y + 16)) # Icons row ix = panel.x + 18 iy = panel.y + 42 for key in page.icons: icon = self._icons.get(key) if icon is None: continue screen.blit(icon, (ix, iy)) ix += icon.get_width() + 8 ix = mid + 18 for key in page.icons: icon = self._icons.get(key) if icon is None: continue screen.blit(icon, (ix, iy)) ix += icon.get_width() + 8 # Bullets y0 = panel.y + 78 line_h = font.get_height() + 6 for i, text in enumerate(page.bullets_pl): screen.blit(font.render(f"- {text}", True, (230, 230, 230)), (panel.x + 18, y0 + i * line_h)) for i, text in enumerate(page.bullets_en): screen.blit(font.render(f"- {text}", True, (230, 230, 230)), (mid + 18, y0 + i * line_h)) # Footer / hints page_txt = font.render(f"{self.page + 1}/{len(self._pages)}", True, (180, 200, 255)) screen.blit(page_txt, (panel.right - page_txt.get_width() - 14, panel.bottom - page_txt.get_height() - 12)) hint = "←/→ Prev/Next Enter Next Esc Back" if self.page >= len(self._pages) - 1 and self.next_action: hint = "Enter START GAME / Enter ROZPOCZNIJ GRĘ | Esc Back" footer = self.app.draw_funding_footer(screen) hint_s = font.render(hint, True, (140, 160, 200)) hint_y = h - 48 if footer: hint_y = min(hint_y, footer.y - hint_s.get_height() - 10) screen.blit(hint_s, (w // 2 - hint_s.get_width() // 2, hint_y)) class StoryIntroScreen(Screen): def __init__(self, app: "App", *, next_action: str | None = None): self.app = app self.next_action = next_action self.page = 0 self.lang = "Polish" if str(app.settings.get("language", "Polish")) == "Polish" else "English" self._pages = self._build_pages() def _build_pages(self) -> list[list[str]]: if self.lang == "Polish": return [ [ "Ziemia weszła w epokę Głodu na Surowce (u Was: „głóg na surowcy”) — nie dlatego, że technologia zniknęła, tylko dlatego, że zniknęły materiały. Łańcuchy dostaw się rozpadły, kopalnie są wyczerpane lub niedostępne, a produkcja „od zera” stała się luksusem.", "Ostatnią szansą jest projekt C.I.R.C.U.I.T. (Circular Innovation Recycling & Crafting Utility In Transit) — orbitalna Stacja Recyklingu, która nie „produkuje”, tylko odtwarza: odzyskuje metale, przewody, stopy, a nawet elementy elektroniki z kosmicznego złomu. Dzięki temu Ziemia może przejść na gospodarkę obiegu zamkniętego: naprawiaj → odzyskaj → przerób → użyj ponownie.", ], [ "Ty jesteś Agentem Ziemi – wyspecjalizowanym „salvage‑pilotem” i inżynierem polowym. Masz jedno zadanie: zdobyć materiały i moduły, które pozwolą uruchomić Stację Recyklingu zanim planeta „zgaśnie” gospodarczo.", "Każdy poziom = jedna misja, zakończona bossem, bo w tych regionach kosmosu złom nie leży bezpański.", ], ] return [ [ "Earth has entered the Age of Resource Famine — not because technology disappeared, but because materials did. Supply chains collapsed, mines are depleted or unreachable, and producing “from scratch” became a luxury.", "The last hope is the C.I.R.C.U.I.T. project (Circular Innovation Recycling & Crafting Utility In Transit) — an orbital Recycling Station that doesn’t “manufacture”, it restores: it recovers metals, wires, alloys, even electronics from space debris. This lets Earth shift to a circular economy: repair → recover → reprocess → reuse.", ], [ "You are an Earth Agent — a specialized salvage pilot and field engineer. Your single task: secure the materials and modules needed to activate the Recycling Station before the planet’s economy goes dark.", "Each level is one mission ending with a boss, because in these regions of space, scrap is never unclaimed.", ], ] def _wrap(self, text: str, font: pygame.font.Font, max_width: int) -> list[str]: words = text.split() if not words: return [""] lines: list[str] = [] cur = words[0] for word in words[1:]: nxt = f"{cur} {word}" if font.size(nxt)[0] > max_width and cur: lines.append(cur) cur = word else: cur = nxt lines.append(cur) return lines def handle_event(self, event: pygame.event.Event) -> Optional[str]: if event.type != pygame.KEYDOWN: return None if event.key == pygame.K_ESCAPE: return "back" if event.key in (pygame.K_LEFT, pygame.K_a): self.page = (self.page - 1) % len(self._pages) return None if event.key in (pygame.K_RIGHT, pygame.K_d, pygame.K_TAB): self.page = (self.page + 1) % len(self._pages) return None if event.key in (pygame.K_RETURN, pygame.K_SPACE): if self.page >= len(self._pages) - 1 and self.next_action: return self.next_action self.page = min(len(self._pages) - 1, self.page + 1) return None return None def draw(self) -> None: screen = self.app.screen screen.fill((5, 6, 12)) self.app.background_preview(screen) w, h = screen.get_size() font = self.app.font big = self.app.big_font title = "WPROWADZENIE" if self.lang == "Polish" else "PROLOGUE" title_s = big.render(title, True, (230, 230, 230)) screen.blit(title_s, (w // 2 - title_s.get_width() // 2, 44)) panel_w = min(920, w - 120) panel_h = min(520, h - 200) panel = pygame.Rect((w - panel_w) // 2, 120, panel_w, panel_h) pygame.draw.rect(screen, (10, 12, 18), panel) pygame.draw.rect(screen, (80, 140, 200), panel, 2) max_w = panel.w - 40 y = panel.y + 24 for para in self._pages[self.page]: for line in self._wrap(para, font, max_w): screen.blit(font.render(line, True, (230, 230, 230)), (panel.x + 20, y)) y += font.get_height() + 6 y += 10 page_txt = font.render(f"{self.page + 1}/{len(self._pages)}", True, (180, 200, 255)) screen.blit(page_txt, (panel.right - page_txt.get_width() - 14, panel.bottom - page_txt.get_height() - 12)) if self.page >= len(self._pages) - 1 and self.next_action: hint = "Enter DALEJ / NEXT Esc Back" else: hint = "←/→ Prev/Next Enter Next Esc Back" footer = self.app.draw_funding_footer(screen) hint_s = font.render(hint, True, (140, 160, 200)) hint_y = h - 48 if footer: hint_y = min(hint_y, footer.y - hint_s.get_height() - 10) screen.blit(hint_s, (w // 2 - hint_s.get_width() // 2, hint_y)) class App: def __init__(self): self.settings = load_settings() self.screen, self.clock, _ = init_pygame(self.settings) self.settings = load_settings() # normalize after init try: self.font = pygame.font.Font("m6x11plus.ttf", 18) self.big_font = pygame.font.Font("m6x11plus.ttf", 34) self.small_font = pygame.font.Font("m6x11plus.ttf", 14) except Exception: self.font = pygame.font.Font(None, 18) self.big_font = pygame.font.Font(None, 36) self.small_font = pygame.font.Font(None, 14) self._state_stack: list[Screen] = [] self._last_gameplay: GameplayScreen | None = None # Animated menu background (Old Version space pack). self._menu_bg_last_ticks: int | None = None self._menu_bg = ParallaxBackground(self.screen.get_size(), ASSETS) try: self._menu_bg.set_layers( [ LayerSpec(("space_bg_pack", "Assets", "Old Version", "layers", "parallax-space-backgound.png"), 0.18, 255), LayerSpec(("space_bg_pack", "Assets", "Old Version", "layers", "parallax-space-stars.png"), 0.75, 210), LayerSpec(("space_bg_pack", "Assets", "Old Version", "layers", "parallax-space-far-planets.png"), 0.32, 235), ] ) self._menu_bg.set_planet_from_layer(("space_bg_pack", "Assets", "Old Version", "layers", "parallax-space-ring-planet.png"), scale=1.25) except Exception: pass self._funding_logo = self._load_funding_logo() self.push(self.make_main_menu()) def make_main_menu(self) -> Screen: return MenuScreen( self, "SCRAP COLLECTOR", [ MenuItem(self.main_menu_label("new_game"), "new_game"), MenuItem(self.main_menu_label("continue"), "continue", enabled=False), MenuItem(self.language_menu_label(), "language"), MenuItem(self.main_menu_label("howto"), "howto"), MenuItem(self.main_menu_label("settings"), "settings"), MenuItem(self.main_menu_label("quit"), "quit"), ], subtitle=self.menu_subtitle(), ) def language_menu_label(self) -> str: lang = "Polish" if str(self.settings.get("language", "Polish")) == "Polish" else "English" return "JĘZYK: PL" if lang == "Polish" else "LANGUAGE: EN" def main_menu_label(self, action: str) -> str: lang = "Polish" if str(self.settings.get("language", "Polish")) == "Polish" else "English" labels = { "new_game": ("NEW GAME", "NOWA GRA"), "continue": ("CONTINUE", "KONTYNUUJ"), "howto": ("HOW TO PLAY", "INSTRUKCJA"), "settings": ("SETTINGS", "USTAWIENIA"), "quit": ("QUIT", "WYJŚCIE"), } en, pl = labels.get(action, (action.upper(), action.upper())) return pl if lang == "Polish" else en def menu_subtitle(self) -> str: lang = "Polish" if str(self.settings.get("language", "Polish")) == "Polish" else "English" return "Prototyp v0.2" if lang == "Polish" else "Prototype v0.2" def menu_hint_text(self) -> str: lang = "Polish" if str(self.settings.get("language", "Polish")) == "Polish" else "English" return "↑/↓ Enter Esc Wróć" if lang == "Polish" else "↑/↓ Enter Esc Back" def _apply_main_menu_localization(self, menu: MenuScreen) -> None: for item in menu.items: if item.action == "language": item.label = self.language_menu_label() else: item.label = self.main_menu_label(item.action) menu.subtitle = self.menu_subtitle() def toggle_language(self) -> None: lang = "Polish" if str(self.settings.get("language", "Polish")) == "Polish" else "English" self.settings["language"] = "English" if lang == "Polish" else "Polish" save_settings(self.settings) try: cur = self.current if isinstance(cur, MenuScreen) and cur.title == "SCRAP COLLECTOR": self._apply_main_menu_localization(cur) except Exception: pass def push(self, screen: Screen): self._state_stack.append(screen) def pop(self): if self._state_stack: self._state_stack.pop() if not self._state_stack: self._state_stack.append(self.make_main_menu()) def replace(self, screen: Screen): if self._state_stack: self._state_stack.pop() self._state_stack.append(screen) @property def current(self) -> Screen: return self._state_stack[-1] def background_preview(self, surface: pygame.Surface): # Animated parallax background for menus (Old Version pack). try: now = pygame.time.get_ticks() if self._menu_bg_last_ticks is None: dt = 0.016 else: dt = max(0.0, min(0.05, (now - self._menu_bg_last_ticks) / 1000.0)) self._menu_bg_last_ticks = now self._menu_bg.update(dt) self._menu_bg.draw(surface) # Slight dim so menu text stays readable. dim = pygame.Surface(surface.get_size(), pygame.SRCALPHA) dim.fill((0, 0, 0, 70)) surface.blit(dim, (0, 0)) return except Exception: pass # Fallback: minimal animated starfield. w, h = surface.get_size() t = pygame.time.get_ticks() / 1000.0 for i in range(70): x = int((i * 137 + t * 30) % w) y = int((i * 73 + t * 18) % h) surface.fill((90, 90, 110), (x, y, 2, 2)) def _load_funding_logo(self) -> pygame.Surface | None: path = Path(__file__).resolve().parent.parent / "assets" / "ui" / "eu_funding_logo.png" if not path.exists(): return None try: logo = ASSETS.load_image(path) except Exception: return None # Scale to a compact footer size. target_h = 44 scale = target_h / max(1, logo.get_height()) w = max(1, int(logo.get_width() * scale)) h = max(1, int(logo.get_height() * scale)) return pygame.transform.smoothscale(logo, (w, h)) def _wrap_footer_text(self, text: str, max_width: int) -> list[str]: words = text.split() if not words: return [""] lines: list[str] = [] cur = words[0] for word in words[1:]: nxt = f"{cur} {word}" if self.small_font.size(nxt)[0] > max_width and cur: lines.append(cur) cur = word else: cur = nxt lines.append(cur) return lines def draw_funding_footer(self, surface: pygame.Surface) -> pygame.Rect | None: # Funding logo must appear before the funding text. logo = self._funding_logo if logo is None: return None w, h = surface.get_size() pad = 12 text_x = pad + logo.get_width() + 12 max_text_w = max(260, w - text_x - pad) lang = "Polish" if str(self.settings.get("language", "Polish")) == "Polish" else "English" lines = [ "SAGE Initiative: Social and Green Entrepreneurship network for youth", "Project No. 2024-2-PL01-KA220-YOU-000286516", ] if lang == "Polish": disclaimer = ( "Sfinansowane przez Unię Europejską. Wyrażone poglądy i opinie są jednak wyłącznie poglądami " "autorów i niekoniecznie odzwierciedlają stanowisko Unii Europejskiej ani Europejskiej Agencji " "Wykonawczej ds. Edukacji i Kultury (EACEA). Unia Europejska ani EACEA nie ponoszą za nie odpowiedzialności." ) else: disclaimer = ( "Funded by the European Union. Views and opinions expressed are however those of the author(s) only " "and do not necessarily reflect those of the European Union or the European Education and Culture " "Executive Agency (EACEA). Neither the European Union nor EACEA can be held responsible for them." ) lines.extend(self._wrap_footer_text(disclaimer, max_text_w)) # Measure footer height. line_h = self.small_font.get_height() + 2 footer_h = max(logo.get_height(), line_h * len(lines)) + 8 footer = pygame.Rect(8, h - footer_h - 8, w - 16, footer_h) bg = pygame.Surface((footer.w, footer.h), pygame.SRCALPHA) bg.fill((5, 6, 12, 160)) surface.blit(bg, footer) pygame.draw.rect(surface, (60, 90, 130), footer, 1) surface.blit(logo, (footer.x + pad, footer.y + (footer.h - logo.get_height()) // 2)) y = footer.y + 6 for line in lines: surf = self.small_font.render(line, True, (210, 210, 220)) surface.blit(surf, (footer.x + text_x, y)) y += line_h return footer def run(self): running = True while running: dt = self.clock.tick(60) / 1000.0 for event in pygame.event.get(): if event.type == pygame.QUIT: running = False continue action = self.current.handle_event(event) running = running and self._dispatch(action) action = self.current.update(dt) running = running and self._dispatch(action) self.current.draw() pygame.display.flip() pygame.quit() async def run_async(self) -> None: running = True while running: dt = self.clock.tick(60) / 1000.0 for event in pygame.event.get(): if event.type == pygame.QUIT: running = False continue action = self.current.handle_event(event) running = running and self._dispatch(action) action = self.current.update(dt) running = running and self._dispatch(action) self.current.draw() pygame.display.flip() await asyncio.sleep(0) pygame.quit() def _dispatch(self, action: Optional[str]) -> bool: if not action: return True if action == "quit": return False if action == "back": self.pop() return True if action == "howto": self.push(HowToPlayScreen(self)) return True if action == "new_game": # Show story intro first, then instructions, then start the game. self.replace(StoryIntroScreen(self, next_action="howto_start")) return True if action == "howto_start": self.replace(HowToPlayScreen(self, next_action="start_game")) return True if action == "start_game": gp = GameplayScreen(self) self._last_gameplay = gp self.replace(gp) return True if action == "pause": if isinstance(self.current, GameplayScreen): self.push(PauseScreen(self, self.current)) elif self._last_gameplay is not None: self.push(PauseScreen(self, self._last_gameplay)) else: self.push(PauseScreen(self, GameplayScreen(self))) return True if action == "resume": self.pop() return True if action == "settings": self.push(SettingsScreen(self)) return True if action == "menu": self._last_gameplay = None self.replace(self.make_main_menu()) return True return True def main(): if not pygame.get_init(): pygame.init() App().run()