Spaces:
Running
Running
| 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 | |
| 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)) | |
| 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) | |
| 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() | |