Antoni09's picture
Upload src/app.py with huggingface_hub
df733e0 verified
Raw
History Blame Contribute Delete
41.9 kB
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()