v1.1: Stufen Easy/Normal/Hard mit Auswahlmenü (Hard = altes Tempo). Koffer behalten beim Drehen ihre Textur. Name/Punkteliste entfernt, nur Stufe + Prozent. Endlos: Ergebnis -> "Nochmals?" -> Stufenauswahl, Ende nur per Esc. storage.py und net.py gelöscht, 48 Tests.

This commit is contained in:
Project Bridge committed 2026-11-01 10:48:52 +00:00
1 parent de550e1d4d
commit d970185e75
15 files changed
+613 -342

No files matched your search

+12 -12
View File
@@ -2,17 +2,17 @@
Aufbau (jedes Modul hat genau eine Aufgabe):
* :mod:`loader_game.config` – alle Masse, Farben, Tempi, Pfade
* :mod:`loader_game.assets` – Bilder, Sounds, Fonts laden und cachen
* :mod:`loader_game.sequences` – Einlesen von ``case_sequences.txt``
* :mod:`loader_game.case` – der Koffer (Geometrie und Bewegung)
* :mod:`loader_game.trailer` – Stapelregeln im Auflieger (reine Logik)
* :mod:`loader_game.input_map` – Tastatur/Controller → abstrakte Aktionen
* :mod:`loader_game.scene` – Zeichnen
* :mod:`loader_game.intro` – Tutorialbild + Ampel + Reissverschluss
* :mod:`loader_game.game` – Zustandsmaschine des Spiels
* :mod:`loader_game.storage` – Laufzeitdaten (letzter Spielername)
* :mod:`loader_game.net` – optionaler Punkte-Upload
* :mod:`loader_game.config` – alle Masse, Farben, Texte, Pfade
* :mod:`loader_game.difficulty` – Stufen Easy/Normal/Hard (Tempo)
* :mod:`loader_game.assets` – Bilder, Sounds, Fonts laden und cachen
* :mod:`loader_game.sequences` – Einlesen von ``case_sequences.txt``
* :mod:`loader_game.case` – der Koffer (Geometrie, Textur, Bewegung)
* :mod:`loader_game.trailer` – Stapelregeln im Auflieger (reine Logik)
* :mod:`loader_game.input_map` – Tastatur/Controller → abstrakte Aktionen
* :mod:`loader_game.scene` – Zeichnen
* :mod:`loader_game.menu` – Stufenauswahl
* :mod:`loader_game.intro` – Tutorialbild + Ampel + Reissverschluss
* :mod:`loader_game.game` – Zustandsmaschine einer Runde
"""
__version__ = "1.0.0"
__version__ = "1.1.0"
+24 -6
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
from dataclasses import dataclass
from . import config
from .difficulty import DEFAULT_DIFFICULTY, Difficulty
@dataclass
@@ -15,9 +16,11 @@ class Case:
"""Ein Koffer im Auflieger.
``units_w`` / ``units_h`` beschreiben die **aktuelle** Grundfläche in
Kasten-Einheiten. Eine Drehung tauscht die beiden Werte – es gibt also
keinen zusätzlichen ``rotated``-Schalter, der mit den Massen kollidieren
könnte (das war im Altstand eine Fehlerquelle).
Kasten-Einheiten. Eine Drehung tauscht die beiden Werte.
``texture`` merkt sich die Orientierung, in der der Koffer eingelaufen ist.
Die Grafik bleibt dadurch immer dieselbe – ein gedrehter Koffer wird nur um
90° gedreht gezeichnet und bekommt keine andere Textur.
"""
units_w: int
@@ -25,11 +28,16 @@ class Case:
x: float
y: float
color: tuple[int, int, int] = config.CASE_COLORS[0]
texture: tuple[int, int] | None = None
placed: bool = False
snapped: bool = False
allow_snap: bool = False
can_rotate: bool = True
def __post_init__(self) -> None:
if self.texture is None:
self.texture = (self.units_w, self.units_h)
# -- Geometrie ------------------------------------------------------
@property
def width(self) -> float:
@@ -47,16 +55,26 @@ class Case:
def bottom(self) -> float:
return self.y + self.height
@property
def is_rotated(self) -> bool:
"""Steht der Koffer quer zu seiner Textur?"""
return (self.units_w, self.units_h) != self.texture
# -- Bewegung -------------------------------------------------------
def move(self, dt: float, wall_x: float) -> None:
def move(
self,
dt: float,
wall_x: float,
difficulty: Difficulty = DEFAULT_DIFFICULTY,
) -> None:
"""Bewegt den Koffer nach links, mit Abbremsen kurz vor der Barriere."""
if self.placed:
return
distance = self.x - wall_x
speed = (
config.CASE_SPEED_FAST
difficulty.speed_fast
if distance > config.FAST_TO_SLOW_DISTANCE
else config.CASE_SPEED_SLOW
else difficulty.speed_slow
)
self.x -= speed * dt
if self.x <= wall_x + config.SNAP_THRESHOLD:
+21 -20
View File
@@ -4,18 +4,18 @@ Alle Geometrieangaben beziehen sich auf eine virtuelle Auflösung von
1920x1080. Das Fenster wird per ``pygame.SCALED`` automatisch auf die
tatsächliche Fenstergrösse skaliert – dadurch sieht das Spiel auf einem
MacBook (Retina, 1440x900, …) genauso aus wie auf einem 1080p-Monitor.
Die Spielgeschwindigkeit steht in :mod:`loader_game.difficulty`.
"""
from __future__ import annotations
import os
from pathlib import Path
# --------------------------------------------------------------------------
# Pfade
# --------------------------------------------------------------------------
BASE_DIR = Path(__file__).resolve().parent.parent
RUNTIME_DIR = BASE_DIR / "runtime"
IMAGES_DIR = BASE_DIR / "images"
FONTS_DIR = BASE_DIR / "fonts"
@@ -66,10 +66,8 @@ MAX_UNITS_W = 4
MAX_UNITS_H = 3
# --------------------------------------------------------------------------
# Spieltempo (jetzt in Pixel pro Sekunde statt pro Frame)
# Spieltempo (Pixel pro Sekunde; Stufen siehe difficulty.py)
# --------------------------------------------------------------------------
CASE_SPEED_FAST = 720 # früher: 6 px/Frame bei 120 fps
CASE_SPEED_SLOW = 240 # früher: 2 px/Frame bei 120 fps
FAST_TO_SLOW_DISTANCE = 120 # ab hier wird abgebremst
SNAP_THRESHOLD = 60 # ab hier darf eingerastet werden
PLATFORM_TOLERANCE = 5 # Toleranz für "bündig an der Barriere"
@@ -105,7 +103,10 @@ COLOR_WALL = (255, 0, 0)
COLOR_WHITE = (255, 255, 255)
COLOR_HINT_SNAP = (0, 255, 0)
COLOR_HINT_STACK = (255, 255, 0)
COLOR_ERROR = (255, 100, 100)
COLOR_DIM = (25, 25, 25)
COLOR_MENU_BOX = (60, 60, 60)
COLOR_MENU_BOX_ACTIVE = (200, 165, 0)
COLOR_MENU_BORDER = (120, 120, 120)
COLOR_PROGRESS_TRACK = (50, 50, 50)
CASE_COLORS = ((200, 0, 0), (0, 200, 0), (0, 0, 200))
@@ -125,27 +126,27 @@ PROGRESS_BAR_RADIUS = 10
FONT_SIZE = 48
FONT_SIZE_SMALL = 34
TITLE_TEXT = WINDOW_TITLE
SUBTITLE_TEMPLATE = "Stufe {difficulty}"
NEXT_LABEL = "Next:"
NEXT_PREVIEW_COUNT = 3
NEXT_PREVIEW_SCALE = 0.6 # Vorschau-Koffer sind kleiner als im Auflieger
NEXT_PREVIEW_MARGIN = 20
HINT_SNAP = "Snap bereit!"
HINT_STACK = "OnTop möglich!"
GAME_OVER_GOOD = "Gratuliere {name}! Du hast {percent}% geladen!"
GAME_OVER_NORMAL = "{name}, du hast {percent}% geladen."
GAME_OVER_HINT = "Drücke A oder SPACE um zu beenden."
GAME_OVER_GOOD_PERCENT = 75
# --------------------------------------------------------------------------
# Spieler / Punkte-Server
# Menü (Stufenauswahl)
# --------------------------------------------------------------------------
DEFAULT_PLAYER_NAME = "Spieler"
MAX_PLAYER_NAME_LENGTH = 14
MENU_TITLE = "Stufe wählen"
MENU_HINT = "← → Stufe wählen A / SPACE starten"
MENU_BOX_WIDTH = 320
MENU_BOX_HEIGHT = 140
MENU_BOX_GAP = 60
MENU_BOX_BORDER = 5
MENU_TOP = 470
# Der Punkte-Upload ist standardmässig deaktiviert. Zum Aktivieren:
# export HAG_SCORE_URL="https://…/api/submit_points.php"
# export HAG_SCORE_API_KEY="…"
# (Schlüssel gehören in die Umgebung, nicht in den Quellcode.)
SCORE_URL = os.environ.get("HAG_SCORE_URL", "")
SCORE_API_KEY = os.environ.get("HAG_SCORE_API_KEY", "")
SCORE_TIMEOUT = 5.0
# --------------------------------------------------------------------------
# Spielende
# --------------------------------------------------------------------------
RESULT_TEMPLATE = "Stufe {difficulty}: {percent}% Beladung"
RESULT_HINT = "Nochmals? A / SPACE = neue Runde ESC = beenden"
+38
View File
@@ -0,0 +1,38 @@
"""Schwierigkeitsstufen: wie schnell die Koffer einlaufen.
``hard`` entspricht dem Tempo des Altstands (6 bzw. 2 Pixel pro Frame bei
120 fps = 720 bzw. 240 Pixel pro Sekunde).
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Difficulty:
"""Eine Stufe. ``speed_fast`` gilt bis kurz vor der Barriere, dann ``speed_slow``."""
key: str
label: str
speed_fast: float
speed_slow: float
def __str__(self) -> str:
return self.label
DIFFICULTIES: tuple[Difficulty, ...] = (
Difficulty("easy", "Easy", speed_fast=420.0, speed_slow=140.0),
Difficulty("normal", "Normal", speed_fast=570.0, speed_slow=190.0),
Difficulty("hard", "Hard", speed_fast=720.0, speed_slow=240.0),
)
DEFAULT_DIFFICULTY: Difficulty = DIFFICULTIES[1]
def by_key(key: str) -> Difficulty:
for difficulty in DIFFICULTIES:
if difficulty.key == key:
return difficulty
return DEFAULT_DIFFICULTY
+39 -40
View File
@@ -8,15 +8,23 @@ from dataclasses import dataclass
from . import config, scene
from .case import Case
from .difficulty import DEFAULT_DIFFICULTY, Difficulty
from .input_map import Action
from .net import submit_score
from .sequences import CaseSpec, FALLBACK_SEQUENCE
from .trailer import Trailer
class State(enum.Enum):
PLAYING = "playing"
GAME_OVER = "game_over"
FINISHED = "finished"
class HandleResult(enum.Enum):
"""Was der Aufrufer nach einer Aktion tun soll."""
CONTINUE = "continue"
NEW_ROUND = "new_round" # zurück zur Stufenauswahl
QUIT = "quit" # Programm beenden (Esc / B)
@dataclass
@@ -45,14 +53,12 @@ class Game:
assets,
sequences,
*,
name: str = config.DEFAULT_PLAYER_NAME,
difficulty: Difficulty = DEFAULT_DIFFICULTY,
rng: random.Random | None = None,
submit_scores: bool = True,
) -> None:
self.assets = assets
self.rng = rng or random.Random()
self.name = name
self.submit_scores = submit_scores
self.difficulty = difficulty
self.state = State.PLAYING
self.trailer = Trailer()
@@ -64,8 +70,6 @@ class Game:
self.animation: Animation | None = None
self.shake = 0.0
self.percent = 0.0
self.status_line = ""
self.status_is_error = False
self._spawn()
@@ -98,20 +102,21 @@ class Game:
# ------------------------------------------------------------------
# Eingabe
# ------------------------------------------------------------------
def handle(self, action: Action) -> bool:
"""Verarbeitet eine Aktion. Rückgabe ``False`` = Spiel beenden."""
def handle(self, action: Action) -> HandleResult:
if action is Action.BACK:
return False
return HandleResult.QUIT
if self.state is State.GAME_OVER:
return action is not Action.PRIMARY
if self.state is State.FINISHED:
if action is Action.PRIMARY:
return HandleResult.NEW_ROUND
return HandleResult.CONTINUE
if self.animation is not None or self.current is None:
return True
return HandleResult.CONTINUE
case = self.current
if case.placed or not case.allow_snap:
return True
return HandleResult.CONTINUE
if action is Action.PRIMARY:
self._start_snap(case)
@@ -119,7 +124,7 @@ class Game:
self._start_rotate(case)
elif action is Action.UP:
self._start_stack(case)
return True
return HandleResult.CONTINUE
def _start_snap(self, case: Case) -> None:
self._stop_roll()
@@ -179,7 +184,7 @@ class Game:
self._finish()
return
case.move(dt, self.trailer.wall_x)
case.move(dt, self.trailer.wall_x, self.difficulty)
if not case.placed and case.x <= self.trailer.wall_x:
self._stack_with_gap(case, sound="fail")
return
@@ -258,25 +263,25 @@ class Game:
self._finish()
def _finish(self) -> None:
if self.state is State.GAME_OVER:
if self.state is State.FINISHED:
return
self.state = State.GAME_OVER
self.state = State.FINISHED
self._stop_roll()
self.percent = self.trailer.load_percent()
if self.percent >= 99.9:
self.percent = 100.0
if self.submit_scores:
ok, message = submit_score(self.name, self.percent)
if ok or config.SCORE_URL:
self.status_line = message
self.status_is_error = not ok
# ------------------------------------------------------------------
# Zeichnen
# ------------------------------------------------------------------
def draw(self, surface) -> None:
assets = self.assets
scene.draw_top_bar(surface, assets, self.trailer.load_percent())
scene.draw_top_bar(
surface,
assets,
self.trailer.load_percent(),
subtitle=config.SUBTITLE_TEMPLATE.format(difficulty=self.difficulty.label),
)
scene.draw_background(surface, assets)
offset = (0, 0)
@@ -304,22 +309,16 @@ class Game:
surface, assets, self.trailer.find_platform(case) is not None
)
if self.state is State.GAME_OVER:
scene.draw_overlay_lines(
surface,
assets,
self.game_over_lines(),
status=self.status_line,
status_color=config.COLOR_ERROR if self.status_is_error else config.COLOR_HINT_SNAP,
)
if self.state is State.FINISHED:
scene.draw_overlay_lines(surface, assets, self.result_lines())
def game_over_lines(self) -> list[str]:
percent = int(self.percent)
if self.percent >= config.GAME_OVER_GOOD_PERCENT:
first = config.GAME_OVER_GOOD.format(name=self.name, percent=percent)
else:
first = config.GAME_OVER_NORMAL.format(name=self.name, percent=percent)
return [first, config.GAME_OVER_HINT]
def result_lines(self) -> list[str]:
return [
config.RESULT_TEMPLATE.format(
difficulty=self.difficulty.label, percent=int(self.percent)
),
config.RESULT_HINT,
]
# ------------------------------------------------------------------
# Hilfen
+3 -2
View File
@@ -15,9 +15,10 @@ class Intro:
Reissverschluss, der den Auflieger "öffnet".
"""
def __init__(self, assets) -> None:
def __init__(self, assets, *, show_instruction: bool = True) -> None:
self.assets = assets
self.phase = "instruction" if assets.instruction is not None else "countdown"
show_tutorial = show_instruction and assets.instruction is not None
self.phase = "instruction" if show_tutorial else "countdown"
self.timer = 0.0
self.dot_index = 0
if self.phase == "countdown":
+100
View File
@@ -0,0 +1,100 @@
"""Startmenü: Schwierigkeitsstufe wählen (Easy / Normal / Hard)."""
from __future__ import annotations
import pygame
from . import config, scene
from .difficulty import DEFAULT_DIFFICULTY, DIFFICULTIES, Difficulty
from .input_map import Action
class DifficultyMenu:
"""Drei Kacheln, Auswahl mit ← / →, Start mit A / Space."""
def __init__(
self,
assets,
options: tuple[Difficulty, ...] = DIFFICULTIES,
selected: Difficulty = DEFAULT_DIFFICULTY,
) -> None:
self.assets = assets
self.options = list(options)
self.index = self.options.index(selected) if selected in self.options else 0
# ------------------------------------------------------------------
@property
def selected(self) -> Difficulty:
return self.options[self.index]
def handle(self, action: Action) -> Difficulty | None:
"""Rückgabe: gewählte Stufe, sobald bestätigt wurde – sonst ``None``."""
if action is Action.LEFT:
self.index = (self.index - 1) % len(self.options)
elif action is Action.RIGHT:
self.index = (self.index + 1) % len(self.options)
elif action is Action.PRIMARY:
return self.selected
return None
# ------------------------------------------------------------------
def draw(self, surface: pygame.Surface) -> None:
scene.draw_top_bar(surface, self.assets, 0.0)
scene.draw_background(surface, self.assets)
scene.clear_bottom_bar(surface)
scene.draw_dim(surface, 170)
font = self.assets.font
small = self.assets.font_small or font
title = font.render(config.MENU_TITLE, True, config.COLOR_WHITE)
surface.blit(
title,
(config.SCREEN_WIDTH // 2 - title.get_width() // 2, config.MENU_TOP - 110),
)
total_width = len(self.options) * config.MENU_BOX_WIDTH + (
len(self.options) - 1
) * config.MENU_BOX_GAP
x = (config.SCREEN_WIDTH - total_width) // 2
for index, difficulty in enumerate(self.options):
rect = pygame.Rect(
x, config.MENU_TOP, config.MENU_BOX_WIDTH, config.MENU_BOX_HEIGHT
)
active = index == self.index
pygame.draw.rect(
surface,
config.COLOR_MENU_BOX_ACTIVE if active else config.COLOR_MENU_BOX,
rect,
border_radius=14,
)
pygame.draw.rect(
surface,
config.COLOR_WHITE if active else config.COLOR_MENU_BORDER,
rect,
width=config.MENU_BOX_BORDER,
border_radius=14,
)
label = font.render(
difficulty.label, True, (0, 0, 0) if active else config.COLOR_WHITE
)
surface.blit(
label,
(
rect.centerx - label.get_width() // 2,
rect.centery - label.get_height() // 2,
),
)
x += config.MENU_BOX_WIDTH + config.MENU_BOX_GAP
hint = small.render(config.MENU_HINT, True, config.COLOR_WHITE)
hint_y = config.MENU_TOP + config.MENU_BOX_HEIGHT + 60
band = pygame.Rect(
config.SCREEN_WIDTH // 2 - hint.get_width() // 2 - 30,
hint_y - 10,
hint.get_width() + 60,
hint.get_height() + 20,
)
pygame.draw.rect(surface, config.COLOR_DIM, band, border_radius=10)
surface.blit(hint, (band.x + 30, hint_y))
-47
View File
@@ -1,47 +0,0 @@
"""Optionaler Punkte-Upload.
Standardmässig **deaktiviert**: Es passiert nichts, solange ``HAG_SCORE_URL``
nicht gesetzt ist. Zugangsdaten kommen aus der Umgebung
(``HAG_SCORE_API_KEY``) – nicht aus dem Quellcode.
"""
from __future__ import annotations
import urllib.error
import urllib.parse
import urllib.request
from . import config
def submit_score(
name: str,
percent: float,
*,
url: str | None = None,
api_key: str | None = None,
timeout: float | None = None,
) -> tuple[bool, str]:
"""Sendet das Ergebnis. Rückgabe: ``(erfolgreich, meldung)``."""
endpoint = url if url is not None else config.SCORE_URL
if not endpoint:
return False, ""
payload = urllib.parse.urlencode(
{
"name": name,
"score": int(percent),
"api_key": api_key if api_key is not None else config.SCORE_API_KEY,
}
).encode("utf-8")
try:
request = urllib.request.Request(endpoint, data=payload)
with urllib.request.urlopen(request, timeout=timeout or config.SCORE_TIMEOUT) as response:
body = response.read().decode("utf-8", "replace")
except (urllib.error.URLError, OSError, ValueError) as exc:
return False, f"Punkte konnten nicht gesendet werden: {exc}"
if "success" not in body.lower():
return False, "Punkte konnten nicht gesendet werden (unerwartete Serverantwort)."
return True, "Punkte gesendet."
+41 -19
View File
@@ -12,13 +12,22 @@ from .sequences import CaseSpec
# --------------------------------------------------------------------------
# Hintergrund-Bänder
# --------------------------------------------------------------------------
def draw_top_bar(surface: pygame.Surface, assets, percent: float) -> None:
def draw_top_bar(surface: pygame.Surface, assets, percent: float, subtitle: str = "") -> None:
surface.fill(
config.COLOR_TOP_BAR, (0, 0, config.SCREEN_WIDTH, config.TOP_BAR_HEIGHT)
)
font = assets.font
title = font.render(config.TITLE_TEXT, True, config.COLOR_WHITE)
surface.blit(title, (40, config.TOP_BAR_HEIGHT // 2 - title.get_height() // 2))
if subtitle:
small = (assets.font_small or font).render(subtitle, True, config.COLOR_WHITE)
surface.blit(
small,
(
40 + title.get_width() + 48,
config.TOP_BAR_HEIGHT // 2 - small.get_height() // 2,
),
)
_draw_progress_bar(surface, assets, percent)
@@ -56,11 +65,21 @@ def draw_background(surface: pygame.Surface, assets) -> None:
)
def draw_bottom_bar(surface: pygame.Surface, assets, upcoming: list[CaseSpec]) -> None:
def clear_bottom_bar(surface: pygame.Surface) -> None:
"""Untere Leiste schwärzen (ohne Inhalt)."""
surface.fill(
config.COLOR_BOTTOM_BAR,
(0, config.TOP_BAR_HEIGHT + config.GAME_AREA_HEIGHT, config.SCREEN_WIDTH, config.BOTTOM_BAR_HEIGHT),
(
0,
config.TOP_BAR_HEIGHT + config.GAME_AREA_HEIGHT,
config.SCREEN_WIDTH,
config.BOTTOM_BAR_HEIGHT,
),
)
def draw_bottom_bar(surface: pygame.Surface, assets, upcoming: list[CaseSpec]) -> None:
clear_bottom_bar(surface)
top = config.SCREEN_HEIGHT - config.BOTTOM_BAR_HEIGHT
label = (assets.font_small or assets.font).render(config.NEXT_LABEL, True, config.COLOR_WHITE)
surface.blit(label, (40, top + 12))
@@ -140,12 +159,19 @@ def _spec_size(spec: CaseSpec) -> tuple[int, int]:
def draw_case(surface: pygame.Surface, assets, case: Case, angle: float = 0.0) -> None:
"""Zeichnet einen Koffer – immer mit **seiner eigenen** Textur.
Ein gedrehter Koffer wird um 90° gedreht gezeichnet, bekommt aber keine
andere Grafik.
"""
x, y = int(case.x), int(case.y)
width, height = int(case.width), int(case.height)
image = assets.case_surface(case.units_w, case.units_h) if assets is not None else None
image = assets.case_surface(*case.texture) if assets is not None else None
if image is None:
pygame.draw.rect(surface, case.color, (x, y, width, height))
return
if case.is_rotated:
image = pygame.transform.rotate(image, -90)
if angle:
rotated = pygame.transform.rotate(image, angle)
surface.blit(rotated, rotated.get_rect(bottomleft=(x, y + height)))
@@ -171,23 +197,26 @@ def draw_hint(surface: pygame.Surface, assets, stack_possible: bool) -> None:
)
def draw_dim(surface: pygame.Surface, alpha: int = 170) -> None:
"""Dunkelt das ganze Bild ab (für Menü und Ergebnis)."""
overlay = pygame.Surface((config.SCREEN_WIDTH, config.SCREEN_HEIGHT))
overlay.set_alpha(alpha)
overlay.fill((0, 0, 0))
surface.blit(overlay, (0, 0))
def draw_overlay_lines(
surface: pygame.Surface,
assets,
lines: list[str],
*,
status: str = "",
status_color: tuple[int, int, int] = config.COLOR_ERROR,
) -> None:
overlay = pygame.Surface((config.SCREEN_WIDTH, config.SCREEN_HEIGHT))
overlay.set_alpha(220)
overlay.fill((0, 0, 0))
surface.blit(overlay, (0, 0))
"""Dunkler Schleier mit zentrierten Textzeilen (Ergebnis-Anzeige)."""
draw_dim(surface, 220)
center_y = config.SCREEN_HEIGHT // 2
line_gap = 16
total_height = 0
rendered: list[pygame.Surface] = []
total_height = 0
for line in lines:
image = assets.font.render(line, True, config.COLOR_WHITE)
rendered.append(image)
@@ -196,10 +225,3 @@ def draw_overlay_lines(
for image in rendered:
surface.blit(image, (config.SCREEN_WIDTH // 2 - image.get_width() // 2, y))
y += image.get_height() + line_gap
if status:
small = assets.font_small.render(status, True, status_color)
surface.blit(
small,
(config.SCREEN_WIDTH // 2 - small.get_width() // 2, y + 20),
)
-50
View File
@@ -1,50 +0,0 @@
"""Laufzeitdaten (letzter Spielername).
Alles, was zur Laufzeit geschrieben wird, landet in ``runtime/`` – dieser
Ordner ist in ``.gitignore`` eingetragen. Über die Umgebungsvariable
``HAG_RUNTIME_DIR`` lässt sich der Ort umbiegen (praktisch für Tests).
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from . import config
LAST_PLAYER_FILE = "last_player.json"
def runtime_dir() -> Path:
"""Verzeichnis für Laufzeitdaten; wird bei Bedarf angelegt."""
override = os.environ.get("HAG_RUNTIME_DIR")
path = Path(override) if override else config.RUNTIME_DIR
path.mkdir(parents=True, exist_ok=True)
return path
def load_last_player() -> str | None:
try:
data = json.loads((runtime_dir() / LAST_PLAYER_FILE).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(data, dict):
return None
name = str(data.get("name", "")).strip()
return name or None
def save_last_player(name: str) -> None:
name = name.strip()[: config.MAX_PLAYER_NAME_LENGTH]
if not name:
return
try:
(runtime_dir() / LAST_PLAYER_FILE).write_text(
json.dumps({"name": name}, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
except OSError:
# Laufzeitdaten sind nice-to-have – ein Schreibfehler darf das Spiel
# nicht stoppen.
pass